# Pyxel Studio


"""
Mystery of Delm Hez -  a Pyxel (Pyxel Studio) vertical slice
==================================================================
Genre : Top-down adventure RPG     Resolution : 512 x 512     Engine : Pyxel 2.x


  * Title screen
  * Character select - the four heroes, each with the GDD's gameplay bonuses
  * Act 1  : Frosthaven - free-roam the town and find the informant yourself
  * Act 1  : The North Road - a travel scene before the Frozen Plains ambush
  * Act 1  : Frozen Plains - tutorial combat ambush vs Ice Elementals
  * Act 2  : Forgotten Prison gate - guards, locked doors, and the Hag
  * Act 2  : Forgotten Prison - the runestone puzzle (Life -> Balance -> Death,
             stones appear in a shuffled physical arrangement each run)
  * Act 5  : Heart of Winter - Auril boss fight: telegraphed AOE attacks,
             destructible Frost Shard weak points, and a phase-3 shockwave
             push that punishes heroes who stay in close
  * Endings: "Winter Has Ended" (good) / "The Winter Continues" (bad)

HOW TO RUN
  pip install pyxel          (needs Pyxel 2.x and Python 3.8+)
  python rime_of_the_frost_maiden.py

CONTROLS
  Arrow keys / WASD ... move
  Z or SPACE .......... attack / talk / examine / confirm / advance dialogue
  X or C .............. use Healing Herb
  ENTER ............... start / select
  R ................... retry after a defeat
  Q / ESC .............. quit

EXPLORATION
  Frosthaven, the North Road, and the Forgotten Prison gate are free-roam
  scenes - nothing is forced on you. Walk up to a person or point of
  interest (marked with a floating ! or ?) and press Z to talk or examine.
  A bouncing ! means that NPC carries plot-critical information and must be
  spoken to before you can continue north; a dim ? is optional flavour text.
  Rocks, ice formations, crates, doors and rubble block your path in every
  level, so you'll need to route around them.

BOSS FIGHT NOTES (Auril)
  Auril winds up one clearly telegraphed attack at a time (a flashing line,
  an expanding ring with a marked safe gap, a beam, or a falling-ice
  barrage) - watch the warning, then move. While she isn't mid-telegraph,
  Frost Shards occasionally crystallize in the arena; break them quickly
  for bonus damage AND a stagger window (double damage taken, her attacks
  paused). In her final phase her Frost Nova erupts into a shockwave: if it
  catches you, on top of the damage it shoves you back ~80px, undoing the
  ground melee heroes fought to close - ranged heroes, who rarely stand
  that close anyway, barely notice it.
"""

import math
import random
import pyxel

# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
W, H = 512, 512

# Game states
(S_TITLE, S_SELECT, S_TOWN, S_STORY, S_TRAVEL, S_PLAINS, S_PRISON, S_PUZZLE,
 S_BOSS, S_WIN, S_LOSE) = range(11)

# Pyxel default 16-colour palette indices we lean on (winter theme)
BLACK, NAVY, PURPLE, DGREEN, BROWN, SLATE, LBLUE, WHITE = 0, 1, 2, 3, 4, 5, 6, 7
RED, ORANGE, YELLOW, GREEN, CYAN, INDIGO, PINK, PEACH = 8, 9, 10, 11, 12, 13, 14, 15

# Rough "shade darker" lookup so flat shapes can get a cheap second tone
# without needing external sprite art.
DARK_MAP = {
    GREEN: DGREEN, CYAN: NAVY, ORANGE: BROWN, YELLOW: ORANGE,
    LBLUE: NAVY, PURPLE: NAVY, INDIGO: NAVY, PINK: PURPLE,
    WHITE: SLATE, SLATE: NAVY, RED: PURPLE, PEACH: ORANGE, BROWN: NAVY,
}

# The four playable heroes, with mechanical versions of their GDD bonuses.
CHARACTERS = [
    {
        "name": "Eira", "title": "The Ranger", "col": GREEN,
        "max_hp": 130, "speed": 3.5, "atk": "ranged", "dmg": 10,
        "undead_mult": 1.0, "mag_res": 1.0, "heal": 44, "hint": False,
        "bonus": "Faster movement, stronger ranged shots.",
    },
    {
        "name": "Aldric", "title": "The Scholar", "col": CYAN,
        "max_hp": 100, "speed": 2.8, "atk": "ranged", "dmg": 16,
        "undead_mult": 1.0, "mag_res": 0.6, "heal": 33, "hint": True,
        "bonus": "Puzzle hints, strong magic resistance.",
    },
    {
        "name": "Rowan", "title": "The Mercenary", "col": ORANGE,
        "max_hp": 300, "speed": 3.3, "atk": "melee", "dmg": 28,
        "undead_mult": 1.0, "mag_res": 1.0, "heal": 100, "hint": False,
        "bonus": "Increased melee damage, higher health.",
    },
    {
        "name": "Seraphine", "title": "The Cleric", "col": YELLOW,
        "max_hp": 400, "speed": 3.0, "atk": "melee", "dmg": 20,
        "undead_mult": 1.8, "mag_res": 1.0, "heal": 400, "hint": False,
        "bonus": "Healing efficiency, bonus vs. undead.",
    },
]


# ----------------------------------------------------------------------------
# Small helpers
# ----------------------------------------------------------------------------
def clamp(v, lo, hi):
    return lo if v < lo else hi if v > hi else v


def dist(ax, ay, bx, by):
    return math.hypot(ax - bx, ay - by)


def dark(col):
    """Cheap darker-shade lookup used to fake a second tone on flat shapes."""
    return DARK_MAP.get(col, BLACK)


def wrap_text(text, max_chars):
    """Word-wrap a string into a list of lines (Pyxel has no text wrapping)."""
    words, lines, cur = text.split(" "), [], ""
    for w in words:
        if len(cur) + len(w) + 1 <= max_chars:
            cur = (cur + " " + w).strip()
        else:
            lines.append(cur)
            cur = w
    if cur:
        lines.append(cur)
    return lines


def draw_text_center(y, s, col, shadow=True):
    x = W // 2 - len(s) * 2
    if shadow:
        pyxel.text(x + 1, y + 1, s, BLACK)
    pyxel.text(x, y, s, col)


def draw_text_center_at(cx, y, s, col):
    pyxel.text(cx - len(s) * 2, y, s, col)


def draw_big(x, y, s, col):
    """Fake a larger font by drawing the same text in a 2x block pattern."""
    for dx in (0, 1):
        for dy in (0, 1):
            pyxel.text(x + dx, y + dy, s, col)


# ----------------------------------------------------------------------------
# Entities
# ----------------------------------------------------------------------------
class Projectile:
    def __init__(self, x, y, vx, vy, dmg, col, friendly):
        self.x, self.y = x, y
        self.vx, self.vy = vx, vy
        self.dmg = dmg
        self.col = col
        self.friendly = friendly
        self.life = 90
        self.r = 3

    def update(self):
        self.x += self.vx
        self.y += self.vy
        self.life -= 1

    def draw(self):
        pyxel.circ(int(self.x), int(self.y), self.r, self.col)
        pyxel.pset(int(self.x), int(self.y), WHITE)

    @property
    def dead(self):
        return self.life <= 0 or not (-10 < self.x < W + 10 and -10 < self.y < H + 10)


class Enemy:
    def __init__(self, x, y, kind="elemental"):
        self.x, self.y = x, y
        self.kind = kind
        self.r = 10
        self.flash = 0
        self.fire_cd = random.randint(40, 90)
        if kind == "elemental":
            self.hp = self.max_hp = 40
            self.speed = 1.0
            self.col = CYAN
            self.touch = 8
            self.undead = False
        if kind == "Lord elemental":
            self.hp = self.max_hp = 80
            self.speed = 1.5
            self.col = NAVY
            self.touch = 14
            self.undead = False
        elif kind == "spectral":
            self.hp = self.max_hp = 30
            self.speed = 1.4
            self.col = LBLUE
            self.touch = 10
            self.undead = True
        else:  # add-on elemental summoned by boss
            self.hp = self.max_hp = 25
            self.speed = 1.2
            self.col = INDIGO
            self.touch = 8
            self.undead = False

    def update(self, px, py, projectiles):
        if self.flash > 0:
            self.flash -= 1
        d = dist(self.x, self.y, px, py)
        if d > 1:
            self.x += (px - self.x) / d * self.speed
            self.y += (py - self.y) / d * self.speed
        # Ranged ice bolt
        self.fire_cd -= 1
        if self.fire_cd <= 0 and d < 260 and self.kind == "elemental":
            self.fire_cd = random.randint(70, 120)
            if d > 1:
                vx, vy = (px - self.x) / d * 3.0, (py - self.y) / d * 3.0
                projectiles.append(Projectile(self.x, self.y, vx, vy, 6, LBLUE, False))

    def draw(self):
        t = pyxel.frame_count
        x, y = int(self.x), int(self.y)
        flashing = self.flash > 0
        if self.kind in ("elemental", "add"):
            base = WHITE if flashing else self.col
            core = WHITE if flashing else (CYAN if self.kind == "elemental" else PINK)
            spin = t * 0.05 + (id(self) % 100) * 0.1
            # crystalline diamond body, two-toned for a faceted look
            pyxel.tri(x, y - self.r, x - self.r, y, x, y + self.r, base)
            pyxel.tri(x, y - self.r, x + self.r, y, x, y + self.r, dark(base))
            pyxel.circ(x, y, max(2, self.r - 6), core)
            # jagged ice spikes radiating outward, slowly rotating
            for k in range(4):
                a = spin + k * math.tau / 4
                ix0 = x + math.cos(a) * self.r * 0.5
                iy0 = y + math.sin(a) * self.r * 0.5
                ix1 = x + math.cos(a) * (self.r + 4)
                iy1 = y + math.sin(a) * (self.r + 4)
                pyxel.line(ix0, iy0, ix1, iy1, base)
            # one orbiting ice mote
            oa = spin * 2
            ox = x + math.cos(oa) * (self.r + 7)
            oy = y + math.sin(oa) * (self.r + 7)
            pyxel.pset(int(ox), int(oy), WHITE)
        else:  # spectral wraith
            base = WHITE if flashing else self.col
            sway = math.sin(t * 0.1 + x) * 3
            pyxel.tri(x - self.r, y - 4, x + self.r, y - 4, x + sway, y + self.r + 6, base)
            pyxel.circ(x, y - 6, self.r - 3, base)
            pyxel.pset(x - 3, y - 7, BLACK)
            pyxel.pset(x + 3, y - 7, BLACK)
            # dithered trailing wisps for a ghostly, semi-transparent feel
            for k in range(3):
                tx = x - self.r + k * self.r
                for j in range(3):
                    if (t // 4 + j + k) % 2 == 0:
                        pyxel.pset(tx, y + self.r + j * 3, base)
        # hp bar
        w = 22
        pyxel.rect(x - w // 2, y - self.r - 6, w, 2, RED)
        pyxel.rect(x - w // 2, y - self.r - 6, int(w * self.hp / self.max_hp), 2, GREEN)


class WeakPoint:
    """A 'Frost Shard' that crystallizes near Auril. Destroying it quickly
    deals bonus damage to the boss and staggers her - the counter-play to
    her telegraphed attacks."""

    SPAWN_TELEGRAPH = 25   # frames of "forming" before it can be damaged
    LIFETIME = 180         # active frames before it fades away unused

    def __init__(self, x, y):
        self.x, self.y = x, y
        self.r = 9
        self.hp = self.max_hp = 18
        self.undead = False
        self.spawn_telegraph = self.SPAWN_TELEGRAPH
        self.life = self.LIFETIME
        self.flash = 0

    def update(self):
        if self.spawn_telegraph > 0:
            self.spawn_telegraph -= 1
        else:
            self.life -= 1
        if self.flash > 0:
            self.flash -= 1

    @property
    def active(self):
        return self.spawn_telegraph <= 0

    @property
    def dead(self):
        return self.hp <= 0 or (self.spawn_telegraph <= 0 and self.life <= 0)

    def draw(self):
        x, y = int(self.x), int(self.y)
        if self.spawn_telegraph > 0:
            col = YELLOW if (pyxel.frame_count // 4) % 2 == 0 else ORANGE
            pyxel.circb(x, y, 14, col)
            pyxel.circb(x, y, 8, col)
            return
        c = WHITE if self.flash > 0 else PINK
        for k in range(6):
            a = pyxel.frame_count * 0.08 + k * math.tau / 6
            sx = x + math.cos(a) * self.r
            sy = y + math.sin(a) * self.r
            pyxel.pset(int(sx), int(sy), CYAN)
        pyxel.circ(x, y, self.r - 3, c)
        pyxel.circb(x, y, self.r - 3, CYAN)
        # shrinking countdown ring shows how long it'll stay breakable
        ring = max(self.r, int(14 * self.life / self.LIFETIME))
        pyxel.circb(x, y, ring, SLATE)
        w = 20
        pyxel.rect(x - w // 2, y - self.r - 8, w, 2, BLACK)
        pyxel.rect(x - w // 2, y - self.r - 8, int(w * self.hp / self.max_hp), 2, PINK)


# ----------------------------------------------------------------------------
# Main application
# ----------------------------------------------------------------------------
class Game:
    def __init__(self):
        pyxel.init(W, H, title="Mystery of Delm Hez", fps=60)
        self.snow = [[random.uniform(0, W), random.uniform(0, H),
                      random.uniform(0.4, 1.6)] for _ in range(120)]
        self.reset_full()
        pyxel.run(self.update, self.draw)

    # ---- global reset ----------------------------------------------------
    def reset_full(self):
        self.state = S_TITLE
        self.char = None
        self.dialogue = []
        self.dlg_i = 0
        self.next_state = None
        self.npcs = []
        self.obstacles = []
        self.active_dialogue = None
        self.ground_deco = []

    # ---- start a chosen hero --------------------------------------------
    def start_run(self, idx):
        self.char = dict(CHARACTERS[idx])
        self.hp = self.char["max_hp"]
        self.herbs = 3
        self.init_town()
        self.state = S_TOWN

    # ---- forced narrative cutscenes (used sparingly, e.g. before a boss) -
    def begin_dialogue(self, lines, after_state):
        self.dialogue = lines
        self.dlg_i = 0
        self.next_state = after_state
        self.state = S_STORY

    # ---- NPC conversation + free-roam helpers ----------------------------
    def open_npc_dialogue(self, npc):
        lines = npc["lines"]
        if npc["talked"] and npc.get("repeat_lines"):
            lines = npc["repeat_lines"]
        self.active_dialogue = {"lines": lines, "i": 0}
        npc["talked"] = True

    def update_active_dialogue(self):
        if pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_SPACE) \
                or pyxel.btnp(pyxel.KEY_RETURN):
            d = self.active_dialogue
            d["i"] += 1
            if d["i"] >= len(d["lines"]):
                self.active_dialogue = None

    def handle_herb_input(self):
        c = self.char
        if (pyxel.btnp(pyxel.KEY_X) or pyxel.btnp(pyxel.KEY_C)) and self.herbs > 0 \
                and self.hp < c["max_hp"]:
            self.herbs -= 1
            self.hp = min(c["max_hp"], self.hp + c["heal"])

    def update_npc_scene(self, npcs, exit_y, on_exit, locked_msg):
        """Shared free-roam + talk-to-NPC + gated-exit logic used by the
        Town, North Road and Prison-gate scenes."""
        if self.location_caption:
            self.location_caption["timer"] -= 1
            if self.location_caption["timer"] <= 0:
                self.location_caption = None
        if self.exit_blocked_timer > 0:
            self.exit_blocked_timer -= 1

        if self.active_dialogue:
            self.update_active_dialogue()
            return

        self.player_move()
        self.handle_herb_input()

        if pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_SPACE):
            for npc in npcs:
                if dist(self.px, self.py, npc["x"], npc["y"]) < 28:
                    self.open_npc_dialogue(npc)
                    return

        if self.py < exit_y:
            missing = [n for n in npcs if n.get("required") and not n["talked"]]
            if missing:
                self.py = exit_y
                self.exit_blocked_msg = locked_msg
                self.exit_blocked_timer = 110
            else:
                on_exit()

    def resolve_obstacles(self):
        """Push the player out of any terrain obstacle or NPC they've
        walked into - simple circle-vs-circle collision."""
        pr = self.char_r()
        colliders = list(self.obstacles) + list(self.npcs)
        for ob in colliders:
            r = ob.get("r", 8)
            d = dist(self.px, self.py, ob["x"], ob["y"])
            min_d = r + pr
            if 0 < d < min_d:
                push = min_d - d
                nx, ny = (self.px - ob["x"]) / d, (self.py - ob["y"]) / d
                self.px = clamp(self.px + nx * push, 16, W - 16)
                self.py = clamp(self.py + ny * push, 70, H - 16)

    # ---- scene initialisers ---------------------------------------------
    def init_town(self):
        self.px, self.py = W // 2, H - 70
        self.facing = (0, -1)
        self.moving = False
        self.swing = 0
        self.npcs = []
        self.active_dialogue = None
        self.exit_blocked_timer = 0
        self.exit_blocked_msg = ""
        n = self.char["name"]
        self.npcs = [
            {"name": "Informant", "x": 256, "y": 232, "col": PURPLE,
             "kind": "informant", "required": True, "talked": False,
             "is_prop": False, "r": 8,
             "lines": [
                 ("Informant", "So. Another fool drawn north by the cold."),
                 ("Informant", "The eternal winter is no act of nature, " + n
                               + ". It began in one place."),
                 ("Informant", "An abandoned prison, frozen into the cliffs. "
                               "Answers wait there... and worse."),
                 ("Informant", "Travel north. Find the lost prison of Delm Hez. And " + n + "..."),
                 ("Informant", "...if you seek the truth, you may not like what "
                               "you find."),
             ],
             "repeat_lines": [("Informant",
                               "The road north starts past the well. Stay warm.")]},
            {"name": "Villager", "x": 130, "y": 320, "col": SLATE,
             "kind": "villager", "required": False, "talked": False,
             "is_prop": False, "r": 8,
             "lines": [("Villager",
                        "The cold gets worse every year. Won't be long now.")]},
            {"name": "Old Woman", "x": 390, "y": 310, "col": LBLUE,
             "kind": "villager", "required": False, "talked": False,
             "is_prop": False, "r": 8,
             "lines": [("Old Woman",
                        "They say the prison up north is cursed. "
                        "I wouldn't go if I were you.")]},
        ]
        self.obstacles = [
            {"x": 256, "y": 340, "r": 20, "kind": "well"},
            {"x": 150, "y": 290, "r": 13, "kind": "crate"},
            {"x": 360, "y": 295, "r": 11, "kind": "fence"},
        ]
        self.location_caption = {"text": "FROSTHAVEN", "timer": 100}

    def init_travel(self):
        self.px, self.py = W // 2, H - 70
        self.facing = (0, -1)
        self.moving = False
        self.swing = 0
        self.active_dialogue = None
        self.exit_blocked_timer = 0
        self.exit_blocked_msg = ""
        self.npcs = [
            {"name": "Cairn", "x": 170, "y": 230, "col": SLATE, "kind": "cairn",
             "required": False, "talked": False, "is_prop": True, "r": 9,
             "lines": [("", "A stone cairn marks the old trade road. "
                            "Someone has added fresh stones recently.")]},
            {"name": "Cart", "x": 360, "y": 270, "col": BROWN, "kind": "cart",
             "required": False, "talked": False, "is_prop": True, "r": 10,
             "lines": [("", "An overturned supply cart, frozen mid-spill. "
                            "Whoever was hauling this never made it far.")]},
            {"name": "Traveler", "x": 256, "y": 330, "col": SLATE,
             "kind": "traveler", "required": False, "talked": False,
             "is_prop": False, "r": 8,
             "lines": [
                 ("Traveler", "You're heading north? Turn back while you still can."),
                 ("Traveler", "The ice out there... it moves. Like it's hunting."),
                 ("Traveler", "I lost my brother to it. Don't make his mistake."),
             ],
             "repeat_lines": [("Traveler", "Please... just turn back.")]},
        ]
        self.obstacles = [
            {"x": 90, "y": 210, "r": 16, "kind": "ice"},
            {"x": 430, "y": 245, "r": 15, "kind": "ice"},
            {"x": 70, "y": 350, "r": 16, "kind": "ice"},
            {"x": 445, "y": 360, "r": 14, "kind": "ice"},
            {"x": 200, "y": 280, "r": 17, "kind": "rock"},
            {"x": 330, "y": 310, "r": 15, "kind": "rock"},
            {"x": 100, "y": 240, "r": 16, "kind": "ice"},
            {"x": 450, "y": 145, "r": 15, "kind": "ice"},
            {"x": 160, "y": 250, "r": 16, "kind": "ice"},
            {"x": 245, "y": 160, "r": 14, "kind": "ice"},
            {"x": 320, "y": 120, "r": 17, "kind": "rock"},
            {"x": 130, "y": 310, "r": 15, "kind": "rock"},
        ]
        self.location_caption = {"text": "THE NORTH ROAD", "timer": 100}

    def init_prison_gate(self):
        self.px, self.py = W // 2, H - 70
        self.facing = (0, -1)
        self.moving = False
        self.swing = 0
        self.active_dialogue = None
        self.exit_blocked_timer = 0
        self.exit_blocked_msg = ""
        self.npcs = [
            {"name": "Hag", "x": 256, "y": 250, "col": PURPLE, "kind": "hag",
             "required": True, "talked": False, "is_prop": False, "r": 8,
             "lines": [
                 ("Hag", "Heh heh... another seeker of truth."),
                 ("Hag", "The gate remembers the old order: "
                         "Life... Balance... Death."),
                 ("Hag", "Light the stones as creation intended, or join the "
                         "spectres within."),
                 ("Hag", "Go on then, dearie. The cold doesn't wait."),
             ],
             "repeat_lines": [("Hag", "Life. Balance. Death. Don't forget it.")]},
            {"name": "Frozen Guard", "x": 140, "y": 220, "col": LBLUE,
             "kind": "guard", "required": False, "talked": False,
             "is_prop": False, "r": 9,
             "lines": [("", "A guard, frozen solid mid-stride. "
                            "Ice has claimed his eyes.")]},
            {"name": "Frozen Guard", "x": 372, "y": 220, "col": LBLUE,
             "kind": "guard", "required": False, "talked": False,
             "is_prop": False, "r": 9,
             "lines": [("", "Another guard, frozen at his post. "
                            "Whatever happened here was sudden.")]},
            {"name": "Iron Door", "x": 150, "y": 280, "col": SLATE,
             "kind": "door", "required": False, "talked": False,
             "is_prop": True, "r": 9,
             "lines": [("", "A heavy iron door, frozen shut. "
                            "It hasn't opened in years.")]},
            {"name": "Iron Door", "x": 362, "y": 280, "col": SLATE,
             "kind": "door", "required": False, "talked": False,
             "is_prop": True, "r": 9,
             "lines": [("", "Another side door, sealed by ice. "
                            "Whatever's behind it can stay there.")]},
        ]
        self.obstacles = [
            {"x": 225, "y": 330, "r": 14, "kind": "pillar"},
            {"x": 295, "y": 330, "r": 14, "kind": "pillar"},
        ]
        self.location_caption = {"text": "THE FORGOTTEN PRISON", "timer": 100}

    def init_plains(self):
        self.px, self.py = W // 2, H - 80
        self.facing = (0, -1)
        self.moving = False
        self.npcs = []
        self.atk_cd = 0
        self.swing = 0
        self.projectiles = []
        self.enemies = [
            Enemy(120, 120, "elemental"),
            Enemy(390, 140, "elemental"),
            Enemy(256, 80, "elemental"),
        ]
        self.wave = 1
        self.ambush_flash = 0
        self.obstacles = [
            {"x": 140, "y": 220, "r": 15, "kind": "ice"},
            {"x": 370, "y": 210, "r": 14, "kind": "rock"},
            {"x": 256, "y": 260, "r": 13, "kind": "rock"},
            {"x": 200, "y": 335, "r": 15, "kind": "ice"},
            {"x": 320, "y": 345, "r": 14, "kind": "rock"},
            {"x": 95, "y": 305, "r": 13, "kind": "ice"},
            {"x": 415, "y": 300, "r": 14, "kind": "rock"},
        ]
        # Precomputed once so the terrain doesn't flicker frame to frame -
        # an organic mix of soft snow drifts and jagged ice cracks instead
        # of a uniform grid.
        self.ground_deco = []
        for _ in range(16):
            kind = random.choice(("drift", "crack"))
            x = random.randint(24, W - 24)
            y = random.randint(72, H - 16)
            if kind == "drift":
                self.ground_deco.append({"kind": "drift", "x": x, "y": y,
                                         "r": random.randint(8, 20)})
            else:
                self.ground_deco.append({
                    "kind": "crack", "x": x, "y": y,
                    "ang": random.uniform(0, math.tau),
                    "len": random.randint(14, 30)})

    def init_puzzle(self):
        self.px, self.py = W // 2, H - 70
        self.facing = (0, -1)
        self.moving = False
        self.npcs = []
        # The three runestones, shuffled into a random physical arrangement
        # each run - the correct ACTIVATION ORDER is always LIFE, BALANCE,
        # DEATH (as the Hag tells you), but which slot holds which stone
        # changes, so you can't just learn "left, middle, right".
        names_cols = [("LIFE", GREEN), ("BALANCE", YELLOW), ("DEATH", PURPLE)]
        random.shuffle(names_cols)
        positions = [(150, 180), (256, 150), (362, 180)]
        self.runes = []
        for (name, col), (x, y) in zip(names_cols, positions):
            self.runes.append({"name": name, "x": x, "y": y, "lit": False, "col": col})
        self.solution_order = ["LIFE", "BALANCE", "DEATH"]
        self.order = []  # indices activated so far, in sequence
        self.puzzle_msg = "Activate the three runestones in the right order."
        self.spectres = []
        self.projectiles = []
        self.atk_cd = 0
        self.swing = 0
        self.obstacles = [
            {"x": 200, "y": 270, "r": 14, "kind": "pillar"},
            {"x": 312, "y": 270, "r": 14, "kind": "pillar"},
        ]

    def init_boss(self):
        self.px, self.py = W // 2, H - 90
        self.facing = (0, -1)
        self.moving = False
        self.npcs = []
        self.obstacles = []   # open arena - the Frost Nova push needs room
        self.atk_cd = 0
        self.swing = 0
        self.projectiles = []
        self.enemies = []          # summoned adds
        self.weakpoints = []       # Frost Shards
        self.boss_x, self.boss_y = W // 2, 130
        self.boss_hp = self.boss_max = 900
        self.boss_phase = 1
        self.boss_cd = 80
        self.boss_flash = 0
        self.boss_move_t = 0
        self.pending_attack = None
        self.boss_dmg_mult = 1.0
        self.stagger_timer = 0
        self.shard_cd = 200
        self.nova_fx = 0
        self.beam_fx_timer = 0
        self.beam_fx_line = None
        self.push_fx = 0
        # Snow-covered cavern floor decor, generated once so it stays put
        # rather than swimming around every frame. Obstacles stay empty
        # (the phase-3 shockwave push needs open ground) but the roots and
        # drifts are purely visual.
        self.boss_ground_deco = [
            {"x": random.randint(24, W - 24), "y": random.randint(150, H - 20),
             "r": random.randint(10, 22)} for _ in range(11)
        ]
        self.boss_roots = self._generate_boss_roots()

    def _generate_boss_roots(self):
        """A handful of gnarled tree roots breaking up through the snow -
        each one a short chain of jittered segments with the occasional
        branch, precomputed once per boss attempt."""
        starts = [
            (random.randint(40, W - 40), H, 0),
            (random.randint(40, W - 40), H, 0),
            (0, random.randint(180, 380), -1),
            (W, random.randint(180, 380), 1),
            (random.randint(60, 200), H, 0),
            (random.randint(320, 460), H, 0),
        ]
        roots = []
        for sx, sy, side in starts:
            if side == 0:
                ang = -math.pi / 2 + random.uniform(-0.3, 0.3)
            else:
                ang = math.pi if side < 0 else 0
                ang += random.uniform(-0.3, 0.3)
            x, y = sx, sy
            segs = []
            n = random.randint(4, 6)
            seg_len = random.randint(16, 24)
            width = random.randint(3, 5)
            for i in range(n):
                ang += random.uniform(-0.35, 0.35)
                nx = x + math.cos(ang) * seg_len
                ny = y + math.sin(ang) * seg_len
                segs.append((x, y, nx, ny, max(1, width - i // 2)))
                if random.random() < 0.45 and i < n - 1:
                    bang = ang + random.choice((-1, 1)) * random.uniform(0.6, 1.1)
                    bx = nx + math.cos(bang) * seg_len * 0.6
                    by = ny + math.sin(bang) * seg_len * 0.6
                    segs.append((nx, ny, bx, by, max(1, width - i // 2 - 1)))
                x, y = nx, ny
            roots.append(segs)
        return roots

    # ====================================================================
    # UPDATE
    # ====================================================================
    def update(self):
        if pyxel.btnp(pyxel.KEY_Q) or pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()

        # animate snow everywhere
        for s in self.snow:
            s[1] += s[2]
            s[0] += math.sin((pyxel.frame_count + s[1]) * 0.02) * 0.3
            if s[1] > H:
                s[1] = -2
                s[0] = random.uniform(0, W)

        if self.state == S_TITLE:
            if pyxel.btnp(pyxel.KEY_RETURN) or pyxel.btnp(pyxel.KEY_SPACE):
                self.state = S_SELECT
                self.sel = 0
        elif self.state == S_SELECT:
            self.update_select()
        elif self.state == S_TOWN:
            self.update_town()
        elif self.state == S_STORY:
            self.update_story()
        elif self.state == S_TRAVEL:
            self.update_travel()
        elif self.state == S_PLAINS:
            self.update_combat(plains=True)
        elif self.state == S_PRISON:
            self.update_prison_gate()
        elif self.state == S_PUZZLE:
            self.update_puzzle()
        elif self.state == S_BOSS:
            self.update_boss()
        elif self.state in (S_WIN, S_LOSE):
            if pyxel.btnp(pyxel.KEY_R) or pyxel.btnp(pyxel.KEY_RETURN):
                self.reset_full()

    def update_select(self):
        if pyxel.btnp(pyxel.KEY_LEFT) or pyxel.btnp(pyxel.KEY_A):
            self.sel = (self.sel - 1) % len(CHARACTERS)
        if pyxel.btnp(pyxel.KEY_RIGHT) or pyxel.btnp(pyxel.KEY_D):
            self.sel = (self.sel + 1) % len(CHARACTERS)
        if pyxel.btnp(pyxel.KEY_RETURN) or pyxel.btnp(pyxel.KEY_SPACE):
            self.start_run(self.sel)

    def update_story(self):
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_Z) \
                or pyxel.btnp(pyxel.KEY_RETURN):
            self.dlg_i += 1
            if self.dlg_i >= len(self.dialogue):
                nxt = self.next_state
                if nxt == S_PUZZLE:
                    self.init_puzzle()
                elif nxt == S_BOSS:
                    self.init_boss()
                self.state = nxt

    # ---- free-roam scenes -------------------------------------------------
    def update_town(self):
        def on_exit():
            self.init_travel()
            self.state = S_TRAVEL
        self.update_npc_scene(
            self.npcs, 95, on_exit,
            "Maybe I should speak with someone before heading into the cold.")

    def update_travel(self):
        def on_exit():
            self.init_plains()
            self.ambush_flash = 50
            self.state = S_PLAINS
        self.update_npc_scene(self.npcs, 95, on_exit, "")

    def update_prison_gate(self):
        def on_exit():
            self.init_puzzle()
            self.state = S_PUZZLE
        self.update_npc_scene(
            self.npcs, 95, on_exit,
            "The gate won't budge. Perhaps someone here knows how to open it.")

    # ---- shared player control + attacks --------------------------------
    def player_move(self):
        c = self.char
        dx = dy = 0
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.KEY_A):
            dx -= 1
        if pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.KEY_D):
            dx += 1
        if pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.KEY_W):
            dy -= 1
        if pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.KEY_S):
            dy += 1
        self.moving = bool(dx or dy)
        if dx or dy:
            mag = math.hypot(dx, dy)
            self.px = clamp(self.px + dx / mag * c["speed"], 16, W - 16)
            self.py = clamp(self.py + dy / mag * c["speed"], 70, H - 16)
            self.facing = (dx / mag, dy / mag)
        self.resolve_obstacles()

    def player_attack(self, targets, boss_target=False):
        """Spawn the player's attack. Applies damage to enemies/weak points
        in `targets`, and - if boss_target is True - to Auril herself."""
        c = self.char
        if self.atk_cd > 0:
            self.atk_cd -= 1
        if self.swing > 0:
            self.swing -= 1
        attack = pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_SPACE)
        if attack and self.atk_cd == 0:
            fx, fy = self.facing
            if c["atk"] == "ranged":
                self.projectiles.append(
                    Projectile(self.px, self.py, fx * 5, fy * 5,
                               c["dmg"], c["col"], True))
                self.atk_cd = 14
            else:  # melee swing - hit resolves immediately, reliably
                self.swing = 8
                self.atk_cd = 18
                hx, hy = self.px + fx * 18, self.py + fy * 18
                for e in targets:
                    if dist(hx, hy, e.x, e.y) < e.r + 14:
                        dmg = c["dmg"]
                        if e.undead:
                            dmg *= c["undead_mult"]
                        e.hp -= dmg
                        e.flash = 4
                if boss_target and dist(hx, hy, self.boss_x, self.boss_y) < 24 + 14:
                    dmg = c["dmg"] * c["undead_mult"] * self.boss_dmg_mult
                    self.boss_hp -= dmg
                    self.boss_flash = 4

        self.handle_herb_input()

    def projectiles_vs_enemies(self, enemies):
        for p in self.projectiles:
            if not p.friendly or p.life <= 0:
                continue
            for e in enemies:
                if dist(p.x, p.y, e.x, e.y) < e.r + p.r:
                    dmg = p.dmg
                    if e.undead:
                        dmg *= self.char["undead_mult"]
                    e.hp -= dmg
                    e.flash = 4
                    p.life = 0
                    break
        self.projectiles = [p for p in self.projectiles if not p.dead]

    def enemy_projectiles_vs_player(self):
        for p in self.projectiles:
            if p.friendly or p.life <= 0:
                continue
            if dist(p.x, p.y, self.px, self.py) < self.char_r() + p.r:
                self.hp -= p.dmg * self.char["mag_res"]
                p.life = 0
        self.projectiles = [p for p in self.projectiles if not p.dead]

    def char_r(self):
        return 9

    # ---- combat scene (plains) ------------------------------------------
    def update_combat(self, plains=False):
        if self.ambush_flash > 0:
            self.ambush_flash -= 1
        self.player_move()
        self.player_attack(self.enemies)
        for e in self.enemies:
            e.update(self.px, self.py, self.projectiles)
            if dist(e.x, e.y, self.px, self.py) < e.r + self.char_r():
                self.hp -= e.touch * 0.5 * self.char["mag_res"]
        for p in self.projectiles:
            p.update()
        self.projectiles_vs_enemies(self.enemies)
        self.enemy_projectiles_vs_player()
        self.enemies = [e for e in self.enemies if e.hp > 0]

        if self.hp <= 0:
            self.state = S_LOSE
            self.lose_reason = "ambush"
            return

        if not self.enemies:
            if self.wave == 1:
                self.wave = 2
                self.enemies = [
                    Enemy(200, 100, "Lord elemental"),
                    Enemy(430, 100, "elemental"),
                    Enemy(80, 100, "elemental")
                ]
            else:
                # plains cleared -> walk on to the prison gate
                self.init_prison_gate()
                self.state = S_PRISON

    # ---- puzzle scene ----------------------------------------------------
    def update_puzzle(self):
        self.player_move()
        # spectral enemies (from a wrong activation)
        for e in self.spectres:
            e.update(self.px, self.py, self.projectiles)
            if dist(e.x, e.y, self.px, self.py) < e.r + self.char_r():
                self.hp -= e.touch * 0.5 * self.char["mag_res"]
        self.player_attack(self.spectres)
        for p in self.projectiles:
            p.update()
        self.projectiles_vs_enemies(self.spectres)
        self.enemy_projectiles_vs_player()
        self.spectres = [e for e in self.spectres if e.hp > 0]

        if self.hp <= 0:
            self.state = S_LOSE
            self.lose_reason = "spectres"
            return

        # activate a rune you are standing on
        act = pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_SPACE)
        if act:
            for i, r in enumerate(self.runes):
                if not r["lit"] and dist(self.px, self.py, r["x"], r["y"]) < 26:
                    self.try_activate(i)
                    break

        if len(self.order) == 3:
            self.begin_dialogue([
                ("", "The seals shatter. The prison gate grinds open."),
                ("", "You descend through cells and archives, ever downward,"),
                ("", "to the frozen roots beneath the Tree of Life."),
                ("Strage writting on the wall", "Beneath lies the heart of Winter - Frost Shards crystallize in the chamber ahead - shatter "
                     "them quickly to stagger what waits below."),
                ("Strange writting on the wall", "In her final fury her frost shockwaves may hurl you "
                     "back - stay light on your feet."),
                ("A booming voice", "You've come so far... only to die in the cold you sought."),
            ], S_BOSS)

    def try_activate(self, i):
        expected = self.solution_order[len(self.order)]
        if self.runes[i]["name"] == expected:
            self.runes[i]["lit"] = True
            self.order.append(i)
            self.puzzle_msg = self.runes[i]["name"] + " resonates. The seal weakens."
        else:
            # wrong: reset and summon a weak spectre
            self.puzzle_msg = "WRONG! The runes darken and a spectre rises."
            for r in self.runes:
                r["lit"] = False
            self.order = []
            self.spectres.append(Enemy(self.runes[i]["x"], self.runes[i]["y"] - 30,
                                       "spectral"))

    # ---- boss scene --------------------------------------------------
    # Auril cycles through ONE telegraphed attack at a time (a warning is
    # always shown before damage applies) and periodically crystallizes a
    # breakable Frost Shard nearby; destroying a shard bursts extra damage
    # into her and staggers her (double damage taken, attacks paused).
    def update_boss(self):
        self.player_move()

        active_weakpoints = [w for w in self.weakpoints if w.active]
        self.player_attack(self.enemies + active_weakpoints, boss_target=True)

        for p in self.projectiles:
            p.update()

        # friendly projectiles vs boss
        for p in self.projectiles:
            if p.friendly and p.life > 0 and \
                    dist(p.x, p.y, self.boss_x, self.boss_y) < 24 + p.r:
                dmg = p.dmg * self.char["undead_mult"] * self.boss_dmg_mult
                self.boss_hp -= dmg
                self.boss_flash = 4
                p.life = 0

        self.projectiles_vs_enemies(self.enemies + active_weakpoints)
        self.enemy_projectiles_vs_player()
        self.projectiles = [p for p in self.projectiles if not p.dead]

        # Frost Shard lifecycle: reward for destroying, no penalty for missing
        destroyed = [w for w in self.weakpoints if w.active and w.hp <= 0]
        for _ in destroyed:
            self.boss_hp -= 35
            self.stagger_timer = 90
            self.boss_dmg_mult = 2.0
            self.pending_attack = None  # breaking a shard interrupts her windup
            self.boss_flash = 8
        for w in self.weakpoints:
            w.update()
        self.weakpoints = [w for w in self.weakpoints if not w.dead]

        # summoned adds
        for e in self.enemies:
            e.update(self.px, self.py, self.projectiles)
            if dist(e.x, e.y, self.px, self.py) < e.r + self.char_r():
                self.hp -= e.touch * 0.5 * self.char["mag_res"]
        self.enemies = [e for e in self.enemies if e.hp > 0]

        if self.boss_flash > 0:
            self.boss_flash -= 1
        if self.nova_fx > 0:
            self.nova_fx -= 1
        if self.beam_fx_timer > 0:
            self.beam_fx_timer -= 1
        if self.push_fx > 0:
            self.push_fx -= 1

        # stagger window / attack scheduling
        if self.stagger_timer > 0:
            self.stagger_timer -= 1
            if self.stagger_timer == 0:
                self.boss_dmg_mult = 1.0
        else:
            if self.pending_attack is None:
                self.boss_cd -= 1
                if self.boss_cd <= 0:
                    self.start_boss_attack()
            else:
                self.pending_attack["timer"] -= 1
                if self.pending_attack["timer"] <= 0:
                    self.resolve_boss_attack(self.pending_attack)
                    self.pending_attack = None

        # Frost Shard spawning (paused while staggered)
        if self.stagger_timer <= 0:
            self.shard_cd -= 1
            if self.shard_cd <= 0 and len(self.weakpoints) < 2:
                self.shard_cd = (random.randint(240, 300) if self.boss_phase == 1
                                  else random.randint(190, 250))
                wx = random.randint(90, W - 90)
                wy = random.randint(140, 300)
                self.weakpoints.append(WeakPoint(wx, wy))

        # boss drifts, but holds still while mid-telegraph so the warning
        # stays accurate to where the attack will actually land
        if self.pending_attack is None:
            self.boss_move_t += 0.015
            self.boss_x = W // 2 + math.sin(self.boss_move_t) * 110
            self.boss_y = 120 + math.sin(self.boss_move_t * 1.7) * 26

        # phase transitions
        if self.boss_phase == 1 and self.boss_hp < self.boss_max * 0.66:
            self.boss_phase = 2
        elif self.boss_phase == 2 and self.boss_hp < self.boss_max * 0.33:
            self.boss_phase = 3

        if self.hp <= 0:
            self.state = S_LOSE
            self.lose_reason = "auril"
        if self.boss_hp <= 0:
            self.state = S_WIN

    def start_boss_attack(self):
        phase = self.boss_phase
        if phase == 1:
            kind = "spear_line"
        elif phase == 2:
            kind = random.choices(["spear_line", "nova_ring"], weights=[45, 55])[0]
            if len(self.enemies) < 2 and random.random() < 0.3:
                self.enemies.append(Enemy(random.randint(60, W - 60), 90, "add"))
        else:
            kind = random.choice(["nova_ring", "beam", "blizzard"])

        if kind == "spear_line":
            ang = math.atan2(self.py - self.boss_y, self.px - self.boss_x)
            self.pending_attack = {"type": "spear_line", "timer": 25,
                                    "duration": 25, "angle": ang}
        elif kind == "nova_ring":
            safe = random.uniform(0, math.tau)
            self.pending_attack = {"type": "nova_ring", "timer": 50,
                                    "duration": 100, "safe_angle": safe}
        elif kind == "beam":
            self.pending_attack = {"type": "beam", "timer": 55, "duration": 55,
                                    "target": (self.px, self.py)}
        else:  # blizzard
            marks = [(random.randint(0, W), random.randint(70, H))
                      for _ in range(100)]
            self.pending_attack = {"type": "blizzard", "timer": 40,
                                    "duration": 40, "marks": marks}
 

    def resolve_boss_attack(self, pa):
        t = pa["type"]
        if t == "spear_line":
            ang = pa["angle"]
            for a in (-0.22, 0, 0.22):
                ang2 = ang + a
                self.projectiles.append(Projectile(
                    self.boss_x, self.boss_y, math.cos(ang2) * 4.2,
                    math.sin(ang2) * 4.2, 50, CYAN, False))
            self.boss_cd = random.randint(90, 130)
        elif t == "nova_ring":
            dx, dy = self.px - self.boss_x, self.py - self.boss_y
            d = math.hypot(dx, dy)
            if d > 0:
                ang = math.atan2(dy, dx)
                diff = abs((ang - pa["safe_angle"] + math.pi) % math.tau - math.pi)
                hit = 55 < d < 175 and diff > 0.65
                if hit:
                    self.hp -= 50 * self.char["mag_res"]
                    if self.boss_phase == 3:
                        # Frost Eruption: phase 3 nova doubles as a shockwave
                        # that shoves you back, costing melee heroes the
                        # ground they fought to close. Ranged heroes, who
                        # rarely stand this near her anyway, mostly escape it.
                        push = 80
                        ux, uy = dx / d, dy / d
                        self.px = clamp(self.px + ux * push, 16, W - 16)
                        self.py = clamp(self.py + uy * push, 70, H - 16)
                        self.push_fx = 20
            self.nova_fx = 12
            self.boss_cd = random.randint(110, 150)
        elif t == "beam":
            tx, ty = pa["target"]
            bx, by = self.boss_x, self.boss_y
            dx, dy = tx - bx, ty - by
            L = math.hypot(dx, dy) or 1
            ux, uy = dx / L, dy / L
            rx, ry = self.px - bx, self.py - by
            proj_len = rx * ux + ry * uy
            perp = abs(rx * uy - ry * ux)
            if proj_len > 0 and perp < 16:
                self.hp -= 60 * self.char["mag_res"]
            self.beam_fx_timer = 10
            self.beam_fx_line = (bx, by, bx + ux * 420, by + uy * 420)
            self.boss_cd = random.randint(85, 115)
        else:  # blizzard
            for (mx, my) in pa["marks"]:
                if dist(self.px, self.py, mx, my) < 22:
                    self.hp -= 80 * self.char["mag_res"]
            self.boss_cd = random.randint(75, 100)

        if self.hp <= 0:
            self.state = S_LOSE
            self.lose_reason = "auril"

    # ====================================================================
    # DRAW
    # ====================================================================
    def draw(self):
        if self.state == S_TITLE:
            self.draw_title()
        elif self.state == S_SELECT:
            self.draw_select()
        elif self.state == S_TOWN:
            self.draw_town()
        elif self.state == S_STORY:
            self.draw_story()
        elif self.state == S_TRAVEL:
            self.draw_travel()
        elif self.state == S_PLAINS:
            self.draw_combat()
        elif self.state == S_PRISON:
            self.draw_prison_gate()
        elif self.state == S_PUZZLE:
            self.draw_puzzle()
        elif self.state == S_BOSS:
            self.draw_boss()
        elif self.state == S_WIN:
            self.draw_win()
        elif self.state == S_LOSE:
            self.draw_lose()

    def draw_snow(self):
        for s in self.snow:
            col = WHITE if s[2] > 1.0 else LBLUE
            pyxel.pset(int(s[0]) % W, int(s[1]), col)

    def draw_hud(self):
        c = self.char
        pyxel.rect(8, 8, 124, 10, BLACK)
        pyxel.rect(10, 10, 120, 6, RED)
        pyxel.rect(10, 10, int(120 * clamp(self.hp, 0, c["max_hp"]) / c["max_hp"]),
                   6, GREEN)
        pyxel.text(12, 20, "%s the %s" % (c["name"], c["title"].split()[-1]), WHITE)
        pyxel.text(12, 28, "Herbs (X): %d" % self.herbs, GREEN)

    # ---- dialogue box (shared by forced cutscenes and NPC talk) ----------
    def draw_dialogue_box(self, speaker, text):
        bx, by, bw, bh = 30, 380, W - 60, 110
        pyxel.rect(bx, by, bw, bh, BLACK)
        pyxel.rectb(bx, by, bw, bh, WHITE)
        if speaker:
            pyxel.rect(bx + 8, by - 8, len(speaker) * 4 + 8, 12, NAVY)
            pyxel.rectb(bx + 8, by - 8, len(speaker) * 4 + 8, 12, WHITE)
            pyxel.text(bx + 12, by - 5, speaker, YELLOW)
        for i, line in enumerate(wrap_text(text, 110)):
            pyxel.text(bx + 12, by + 14 + i * 12, line, WHITE)
        if (pyxel.frame_count // 20) % 2 == 0:
            pyxel.text(bx + bw - 80, by + bh - 14, "SPACE ->", LBLUE)

    def draw_active_dialogue(self):
        if not self.active_dialogue:
            return
        d = self.active_dialogue
        speaker, text = d["lines"][d["i"]]
        self.draw_dialogue_box(speaker, text)

    def draw_blocked_msg(self):
        if self.exit_blocked_timer <= 0:
            return
        msg = self.exit_blocked_msg
        bw = len(msg) * 4 + 16
        bx = W // 2 - bw // 2
        pyxel.rect(bx, 100, bw, 16, BLACK)
        pyxel.rectb(bx, 100, bw, 16, YELLOW)
        pyxel.text(bx + 8, 104, msg, YELLOW)

    def draw_location_caption(self):
        lc = self.location_caption
        if not lc:
            return
        draw_text_center(150, lc["text"], WHITE)

    # ---- interactable NPCs / examinable props -----------------------------
    def draw_interactable(self, n):
        x, y = int(n["x"]), int(n["y"])
        kind = n["kind"]
        col = n["col"]
        t = pyxel.frame_count
        if kind == "informant":
            pyxel.tri(x - 9, y + 10, x + 9, y + 10, x, y - 8, dark(col))
            pyxel.tri(x - 6, y + 8, x + 6, y + 8, x, y - 6, col)
            pyxel.circ(x, y - 12, 5, PEACH)
            pyxel.tri(x - 6, y - 12, x + 6, y - 12, x, y - 19, col)
            glow = YELLOW if (t // 6) % 2 == 0 else ORANGE
            pyxel.circ(x + 10, y, 3, glow)
            pyxel.line(x + 6, y - 2, x + 10, y - 3, SLATE)
        elif kind == "villager":
            pyxel.tri(x - 7, y + 9, x + 7, y + 9, x, y - 5, col)
            pyxel.circ(x, y - 9, 4, PEACH)
            pyxel.tri(x - 5, y - 9, x + 5, y - 9, x, y - 15, dark(col))
        elif kind == "hag":
            sway = math.sin(t * 0.08) * 1.5
            pyxel.tri(x - 8 + sway, y + 10, x + 8 + sway, y + 10,
                      x + sway * 0.3, y - 4, dark(col))
            pyxel.circ(x, y - 8, 4, SLATE)
            pyxel.tri(x - 5, y - 8, x + 5, y - 8, x, y - 15, col)
            pyxel.line(x + 7, y - 2, x + 12, y + 10, BROWN)
        elif kind == "guard":
            pyxel.rect(x - 6, y - 14, 12, 22, col)
            pyxel.rectb(x - 6, y - 14, 12, 22, dark(col))
            pyxel.circ(x, y - 18, 5, col)
            pyxel.pset(x - 2, y - 19, NAVY)
            pyxel.pset(x + 2, y - 19, NAVY)
            for k in range(3):
                pyxel.pset(x - 6 + (t // 3 + k * 4) % 12, y - 14 + k * 6, WHITE)
        elif kind == "traveler":
            pyxel.circ(x, y, 9, col)
            pyxel.circ(x, y - 9, 4, PEACH)
            pyxel.circ(x + 14, y + 6, 4, SLATE)
            pyxel.pset(x + 14, y + 3, ORANGE if (t // 30) % 5 == 0 else SLATE)
        elif kind == "door":
            pyxel.rect(x - 8, y - 16, 16, 26, col)
            pyxel.rectb(x - 8, y - 16, 16, 26, NAVY)
            for ry in (-10, -2, 6):
                pyxel.pset(x - 5, y + ry, NAVY)
                pyxel.pset(x + 5, y + ry, NAVY)
            pyxel.pset(x, y + 2, YELLOW)
        elif kind == "cairn":
            pyxel.circ(x, y + 8, 7, col)
            pyxel.circ(x, y, 6, col)
            pyxel.circ(x, y - 7, 4, col)
        elif kind == "cart":
            pyxel.rect(x - 10, y - 4, 20, 8, col)
            pyxel.circb(x - 8, y + 6, 4, SLATE)
            pyxel.circb(x + 8, y + 6, 4, SLATE)
            pyxel.line(x - 10, y - 4, x - 16, y + 2, col)
        else:
            pyxel.circ(x, y, 8, col)

        if not n["talked"]:
            if n.get("required"):
                bob = math.sin(t * 0.15) * 2
                draw_text_center_at(x, y - 34 + int(bob), "!", YELLOW)
            else:
                draw_text_center_at(x, y - 30, "?", SLATE)

    def draw_interactables(self, npcs):
        for n in npcs:
            self.draw_interactable(n)
        if not self.active_dialogue:
            for n in npcs:
                if dist(self.px, self.py, n["x"], n["y"]) < 28:
                    label = "Z: examine" if n.get("is_prop") else "Z: talk"
                    draw_text_center_at(int(n["x"]), int(n["y"]) - 46, label, WHITE)
                    break

    # ---- terrain obstacles -------------------------------------------------
    def draw_obstacle(self, ob):
        x, y, r = int(ob["x"]), int(ob["y"]), ob["r"]
        kind = ob["kind"]
        if kind == "rock":
            pyxel.circ(x, y, r, SLATE)
            pyxel.circ(x - r // 3, y - r // 3, max(2, r // 2), dark(SLATE))
            pyxel.circb(x, y, r, NAVY)
        elif kind == "ice":
            for k in range(3):
                ang = -1.1 + k * 1.1
                sx = x + math.cos(ang) * r * 0.55
                sy = y + math.sin(ang) * r * 0.3
                pyxel.tri(sx, sy, sx - r * 0.35, sy + r, sx + r * 0.35, sy + r, CYAN)
            pyxel.tri(x, y - r * 0.3, x - r * 0.4, y + r * 0.9, x + r * 0.4,
                      y + r * 0.9, WHITE)
        elif kind == "crate":
            pyxel.rect(x - r, y - r, r * 2, r * 2, BROWN)
            pyxel.rectb(x - r, y - r, r * 2, r * 2, dark(BROWN))
            pyxel.line(x - r, y - r, x + r, y + r, dark(BROWN))
            pyxel.line(x + r, y - r, x - r, y + r, dark(BROWN))
        elif kind == "pillar":
            pyxel.rect(x - r, y - r * 2, r * 2, r * 3, SLATE)
            pyxel.rectb(x - r, y - r * 2, r * 2, r * 3, NAVY)
            pyxel.line(x - r, y, x + r, y - r, NAVY)
            pyxel.line(x - r + 2, y - r, x + r - 2, y - r, NAVY)
        elif kind == "fence":
            for k in range(-1, 2):
                pyxel.rect(x + k * 8 - 2, y - r, 4, r * 2, BROWN)
            pyxel.line(x - 12, y - r // 2, x + 12, y - r // 2, dark(BROWN))
        elif kind == "well":
            pyxel.circ(x, y, r, SLATE)
            pyxel.circb(x, y, r, NAVY)
            pyxel.circ(x, y, r - 6, NAVY)
            for k in range(4):
                a = k * math.tau / 4 + pyxel.frame_count * 0.02
                pyxel.line(x + math.cos(a) * (r - 2), y + math.sin(a) * (r - 2),
                          x + math.cos(a) * (r + 4), y + math.sin(a) * (r + 4), WHITE)
        else:
            pyxel.circ(x, y, r, SLATE)

    def draw_obstacles(self):
        for ob in self.obstacles:
            self.draw_obstacle(ob)

    def draw_cobblestone(self, x0, y0, x1, y1, tile=26):
        """Tiled, staggered stone-block floor. Shading is derived from the
        tile's own grid coordinates (not random.*) so the pattern is stable
        frame to frame instead of flickering."""
        shades = (LBLUE, NAVY, BLACK)
        row = 0
        y = y0
        while y < y1:
            offset = (tile // 2) if row % 2 else 0
            x = x0 - offset
            col_i = 0
            while x < x1:
                h = (row * 13 + col_i * 7 + (row * col_i) * 3) % 5
                pyxel.rect(x, y, tile - 3, tile - 3, shades[h % 3])
                pyxel.rectb(x, y, tile - 3, tile - 3, BLACK)
                if h == 0:
                    pyxel.pset(x + 5, y + 5, WHITE)
                elif h == 3:
                    pyxel.pset(x + tile - 9, y + tile - 9, LBLUE)
                x += tile
                col_i += 1
            y += tile
            row += 1

    # ---- title -----------------------------------------------------------
    def draw_title(self):
        pyxel.cls(NAVY)
        # frozen tree silhouette
        pyxel.rect(W // 2 - 6, 250, 12, 160, INDIGO)
        for i in range(7):
            ang = -math.pi / 2 + (i - 3) * 0.4
            ex = W // 2 + math.cos(ang) * (90 + i * 6)
            ey = 250 + math.sin(ang) * (90 + i * 6)
            pyxel.line(W // 2, 250, ex, ey, INDIGO)
            pyxel.line(W // 2, 280, ex, ey + 30, SLATE)
        self.draw_snow()
        draw_big(W // 2 - 120, 120, "Mystery", LBLUE)
        draw_big(W // 2 - 120, 145, "At Delm Hez", WHITE)
        draw_text_center(190, "An Adventure RPG  -  Heart of Winter", LBLUE)
        if (pyxel.frame_count // 20) % 2 == 0:
            draw_text_center(420, "PRESS ENTER TO BEGIN", YELLOW)
        draw_text_center(470, "Z/SPACE attack   X heal   arrows move   Q quit", SLATE)

    # ---- select ----------------------------------------------------------
    def draw_select(self):
        pyxel.cls(NAVY)
        self.draw_snow()
        draw_text_center(40, "CHOOSE YOUR HERO", WHITE)
        draw_text_center(56, "Each shares the journey, but not the same path.", LBLUE)
        cw = 116
        x0 = (W - cw * 4 - 18 * 3) // 2
        for i, ch in enumerate(CHARACTERS):
            x = x0 + i * (cw + 18)
            y = 110
            sel = (i == self.sel)
            pyxel.rect(x, y, cw, 280, BLACK if not sel else ch["col"])
            pyxel.rectb(x, y, cw, 280, ch["col"])
            pyxel.rect(x + 2, y + 2, cw - 4, 276, NAVY)
            # portrait blob
            pyxel.circ(x + cw // 2, y + 60, 26, ch["col"])
            pyxel.circ(x + cw // 2, y + 60, 20, NAVY)
            pyxel.circ(x + cw // 2, y + 60, 14, ch["col"])
            draw_text_center_at(x + cw // 2, y + 110, ch["name"], WHITE)
            draw_text_center_at(x + cw // 2, y + 122, ch["title"], LBLUE)
            stats = [
                "HP   %d" % ch["max_hp"],
                "SPD  %.1f" % ch["speed"],
                "ATK  %s" % ch["atk"],
                "DMG  %d" % ch["dmg"],
            ]
            for j, s in enumerate(stats):
                pyxel.text(x + 14, y + 150 + j * 12, s, WHITE)
            for j, line in enumerate(wrap_text(ch["bonus"], 17)):
                pyxel.text(x + 10, y + 210 + j * 10, line, YELLOW)
            if sel:
                pyxel.rectb(x - 2, y - 2, cw + 4, 284, YELLOW)
        draw_text_center(410, "< >  choose      ENTER  confirm", WHITE)

    # ---- forced narrative cutscene (puzzle -> boss) -----------------------
    def draw_story(self):
        pyxel.cls(NAVY)
        self.draw_snow()
        pyxel.rect(0, 360, W, 152, INDIGO)
        speaker, text = self.dialogue[self.dlg_i]
        self.draw_dialogue_box(speaker, text)

    # ---- player draw -----------------------------------------------------
    def draw_player(self):
        c = self.char
        ix, iy = int(self.px), int(self.py)
        fx, fy = self.facing
        col = c["col"]
        t = pyxel.frame_count

        # soft ground shadow
        pyxel.circ(ix, iy + 10, 6, NAVY)

        # cloak, billowing slightly opposite the facing direction
        sway = math.sin(t * 0.15) * 2
        cx, cy = ix - fx * 9 + sway, iy - fy * 5 + 5
        pyxel.tri(ix - 6, iy - 2, ix + 6, iy - 2, cx, cy + 9, dark(col))

        # simple two-frame leg walk cycle
        step = math.sin(t * 0.5) * 2 if self.moving else 0
        pyxel.rect(ix - 5, iy + 4 - int(step), 3, 6, BLACK)
        pyxel.rect(ix + 2, iy + 4 + int(step), 3, 6, BLACK)

        # torso
        pyxel.tri(ix - 7, iy + 5, ix + 7, iy + 5, ix, iy - 5, col)
        pyxel.rect(ix - 4, iy - 4, 8, 7, col)

        # head + hood
        pyxel.circ(ix, iy - 9, 5, PEACH)
        pyxel.circb(ix, iy - 9, 5, dark(col))
        pyxel.tri(ix - 6, iy - 9, ix + 6, iy - 9, ix, iy - 16, col)

        # eyes, looking the way you're facing
        ex, ey = ix + fx * 2, iy - 9 + fy * 1
        pyxel.pset(int(ex) - 1, int(ey), BLACK)
        pyxel.pset(int(ex) + 1, int(ey), BLACK)

        self.draw_weapon(ix, iy, fx, fy)

    def draw_weapon(self, ix, iy, fx, fy):
        """Each hero gets a distinct weapon silhouette in their facing hand."""
        c = self.char
        hx, hy = ix + fx * 13, iy + fy * 9
        name = c["name"]
        if name == "Rowan":
            blade = WHITE if self.swing > 0 else SLATE
            pyxel.line(ix + fx * 5, iy + fy * 3, hx, hy, blade)
            pyxel.line(ix + fx * 5 + 1, iy + fy * 3, hx + 1, hy, SLATE)
            if self.swing > 0:
                pyxel.circb(int(hx), int(hy), 11, YELLOW)
        elif name == "Seraphine":
            pyxel.line(ix, iy, hx, hy, SLATE)
            gcol = YELLOW if self.swing > 0 else ORANGE
            pyxel.line(int(hx) - 3, int(hy), int(hx) + 3, int(hy), gcol)
            pyxel.line(int(hx), int(hy) - 3, int(hx), int(hy) + 3, gcol)
            if self.swing > 0:
                pyxel.circb(int(hx), int(hy), 11, YELLOW)
        elif name == "Eira":
            px_, py_ = -fy, fx  # perpendicular to facing
            ox, oy = ix + px_ * 8, iy + py_ * 8
            ang0 = math.atan2(py_, px_)
            pts = []
            for k in range(5):
                a = ang0 - 0.5 + k * 0.25
                pts.append((ox + math.cos(a) * 7, oy + math.sin(a) * 7))
            for k in range(len(pts) - 1):
                pyxel.line(pts[k][0], pts[k][1], pts[k + 1][0], pts[k + 1][1], BROWN)
            pyxel.line(pts[0][0], pts[0][1], pts[-1][0], pts[-1][1], SLATE)
        else:  # Aldric
            pyxel.line(ix, iy, hx, hy, BROWN)
            glow = WHITE if (pyxel.frame_count // 4) % 2 == 0 else CYAN
            pyxel.circ(int(hx), int(hy), 3, glow)

    def draw_projectiles(self):
        for p in self.projectiles:
            p.draw()

    # ---- Frosthaven (town) -------------------------------------------------
    def draw_building(self, cx, cy, w, h, col, label=None):
        x0, y0 = cx - w // 2, cy - h // 2
        pyxel.rect(x0, y0, w, h, dark(col))
        pyxel.rect(x0 + 2, y0 + 2, w - 4, h - 4, col)
        pyxel.tri(x0 - 6, y0, x0 + w + 6, y0, cx, y0 - h // 2, dark(col))
        for wx in (x0 + 10, x0 + w - 18):
            glow = YELLOW if (pyxel.frame_count // 10) % 2 == 0 else ORANGE
            pyxel.rect(wx, y0 + h // 2, 8, 8, glow)
        sx = x0 + w - 10
        for k in range(3):
            sy = y0 - h // 2 - 6 - k * 8 - (pyxel.frame_count % 8)
            pyxel.pset(sx + int(math.sin(sy * 0.1) * 2), sy, SLATE)
        if label:
            draw_text_center_at(cx, y0 + h + 6, label, WHITE)

    def draw_town(self):
        pyxel.cls(NAVY)
        pyxel.rect(0, 90, W, H - 90, LBLUE)
        for i in range(0, W, 40):
            pyxel.line(i, 90, i, H, NAVY)
        # north road
        pyxel.rect(W // 2 - 26, 0, 52, 220, SLATE)
        pyxel.rectb(W // 2 - 26, 0, 52, 220, NAVY)
        draw_text_center_at(W // 2, 70, "NORTH ROAD", WHITE)

        self.draw_building(256, 230, 90, 60, BROWN, "TAVERN")
        self.draw_building(110, 250, 60, 44, SLATE)
        self.draw_building(400, 250, 60, 44, SLATE)

        self.draw_obstacles()
        self.draw_snow()
        self.draw_interactables(self.npcs)
        self.draw_player()
        self.draw_hud()
        self.draw_location_caption()
        self.draw_blocked_msg()
        self.draw_active_dialogue()
        pyxel.text(20, H - 16, "Z: talk / examine    arrows: move", LBLUE)

    # ---- North Road (travel scene) -----------------------------------------
    def draw_travel(self):
        pyxel.cls(LBLUE)
        pyxel.rect(0, 90, W, H - 90, LBLUE)
        for i in range(6):
            mx = i * 100 - 20
            pyxel.tri(mx, 140, mx + 70, 140, mx + 35, 60, SLATE)
            pyxel.tri(mx + 20, 140, mx + 50, 140, mx + 35, 80, WHITE)
        pyxel.rect(W // 2 - 30, 0, 60, H, NAVY)
        for j in range(0, H, 24):
            pyxel.rect(W // 2 - 3, j, 6, 10, SLATE)
        draw_text_center_at(W // 2, 70, "NORTH", WHITE)

        self.draw_obstacles()
        self.draw_snow()
        self.draw_interactables(self.npcs)
        self.draw_player()
        self.draw_hud()
        self.draw_location_caption()
        self.draw_blocked_msg()
        self.draw_active_dialogue()
        pyxel.text(20, H - 16, "Z: talk / examine    arrows: move", LBLUE)

    # ---- Prison gate --------------------------------------------------------
    def draw_prison_gate(self):
        pyxel.cls(BLACK)
        pyxel.rect(0, 0, W, 100, NAVY)
        for i in range(10):
            x = i * (W // 9)
            pyxel.line(x, 0, x + math.sin(i * 1.3) * 30, 100, INDIGO)
        self.draw_cobblestone(0, 100, W, H)
        gx, gy, gw, gh = W // 2 - 70, 60, 140, 110
        pyxel.rect(gx, gy, gw, gh, BLACK)
        pyxel.rectb(gx, gy, gw, gh, SLATE)
        pyxel.rectb(gx + 4, gy + 4, gw - 8, gh - 8, CYAN)
        draw_text_center_at(W // 2, gy + gh + 8, "PRISON GATE", CYAN)
        pyxel.rect(gx - 40, gy - 20, 24, gh + 20, SLATE)
        pyxel.rect(gx + gw + 16, gy - 20, 24, gh + 20, SLATE)

        self.draw_obstacles()
        self.draw_snow()
        self.draw_interactables(self.npcs)
        self.draw_player()
        self.draw_hud()
        self.draw_location_caption()
        self.draw_blocked_msg()
        self.draw_active_dialogue()
        pyxel.text(20, H - 16, "Z: talk / examine    arrows: move", LBLUE)

    # ---- combat ----------------------------------------------------------
    def draw_ground_deco(self):
        if not hasattr(self, "ground_deco"):
            return
        for d in self.ground_deco:
            if d["kind"] == "drift":
                x, y, r = d["x"], d["y"], d["r"]
                pyxel.circ(x, y, r, WHITE)
                pyxel.circ(x - r // 3, y - r // 4, max(2, r // 2), LBLUE)
            else:  # crack - a jagged fracture line with a short branch
                x, y, ang, ln = d["x"], d["y"], d["ang"], d["len"]
                ex = x + math.cos(ang) * ln
                ey = y + math.sin(ang) * ln
                mx = x + math.cos(ang) * ln * 0.5
                my = y + math.sin(ang) * ln * 0.5
                pyxel.line(x, y, ex, ey, INDIGO)
                bx = mx + math.cos(ang + 0.9) * ln * 0.35
                by = my + math.sin(ang + 0.9) * ln * 0.35
                pyxel.line(mx, my, bx, by, INDIGO)

    def draw_combat(self):
        pyxel.cls(LBLUE)
        pyxel.rect(0, 60, W, H - 60, LBLUE)
        self.draw_ground_deco()
        self.draw_obstacles()
        self.draw_snow()
        pyxel.text(W // 2 - 70, 64, "FROZEN PLAINS  -  survive the ambush", NAVY)
        for e in self.enemies:
            e.draw()
        self.draw_projectiles()
        self.draw_player()
        self.draw_hud()
        pyxel.text(W // 2 - 90, H - 14,
                   "Wave %d   enemies left: %d" % (self.wave, len(self.enemies)),
                   NAVY)
        if self.ambush_flash > 0 and (pyxel.frame_count // 4) % 2 == 0:
            draw_text_center(150, "AMBUSH!", RED)

    # ---- puzzle ----------------------------------------------------------
    def draw_puzzle(self):
        pyxel.cls(INDIGO)
        self.draw_cobblestone(0, 60, W, H)
        # the sealed gate
        pyxel.rect(W // 2 - 60, 70, 120, 60, BLACK)
        lit = sum(1 for r in self.runes if r["lit"])
        pyxel.rectb(W // 2 - 60, 70, 120, 60, CYAN if lit < 3 else GREEN)
        pyxel.text(W // 2 - 36, 95, "PRISON GATE", CYAN if lit < 3 else GREEN)
        self.draw_obstacles()
        self.draw_snow()
        for r in self.runes:
            col = r["col"] if r["lit"] else SLATE
            pyxel.circ(r["x"], r["y"], 20, BLACK)
            pyxel.circb(r["x"], r["y"], 20, col)
            pyxel.circb(r["x"], r["y"], 14, col)
            if r["lit"]:
                pyxel.circ(r["x"], r["y"], 8, r["col"])
            draw_text_center_at(r["x"], r["y"] + 26, r["name"], col)
        for e in self.spectres:
            e.draw()
        self.draw_projectiles()
        self.draw_player()
        self.draw_hud()
        # message + optional hint
        pyxel.rect(20, H - 60, W - 40, 44, BLACK)
        pyxel.rectb(20, H - 60, W - 40, 44, WHITE)
        pyxel.text(28, H - 52, self.puzzle_msg, WHITE)
        pyxel.text(28, H - 40, "Stand on a rune and press Z to activate.", LBLUE)
        if self.char["hint"]:
            pyxel.text(28, H - 28, "Scholar's insight: LIFE -> BALANCE -> DEATH",
                       YELLOW)
        else:
            done = " ".join(self.runes[i]["name"] for i in self.order) or "-"
            pyxel.text(28, H - 28, "Sequence so far: " + done, LBLUE)

    # ---- boss --------------------------------------------------------
    def draw_attack_telegraph(self):
        pa = self.pending_attack
        if not pa:
            return
        blink = (pyxel.frame_count // 4) % 2 == 0
        if pa["type"] == "spear_line":
            ang = pa["angle"]
            ex = self.boss_x + math.cos(ang) * 420
            ey = self.boss_y + math.sin(ang) * 420
            col = RED if blink else YELLOW
            pyxel.line(self.boss_x, self.boss_y, ex, ey, col)
        elif pa["type"] == "nova_ring":
            prog = 1 - pa["timer"] / pa["duration"]
            r = 10 + prog * 165
            col = RED if blink else (PINK if self.boss_phase == 3 else ORANGE)
            pyxel.circb(int(self.boss_x), int(self.boss_y), int(r), col)
            sa = pa["safe_angle"]
            for off in (-0.65, 0.65):
                wx = self.boss_x + math.cos(sa + off) * 170
                wy = self.boss_y + math.sin(sa + off) * 170
                pyxel.line(self.boss_x, self.boss_y, wx, wy, GREEN)
            if self.boss_phase == 3:
                draw_text_center_at(int(self.boss_x), int(self.boss_y) - 60,
                                    "BRACE FOR IMPACT", PINK)
        elif pa["type"] == "beam":
            tx, ty = pa["target"]
            bx, by = self.boss_x, self.boss_y
            dx, dy = tx - bx, ty - by
            L = math.hypot(dx, dy) or 1
            ux, uy = dx / L, dy / L
            ex, ey = bx + ux * 420, by + uy * 420
            col = RED if blink else WHITE
            pyxel.line(bx, by, ex, ey, col)
        elif pa["type"] == "blizzard":
            col = RED if blink else CYAN
            for (mx, my) in pa["marks"]:
                pyxel.circb(mx, my, 18, col)

    def draw_boss_ground(self):
        # dark cavern ceiling up top, with faint cracks running into it
        pyxel.rect(0, 0, W, 110, NAVY if self.boss_phase < 3 else INDIGO)
        for i in range(8):
            x = i * (W // 7)
            pyxel.line(x, 0, x + math.sin(i) * 40, 110, INDIGO)
        # snow-covered cavern floor
        pyxel.rect(0, 110, W, H - 110, LBLUE)
        for d in self.boss_ground_deco:
            r = d["r"]
            pyxel.circ(d["x"], d["y"], r, WHITE)
            pyxel.circ(d["x"] - r // 3, d["y"] - r // 4, max(2, r // 2), LBLUE)
        # gnarled roots breaking up through the snow
        for segs in self.boss_roots:
            for (x1, y1, x2, y2, w) in segs:
                for o in range(-(w // 2), w // 2 + 1):
                    pyxel.line(x1 + o, y1, x2 + o, y2, BROWN)
                pyxel.line(x1, y1, x2, y2, dark(BROWN))

    def draw_boss(self):
        pyxel.cls(BLACK)
        self.draw_boss_ground()
        self.draw_snow()

        self.draw_attack_telegraph()

        bx, by = int(self.boss_x), int(self.boss_y)
        t = pyxel.frame_count
        staggered = self.stagger_timer > 0
        bc = WHITE if self.boss_flash > 0 else (PINK if staggered else CYAN)

        aura_r = 30 + math.sin(t * 0.1) * 3
        pyxel.circb(bx, by, int(aura_r), PINK if staggered else LBLUE)
        # flowing robe (two layered triangles for a bit of depth)
        pyxel.tri(bx - 20, by + 10, bx + 20, by + 10, bx, by - 4, dark(bc))
        pyxel.tri(bx - 13, by + 8, bx + 13, by + 8, bx, by - 2, bc)
        # head / core
        pyxel.circ(bx, by - 10, 16, bc)
        pyxel.circ(bx, by - 10, 10, LBLUE)
        pyxel.circ(bx, by - 10, 5, WHITE)
        pyxel.pset(bx - 4, by - 12, RED if staggered else BLACK)
        pyxel.pset(bx + 4, by - 12, RED if staggered else BLACK)
        # crown of ice shards
        for k in range(-3, 4):
            hx_ = bx + k * 7
            hgt = 14 + (4 if k % 2 == 0 else 0)
            pyxel.tri(hx_, by - 22, hx_ - 3, by - 22 + hgt, hx_ + 3, by - 22 + hgt, WHITE)

        if self.nova_fx > 0:
            r = (12 - self.nova_fx) * 8 + 20
            pyxel.circb(bx, by, int(r), WHITE)
        if self.beam_fx_timer > 0 and self.beam_fx_line:
            x1, y1, x2, y2 = self.beam_fx_line
            pyxel.line(x1, y1, x2, y2, WHITE)

        for w in self.weakpoints:
            w.draw()
        for e in self.enemies:
            e.draw()
        self.draw_projectiles()
        self.draw_player()
        self.draw_hud()

        # boss hp + phase
        pyxel.rect(W // 2 - 160, 30, 320, 10, BLACK)
        pyxel.rect(W // 2 - 158, 32, 316, 6, RED)
        pyxel.rect(W // 2 - 158, 32,
                   int(316 * clamp(self.boss_hp, 0, self.boss_max) / self.boss_max),
                   6, PINK if staggered else PURPLE)
        names = {1: "Phase 1: Divine Avatar", 2: "Phase 2: Winter Incarnate",
                 3: "Phase 3: Tree Fusion"}
        label = "AURIL, the Snow Goddess  -  " + names[self.boss_phase]
        if staggered:
            label = "AURIL IS STAGGERED -- ATTACK NOW!"
        draw_text_center(44, label, PINK if staggered else WHITE)
        if self.weakpoints:
            draw_text_center(H - 20, "Break the Frost Shards to stagger Auril!", CYAN)
        if self.push_fx > 0 and (pyxel.frame_count // 3) % 2 == 0:
            draw_text_center(60, "KNOCKED BACK!", PINK)

    # ---- endings -----------------------------------------------------
    def draw_win(self):
        pyxel.cls(NAVY)
        # warm sunrise
        for r in range(160, 0, -8):
            col = ORANGE if r < 90 else YELLOW if r < 130 else PEACH
            pyxel.circ(W // 2, H, r + 40, col)
        self.draw_snow()
        draw_big(W // 2 - 100, 140, "WINTER HAS ENDED", WHITE)
        for i, line in enumerate(wrap_text(
                "The corruption drains from the Tree of Life. Snow melts, "
                "rivers wake, and the villages of Frosthaven stir toward spring. "
                "Your hero watches the first sunrise in decades.", 70)):
            draw_text_center(200 + i * 14, line, LBLUE)
        draw_text_center(380, "- THE END -", YELLOW)
        if (pyxel.frame_count // 20) % 2 == 0:
            draw_text_center(420, "Press ENTER to return to the title", WHITE)

    def draw_lose(self):
        pyxel.cls(BLACK)
        self.draw_snow()
        draw_big(W // 2 - 110, 140, "THE WINTER CONTINUES", LBLUE)
        msgs = {
            "ambush": "The ice claims you on the frozen plains.",
            "spectres": "The spectres drag you down among the runestones.",
            "auril": "Auril's frost seals your heart forever.",
        }
        reason = msgs.get(getattr(self, "lose_reason", ""), "The cold takes you.")
        for i, line in enumerate(wrap_text(
                reason + " Years later, a traveler finds an old informant alone "
                "in the Frosthaven tavern, repeating the same warning: "
                "'If you seek the truth, travel north...' The cycle begins again.",
                70)):
            draw_text_center(210 + i * 14, line, WHITE)
        draw_text_center(390, "- FADE TO BLACK -", SLATE)
        if (pyxel.frame_count // 20) % 2 == 0:
            draw_text_center(430, "Press R to try again", YELLOW)


# ----------------------------------------------------------------------------
if __name__ == "__main__":
    Game()