Space Invaders 2D¶
the arcade classic rebuilt on nodes, signals and synthesized sound.
â–¶ Run in browserTags: 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.frameis aProperty, so changing it redraws the sprite; a barrier callsqueue_redraw()when a hit erodes it; the starfield, the effects and the blinking cannon change every frame and setdynamic = TrueAliens as children of one
Formationnode: moving the formation moves the fleet, and the formation’s own signals,clearedandlanded, tell the game when a wave is won or lostAudioSynthbaking every sound at import, including a customAudioSourcefor the falling shot chirp and the mystery ship’s siren, played by one-shotAudioPlayernodes 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 goesTimerchildren for the fire rate, the alien volley, respawn invulnerability, the mystery ship and the wave bannertree.change_scenebetween the title, the game and the game-over card, each declaring the sameinput_context;moveis oneAxis1Daction that keys and a stick both drive, and@on_inputhandles the pressesAn anchored
LabelHUD on aCanvasLayer, pinned to the screen whileCamera2D.shakerattles the world on a deathA modal mixer on the title:
show_overlay("blocking", inert=True)dims and pauses the scene behind aPanelwhoseSlidersets theSFXbus volume liveA
TouchControllerin 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())