ajhahn.de
← the-way-out
Python 727 lines
import pygame

import audio
import level_catalog
import save
import theme

# Palette, font cache and the shared title / back-hint / hover
# primitives. Bound to module-private aliases to match the internal
# naming used by the screens below.
from theme import (
    ACCENT,
    BG,
    DONE_C,
    INK,
    LINE_C,
    MUTED,
    SEL_C,
    TITLE_C,
    measure,
)
from theme import draw_back_hint as _draw_back_hint
from theme import draw_title as _draw_title
from theme import hover_marker as _hover_marker
from units import CHARACTER_INFO
from version import VERSION


class MainMenu:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.font = theme.font(46)
        self.title_font = theme.font(78)
        self.small_font = theme.font(24)
        # Set by main.py's update flow; drawn as a toast under the
        # title. status_until is the wall-clock time (seconds, from
        # pygame.time.get_ticks) at which main.py should clear the
        # status — animated phases (the dots) set it to None to opt out
        # of the auto-dismiss, results set it to ~4 s out.
        self.status = ""
        self.status_until = None

        self.buttons = [
            {"text": "Levels", "rect": None, "action": "lvls"},
            {"text": "Editor", "rect": None, "action": "editor"},
            {"text": "Characters", "rect": None, "action": "chars"},
            {"text": "Settings", "rect": None, "action": "settings"},
            {"text": "Update", "rect": None, "action": "update"},
            {"text": "Quit", "rect": None, "action": "quit"}
        ]

        center_x = width // 2
        start_y = height // 2 - 100

        for i, btn in enumerate(self.buttons):
            rect = measure(self.font, btn["text"])
            rect.center = (center_x, start_y + i * 90)
            btn["rect"] = rect

        self.title_center_y = height // 2 - 210
        # Toast sits between title-bottom and first-button-top so it
        # cannot collide with QUIT or the tip line at any resolution.
        title_bottom = (self.title_center_y
                        + self.title_font.get_height() // 2)
        first_btn_top = self.buttons[0]["rect"].top
        self._toast_y = (title_bottom + first_btn_top) // 2

        # Ambient background: scrolling floor + wandering sprites +
        # vignette. Replaces the prior PixelDust on the title screen.
        self.scene = theme.MenuScene(width, height, seed=7)

        # Playable avatar overlay (AC-style loading screen). The
        # wandering MenuScene actors stay non-interactive — they have no
        # hitbox and aren't in any group, so the player walks through
        # them and shots can't hit them. Bounds are enforced by a
        # screen-rect clamp in update(); walls/targets are empty groups.
        self._character_classes = {
            key: cls for key, cls, _label, _tag in CHARACTER_INFO}
        self.world_obstacles = pygame.sprite.Group()
        self.projectile_targets = pygame.sprite.Group()
        self.projectile_group = pygame.sprite.Group()
        self.player = None
        self._spawn_player("c_wiz")

    def _spawn_player(self, key):
        cls = self._character_classes.get(key)
        if cls is None:
            return
        # Bottom-center, well clear of the title and buttons. The clamp
        # in update() keeps the player on screen no matter the spawn.
        spawn_x = self.width // 2
        spawn_y = self.height - 220
        self.player = cls(spawn_x, spawn_y, self.world_obstacles)
        # Center the sprite on the requested spawn point.
        self.player.rect.center = (spawn_x, spawn_y)
        self.player.pos.update(self.player.rect.topleft)
        self.player.hitbox.center = self.player.rect.center
        self.player.projectile_group = self.projectile_group
        self.player.projectile_targets = self.projectile_targets
        # Left mouse must stay reserved for clicking menu buttons.
        self.player.attack_mouse_enabled = False
        self.current_character_key = key

    def set_character(self, key):
        """Rebuild the menu avatar when CharacterMenu picks a new one."""
        if key == getattr(self, "current_character_key", None):
            return
        for shot in list(self.projectile_group):
            shot.kill()
        self._spawn_player(key)

    def update(self, dt):
        if self.player is None:
            return
        self.player.update(dt)
        self.projectile_group.update(dt)

        # Clamp the player to the screen rect. The level's wall-collide
        # path is unavailable here (no walls), so cap pos/rect/hitbox
        # together to keep the sprite, draw rect and shot-spawn point in
        # sync.
        rect = self.player.rect
        max_x = self.width - rect.width
        max_y = self.height - rect.height
        if self.player.pos.x < 0:
            self.player.pos.x = 0
        elif self.player.pos.x > max_x:
            self.player.pos.x = max_x
        if self.player.pos.y < 0:
            self.player.pos.y = 0
        elif self.player.pos.y > max_y:
            self.player.pos.y = max_y
        rect.topleft = (round(self.player.pos.x), round(self.player.pos.y))
        self.player.hitbox.center = rect.center

        # Prune shots that left the screen; Projectile.update would
        # eventually drop them via PROJECTILE_LIFETIME, but clearing
        # off-screen orbs early keeps the group tight.
        screen_rect = pygame.Rect(0, 0, self.width, self.height)
        for shot in list(self.projectile_group):
            if not screen_rect.colliderect(shot.rect):
                shot.kill()

    def set_status(self, text, ttl=4.0):
        """Set the toast text. ``ttl`` is seconds until main.py clears
        it; pass ``None`` for a status that should persist (animated
        phases overwrite themselves every frame instead)."""
        self.status = text
        if ttl is None:
            self.status_until = None
        else:
            self.status_until = pygame.time.get_ticks() / 1000.0 + ttl

    def clear_status(self):
        self.status = ""
        self.status_until = None

    def draw(self, screen):
        # No screen.fill(BG): MenuScene.draw immediately overdraws the
        # whole screen with 4 opaque slab blits, so the fill is dead
        # work (submenus keep theirs — PixelDust is sparse, does not
        # cover the screen).
        self.scene.draw(screen)

        # AC-style loading-screen overlay: shots under the player, both
        # above the scene and below the title/buttons so the UI stays
        # readable and clickable.
        self.projectile_group.draw(screen)
        if self.player is not None:
            screen.blit(self.player.image, self.player.rect)

        mouse_pos = pygame.mouse.get_pos()

        title = theme.text_surface(self.title_font, "THE WAY OUT", TITLE_C)
        screen.blit(title, title.get_rect(
            center=(self.width // 2, self.title_center_y)))

        if self.status:
            theme.draw_toast(
                screen, self.status, self.small_font,
                center_x=self.width // 2, center_y=self._toast_y)

        for btn in self.buttons:
            is_hovered = btn["rect"].collidepoint(mouse_pos)
            # Thin separator above the last item (Quit) to set it apart.
            if btn["action"] == "quit":
                ly = btn["rect"].top - 22
                pygame.draw.line(screen, LINE_C,
                                 (self.width // 2 - 90, ly),
                                 (self.width // 2 + 90, ly), 2)
            color = ACCENT if is_hovered else INK
            text_surf = theme.text_surface(self.font, btn["text"], color)
            screen.blit(text_surf, btn["rect"])
            if is_hovered:
                _hover_marker(screen, btn["rect"])

        d = theme.HINT_DOT
        tip = theme.text_surface(
            self.small_font,
            f"WASD/Arrows move + aim   {d}   Space shoot   {d}   "
            f"Shift ability   {d}   E use",
            MUTED)
        screen.blit(tip, tip.get_rect(
            center=(self.width // 2, self.height - 58)))

        if VERSION:
            ver = theme.text_surface(self.small_font, VERSION, MUTED)
            screen.blit(ver, ver.get_rect(
                bottomleft=(16, self.height - 12)))

    def handle_input(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_pos = pygame.mouse.get_pos()
            for btn in self.buttons:
                if btn["rect"].collidepoint(mouse_pos):
                    audio.play("menu_confirm")
                    return btn["action"]
        return None


class SettingsMenu:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.font = theme.font(46)
        self.title_font = theme.font(56)
        self.small_font = theme.font(24)
        # Persisted preference; main.py applies it to the audio module
        # at startup so it holds before Settings is ever opened.
        _prefs = save.load_settings()
        self.sound_on = _prefs.get("sound", True)
        # Five-step bed level (0 / 25 / 50 / 75 / 100 %) — coarse on
        # purpose so a click cycles through them clearly. Volume is
        # independent of the sound toggle: muting kills audio outright,
        # the slider sets the music level when audio is on.
        raw_vol = _prefs.get("music_vol", 1.0)
        self.music_vol = max(0.0, min(1.0,
            float(raw_vol) if isinstance(raw_vol, (int, float)) else 1.0))
        # Fullscreen vs. bordered window only — no resolution picker.
        # The game always boots fullscreen at the monitor's own size
        # (main.py); this toggle is session-only, never persisted.
        self.toggle_screen = True

        # Same idle motion as the title screen but quieter — a
        # different seed gives each submenu its own pattern.
        self.dust = theme.PixelDust(width, height, seed=11, count=35)

        self.update_buttons()

    def update_buttons(self):
        sound_text = f"Sound: {'ON' if self.sound_on else 'OFF'}"
        music_text = f"Music: {int(round(self.music_vol * 100))}/100"
        screen_text = (
            f"Screen: {'FULLSCREEN' if self.toggle_screen else 'BORDERED'}")

        self.buttons = [
            {"text": sound_text, "rect": None, "action": "toggle_sound"},
            {"text": music_text, "rect": None, "action": "cycle_music"},
            {"text": screen_text, "rect": None, "action": "toggle_fs_w"},
        ]

        center_x = self.width // 2
        start_y = self.height // 2 - 100

        for i, btn in enumerate(self.buttons):
            rect = measure(self.font, btn["text"])
            rect.center = (center_x, start_y + i * 100)
            btn["rect"] = rect

    def draw(self, screen):
        screen.fill(BG)
        self.dust.draw(screen)
        _draw_title(screen, self.title_font, "Settings", self.width)
        _draw_back_hint(screen, self.small_font)

        mouse_pos = pygame.mouse.get_pos()
        for btn in self.buttons:
            is_hovered = btn["rect"].collidepoint(mouse_pos)
            color = ACCENT if is_hovered else INK
            screen.blit(theme.text_surface(
                self.font, btn["text"], color), btn["rect"])
            if is_hovered:
                _hover_marker(screen, btn["rect"])

    def handle_input(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_pos = pygame.mouse.get_pos()
            for btn in self.buttons:
                if btn["rect"].collidepoint(mouse_pos):
                    if btn["action"] == "toggle_sound":
                        self.sound_on = not self.sound_on
                        audio.set_enabled(self.sound_on)
                        save.set_setting("sound", self.sound_on)
                        self.update_buttons()

                    elif btn["action"] == "cycle_music":
                        # Cycle 0 → 25 → 50 → 75 → 100 → 0. Snap any
                        # off-step saved value to the next step up.
                        steps = (0.0, 0.25, 0.5, 0.75, 1.0)
                        cur = round(self.music_vol * 4) / 4
                        idx = (steps.index(cur) + 1) % len(steps) \
                            if cur in steps else 0
                        self.music_vol = steps[idx]
                        audio.set_music_volume(self.music_vol)
                        save.set_setting("music_vol", self.music_vol)
                        self.update_buttons()

                    elif btn["action"] == "toggle_fs_w":
                        self.toggle_screen = not self.toggle_screen
                        pygame.display.toggle_fullscreen()
                        self.update_buttons()

                    audio.play("menu_confirm")
                    return btn["action"]
        return None


class LevelMenu:
    """Level select with completion checkmarks read from ``save.py``.

    Entries are rebuilt from ``level_catalog`` on every ``refresh()`` so:
      * freshly-beaten levels light up the next time you back to the menu
      * a custom level the player just saved in the editor appears
        without restarting the game

    Built-in levels are listed first (manifest order); user-built levels
    follow, visually marked as ``Custom``.
    """

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.font = theme.font(46)
        self.title_font = theme.font(56)
        self.small_font = theme.font(24)
        self.tag_font = theme.font(22)
        self.best_font = theme.font(20)

        self.times = {}
        self.entries = []
        # Idle motion, kept thinner than the title because this screen
        # is text-dense (rows of titles, taglines and best times).
        self.dust = theme.PixelDust(width, height, seed=13, count=25)
        self.refresh()

    def _layout(self):
        """Stack entries vertically, auto-shrinking spacing when the
        catalog grows so custom levels still fit on screen."""
        if not self.entries:
            return
        count = len(self.entries)
        # 130 px per row up to 5 entries, then tighten so 10 still fit.
        gap = max(60, min(130, (self.height - 240) // max(count, 1)))
        center_x = self.width // 2
        start_y = self.height // 2 - (count - 1) * gap // 2
        for i, btn in enumerate(self.entries):
            rect = measure(self.font, btn["text"])
            rect.center = (center_x, start_y + i * gap)
            btn["rect"] = rect

    def refresh(self):
        """Rebuild entries from the catalog + reread completed ids and
        best times."""
        self.completed = save.load_completed()
        self.times = save.load_times()
        self.entries = []
        for entry in level_catalog.load_catalog():
            self.entries.append({
                "text": entry.title,
                "action": entry.id,
                "tagline": entry.tagline,
                "custom": entry.custom,
                "rect": None,
            })
        self._layout()

    def draw(self, screen):
        screen.fill(BG)
        self.dust.draw(screen)
        _draw_title(screen, self.title_font, "Levels", self.width)
        _draw_back_hint(screen, self.small_font)

        if not self.entries:
            empty = theme.text_surface(
                self.small_font,
                "No levels found — check assets/levels/manifest.json",
                MUTED)
            screen.blit(empty, empty.get_rect(
                center=(self.width // 2, self.height // 2)))
            return

        mouse_pos = pygame.mouse.get_pos()

        for btn in self.entries:
            is_hovered = btn["rect"].collidepoint(mouse_pos)
            is_done = btn["action"] in self.completed
            if is_hovered:
                color = ACCENT
            elif is_done:
                color = DONE_C
            else:
                color = INK
            text_surf = theme.text_surface(self.font, btn["text"], color)
            screen.blit(text_surf, btn["rect"])
            if is_hovered:
                _hover_marker(screen, btn["rect"])

            tag = btn["tagline"]
            if btn["custom"]:
                # No pill — a quiet prefix keeps the row flat.
                tag = f"custom | {tag}"
            tag_surf = theme.text_surface(
                self.tag_font, tag,
                MUTED if not is_done else theme.shade(DONE_C, -30))
            screen.blit(tag_surf, tag_surf.get_rect(
                center=(btn["rect"].centerx, btn["rect"].bottom + 16)))

            best = self.times.get(btn["action"])
            if best is not None:
                m, s = divmod(int(best), 60)
                # INK, not ACCENT: a persistent 20px label in the gold
                # accent is too low-contrast to read (same reason the
                # update status line uses INK).
                bt = theme.text_surface(
                    self.best_font, f"best  {m}:{s:02d}", INK)
                screen.blit(bt, bt.get_rect(
                    center=(btn["rect"].centerx, btn["rect"].bottom + 42)))

            if is_done:
                # Minimal check: a thin tick, no filled circle.
                tx = btn["rect"].left - 60
                ty = btn["rect"].centery
                pygame.draw.lines(screen, DONE_C, False, [
                    (tx - 9, ty),
                    (tx - 2, ty + 8),
                    (tx + 11, ty - 8)], 3)

    def handle_input(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_pos = pygame.mouse.get_pos()
            for btn in self.entries:
                if btn["rect"] and btn["rect"].collidepoint(mouse_pos):
                    audio.play("menu_confirm")
                    return btn["action"]
        return None


class CharacterMenu:
    """Character select with the stat block of the currently-hovered
    (or, if none, currently-selected) character shown alongside."""

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.title_font = theme.font(56)
        self.card_font = theme.font(44)
        self.name_font = theme.font(46)
        self.small_font = theme.font(24)
        self.stat_font = theme.font(24)
        self.tagline_font = theme.font(22)

        # Build entries from the units catalogue.
        self.character = []
        for key, cls, label, tagline in CHARACTER_INFO:
            self.character.append({
                "text": label,
                "action": key,
                "tagline": tagline,
                "cls": cls,
                "rect": None,
            })

        # Left-align every name on a single vertical line so the column
        # doesn't zigzag with each name's width.
        self.name_x = width // 2 - 320
        start_y = height // 2 - 200

        for i, btn in enumerate(self.character):
            rect = measure(self.name_font, btn["text"])
            rect.midleft = (self.name_x, start_y + i * 100)
            btn["rect"] = rect

        # Two scaled idle-frame lists per character:
        #  * ``previews``: 220 px hero, used by the focused row only.
        #  * ``thumbs``:    64 px badge, drawn next to every row so the
        #                   whole list animates instead of standing
        #                   still everywhere except the focus.
        self.previews = {}
        self.thumbs = {}
        for key, cls, _label, _tagline in CHARACTER_INFO:
            self.previews[key] = self._load_idle_frames(cls, 220)
            self.thumbs[key] = self._load_idle_frames(cls, 72)

        # Idle motion — quieter than the title screen so it doesn't
        # compete with the stat block on the right.
        self.dust = theme.PixelDust(width, height, seed=17, count=30)

    def _load_idle_frames(self, cls, target_h):
        """Every idle frame, scaled to ``target_h`` px tall.

        Used twice per character: once for the focused-row hero
        preview, once for the per-row thumbnail so every figure in the
        list loops its idle instead of sitting on a static name.
        Returns ``None`` if the sheet is missing — callers skip the
        blit, and the row just shows the name."""
        try:
            sheet = pygame.image.load(
                f"assets/units/{cls.asset_folder}/D_Idle.png").convert_alpha()
        except (pygame.error, FileNotFoundError):
            return None
        _, count = cls.SPRITE_SHEETS['idle_down']
        fw = sheet.get_width() // count
        fh = sheet.get_height()
        scale = target_h / fh
        size = (int(fw * scale), int(fh * scale))
        return [
            pygame.transform.scale(
                sheet.subsurface(pygame.Rect(i * fw, 0, fw, fh)), size)
            for i in range(count)
        ]

    def draw(self, screen, current_selected):
        screen.fill(BG)
        self.dust.draw(screen)
        _draw_title(screen, self.title_font, "Select Character", self.width)
        _draw_back_hint(screen, self.small_font)

        mouse_pos = pygame.mouse.get_pos()

        # Choose which character's stats to show: hovered first,
        # otherwise the current selection.
        focus = None
        for btn in self.character:
            if btn["rect"].collidepoint(mouse_pos):
                focus = btn
                break
        if focus is None:
            for btn in self.character:
                if btn["action"] == current_selected:
                    focus = btn
                    break

        ticks = pygame.time.get_ticks()
        for i, btn in enumerate(self.character):
            is_hovered = btn["rect"].collidepoint(mouse_pos)

            if btn["action"] == current_selected:
                color = SEL_C
            elif is_hovered:
                color = ACCENT
            else:
                color = INK

            text_surf = theme.text_surface(self.name_font, btn["text"], color)
            screen.blit(text_surf, btn["rect"])
            if is_hovered:
                _hover_marker(screen, btn["rect"])
            tag = theme.text_surface(
                self.tagline_font, btn["tagline"], MUTED)
            screen.blit(tag, tag.get_rect(
                topleft=(btn["rect"].left, btn["rect"].bottom + 4)))

            # Per-row idle thumbnail — every character animates. Skip
            # the focused row: it gets the bigger hero sprite drawn
            # below, and a duplicate thumb beside the name would compete
            # with the stat-card column.
            is_focus = focus is not None and btn["action"] == focus["action"]
            if not is_focus:
                thumbs = self.thumbs.get(btn["action"])
                if thumbs:
                    # Stagger frame index per row so they don't blink in
                    # sync. ~7 fps idle loop.
                    idx = (ticks // 140 + i * 2) % len(thumbs)
                    frame = thumbs[idx]
                    screen.blit(frame, frame.get_rect(
                        center=(self.name_x - 60, btn["rect"].centery)))

        if focus is not None:
            frames = self.previews.get(focus["action"])
            if frames:
                # ~7 fps idle loop, timed off the wall clock so this
                # screen doesn't need a dt plumbed in just for the sprite.
                frame = frames[(ticks // 140) % len(frames)]
                pcx = self.name_x - 170
                pcy = self.height // 2
                screen.blit(frame, frame.get_rect(center=(pcx, pcy)))
            self._draw_stat_card(screen, focus)

    def _draw_stat_card(self, screen, btn):
        cls = btn["cls"]
        # No box: a flat column with one thin separator under the name.
        card_w = 520
        cx = self.width // 2 + 360
        left = cx - card_w // 2
        top = self.height // 2 - 220

        name = theme.text_surface(self.card_font, btn["text"], TITLE_C)
        screen.blit(name, name.get_rect(center=(cx, top)))
        tag = theme.text_surface(self.tagline_font, btn["tagline"], MUTED)
        screen.blit(tag, tag.get_rect(center=(cx, top + 44)))
        pygame.draw.line(screen, LINE_C,
                         (left + 20, top + 78),
                         (left + card_w - 20, top + 78), 2)

        stats = [
            ("HP",        cls.max_hp, 200),
            ("SPEED",     cls.speed, 900),
            ("DAMAGE",    cls.attack_damage, 25),
            ("FIRE RATE", 1.0 / max(0.01, cls.attack_cooldown), 6.0),
        ]
        # Label column width from font metrics (codebase idiom — cf.
        # theme.draw_toast, MainMenu._toast_y) so the widest label
        # ("FIRE RATE") can't overrun a hardcoded 110 px column into
        # its bar. bar_right reproduces the old right edge
        # (left+130)+(card_w-170) so the value-number column is byte-
        # stable; the max(60, …) is a defensive floor (B10-class) that
        # never triggers with the current font/labels.
        label_w = max(self.stat_font.size(s)[0] for s, _, _ in stats)
        bar_right = left + card_w - 40
        bar_x = left + 20 + label_w + 18
        bar_w = max(60, bar_right - bar_x)
        bar_h = 10
        y = top + 130
        for label, val, vmax in stats:
            text = theme.text_surface(self.stat_font, label, MUTED)
            screen.blit(text, text.get_rect(midleft=(left + 20, y + 5)))
            ratio = max(0.05, min(1.0, val / vmax))
            theme.draw_bar(screen,
                           pygame.Rect(bar_x, y, bar_w, bar_h),
                           ratio, ACCENT, border=False)
            num = theme.text_surface(
                self.stat_font,
                f"{val:.1f}" if isinstance(val, float) else str(val),
                INK)
            screen.blit(num, num.get_rect(midleft=(bar_x + bar_w + 12, y + 5)))
            y += 56

        # Signature ability — a fifth line below the stat bars so the
        # differentiator reads before the character is picked.
        if getattr(cls, "ABILITY_NAME", ""):
            pygame.draw.line(screen, LINE_C,
                             (left + 20, y - 6),
                             (left + card_w - 20, y - 6), 2)
            y += 14
            name = theme.text_surface(
                self.stat_font, f"ABILITY  {cls.ABILITY_NAME}", TITLE_C)
            screen.blit(name, name.get_rect(midleft=(left + 20, y + 5)))
            y += 34
            desc = theme.text_surface(
                self.tagline_font, cls.ABILITY_DESC, MUTED)
            screen.blit(desc, desc.get_rect(midleft=(left + 20, y + 5)))

    def handle_input(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mouse_pos = pygame.mouse.get_pos()
            for btn in self.character:
                if btn["rect"].collidepoint(mouse_pos):
                    audio.play("menu_confirm")
                    return btn["action"]
        return None


class PauseMenu:
    """Translucent overlay over the live game.

    The level keeps its state — ``main.py`` simply stops calling
    ``LevelManager.update`` while paused, so the next Resume picks up
    exactly where you froze.
    """

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.font = theme.font(50)
        self.title_font = theme.font(76)
        self.hint_font = theme.font(24)

        self.buttons = [
            {"text": "Resume",       "action": "resume"},
            {"text": "Restart Level", "action": "restart"},
            {"text": "Quit to Menu", "action": "quit"},
        ]

        cx = width // 2
        start_y = height // 2 - 30
        for i, btn in enumerate(self.buttons):
            rect = measure(self.font, btn["text"])
            rect.center = (cx, start_y + i * 110)
            btn["rect"] = rect

    def draw(self, screen):
        overlay = pygame.Surface(screen.get_size(), pygame.SRCALPHA)
        overlay.fill((*BG, 210))
        screen.blit(overlay, (0, 0))

        title = theme.text_surface(self.title_font, "PAUSED", TITLE_C)
        t_rect = title.get_rect(
            center=(self.width // 2, self.height // 2 - 200))
        screen.blit(title, t_rect)
        ly = t_rect.bottom + 16
        pygame.draw.line(screen, LINE_C,
                         (self.width // 2 - 150, ly),
                         (self.width // 2 + 150, ly), 2)

        mp = pygame.mouse.get_pos()
        for btn in self.buttons:
            hov = btn["rect"].collidepoint(mp)
            col = ACCENT if hov else INK
            screen.blit(theme.text_surface(
                self.font, btn["text"], col), btn["rect"])
            if hov:
                _hover_marker(screen, btn["rect"])

        hint = theme.text_surface(self.hint_font, "Esc to resume", MUTED)
        screen.blit(hint, hint.get_rect(
            center=(self.width // 2, self.height - 88)))

    def handle_input(self, event):
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            mp = pygame.mouse.get_pos()
            for btn in self.buttons:
                if btn["rect"].collidepoint(mp):
                    audio.play("menu_confirm")
                    return btn["action"]
        return None