Space Invaders 2D¶
Space Invaders — classic arcade game built on SimVX (Vulkan + WebGPU).
Run: uv run python packages/graphics/examples/game_spaceinvaders2d.py
Source Code¶
1#!/usr/bin/env python3
2"""Space Invaders — classic arcade game built on SimVX (Vulkan + WebGPU).
3
4Run: ``uv run python packages/graphics/examples/game_spaceinvaders2d.py``
5"""
6
7import random
8
9import numpy as np
10
11from simvx.core import (
12 AudioStream,
13 AudioStreamPlayer,
14 Camera2D,
15 CharacterBody2D,
16 Input,
17 InputMap,
18 Key,
19 Node,
20 Node2D,
21 Property,
22 Signal,
23 Timer,
24 Vec2,
25)
26from simvx.core.audio_bus import AudioBusLayout
27from simvx.core.ui import AnchorPreset, Button, Control, Label, Panel, Slider
28from simvx.graphics import App
29
30WIDTH, HEIGHT = 800, 600
31PIXEL = 3 # scale for 8x8 sprites
32SAMPLE_RATE = 44100
33
34# 8x8 bit-pattern sprites (each int = one row, bit 7 = leftmost pixel).
35# Two frames per type drives the iconic "step" animation.
36ALIEN_SQUID_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x5A, 0xA5]
37ALIEN_SQUID_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0xA5, 0x5A]
38ALIEN_CRAB_A = [0x24, 0x18, 0x3C, 0x5A, 0x7E, 0x24, 0x24, 0x42]
39ALIEN_CRAB_B = [0x24, 0xA5, 0x3C, 0x5A, 0x7E, 0x24, 0x42, 0x24]
40ALIEN_OCTOPUS_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81, 0x42]
41ALIEN_OCTOPUS_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x42, 0x81]
42ALIEN_UFO = [0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x7E, 0x24, 0x00]
43
44ALIEN_TYPES = [
45 ((ALIEN_SQUID_A, ALIEN_SQUID_B), (1.0, 0.2, 0.2), 30), # squid, red, 30 pts
46 ((ALIEN_CRAB_A, ALIEN_CRAB_B), (0.2, 1.0, 0.2), 20), # crab, green, 20 pts
47 ((ALIEN_OCTOPUS_A, ALIEN_OCTOPUS_B), (0.2, 0.59, 1.0), 10), # octopus, blue, 10 pts
48]
49
50# Canonical Invaders mystery-ship 15-cycle (player shot count → points).
51MYSTERY_POINTS_CYCLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
52
53BARRIER_PATTERN = [
54 " ####### ",
55 " ######### ",
56 "###########",
57 "###########",
58 "###########",
59 "###########",
60 "### # ###",
61 "## # ##",
62]
63BARRIER_PIXEL = 3
64
65
66# ---------------------------------------------------------------------------
67# Procedural audio — generated once at import time. The numpy buffers play
68# unchanged on both Vulkan (miniaudio) and WebGPU (Web Audio API) backends.
69# ---------------------------------------------------------------------------
70
71
72def _envelope(n_frames: int, attack: float = 0.005, release: float = 0.05) -> np.ndarray:
73 env = np.ones(n_frames, dtype=np.float32)
74 a = min(int(SAMPLE_RATE * attack), n_frames // 4)
75 r = min(int(SAMPLE_RATE * release), n_frames // 2)
76 if a:
77 env[:a] = np.linspace(0, 1, a, dtype=np.float32)
78 if r:
79 env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
80 return env
81
82
83def _stereo(mono: np.ndarray) -> np.ndarray:
84 out = np.empty(mono.size * 2, dtype=np.float32)
85 out[0::2] = mono
86 out[1::2] = mono
87 return out
88
89
90def _stream(name: str, mono: np.ndarray) -> AudioStream:
91 s = AudioStream(name)
92 s.backend_data = _stereo(np.clip(mono, -1.0, 1.0).astype(np.float32))
93 return s
94
95
96def _make_shoot() -> AudioStream:
97 duration = 0.10
98 n = int(SAMPLE_RATE * duration)
99 sweep = np.linspace(880.0, 220.0, n, dtype=np.float32)
100 phase = 2 * np.pi * np.cumsum(sweep) / SAMPLE_RATE
101 sig = 0.35 * np.sign(np.sin(phase)) * _envelope(n, 0.002, 0.05)
102 return _stream("sfx:shoot", sig)
103
104
105def _make_explosion() -> AudioStream:
106 duration = 0.35
107 n = int(SAMPLE_RATE * duration)
108 noise = np.random.uniform(-1, 1, n).astype(np.float32)
109 sweep = np.linspace(1.0, 0.2, n, dtype=np.float32)
110 sig = 0.45 * noise * sweep * _envelope(n, 0.001, 0.15)
111 return _stream("sfx:explosion", sig)
112
113
114def _make_step(freq: float) -> AudioStream:
115 duration = 0.08
116 n = int(SAMPLE_RATE * duration)
117 t = np.linspace(0, duration, n, dtype=np.float32)
118 sig = 0.4 * np.sign(np.sin(2 * np.pi * freq * t)) * _envelope(n, 0.002, 0.02)
119 return _stream(f"sfx:step:{freq:.0f}", sig)
120
121
122def _make_ufo_loop() -> AudioStream:
123 duration = 0.40
124 n = int(SAMPLE_RATE * duration)
125 t = np.linspace(0, duration, n, dtype=np.float32)
126 # Wobble between 600 and 1100 Hz — classic UFO siren.
127 freq = 850.0 + 250.0 * np.sin(2 * np.pi * 6.0 * t)
128 phase = 2 * np.pi * np.cumsum(freq) / SAMPLE_RATE
129 sig = 0.25 * np.sin(phase).astype(np.float32)
130 return _stream("sfx:ufo_loop", sig)
131
132
133def _make_ufo_hit() -> AudioStream:
134 duration = 0.45
135 n = int(SAMPLE_RATE * duration)
136 t = np.linspace(0, duration, n, dtype=np.float32)
137 # Three descending tones blended with noise for a "chord crash".
138 tone = sum(np.sin(2 * np.pi * f * t) for f in (660.0, 440.0, 220.0)) / 3.0
139 noise = np.random.uniform(-0.4, 0.4, n).astype(np.float32)
140 sig = 0.4 * (tone + 0.5 * noise) * _envelope(n, 0.001, 0.2)
141 return _stream("sfx:ufo_hit", sig)
142
143
144SFX_SHOOT = _make_shoot()
145SFX_EXPLOSION = _make_explosion()
146SFX_STEPS = [_make_step(f) for f in (110.0, 92.5, 82.4, 73.4)]
147SFX_UFO_LOOP = _make_ufo_loop()
148SFX_UFO_HIT = _make_ufo_hit()
149
150
151def _ensure_buses() -> None:
152 """Ensure lowercase ``music`` / ``sfx`` buses exist so AudioStreamPlayer's
153 ``bus="sfx"`` enum value routes through a real volume bus. The shipped
154 default layout uses PascalCase names (``"SFX"``, ``"Music"``) which the
155 player can't reference via its current Property enum.
156 """
157 layout = AudioBusLayout.get_default()
158 for name in ("music", "sfx"):
159 if layout.get_bus(name) is None:
160 layout.add_bus(name, send_to="Master")
161
162
163_ensure_buses()
164
165
166def _stream_duration(stream: AudioStream) -> float:
167 """Return clip length in seconds (stereo float32 backed)."""
168 data = getattr(stream, "backend_data", None)
169 if data is None:
170 return 0.5
171 return float(data.size) / 2.0 / float(SAMPLE_RATE)
172
173
174def play_sfx(parent: Node, stream: AudioStream, *, bus: str = "sfx",
175 volume_db: float = 0.0, pitch: float = 1.0) -> AudioStreamPlayer:
176 """Spawn a one-shot SFX player and auto-destroy after the clip's duration."""
177 player = parent.add_child(AudioStreamPlayer(
178 stream=stream, bus=bus, volume_db=volume_db,
179 pitch_scale=pitch, autoplay=True, name="SFX"))
180 life = parent.add_child(Timer(_stream_duration(stream) + 0.1,
181 one_shot=True, autostart=True, name="SFXLife"))
182 life.timeout.connect(player.destroy)
183 life.timeout.connect(life.destroy)
184 return player
185
186
187# ---------------------------------------------------------------------------
188# Helpers
189# ---------------------------------------------------------------------------
190
191
192def draw_sprite(renderer, sprite, x, y, scale, colour):
193 """Draw an 8x8 bit-pattern sprite using filled rects."""
194 for row_i, row_bits in enumerate(sprite):
195 for col in range(8):
196 if row_bits & (1 << (7 - col)):
197 renderer.draw_rect((x + col * scale, y + row_i * scale),
198 (scale, scale), colour=colour, filled=True)
199
200
201# ---------------------------------------------------------------------------
202# Starfield — parallax background drawn beneath everything
203# ---------------------------------------------------------------------------
204
205
206class Starfield(Node2D):
207 """Slow-scrolling parallax stars rendered in screen space."""
208
209 def __init__(self, count: int = 70, **kwargs):
210 super().__init__(**kwargs)
211 rng = random.Random(0xC0DE) # deterministic so the field looks the same each run
212 self._stars = [
213 (rng.uniform(0, WIDTH), rng.uniform(0, HEIGHT),
214 rng.uniform(0.25, 1.0), rng.uniform(0.2, 1.0))
215 for _ in range(count)
216 ]
217
218 def process(self, dt: float):
219 new_stars = []
220 for x, y, brightness, parallax in self._stars:
221 y += parallax * 18.0 * dt
222 if y > HEIGHT:
223 y -= HEIGHT
224 x = random.uniform(0, WIDTH)
225 new_stars.append((x, y, brightness, parallax))
226 self._stars = new_stars
227
228 def draw(self, renderer):
229 for x, y, b, _p in self._stars:
230 renderer.draw_rect((x, y), (1.5, 1.5),
231 colour=(b, b, b, 1.0), filled=True)
232
233
234# ---------------------------------------------------------------------------
235# Alien
236# ---------------------------------------------------------------------------
237
238
239class Alien(CharacterBody2D):
240 died = Signal() # emits self when destroyed by a player bullet
241
242 def __init__(self, alien_type: int = 0, **kwargs):
243 super().__init__(collision=PIXEL * 4, **kwargs)
244 self.add_to_group("aliens")
245 frames, colour, points = ALIEN_TYPES[min(alien_type, 2)]
246 self.frames = frames
247 self.colour = colour
248 self.points = points
249
250 def draw(self, renderer):
251 wp = self.world_position
252 frame_idx = self.parent._frame if hasattr(self.parent, "_frame") else 0
253 sprite = self.frames[frame_idx % 2]
254 draw_sprite(renderer, sprite,
255 wp.x - PIXEL * 4, wp.y - PIXEL * 4, PIXEL, self.colour)
256
257
258# ---------------------------------------------------------------------------
259# AlienFormation
260# ---------------------------------------------------------------------------
261
262
263class AlienFormation(Node2D):
264 speed = Property(28.0)
265 wave_cleared = Signal()
266 reached_bottom = Signal()
267
268 def __init__(self, wave: int = 1, **kwargs):
269 super().__init__(**kwargs)
270 self._direction = 1
271 self._wave = wave
272 self._frame = 0
273 self._step_index = 0
274 self._move_accum = 0.0 # pixels travelled since last animation step
275 self._step_cooldown = 0.0 # seconds remaining before next step is allowed
276
277 def ready(self):
278 row_types = [0, 1, 1, 2, 2]
279 for row in range(5):
280 for col in range(11):
281 x = (col - 5) * 40
282 y = (row - 2) * 36
283 self.add_child(Alien(alien_type=row_types[row],
284 name=f"Alien_{row}_{col}",
285 position=Vec2(x, y)))
286
287 def process(self, dt: float):
288 aliens = self.tree.get_group("aliens") if self.tree else []
289 if not aliens:
290 self.wave_cleared()
291 return
292
293 # Speed scales with fewer aliens + gentle wave progression.
294 spd = (self.speed + (55 - len(aliens)) * 4.0) * (1.0 + (self._wave - 1) * 0.10)
295 dx = self._direction * spd * dt
296
297 min_x = min(a.world_position.x for a in aliens)
298 max_x = max(a.world_position.x for a in aliens)
299
300 if (max_x + dx > WIDTH - 30 and self._direction > 0) or \
301 (min_x + dx < 30 and self._direction < 0):
302 self._direction *= -1
303 self.position.y += 22 # was 15 — chunkier descent without being lethal
304 for a in aliens:
305 if a.world_position.y > HEIGHT - 80:
306 self.reached_bottom()
307 return
308 else:
309 self.position.x += dx
310 self._move_accum += abs(dx)
311
312 # Pixel cadence drives the visual frame swap; a separate time cooldown
313 # keeps the step audio from devolving into a buzz at end-of-wave speeds
314 # (cap at ~8 Hz). Visual animation stays untied to the cooldown.
315 self._step_cooldown = max(0.0, self._step_cooldown - dt)
316 if self._move_accum >= 14.0:
317 self._move_accum -= 14.0
318 self._frame ^= 1
319 if self._step_cooldown <= 0.0:
320 play_sfx(self, SFX_STEPS[self._step_index % 4], bus="sfx")
321 self._step_index += 1
322 self._step_cooldown = 0.12
323
324
325# ---------------------------------------------------------------------------
326# Player
327# ---------------------------------------------------------------------------
328
329
330class Player(CharacterBody2D):
331 speed = Property(300.0)
332 hit = Signal()
333 fired = Signal()
334
335 def __init__(self, **kwargs):
336 super().__init__(collision=12, **kwargs)
337 self.add_to_group("player")
338 self.fire_timer = self.add_child(Timer(0.4, name="FireTimer"))
339 self._invuln = False
340 self._blink_phase = 0.0
341
342 def ready(self):
343 self.position = Vec2(WIDTH / 2, HEIGHT - 50)
344
345 def physics_process(self, dt: float):
346 if self._invuln:
347 self._blink_phase += dt
348 if Input.is_action_pressed("move_left"):
349 self.position.x -= self.speed * dt
350 if Input.is_action_pressed("move_right"):
351 self.position.x += self.speed * dt
352 self.position.x = max(20, min(WIDTH - 20, self.position.x))
353
354 if Input.is_action_pressed("fire") and self.fire_timer.stopped:
355 self.fire_timer.start()
356 self.parent.add_child(Bullet(direction=-1, name="PBullet",
357 position=Vec2(self.position.x,
358 self.position.y - 15)))
359 play_sfx(self, SFX_SHOOT, bus="sfx")
360 self.fired()
361
362 def set_invuln(self, on: bool):
363 self._invuln = on
364 self._blink_phase = 0.0
365
366 def draw(self, renderer):
367 if self._invuln and int(self._blink_phase * 8) % 2 == 0:
368 return # blink while respawning
369 x, y = self.position.x, self.position.y
370 green = (0.0, 1.0, 0.0, 1.0)
371 renderer.draw_rect((x - 13, y - 4), (26, 8), colour=green, filled=True)
372 renderer.draw_rect((x - 3, y - 12), (6, 8), colour=green, filled=True)
373 renderer.draw_rect((x - 1, y - 15), (2, 3), colour=green, filled=True)
374
375
376# ---------------------------------------------------------------------------
377# Bullet
378# ---------------------------------------------------------------------------
379
380
381class Bullet(CharacterBody2D):
382 def __init__(self, direction: int = 1, **kwargs):
383 super().__init__(collision=3, **kwargs)
384 self.direction = direction
385 self.speed = 400.0
386 self.add_to_group("player_bullets" if direction < 0 else "alien_bullets")
387
388 def physics_process(self, dt: float):
389 self.position.y += self.direction * self.speed * dt
390 if self.position.y < 0 or self.position.y > HEIGHT:
391 self.destroy()
392
393 def draw(self, renderer):
394 colour = (1.0, 1.0, 1.0, 1.0) if self.direction < 0 else (1.0, 1.0, 0.2, 1.0)
395 renderer.draw_rect((self.position.x - 1, self.position.y - 4),
396 (2, 8), colour=colour, filled=True)
397
398
399# ---------------------------------------------------------------------------
400# Barrier
401# ---------------------------------------------------------------------------
402
403
404class Barrier(Node2D):
405 def __init__(self, **kwargs):
406 super().__init__(**kwargs)
407 self.add_to_group("barriers")
408 self.pixels = [[ch == "#" for ch in row] for row in BARRIER_PATTERN]
409
410 def hit(self, pos) -> bool:
411 bx, by = self.position.x, self.position.y
412 pw, ph = len(self.pixels[0]), len(self.pixels)
413 col = int((pos.x - bx) / BARRIER_PIXEL)
414 row = int((pos.y - by) / BARRIER_PIXEL)
415 if 0 <= row < ph and 0 <= col < pw and self.pixels[row][col]:
416 for dr in range(-1, 2):
417 for dc in range(-1, 2):
418 r, c = row + dr, col + dc
419 if 0 <= r < ph and 0 <= c < pw:
420 self.pixels[r][c] = False
421 return True
422 return False
423
424 def draw(self, renderer):
425 barrier_colour = (0.0, 1.0, 0.39, 1.0)
426 bx, by = self.position.x, self.position.y
427 for row_i, row in enumerate(self.pixels):
428 for col_i, alive in enumerate(row):
429 if alive:
430 renderer.draw_rect((bx + col_i * BARRIER_PIXEL,
431 by + row_i * BARRIER_PIXEL),
432 (BARRIER_PIXEL, BARRIER_PIXEL),
433 colour=barrier_colour, filled=True)
434
435
436# ---------------------------------------------------------------------------
437# Floating effects
438# ---------------------------------------------------------------------------
439
440
441class ScorePopup(Node2D):
442 def __init__(self, points: int = 0, colour=(1.0, 1.0, 1.0), **kwargs):
443 super().__init__(**kwargs)
444 self._text = f"+{points}"
445 self._colour = colour
446 self._elapsed = 0.0
447 self._duration = 0.8
448 t = self.add_child(Timer(self._duration, name="Life"))
449 t.timeout.connect(self.destroy)
450 t.start()
451
452 def process(self, dt: float):
453 self._elapsed += dt
454 self.position.y -= 40 * dt
455
456 def draw(self, renderer):
457 alpha = max(0.0, 1.0 - self._elapsed / self._duration)
458 r, g, b = self._colour
459 renderer.draw_text(self._text, (self.position.x, self.position.y),
460 scale=2, colour=(r, g, b, alpha))
461
462
463class Explosion(Node2D):
464 def __init__(self, colour=(1.0, 1.0, 1.0), **kwargs):
465 super().__init__(**kwargs)
466 self._colour = colour
467 self._elapsed = 0.0
468 self._duration = 0.4
469 self._particles = [
470 (random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(40, 100))
471 for _ in range(8)
472 ]
473 t = self.add_child(Timer(self._duration, name="Life"))
474 t.timeout.connect(self.destroy)
475 t.start()
476
477 def process(self, dt: float):
478 self._elapsed += dt
479
480 def draw(self, renderer):
481 alpha = max(0.0, 1.0 - self._elapsed / self._duration)
482 r, g, b = self._colour
483 colour = (r, g, b, alpha)
484 px, py = self.position.x, self.position.y
485 for dx, dy, spd in self._particles:
486 dist = spd * self._elapsed
487 renderer.draw_rect((px + dx * dist - 1.5, py + dy * dist - 1.5),
488 (3, 3), colour=colour, filled=True)
489
490
491# ---------------------------------------------------------------------------
492# MysteryShip
493# ---------------------------------------------------------------------------
494
495
496class MysteryShip(CharacterBody2D):
497 def __init__(self, points: int = 100, **kwargs):
498 super().__init__(collision=PIXEL * 4, **kwargs)
499 self.add_to_group("mystery")
500 self.points = points
501 self.colour = (1.0, 0.2, 1.0)
502 self._dir = 1 if random.random() < 0.5 else -1
503 self.position = Vec2(-30 if self._dir > 0 else WIDTH + 30, 40)
504 # Looping siren — quieter than other SFX (closer to background music)
505 # so it doesn't dominate the mix while the ship transits the screen.
506 self._siren = self.add_child(AudioStreamPlayer(
507 stream=SFX_UFO_LOOP, bus="sfx", loop=True, autoplay=True,
508 volume_db=-6.0, name="Siren"))
509
510 def _stop_siren(self) -> None:
511 # Belt-and-braces stop: called both when leaving the screen and on hit.
512 # Calling stop() on an already-stopped player is a no-op.
513 if self._siren is not None:
514 self._siren.stop()
515
516 def _exit_tree(self) -> None:
517 # Defensive cleanup when the ship is destroyed externally (bullet hit)
518 # so the siren channel doesn't leak past the parent's lifetime.
519 self._stop_siren()
520 super()._exit_tree()
521
522 def process(self, dt: float):
523 self.position.x += self._dir * 120 * dt
524 if (self._dir > 0 and self.position.x > WIDTH + 40) or \
525 (self._dir < 0 and self.position.x < -40):
526 self._stop_siren()
527 self.destroy()
528
529 def draw(self, renderer):
530 draw_sprite(renderer, ALIEN_UFO,
531 self.position.x - PIXEL * 4,
532 self.position.y - PIXEL * 4,
533 PIXEL, self.colour)
534
535
536# ---------------------------------------------------------------------------
537# Wave banner — shown briefly between waves
538# ---------------------------------------------------------------------------
539
540
541class WaveBanner(Control):
542 def __init__(self, wave: int, on_done, **kwargs):
543 super().__init__(**kwargs)
544 self.set_anchor_preset(AnchorPreset.CENTER)
545 self.size = Vec2(WIDTH, 80)
546 self.margin_left = -WIDTH / 2
547 self.margin_top = -40
548 label = self.add_child(Label(f"WAVE {wave}", name="WaveLabel"))
549 label.font_size = 56
550 label.alignment = "center"
551 label.set_anchor_preset(AnchorPreset.FULL_RECT)
552 label.text_colour = (1.0, 1.0, 1.0, 1.0)
553 self._timer = self.add_child(Timer(1.5, one_shot=True, autostart=True, name="Life"))
554
555 def _finish():
556 on_done()
557 self.destroy()
558 self._timer.timeout.connect(_finish)
559
560
561# ---------------------------------------------------------------------------
562# Audio settings popup
563# ---------------------------------------------------------------------------
564
565
566def _slider_to_db(v: float) -> float:
567 """Map slider 0..100 to bus volume in dB. 0 → -40 (near-silent), 100 → 0."""
568 return -40.0 + (v / 100.0) * 40.0
569
570
571def _db_to_slider(db: float) -> float:
572 return max(0.0, min(100.0, (db + 40.0) * 100.0 / 40.0))
573
574
575class AudioSettingsPopup(Control):
576 """Modal popup with Music/SFX volume sliders. Pushed via tree.push_popup()."""
577
578 closed = Signal()
579
580 def __init__(self, **kwargs):
581 super().__init__(**kwargs)
582 self.set_anchor_preset(AnchorPreset.FULL_RECT)
583 self.z_index = 2000
584
585 # Backdrop dims everything underneath.
586 self._backdrop = self.add_child(Panel(name="Backdrop"))
587 self._backdrop.set_anchor_preset(AnchorPreset.FULL_RECT)
588 self._backdrop.bg_colour = (0.0, 0.0, 0.0, 0.55)
589
590 # Centred dialog body.
591 body = self.add_child(Panel(name="Body"))
592 body.set_anchor_preset(AnchorPreset.CENTER)
593 body.size = Vec2(360, 240)
594 body.margin_left = -180
595 body.margin_top = -120
596 body.bg_colour = (0.08, 0.08, 0.12, 0.95)
597
598 title = body.add_child(Label("AUDIO", name="Title"))
599 title.font_size = 32
600 title.alignment = "center"
601 title.set_anchor_preset(AnchorPreset.TOP_WIDE)
602 title.margin_top = 12
603 title.size = Vec2(360, 40)
604
605 layout = AudioBusLayout.get_default()
606 music_db = layout.get_bus("music").volume_db if layout.get_bus("music") else 0.0
607 sfx_db = layout.get_bus("sfx").volume_db if layout.get_bus("sfx") else 0.0
608
609 self._music_slider = self._row(body, "MUSIC", _db_to_slider(music_db), 70,
610 lambda v: self._set_bus("music", v))
611 self._sfx_slider = self._row(body, "SFX", _db_to_slider(sfx_db), 130,
612 lambda v: self._set_bus("sfx", v))
613
614 back = body.add_child(Button("BACK", name="Back"))
615 back.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
616 back.size = Vec2(120, 36)
617 back.margin_left = 120
618 back.margin_top = -50
619 back.pressed.connect(self._close)
620
621 def _row(self, parent: Control, label: str, value: float, top_y: float,
622 on_change) -> Slider:
623 lab = parent.add_child(Label(label))
624 lab.font_size = 18
625 lab.set_anchor_preset(AnchorPreset.TOP_LEFT)
626 lab.margin_left = 24
627 lab.margin_top = top_y
628 lab.size = Vec2(80, 28)
629
630 slider = parent.add_child(Slider(0, 100, value=value, name=f"Slider{label}"))
631 slider.set_anchor_preset(AnchorPreset.TOP_LEFT)
632 slider.margin_left = 120
633 slider.margin_top = top_y + 4
634 slider.size = Vec2(220, 20)
635 slider.value_changed.connect(on_change)
636 return slider
637
638 @staticmethod
639 def _set_bus(name: str, slider_value: float):
640 bus = AudioBusLayout.get_default().get_bus(name)
641 if bus is not None:
642 bus.volume_db = _slider_to_db(slider_value)
643
644 def _close(self):
645 self.closed()
646 if self.tree is not None:
647 self.tree.pop_popup(self)
648 self.destroy()
649
650
651# ---------------------------------------------------------------------------
652# MainMenu
653# ---------------------------------------------------------------------------
654
655
656class MainMenu(Node):
657 def __init__(self, **kwargs):
658 super().__init__(name="MainMenu", **kwargs)
659 self._popup_open = False
660 self._blink_on = True
661 self._blink_timer = self.add_child(
662 Timer(0.5, one_shot=False, autostart=True, name="Blink"))
663 self._blink_timer.timeout.connect(self._toggle_blink)
664
665 def _toggle_blink(self):
666 self._blink_on = not self._blink_on
667
668 def ready(self):
669 InputMap.add_action("move_left", [Key.A, Key.LEFT])
670 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
671 InputMap.add_action("fire", [Key.SPACE])
672 InputMap.add_action("start", [Key.ENTER])
673 InputMap.add_action("options", [Key.O])
674 self.add_child(Starfield(name="Starfield"))
675
676 def process(self, dt: float):
677 if self._popup_open:
678 return
679 if Input.is_action_just_pressed("start"):
680 self.tree.change_scene(Game())
681 elif Input.is_action_just_pressed("options"):
682 popup = AudioSettingsPopup(name="AudioSettings")
683 self.tree.root.add_child(popup)
684 self.tree.push_popup(popup)
685 self._popup_open = True
686 popup.closed.connect(self._on_popup_closed)
687
688 def _on_popup_closed(self):
689 self._popup_open = False
690
691 def draw(self, renderer):
692 if self._popup_open:
693 return # popup owns the screen; menu copy would bleed through the backdrop
694
695 title = "SPACE INVADERS"
696 tw = renderer.text_width(title, 5)
697 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 80), scale=5,
698 colour=(1.0, 1.0, 1.0))
699
700 y = 220
701 for frames, colour, points in ALIEN_TYPES:
702 draw_sprite(renderer, frames[0], WIDTH // 2 - 80, y, 3, colour)
703 renderer.draw_text(f"= {points} PTS", (WIDTH // 2 - 45, y + 4),
704 scale=2, colour=(1.0, 1.0, 1.0))
705 y += 50
706 draw_sprite(renderer, ALIEN_UFO, WIDTH // 2 - 80, y, 3, (1.0, 0.2, 1.0))
707 renderer.draw_text("= ??? PTS", (WIDTH // 2 - 45, y + 4),
708 scale=2, colour=(1.0, 1.0, 1.0))
709
710 if self._blink_on:
711 prompt = "PRESS ENTER TO START"
712 pw = renderer.text_width(prompt, 3)
713 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 470), scale=3,
714 colour=(0.78, 0.78, 0.78))
715 opts = "O — AUDIO OPTIONS"
716 ow = renderer.text_width(opts, 2)
717 renderer.draw_text(opts, (WIDTH // 2 - ow // 2, 530), scale=2,
718 colour=(0.55, 0.55, 0.55))
719
720
721# ---------------------------------------------------------------------------
722# Game
723# ---------------------------------------------------------------------------
724
725
726class Game(Node):
727 def __init__(self, **kwargs):
728 super().__init__(name="Game", **kwargs)
729 self.score = 0
730 self.lives = 3
731 self._wave = 0
732 self._shots_fired = 0
733 self._between_waves = False
734
735 self.add_child(Starfield(name="Starfield"))
736
737 self.camera = self.add_child(
738 Camera2D(name="Camera", position=Vec2(WIDTH / 2, HEIGHT / 2)))
739 self.player = self.add_child(Player(name="Player"))
740 self.player.fired.connect(self._on_player_fired)
741 self.formation: AlienFormation | None = None
742 self._shoot_timer: Timer | None = None
743
744 # HUD — Label controls with anchors so layout scales with the window.
745 self._score_label = self._make_hud_label(
746 "SCORE 00000", AnchorPreset.TOP_LEFT, margin_left=10, margin_top=10)
747 self._wave_label = self._make_hud_label(
748 "WAVE 1", AnchorPreset.CENTER_TOP,
749 colour=(0.78, 0.78, 0.78, 1.0), margin_top=10, x_offset=-60)
750 self._lives_label = self._make_hud_label(
751 "LIVES 3", AnchorPreset.TOP_RIGHT, margin_right=130, margin_top=10)
752
753 self._mystery_timer = self.add_child(Timer(
754 random.uniform(15, 30), one_shot=True, autostart=True, name="MysteryTimer"))
755 self._mystery_timer.timeout.connect(self._spawn_mystery)
756
757 def _make_hud_label(self, text: str, preset: AnchorPreset, *,
758 colour=(1.0, 1.0, 1.0, 1.0),
759 margin_left: float = 0.0, margin_top: float = 0.0,
760 margin_right: float = 0.0, x_offset: float = 0.0) -> Label:
761 lbl = self.add_child(Label(text, name=f"HUD_{text.split()[0]}"))
762 lbl.set_anchor_preset(preset)
763 lbl.font_size = 22
764 lbl.text_colour = colour
765 lbl.size = Vec2(120, 28)
766 lbl.margin_left = margin_left + x_offset
767 lbl.margin_top = margin_top
768 if preset == AnchorPreset.TOP_RIGHT:
769 lbl.margin_left = -margin_right
770 return lbl
771
772 def ready(self):
773 self._spawn_barriers()
774 self._next_wave()
775
776 def _next_wave(self):
777 self._wave += 1
778 self._between_waves = False
779 self.formation = self.add_child(AlienFormation(
780 wave=self._wave, name="Formation",
781 position=Vec2(WIDTH // 2, 80 + 2 * 36)))
782 self.formation.wave_cleared.connect(self._on_wave_cleared)
783 self.formation.reached_bottom.connect(self._on_game_over)
784 # Connect this game to every alien's death signal — Godot-style per-instance
785 # signals + auto-disconnect mean we can fire-and-forget.
786 for alien in self.formation.children:
787 if isinstance(alien, Alien):
788 alien.died.connect(self._on_alien_killed)
789
790 # Shoot interval slows the floor a bit so wave 5+ stays beatable.
791 interval = max(0.45, 1.10 - (self._wave - 1) * 0.10)
792 self._shoot_timer = self.add_child(Timer(
793 interval, one_shot=False, autostart=True, name="ShootTimer"))
794 self._shoot_timer.timeout.connect(self._alien_shoot)
795
796 def _on_wave_cleared(self):
797 if self._between_waves:
798 return
799 self._between_waves = True
800 if self._shoot_timer:
801 self._shoot_timer.destroy()
802 self._shoot_timer = None
803 if self.formation:
804 self.formation.destroy()
805 self.formation = None
806 banner = self.add_child(WaveBanner(self._wave + 1, self._next_wave,
807 name="WaveBanner"))
808 del banner
809
810 def _on_game_over(self):
811 self.tree.change_scene(GameOver(self.score))
812
813 def _alien_shoot(self):
814 aliens = self.tree.get_group("aliens") if self.tree else []
815 if not aliens:
816 return
817 # Pick the bottom-most alien per column so back rows can't friendly-fire.
818 columns: dict[int, Alien] = {}
819 for a in aliens:
820 col = round(a.world_position.x / 40)
821 existing = columns.get(col)
822 if existing is None or a.world_position.y > existing.world_position.y:
823 columns[col] = a
824 shooter = random.choice(list(columns.values()))
825 wp = shooter.world_position
826 self.add_child(Bullet(direction=1, name="ABullet",
827 position=Vec2(wp.x, wp.y + 10)))
828
829 def _spawn_barriers(self):
830 barrier_w = len(BARRIER_PATTERN[0]) * BARRIER_PIXEL
831 total_w = 4 * barrier_w
832 gap = (WIDTH - total_w) / 5
833 for i in range(4):
834 bx = gap + i * (barrier_w + gap)
835 self.add_child(Barrier(name=f"Barrier_{i}",
836 position=Vec2(bx, HEIGHT - 130)))
837
838 def _spawn_mystery(self):
839 points = MYSTERY_POINTS_CYCLE[self._shots_fired % len(MYSTERY_POINTS_CYCLE)]
840 self.add_child(MysteryShip(points=points, name="Mystery"))
841 self._mystery_timer.start(random.uniform(15, 30))
842
843 def _on_player_fired(self):
844 self._shots_fired += 1
845
846 def _on_alien_killed(self, alien: Alien):
847 wp = Vec2(alien.world_position)
848 self.score += alien.points
849 self.add_child(ScorePopup(points=alien.points, colour=alien.colour,
850 position=wp))
851 self.add_child(Explosion(colour=alien.colour, position=wp))
852 play_sfx(self, SFX_EXPLOSION, bus="sfx", volume_db=-3.0)
853
854 def _on_player_hit(self):
855 self.camera.shake(intensity=4.0, duration=0.3)
856 wp = Vec2(self.player.position)
857 self.add_child(Explosion(colour=(0.0, 1.0, 0.4), position=wp))
858 play_sfx(self, SFX_EXPLOSION, bus="sfx", volume_db=-3.0)
859 self.lives -= 1
860 if self.lives <= 0:
861 self.tree.change_scene(GameOver(self.score))
862 return
863 self.player.position = Vec2(WIDTH / 2, HEIGHT - 50)
864 self.player.set_invuln(True)
865 respawn = self.add_child(Timer(1.5, one_shot=True, autostart=True,
866 name="Respawn"))
867 respawn.timeout.connect(lambda: self.player.set_invuln(False))
868 respawn.timeout.connect(respawn.destroy)
869
870 def process(self, dt: float):
871 self._score_label.text = f"SCORE {self.score:05d}"
872 self._wave_label.text = f"WAVE {self._wave}"
873 self._lives_label.text = f"LIVES {self.lives}"
874
875 def physics_process(self, dt: float):
876 tree = self.tree
877 if not tree:
878 return
879
880 for bullet in tree.get_group("player_bullets"):
881 # Aliens
882 hits = bullet.get_overlapping(group="aliens")
883 if hits:
884 alien = hits[0]
885 alien.died(alien)
886 alien.destroy()
887 bullet.destroy()
888 continue
889 # Mystery ship
890 hits = bullet.get_overlapping(group="mystery")
891 if hits:
892 mystery = hits[0]
893 wp = Vec2(mystery.position)
894 self.score += mystery.points
895 self.add_child(ScorePopup(points=mystery.points,
896 colour=mystery.colour, position=wp))
897 self.add_child(Explosion(colour=mystery.colour, position=wp))
898 play_sfx(self, SFX_UFO_HIT, bus="sfx", volume_db=-3.0)
899 mystery.destroy()
900 bullet.destroy()
901 continue
902 # Mid-air interception with alien bullets — both vanish in a spark.
903 hits = bullet.get_overlapping(group="alien_bullets")
904 if hits:
905 other = hits[0]
906 self.add_child(Explosion(colour=(1.0, 1.0, 1.0),
907 position=Vec2(bullet.position)))
908 bullet.destroy()
909 other.destroy()
910 continue
911 # Barriers
912 for barrier in tree.get_group("barriers"):
913 if barrier.hit(bullet.position):
914 bullet.destroy()
915 break
916
917 for bullet in tree.get_group("alien_bullets"):
918 if not self.player._invuln:
919 hits = bullet.get_overlapping(group="player")
920 if hits:
921 bullet.destroy()
922 self.player.hit()
923 self._on_player_hit()
924 continue
925 for barrier in tree.get_group("barriers"):
926 if barrier.hit(bullet.position):
927 bullet.destroy()
928 break
929
930
931# ---------------------------------------------------------------------------
932# GameOver
933# ---------------------------------------------------------------------------
934
935
936class GameOver(Node):
937 def __init__(self, score: int = 0, **kwargs):
938 super().__init__(name="GameOver", **kwargs)
939 self.score = score
940 self._blink_on = True
941 self._blink_timer = self.add_child(
942 Timer(0.5, one_shot=False, autostart=True, name="Blink"))
943 self._blink_timer.timeout.connect(self._toggle_blink)
944
945 def _toggle_blink(self):
946 self._blink_on = not self._blink_on
947
948 def ready(self):
949 self.add_child(Starfield(name="Starfield"))
950
951 def process(self, dt: float):
952 if Input.is_action_just_pressed("start"):
953 self.tree.change_scene(MainMenu())
954
955 def draw(self, renderer):
956 title = "GAME OVER"
957 tw = renderer.text_width(title, 5)
958 renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5,
959 colour=(1.0, 0.2, 0.2))
960
961 score_text = f"SCORE {self.score:05d}"
962 sw = renderer.text_width(score_text, 3)
963 renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3,
964 colour=(1.0, 1.0, 1.0))
965
966 if self._blink_on:
967 prompt = "PRESS ENTER TO CONTINUE"
968 pw = renderer.text_width(prompt, 2)
969 renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 420), scale=2,
970 colour=(0.78, 0.78, 0.78))
971
972
973# ---------------------------------------------------------------------------
974# Main
975# ---------------------------------------------------------------------------
976
977
978if __name__ == "__main__":
979 App("Space Invaders", WIDTH, HEIGHT, target_fps=30).run(MainMenu())