code.py
  1  # SPDX-FileCopyrightText: 2021 Phillip Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  Dragon Drop: a simple game for Adafruit MACROPAD. Uses OLED display in
  7  portrait (vertical) orientation. Tap one of four keys across a row to
  8  catch falling eggs before they hit the ground. Avoid fireballs.
  9  """
 10  
 11  # pylint: disable=import-error, unused-import
 12  import gc
 13  import random
 14  import time
 15  import displayio
 16  import adafruit_imageload
 17  from adafruit_macropad import MacroPad
 18  from adafruit_bitmap_font import bitmap_font
 19  from adafruit_display_text import label
 20  from adafruit_progressbar.progressbar import HorizontalProgressBar
 21  import board      # These three can be removed
 22  import audiocore  # if/when MacroPad library
 23  import audiopwmio # adds background audio
 24  
 25  
 26  # CONFIGURABLES ------------------------
 27  
 28  MAX_EGGS = 7          # Max count of all projectiles; some are fireballs
 29  PATH = '/dragondrop/' # Location of graphics, fonts, WAVs, etc.
 30  
 31  
 32  # UTILITY FUNCTIONS AND CLASSES --------
 33  
 34  def background_sound(filename):
 35      """ Start a WAV file playing in the background (non-blocking). This
 36          func can be removed if/when MacroPad lib gets background audio. """
 37      # pylint: disable=protected-access
 38      macropad._speaker_enable.value = True
 39      audio.play(audiocore.WaveFile(open(PATH + filename, 'rb')))
 40  
 41  def show_screen(group):
 42      """ Activate a given displayio group, pause until keypress. """
 43      macropad.display.show(group)
 44      macropad.display.refresh()
 45      # Purge any queued up key events...
 46      while macropad.keys.events.get():
 47          pass
 48      while True: # ...then wait for first new key press event
 49          key_event = macropad.keys.events.get()
 50          if key_event and key_event.pressed:
 51              return
 52  
 53  # pylint: disable=too-few-public-methods
 54  class Sprite:
 55      """ Class holds sprite (eggs, fireballs) state information. """
 56      def __init__(self, col, start_time):
 57          self.column = col                       # 0-3
 58          self.is_fire = (random.random() < 0.25) # 1/4 chance of fireballs
 59          self.start_time = start_time            # For drop physics
 60          self.paused = False
 61  
 62  
 63  # ONE-TIME INITIALIZATION --------------
 64  
 65  macropad = MacroPad(rotation=90)
 66  macropad.display.auto_refresh = False
 67  macropad.pixels.auto_write = False
 68  macropad.pixels.brightness = 0.5
 69  audio = audiopwmio.PWMAudioOut(board.SPEAKER) # For background audio
 70  
 71  font = bitmap_font.load_font(PATH + 'cursive-smart.pcf')
 72  
 73  # Create 3 displayio groups -- one each for the title, play and end screens.
 74  
 75  title_group = displayio.Group()
 76  title_bitmap, title_palette = adafruit_imageload.load(PATH + 'title.bmp',
 77                                                        bitmap=displayio.Bitmap,
 78                                                        palette=displayio.Palette)
 79  title_group.append(displayio.TileGrid(title_bitmap, pixel_shader=title_palette,
 80                                        width=1, height=1,
 81                                        tile_width=title_bitmap.width,
 82                                        tile_height=title_bitmap.height))
 83  
 84  # Bitmap containing eggs, hatchling and fireballs
 85  sprite_bitmap, sprite_palette = adafruit_imageload.load(
 86      PATH + 'sprites.bmp', bitmap=displayio.Bitmap, palette=displayio.Palette)
 87  sprite_palette.make_transparent(0)
 88  
 89  play_group = displayio.Group()
 90  # Bitmap containing five shadow tiles ('no shadow' through 'max shadow')
 91  shadow_bitmap, shadow_palette = adafruit_imageload.load(
 92      PATH + 'shadow.bmp', bitmap=displayio.Bitmap, palette=displayio.Palette)
 93  # Tilegrid with four shadow tiles; one per column
 94  shadow = displayio.TileGrid(shadow_bitmap, pixel_shader=shadow_palette,
 95                              width=4, height=1, tile_width=16,
 96                              tile_height=shadow_bitmap.height, x=0,
 97                              y=macropad.display.height - shadow_bitmap.height)
 98  play_group.append(shadow)
 99  shadow_scale = 5 / (macropad.display.height - 20) # For picking shadow sprite
100  life_bar = HorizontalProgressBar((0, 0), (macropad.display.width, 7),
101                                   value=100, min_value=0, max_value=100,
102                                   bar_color=0xFFFFFF, outline_color=0xFFFFFF,
103                                   fill_color=0, margin_size=1)
104  play_group.append(life_bar)
105  # Score is last object in play_group, can be indexed as -1
106  play_group.append(label.Label(font, text='0', color=0xFFFFFF,
107                                anchor_point=(0.5, 0.0),
108                                anchored_position=(macropad.display.width // 2,
109                                                   10)))
110  
111  end_group = displayio.Group()
112  end_bitmap, end_palette = adafruit_imageload.load(
113      PATH + 'gameover.bmp', bitmap=displayio.Bitmap, palette=displayio.Palette)
114  end_group.append(displayio.TileGrid(end_bitmap, pixel_shader=end_palette,
115                                      width=1, height=1,
116                                      tile_width=end_bitmap.width,
117                                      tile_height=end_bitmap.height))
118  end_group.append(label.Label(font, text='0', color=0xFFFFFF,
119                               anchor_point=(0.5, 0.0),
120                               anchored_position=(macropad.display.width // 2,
121                                                  90)))
122  
123  
124  # MAIN LOOP -- alternates play and end-game screens --------
125  
126  show_screen(title_group) # Just do this once on startup
127  
128  while True:
129  
130      # NEW GAME -------------------------
131  
132      sprites = []
133      score = 0
134      play_group[-1].text = '0' # Score text
135      life_bar.value = 100
136      audio.stop()
137      macropad.display.show(play_group)
138      macropad.display.refresh()
139      start = time.monotonic()
140  
141      # PLAY UNTIL LIFE BAR DEPLETED -----
142  
143      while life_bar.value > 0:
144          now = time.monotonic()
145          speed = 10 + (now - start) / 30   # Gradually speed up
146          fire_sprite = 3 + int((now * 6) % 2.0) # For animating fire
147  
148          # Coalese any/all queued-up keypress events per column
149          column_pressed = [False] * 4
150          while True:
151              event = macropad.keys.events.get()
152              if not event:
153                  break
154              if event.pressed:
155                  column_pressed[event.key_number % 4] = True
156  
157          # For determining upper/lower extents of active egg sprites per column
158          column_min = [macropad.display.height] * 4
159          column_max = [0] * 4
160  
161          # Traverse sprite list backwards so we can pop() without index problems
162          for i in range(len(sprites) - 1, -1, -1):
163              sprite = sprites[i]
164              tile = play_group[i + 1] # Corresponding 1x1 TileGrid for sprite
165              column = sprite.column
166              elapsed = now - sprite.start_time # Time since add or pause event
167  
168              if sprite.is_fire:
169                  tile[0] = fire_sprite # Animate all flame sprites
170  
171              if sprite.paused:                # Sprite at bottom of screen
172                  if elapsed > 0.75:           # Hold position for 3/4 second,
173                      for x in range(0, 9, 4): # then LEDs off,
174                          macropad.pixels[x + sprite.column] = (0, 0, 0)
175                      sprites.pop(i)           # and delete Sprite object and
176                      play_group.pop(i + 1)    # element from displayio group
177                      continue
178                  if not sprite.is_fire:
179                      column_max[column] = max(column_max[column],
180                                               macropad.display.height - 22)
181              else: # Sprite in motion
182                  y = speed * elapsed * elapsed - 16
183                  # Track top of all sprites, bottom of eggs only
184                  column_min[column] = min(column_min[column], y)
185                  if not sprite.is_fire:
186                      column_max[column] = max(column_max[column], y)
187                  tile.y = int(y) # Sprite's vertical pos. in play_group
188  
189                  # Handle various catch or off-bottom actions...
190                  if sprite.is_fire:
191                      if y >= macropad.display.height: # Off bottom of screen,
192                          sprites.pop(i)               # remove fireball sprite
193                          play_group.pop(i + 1)
194                          continue
195                      if y >= macropad.display.height - 40:
196                          if column_pressed[column]:
197                              # Fireball caught, ouch!
198                              background_sound('sizzle.wav') # I smell bacon
199                              sprite.paused = True
200                              sprite.start_time = now
201                              tile.y = macropad.display.height - 20
202                              life_bar.value = max(0, life_bar.value - 5)
203                              for x in range(0, 9, 4):
204                                  macropad.pixels[x + sprite.column] = (255, 0, 0)
205                  else: # Is egg...
206                      if y >= macropad.display.height - 22:
207                          # Egg hit ground
208                          background_sound('splat.wav')
209                          sprite.paused = True
210                          sprite.start_time = now
211                          tile.y = macropad.display.height - 22
212                          tile[0] = 1 # Change sprite to broken egg
213                          life_bar.value = max(0, life_bar.value - 5)
214                          macropad.pixels[8 + sprite.column] = (255, 255, 0)
215                      elif column_pressed[column]:
216                          if y >= macropad.display.height - 40:
217                              # Egg caught at right time
218                              background_sound('rawr.wav')
219                              sprite.paused = True
220                              sprite.start_time = now
221                              tile.y = macropad.display.height - 22
222                              tile[0] = 2 # Hatchling
223                              macropad.pixels[4 + sprite.column] = (0, 255, 0)
224                              score += 10
225                              play_group[-1].text = str(score)
226                          elif y >= macropad.display.height - 58:
227                              # Egg caught too early
228                              background_sound('splat.wav')
229                              sprite.paused = True
230                              sprite.start_time = now
231                              tile.y = macropad.display.height - 40
232                              tile[0] = 1 # Broken egg
233                              life_bar.value = max(0, life_bar.value - 5)
234                              macropad.pixels[sprite.column] = (255, 255, 0)
235  
236          # Select shadow bitmaps based on each column's lowest egg
237          for i in range(4):
238              shadow[i] = min(4, int(column_max[i] * shadow_scale))
239  
240          # Time to introduce a new sprite? 1/20 chance each frame, if space
241          if (len(sprites) < MAX_EGGS and random.random() < 0.05 and
242                  max(column_min) > 16):
243              # Pick a column randomly...if it's occupied, keep trying...
244              while True:
245                  column = random.randint(0, 3)
246                  if column_min[column] > 16:
247                      # Found a clear spot. Add sprite and break loop
248                      sprites.append(Sprite(column, now))
249                      play_group.insert(-2, displayio.TileGrid(sprite_bitmap,
250                                                               pixel_shader=sprite_palette,
251                                                               width=1, height=1,
252                                                               tile_width=16,
253                                                               tile_height=sprite_bitmap.height,
254                                                               x=column * 16,
255                                                               y=-16))
256                      break
257  
258          macropad.display.refresh()
259          macropad.pixels.show()
260          if not audio.playing:
261              # pylint: disable=protected-access
262              macropad._speaker_enable.value = False
263          gc.collect()
264  
265          # Encoder button pauses/resumes game.
266          macropad.encoder_switch_debounced.update()
267          if macropad.encoder_switch_debounced.pressed:
268              for n in (True, False, True): # Press, release, press
269                  while n == macropad.encoder_switch_debounced.pressed:
270                      macropad.encoder_switch_debounced.update()
271              # Sprite start times must be offset by pause duration
272              # because time.monotonic() is used for drop physics.
273              now = time.monotonic() - now # Pause duration
274              for sprite in sprites:
275                  sprite.start_time += now
276  
277      # GAME OVER ------------------------
278  
279      time.sleep(1.5) # Pause display for a moment
280      macropad.pixels.fill(0)
281      macropad.pixels.show()
282      # pylint: disable=protected-access
283      macropad._speaker_enable.value = False
284      # Pop any sprites from play_group (other elements remain, and sprites[]
285      # list is cleared at start of next game).
286      for _ in sprites:
287          play_group.pop(1)
288      end_group[-1].text = str(score)
289      show_screen(end_group)