Tidy and fully comment code
authorTJ <git@iam.tj>
Sun, 10 Nov 2013 09:55:55 +0000 (09:55 +0000)
committerTJ <git@iam.tj>
Sun, 10 Nov 2013 09:55:55 +0000 (09:55 +0000)
pygame-scroller.py

index 858917e..f650837 100755 (executable)
@@ -1,17 +1,39 @@
 #!/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
@@ -23,6 +45,7 @@ class Channel:
    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:
@@ -30,25 +53,45 @@ class Channel:
    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)
@@ -56,7 +99,9 @@ class Channel:
   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:
@@ -69,52 +114,89 @@ class Channel:
     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:
@@ -125,18 +207,25 @@ class SeamlessBackground:
     # 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")
@@ -150,7 +239,7 @@ def main():
  # 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
@@ -176,35 +265,31 @@ def main():
   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)
@@ -232,20 +317,26 @@ def main():
     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()