ajhahn.de
← the-way-out
Python 932 lines
import random

import pygame

import audio
from settings import (
    ATTACK_COOLDOWN,
    BOSS_AIM_TIME,
    BOSS_CHASE_TIME_MAX,
    BOSS_CHASE_TIME_MIN,
    BOSS_DASH_SPEED_MULT,
    BOSS_DASH_TIME,
    BOSS_HITBOX_SIZE,
    BOSS_MAX_HP,
    BOSS_PHASE2_HP_RATIO,
    BOSS_PROJECTILE_DAMAGE,
    BOSS_PROJECTILE_SPEED,
    BOSS_RECOVER_TIME,
    BOSS_SCALE,
    BOSS_SPEED,
    BOSS_WINDUP_TIME,
    DASH_COOLDOWN,
    DASH_DURATION,
    DASH_INVULN_BONUS,
    DASH_SPEED_MULT,
    HIT_FLASH_TIME,
    PLAYER_HITBOX_SIZE,
    PLAYER_MAX_HP,
    PLAYER_SCALE,
    PLAYER_SPEED,
    PROJECTILE_DAMAGE,
    PROJECTILE_LIFETIME,
    PROJECTILE_RADIUS,
    PROJECTILE_SPEED,
)

# Unit vector for each facing direction (used as aim fallback when no
# explicit aim is set and for the boss's targeting).
FACING_VECTORS = {
    'down': (0, 1),
    'up': (0, -1),
    'left': (-1, 0),
    'right': (1, 0),
}


class Character(pygame.sprite.Sprite):
    """Base class for all units (player and AI).

    Subclasses set ``asset_folder`` plus, optionally, the tuning class
    attributes below; all loading, animation, movement, collision and
    health behaviour is shared. The player is keyboard-driven via
    ``get_input``; enemies override it with AI.
    """

    asset_folder = None

    # Tuning — overridable per subclass.
    scale = PLAYER_SCALE
    speed = PLAYER_SPEED
    hitbox_size = PLAYER_HITBOX_SIZE
    max_hp = PLAYER_MAX_HP
    attack_damage = PROJECTILE_DAMAGE
    attack_cooldown = ATTACK_COOLDOWN

    # --- Signature ability ---
    # Each playable subclass overrides these plus the activate / tick /
    # on_ability_end hooks below; the base values keep non-playable
    # units (Enemy / Boss) inert — they never reach the trigger path.
    ABILITY_NAME = ""           # HUD + character-menu label
    ABILITY_DESC = ""           # one-line tagline for the character menu
    ABILITY_GLYPH = "dash"      # which icon draw_ability_meter draws
    ABILITY_DURATION = 0.0      # seconds the ability stays active
    ABILITY_COOLDOWN = 0.0      # seconds before it can fire again

    # When False, left mouse no longer triggers an attack — only Space
    # fires. The main-menu's playable avatar sets this off so clicks on
    # buttons don't double as shots; gameplay keeps the default.
    attack_mouse_enabled = True

    # name -> frame count in the sprite sheet
    SPRITE_SHEETS = {
        'idle_down': ('D_Idle', 4),
        'walk_down': ('D_Walk', 6),
        'idle_up': ('U_Idle', 4),
        'walk_up': ('U_Walk', 6),
        'idle_left': ('S_Idle', 4),
        'walk_left': ('S_Walk', 6),
    }

    def __init__(self, x, y, obstacle_sprites=None):
        super().__init__()
        if self.asset_folder is None:
            raise ValueError(
                f"{type(self).__name__} must define an 'asset_folder'")

        self.facing = 'down'
        self.load_assets()

        self.status = 'idle_down'
        self.frame_index = 0
        self.animation_speed = 10

        frames = self.animations[self.status]
        self.image = (frames[self.frame_index] if frames
                      else self._placeholder_frame())
        self.rect = self.image.get_rect(topleft=(x, y))

        # Smaller collision box, centered on the sprite. Walls are
        # checked against this, not the (mostly transparent) image rect.
        self.hitbox = self.rect.inflate(
            self.hitbox_size - self.rect.width,
            self.hitbox_size - self.rect.height)

        # Sprites this unit cannot walk through. None -> no collision.
        self.obstacle_sprites = obstacle_sprites

        self.pos = pygame.math.Vector2(x, y)
        self.direction = pygame.math.Vector2()

        # Health / combat
        self.hp = self.max_hp
        self.invuln_timer = 0.0
        self.attack_timer = 0.0
        self.hit_flash_timer = 0.0
        # Boss telegraphs are driven by ``tint_color``: an (r,g,b,a) tuple
        # the base ``animate`` will additively overlay onto this frame's
        # opaque pixels (so transparent padding stays transparent).
        self.tint_color = None

        # Ability state — the player triggers their character's
        # signature ability on Shift; the base class owns only these
        # timers, each playable subclass owns the actual mechanic.
        # Enemies never reach the trigger path (projectile_group stays
        # None), so this stays inert for them.
        self.ability_timer = 0.0           # seconds left of active ability
        self.ability_cooldown_timer = 0.0  # seconds until it can re-fire
        self.ability_active = False
        self.speed_mult = 1.0              # >1 during a dash / sprint

        # Set by the level for the player only; enemies leave these None
        # unless the level wires them up (boss phase 2 needs them too).
        self.projectile_group = None
        self.projectile_targets = None

    def load_assets(self):
        path = f"assets/units/{self.asset_folder}/"

        def import_frames(name, frame_count):
            img_path = f"{path}{name}.png"
            try:
                sheet = pygame.image.load(img_path).convert_alpha()
            except (pygame.error, FileNotFoundError):
                print(f"{img_path} not found.")
                return []  # crash safety

            frames = []
            width = sheet.get_width() // frame_count
            height = sheet.get_height()

            for i in range(frame_count):
                rect = pygame.Rect(i * width, 0, width, height)
                surf = sheet.subsurface(rect)
                scaled = pygame.transform.scale(
                    surf, (width * self.scale, height * self.scale))
                frames.append(scaled)
            return frames

        self.animations = {
            status: import_frames(name, count)
            for status, (name, count) in self.SPRITE_SHEETS.items()
        }

        # Right-facing frames are mirrored left-facing frames.
        self.animations['idle_right'] = [
            pygame.transform.flip(img, True, False)
            for img in self.animations['idle_left']
        ]
        self.animations['walk_right'] = [
            pygame.transform.flip(img, True, False)
            for img in self.animations['walk_left']
        ]

    # --- input / movement -------------------------------------------

    def get_status(self):
        if self.direction.magnitude() != 0:
            if abs(self.direction.x) > abs(self.direction.y):
                self.facing = 'right' if self.direction.x > 0 else 'left'
            else:
                self.facing = 'down' if self.direction.y > 0 else 'up'
            self.status = f'walk_{self.facing}'
        else:
            self.status = f'idle_{self.facing}'

    def get_input(self):
        # Belt-and-braces for the focus-loss hitch: while the window is
        # unfocused SDL keeps reporting the last-held keys, so treat
        # "unfocused" as "no input" even if the pause event raced us.
        if not pygame.key.get_focused():
            self.direction.update(0, 0)
            return
        keys = pygame.key.get_pressed()
        right = keys[pygame.K_RIGHT] or keys[pygame.K_d]
        left = keys[pygame.K_LEFT] or keys[pygame.K_a]
        down = keys[pygame.K_DOWN] or keys[pygame.K_s]
        up = keys[pygame.K_UP] or keys[pygame.K_w]
        self.direction.x = int(right) - int(left)
        self.direction.y = int(down) - int(up)

        if self.direction.magnitude() != 0:
            self.direction = self.direction.normalize()

    def move(self, dt):
        speed = self.speed * self.speed_mult

        # Move and resolve collisions one axis at a time so the unit
        # slides along walls instead of getting stuck on them.
        self.pos.x += self.direction.x * speed * dt
        self.rect.x = round(self.pos.x)
        self.hitbox.centerx = self.rect.centerx
        self.collide('horizontal')

        self.pos.y += self.direction.y * speed * dt
        self.rect.y = round(self.pos.y)
        self.hitbox.centery = self.rect.centery
        self.collide('vertical')

    def collide(self, direction):
        if self.obstacle_sprites is None:
            return

        for sprite in self.obstacle_sprites:
            if not sprite.hitbox.colliderect(self.hitbox):
                continue

            if direction == 'horizontal':
                if self.direction.x > 0:        # moving right
                    self.hitbox.right = sprite.hitbox.left
                elif self.direction.x < 0:      # moving left
                    self.hitbox.left = sprite.hitbox.right
                self.rect.centerx = self.hitbox.centerx
                self.pos.x = self.rect.x
            else:
                if self.direction.y > 0:        # moving down
                    self.hitbox.bottom = sprite.hitbox.top
                elif self.direction.y < 0:      # moving up
                    self.hitbox.top = sprite.hitbox.bottom
                self.rect.centery = self.hitbox.centery
                self.pos.y = self.rect.y

    def _toward_target(self, min_len=0):
        """Unit vector from this unit to ``self.target`` (set by the AI
        subclasses — Boss/Enemy), or zero when closer than
        ``min_len``."""
        to = (pygame.math.Vector2(self.target.hitbox.center)
              - pygame.math.Vector2(self.hitbox.center))
        if to.length() > max(1, min_len):
            return to.normalize()
        return pygame.math.Vector2(0, 0)

    # --- damage / feedback ------------------------------------------

    def take_damage(self, amount):
        """Subtract HP. Each hit always lands (no i-frames here).

        Contact-spam protection for the player lives in the level, which
        gates repeated hits with ``invuln_timer`` — projectiles, by
        contrast, should damage the boss on every shot.
        """
        if self.hp <= 0:
            return False
        self.hp = max(0, self.hp - amount)
        # White flash on hit so the damage reads instantly even on the
        # boss's huge sprite.
        self.hit_flash_timer = HIT_FLASH_TIME
        audio.play("hit")
        return True

    # --- combat -----------------------------------------------------

    def current_attack_cooldown(self):
        """Seconds between shots. A hook so an ability (Elf's
        rapid-fire) can shorten the cadence for its active window."""
        return self.attack_cooldown

    def handle_attack(self, dt):
        """Player only: fire a projectile in the facing direction on
        Space or left-click.

        Aim follows the way the character is looking (the 4 facings) —
        there is deliberately no free mouse aim; the cursor doesn't
        steer shots.
        """
        if self.projectile_group is None:
            return

        self.attack_timer = max(0.0, self.attack_timer - dt)

        keys = pygame.key.get_pressed()
        mouse_left = (self.attack_mouse_enabled
                      and pygame.mouse.get_pressed()[0])
        if (keys[pygame.K_SPACE] or mouse_left) and self.attack_timer <= 0:
            aim = pygame.math.Vector2(*FACING_VECTORS[self.facing])
            spawn = pygame.math.Vector2(self.hitbox.center)
            spawn += aim * (self.hitbox_size / 2 + 8)
            Projectile(
                spawn, aim,
                self.obstacle_sprites, self.projectile_targets,
                [self.projectile_group],
                damage=self.attack_damage, owner='player')
            audio.play("shoot")
            self.attack_timer = self.current_attack_cooldown()

    def handle_ability(self, dt):
        """Shift triggers this character's signature ability.

        The base class owns only the timers; each playable subclass
        fills in :meth:`activate_ability` (and, where the ability needs
        per-frame work or teardown, :meth:`tick_ability` /
        :meth:`on_ability_end`). Only the player reaches the trigger
        path — enemies leave ``projectile_group`` None.
        """
        # Tick the active window first; the cooldown only counts once
        # the ability itself has ended.
        if self.ability_active:
            self.ability_timer = max(0.0, self.ability_timer - dt)
            if self.ability_timer == 0:
                self.ability_active = False
                self.ability_cooldown_timer = self.ABILITY_COOLDOWN
                self.on_ability_end()
            else:
                self.tick_ability(dt)
        elif self.ability_cooldown_timer > 0:
            self.ability_cooldown_timer = max(
                0.0, self.ability_cooldown_timer - dt)

        if self.projectile_group is None:
            return  # only the player triggers abilities via input

        keys = pygame.key.get_pressed()
        shift = keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]
        if (shift and not self.ability_active
                and self.ability_cooldown_timer == 0):
            self.ability_active = True
            self.ability_timer = self.ABILITY_DURATION
            self.activate_ability()

    # Subclass hooks — no-ops on the base so Enemy / Boss stay inert
    # and a character with no per-frame work only overrides what it
    # actually needs.
    def activate_ability(self):
        """Run once the instant the ability fires."""

    def tick_ability(self, dt):
        """Run every frame the ability is active (not its final frame)."""

    def on_ability_end(self):
        """Run once when the active window runs out."""

    # --- animation / tick -------------------------------------------

    def _placeholder_frame(self):
        """Visible stand-in when a sprite sheet is missing/renamed.

        ``load_assets`` returns ``[]`` for an absent PNG ('crash
        safety'); this keeps that promise so one bad asset degrades to a
        loud magenta box instead of an ``IndexError`` that kills the
        whole run. Magenta matches the editor's missing-art marker.
        """
        s = max(self.hitbox_size, 16)
        surf = pygame.Surface((s, s), pygame.SRCALPHA)
        surf.fill((180, 60, 180, 120))
        pygame.draw.rect(surf, (180, 60, 180), surf.get_rect(), 2)
        return surf

    def _apply_overlay(self, frame, color_rgba):
        """Additive RGB overlay that respects the sprite's alpha mask.

        ``BLEND_RGB_ADD`` adds the overlay's RGB to opaque destination
        pixels without touching the destination alpha, so a hit flash on
        a transparent-padded sprite stays a flash on the *character* and
        doesn't fill the bounding box with white.
        """
        if frame.get_flags() & pygame.SRCALPHA == 0:
            frame = frame.copy()
        else:
            frame = frame.copy()
        intensity = pygame.Surface(frame.get_size(), pygame.SRCALPHA)
        intensity.fill(color_rgba)
        frame.blit(intensity, (0, 0),
                   special_flags=pygame.BLEND_RGB_ADD)
        return frame

    def animate(self, dt):
        current_animation = self.animations[self.status]

        if not current_animation:
            return

        self.frame_index += self.animation_speed * dt
        self.frame_index %= len(current_animation)
        frame = current_animation[int(self.frame_index)]

        # Flicker while invulnerable so hits read clearly — but not
        # while an ability is active: Penguin's shield shows an aura
        # instead, and a 0.18 s dash flicker barely registers anyway.
        if (self.invuln_timer > 0 and not self.ability_active
                and int(self.invuln_timer * 20) % 2 == 0):
            frame = frame.copy()
            frame.set_alpha(90)
        # Boss telegraph tint (red windup / gold aim) — applied before
        # the brighter hit flash so a hit during windup still pops.
        if self.tint_color is not None:
            frame = self._apply_overlay(frame, self.tint_color)
        if self.hit_flash_timer > 0:
            v = int(180 * (self.hit_flash_timer / HIT_FLASH_TIME))
            frame = self._apply_overlay(frame, (v, v, v, 255))
        self.image = frame

    def update(self, dt):
        if self.invuln_timer > 0:
            self.invuln_timer -= dt
        if self.hit_flash_timer > 0:
            self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)

        self.get_input()
        self.handle_ability(dt)
        self.get_status()
        self.move(dt)
        self.animate(dt)
        self.handle_attack(dt)


# --- player-character variants -----------------------------------------
# Each character is mechanically distinct (not just a skin).
# The blurbs in CHARACTER_INFO are what the menu shows; keep them in
# sync if you tune the numbers.

class Wizard(Character):
    """Balanced reference character.

    Signature — *Slow*: bends time for every enemy, the boss and their
    in-flight shots while the Wizard keeps moving and firing at full
    speed. The slow itself lives in ``LevelManager.update``, which reads
    ``ability_active``; this class only triggers it.
    """
    asset_folder = "wizard"
    # defaults: HP 100, spd 600, cd 0.35, dmg 10

    ABILITY_NAME = "SLOW"
    ABILITY_DESC = "Slows enemies and their shots for 3s"
    ABILITY_GLYPH = "slow"
    ABILITY_DURATION = 3.0
    ABILITY_COOLDOWN = 12.0

    def activate_ability(self):
        audio.play("slow")


class Penguin(Character):
    """Tank — slower but takes more punishment and hits a bit harder.

    Signature — *Shield*: total damage immunity for a short window.
    Implemented by keeping ``invuln_timer`` topped up, so every damage
    path (boss / enemy contact, spikes, projectiles — all gated on
    ``invuln_timer``) is blocked for free.
    """
    asset_folder = "penguin"
    max_hp = 140
    speed = 480
    attack_damage = 12
    attack_cooldown = 0.45

    ABILITY_NAME = "SHIELD"
    ABILITY_DESC = "Immune to all damage for 2.5s"
    ABILITY_GLYPH = "shield"
    ABILITY_DURATION = 2.5
    ABILITY_COOLDOWN = 11.0

    def activate_ability(self):
        audio.play("shield")

    def tick_ability(self, dt):
        # A hit drops invuln_timer to PLAYER_INVULN_TIME, which can be
        # shorter than the shield still has to run — so re-arm a sliver
        # of i-frames every frame the shield is up.
        self.invuln_timer = max(self.invuln_timer, 0.2)


class Elf(Character):
    """Archer — rapid-fire, lower damage per shot, fragile.

    Signature — *Volley*: doubles the fire rate for a short window by
    halving the effective attack cooldown.
    """
    asset_folder = "elf"
    max_hp = 90
    speed = 580
    attack_damage = 7
    attack_cooldown = 0.20

    ABILITY_NAME = "VOLLEY"
    ABILITY_DESC = "Doubles fire rate for 2s"
    ABILITY_GLYPH = "rapid"
    ABILITY_DURATION = 2.0
    ABILITY_COOLDOWN = 9.0

    def activate_ability(self):
        audio.play("volley")

    def current_attack_cooldown(self):
        if self.ability_active:
            return self.attack_cooldown * 0.5
        return self.attack_cooldown


class Shiggy(Character):
    """Glass cannon — biggest hit, smallest health pool.

    Signature — *Dash*: a short, i-framed speed burst. Direction is
    locked at dash start (current input dir, falling back to facing) so
    a panic dash always commits somewhere sensible. This is the dash
    every character shared before v0.5.0 — now Shiggy's alone.
    """
    asset_folder = "shiggy"
    max_hp = 70
    speed = 620
    attack_damage = 20
    attack_cooldown = 0.40

    ABILITY_NAME = "DASH"
    ABILITY_DESC = "Quick i-framed burst dash"
    ABILITY_GLYPH = "dash"
    ABILITY_DURATION = DASH_DURATION
    ABILITY_COOLDOWN = DASH_COOLDOWN

    def activate_ability(self):
        d = self.direction if self.direction.magnitude() != 0 \
            else pygame.math.Vector2(*FACING_VECTORS[self.facing])
        self._dash_dir = d.normalize()
        self.direction.update(self._dash_dir)
        self.speed_mult = DASH_SPEED_MULT
        self.invuln_timer = max(self.invuln_timer, DASH_DURATION)
        audio.play("dash")

    def tick_ability(self, dt):
        # Lock direction during the dash so mid-burst input can't
        # change course.
        self.direction.update(self._dash_dir)

    def on_ability_end(self):
        self.speed_mult = 1.0
        # A whisker of i-frames after landing helps cross contact-damage
        # windows with frame-perfect dashes.
        self.invuln_timer = max(self.invuln_timer, DASH_INVULN_BONUS)


class Wolf(Character):
    """Scout — very fast, modest combat stats.

    Signature — *Sprint*: a burst of peak movement speed. Unlike
    Shiggy's dash it grants no i-frames and never locks the steering —
    full directional control the whole time.
    """
    asset_folder = "wolf"
    max_hp = 85
    speed = 760
    attack_damage = 9
    attack_cooldown = 0.40

    ABILITY_NAME = "SPRINT"
    ABILITY_DESC = "Peak movement speed for 1.5s"
    ABILITY_GLYPH = "sprint"
    ABILITY_DURATION = 1.5
    ABILITY_COOLDOWN = 8.0
    SPRINT_SPEED_MULT = 2.0

    def activate_ability(self):
        self.speed_mult = self.SPRINT_SPEED_MULT
        audio.play("sprint")

    def on_ability_end(self):
        self.speed_mult = 1.0


# Catalogue used by the character menu to display stats and by
# levels.py to instantiate the chosen class. Order = menu order.
CHARACTER_INFO = [
    ("c_wiz",  Wizard,  "Wizard",  "Balanced"),
    ("c_peng", Penguin, "Penguin", "Tank"),
    ("c_elf",  Elf,     "Elf",     "Rapid-fire"),
    ("c_shig", Shiggy,  "Shiggy",  "Glass cannon"),
    ("c_wolf", Wolf,    "Wolf",    "Speedster"),
]


# Pool of "generals" the level picks one of as its boss. Mechanics are
# identical (one Boss class) — only the name, sprite folder and a
# subtle identity overlay change so each level feels distinct. The
# fourth tuple element is an optional RGBA tint baked into every loaded
# frame once at boss init, so the telegraph (red/gold) still sits on
# top during attack windups without fighting a per-frame identity
# overlay.
BOSS_ROSTER = [
    ("Mr. Green",  "mrgreen", None),
    ("Mr. Orange", "orange",  (180, 90, 30, 70)),
    ("Gen. Frost", "penguin", (60, 120, 210, 90)),
    ("The Archer", "elf",     (70, 180, 90, 90)),
    ("Mr. Shadow", "shiggy",  (90, 40, 140, 90)),
]


# --- boss --------------------------------------------------------------

class Boss(Character):
    """AI enemy with a small state-machine fighting style.

    Phase 1 (HP above ``BOSS_PHASE2_HP_RATIO``): chase, then telegraphed
    dash attack. Phase 2 (below): also fires a 3-shot spread between
    chases. Each new state picks itself the moment the previous timer
    runs out, so the rhythm is readable and you can dodge by pattern.
    """

    asset_folder = "mrgreen"
    scale = BOSS_SCALE
    speed = BOSS_SPEED
    hitbox_size = BOSS_HITBOX_SIZE
    max_hp = BOSS_MAX_HP

    def __init__(self, x, y, obstacle_sprites=None, target=None,
                 projectile_group=None, projectile_targets=None,
                 *, display_name="Mr. Green", asset_folder=None,
                 identity_tint=None):
        # Set instance asset_folder + identity_tint BEFORE super so
        # Character.load_assets reads the chosen sprite folder and the
        # overridden load_assets below bakes the tint in.
        if asset_folder is not None:
            self.asset_folder = asset_folder
        self.identity_tint = identity_tint
        self.display_name = display_name
        super().__init__(x, y, obstacle_sprites)
        self.target = target
        # The level wires these so phase 2 can spawn boss projectiles
        # that hurt only the player (not other enemies / the boss itself).
        self.projectile_group = projectile_group
        self.projectile_targets = projectile_targets

        self.state = 'chase'
        self.state_timer = random.uniform(
            BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
        self.dash_dir = pygame.math.Vector2(0, 1)

    def load_assets(self):
        # Bake identity_tint into every frame once at init so the
        # telegraph tint (red/gold during windup/aim) still overlays
        # cleanly on top without a per-frame swap that pops between
        # neutral and tinted between attacks.
        super().load_assets()
        if self.identity_tint is None:
            return
        for status, frames in self.animations.items():
            self.animations[status] = [
                self._apply_overlay(f, self.identity_tint) for f in frames
            ]

    def get_input(self):
        if self.target is None or self.target.hp <= 0 or self.hp <= 0:
            self.direction.update(0, 0)
            return

        if self.state == 'chase':
            self.direction = self._toward_target(min_len=6)
        elif self.state == 'dash':
            self.direction.update(self.dash_dir)
        else:  # windup / aim / recover / shoot — hold still
            self.direction.update(0, 0)

    def handle_attack(self, dt):
        # Boss doesn't fire via input; ranged volleys are scheduled by
        # the state machine in :meth:`update`.
        return

    def handle_ability(self, dt):
        # Boss has no signature ability — its dash is an FSM state.
        return

    def _in_phase2(self):
        return self.hp <= self.max_hp * BOSS_PHASE2_HP_RATIO

    def _enter(self, state):
        self.state = state
        if state == 'chase':
            self.state_timer = random.uniform(
                BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
            self.speed_mult = 1.0
        elif state == 'windup':
            # Lock in the dash direction at the *start* of the windup so
            # the player has the full telegraph to read it.
            self.dash_dir = self._toward_target()
            if self.dash_dir.length() == 0:
                self.dash_dir.update(0, 1)
            self.state_timer = BOSS_WINDUP_TIME
            self.speed_mult = 0.0
        elif state == 'dash':
            self.state_timer = BOSS_DASH_TIME
            self.speed_mult = BOSS_DASH_SPEED_MULT
        elif state == 'recover':
            self.state_timer = BOSS_RECOVER_TIME
            self.speed_mult = 0.0
        elif state == 'aim':
            self.state_timer = BOSS_AIM_TIME
            self.speed_mult = 0.0
        elif state == 'shoot':
            # Shoot has zero duration: we fire on the next FSM tick and
            # immediately transition to recover.
            self.state_timer = 0.0
            self.speed_mult = 0.0

    def _fire_volley(self):
        """3-shot spread aimed at the player. Reuses :class:`Projectile`."""
        if (self.target is None or self.projectile_group is None
                or self.projectile_targets is None):
            return
        base = self._toward_target()
        if base.length() == 0:
            return
        spawn = pygame.math.Vector2(self.hitbox.center)
        for deg in (-12, 0, 12):
            v = base.rotate(deg)
            Projectile(
                spawn + v * (self.hitbox_size / 2 + 12), v,
                self.obstacle_sprites, self.projectile_targets,
                [self.projectile_group],
                damage=BOSS_PROJECTILE_DAMAGE,
                speed=BOSS_PROJECTILE_SPEED,
                color=(255, 130, 70), owner='enemy')

    def _update_tint(self):
        """Red ramp during windup, gold ramp during aim — the colour
        cue tells the player which attack is coming."""
        if self.state == 'windup':
            ramp = 1.0 - (self.state_timer / BOSS_WINDUP_TIME)
            ramp = max(0.0, min(1.0, ramp))
            self.tint_color = (140, 30, 30, int(40 + 80 * ramp))
        elif self.state == 'aim':
            ramp = 1.0 - (self.state_timer / BOSS_AIM_TIME)
            ramp = max(0.0, min(1.0, ramp))
            self.tint_color = (140, 110, 30, int(40 + 80 * ramp))
        else:
            self.tint_color = None

    def update(self, dt):
        # Tick the FSM first so this frame's direction / speed reflect
        # the current state.
        if self.hp > 0 and self.target is not None and self.target.hp > 0:
            self.state_timer = max(0.0, self.state_timer - dt)
            if self.state_timer == 0.0:
                self._advance_state()
        self._update_tint()

        if self.invuln_timer > 0:
            self.invuln_timer -= dt
        if self.hit_flash_timer > 0:
            self.hit_flash_timer = max(0.0, self.hit_flash_timer - dt)

        self.get_input()
        self.get_status()
        prev = self.rect.topleft
        self.move(dt)

        # A dash that slammed into a wall ends early — scraping along
        # the wall for the rest of the dash feels broken.
        if self.state == 'dash' and self.rect.topleft == prev:
            self._enter('recover')

        self.animate(dt)
        # Boss has no handle_attack — see _fire_volley instead.

    def _advance_state(self):
        """Pick the next state. The boss alternates chase with one of
        its attacks; phase 2 unlocks ranged volleys."""
        if self.state == 'chase':
            # In phase 2, ~50% of the attacks are ranged volleys.
            if self._in_phase2() and random.random() < 0.5:
                self._enter('aim')
            else:
                self._enter('windup')
        elif self.state == 'windup':
            self._enter('dash')
        elif self.state == 'dash':
            self._enter('recover')
        elif self.state == 'recover':
            self._enter('chase')
        elif self.state == 'aim':
            self._enter('shoot')
        elif self.state == 'shoot':
            self._fire_volley()
            self._enter('recover')


# --- enemies -----------------------------------------------------------
# Generic placeable threats (a token char in the level text), as
# opposed to the single scripted Boss. Adding one is a class + a line
# in ENEMY_INFO + a sprite folder; tiles.REGISTRY and the editor
# palette pick it up from ENEMY_INFO automatically.

class Enemy(Character):
    """Plain chaser: walks straight at the player and relies on contact
    damage (applied by the level, like the boss touch).

    No FSM, no ranged attack, no dash — unlike :class:`Boss` it can be
    dropped anywhere and spawns immediately rather than lazily, and it
    does **not** gate the exit (only the boss/key do)."""

    scale = 6
    speed = 300
    max_hp = 45
    hitbox_size = 70
    touch_damage = 12

    def __init__(self, x, y, obstacle_sprites=None, target=None):
        super().__init__(x, y, obstacle_sprites)
        self.target = target

    def get_input(self):
        if self.target is None or self.target.hp <= 0 or self.hp <= 0:
            self.direction.update(0, 0)
            return
        self.direction = self._toward_target(min_len=4)

    def handle_attack(self, dt):
        return  # contact-only

    def handle_ability(self, dt):
        return  # no signature ability


class Orange(Enemy):
    """The orange blob — the basic roaming enemy."""
    asset_folder = "orange"


# Catalogue parallel to CHARACTER_INFO: (level token char, class,
# label). One line here + a sprite folder = a new placeable enemy.
ENEMY_INFO = [
    ("N", Orange, "Chaser"),
]


# --- projectile --------------------------------------------------------

class Projectile(pygame.sprite.Sprite):
    """A simple orb that flies straight, hurts its targets and dies on walls.

    The colour and speed can be customised so the boss's volley reads
    visually different from the player's shots. ``owner`` ('player' /
    'enemy') tags which side fired it, so the Wizard's slow-time can
    spare player shots while slowing enemy ones.
    """

    def __init__(self, pos, direction, obstacle_sprites, targets, groups,
                 damage=PROJECTILE_DAMAGE, color=(120, 230, 255),
                 speed=PROJECTILE_SPEED, owner='enemy'):
        super().__init__(groups)
        self.obstacle_sprites = obstacle_sprites
        self.targets = targets
        self.damage = damage
        self.speed = speed
        self.owner = owner

        r = PROJECTILE_RADIUS
        size = (r + 4) * 2
        self.image = pygame.Surface((size, size), pygame.SRCALPHA)
        # Soft outer halo.
        for i in range(3):
            pygame.draw.circle(
                self.image, (*color, 70 - i * 20),
                (size // 2, size // 2), r + 3 - i)
        pygame.draw.circle(self.image, color,
                           (size // 2, size // 2), r)
        pygame.draw.circle(self.image, (255, 255, 255),
                           (size // 2, size // 2), r // 2)
        self.rect = self.image.get_rect(center=(int(pos.x), int(pos.y)))
        # Smaller hitbox than the visual halo so glancing shots feel fair.
        self.hitbox = self.rect.inflate(-8, -8)

        self.pos = pygame.math.Vector2(pos)
        self.direction = pygame.math.Vector2(direction)
        if self.direction.magnitude() != 0:
            self.direction = self.direction.normalize()
        self.life = PROJECTILE_LIFETIME

    def update(self, dt):
        self.life -= dt
        if self.life <= 0:
            self.kill()
            return

        # Substep so a fast shot can't tunnel a thin wall or a small
        # target in one tick: the per-tick move at 950 px/s under the
        # dt cap can exceed two projectile hitboxes.
        distance = self.speed * dt
        max_step = max(1.0, min(self.hitbox.width, self.hitbox.height) * 0.5)
        steps = 1 if distance <= max_step else int(distance / max_step) + 1
        step_vec = self.direction * (distance / steps)

        for _ in range(steps):
            self.pos += step_vec
            self.rect.center = (round(self.pos.x), round(self.pos.y))
            self.hitbox.center = self.rect.center

            # Targets first: a shot at an enemy pressed flush against a
            # wall is still credited before the wall kills the orb.
            if self.targets is not None:
                for target in self.targets:
                    if target.hitbox.colliderect(self.hitbox):
                        # Honour i-frames so a single tick of overlap
                        # from a spread shot can't burn the player's
                        # whole bar.
                        if getattr(target, 'invuln_timer', 0) > 0:
                            continue
                        target.take_damage(self.damage)
                        self.kill()
                        return

            if self.obstacle_sprites is not None:
                for wall in self.obstacle_sprites:
                    if wall.hitbox.colliderect(self.hitbox):
                        self.kill()
                        return