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