Space Invaders 2D¶

the arcade classic rebuilt on nodes, signals and synthesized sound.

â–¶ Run in browser

Tags: game collision waves shooting

Five rows of aliens march across the screen, drop closer every time the formation touches an edge, and shoot back from the bottom of each column. Four barriers erode block by block as fire lands on them, and a mystery ship crosses the top now and then, worth the arcade’s points for however many shots you have fired. Clear the fleet and the next wave marches faster and fires more often; the run ends when your three lives do, or when the aliens reach the ground.

Every pixel and every sound is made in code: the demo ships no asset files. Each actor is a plain Node2D moved by setting position, and collision is a circle test, first_hit(node, group), against the nodes in a scene-tree group, so there is no physics world. The demo shows:

  • Immediate-mode drawing in on_draw: 8x8 bit-pattern sprites drawn as filled rects, two frames per alien. Alien.frame is a Property, so changing it redraws the sprite; a barrier calls queue_redraw() when a hit erodes it; the starfield, the effects and the blinking cannon change every frame and set dynamic = True

  • Aliens as children of one Formation node: moving the formation moves the fleet, and the formation’s own signals, cleared and landed, tell the game when a wave is won or lost

  • AudioSynth baking every sound at import, including a custom AudioSource for the falling shot chirp and the mystery ship’s siren, played by one-shot AudioPlayer nodes that free themselves when the clip ends (queue_free_on_end); the siren loops on a child of the ship, so it stops when the ship goes

  • Timer children for the fire rate, the alien volley, respawn invulnerability, the mystery ship and the wave banner

  • tree.change_scene between the title, the game and the game-over card, each declaring the same input_context; move is one Axis1D action that keys and a stick both drive, and @on_input handles the presses

  • An anchored Label HUD on a CanvasLayer, pinned to the screen while Camera2D.shake rattles the world on a death

  • A modal mixer on the title: show_overlay("blocking", inert=True) dims and pauses the scene behind a Panel whose Slider sets the SFX bus volume live

  • A TouchController in the game, so a phone plays it with two thumbs

Controls: A/D or Left/Right move, Space fires, Enter starts, O opens the mixer. On a controller the stick moves, A fires and Y opens the mixer on the title; in the game an on-screen stick and A button do the same on a phone. Hold the mouse, or a finger away from the on-screen pad, to slide the cannon toward the pointer and fire. A click or tap starts the game. The mixer opens with BACK focused, so Enter or A closes it as a click or tap on BACK does; its volume slider takes the mouse or a finger.

Source¶

  1# /// simvx
  2# tags = ["game", "collision", "waves", "shooting"]
  3# ///
  4"""Space Invaders 2D: the arcade classic rebuilt on nodes, signals and synthesized sound.
  5
  6Five rows of aliens march across the screen, drop closer every time the
  7formation touches an edge, and shoot back from the bottom of each column. Four
  8barriers erode block by block as fire lands on them, and a mystery ship crosses
  9the top now and then, worth the arcade's points for however many shots you have
 10fired. Clear the fleet and the next wave marches faster and fires more often;
 11the run ends when your three lives do, or when the aliens reach the ground.
 12
 13Every pixel and every sound is made in code: the demo ships no asset files.
 14Each actor is a plain ``Node2D`` moved by setting ``position``, and collision
 15is a circle test, ``first_hit(node, group)``, against the nodes in a
 16scene-tree group, so there is no physics world. The demo shows:
 17
 18- Immediate-mode drawing in ``on_draw``: 8x8 bit-pattern sprites drawn as
 19  filled rects, two frames per alien. ``Alien.frame`` is a ``Property``, so
 20  changing it redraws the sprite; a barrier calls ``queue_redraw()`` when a
 21  hit erodes it; the starfield, the effects and the blinking cannon change
 22  every frame and set ``dynamic = True``
 23- Aliens as children of one ``Formation`` node: moving the formation moves
 24  the fleet, and the formation's own signals, ``cleared`` and ``landed``, tell
 25  the game when a wave is won or lost
 26- ``AudioSynth`` baking every sound at import, including a custom
 27  ``AudioSource`` for the falling shot chirp and the mystery ship's siren,
 28  played by one-shot ``AudioPlayer`` nodes that free themselves when the clip
 29  ends (``queue_free_on_end``); the siren loops on a child of the ship, so it
 30  stops when the ship goes
 31- ``Timer`` children for the fire rate, the alien volley, respawn
 32  invulnerability, the mystery ship and the wave banner
 33- ``tree.change_scene`` between the title, the game and the game-over card,
 34  each declaring the same ``input_context``; ``move`` is one ``Axis1D`` action
 35  that keys and a stick both drive, and ``@on_input`` handles the presses
 36- An anchored ``Label`` HUD on a ``CanvasLayer``, pinned to the screen while
 37  ``Camera2D.shake`` rattles the world on a death
 38- A modal mixer on the title: ``show_overlay("blocking", inert=True)`` dims and
 39  pauses the scene behind a ``Panel`` whose ``Slider`` sets the ``SFX`` bus
 40  volume live
 41- A ``TouchController`` in the game, so a phone plays it with two thumbs
 42
 43Controls: A/D or Left/Right move, Space fires, Enter starts, O opens the mixer.
 44On a controller the stick moves, A fires and Y opens the mixer on the title; in
 45the game an on-screen stick and A button do the same on a phone. Hold the mouse,
 46or a finger away from the on-screen pad, to slide the cannon toward the pointer
 47and fire. A click or tap starts the game. The mixer opens with BACK focused, so
 48Enter or A closes it as a click or tap on BACK does; its volume slider takes the
 49mouse or a finger.
 50"""
 51
 52import random
 53from typing import NamedTuple
 54
 55import numpy as np
 56
 57from simvx.core import (
 58    ADSR,
 59    AudioBusLayout,
 60    AudioClip,
 61    AudioPlayer,
 62    AudioSynth,
 63    Axis1D,
 64    Camera2D,
 65    CanvasLayer,
 66    Controller,
 67    Input,
 68    Key,
 69    Linear,
 70    Node,
 71    Node2D,
 72    Oscillator,
 73    Pointer,
 74    Property,
 75    Signal,
 76    Timer,
 77    Vec2,
 78    on_input,
 79)
 80from simvx.core.audio_synth import AudioSource
 81from simvx.core.ui import Anchor, Button, Label, Panel, Slider, TouchController
 82from simvx.graphics import App
 83
 84INPUT_ACTIONS = {
 85    "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
 86    "fire": [Key.SPACE, Controller.A],
 87    "steer": [Pointer.PRIMARY],
 88    "start": [Key.ENTER, Pointer.PRIMARY, Controller.A, Controller.START],
 89    "mixer": [Key.O, Controller.Y],
 90}
 91
 92WHITE = (1.0, 1.0, 1.0, 1.0)
 93GREY = (0.7, 0.7, 0.7, 1.0)
 94PIXEL = 3  # screen pixels per sprite pixel
 95
 96
 97class AlienKind(NamedTuple):
 98    frames: tuple[list[int], list[int]]  # 8 rows of 8 bits each, bit 7 the leftmost pixel
 99    colour: tuple[float, float, float, float]
100    points: int
101
102
103SQUID = AlienKind(
104    ([0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x5A, 0xA5], [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0xA5, 0x5A]),
105    (1.0, 0.2, 0.2, 1.0),
106    30,
107)
108CRAB = AlienKind(
109    ([0x24, 0x18, 0x3C, 0x5A, 0x7E, 0x24, 0x24, 0x42], [0x24, 0xA5, 0x3C, 0x5A, 0x7E, 0x24, 0x42, 0x24]),
110    (0.2, 1.0, 0.2, 1.0),
111    20,
112)
113OCTOPUS = AlienKind(
114    ([0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81, 0x42], [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x42, 0x81]),
115    (0.2, 0.59, 1.0, 1.0),
116    10,
117)
118UFO_SPRITE = [0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x7E, 0x24, 0x00]
119UFO_COLOUR = (1.0, 0.2, 1.0, 1.0)
120
121FLEET_ROWS = [SQUID, CRAB, CRAB, OCTOPUS, OCTOPUS]  # top row first
122FLEET_COLS = 11
123FLEET_SIZE = len(FLEET_ROWS) * FLEET_COLS
124COLUMN_PITCH, ROW_PITCH = 40.0, 36.0
125
126# The arcade's mystery ship points, indexed by the player's shot count.
127MYSTERY_POINTS = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
128
129BARRIER_PATTERN = [
130    "  #######  ",
131    " ######### ",
132    "###########",
133    "###########",
134    "###########",
135    "###########",
136    "###  #  ###",
137    "##   #   ##",
138]
139
140
141class Sweep(AudioSource):
142    """A tone whose pitch follows ``pitch(t)`` in Hz, for the sounds a fixed ``Oscillator`` cannot make."""
143
144    def __init__(self, pitch, wave=np.sin):
145        self.pitch = pitch
146        self.wave = wave
147
148    def render(self, cursor_samples: int, n_samples: int, sample_rate: int) -> np.ndarray:
149        t = (np.arange(n_samples) + cursor_samples) / sample_rate
150        phase = 2 * np.pi * np.cumsum(self.pitch(t)) / sample_rate
151        return self.wave(phase).astype(np.float32)
152
153
154def bake(duration: float, *voices) -> AudioClip:
155    """Mix ``(source, envelope, gain)`` voices into one clip ``duration`` seconds long."""
156    synth = AudioSynth()
157    for source, envelope, gain in voices:
158        synth.add(source, envelope=envelope, gain=gain)
159    return synth.bake(duration)
160
161
162def square(phase):
163    return np.sign(np.sin(phase))
164
165
166FADE = Linear(start=1.0, end=0.0)
167SHOOT = bake(0.1, (Sweep(lambda t: 880 - 6600 * t, square), ADSR(attack=0.002, release=0.05), 0.35))
168EXPLOSION = bake(0.35, (Oscillator.noise.white(), FADE, 0.45))
169MARCH_STEPS = [
170    bake(0.08, (Oscillator.square(f), ADSR(attack=0.002, release=0.02), 0.4)) for f in (110, 92.5, 82.4, 73.4)
171]
172SIREN = bake(0.4, (Sweep(lambda t: 850 + 250 * np.sin(2 * np.pi * 6 * t)), None, 0.25))
173UFO_HIT = bake(
174    0.45,
175    *[(Oscillator.sine(f), FADE, 0.13) for f in (660, 440, 220)],
176    (Oscillator.noise.white(), FADE, 0.08),
177)
178
179
180def play(parent: Node, clip, volume_db: float = 0.0):
181    """Play ``clip`` once on a player that removes itself when it ends, so sounds can overlap."""
182    parent.add_child(AudioPlayer(stream=clip, bus="SFX", volume_db=volume_db, autoplay=True, queue_free_on_end=True))
183
184
185def draw_sprite(renderer, rows: list[int], colour, scale: float = PIXEL, origin=(-4 * PIXEL, -4 * PIXEL)):
186    for y, bits in enumerate(rows):
187        for x in range(8):
188            if bits & (0x80 >> x):
189                renderer.rect((origin[0] + x * scale, origin[1] + y * scale), (scale, scale), fill=colour)
190
191
192def first_hit(node, group: str):
193    """The first node in ``group`` whose circle overlaps ``node``'s, or None."""
194    for other in node.tree.group(group):
195        if (other.world_position - node.world_position).length() < other.radius + node.radius:
196            return other
197    return None
198
199
200class Starfield(Node2D):
201    """Stars drifting down at their own speeds, held as fractions of the view so a resize keeps them spread."""
202
203    dynamic = True
204
205    def on_ready(self):
206        self.stars = [[random.random(), random.random(), random.uniform(0.25, 1.0)] for _ in range(70)]
207
208    def on_update(self, dt):
209        height = self.viewport_size.y
210        for star in self.stars:
211            star[1] += star[2] * 18.0 * dt / height
212            if star[1] > 1.0:
213                star[:2] = random.random(), 0.0
214
215    def on_draw(self, renderer):
216        width, height = self.viewport_size
217        for x, y, brightness in self.stars:
218            renderer.rect((x * width, y * height), (1.5, 1.5), fill=(brightness, brightness, brightness, 1.0))
219
220
221class Alien(Node2D):
222    radius = 4 * PIXEL
223    sound = EXPLOSION
224    frame = Property(0, range=(0, 1))
225
226    def __init__(self, kind: AlienKind, column: int, **kwargs):
227        super().__init__(**kwargs)
228        self.kind = kind
229        self.column = column
230        self.points = kind.points
231        self.colour = kind.colour
232
233    def on_ready(self):
234        self.add_to_group("aliens")
235
236    def on_draw(self, renderer):
237        draw_sprite(renderer, self.kind.frames[self.frame], self.colour)
238
239
240class Formation(Node2D):
241    """The fleet: marches as one, turns and drops at the edges, and speeds up as it thins."""
242
243    cleared = Signal()
244    landed = Signal()
245
246    speed = Property(28.0, range=(1, 200))
247
248    def __init__(self, wave: int, **kwargs):
249        super().__init__(**kwargs)
250        self.wave = wave
251        self.march = 1  # +1 marching right, -1 left
252        self.travel = 0.0  # pixels since the last animation step
253        self.steps = 0
254        self.last_step_sound = 0.0
255
256    def on_ready(self):
257        # Eleven columns at the arcade pitch are wider than an upright phone: close them up to fit.
258        pitch = min(COLUMN_PITCH, (self.viewport_size.x - 80) / FLEET_COLS)
259        for row, kind in enumerate(FLEET_ROWS):
260            for col in range(FLEET_COLS):
261                position = ((col - FLEET_COLS // 2) * pitch, (row - len(FLEET_ROWS) // 2) * ROW_PITCH)
262                self.add_child(Alien(kind, col, position=position))
263
264    def on_update(self, dt):
265        aliens = self.tree.group("aliens")
266        if not aliens:
267            self.cleared.emit()
268            self.destroy()
269            return
270
271        speed = (self.speed + (FLEET_SIZE - len(aliens)) * 4.0) * (1.0 + (self.wave - 1) * 0.1)
272        dx = self.march * speed * dt
273        xs = [alien.world_position.x for alien in aliens]
274        width, height = self.viewport_size
275        if (self.march > 0 and max(xs) + dx > width - 30) or (self.march < 0 and min(xs) + dx < 30):
276            self.march = -self.march
277            self.position.y += 22
278            if max(alien.world_position.y for alien in aliens) > height - 80:
279                self.landed.emit()
280            return
281        self.position.x += dx
282
283        # The sprites swap frames every 14 pixels marched; the march sound is capped at about 8 steps
284        # a second, or a nearly cleared fleet would buzz.
285        self.travel += abs(dx)
286        if self.travel >= 14.0:
287            self.travel -= 14.0
288            for alien in aliens:
289                alien.frame ^= 1
290            if self.tree.now - self.last_step_sound >= 0.12:
291                self.last_step_sound = self.tree.now
292                play(self, MARCH_STEPS[self.steps % len(MARCH_STEPS)])
293                self.steps += 1
294
295
296class Player(Node2D):
297    fired = Signal()
298
299    dynamic = True  # blinks while invulnerable
300    radius = 12
301    speed = Property(300.0, range=(50, 1000))
302
303    def on_ready(self):
304        self.add_to_group("player")
305        self.gun = self.add_child(Timer(duration=0.4))
306        self.respawn = self.add_child(Timer(duration=1.5))
307
308    @property
309    def invulnerable(self) -> bool:
310        return not self.respawn.stopped
311
312    def on_fixed_update(self, dt):
313        step = self.speed * dt
314        x = self.position.x + Input.value("move") * step
315        steering = Input.pressed("steer")
316        if steering:
317            x += max(-step, min(step, Input.mouse_position.x - x))
318        self.position.x = max(20, min(self.viewport_size.x - 20, x))
319
320        if (Input.pressed("fire") or steering) and self.gun.stopped:
321            self.gun.start()
322            self.fired.emit()
323
324    def on_draw(self, renderer):
325        if self.invulnerable and int(self.tree.now * 8) % 2 == 0:
326            return
327        green = (0.0, 1.0, 0.0, 1.0)
328        renderer.rect((-13, -4), (26, 8), fill=green)
329        renderer.rect((-3, -12), (6, 8), fill=green)
330        renderer.rect((-1, -15), (2, 3), fill=green)
331
332
333class Bullet(Node2D):
334    radius = 3
335    direction = Property(-1, hint="-1 flies up (the player's), +1 down (an alien's)")
336    speed = Property(400.0)
337
338    def on_ready(self):
339        self.add_to_group("player_bullets" if self.direction < 0 else "alien_bullets")
340
341    def on_fixed_update(self, dt):
342        self.position.y += self.direction * self.speed * dt
343        if not 0 <= self.position.y <= self.viewport_size.y:
344            self.destroy()
345
346    def on_draw(self, renderer):
347        colour = WHITE if self.direction < 0 else (1.0, 1.0, 0.2, 1.0)
348        renderer.rect((-1, -4), (2, 8), fill=colour)
349
350
351class Barrier(Node2D):
352    """A bunker of pixels: a bullet that lands on one knocks out it and its neighbours."""
353
354    def on_ready(self):
355        self.pixels = [[cell == "#" for cell in line] for line in BARRIER_PATTERN]
356
357    def hit(self, point) -> bool:
358        col, row = ((point - self.position) // PIXEL).astype(int)
359        rows, cols = len(self.pixels), len(self.pixels[0])
360        if not (0 <= row < rows and 0 <= col < cols and self.pixels[row][col]):
361            return False
362        for r in range(max(0, row - 1), min(rows, row + 2)):
363            for c in range(max(0, col - 1), min(cols, col + 2)):
364                self.pixels[r][c] = False
365        self.queue_redraw()
366        return True
367
368    def on_draw(self, renderer):
369        for row, line in enumerate(self.pixels):
370            for col, alive in enumerate(line):
371                if alive:
372                    renderer.rect((col * PIXEL, row * PIXEL), (PIXEL, PIXEL), fill=(0.0, 1.0, 0.39, 1.0))
373
374
375class MysteryShip(Node2D):
376    radius = 4 * PIXEL
377    colour = UFO_COLOUR
378    sound = UFO_HIT
379
380    def __init__(self, points: int, **kwargs):
381        super().__init__(**kwargs)
382        self.points = points
383        self.heading = random.choice((-1, 1))
384
385    def on_ready(self):
386        self.add_to_group("mystery")
387        self.position = (-30 if self.heading > 0 else self.viewport_size.x + 30, 40)
388        self.add_child(AudioPlayer(stream=SIREN, bus="SFX", loop=True, autoplay=True, volume_db=-6.0))
389
390    def on_update(self, dt):
391        self.position.x += self.heading * 120 * dt
392        if not -40 <= self.position.x <= self.viewport_size.x + 40:
393            self.destroy()
394
395    def on_draw(self, renderer):
396        draw_sprite(renderer, UFO_SPRITE, self.colour)
397
398
399class Effect(Node2D):
400    """A short-lived flourish that fades out over ``lifetime`` seconds and then removes itself."""
401
402    dynamic = True
403    lifetime = 0.5
404
405    def __init__(self, colour, **kwargs):
406        super().__init__(**kwargs)
407        self.colour = colour
408        self.age = 0.0
409
410    def on_update(self, dt):
411        self.age += dt
412        if self.age >= self.lifetime:
413            self.destroy()
414
415    @property
416    def faded(self):
417        return (*self.colour[:3], 1.0 - self.age / self.lifetime)
418
419
420class Explosion(Effect):
421    lifetime = 0.4
422
423    def on_ready(self):
424        self.sparks = [(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(40, 100)) for _ in range(8)]
425
426    def on_draw(self, renderer):
427        for dx, dy, speed in self.sparks:
428            reach = speed * self.age
429            renderer.rect((dx * reach - 1.5, dy * reach - 1.5), (3, 3), fill=self.faded)
430
431
432class ScorePopup(Effect):
433    lifetime = 0.8
434
435    def __init__(self, points: int, colour, **kwargs):
436        super().__init__(colour, **kwargs)
437        self.text = f"+{points}"
438
439    def on_update(self, dt):
440        super().on_update(dt)
441        self.position.y -= 40 * dt
442
443    def on_draw(self, renderer):
444        renderer.text(self.text, (0, 0), fill=self.faded, scale=2)
445
446
447class Game(Node):
448    input_context = INPUT_ACTIONS
449
450    lives = Property(3, range=(1, 10))
451
452    def on_ready(self):
453        self.score = 0
454        self.lives_left = self.lives
455        self.wave = 0
456        self.shots = 0
457        width, height = self.viewport_size
458
459        self.add_child(Starfield())
460        self.camera = self.add_child(Camera2D(position=self.viewport_size / 2))
461        self.player = self.add_child(Player(position=(width / 2, height - 50)))
462        self.player.fired.connect(self.player_fire)
463        barrier_width = len(BARRIER_PATTERN[0]) * PIXEL
464        gap = (width - 4 * barrier_width) / 5
465        for i in range(4):
466            self.add_child(Barrier(position=(gap + i * (barrier_width + gap), height - 130)))
467
468        self.volley = self.add_child(Timer(one_shot=False))
469        self.volley.timeout.connect(self.alien_fire)
470        self.mystery = self.add_child(Timer(duration=random.uniform(15, 30), autostart=True))
471        self.mystery.timeout.connect(self.spawn_mystery)
472
473        self.hud = self.add_child(CanvasLayer())
474        self.score_label = self.hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=22))
475        self.wave_label = self.hud.add_child(Label(anchor=Anchor.CENTER_TOP, inset=10, font_size=22))
476        self.wave_label.text_colour = GREY
477        self.lives_label = self.hud.add_child(Label(anchor=Anchor.TOP_RIGHT, inset=10, font_size=22))
478        self.add_child(TouchController(buttons=(Controller.A,)))
479        self.next_wave()
480
481    def show_hud(self):
482        self.score_label.text = f"SCORE {self.score:05d}"
483        self.wave_label.text = f"WAVE {self.wave}"
484        self.lives_label.text = f"LIVES {self.lives_left}"
485
486    def next_wave(self):
487        self.wave += 1
488        top = 80 + len(FLEET_ROWS) // 2 * ROW_PITCH
489        formation = self.add_child(Formation(self.wave, position=(self.viewport_size.x / 2, top)))
490        formation.cleared.connect(self.wave_cleared)
491        formation.landed.connect(self.game_over)
492        self.volley.start(max(0.45, 1.1 - (self.wave - 1) * 0.1))
493        self.show_hud()
494
495    def wave_cleared(self):
496        self.volley.stop()
497        banner = self.hud.add_child(Label(f"WAVE {self.wave + 1}", anchor=Anchor.CENTER, font_size=56))
498        pause = banner.add_child(Timer(duration=1.5, autostart=True))
499        pause.timeout.connect(banner.destroy)
500        pause.timeout.connect(self.next_wave)
501
502    def player_fire(self):
503        self.shots += 1
504        self.add_child(Bullet(direction=-1, position=self.player.position - Vec2(0, 15)))
505        play(self, SHOOT)
506
507    def alien_fire(self):
508        # Only the lowest alien in each column shoots, so none fires through its own ranks.
509        lowest = {}
510        for alien in self.tree.group("aliens"):
511            if alien.column not in lowest or alien.position.y > lowest[alien.column].position.y:
512                lowest[alien.column] = alien
513        if lowest:
514            shooter = random.choice(list(lowest.values()))
515            self.add_child(Bullet(direction=1, position=shooter.world_position + Vec2(0, 10)))
516
517    def spawn_mystery(self):
518        self.add_child(MysteryShip(MYSTERY_POINTS[self.shots % len(MYSTERY_POINTS)]))
519        self.mystery.start(random.uniform(15, 30))
520
521    def on_fixed_update(self, dt):
522        barriers = self.find_all(Barrier, direct=True)
523        for bullet in self.tree.group("player_bullets"):
524            if target := first_hit(bullet, "aliens") or first_hit(bullet, "mystery"):
525                self.shoot_down(target)
526                bullet.destroy()
527            elif other := first_hit(bullet, "alien_bullets"):
528                self.add_child(Explosion(WHITE, position=bullet.position))  # shots meet in mid-air
529                other.destroy()
530                bullet.destroy()
531            elif any(barrier.hit(bullet.position) for barrier in barriers):
532                bullet.destroy()
533
534        for bullet in self.tree.group("alien_bullets"):
535            if not self.player.invulnerable and first_hit(bullet, "player"):
536                bullet.destroy()
537                self.lose_life()
538                return
539            if any(barrier.hit(bullet.position) for barrier in barriers):
540                bullet.destroy()
541
542    def shoot_down(self, target):
543        self.score += target.points
544        self.show_hud()
545        position = target.world_position
546        self.add_child(ScorePopup(target.points, target.colour, position=position))
547        self.add_child(Explosion(target.colour, position=position))
548        play(self, target.sound, volume_db=-3.0)
549        target.destroy()
550
551    def lose_life(self):
552        self.camera.shake(intensity=4.0, duration=0.3)
553        self.add_child(Explosion((0.0, 1.0, 0.4, 1.0), position=self.player.position))
554        play(self, EXPLOSION, volume_db=-3.0)
555        self.lives_left -= 1
556        self.show_hud()
557        if self.lives_left == 0:
558            self.game_over()
559            return
560        width, height = self.viewport_size
561        self.player.position = (width / 2, height - 50)
562        self.player.respawn.start()
563
564    def game_over(self):
565        self.tree.change_scene(GameOverCard(self.score))
566
567
568class Card(Node):
569    """A full-screen text card over the starfield: a press moves on to ``next_scene()``."""
570
571    dynamic = True  # the prompt blinks
572    input_context = INPUT_ACTIONS
573    prompt = "PRESS ENTER OR TAP"
574
575    def on_ready(self):
576        self.add_child(Starfield())
577
578    def next_scene(self) -> Node:
579        """The scene a press moves on to; each card names its own."""
580        raise NotImplementedError
581
582    @on_input("start")
583    def start(self, event):
584        self.tree.change_scene(self.next_scene())
585
586    def lines(self):
587        """``(text, height fraction, scale, colour)`` for each line of the card."""
588        return []
589
590    def on_draw(self, renderer):
591        width, height = self.viewport_size
592        lines = self.lines()
593        if int(self.tree.now * 2) % 2 == 0:
594            lines.append((self.prompt, 0.66, 3, GREY))
595        for text, y, scale, colour in lines:
596            fitted = renderer.fit_scale(text, width * 0.9, base_scale=scale)
597            renderer.text(text, (width / 2, height * y), fill=colour, scale=fitted, align="centre")
598
599
600class TitleScreen(Card):
601    prompt = "PRESS ENTER OR TAP TO START"
602
603    def on_ready(self):
604        super().on_ready()
605        self.add_child(
606            Button("MIXER (O)", on_press=self.open_mixer, anchor=Anchor.CENTER_BOTTOM, inset=30, size=(200, 42))
607        )
608
609    def next_scene(self):
610        return Game()
611
612    @on_input("mixer")
613    def open_mixer(self, event=None):
614        mixer = self.add_child(Mixer())
615        mixer.show_overlay("blocking", dismiss=False, inert=True, initial_focus=mixer.back)
616
617    def lines(self):
618        return [("SPACE INVADERS", 0.13, 5, WHITE), *CONTROLS_HELP]
619
620    def on_draw(self, renderer):
621        super().on_draw(renderer)
622        width, height = self.viewport_size
623        x, y = width / 2 - 80, height * 0.3
624        legend = [(kind.frames[0], kind.colour, kind.points) for kind in (SQUID, CRAB, OCTOPUS)]
625        for sprite, colour, points in [*legend, (UFO_SPRITE, UFO_COLOUR, "???")]:
626            draw_sprite(renderer, sprite, colour, origin=(x, y))
627            renderer.text(f"= {points} PTS", (x + 35, y + 4), fill=WHITE, scale=2)
628            y += min(46.0, height * 0.077)
629
630
631class GameOverCard(Card):
632    prompt = "PRESS ENTER OR TAP TO CONTINUE"
633
634    def __init__(self, score: int, **kwargs):
635        super().__init__(**kwargs)
636        self.score = score
637
638    def next_scene(self):
639        return TitleScreen()
640
641    def lines(self):
642        return [("GAME OVER", 0.3, 5, (1.0, 0.2, 0.2, 1.0)), (f"SCORE  {self.score:05d}", 0.5, 3, WHITE)]
643
644
645class Mixer(Panel):
646    """A modal panel whose slider sets the ``SFX`` bus volume in decibels as it moves."""
647
648    def __init__(self, **kwargs):
649        super().__init__(anchor=Anchor.CENTER, size=(340, 170), **kwargs)
650        self.bg_colour = (0.08, 0.08, 0.12, 0.95)
651        self.bus = AudioBusLayout.get_default().get_bus("SFX")
652        self.add_child(
653            Label("SFX VOLUME", anchor=Anchor.TOP_WIDE, inset=(0, 12), size=(0, 36), font_size=24, alignment="centre")
654        )
655        slider = self.add_child(
656            Slider(-40, 0, value=self.bus.volume_db, anchor=Anchor.TOP_WIDE, inset=(30, 70), size=(0, 20))
657        )
658        slider.value_changed.connect(self.set_volume)
659        self.back = self.add_child(
660            Button("BACK", on_press=self.close, anchor=Anchor.CENTER_BOTTOM, inset=16, size=(120, 36))
661        )
662
663    def set_volume(self, db: float):
664        self.bus.volume_db = db
665
666    def close(self):
667        self.close_overlay()
668        self.destroy()
669
670
671# Example help overlay: not part of the technique shown.
672CONTROLS_HELP = [
673    ("A/D OR ARROWS MOVE    SPACE FIRES", 0.76, 2, GREY),
674    ("OR HOLD THE POINTER TO MOVE AND FIRE", 0.81, 2, GREY),
675]
676
677
678if __name__ == "__main__":
679    App(title="Space Invaders", width=800, height=600, target_fps=30).run(TitleScreen())