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