#!/usr/bin/python3
+# (c) Copyright 2013 TJ <hacker@iam.tj>
+# (c) Copyright Eddie Berrisford-Lynch <dev@fun2be.me>
+# Licensed on the terms of the GNU General Public License version 3 (see COPYING)
+#
+# Demonstration of using Python 3.x, PyGame 1.92 and SDL to create a simple 2D
+# platform scrolling game
import sys, os, time, random, pygame
+# Use conditionals in the code to print useful information to the console using the form:
+# if DEBUG: print("Debug: some_var=%d, another_var=%s" % (some_var, another_var))
DEBUG = True
pygame.init()
class Channel:
+ """ Represents an audio output. Can represent a user-created SDL channel or the single
+ Music channel (which can load/play Ogg, MP3 and other audio media). The Music channel
+ can be primed with a playlist which will loop.
+ Many methods have dual functionality depending on whether the instance object represents
+ the Music channel or a Sound channel, since those PyGame Classes have some differences in
+ functionality.
+ """
# use integers to control volume which avoids float rounding error issues
MAX = 100
MIN = 0
- step = 5
- def __init__(self, volume=1.0, channel_id=0):
+ volume_step = 5
+ def __init__(self, volume=1.0, channel_id=0):
+ """ Constructor.
+ volume: an SDL/PyGame floating-point with range 0.00 to 1.00. It is storied internally as an
+ integer with range 0 to 100 to avoid rounding errors when stepping the volume.
+ channel_id: -1 == create the single Music channel. 0,1,2,3... one of several Sound channels.
+
+ self.channel == None indicates this is the Music channel.
+ """
self.volume = int(volume*100)
self.channel_id = 0
self.paused = False
self.channel = pygame.mixer.Channel(self.channel_id)
def set_volume(self, new_level):
+ """ Ensure the new level is within legal range and alter the mixer level """
if new_level >= Channel.MIN and new_level <= Channel.MAX:
self.volume = new_level
if self.channel == None:
else:
self.channel.set_volume(self.volume/100)
if DEBUG: print("Channel.set_volume(", self.volume, ")")
- def up(self):
- if self.volume+self.step <= Channel.MAX:
- self.set_volume(self.volume + self.step)
- def down(self):
- if self.volume-self.step >= Channel.MIN:
- self.set_volume(self.volume - self.step)
- def get(self):
+
+ def volume_up(self):
+ """ Increase volume by a single step """
+ if self.volume + self.volume_step <= Channel.MAX:
+ self.set_volume(self.volume + self.volume_step)
+
+ def volume_down(self):
+ """ Decrease volume by a single step """
+ if self.volume - self.volume_step >= Channel.MIN:
+ self.set_volume(self.volume - self.volume_step)
+
+ def get_volume(self):
return self.volume
+
def queue(self, sound):
+ """ Sound channels can queue one Sound in addition to the currently playing Sound """
if self.channel != None:
self.channel.queue(sound)
+
def get_queue(self):
+ """ Retrieve the queued Sound (if any) """
return self.channel.get_queue() if self.channel != None else None
+
def fadeout(self, time):
+ """ Fade out the sound nicely
+ time: milliseconds
+ """
if self.channel == None:
pygame.mixer.music.fadeout(time)
else:
self.channel.fadeout(time)
+
def play(self, sound, loops=0, maxtime=0, fade_ms=0):
+ """ Play a Music track or Sound
+ sound:
+ loops: number of repeats (after initial play - see pyGame API docs)
+ maxtime: seconds to play. 0 == to the end
+ fade_ms: fade-out time in milliseconds
+ """
if self.channel != None:
# allow 'sound' to be a path string or an object
sound = pygame.mixer.Sound(sound)
else:
pygame.mixer.music.load(sound)
pygame.mixer.music.play(loops)
+
def pause(self):
+ """ Toggle the play/pause state of the channel """
if self.paused:
self.paused = False
if self.channel == None:
pygame.mixer.music.pause()
else:
self.channel.pause()
+
def playlist(self, path):
+ """ Create a playlist from a given file-system directory
+ path: absolute or relative path to a directory containing *only* valid media files (Ogg, MP3, etc.)
+
+ See pygame.mixer.music API docs
+ """
self.music_path = path
+ # build a list of the filenames in the directory (ignores sub-directories)
self.playlist = [ f for f in os.listdir(self.music_path) if os.path.isfile(os.path.join(self.music_path, f)) ]
- random.shuffle(self.playlist)
+ random.shuffle(self.playlist) # randomise the play order
+
if DEBUG: print ("playlist=", self.playlist)
+
self.playlist_index = -1 # flag for 'start from the beginning of list'
- self.playlist_next()
+ self.playlist_next() # begin play-back
+ # generate an event when the current track ends
pygame.mixer.music.set_endevent(pygame.USEREVENT)
+
def playlist_next(self):
+ """ Begin playing the next track in the playlist. Loops when it reaches the end of the list. """
self.playlist_index = self.playlist_index + 1 if self.playlist_index < len(self.playlist)-1 else 0
if DEBUG: print("Channel.playlist_next() = [%d] %s" %(self.playlist_index, self.playlist[self.playlist_index] ))
+
pygame.mixer.music.load(os.path.join(self.music_path, self.playlist[self.playlist_index]))
pygame.mixer.music.play()
-# container for all game control and configuration items
+
class Game:
+ """ Container for all game control and configuration items """
config = dict()
soundtrack = Channel(channel_id=-1)
sound_effects = Channel(channel_id=0)
+
def __init__(self, width=0, height=0, title=""):
+ """ Constructor.
+ width: desired width of the game window
+ height: desired height of the game window
+ title: Window caption (title-bar)
+ """
self.config.update({'width':width, 'height':height})
self.config.update({'title':title})
- self.config.update({'sound':1})
- self.play = False
+ self.config.update({'sound':1}) # default to playing sound
+ self.play = False # Flag that controls exit from main loop
self.resolution = width, height
self.debug = DEBUG
+
class SeamlessBackground:
+ """ The game window has a scrolling background image that is seamless, which allows it to be tiled
+ so that as the viewport scrolls the image appears to be continuous in both directions.
+
+ The game window only scrolls left or right, which means that the background needs to be redrawn
+ at the left or right margins (depending on direction of scroll) or, as a one-off, the entire
+ background image needs drawing at the beginning of the game.
+
+ The seamless background image provided should be larger (wider) than the intended game window to
+ allow the wrapping of the image to work correctly.
+ """
image_path = None
image = None
- offset_x = 0
width = None
- def __init__(self, filename, offset=0):
+
+ def __init__(self, filename):
+ """ Constructor.
+ filename: absolute or relative path to seamless background image file (JPeG, PNG, etc.)
+ """
if os.path.exists(filename):
self.image_path = filename
- self.image = pygame.image.load(self.image_path) #.convert()
- self.offset = offset
+ self.image = pygame.image.load(self.image_path).convert()
self.width = self.image.get_size()[0]
+
if self.image == None:
- print("Failed to load image", filename)
+ print("Error: failed to load image", filename)
def draw(self, screen, x_pos, step):
- """ x_pos == -1 requests the full screen be painted, not just the damaged left/right margins
+ """ Redraw damaged areas of the game screen.
+ x_pos: -1 == request the full screen be painted, not just the damaged left/right margins. Otherwise,
+ it is the current game-level's left-edge x coordinate.
+ step: the number of pixels, and direction, the screen has just scrolled (negative, 0, or positive)
"""
- #if DEBUG: print("SeamlessBackground.draw(screen, %d, %d)" % (x_pos, step))
if self.image != None:
+ # resolution[0] = width, [1] = height
resolution = screen.get_size()
# adjust x_pos to be x coord of damaged rectangle (left or right side of screen)
if step > 0:
# nothing to do if not drawing the initial background
return
+ # calculate the offset into the background image such that the scrolled
+ # game window and the background image will match
+
+ # if drawing the background for the first time (x_pos==-1) use the entire image
+ # use modulo to get the offset within the image of the first damaged column
bk_x_start = x_pos % self.width if x_pos != -1 else 0
bk_rect = pygame.Rect(bk_x_start, 0, resolution[0] if x_pos == -1 else abs(step), resolution[1])
+ # When the image wraps there might not be 'step' columns of pixels available from the right side of
+ # the seamless image. Therefore use an intermediate 'patch' image to build the complete image to be drawn
+ # to the game window
patch = pygame.Surface((bk_rect.width, bk_rect.height))
patch.blit(self.image, (0, 0), bk_rect)
remainder = self.width - bk_x_start
if remainder < abs(step):
- # need more pixels because we're just wrapped around the background image
+ # need more pixel columns because the seamless image just wrapped around
patch.blit(self.image, (4,0), pygame.Rect(0, 0, remainder, resolution[1]))
- #if DEBUG: print("Wrap-around, remainder=%d" % remainder)
+ # calculate the left coord in the game window where the new background should be drawn
scr_x_dest = resolution[0] - step if step > 0 else 0
- #if DEBUG: print("x_pos=%d, scr_x_dest=%d, bk_x_start=%d, bk_rect=%s" % (x_pos, scr_x_dest, bk_x_start, bk_rect))
screen.blit(patch, (scr_x_dest, 0))
else:
print("No image to draw")
# main loop sleep (100 milliseconds)
delay = 0.01
- pygame.display.set_caption("TJ's platform scroller")
+ pygame.display.set_caption("Demo platform scroller")
# background colour
stepping = 8 # pixels scrolled each flip
myGame.soundtrack.playlist(os.path.join(os.getcwd(), "resources", "Music"))
auto_scroll = False
- movement = 0
+ movement = 0 # represents number of pixels, and direction, of next scroll
x_pos = 0
- myGame.play = True
+ myGame.play = True # start the game
while (myGame.play):
# update game state
if x_pos + movement >= 0:
x_pos += movement
else:
movement = 0
- # print(movement)
# change colour randomly
d_red = random.randrange(-1,2) *8
d_green = random.randrange(-1,2) *8
d_blue = random.randrange(-1,2) *8
- # build a new tuple, ensuring all values are valid
+ # build a modified colour tuple, ensuring all values are valid
blip_colour = tuple( b+d if b+d < 256 and b+d >=0 else b for b, d in zip(blip_colour, (d_red, d_green, d_blue)) )
blip.fill(blip_colour)
- # change position up or down randomly by one step
+ # change blip position up or down randomly by one step
dy = random.randrange(-1,2) * abs(stepping)
if((y + dy) < resolution[1] and (y + dy) >= 0):
+ # TODO: no longer need to redraw background colour if using a background image
# screen.fill(background_colour, pygame.Rect(resolution[0] - abs(stepping), y, abs(stepping), abs(stepping) ))
y += dy
- # debug info
- #if myGame.debug:
- #print(y, blip_colour)
-
# redraw the display
screen.scroll(-movement, 0)
background_image.draw(screen, x_pos, movement)
myGame.soundtrack.playlist_next()
if not auto_scroll:
- movement = 0
+ # reset if not auto-scrolling
+ movement = 0
+
+ # detect which keys are held down
keys_pressed = pygame.key.get_pressed()
if sum(keys_pressed):
if keys_pressed[pygame.K_6]:
# volume down
- myGame.soundtrack.down()
+ myGame.soundtrack.volume_down()
elif keys_pressed[pygame.K_8]:
# volume up
- myGame.soundtrack.up()
+ myGame.soundtrack.volume_up()
elif keys_pressed[pygame.K_d]:
+ # move right
movement = stepping
elif keys_pressed[pygame.K_a]:
+ # move left
movement = -stepping
+ # end of game
myGame.soundtrack.fadeout(5000)
time.sleep(5)
pygame.display.quit()