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 on the arcade’s 15-shot scoring cycle. Clear the fleet and the next wave arrives faster and fires more often, so the run ends when your three lives do, or when the aliens reach the ground.
Every pixel and every sound is generated in code: the demo ships no asset files.
8x8 bit-pattern sprites drawn as filled rects, two frames per alien type driving the marching animation
Shot, explosion, march-step and UFO-siren clips synthesized with NumPy at import time and wrapped as
AudioClip.from_pcmbuffersOne-shot
AudioPlayernodes that reap themselves viaqueue_free_on_end, mixed through theSFXbus the options popup drives liveScene-tree groups (aliens, bullets, barriers, the player, the mystery ship) backing the circle-overlap collision queries, with no physics world involved
Per-instance signals (
died,wave_cleared,reached_bottom,hit,fired) keeping the rules out of the actorsTimernodes for the fire rate, the alien volley, respawn invulnerability and the wave bannertree.change_scenebetween menu, game and game-over, with declarativeinput_actionsre-registered on every swapAnchored
LabelHUD plus a modalshow_overlaypopup withSlidervolume controlsCamera2D.shakeon a player death, over a parallax starfield
Controls: arrows or A/D move, SPACE fires, ENTER starts. With a mouse or a touchscreen, hold anywhere to slide the cannon toward the pointer and fire while held; the AUDIO OPTIONS button (or O) opens the mixer.
Source¶
1#!/usr/bin/env python3
2"""Space Invaders 2D: the arcade classic rebuilt on nodes, signals and synthesized sound.
3
4# /// simvx
5# tags = ["game", "collision", "waves", "shooting"]
6# web = { root = "MainMenu" }
7# ///
8
9Five rows of aliens march across the screen, drop closer every time the
10formation touches an edge, and shoot back from the bottom of each column. Four
11barriers erode block by block as fire lands on them, and a mystery ship crosses
12the top on the arcade's 15-shot scoring cycle. Clear the fleet and the next wave
13arrives faster and fires more often, so the run ends when your three lives do,
14or when the aliens reach the ground.
15
16Every pixel and every sound is generated in code: the demo ships no asset files.
17
18- 8x8 bit-pattern sprites drawn as filled rects, two frames per alien type
19 driving the marching animation
20- Shot, explosion, march-step and UFO-siren clips synthesized with NumPy at
21 import time and wrapped as ``AudioClip.from_pcm`` buffers
22- One-shot ``AudioPlayer`` nodes that reap themselves via ``queue_free_on_end``,
23 mixed through the ``SFX`` bus the options popup drives live
24- Scene-tree groups (aliens, bullets, barriers, the player, the mystery ship)
25 backing the circle-overlap collision queries, with no physics world involved
26- Per-instance signals (``died``, ``wave_cleared``, ``reached_bottom``, ``hit``,
27 ``fired``) keeping the rules out of the actors
28- ``Timer`` nodes for the fire rate, the alien volley, respawn invulnerability
29 and the wave banner
30- ``tree.change_scene`` between menu, game and game-over, with declarative
31 ``input_actions`` re-registered on every swap
32- Anchored ``Label`` HUD plus a modal ``show_overlay`` popup with ``Slider``
33 volume controls
34- ``Camera2D.shake`` on a player death, over a parallax starfield
35
36Controls: arrows or A/D move, SPACE fires, ENTER starts. With a mouse or a
37touchscreen, hold anywhere to slide the cannon toward the pointer and fire while
38held; the AUDIO OPTIONS button (or O) opens the mixer.
39"""
40
41import random
42
43import numpy as np
44
45from simvx.core import (
46 AudioClip,
47 AudioPlayer,
48 Camera2D,
49 Input,
50 Key,
51 MouseButton,
52 Node,
53 Node2D,
54 Property,
55 Signal,
56 Timer,
57 Vec2,
58)
59from simvx.core.audio_bus import AudioBusLayout
60from simvx.core.ui import AnchorPreset, Button, Control, Label, Panel, Slider
61from simvx.graphics import App
62
63WIDTH, HEIGHT = 800, 600
64
65# Declared on every root scene below: the tree bulk-registers a root's
66# ``input_actions`` on mount and again after each ``change_scene``, so no scene
67# depends on another having run first.
68INPUT_ACTIONS = {
69 "move_left": [Key.A, Key.LEFT],
70 "move_right": [Key.D, Key.RIGHT],
71 "fire": [Key.SPACE],
72 "start": [Key.ENTER, MouseButton.LEFT],
73 "options": [Key.O],
74}
75
76
77class Body2D(Node2D):
78 """A manually integrated 2D actor with a collision radius + group overlap query.
79
80 Space Invaders is an arcade game with NO physics simulation: aliens, bullets
81 and the player move by directly setting ``position`` and collisions are plain
82 circle-vs-circle radius tests. So this is deliberately not a physics body
83 (these entities need no collider at all): it is a light ``Node2D`` carrying a
84 radius and a group-scoped overlap poll.
85 """
86
87 def __init__(self, radius: float = 8.0, **kwargs):
88 super().__init__(**kwargs)
89 self.radius = float(radius)
90
91 def get_overlapping(self, group: str) -> list[Body2D]:
92 """Other ``Body2D`` nodes in ``group`` whose radius overlaps ours."""
93 if not self.tree:
94 return []
95 hits: list[Body2D] = []
96 for b in self.tree.group(group):
97 if b is self or not isinstance(b, Body2D):
98 continue
99 d = b.world_position - self.world_position
100 rr = self.radius + b.radius
101 if float(d.x) ** 2 + float(d.y) ** 2 <= rr * rr:
102 hits.append(b)
103 return hits
104
105
106PIXEL = 3 # scale for 8x8 sprites
107SAMPLE_RATE = 48000 # matches the engine mixer, so the clips need no resampling
108
109# 8x8 bit-pattern sprites (each int = one row, bit 7 = leftmost pixel).
110# Two frames per type drives the iconic "step" animation.
111ALIEN_SQUID_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x5A, 0xA5]
112ALIEN_SQUID_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0xA5, 0x5A]
113ALIEN_CRAB_A = [0x24, 0x18, 0x3C, 0x5A, 0x7E, 0x24, 0x24, 0x42]
114ALIEN_CRAB_B = [0x24, 0xA5, 0x3C, 0x5A, 0x7E, 0x24, 0x42, 0x24]
115ALIEN_OCTOPUS_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81, 0x42]
116ALIEN_OCTOPUS_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x42, 0x81]
117ALIEN_UFO = [0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x7E, 0x24, 0x00]
118
119ALIEN_TYPES = [
120 ((ALIEN_SQUID_A, ALIEN_SQUID_B), (1.0, 0.2, 0.2), 30), # squid, red, 30 pts
121 ((ALIEN_CRAB_A, ALIEN_CRAB_B), (0.2, 1.0, 0.2), 20), # crab, green, 20 pts
122 ((ALIEN_OCTOPUS_A, ALIEN_OCTOPUS_B), (0.2, 0.59, 1.0), 10), # octopus, blue, 10 pts
123]
124
125# Formation geometry: one squid row, two crab rows, two octopus rows.
126ALIEN_ROW_TYPES = [0, 1, 1, 2, 2]
127ALIEN_ROWS = len(ALIEN_ROW_TYPES)
128ALIEN_COLS = 11
129ALIEN_SPACING_X = 40
130ALIEN_SPACING_Y = 36
131
132# Canonical Invaders mystery-ship 15-cycle (player shot count → points).
133MYSTERY_POINTS_CYCLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
134
135BARRIER_PATTERN = [
136 " ####### ",
137 " ######### ",
138 "###########",
139 "###########",
140 "###########",
141 "###########",
142 "### # ###",
143 "## # ##",
144]
145BARRIER_PIXEL = 3
146
147
148# ---------------------------------------------------------------------------
149# Procedural audio: generated once at import time. The same numpy buffers feed
150# the desktop (miniaudio) and web (Web Audio) backends unchanged.
151# ---------------------------------------------------------------------------
152
153
154def _envelope(n_frames: int, attack: float = 0.005, release: float = 0.05) -> np.ndarray:
155 env = np.ones(n_frames, dtype=np.float32)
156 a = min(int(SAMPLE_RATE * attack), n_frames // 4)
157 r = min(int(SAMPLE_RATE * release), n_frames // 2)
158 if a:
159 env[:a] = np.linspace(0, 1, a, dtype=np.float32)
160 if r:
161 env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
162 return env
163
164
165def _clip(name: str, mono: np.ndarray) -> AudioClip:
166 """Wrap a mono float32 buffer as a centred stereo ``AudioClip``.
167
168 ``AudioClip.from_pcm`` is the sanctioned way to hand the engine a
169 pre-rendered buffer: it records the rate and channel count alongside the
170 samples, so nothing downstream has to guess how to play them.
171 """
172 mono = np.clip(mono, -1.0, 1.0).astype(np.float32)
173 interleaved = np.empty(mono.size * 2, dtype=np.float32)
174 interleaved[0::2] = mono
175 interleaved[1::2] = mono
176 return AudioClip.from_pcm(interleaved, sample_rate=SAMPLE_RATE, channels=2, name=name)
177
178
179def _make_shoot() -> AudioClip:
180 duration = 0.10
181 n = int(SAMPLE_RATE * duration)
182 sweep = np.linspace(880.0, 220.0, n, dtype=np.float32)
183 phase = 2 * np.pi * np.cumsum(sweep) / SAMPLE_RATE
184 sig = 0.35 * np.sign(np.sin(phase)) * _envelope(n, 0.002, 0.05)
185 return _clip("sfx:shoot", sig)
186
187
188def _make_explosion() -> AudioClip:
189 duration = 0.35
190 n = int(SAMPLE_RATE * duration)
191 noise = np.random.uniform(-1, 1, n).astype(np.float32)
192 sweep = np.linspace(1.0, 0.2, n, dtype=np.float32)
193 sig = 0.45 * noise * sweep * _envelope(n, 0.001, 0.15)
194 return _clip("sfx:explosion", sig)
195
196
197def _make_step(freq: float) -> AudioClip:
198 duration = 0.08
199 n = int(SAMPLE_RATE * duration)
200 t = np.linspace(0, duration, n, dtype=np.float32)
201 sig = 0.4 * np.sign(np.sin(2 * np.pi * freq * t)) * _envelope(n, 0.002, 0.02)
202 return _clip(f"sfx:step:{freq:.0f}", sig)
203
204
205def _make_ufo_loop() -> AudioClip:
206 duration = 0.40
207 n = int(SAMPLE_RATE * duration)
208 t = np.linspace(0, duration, n, dtype=np.float32)
209 # Wobble between 600 and 1100 Hz: classic UFO siren.
210 freq = 850.0 + 250.0 * np.sin(2 * np.pi * 6.0 * t)
211 phase = 2 * np.pi * np.cumsum(freq) / SAMPLE_RATE
212 sig = 0.25 * np.sin(phase).astype(np.float32)
213 return _clip("sfx:ufo_loop", sig)
214
215
216def _make_ufo_hit() -> AudioClip:
217 duration = 0.45
218 n = int(SAMPLE_RATE * duration)
219 t = np.linspace(0, duration, n, dtype=np.float32)
220 # Three descending tones blended with noise for a "chord crash".
221 tone = sum(np.sin(2 * np.pi * f * t) for f in (660.0, 440.0, 220.0)) / 3.0
222 noise = np.random.uniform(-0.4, 0.4, n).astype(np.float32)
223 sig = 0.4 * (tone + 0.5 * noise) * _envelope(n, 0.001, 0.2)
224 return _clip("sfx:ufo_hit", sig)
225
226
227SFX_SHOOT = _make_shoot()
228SFX_EXPLOSION = _make_explosion()
229SFX_STEPS = [_make_step(f) for f in (110.0, 92.5, 82.4, 73.4)]
230SFX_UFO_LOOP = _make_ufo_loop()
231SFX_UFO_HIT = _make_ufo_hit()
232
233
234def play_sfx(parent: Node, stream: AudioClip, *, bus: str = "SFX", volume_db: float = 0.0) -> AudioPlayer:
235 """Spawn a one-shot SFX player that removes itself when the clip ends."""
236 return parent.add_child(
237 AudioPlayer(stream=stream, bus=bus, volume_db=volume_db, autoplay=True, queue_free_on_end=True, name="SFX")
238 )
239
240
241# ---------------------------------------------------------------------------
242# Helpers
243# ---------------------------------------------------------------------------
244
245
246def draw_sprite(renderer, sprite, x, y, scale, colour):
247 """Draw an 8x8 bit-pattern sprite using filled rects."""
248 for row_i, row_bits in enumerate(sprite):
249 for col in range(8):
250 if row_bits & (1 << (7 - col)):
251 renderer.draw_rect((x + col * scale, y + row_i * scale), (scale, scale), colour=colour, filled=True)
252
253
254# ---------------------------------------------------------------------------
255# Starfield: parallax background drawn beneath everything
256# ---------------------------------------------------------------------------
257
258
259class Starfield(Node2D):
260 """Slow-scrolling parallax stars rendered in screen space."""
261
262 # Stars drift down every frame (on_update mutates _stars), so on_draw must
263 # re-run each frame under retained 2D.
264 dynamic = True
265
266 def __init__(self, count: int = 70, **kwargs):
267 super().__init__(**kwargs)
268 rng = random.Random(0xC0DE) # deterministic so the field looks the same each run
269 self._stars = [
270 (rng.uniform(0, WIDTH), rng.uniform(0, HEIGHT), rng.uniform(0.25, 1.0), rng.uniform(0.2, 1.0))
271 for _ in range(count)
272 ]
273
274 def on_update(self, dt: float):
275 new_stars = []
276 for x, y, brightness, parallax in self._stars:
277 y += parallax * 18.0 * dt
278 if y > HEIGHT:
279 y -= HEIGHT
280 x = random.uniform(0, WIDTH)
281 new_stars.append((x, y, brightness, parallax))
282 self._stars = new_stars
283
284 def on_draw(self, renderer):
285 for x, y, b, _p in self._stars:
286 renderer.draw_rect((x, y), (1.5, 1.5), colour=(b, b, b, 1.0), filled=True)
287
288
289# ---------------------------------------------------------------------------
290# Alien
291# ---------------------------------------------------------------------------
292
293
294class Alien(Body2D):
295 died = Signal() # emits self when destroyed by a player bullet
296
297 def __init__(self, alien_type: int = 0, **kwargs):
298 super().__init__(radius=PIXEL * 4, **kwargs)
299 self.add_to_group("aliens")
300 frames, colour, points = ALIEN_TYPES[min(alien_type, 2)]
301 self.frames = frames
302 self.colour = colour
303 self.points = points
304 self.frame = 0 # animation frame, pushed by the formation via set_frame()
305
306 def set_frame(self, frame: int):
307 """Swap the animation frame and mark the sprite for redraw."""
308 if frame != self.frame:
309 self.frame = frame
310 self.queue_redraw()
311
312 def on_draw(self, renderer):
313 wp = self.world_position
314 draw_sprite(renderer, self.frames[self.frame], wp.x - PIXEL * 4, wp.y - PIXEL * 4, PIXEL, self.colour)
315
316
317# ---------------------------------------------------------------------------
318# AlienFormation
319# ---------------------------------------------------------------------------
320
321
322class AlienFormation(Node2D):
323 speed = Property(28.0)
324 wave_cleared = Signal()
325 reached_bottom = Signal()
326
327 def __init__(self, wave: int = 1, **kwargs):
328 super().__init__(**kwargs)
329 self._direction = 1
330 self._wave = wave
331 self._frame = 0
332 self._step_index = 0
333 self._move_accum = 0.0 # pixels travelled since last animation step
334 self._step_cooldown = 0.0 # seconds remaining before next step is allowed
335
336 def on_ready(self):
337 for row in range(ALIEN_ROWS):
338 for col in range(ALIEN_COLS):
339 x = (col - ALIEN_COLS // 2) * ALIEN_SPACING_X
340 y = (row - ALIEN_ROWS // 2) * ALIEN_SPACING_Y
341 self.add_child(Alien(alien_type=ALIEN_ROW_TYPES[row], name=f"Alien_{row}_{col}", position=Vec2(x, y)))
342
343 def on_update(self, dt: float):
344 aliens = self.tree.group("aliens") if self.tree else []
345 if not aliens:
346 self.wave_cleared()
347 return
348
349 # Speed scales with fewer aliens + gentle wave progression.
350 full_fleet = ALIEN_ROWS * ALIEN_COLS
351 spd = (self.speed + (full_fleet - len(aliens)) * 4.0) * (1.0 + (self._wave - 1) * 0.10)
352 dx = self._direction * spd * dt
353
354 min_x = min(a.world_position.x for a in aliens)
355 max_x = max(a.world_position.x for a in aliens)
356
357 if (max_x + dx > WIDTH - 30 and self._direction > 0) or (min_x + dx < 30 and self._direction < 0):
358 self._direction *= -1
359 # Descend 22 px per edge bounce: enough to feel the fleet closing in,
360 # small enough that a wave is still survivable.
361 self.position.y += 22
362 for a in aliens:
363 if a.world_position.y > HEIGHT - 80:
364 self.reached_bottom()
365 return
366 else:
367 self.position.x += dx
368 self._move_accum += abs(dx)
369
370 # Pixel cadence drives the visual frame swap; a separate time cooldown
371 # keeps the step audio from devolving into a buzz at end-of-wave speeds
372 # (cap at ~8 Hz). Visual animation stays untied to the cooldown.
373 self._step_cooldown = max(0.0, self._step_cooldown - dt)
374 if self._move_accum >= 14.0:
375 self._move_accum -= 14.0
376 self._frame ^= 1
377 for alien in aliens:
378 alien.set_frame(self._frame)
379 if self._step_cooldown <= 0.0:
380 play_sfx(self, SFX_STEPS[self._step_index % 4], bus="SFX")
381 self._step_index += 1
382 self._step_cooldown = 0.12
383
384
385# ---------------------------------------------------------------------------
386# Player
387# ---------------------------------------------------------------------------
388
389
390class Player(Body2D):
391 speed = Property(300.0)
392 # Set while the cannon is respawning: the game skips hit tests and the
393 # cannon blinks. The on_change hook restarts the blink from full brightness.
394 invulnerable = Property(False, on_change="_on_invulnerable_changed")
395 hit = Signal()
396 fired = Signal()
397
398 def __init__(self, **kwargs):
399 super().__init__(radius=12, **kwargs)
400 self.add_to_group("player")
401 self.fire_timer = self.add_child(Timer(0.4, name="FireTimer"))
402 self._invuln_timer = self.add_child(Timer(1.5, one_shot=True, name="InvulnTimer"))
403 self._invuln_timer.timeout.connect(self._end_invulnerability)
404 self._blink_phase = 0.0
405
406 def on_ready(self):
407 self.position = Vec2(WIDTH / 2, HEIGHT - 50)
408
409 def begin_invulnerability(self, seconds: float):
410 """Blink and ignore alien fire for ``seconds`` after a respawn."""
411 self.invulnerable = True
412 self._invuln_timer.start(seconds)
413
414 def _end_invulnerability(self):
415 self.invulnerable = False
416
417 def _on_invulnerable_changed(self):
418 self._blink_phase = 0.0
419 self.queue_redraw()
420
421 def on_fixed_update(self, dt: float):
422 if self.invulnerable:
423 self._blink_phase += dt
424 self.queue_redraw() # the blink is time-driven, so dirty the sprite by hand
425
426 # Pointer controls: hold/drag to slide the cannon toward the pointer and
427 # fire while held (web touch arrives as MouseButton.LEFT).
428 pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
429 if Input.is_action_pressed("move_left"):
430 self.position.x -= self.speed * dt
431 if Input.is_action_pressed("move_right"):
432 self.position.x += self.speed * dt
433 if pointer_held:
434 step = self.speed * dt
435 self.position.x += max(-step, min(step, float(Input.mouse_position.x) - self.position.x))
436 self.position.x = max(20, min(WIDTH - 20, self.position.x))
437
438 if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
439 self.fire_timer.start()
440 self.parent.add_child(
441 Bullet(direction=-1, name="PBullet", position=Vec2(self.position.x, self.position.y - 15))
442 )
443 play_sfx(self, SFX_SHOOT, bus="SFX")
444 self.fired()
445
446 def on_draw(self, renderer):
447 if self.invulnerable and int(self._blink_phase * 8) % 2 == 0:
448 return # blink while respawning
449 x, y = self.position.x, self.position.y
450 green = (0.0, 1.0, 0.0, 1.0)
451 renderer.draw_rect((x - 13, y - 4), (26, 8), colour=green, filled=True)
452 renderer.draw_rect((x - 3, y - 12), (6, 8), colour=green, filled=True)
453 renderer.draw_rect((x - 1, y - 15), (2, 3), colour=green, filled=True)
454
455
456# ---------------------------------------------------------------------------
457# Bullet
458# ---------------------------------------------------------------------------
459
460
461class Bullet(Body2D):
462 def __init__(self, direction: int = 1, **kwargs):
463 super().__init__(radius=3, **kwargs)
464 self.direction = direction
465 self.speed = 400.0
466 self.add_to_group("player_bullets" if direction < 0 else "alien_bullets")
467
468 def on_fixed_update(self, dt: float):
469 self.position.y += self.direction * self.speed * dt
470 if self.position.y < 0 or self.position.y > HEIGHT:
471 self.destroy()
472
473 def on_draw(self, renderer):
474 colour = (1.0, 1.0, 1.0, 1.0) if self.direction < 0 else (1.0, 1.0, 0.2, 1.0)
475 renderer.draw_rect((self.position.x - 1, self.position.y - 4), (2, 8), colour=colour, filled=True)
476
477
478# ---------------------------------------------------------------------------
479# Barrier
480# ---------------------------------------------------------------------------
481
482
483class Barrier(Node2D):
484 def __init__(self, **kwargs):
485 super().__init__(**kwargs)
486 self.add_to_group("barriers")
487 self.pixels = [[ch == "#" for ch in row] for row in BARRIER_PATTERN]
488
489 def hit(self, pos) -> bool:
490 bx, by = self.position.x, self.position.y
491 pw, ph = len(self.pixels[0]), len(self.pixels)
492 col = int((pos.x - bx) / BARRIER_PIXEL)
493 row = int((pos.y - by) / BARRIER_PIXEL)
494 if 0 <= row < ph and 0 <= col < pw and self.pixels[row][col]:
495 for dr in range(-1, 2):
496 for dc in range(-1, 2):
497 r, c = row + dr, col + dc
498 if 0 <= r < ph and 0 <= c < pw:
499 self.pixels[r][c] = False
500 return True
501 return False
502
503 def on_draw(self, renderer):
504 barrier_colour = (0.0, 1.0, 0.39, 1.0)
505 bx, by = self.position.x, self.position.y
506 for row_i, row in enumerate(self.pixels):
507 for col_i, alive in enumerate(row):
508 if alive:
509 renderer.draw_rect(
510 (bx + col_i * BARRIER_PIXEL, by + row_i * BARRIER_PIXEL),
511 (BARRIER_PIXEL, BARRIER_PIXEL),
512 colour=barrier_colour,
513 filled=True,
514 )
515
516
517# ---------------------------------------------------------------------------
518# Floating effects
519# ---------------------------------------------------------------------------
520
521
522class ScorePopup(Node2D):
523 def __init__(self, points: int = 0, colour=(1.0, 1.0, 1.0), **kwargs):
524 super().__init__(**kwargs)
525 self._text = f"+{points}"
526 self._colour = colour
527 self._elapsed = 0.0
528 self._duration = 0.8
529 t = self.add_child(Timer(self._duration, name="Life"))
530 t.timeout.connect(self.destroy)
531 t.start()
532
533 def on_update(self, dt: float):
534 self._elapsed += dt
535 self.position.y -= 40 * dt
536
537 def on_draw(self, renderer):
538 alpha = max(0.0, 1.0 - self._elapsed / self._duration)
539 r, g, b = self._colour
540 renderer.draw_text(self._text, (self.position.x, self.position.y), scale=2, colour=(r, g, b, alpha))
541
542
543class Explosion(Node2D):
544 # The particles expand and fade from a plain elapsed-time attribute while the
545 # node stays put, so nothing dirties the draw: opt into per-frame redraws.
546 dynamic = True
547
548 def __init__(self, colour=(1.0, 1.0, 1.0), **kwargs):
549 super().__init__(**kwargs)
550 self._colour = colour
551 self._elapsed = 0.0
552 self._duration = 0.4
553 self._particles = [(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(40, 100)) for _ in range(8)]
554 t = self.add_child(Timer(self._duration, name="Life"))
555 t.timeout.connect(self.destroy)
556 t.start()
557
558 def on_update(self, dt: float):
559 self._elapsed += dt
560
561 def on_draw(self, renderer):
562 alpha = max(0.0, 1.0 - self._elapsed / self._duration)
563 r, g, b = self._colour
564 colour = (r, g, b, alpha)
565 px, py = self.position.x, self.position.y
566 for dx, dy, spd in self._particles:
567 dist = spd * self._elapsed
568 renderer.draw_rect((px + dx * dist - 1.5, py + dy * dist - 1.5), (3, 3), colour=colour, filled=True)
569
570
571# ---------------------------------------------------------------------------
572# MysteryShip
573# ---------------------------------------------------------------------------
574
575
576class MysteryShip(Body2D):
577 def __init__(self, points: int = 100, **kwargs):
578 super().__init__(radius=PIXEL * 4, **kwargs)
579 self.add_to_group("mystery")
580 self.points = points
581 self.colour = (1.0, 0.2, 1.0)
582 self._dir = 1 if random.random() < 0.5 else -1
583 self.position = Vec2(-30 if self._dir > 0 else WIDTH + 30, 40)
584 # Looping siren: quieter than other SFX (closer to background music) so it
585 # doesn't dominate the mix while the ship transits the screen. As a child
586 # of the ship it stops itself when the ship leaves the tree, whether that
587 # is a bullet hit or the exit below.
588 self.add_child(
589 AudioPlayer(stream=SFX_UFO_LOOP, bus="SFX", loop=True, autoplay=True, volume_db=-6.0, name="Siren")
590 )
591
592 def on_update(self, dt: float):
593 self.position.x += self._dir * 120 * dt
594 if (self._dir > 0 and self.position.x > WIDTH + 40) or (self._dir < 0 and self.position.x < -40):
595 self.destroy()
596
597 def on_draw(self, renderer):
598 draw_sprite(renderer, ALIEN_UFO, self.position.x - PIXEL * 4, self.position.y - PIXEL * 4, PIXEL, self.colour)
599
600
601# ---------------------------------------------------------------------------
602# Wave banner: shown briefly between waves
603# ---------------------------------------------------------------------------
604
605
606class WaveBanner(Control):
607 """Full-width "WAVE n" banner shown for a moment between waves.
608
609 ``AnchorPreset.CENTER`` collapses both anchor pairs onto the centre point, so
610 the box comes from the margins: set all four, not ``size``.
611 """
612
613 def __init__(self, wave: int, on_done, **kwargs):
614 super().__init__(**kwargs)
615 self.set_anchor_preset(AnchorPreset.CENTER)
616 self.margin_left = -WIDTH / 2
617 self.margin_right = WIDTH / 2
618 self.margin_top = -40
619 self.margin_bottom = 40
620 label = self.add_child(Label(f"WAVE {wave}", name="WaveLabel"))
621 label.font_size = 56
622 label.alignment = "center"
623 label.set_anchor_preset(AnchorPreset.FULL_RECT)
624 label.text_colour = (1.0, 1.0, 1.0, 1.0)
625 self._timer = self.add_child(Timer(1.5, one_shot=True, autostart=True, name="Life"))
626
627 def _finish():
628 on_done()
629 self.destroy()
630
631 self._timer.timeout.connect(_finish)
632
633
634# ---------------------------------------------------------------------------
635# Audio settings popup
636# ---------------------------------------------------------------------------
637
638
639def _slider_to_db(v: float) -> float:
640 """Map slider 0..100 to bus volume in dB. 0 → -40 (near-silent), 100 → 0."""
641 return -40.0 + (v / 100.0) * 40.0
642
643
644def _db_to_slider(db: float) -> float:
645 return max(0.0, min(100.0, (db + 40.0) * 100.0 / 40.0))
646
647
648class AudioSettingsPopup(Control):
649 """Modal popup with Music/SFX volume sliders.
650
651 Shown through the overlay layer: ``show_overlay("blocking", inert=True)``
652 supplies the dim scrim, freezes the scene underneath and keeps input from
653 reaching it, so the popup only has to lay out its own widgets.
654 """
655
656 closed = Signal()
657
658 def __init__(self, **kwargs):
659 super().__init__(**kwargs)
660 self.set_anchor_preset(AnchorPreset.FULL_RECT)
661
662 # Centred dialog body. A collapsed anchor pair (CENTER here, and the
663 # TOP_WIDE / BOTTOM_WIDE children below on their vertical axis) takes its
664 # box from the two margins on that axis, so both are set.
665 body = self.add_child(Panel(name="Body"))
666 body.set_anchor_preset(AnchorPreset.CENTER)
667 body.margin_left = -180
668 body.margin_right = 180
669 body.margin_top = -120
670 body.margin_bottom = 120
671 body.bg_colour = (0.08, 0.08, 0.12, 0.95)
672
673 title = body.add_child(Label("AUDIO", name="Title"))
674 title.font_size = 32
675 title.alignment = "center"
676 title.set_anchor_preset(AnchorPreset.TOP_WIDE)
677 title.margin_top = 12
678 title.margin_bottom = 52
679
680 layout = AudioBusLayout.get_default()
681 music_db = layout.get_bus("Music").volume_db
682 sfx_db = layout.get_bus("SFX").volume_db
683
684 self._music_slider = self._row(body, "MUSIC", _db_to_slider(music_db), 70, lambda v: self._set_bus("Music", v))
685 self._sfx_slider = self._row(body, "SFX", _db_to_slider(sfx_db), 130, lambda v: self._set_bus("SFX", v))
686
687 back = body.add_child(Button("BACK", name="Back"))
688 back.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
689 back.margin_left = 120
690 back.margin_right = 120
691 back.margin_top = -52
692 back.margin_bottom = -16
693 back.pressed.connect(self._close)
694
695 def _row(self, parent: Control, label: str, value: float, top_y: float, on_change) -> Slider:
696 lab = parent.add_child(Label(label))
697 lab.font_size = 18
698 lab.set_anchor_preset(AnchorPreset.TOP_LEFT)
699 lab.margin_left = 24
700 lab.margin_top = top_y
701 lab.size = Vec2(80, 28)
702
703 slider = parent.add_child(Slider(0, 100, value=value, name=f"Slider{label}"))
704 slider.set_anchor_preset(AnchorPreset.TOP_LEFT)
705 slider.margin_left = 120
706 slider.margin_top = top_y + 4
707 slider.size = Vec2(220, 20)
708 slider.value_changed.connect(on_change)
709 return slider
710
711 @staticmethod
712 def _set_bus(name: str, slider_value: float):
713 bus = AudioBusLayout.get_default().get_bus(name)
714 bus.volume_db = _slider_to_db(slider_value)
715
716 def _close(self):
717 self.closed()
718 if self.is_overlay_open:
719 self.close_overlay()
720 self.destroy()
721
722
723# ---------------------------------------------------------------------------
724# MainMenu
725# ---------------------------------------------------------------------------
726
727
728class MainMenu(Node):
729 input_actions = INPUT_ACTIONS
730
731 def __init__(self, **kwargs):
732 super().__init__(name="MainMenu", **kwargs)
733 self._popup_open = False
734 self._blink_on = True
735 self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
736 self._blink_timer.timeout.connect(self._toggle_blink)
737
738 # Real button so the mixer is reachable with a mouse or a finger, not
739 # only with the O key.
740 self._options_button = self.add_child(Button("AUDIO OPTIONS (O)", on_press=self._open_popup, name="Options"))
741 self._options_button.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
742 self._options_button.font_size = 18
743 self._options_button.margin_left = -120
744 self._options_button.margin_right = 120
745 self._options_button.margin_top = -72
746 self._options_button.margin_bottom = -30
747
748 def _toggle_blink(self):
749 self._blink_on = not self._blink_on
750 self.queue_redraw() # blink flips on a timer event, not per-frame -> dirty here
751
752 def on_ready(self):
753 self.add_child(Starfield(name="Starfield"))
754
755 def _pointer_over_options(self) -> bool:
756 """True while the pointer sits on the options button.
757
758 ``start`` is bound to the left button so a tap anywhere begins a game;
759 this keeps a tap on the button from doing both at once.
760 """
761 x, y, w, h = self._options_button.get_global_rect()
762 p = Input.mouse_position
763 return x <= p.x <= x + w and y <= p.y <= y + h
764
765 def on_update(self, dt: float):
766 if self._popup_open:
767 return
768 if Input.is_action_just_pressed("start") and not self._pointer_over_options():
769 self.tree.change_scene(Game())
770 elif Input.is_action_just_pressed("options"):
771 self._open_popup()
772
773 def _open_popup(self):
774 if self._popup_open:
775 return
776 popup = self.add_child(AudioSettingsPopup(name="AudioSettings"))
777 popup.show_overlay("blocking", inert=True, dismiss=False)
778 popup.closed.connect(self._on_popup_closed)
779 self._popup_open = True
780 self._options_button.visible = False
781 self.queue_redraw() # menu copy must clear so it can't bleed through the scrim
782
783 def _on_popup_closed(self):
784 self._popup_open = False
785 self._options_button.visible = True
786 self.queue_redraw() # repaint the menu now the popup is gone
787
788 def on_draw(self, renderer):
789 if self._popup_open:
790 return # popup owns the screen; menu copy would bleed through the backdrop
791
792 title = "SPACE INVADERS"
793 tw = renderer.text_width(title, 5)
794 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 80), scale=5, colour=(1.0, 1.0, 1.0))
795
796 y = 200
797 for frames, colour, points in ALIEN_TYPES:
798 draw_sprite(renderer, frames[0], WIDTH // 2 - 80, y, 3, colour)
799 renderer.draw_text(f"= {points} PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
800 y += 46
801 draw_sprite(renderer, ALIEN_UFO, WIDTH // 2 - 80, y, 3, (1.0, 0.2, 1.0))
802 renderer.draw_text("= ??? PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
803
804 if self._blink_on:
805 prompt = "ENTER OR CLICK TO START"
806 pw = renderer.text_width(prompt, 3)
807 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 396), scale=3, colour=(0.78, 0.78, 0.78))
808 for i, line in enumerate(("ARROWS / A-D MOVE SPACE FIRE", "OR HOLD THE POINTER TO MOVE AND FIRE")):
809 lw = renderer.text_width(line, 2)
810 renderer.draw_text(line, (WIDTH // 2 - lw // 2, 456 + i * 34), scale=2, colour=(0.6, 0.6, 0.6))
811
812
813# ---------------------------------------------------------------------------
814# Game
815# ---------------------------------------------------------------------------
816
817
818class Game(Node):
819 input_actions = INPUT_ACTIONS
820
821 def __init__(self, **kwargs):
822 super().__init__(name="Game", **kwargs)
823 self.score = 0
824 self.lives = 3
825 self._wave = 0
826 self._shots_fired = 0
827 self._between_waves = False
828
829 self.add_child(Starfield(name="Starfield"))
830
831 self.camera = self.add_child(Camera2D(name="Camera", position=Vec2(WIDTH / 2, HEIGHT / 2)))
832 self.player = self.add_child(Player(name="Player"))
833 self.player.fired.connect(self._on_player_fired)
834 self.player.hit.connect(self._on_player_hit)
835 self.formation: AlienFormation | None = None
836 self._shoot_timer: Timer | None = None
837
838 # HUD: Label controls with anchors so layout follows the window.
839 self._score_label = self._make_hud_label("SCORE 00000", AnchorPreset.TOP_LEFT, left=10, width=170)
840 self._wave_label = self._make_hud_label(
841 "WAVE 1", AnchorPreset.CENTER_TOP, left=-70, width=140, align="center", colour=(0.78, 0.78, 0.78, 1.0)
842 )
843 self._lives_label = self._make_hud_label("LIVES 3", AnchorPreset.TOP_RIGHT, left=-150, width=140, align="right")
844
845 self._mystery_timer = self.add_child(
846 Timer(random.uniform(15, 30), one_shot=True, autostart=True, name="MysteryTimer")
847 )
848 self._mystery_timer.timeout.connect(self._spawn_mystery)
849
850 def _make_hud_label(
851 self,
852 text: str,
853 preset: AnchorPreset,
854 *,
855 left: float,
856 width: float,
857 align: str = "left",
858 colour=(1.0, 1.0, 1.0, 1.0),
859 ) -> Label:
860 """One anchored HUD label, ``left`` pixels from its anchor.
861
862 A preset whose anchors collapse to a point (CENTER_TOP, TOP_RIGHT) takes
863 its box from the margin pair while TOP_LEFT takes it from ``size``, so
864 both are set and kept in agreement.
865 """
866 height = 28.0
867 lbl = self.add_child(Label(text, name=f"HUD_{text.split()[0]}"))
868 lbl.set_anchor_preset(preset)
869 lbl.font_size = 22
870 lbl.alignment = align
871 lbl.text_colour = colour
872 lbl.size = Vec2(width, height)
873 lbl.margin_left = left
874 lbl.margin_right = left + width
875 lbl.margin_top = 10
876 lbl.margin_bottom = 10 + height
877 return lbl
878
879 def on_ready(self):
880 self._spawn_barriers()
881 self._next_wave()
882
883 def _next_wave(self):
884 self._wave += 1
885 self._between_waves = False
886 self.formation = self.add_child(
887 AlienFormation(
888 wave=self._wave, name="Formation", position=Vec2(WIDTH // 2, 80 + (ALIEN_ROWS // 2) * ALIEN_SPACING_Y)
889 )
890 )
891 self.formation.wave_cleared.connect(self._on_wave_cleared)
892 self.formation.reached_bottom.connect(self._on_game_over)
893 # Signals are per-instance and disconnect themselves when either end is
894 # destroyed, so the wave can be wired up and then forgotten.
895 for alien in self.formation.children:
896 if isinstance(alien, Alien):
897 alien.died.connect(self._on_alien_killed)
898
899 # Shoot interval slows the floor a bit so wave 5+ stays beatable.
900 interval = max(0.45, 1.10 - (self._wave - 1) * 0.10)
901 self._shoot_timer = self.add_child(Timer(interval, one_shot=False, autostart=True, name="ShootTimer"))
902 self._shoot_timer.timeout.connect(self._alien_shoot)
903
904 def _on_wave_cleared(self):
905 if self._between_waves:
906 return
907 self._between_waves = True
908 if self._shoot_timer:
909 self._shoot_timer.destroy()
910 self._shoot_timer = None
911 if self.formation:
912 self.formation.destroy()
913 self.formation = None
914 self.add_child(WaveBanner(self._wave + 1, self._next_wave, name="WaveBanner"))
915
916 def _on_game_over(self):
917 self.tree.change_scene(GameOver(self.score))
918
919 def _alien_shoot(self):
920 aliens = self.tree.group("aliens") if self.tree else []
921 if not aliens:
922 return
923 # Pick the bottom-most alien per column so back rows can't friendly-fire.
924 columns: dict[int, Alien] = {}
925 for a in aliens:
926 col = round(a.world_position.x / ALIEN_SPACING_X)
927 existing = columns.get(col)
928 if existing is None or a.world_position.y > existing.world_position.y:
929 columns[col] = a
930 shooter = random.choice(list(columns.values()))
931 wp = shooter.world_position
932 self.add_child(Bullet(direction=1, name="ABullet", position=Vec2(wp.x, wp.y + 10)))
933
934 def _spawn_barriers(self):
935 barrier_w = len(BARRIER_PATTERN[0]) * BARRIER_PIXEL
936 total_w = 4 * barrier_w
937 gap = (WIDTH - total_w) / 5
938 for i in range(4):
939 bx = gap + i * (barrier_w + gap)
940 self.add_child(Barrier(name=f"Barrier_{i}", position=Vec2(bx, HEIGHT - 130)))
941
942 def _spawn_mystery(self):
943 points = MYSTERY_POINTS_CYCLE[self._shots_fired % len(MYSTERY_POINTS_CYCLE)]
944 self.add_child(MysteryShip(points=points, name="Mystery"))
945 self._mystery_timer.start(random.uniform(15, 30))
946
947 def _on_player_fired(self):
948 self._shots_fired += 1
949
950 def _on_alien_killed(self, alien: Alien):
951 wp = Vec2(alien.world_position)
952 self.score += alien.points
953 self.add_child(ScorePopup(points=alien.points, colour=alien.colour, position=wp))
954 self.add_child(Explosion(colour=alien.colour, position=wp))
955 play_sfx(self, SFX_EXPLOSION, bus="SFX", volume_db=-3.0)
956
957 def _on_player_hit(self):
958 self.camera.shake(intensity=4.0, duration=0.3)
959 wp = Vec2(self.player.position)
960 self.add_child(Explosion(colour=(0.0, 1.0, 0.4), position=wp))
961 play_sfx(self, SFX_EXPLOSION, bus="SFX", volume_db=-3.0)
962 self.lives -= 1
963 if self.lives <= 0:
964 self.tree.change_scene(GameOver(self.score))
965 return
966 self.player.position = Vec2(WIDTH / 2, HEIGHT - 50)
967 self.player.begin_invulnerability(1.5)
968
969 def on_update(self, dt: float):
970 self._score_label.text = f"SCORE {self.score:05d}"
971 self._wave_label.text = f"WAVE {self._wave}"
972 self._lives_label.text = f"LIVES {self.lives}"
973
974 def on_fixed_update(self, dt: float):
975 tree = self.tree
976 if not tree:
977 return
978
979 for bullet in tree.group("player_bullets"):
980 # Aliens
981 hits = bullet.get_overlapping(group="aliens")
982 if hits:
983 alien = hits[0]
984 alien.died(alien)
985 alien.destroy()
986 bullet.destroy()
987 continue
988 # Mystery ship
989 hits = bullet.get_overlapping(group="mystery")
990 if hits:
991 mystery = hits[0]
992 wp = Vec2(mystery.position)
993 self.score += mystery.points
994 self.add_child(ScorePopup(points=mystery.points, colour=mystery.colour, position=wp))
995 self.add_child(Explosion(colour=mystery.colour, position=wp))
996 play_sfx(self, SFX_UFO_HIT, bus="SFX", volume_db=-3.0)
997 mystery.destroy()
998 bullet.destroy()
999 continue
1000 # Mid-air interception with alien bullets: both vanish in a spark.
1001 hits = bullet.get_overlapping(group="alien_bullets")
1002 if hits:
1003 other = hits[0]
1004 self.add_child(Explosion(colour=(1.0, 1.0, 1.0), position=Vec2(bullet.position)))
1005 bullet.destroy()
1006 other.destroy()
1007 continue
1008 # Barriers
1009 for barrier in tree.group("barriers"):
1010 if barrier.hit(bullet.position):
1011 bullet.destroy()
1012 break
1013
1014 for bullet in tree.group("alien_bullets"):
1015 if not self.player.invulnerable:
1016 hits = bullet.get_overlapping(group="player")
1017 if hits:
1018 bullet.destroy()
1019 self.player.hit() # connected to _on_player_hit in __init__
1020 continue
1021 for barrier in tree.group("barriers"):
1022 if barrier.hit(bullet.position):
1023 bullet.destroy()
1024 break
1025
1026
1027# ---------------------------------------------------------------------------
1028# GameOver
1029# ---------------------------------------------------------------------------
1030
1031
1032class GameOver(Node):
1033 input_actions = INPUT_ACTIONS
1034
1035 def __init__(self, score: int = 0, **kwargs):
1036 super().__init__(name="GameOver", **kwargs)
1037 self.score = score
1038 self._blink_on = True
1039 self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
1040 self._blink_timer.timeout.connect(self._toggle_blink)
1041
1042 def _toggle_blink(self):
1043 self._blink_on = not self._blink_on
1044 self.queue_redraw() # blink flips on a timer event, not per-frame -> dirty here
1045
1046 def on_ready(self):
1047 self.add_child(Starfield(name="Starfield"))
1048
1049 def on_update(self, dt: float):
1050 if Input.is_action_just_pressed("start"):
1051 self.tree.change_scene(MainMenu())
1052
1053 def on_draw(self, renderer):
1054 title = "GAME OVER"
1055 tw = renderer.text_width(title, 5)
1056 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
1057
1058 score_text = f"SCORE {self.score:05d}"
1059 sw = renderer.text_width(score_text, 3)
1060 renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
1061
1062 if self._blink_on:
1063 prompt = "ENTER OR CLICK TO CONTINUE"
1064 pw = renderer.text_width(prompt, 2)
1065 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 420), scale=2, colour=(0.78, 0.78, 0.78))
1066
1067
1068# ---------------------------------------------------------------------------
1069# Main
1070# ---------------------------------------------------------------------------
1071
1072
1073if __name__ == "__main__":
1074 App("Space Invaders", WIDTH, HEIGHT, target_fps=30).run(MainMenu())