f65083785fe084f1ab4d471adcd4a56d14ac02c6
[base2-runner.git] / pygame-scroller.py
1 #!/usr/bin/python3
2 # (c) Copyright 2013 TJ <hacker@iam.tj>
3 # (c) Copyright Eddie Berrisford-Lynch <dev@fun2be.me>
4 # Licensed on the terms of the GNU General Public License version 3 (see COPYING)
5 #
6 # Demonstration of using Python 3.x, PyGame 1.92 and SDL to create a simple 2D
7 # platform scrolling game
8
9 import sys, os, time, random, pygame
10
11 # Use conditionals in the code to print useful information to the console using the form:
12 # if DEBUG: print("Debug: some_var=%d, another_var=%s" % (some_var, another_var))
13 DEBUG = True
14
15 pygame.init()
16
17 class Channel:
18  """ Represents an audio output. Can represent a user-created SDL channel or the single
19  Music channel (which can load/play Ogg, MP3 and other audio media). The Music channel
20  can be primed with a playlist which will loop.
21  Many methods have dual functionality depending on whether the instance object represents
22  the Music channel or a Sound channel, since those PyGame Classes have some differences in
23  functionality.
24  """
25  # use integers to control volume which avoids float rounding error issues
26  MAX = 100
27  MIN = 0
28  volume_step = 5
29  def __init__(self, volume=1.0, channel_id=0):
30   """ Constructor.
31    volume:     an SDL/PyGame floating-point with range 0.00 to 1.00. It is storied internally as an 
32                integer with range 0 to 100 to avoid rounding errors when stepping the volume.
33    channel_id: -1 == create the single Music channel. 0,1,2,3... one of several Sound channels.
34
35    self.channel == None indicates this is the Music channel.
36   """
37   self.volume = int(volume*100)
38   self.channel_id = 0
39   self.paused = False
40   if channel_id == -1:
41    self.channel_id = channel_id
42    self.channel = None # flag to show this uses pygame.mixer.music 
43   elif channel_id < pygame.mixer.get_num_channels():
44    self.channel_id = channel_id
45    self.channel = pygame.mixer.Channel(self.channel_id)
46
47  def set_volume(self, new_level):
48   """ Ensure the new level is within legal range and alter the mixer level """
49   if new_level >= Channel.MIN and new_level <= Channel.MAX:
50    self.volume = new_level
51    if self.channel == None:
52     pygame.mixer.music.set_volume(self.volume/100)
53    else:
54     self.channel.set_volume(self.volume/100)
55    if DEBUG: print("Channel.set_volume(", self.volume, ")")
56
57  def volume_up(self):
58   """ Increase volume by a single step """
59   if self.volume + self.volume_step <= Channel.MAX:
60    self.set_volume(self.volume + self.volume_step)
61
62  def volume_down(self):
63   """ Decrease volume by a single step """
64   if self.volume - self.volume_step >= Channel.MIN:
65    self.set_volume(self.volume - self.volume_step)
66
67  def get_volume(self):
68   return self.volume
69
70  def queue(self, sound):
71   """ Sound channels can queue one Sound in addition to the currently playing Sound """
72   if self.channel != None:
73    self.channel.queue(sound)
74
75  def get_queue(self):
76   """ Retrieve the queued Sound (if any) """
77   return self.channel.get_queue() if self.channel != None else None
78
79  def fadeout(self, time):
80   """ Fade out the sound nicely
81    time: milliseconds   
82   """
83   if self.channel == None:
84    pygame.mixer.music.fadeout(time)
85   else:
86    self.channel.fadeout(time)
87
88  def play(self, sound, loops=0, maxtime=0, fade_ms=0):
89   """ Play a Music track or Sound
90    sound:
91    loops:    number of repeats (after initial play - see pyGame API docs)
92    maxtime:  seconds to play. 0 == to the end
93    fade_ms:  fade-out time in milliseconds
94   """
95   if self.channel != None:
96    # allow 'sound' to be a path string or an object
97    sound = pygame.mixer.Sound(sound)
98    self.channel.play(sound, loops, maxtime, fade_ms)
99   else:
100    pygame.mixer.music.load(sound)
101    pygame.mixer.music.play(loops)
102
103  def pause(self):
104   """ Toggle the play/pause state of the channel """
105   if self.paused:
106    self.paused = False
107    if self.channel == None:
108     pygame.mixer.music.unpause()
109    else:
110     self.channel.unpause()
111   else:
112    self.paused = True
113    if self.channel == None:
114     pygame.mixer.music.pause()
115    else:
116     self.channel.pause()
117
118  def playlist(self, path):
119   """ Create a playlist from a given file-system directory
120    path:   absolute or relative path to a directory containing *only* valid media files (Ogg, MP3, etc.)
121
122    See pygame.mixer.music API docs
123   """
124   self.music_path = path
125   # build a list of the filenames in the directory (ignores sub-directories)
126   self.playlist = [ f for f in os.listdir(self.music_path) if os.path.isfile(os.path.join(self.music_path, f)) ]
127   random.shuffle(self.playlist) # randomise the play order
128
129   if DEBUG: print ("playlist=", self.playlist)
130
131   self.playlist_index = -1 # flag for 'start from the beginning of list'
132   self.playlist_next()     # begin play-back
133   # generate an event when the current track ends
134   pygame.mixer.music.set_endevent(pygame.USEREVENT)
135
136  def playlist_next(self):
137   """ Begin playing the next track in the playlist. Loops when it reaches the end of the list. """
138   self.playlist_index = self.playlist_index + 1 if self.playlist_index < len(self.playlist)-1 else 0
139   if DEBUG: print("Channel.playlist_next() = [%d] %s" %(self.playlist_index, self.playlist[self.playlist_index] ))
140
141   pygame.mixer.music.load(os.path.join(self.music_path, self.playlist[self.playlist_index]))
142   pygame.mixer.music.play()
143
144
145 class Game:
146  """ Container for all game control and configuration items """
147  config = dict()
148  soundtrack = Channel(channel_id=-1)
149  sound_effects = Channel(channel_id=0)
150
151  def __init__(self, width=0, height=0, title=""):
152   """ Constructor.
153    width:   desired width of the game window
154    height:  desired height of the game window
155    title:   Window caption (title-bar)
156   """
157   self.config.update({'width':width, 'height':height})
158   self.config.update({'title':title})
159   self.config.update({'sound':1}) # default to playing sound
160   self.play = False               # Flag that controls exit from main loop
161   self.resolution = width, height
162   self.debug = DEBUG
163
164
165 class SeamlessBackground:
166  """ The game window has a scrolling background image that is seamless, which allows it to be tiled
167      so that as the viewport scrolls the image appears to be continuous in both directions.
168
169      The game window only scrolls left or right, which means that the background needs to be redrawn 
170      at the left or right margins (depending on direction of scroll) or, as a one-off, the entire
171      background image needs drawing at the beginning of the game.
172
173      The seamless background image provided should be larger (wider) than the intended game window to
174      allow the wrapping of the image to work correctly.
175  """
176  image_path = None
177  image = None
178  width = None
179
180  def __init__(self, filename):
181   """ Constructor.
182    filename:  absolute or relative path to seamless background image file (JPeG, PNG, etc.)
183   """
184   if os.path.exists(filename):
185    self.image_path = filename
186    self.image = pygame.image.load(self.image_path).convert()
187    self.width = self.image.get_size()[0]
188
189   if self.image == None:
190    print("Error: failed to load image", filename)
191
192  def draw(self, screen, x_pos, step):
193   """ Redraw damaged areas of the game screen.
194    x_pos:  -1 == request the full screen be painted, not just the damaged left/right margins. Otherwise, 
195            it is the current game-level's left-edge x coordinate.
196    step:   the number of pixels, and direction, the screen has just scrolled (negative, 0, or positive)
197   """
198   if self.image != None:
199    # resolution[0] = width, [1] = height
200    resolution = screen.get_size()
201    # adjust x_pos to be x coord of damaged rectangle (left or right side of screen)
202    if step > 0:
203     x_pos += resolution[0] - step
204    elif step < 0:
205     x_pos -= step
206    elif step == 0 and x_pos >= 0:
207     # nothing to do if not drawing the initial background
208     return
209
210    # calculate the offset into the background image such that the scrolled
211    # game window and the background image will match
212
213    # if drawing the background for the first time (x_pos==-1) use the entire image
214    # use modulo to get the offset within the image of the first damaged column
215    bk_x_start = x_pos % self.width if x_pos != -1 else 0
216    bk_rect = pygame.Rect(bk_x_start, 0, resolution[0] if x_pos == -1 else abs(step), resolution[1])
217    # When the image wraps there might not be 'step' columns of pixels available from the right side of 
218    # the seamless image. Therefore use an intermediate 'patch' image to build the complete image to be drawn
219    # to the game window
220    patch = pygame.Surface((bk_rect.width, bk_rect.height))
221    patch.blit(self.image, (0, 0), bk_rect)
222    remainder = self.width - bk_x_start
223    if remainder < abs(step):
224     # need more pixel columns because the seamless image just wrapped around
225     patch.blit(self.image, (4,0), pygame.Rect(0, 0, remainder, resolution[1]))
226
227    # calculate the left coord in the game window where the new background should be drawn
228    scr_x_dest = resolution[0] - step if step > 0 else 0
229    screen.blit(patch, (scr_x_dest, 0))
230   else:
231    print("No image to draw")
232
233
234 def main():
235  myGame = Game(900, 600)
236  flags = pygame.OPENGL & pygame.DOUBLEBUF
237  depth = 0
238
239  # main loop sleep (100 milliseconds)
240  delay = 0.01
241
242  pygame.display.set_caption("Demo platform scroller")
243
244  # background colour
245  stepping = 8 # pixels scrolled each flip
246  background_colour = (20, 20, 64)
247  screen = pygame.display.set_mode(myGame.resolution, flags, depth)
248  resolution = screen.get_size()
249  screen.fill(background_colour)
250
251  # background image
252  background_image = SeamlessBackground(os.path.join("resources", "binary.jpg"))
253  background_image.draw(screen, -1, 0) # -1 flags initial background drawing
254  pygame.display.flip()
255
256  # scrolling object
257  blip = pygame.Surface((abs(stepping), abs(stepping)), pygame.HWSURFACE)
258  blip_colour = (128, 128, 128)
259  blip.fill(blip_colour)
260
261  # starting position
262  y = resolution[1] / 2
263
264  if myGame.config['sound'] == 1:
265   myGame.soundtrack.playlist(os.path.join(os.getcwd(), "resources", "Music"))
266
267  auto_scroll = False
268  movement = 0 # represents number of pixels, and direction, of next scroll
269  x_pos = 0
270  myGame.play = True # start the game
271  while (myGame.play):
272   # update game state
273   if x_pos + movement >= 0:
274    x_pos += movement
275   else:
276    movement = 0
277
278   # change colour randomly
279   d_red = random.randrange(-1,2) *8
280   d_green = random.randrange(-1,2) *8
281   d_blue = random.randrange(-1,2) *8
282   # build a modified colour tuple, ensuring all values are valid
283   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)) )
284   blip.fill(blip_colour)
285
286   # change blip position up or down randomly by one step
287   dy = random.randrange(-1,2) * abs(stepping)
288   if((y + dy) < resolution[1] and (y + dy) >= 0):
289    # TODO: no longer need to redraw background colour if using a background image
290    # screen.fill(background_colour, pygame.Rect(resolution[0] - abs(stepping), y, abs(stepping), abs(stepping) ))
291    y += dy
292
293   # redraw the display
294   screen.scroll(-movement, 0)
295   background_image.draw(screen, x_pos, movement)
296   screen.blit(blip, (resolution[0] - abs(stepping), y))
297   pygame.display.flip()
298   
299   # manage the frame-rate
300   time.sleep(delay)
301
302   # process events
303   for event in pygame.event.get():
304    if event.type == pygame.QUIT or (event.type == pygame.KEYUP and event.key == pygame.K_q):
305     myGame.play = False
306    if event.type == pygame.KEYUP:
307     k = event.key
308     if k == pygame.K_p:
309      time.sleep(10)
310     elif k == pygame.K_7:
311      myGame.soundtrack.pause()
312     elif k == pygame.K_l:
313      auto_scroll = True if auto_scroll == False else False
314
315    if event.type == pygame.USEREVENT:
316     if DEBUG: print("pygame.USEREVENT received; calling playlist_next()")
317     myGame.soundtrack.playlist_next()
318
319   if not auto_scroll:
320    # reset if not auto-scrolling
321    movement = 0
322
323   # detect which keys are held down
324   keys_pressed = pygame.key.get_pressed()
325   if sum(keys_pressed):
326    if keys_pressed[pygame.K_6]:
327     # volume down
328     myGame.soundtrack.volume_down()
329    elif keys_pressed[pygame.K_8]:
330     # volume up
331     myGame.soundtrack.volume_up()
332    elif keys_pressed[pygame.K_d]:
333     # move right
334     movement = stepping
335    elif keys_pressed[pygame.K_a]:
336     # move left
337     movement = -stepping
338
339  # end of game
340  myGame.soundtrack.fadeout(5000)
341  time.sleep(5)
342  pygame.display.quit()
343  exit(0)
344
345 if __name__ == '__main__':
346  main()