Space Invaders 2D

Rows of enemies, bullets, and wave progression.

▶ Run in browser

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