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