Space Invaders 2D

Play Demo

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())