import pyxel
import random
import math

# ==================================================
# CONFIG
# ==================================================
W, H = 384, 288
TILE = 8
MAP_COLS = 84
MAP_ROWS = 64
MAP_W = MAP_COLS * TILE
MAP_H = MAP_ROWS * TILE

T_WALL = 0
T_FLOOR = 1
T_PORTAL = 2

ROOM_NORMAL = 0
ROOM_SPIDER = 1
ROOM_BAT = 2
ROOM_MAGE = 3
ROOM_TREASURE = 4
ROOM_START = 5
ROOM_BOSS = 6

ST_MENU = 0
ST_TUTORIAL = 1
ST_SELECT = 2
ST_BOSS_INTRO = 3
ST_PLAY = 4
ST_BOSS = 5
ST_PAUSE = 6
ST_WIN = 7
ST_DEAD = 8

C_BLACK = 0
C_DARK = 1
C_PURPLE = 2
C_GREEN = 3
C_BROWN = 4
C_DGRAY = 5
C_LGRAY = 6
C_WHITE = 7
C_RED = 8
C_ORANGE = 9
C_YELLOW = 10
C_LGREEN = 11
C_CYAN = 12
C_LBLUE = 13
C_BLUE = 14
C_PINK = 15

SFX_SELECT = 0
SFX_CONFIRM = 1
SFX_SWORD = 2
SFX_FIRE = 3
SFX_CHARGE = 4
SFX_HIT = 5
SFX_HURT = 6
SFX_ENEMY_DIE = 7
SFX_BOSS_DIE = 8
SFX_PICKUP = 9
SFX_PORTAL = 10
SFX_WIN = 11
SFX_DEATH = 12
SFX_CHEST = 13
SFX_ROAR = 14
SFX_FOOTSTEP = 15
SFX_CHEST_OPEN = 16
SFX_RELIC = 17
SFX_STAGE_CLEAR = 18
SFX_PORTAL_READY = 19
SFX_CRIT_HP = 20
SFX_TELEPORT = 21
SFX_BITE = 22
SFX_SCREECH = 23
SFX_DODGE = 24
SFX_BLEED = 25
SFX_FROST = 26
SFX_MUS_MENU = 27
SFX_MUS_DUNG = 28
SFX_MUS_BOSS = 29


# ==================================================
# HELPERS
# ==================================================
def clamp(v, lo, hi):
    return max(lo, min(hi, v))


def draw_text_center(y, text, col):
    pyxel.text(W // 2 - len(text) * 2, y, text, col)


def draw_text_shadow(x, y, text, col, shadow_col=C_BLACK):
    pyxel.text(x + 1, y + 1, text, shadow_col)
    pyxel.text(x, y, text, col)


def draw_text_center_shadow(y, text, col, shadow_col=C_BLACK):
    draw_text_shadow(W // 2 - len(text) * 2, y, text, col, shadow_col)


def length(x, y):
    return math.sqrt(x * x + y * y)


def normalize(x, y):
    d = length(x, y)
    if d == 0:
        return 0.0, 0.0
    return x / d, y / d


def angle_diff(a, b):
    return (a - b + math.pi) % (2 * math.pi) - math.pi


def tile_solid(tilemap, tx, ty):
    if tx < 0 or ty < 0 or tx >= MAP_COLS or ty >= MAP_ROWS:
        return True
    return tilemap[ty][tx] == T_WALL


def has_line_of_sight(tilemap, x1, y1, x2, y2):
    """Raycast from (x1,y1) to (x2,y2). False if a wall blocks."""
    dx = x2 - x1
    dy = y2 - y1
    dist = length(dx, dy)
    if dist < 1:
        return True
    steps = max(2, int(dist / 3))
    for i in range(1, steps):
        t = i / steps
        sx = x1 + dx * t
        sy = y1 + dy * t
        if tile_solid(tilemap, int(sx) // TILE, int(sy) // TILE):
            return False
    return True


def world_to_screen(x, y, cam_x, cam_y):
    return int(x - cam_x), int(y - cam_y)


def point_in_room(tx, ty, room):
    rx, ry, rw, rh = room
    return rx <= tx < rx + rw and ry <= ty < ry + rh


def point_near_tiles(tx, ty, tiles, radius=1):
    for gx, gy in tiles:
        if abs(tx - gx) <= radius and abs(ty - gy) <= radius:
            return True
    return False


def room_center(room):
    rx, ry, rw, rh = room
    return rx + rw // 2, ry + rh // 2


def is_spider_web_tile(tx, ty, room_type):
    return room_type == ROOM_SPIDER and (tx + ty) % 6 == 0


def try_play(ch, snd):
    try:
        pyxel.play(ch, snd)
    except Exception:
        pass


# ==================================================
# THEMES
# ==================================================
THEMES = {
    0: {
        "name": "STAGE 1", "label_col": C_LBLUE,
        "wall_main": C_DGRAY, "wall_brick": C_DARK, "wall_mortar": C_BLACK, "wall_light": C_LGRAY,
        "wall_vine": C_GREEN, "wall_moss": C_LGREEN, "wall_stain": C_RED,
        "floor_main": C_BROWN, "floor_alt": C_DARK, "floor_spark": C_LGRAY,
        "floor_crack": C_BLACK, "floor_bone": C_WHITE, "floor_puddle": C_LBLUE,
        "portal_a": C_CYAN, "portal_b": C_BLUE,
    },
    1: {
        "name": "STAGE 2", "label_col": C_YELLOW,
        "wall_main": C_PURPLE, "wall_brick": C_DARK, "wall_mortar": C_BLACK, "wall_light": C_PINK,
        "wall_vine": C_GREEN, "wall_moss": C_LGREEN, "wall_stain": C_RED,
        "floor_main": C_DARK, "floor_alt": C_PURPLE, "floor_spark": C_PINK,
        "floor_crack": C_BLACK, "floor_bone": C_LGRAY, "floor_puddle": C_BLUE,
        "portal_a": C_LGREEN, "portal_b": C_CYAN,
    },
    2: {
        "name": "FINAL", "label_col": C_ORANGE,
        "wall_main": C_RED, "wall_brick": C_DARK, "wall_mortar": C_BLACK, "wall_light": C_YELLOW,
        "wall_vine": C_ORANGE, "wall_moss": C_BROWN, "wall_stain": C_BLACK,
        "floor_main": C_DARK, "floor_alt": C_RED, "floor_spark": C_ORANGE,
        "floor_crack": C_YELLOW, "floor_bone": C_WHITE, "floor_puddle": C_RED,
        "portal_a": C_ORANGE, "portal_b": C_YELLOW,
    },
}


# ==================================================
# DUNGEON GENERATION
# ==================================================
def carve_room(grid, rx, ry, rw, rh):
    for ty in range(ry, ry + rh):
        for tx in range(rx, rx + rw):
            grid[ty][tx] = T_FLOOR


def carve_h_tunnel(grid, x1, x2, y):
    a, b = min(x1, x2), max(x1, x2)
    for x in range(a, b + 1):
        if 0 <= x < MAP_COLS and 0 <= y < MAP_ROWS:
            grid[y][x] = T_FLOOR


def carve_v_tunnel(grid, y1, y2, x):
    a, b = min(y1, y2), max(y1, y2)
    for y in range(a, b + 1):
        if 0 <= x < MAP_COLS and 0 <= y < MAP_ROWS:
            grid[y][x] = T_FLOOR


def choose_room_types(rooms, is_boss):
    room_types = [ROOM_NORMAL for _ in rooms]
    if not rooms:
        return room_types
    room_types[0] = ROOM_START
    if is_boss:
        room_types[-1] = ROOM_BOSS
        for i in range(1, len(rooms) - 1):
            room_types[i] = random.choice([ROOM_NORMAL, ROOM_MAGE, ROOM_SPIDER, ROOM_BAT])
        return room_types
    candidate_indices = list(range(1, len(rooms)))
    random.shuffle(candidate_indices)
    num_treasures = 2 if len(rooms) >= 4 else 1
    treasure_indices = set(candidate_indices[:num_treasures])
    for i in range(1, len(rooms)):
        if i in treasure_indices:
            room_types[i] = ROOM_TREASURE
        else:
            room_types[i] = random.choice([ROOM_NORMAL, ROOM_NORMAL, ROOM_SPIDER, ROOM_BAT, ROOM_MAGE])
    return room_types


def generate_dungeon(is_boss=False):
    grid = [[T_WALL] * MAP_COLS for _ in range(MAP_ROWS)]
    rooms = []
    if is_boss:
        num_rooms, room_min, room_max = 3, 18, 28
    else:
        num_rooms, room_min, room_max = random.randint(12, 16), 7, 14
    attempts = 0
    while len(rooms) < num_rooms and attempts < 700:
        attempts += 1
        rw = random.randint(room_min, room_max)
        rh = random.randint(room_min, room_max)
        rx = random.randint(2, MAP_COLS - rw - 3)
        ry = random.randint(2, MAP_ROWS - rh - 3)
        overlap = False
        for ox, oy, ow, oh in rooms:
            if rx < ox + ow + 2 and rx + rw + 2 > ox and ry < oy + oh + 2 and ry + rh + 2 > oy:
                overlap = True
                break
        if overlap:
            continue
        carve_room(grid, rx, ry, rw, rh)
        rooms.append((rx, ry, rw, rh))
    if not rooms:
        rooms = [(4, 4, 12, 12)]
        carve_room(grid, 4, 4, 12, 12)
    for i in range(1, len(rooms)):
        ax, ay = room_center(rooms[i - 1])
        bx, by = room_center(rooms[i])
        if random.random() < 0.5:
            carve_h_tunnel(grid, ax, bx, ay)
            carve_h_tunnel(grid, ax, bx, ay + 1)
            carve_v_tunnel(grid, ay, by, bx)
            carve_v_tunnel(grid, ay, by, bx + 1)
        else:
            carve_v_tunnel(grid, ay, by, ax)
            carve_v_tunnel(grid, ay, by, ax + 1)
            carve_h_tunnel(grid, ax, bx, by)
            carve_h_tunnel(grid, ax, bx, by + 1)
    last_room = rooms[-1]
    portal_cx = last_room[0] + last_room[2] // 2
    portal_y = last_room[1]
    portal_tiles = []
    # BIGGER portal: 5 tiles wide, 2 tiles tall
    for dx in (-2, -1, 0, 1, 2):
        for dy in (0, 1):
            tx = portal_cx + dx
            ty = portal_y + dy
            if 0 <= tx < MAP_COLS and 0 <= ty < MAP_ROWS:
                grid[ty][tx] = T_PORTAL
                portal_tiles.append((tx, ty))
    return grid, rooms, portal_tiles, choose_room_types(rooms, is_boss)


def generate_tutorial_map():
    grid = [[T_WALL] * MAP_COLS for _ in range(MAP_ROWS)]
    rooms = [(6, 16, 16, 16), (30, 16, 16, 16), (54, 16, 16, 16)]
    for room in rooms:
        carve_room(grid, room[0], room[1], room[2], room[3])
    carve_h_tunnel(grid, 21, 30, 24)
    carve_h_tunnel(grid, 45, 54, 24)
    return grid, rooms, [], [ROOM_START, ROOM_MAGE, ROOM_BOSS]


def style_boss_arena(grid, room):
    rx, ry, rw, rh = room
    cx = rx + rw // 2
    cy = ry + rh // 2
    for ty in range(ry, ry + rh):
        for tx in range(rx, rx + rw):
            if 0 <= tx < MAP_COLS and 0 <= ty < MAP_ROWS:
                grid[ty][tx] = T_FLOOR
    blocks = [(cx - 10, cy - 6), (cx + 10, cy - 6), (cx - 10, cy + 6),
              (cx + 10, cy + 6), (cx - 5, cy + 9), (cx + 5, cy + 9)]
    for bx, by in blocks:
        for yy in range(by - 1, by + 2):
            for xx in range(bx - 1, bx + 2):
                if 0 <= xx < MAP_COLS and 0 <= yy < MAP_ROWS:
                    grid[yy][xx] = T_WALL
    portal_cx = rx + rw // 2
    for clear_y in range(ry, ry + 6):
        for dx in (-2, -1, 0, 1, 2):
            tx = portal_cx + dx
            if 0 <= tx < MAP_COLS and 0 <= clear_y < MAP_ROWS:
                if grid[clear_y][tx] == T_WALL:
                    grid[clear_y][tx] = T_FLOOR


# ==================================================
# BASE ENTITY
# ==================================================
class Entity:
    def __init__(self, x, y, hp, col):
        self.x = float(x); self.y = float(y)
        self.hp = hp; self.max_hp = hp
        self.col = col
        self.vx = 0.0; self.vy = 0.0
        self.alive = True
        self.iframes = 0
        self.anim_tick = 0
        self.anim_frame = 0
        self.hit_flash_timer = 0

    def tile_x(self):
        return int(self.x) // TILE

    def tile_y(self):
        return int(self.y) // TILE

    def move(self, tilemap, hitbox=6):
        nx = self.x + self.vx
        ny = self.y + self.vy
        half = hitbox // 2
        tx_l = int(nx - half) // TILE
        tx_r = int(nx + half - 1) // TILE
        ty_t = int(self.y - half) // TILE
        ty_b = int(self.y + half - 1) // TILE
        if not (tile_solid(tilemap, tx_l, ty_t) or tile_solid(tilemap, tx_l, ty_b)
                or tile_solid(tilemap, tx_r, ty_t) or tile_solid(tilemap, tx_r, ty_b)):
            self.x = nx
        tx_l = int(self.x - half) // TILE
        tx_r = int(self.x + half - 1) // TILE
        ty_t = int(ny - half) // TILE
        ty_b = int(ny + half - 1) // TILE
        if not (tile_solid(tilemap, tx_l, ty_t) or tile_solid(tilemap, tx_r, ty_t)
                or tile_solid(tilemap, tx_l, ty_b) or tile_solid(tilemap, tx_r, ty_b)):
            self.y = ny
        self.x = clamp(self.x, 6, MAP_W - 7)
        self.y = clamp(self.y, 6, MAP_H - 7)

    def hurt(self, dmg):
        if self.iframes > 0 or not self.alive:
            return False
        self.hp -= dmg
        self.iframes = 18
        self.hit_flash_timer = 8
        if self.hp <= 0:
            self.hp = 0
            self.alive = False
        return True

    def update_common(self):
        self.iframes = max(0, self.iframes - 1)
        self.hit_flash_timer = max(0, self.hit_flash_timer - 1)

    def update_anim(self, moving, speed=8):
        if moving:
            self.anim_tick += 1
            if self.anim_tick >= speed:
                self.anim_tick = 0
                self.anim_frame = (self.anim_frame + 1) % 4
        else:
            self.anim_tick = 0
            self.anim_frame = 0

    def draw_hp_bar(self, sx, sy, bw=28):
        pyxel.rect(sx, sy, bw, 3, C_DARK)
        fill = int(bw * self.hp / self.max_hp) if self.max_hp > 0 else 0
        pyxel.rect(sx, sy, fill, 3, C_RED)


# ==================================================
# PARTICLES / FX
# ==================================================
class Particle:
    def __init__(self, x, y, vx, vy, life, col, size=1, gravity=0.0, shrink=False):
        self.x = float(x); self.y = float(y)
        self.vx = float(vx); self.vy = float(vy)
        self.life = life; self.max_life = life
        self.col = col; self.size = size
        self.gravity = gravity; self.shrink = shrink
        self.alive = True

    def update(self):
        self.x += self.vx
        self.y += self.vy
        self.vy += self.gravity
        self.vx *= 0.98
        self.vy *= 0.98
        self.life -= 1
        if self.life <= 0:
            self.alive = False

    def draw(self, cam_x, cam_y):
        if not self.alive:
            return
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        s = self.size
        if self.shrink:
            s = max(1, int(self.size * self.life / self.max_life))
        if s <= 1:
            pyxel.pset(sx, sy, self.col)
        elif s == 2:
            pyxel.rect(sx, sy, 2, 2, self.col)
        else:
            pyxel.rect(sx - s // 2, sy - s // 2, s, s, self.col)


class Effect:
    def __init__(self, x, y, kind):
        self.x = float(x); self.y = float(y)
        self.kind = kind
        self.life = 20
        self.alive = True

    def update(self):
        self.life -= 1
        if self.life <= 0:
            self.alive = False

    def draw(self, cam_x, cam_y):
        if not self.alive:
            return
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        if self.kind == "mage_cast":
            r = max(1, (20 - self.life) // 3 + 1)
            pyxel.circb(sx, sy, r + 2, C_ORANGE)
            pyxel.circb(sx, sy, r, C_YELLOW)
            pyxel.pset(sx, sy, C_WHITE)
        elif self.kind == "heal":
            pyxel.text(sx - 8, sy - self.life, "HEAL", C_LGREEN)
        elif self.kind == "speed":
            pyxel.text(sx - 10, sy - self.life, "SPEED", C_CYAN)
        elif self.kind == "damage":
            pyxel.text(sx - 11, sy - self.life, "DAMAGE", C_RED)
        elif self.kind == "hp":
            pyxel.text(sx - 8, sy - self.life, "+HP", C_PINK)
        elif self.kind == "hit":
            pyxel.circb(sx, sy, max(1, (20 - self.life) // 4 + 1), C_WHITE)
        elif self.kind == "big_hit":
            r = max(1, (20 - self.life) // 2 + 1)
            pyxel.circb(sx, sy, r, C_YELLOW)
            pyxel.circb(sx, sy, max(1, r - 2), C_WHITE)
        elif self.kind == "second_wind":
            pyxel.text(sx - 22, sy - self.life, "SECOND WIND!", C_PINK)
            r = max(1, (20 - self.life) + 1)
            pyxel.circb(sx, sy + 4, r, C_PINK)
            pyxel.circb(sx, sy + 4, max(1, r - 3), C_WHITE)
# ==================================================
# PICKUPS / CHESTS / PROJECTILES
# ==================================================
class HealPotion:
    def __init__(self, x, y, room_id):
        self.x = float(x); self.y = float(y)
        self.room_id = room_id
        self.alive = True
        self.float_tick = random.randint(0, 30)

    def update(self, player, game):
        if not self.alive:
            return
        self.float_tick += 1
        if abs(player.x - self.x) < 8 and abs(player.y - self.y) < 8:
            player.hp = player.max_hp
            self.alive = False
            game.spawn_pickup_particles(self.x, self.y, C_PINK)
            game.effects.append(Effect(self.x, self.y, "heal"))
            try_play(2, SFX_PICKUP)

    def draw(self, cam_x, cam_y):
        if not self.alive:
            return
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        sy += -1 if (self.float_tick // 8) % 2 == 0 else 0
        if sx < -8 or sy < -8 or sx > W + 8 or sy > H + 8:
            return
        pyxel.rect(sx - 2, sy - 4, 5, 7, C_PINK)
        pyxel.rect(sx - 1, sy - 6, 3, 2, C_LBLUE)
        pyxel.pset(sx, sy - 1, C_WHITE)
        pyxel.pset(sx + 1, sy, C_WHITE)


class Chest:
    def __init__(self, x, y, room_id):
        self.x = float(x); self.y = float(y)
        self.room_id = room_id
        self.opened = False

    def draw(self, cam_x, cam_y, selected=False):
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        if sx < -16 or sy < -16 or sx > W + 16 or sy > H + 16:
            return
        if self.opened:
            pyxel.rect(sx - 7, sy - 1, 14, 8, C_BROWN)
            pyxel.rectb(sx - 7, sy - 1, 14, 8, C_BLACK)
            pyxel.rect(sx - 6, sy, 12, 3, C_BLACK)
            pyxel.rect(sx - 5, sy + 1, 10, 1, C_DARK)
            pyxel.tri(sx - 7, sy - 1, sx + 7, sy - 1, sx - 7, sy - 7, C_BROWN)
            pyxel.line(sx - 7, sy - 1, sx - 7, sy - 7, C_BLACK)
            pyxel.line(sx - 7, sy - 7, sx + 7, sy - 1, C_BLACK)
            pyxel.line(sx - 5, sy - 2, sx + 4, sy - 2, C_DARK)
            pyxel.line(sx - 3, sy - 4, sx + 2, sy - 4, C_DARK)
            pyxel.rect(sx - 7, sy - 1, 2, 2, C_DGRAY)
            pyxel.rect(sx + 5, sy - 1, 2, 2, C_DGRAY)
            pyxel.rect(sx - 7, sy + 5, 2, 2, C_DGRAY)
            pyxel.rect(sx + 5, sy + 5, 2, 2, C_DGRAY)
            pyxel.rect(sx - 6, sy + 7, 2, 1, C_DARK)
            pyxel.rect(sx + 4, sy + 7, 2, 1, C_DARK)
        else:
            pyxel.rect(sx - 7, sy - 1, 14, 8, C_BROWN)
            pyxel.rectb(sx - 7, sy - 1, 14, 8, C_BLACK)
            pyxel.line(sx - 6, sy + 1, sx + 5, sy + 1, C_DARK)
            pyxel.line(sx - 6, sy + 4, sx + 5, sy + 4, C_DARK)
            pyxel.rect(sx - 7, sy - 1, 2, 2, C_DGRAY)
            pyxel.rect(sx + 5, sy - 1, 2, 2, C_DGRAY)
            pyxel.rect(sx - 7, sy + 5, 2, 2, C_DGRAY)
            pyxel.rect(sx + 5, sy + 5, 2, 2, C_DGRAY)
            pyxel.pset(sx - 6, sy, C_LGRAY)
            pyxel.pset(sx + 6, sy, C_LGRAY)
            pyxel.pset(sx - 6, sy + 6, C_LGRAY)
            pyxel.pset(sx + 6, sy + 6, C_LGRAY)
            pyxel.rect(sx - 7, sy - 6, 14, 6, C_BROWN)
            pyxel.rectb(sx - 7, sy - 6, 14, 6, C_BLACK)
            pyxel.pset(sx - 7, sy - 6, C_BROWN)
            pyxel.pset(sx + 6, sy - 6, C_BROWN)
            pyxel.rect(sx - 7, sy - 4, 14, 1, C_DGRAY)
            pyxel.pset(sx - 4, sy - 4, C_LGRAY)
            pyxel.pset(sx + 3, sy - 4, C_LGRAY)
            pyxel.rect(sx - 7, sy - 6, 2, 2, C_DGRAY)
            pyxel.rect(sx + 5, sy - 6, 2, 2, C_DGRAY)
            pyxel.rect(sx - 2, sy - 3, 5, 5, C_YELLOW)
            pyxel.rectb(sx - 2, sy - 3, 5, 5, C_ORANGE)
            pyxel.pset(sx, sy - 2, C_BLACK)
            pyxel.pset(sx, sy - 1, C_BLACK)
            pyxel.pset(sx + 1, sy, C_BLACK)
            pyxel.pset(sx - 1, sy, C_BLACK)
            pyxel.rect(sx - 6, sy + 7, 2, 1, C_DARK)
            pyxel.rect(sx + 4, sy + 7, 2, 1, C_DARK)
            sparkle_phase = (pyxel.frame_count // 6) % 4
            if sparkle_phase == 0:
                pyxel.pset(sx - 5, sy - 9, C_YELLOW); pyxel.pset(sx - 5, sy - 10, C_WHITE)
            elif sparkle_phase == 1:
                pyxel.pset(sx + 4, sy - 9, C_WHITE); pyxel.pset(sx + 3, sy - 10, C_YELLOW)
            elif sparkle_phase == 2:
                pyxel.pset(sx, sy - 11, C_YELLOW); pyxel.pset(sx, sy - 12, C_WHITE)
            if (pyxel.frame_count // 12) % 2 == 0:
                pyxel.pset(sx - 8, sy + 2, C_YELLOW); pyxel.pset(sx + 7, sy + 2, C_YELLOW)
        if selected:
            pulse = C_YELLOW if (pyxel.frame_count // 4) % 2 == 0 else C_WHITE
            pyxel.rectb(sx - 10, sy - 10, 20, 20, pulse)
            pyxel.rectb(sx - 11, sy - 11, 22, 22, C_ORANGE)


class Projectile:
    def __init__(self, x, y, vx, vy, col, dmg, friendly, size=2, homing=False, from_mage=False):
        self.x = float(x); self.y = float(y)
        self.vx = vx; self.vy = vy
        self.col = col; self.dmg = dmg
        self.friendly = friendly
        self.alive = True
        self.life = 120
        self.size = size
        self.homing = homing
        self.from_mage = from_mage
        self.homing_delay = 14 if (friendly and homing) else 0
        self.frost = False

    def update(self, tilemap, enemies=None):
        if self.friendly and self.homing and enemies:
            if self.homing_delay > 0:
                self.homing_delay -= 1
            else:
                target = None
                best_d = 999999
                for e in enemies:
                    if not e.alive:
                        continue
                    d = length(e.x - self.x, e.y - self.y)
                    if d < best_d:
                        best_d = d
                        target = e
                if target and best_d < 140:
                    nx, ny = normalize(target.x - self.x, target.y - self.y)
                    steer = 0.12
                    speed = max(0.001, length(self.vx, self.vy))
                    curx, cury = normalize(self.vx, self.vy)
                    mixx = curx * (1.0 - steer) + nx * steer
                    mixy = cury * (1.0 - steer) + ny * steer
                    mixx, mixy = normalize(mixx, mixy)
                    self.vx = mixx * speed
                    self.vy = mixy * speed
        self.x += self.vx
        self.y += self.vy
        self.life -= 1
        if self.life <= 0:
            self.alive = False
            return
        if tile_solid(tilemap, int(self.x) // TILE, int(self.y) // TILE):
            self.alive = False

    def draw(self, cam_x, cam_y):
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        if -8 <= sx < W + 8 and -8 <= sy < H + 8:
            if self.size == 2:
                pyxel.rect(sx, sy, 2, 2, self.col)
            elif self.size == 3:
                pyxel.circ(sx, sy, 2, self.col)
                pyxel.pset(sx, sy, C_WHITE)
            else:
                pyxel.circ(sx, sy, 3, self.col)
                pyxel.circ(sx, sy, 1, C_YELLOW)
                pyxel.pset(sx, sy, C_WHITE)


# ==================================================
# RELIC ICONS / PLAYER SPRITES
# ==================================================
def draw_relic_icon(kind, x, y):
    pyxel.rect(x, y, 10, 10, C_BLACK)
    pyxel.rectb(x, y, 10, 10, C_DGRAY)
    if kind == "wide_arc":
        pyxel.line(x + 1, y + 4, x + 8, y + 2, C_YELLOW)
        pyxel.line(x + 1, y + 5, x + 8, y + 7, C_YELLOW)
        pyxel.pset(x + 8, y + 2, C_WHITE); pyxel.pset(x + 8, y + 7, C_WHITE)
    elif kind == "bleeding_edge":
        pyxel.circ(x + 5, y + 5, 2, C_RED)
        pyxel.pset(x + 5, y + 3, C_WHITE); pyxel.pset(x + 5, y + 7, C_RED)
    elif kind == "twin_shot":
        pyxel.circ(x + 3, y + 5, 1, C_ORANGE); pyxel.circ(x + 7, y + 5, 1, C_ORANGE)
        pyxel.pset(x + 3, y + 5, C_YELLOW); pyxel.pset(x + 7, y + 5, C_YELLOW)
    elif kind == "frost_shot":
        pyxel.line(x + 5, y + 2, x + 5, y + 8, C_LBLUE)
        pyxel.line(x + 2, y + 5, x + 8, y + 5, C_LBLUE)
        pyxel.line(x + 3, y + 3, x + 7, y + 7, C_CYAN)
        pyxel.line(x + 3, y + 7, x + 7, y + 3, C_CYAN)
        pyxel.pset(x + 5, y + 5, C_WHITE)
    elif kind == "charge_blast":
        pyxel.circb(x + 5, y + 5, 3, C_ORANGE); pyxel.circ(x + 5, y + 5, 1, C_YELLOW)
    elif kind == "iron_hide":
        pyxel.tri(x + 5, y + 1, x + 1, y + 5, x + 9, y + 5, C_LGRAY)
        pyxel.tri(x + 1, y + 5, x + 9, y + 5, x + 5, y + 9, C_LGRAY)
        pyxel.pset(x + 5, y + 5, C_WHITE)
    elif kind == "lifesteal":
        pyxel.rect(x + 2, y + 3, 6, 3, C_PINK)
        pyxel.pset(x + 3, y + 6, C_PINK); pyxel.pset(x + 5, y + 6, C_PINK)
        pyxel.pset(x + 4, y + 7, C_PINK); pyxel.pset(x + 4, y + 4, C_RED)
    elif kind == "thorns":
        pyxel.line(x + 5, y + 1, x + 5, y + 8, C_GREEN)
        pyxel.line(x + 1, y + 5, x + 8, y + 5, C_GREEN)
        pyxel.line(x + 2, y + 2, x + 7, y + 7, C_LGREEN)
        pyxel.line(x + 7, y + 2, x + 2, y + 7, C_LGREEN)
        pyxel.pset(x + 5, y + 5, C_WHITE)
    elif kind == "second_wind":
        pyxel.pset(x + 1, y + 3, C_WHITE); pyxel.line(x + 1, y + 4, x + 4, y + 5, C_WHITE)
        pyxel.pset(x + 8, y + 3, C_WHITE); pyxel.line(x + 8, y + 4, x + 5, y + 5, C_WHITE)
        pyxel.pset(x + 4, y + 5, C_YELLOW); pyxel.pset(x + 5, y + 5, C_YELLOW)


def draw_player_sprite(kind, sx, sy, frame, facing_x, ducking=False, swinging=False,
                       swing_phase=0, charging=False, aim_angle=0.0, hurt_flash=False,
                       show_bull_aim=True, rolling=False, relics=None):
    relics = relics or {}
    head_y = sy - 10
    torso_y = sy - 4
    if ducking or rolling:
        head_y += 3
        torso_y += 3
    arm_patterns = [(-5, -1, 5, 1), (-6, 0, 6, -1), (-5, 1, 5, 0), (-4, 0, 4, 1)]
    ax1, ay1, ax2, ay2 = arm_patterns[frame]
    leg_patterns = [(-2, 2, 0, 0), (-4, 3, 1, -1), (-2, 2, 0, 0), (-1, 4, -1, 1)]
    lx1, lx2, ly1, ly2 = leg_patterns[frame]

    if kind == 0:
        body = C_WHITE if hurt_flash else C_LGRAY
        cape_sway = (frame - 1) if not ducking else 0
        pyxel.tri(sx - 3, torso_y + 1, sx + 3, torso_y + 1, sx + cape_sway, torso_y + 11, C_RED)
        pyxel.tri(sx - 2, torso_y + 2, sx + 2, torso_y + 2, sx + cape_sway, torso_y + 10, C_BROWN)
        pyxel.rect(sx - 3, head_y, 6, 6, body)
        pyxel.rect(sx - 3, head_y + 6, 6, 1, C_DGRAY)
        pyxel.rect(sx - 3, head_y + 2, 6, 1, C_BLACK)
        pyxel.pset(sx - 1, head_y + 2, C_CYAN); pyxel.pset(sx + 1, head_y + 2, C_CYAN)
        pyxel.pset(sx - 3, head_y + 1, C_DGRAY); pyxel.pset(sx + 3, head_y + 1, C_DGRAY)
        pyxel.pset(sx - 3, head_y + 4, C_DGRAY); pyxel.pset(sx + 3, head_y + 4, C_DGRAY)
        plume_sway = (frame % 2)
        pyxel.rect(sx, head_y - 3, 2, 3, C_RED)
        pyxel.pset(sx + plume_sway, head_y - 4, C_ORANGE)
        pyxel.pset(sx + plume_sway, head_y - 5, C_YELLOW)
        pyxel.rect(sx - 4, torso_y, 8, 9, body)
        pyxel.rect(sx - 5, torso_y, 2, 3, C_DGRAY); pyxel.rect(sx + 4, torso_y, 2, 3, C_DGRAY)
        pyxel.pset(sx - 5, torso_y, C_LGRAY); pyxel.pset(sx + 5, torso_y, C_LGRAY)
        pyxel.rect(sx - 2, torso_y + 1, 4, 4, C_WHITE)
        pyxel.rect(sx - 1, torso_y + 1, 2, 4, C_RED)
        pyxel.rect(sx - 2, torso_y + 2, 4, 2, C_RED)
        pyxel.pset(sx, torso_y + 3, C_YELLOW)
        pyxel.rect(sx - 4, torso_y + 6, 8, 2, C_DARK)
        pyxel.pset(sx, torso_y + 7, C_YELLOW)
    elif kind == 1:
        body = C_WHITE if hurt_flash else C_LBLUE
        pyxel.tri(sx - 5, head_y + 3, sx + 5, head_y + 3, sx, head_y - 4, C_PURPLE)
        pyxel.tri(sx - 4, head_y + 2, sx + 4, head_y + 2, sx, head_y - 3, C_BLUE)
        pyxel.rect(sx - 5, head_y + 1, 10, 2, C_DARK)
        pyxel.pset(sx - 5, head_y + 2, C_PURPLE); pyxel.pset(sx + 4, head_y + 2, C_PURPLE)
        pyxel.pset(sx, head_y - 2, C_YELLOW); pyxel.pset(sx - 1, head_y - 1, C_YELLOW)
        pyxel.pset(sx + 1, head_y - 1, C_YELLOW); pyxel.pset(sx, head_y - 3, C_YELLOW)
        pyxel.line(sx + 3, head_y - 3, sx + 4, head_y - 1, C_ORANGE)
        pyxel.pset(sx + 4, head_y, C_YELLOW)
        pyxel.rect(sx - 3, head_y + 3, 6, 3, C_BROWN)
        pyxel.pset(sx - 1, head_y + 4, C_CYAN); pyxel.pset(sx + 1, head_y + 4, C_CYAN)
        pyxel.rect(sx - 2, head_y + 5, 4, 1, C_WHITE); pyxel.pset(sx, head_y + 6, C_WHITE)
        pyxel.rect(sx - 4, torso_y, 8, 9, body)
        pyxel.line(sx - 4, torso_y + 8, sx + 3, torso_y + 8, C_PURPLE)
        pyxel.pset(sx - 2, torso_y + 2, C_YELLOW)
        pyxel.pset(sx + 2, torso_y + 4, C_YELLOW)
        pyxel.pset(sx, torso_y + 6, C_YELLOW)
        pyxel.rect(sx - 3, torso_y + 7, 6, 1, C_BROWN)
        pyxel.pset(sx + 2, torso_y + 7, C_YELLOW)
        staff_x = sx + 5 if facing_x >= 0 else sx - 5
        pyxel.line(staff_x, torso_y - 3, staff_x, torso_y + 7, C_BROWN)
        pyxel.pset(staff_x - 1, torso_y - 1, C_DARK)
        orb_pulse = (pyxel.frame_count // 6) % 2
        pyxel.circ(staff_x, torso_y - 4, 2, C_ORANGE)
        pyxel.circ(staff_x, torso_y - 4, 1, C_YELLOW)
        if orb_pulse:
            pyxel.pset(staff_x, torso_y - 4, C_WHITE)
    else:
        body = C_WHITE if hurt_flash else C_DGRAY
        pyxel.rect(sx - 7, head_y, 2, 2, C_LGRAY); pyxel.rect(sx + 5, head_y, 2, 2, C_LGRAY)
        pyxel.pset(sx - 8, head_y + 1, C_LGRAY); pyxel.pset(sx + 7, head_y + 1, C_LGRAY)
        pyxel.pset(sx - 6, head_y - 1, C_WHITE); pyxel.pset(sx + 6, head_y - 1, C_WHITE)
        pyxel.pset(sx - 7, head_y, C_DGRAY); pyxel.pset(sx + 6, head_y, C_DGRAY)
        pyxel.rect(sx - 5, head_y + 1, 10, 6, body)
        pyxel.rect(sx - 3, head_y + 5, 6, 3, C_BROWN)
        pyxel.circb(sx, head_y + 7, 1, C_YELLOW)
        pyxel.pset(sx - 1, head_y + 6, C_BLACK); pyxel.pset(sx + 1, head_y + 6, C_BLACK)
        pyxel.rect(sx - 3, head_y + 2, 2, 2, C_RED); pyxel.rect(sx + 1, head_y + 2, 2, 2, C_RED)
        pyxel.pset(sx - 2, head_y + 2, C_YELLOW); pyxel.pset(sx + 2, head_y + 2, C_YELLOW)
        pyxel.line(sx - 4, head_y + 1, sx - 2, head_y + 4, C_WHITE)
        pyxel.pset(sx - 5, head_y + 1, C_BROWN); pyxel.pset(sx + 5, head_y + 1, C_BROWN)
        pyxel.rect(sx - 5, torso_y, 10, 10, body)
        pyxel.rect(sx - 5, torso_y + 2, 10, 3, C_BLACK)
        pyxel.pset(sx - 3, torso_y + 3, C_YELLOW); pyxel.pset(sx, torso_y + 3, C_YELLOW)
        pyxel.pset(sx + 3, torso_y + 3, C_YELLOW)
        pyxel.line(sx - 5, torso_y, sx - 5, torso_y + 9, C_DARK)
        pyxel.line(sx + 4, torso_y, sx + 4, torso_y + 9, C_DARK)
        pyxel.rect(sx - 4, torso_y + 7, 8, 2, C_BROWN)
        pyxel.pset(sx, torso_y + 8, C_YELLOW)

    if ducking or rolling:
        pyxel.line(sx - 2, torso_y + 3, sx - 4, torso_y + 5, C_DGRAY)
        pyxel.line(sx + 2, torso_y + 3, sx + 4, torso_y + 5, C_DGRAY)
        pyxel.rect(sx - 4, torso_y + 8, 8, 3, C_DGRAY)
        if rolling:
            dust_col = C_LGRAY if (pyxel.frame_count // 2) % 2 == 0 else C_DGRAY
            pyxel.pset(sx - 5 if facing_x >= 0 else sx + 5, torso_y + 10, dust_col)
            pyxel.pset(sx - 6 if facing_x >= 0 else sx + 6, torso_y + 9, C_DGRAY)
    else:
        pyxel.line(sx - 3, torso_y + 2, sx + ax1, torso_y + ay1 + 4, C_DGRAY)
        pyxel.line(sx + 3, torso_y + 2, sx + ax2, torso_y + ay2 + 4, C_DGRAY)
        pyxel.line(sx - 2, torso_y + 9, sx + lx1, torso_y + 12 + ly1, C_DGRAY)
        pyxel.line(sx + 2, torso_y + 9, sx + lx2, torso_y + 12 + ly2, C_DGRAY)
        pyxel.pset(sx + lx1, torso_y + 12 + ly1, C_BLACK)
        pyxel.pset(sx + lx2, torso_y + 12 + ly2, C_BLACK)

    if kind == 0 and swinging:
        offsets = [(-2, -8), (1, -9), (5, -7), (8, -3), (6, 2), (3, 5)]
        ox, oy = offsets[swing_phase]
        if facing_x < 0:
            ox = -ox
        bx, by = sx + ox, sy + oy
        pyxel.line(sx, sy - 2, bx, by, C_WHITE)
        pyxel.line(sx + 1, sy - 2, bx + 1, by + 1, C_LBLUE)
        pyxel.pset(sx, sy - 2, C_YELLOW)
        pyxel.pset(bx - 2, by - 1, C_WHITE)
        pyxel.pset(bx + 2, by + 1, C_LBLUE)

    if kind == 2 and not charging and show_bull_aim:
        ax = sx + int(math.cos(aim_angle) * 14)
        ay = sy + int(math.sin(aim_angle) * 14)
        pyxel.line(sx, sy, ax, ay, C_LGREEN)
        pyxel.pset(ax, ay, C_YELLOW); pyxel.pset(ax + 1, ay, C_YELLOW)

    if kind == 2 and charging:
        pyxel.rectb(sx - 8, sy - 12, 16, 18, C_LGREEN)
        pyxel.pset(sx - 7 + (pyxel.frame_count % 16), sy - 11, C_YELLOW)


# ==================================================
# PLAYER CLASS
# ==================================================
class Player(Entity):
    def __init__(self, char, x, y):
        configs = {
            0: dict(hp=98, col=C_CYAN, speed=1.60, dmg=18, name="Knight"),
            1: dict(hp=58, col=C_LBLUE, speed=1.45, dmg=13, name="Mage"),
            2: dict(hp=125, col=C_GREEN, speed=1.48, dmg=13, name="Bull"),
        }
        c = configs[char]
        super().__init__(x, y, c["hp"], c["col"])
        self.char = char
        self.base_speed = c["speed"]; self.speed = c["speed"]
        self.base_dmg = c["dmg"]; self.dmg = c["dmg"]
        self.name = c["name"]
        self.relic_split_fire = False
        self.relic_wide_swing = False
        self.relic_charge_blast = False
        self.relic_bleeding = False
        self.relic_frost = False
        self.relic_iron_hide = False
        self.relic_lifesteal = False
        self.relic_thorns = False
        self.relic_second_wind = False
        self.second_wind_used = False
        self.second_wind_triggered = False
        self.last_damage_source = None
        self.taken_specials = set()
        self.attack_timer = 0
        self.attack_cooldown = 16
        self.facing_x = 1
        self.facing_y = 0
        self.ducking = False
        self.charging = False
        self.charge_timer = 0
        self.charge_dx = 0.0; self.charge_dy = 0.0
        self.aim_angle = 0.0
        self.swinging = False
        self.swing_timer = 0
        self.swing_total = 12
        self.swing_hit_done = False
        self.used_charge_once = False
        self.did_duck_this_life = False
        self.did_dodge_this_life = False
        self.slow_timer = 0
        self.rolling = False
        self.roll_timer = 0
        self.roll_dx = 0.0; self.roll_dy = 0.0
        self.last_k_press = -100
        self.roll_cooldown = 0
        self.step_tick = 0

    def get_speed_multiplier(self, rooms, room_types):
        tx = self.tile_x(); ty = self.tile_y()
        for i, room in enumerate(rooms):
            if point_in_room(tx, ty, room):
                if i < len(room_types) and is_spider_web_tile(tx, ty, room_types[i]):
                    return 0.55
                break
        return 1.0

    def hurt(self, dmg, source_name=None):
        if self.relic_iron_hide:
            dmg = max(1, int(math.ceil(dmg * 0.75)))
        was_alive = self.alive
        result = super().hurt(dmg)
        if result and source_name:
            self.last_damage_source = source_name
        if was_alive and not self.alive and self.relic_second_wind and not self.second_wind_used:
            self.alive = True
            self.hp = max(1, int(self.max_hp * 0.25))
            self.second_wind_used = True
            self.iframes = 90
            self.second_wind_triggered = True
        return result

    def update(self, tilemap, projectiles, enemies, effects, rooms, room_types, input_locked=False):
        self.update_common()
        self.attack_timer = max(0, self.attack_timer - 1)
        self.slow_timer = max(0, self.slow_timer - 1)
        self.roll_cooldown = max(0, self.roll_cooldown - 1)

        if not input_locked and pyxel.btnp(pyxel.KEY_K) and not self.rolling and not self.charging and self.roll_cooldown == 0:
            if pyxel.frame_count - self.last_k_press < 18:
                self.rolling = True
                self.roll_timer = 15
                rdx, rdy = self.facing_x, self.facing_y
                if rdx == 0 and rdy == 0:
                    rdx = 1
                self.roll_dx, self.roll_dy = rdx, rdy
                self.iframes = 18
                self.roll_cooldown = 120
                self.did_dodge_this_life = True
                try_play(0, SFX_DODGE)
            self.last_k_press = pyxel.frame_count

        dx = 0; dy = 0
        if not input_locked and not self.charging and not self.rolling:
            if pyxel.btn(pyxel.KEY_A): dx -= 1
            if pyxel.btn(pyxel.KEY_D): dx += 1
            if pyxel.btn(pyxel.KEY_W): dy -= 1
            if pyxel.btn(pyxel.KEY_S): dy += 1
        if dx != 0 or dy != 0:
            self.facing_x, self.facing_y = normalize(dx, dy)

        self.ducking = False
        if not input_locked and pyxel.btn(pyxel.KEY_K) and not self.charging and not self.rolling:
            self.ducking = True
            self.did_duck_this_life = True
            dx = 0; dy = 0

        if self.char == 2 and not self.charging:
            self.aim_angle += 0.09
            if self.aim_angle > math.tau:
                self.aim_angle -= math.tau

        speed_mult = self.get_speed_multiplier(rooms, room_types)
        if self.slow_timer > 0:
            speed_mult *= 0.65

        if self.rolling:
            spd = 3.2
            self.vx = self.roll_dx * spd
            self.vy = self.roll_dy * spd
            self.roll_timer -= 1
            if self.roll_timer <= 0:
                self.rolling = False
        elif self.char == 2 and self.charging:
            spd = 3.7
            self.vx = self.charge_dx * spd
            self.vy = self.charge_dy * spd
            self.charge_timer -= 1
            if self.charge_timer <= 0:
                self.charging = False
        else:
            base_move_speed = self.speed * speed_mult
            spd = base_move_speed * (0.0 if self.ducking else 1.0)
            nx, ny = normalize(dx, dy)
            self.vx = nx * spd
            self.vy = ny * spd

        moving = abs(self.vx) > 0.05 or abs(self.vy) > 0.05
        self.update_anim(moving, 6)
        self.move(tilemap, hitbox=6)

        if moving and not self.rolling:
            self.step_tick += 1
            if self.step_tick >= 14:
                self.step_tick = 0
                try_play(2, SFX_FOOTSTEP)
        else:
            self.step_tick = 0

        if self.swinging:
            self.swing_timer -= 1
            if self.swing_timer == self.swing_total // 2 and not self.swing_hit_done:
                self._knight_arc_hit(enemies, tilemap)
                self.swing_hit_done = True
            if self.swing_timer <= 0:
                self.swinging = False

        if not input_locked and pyxel.btnp(pyxel.KEY_J) and self.attack_timer == 0 and not self.rolling:
            self.attack_timer = self.attack_cooldown
            self._do_attack(projectiles, enemies, effects)

    def _knight_arc_hit(self, enemies, tilemap):
        sword_angle = math.atan2(self.facing_y, self.facing_x)
        hit_range = 36
        arc_half = math.radians(82 if self.relic_wide_swing else 64)
        for e in enemies:
            if not e.alive:
                continue
            ex = e.x - self.x
            ey = e.y - self.y
            dist = length(ex, ey)
            if dist == 0 or dist > hit_range:
                continue
            enemy_angle = math.atan2(ey, ex)
            if abs(angle_diff(enemy_angle, sword_angle)) <= arc_half:
                # Wall-LOS fix: can't hit through walls
                if not has_line_of_sight(tilemap, self.x, self.y, e.x, e.y):
                    continue
                if e.hurt(self.dmg):
                    nx, ny = normalize(ex, ey)
                    e.vx += nx * 1.8
                    e.vy += ny * 1.8
                    if self.relic_bleeding:
                        e.bleed_timer = 180
                        try_play(1, SFX_BLEED)

    def _do_attack(self, projectiles, enemies, effects):
        if self.char == 0:
            self.swinging = True
            self.swing_timer = self.swing_total
            self.swing_hit_done = False
            try_play(0, SFX_SWORD)
        elif self.char == 1:
            vx = self.facing_x * 2.8
            vy = self.facing_y * 2.8
            if vx == 0 and vy == 0:
                vx = 2.8
            proj_col = C_LBLUE if self.relic_frost else C_ORANGE
            main_proj = Projectile(self.x, self.y - 4, vx, vy, proj_col, self.dmg, True, size=4, homing=True)
            main_proj.frost = self.relic_frost
            projectiles.append(main_proj)
            if self.relic_split_fire:
                for spread in (-0.25, 0.25):
                    ang = math.atan2(vy, vx) + spread
                    extra = Projectile(self.x, self.y - 4, math.cos(ang) * 2.8,
                                       math.sin(ang) * 2.8, proj_col, max(1, self.dmg - 2), True, size=3, homing=True)
                    extra.frost = self.relic_frost
                    projectiles.append(extra)
            effects.append(Effect(self.x, self.y - 2, "mage_cast"))
            try_play(0, SFX_FIRE)
        elif self.char == 2:
            self.charge_dx = math.cos(self.aim_angle)
            self.charge_dy = math.sin(self.aim_angle)
            self.charging = True
            self.charge_timer = 14
            self.facing_x = self.charge_dx
            self.facing_y = self.charge_dy
            self.used_charge_once = True
            try_play(0, SFX_CHARGE)

    def draw(self, cam_x, cam_y):
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        if sx < -24 or sy < -24 or sx > W + 24 or sy > H + 24:
            return
        swing_phase = 0
        if self.swinging:
            progress = self.swing_total - self.swing_timer
            swing_phase = min(5, progress // 2)
        hurt_flash = self.hit_flash_timer > 0 and pyxel.frame_count % 2 == 0
        relics = {
            "wide_arc": self.relic_wide_swing, "bleeding_edge": self.relic_bleeding,
            "twin_shot": self.relic_split_fire, "frost_shot": self.relic_frost,
            "charge_blast": self.relic_charge_blast, "iron_hide": self.relic_iron_hide,
        }
        draw_player_sprite(self.char, sx, sy, self.anim_frame, self.facing_x,
                           ducking=self.ducking, swinging=self.swinging, swing_phase=swing_phase,
                           charging=self.charging, aim_angle=self.aim_angle,
                           hurt_flash=hurt_flash, show_bull_aim=True, rolling=self.rolling, relics=relics)

    def draw_hud(self, level_index, tutorial=False):
        pyxel.text(6, 6, self.name, C_WHITE)
        self.draw_hp_bar(6, 14, 80)
        pyxel.text(6, 20, f"HP:{self.hp}/{self.max_hp}", C_LGRAY)
        if tutorial:
            pyxel.text(W - 58, 6, "TUTORIAL", C_YELLOW)
        else:
            theme = THEMES[level_index]
            pyxel.text(W - 52, 6, theme["name"], theme["label_col"])
        collected = self._relic_list()
        if collected:
            pyxel.text(6, 28, "RELICS:", C_LGRAY)
            ix = 40
            for kind in collected:
                draw_relic_icon(kind, ix, 27)
                ix += 12

    def _relic_list(self):
        out = []
        if self.relic_wide_swing: out.append("wide_arc")
        if self.relic_bleeding: out.append("bleeding_edge")
        if self.relic_split_fire: out.append("twin_shot")
        if self.relic_frost: out.append("frost_shot")
        if self.relic_charge_blast: out.append("charge_blast")
        if self.relic_iron_hide: out.append("iron_hide")
        if self.relic_lifesteal: out.append("lifesteal")
        if self.relic_thorns: out.append("thorns")
        if self.relic_second_wind: out.append("second_wind")
        return out
# ==================================================
# ENEMY CLASS
# ==================================================
class Enemy(Entity):
    def __init__(self, x, y, kind, room_id, level_index = 0):
        configs = {
            0: dict(hp=15, col=C_RED, spd=0.70, dmg=4, move_hitbox=8, hit_radius=11),
            1: dict(hp=17, col=C_PURPLE, spd=0.46, dmg=4, move_hitbox=8, hit_radius=11),
            2: dict(hp=12, col=C_LBLUE, spd=1.00, dmg=3, move_hitbox=8, hit_radius=11),
            3: dict(hp=550, col=C_ORANGE, spd=0.82, dmg=7, move_hitbox=16, hit_radius=26),
            4: dict(hp=60, col=C_PURPLE, spd=0.58, dmg=7, move_hitbox=14, hit_radius=18),
        }
        c = configs[kind]
        if level_index == 1:
            if kind == 1:
                c["col"]=C_LGRAY
            elif kind == 2:
                c["col"]=C_GREEN
        super().__init__(x, y, c["hp"], c["col"])
        self.kind = kind
        self.room_id = room_id
        self.spd = c["spd"]
        self.dmg = c["dmg"]
        self.move_hitbox = c["move_hitbox"]
        self.hit_radius = c["hit_radius"]
        self.active = False
        self.shoot_timer = random.randint(40, 80)
        self.burst_timer = random.randint(100, 150)
        self.wander_timer = 0
        self.wander_dx = 0; self.wander_dy = 0
        self.flutter = random.randint(0, 50)
        self.phase = 1
        self.phase_changed_this_frame = False
        self.elite = False
        self.bleed_timer = 0
        self.frost_timer = 0
        self.dash_cd = random.randint(60, 120)
        self.dashing = False
        self.dash_duration = 0
        self.teleport_cd = random.randint(40, 80)
        self.teleport_start_fx = None
        self.teleport_end_fx = None
        self.swoop_state = "hover"
        self.swoop_timer = random.randint(60, 120)
        self.lunge_telegraph = 0
        self.fire_nova_telegraph = 0
        self.fire_nova_cd = random.randint(180, 260)
        self.rush_telegraph = 0
        self.rush_active = 0
        self.rush_cd = random.randint(240, 360)
        self.display_name = {
            0: "Spider", 1: "Dark Mage", 2: "Bat", 3: "Ancient Dragon", 4: "Giant Spider"
        }.get(kind, "Foe")

    def make_elite(self):
        if self.kind == 3 or self.kind == 4:
            return
        # GOLDEN MOBS: 3x HP, same damage
        self.elite = True
        self.max_hp = int(self.max_hp * 3)
        self.hp = self.max_hp
        self.display_name = "Golden " + self.display_name

    def update_phase(self):
        if self.kind != 3:
            return
        old_phase = self.phase
        ratio = self.hp / self.max_hp if self.max_hp > 0 else 0
        if ratio <= 0.33:
            self.phase = 3
        elif ratio <= 0.66:
            self.phase = 2
        else:
            self.phase = 1
        if self.phase != old_phase:
            self.phase_changed_this_frame = True

    def update(self, tilemap, player, projectiles, boss_strength=0, game=None):
        self.update_common()
        if not self.alive:
            return
        self.flutter += 1
        self.update_phase()

        if self.bleed_timer > 0:
            if self.bleed_timer % 20 == 0:
                self.hp -= 1
                if self.hp <= 0:
                    self.hp = 0
                    self.alive = False
                    return
            self.bleed_timer -= 1

        frost_mult = 0.55 if self.frost_timer > 0 else 1.0
        if self.frost_timer > 0:
            self.frost_timer -= 1

        ex = player.x - self.x
        ey = player.y - self.y
        dist = length(ex, ey)
        nx, ny = normalize(ex, ey)
        self.vx *= 0.85
        self.vy *= 0.85

        if self.active:
            if self.kind == 0:
                if dist > 22:
                    self.vx += nx * self.spd * 0.75 * frost_mult
                    self.vy += ny * self.spd * 0.75 * frost_mult
                elif dist > 14:
                    side_x = -ny; side_y = nx
                    self.vx += nx * self.spd * 0.3 * frost_mult
                    self.vy += ny * self.spd * 0.3 * frost_mult
                    self.vx += side_x * self.spd * 0.35 * frost_mult
                    self.vy += side_y * self.spd * 0.35 * frost_mult
                else:
                    self.vx += -nx * self.spd * 0.35 * frost_mult
                    self.vy += -ny * self.spd * 0.35 * frost_mult
                if dist < 13:
                    if player.hurt(self.dmg, self.display_name):
                        player.slow_timer = 40
                        try_play(1, SFX_BITE)
                        if getattr(player, 'relic_thorns', False):
                            self.hurt(4)
            elif self.kind == 1:
                # DARK MAGE with BIG teleport fx
                self.teleport_cd = max(0, self.teleport_cd - 1)
                if dist < 40 and self.teleport_cd <= 0:
                    old_x, old_y = self.x, self.y
                    tp_angle = random.uniform(0, math.tau)
                    new_x = player.x + math.cos(tp_angle) * 82
                    new_y = player.y + math.sin(tp_angle) * 82
                    ntx = int(new_x) // TILE
                    nty = int(new_y) // TILE
                    if not tile_solid(tilemap, ntx, nty):
                        self.x = clamp(new_x, 20, MAP_W - 20)
                        self.y = clamp(new_y, 20, MAP_H - 20)
                        # HUGE particle burst at BOTH positions
                        if game is not None:
                            game.spawn_teleport_burst(old_x, old_y)
                            game.spawn_teleport_burst(self.x, self.y)
                    self.teleport_cd = 150
                    try_play(3, SFX_TELEPORT)
                else:
                    if dist < 60:
                        self.vx += -nx * self.spd * 0.6 * frost_mult
                        self.vy += -ny * self.spd * 0.6 * frost_mult
                    elif dist > 90:
                        self.vx += nx * self.spd * 0.4 * frost_mult
                        self.vy += ny * self.spd * 0.4 * frost_mult
                self.shoot_timer -= 1
                if self.shoot_timer <= 0 and dist < 140:
                    self.shoot_timer = random.randint(70, 110)
                    base_ang = math.atan2(ny, nx)
                    for spread in (-0.28, 0, 0.28):
                        ang = base_ang + spread
                        pr = Projectile(self.x, self.y - 3, math.cos(ang) * 1.7,
                                        math.sin(ang) * 1.7, C_PURPLE, self.dmg, False, size=3, from_mage=True)
                        pr.source_name = self.display_name
                        projectiles.append(pr)
            elif self.kind == 2:
                self.swoop_timer -= 1
                ideal_dist = 40
                if self.swoop_state == "hover":
                    side_x = -ny; side_y = nx
                    if dist < ideal_dist - 6:
                        self.vx += -nx * self.spd * 0.22 * frost_mult
                        self.vy += -ny * self.spd * 0.22 * frost_mult
                    elif dist > ideal_dist + 6:
                        self.vx += nx * self.spd * 0.22 * frost_mult
                        self.vy += ny * self.spd * 0.22 * frost_mult
                    self.vx += side_x * self.spd * 0.30 * frost_mult
                    self.vy += side_y * self.spd * 0.30 * frost_mult
                    self.vy += math.sin(self.flutter * 0.12) * 0.12
                    if self.swoop_timer <= 0:
                        self.swoop_state = "swoop_in"
                        self.swoop_timer = 40
                        try_play(3, SFX_SCREECH)
                elif self.swoop_state == "swoop_in":
                    self.vx += nx * self.spd * 1.0 * frost_mult
                    self.vy += ny * self.spd * 1.0 * frost_mult
                    if dist < 12:
                        if player.hurt(self.dmg, self.display_name):
                            try_play(1, SFX_BITE)
                            if getattr(player, 'relic_thorns', False):
                                self.hurt(4)
                        self.swoop_state = "retreat"
                        self.swoop_timer = 30
                    elif self.swoop_timer <= 0:
                        self.swoop_state = "retreat"
                        self.swoop_timer = 35
                else:
                    self.vx += -nx * self.spd * 0.7 * frost_mult
                    self.vy += -ny * self.spd * 0.7 * frost_mult
                    if self.swoop_timer <= 0:
                        self.swoop_state = "hover"
                        self.swoop_timer = random.randint(140, 200)
            elif self.kind == 4:
                if self.lunge_telegraph > 0:
                    self.lunge_telegraph -= 1
                    self.vx *= 0.5; self.vy *= 0.5
                    if self.lunge_telegraph == 0 and dist < 120:
                        self.vx += nx * 5.5
                        self.vy += ny * 5.5
                else:
                    if dist > 28:
                        self.vx += nx * self.spd * 0.9 * frost_mult
                        self.vy += ny * self.spd * 0.9 * frost_mult
                    else:
                        self.vx += -nx * self.spd * 0.3 * frost_mult
                        self.vy += -ny * self.spd * 0.3 * frost_mult
                    self.burst_timer -= 1
                    if self.burst_timer <= 0 and dist < 100:
                        self.burst_timer = random.randint(220, 320)
                        self.lunge_telegraph = 32
                        try_play(3, SFX_SCREECH)
                if dist < 18:
                    if player.hurt(self.dmg, self.display_name):
                        player.slow_timer = 60
                        try_play(1, SFX_BITE)
                        if getattr(player, 'relic_thorns', False):
                            self.hurt(4)
            else:
                # ANCIENT DRAGON
                self.fire_nova_cd = max(0, self.fire_nova_cd - 1)
                self.rush_cd = max(0, self.rush_cd - 1)
                if self.rush_active > 0:
                    rush_spd = 3.8
                    self.vx = self._rush_dx * rush_spd
                    self.vy = self._rush_dy * rush_spd
                    self.rush_active -= 1
                    if dist < 20:
                        if player.hurt(self.dmg + 3, self.display_name):
                            if getattr(player, 'relic_thorns', False):
                                self.hurt(2)
                elif self.rush_telegraph > 0:
                    self.vx *= 0.5; self.vy *= 0.5
                    if self.rush_telegraph == 20:
                        self._rush_dx = nx
                        self._rush_dy = ny
                    self.rush_telegraph -= 1
                    if self.rush_telegraph == 0:
                        self.rush_active = 38
                        try_play(3, SFX_CHARGE)
                elif self.fire_nova_telegraph > 0:
                    self.vx *= 0.6; self.vy *= 0.6
                    self.fire_nova_telegraph -= 1
                    if self.fire_nova_telegraph == 0:
                        try_play(3, SFX_ROAR)
                        for i in range(16):
                            ang = i * (math.tau / 16)
                            speed = 2.0 + boss_strength * 0.04
                            pr = Projectile(self.x, self.y, math.cos(ang) * speed,
                                            math.sin(ang) * speed, C_ORANGE, self.dmg + 1, False, size=4)
                            pr.source_name = self.display_name
                            pr.duckable = True
                            projectiles.append(pr)
                else:
                    if self.fire_nova_cd == 0 and dist < 90:
                        self.fire_nova_telegraph = 50
                        self.fire_nova_cd = random.randint(240, 340)
                    elif self.rush_cd == 0 and 60 < dist < 160:
                        self.rush_telegraph = 34
                        self.rush_cd = random.randint(280, 400)
                    elif self.phase == 1:
                        if dist > 84:
                            self.vx += nx * (self.spd + boss_strength * 0.02)
                            self.vy += ny * (self.spd + boss_strength * 0.02)
                        elif dist < 52:
                            self.vx += -nx * (self.spd * 0.65)
                            self.vy += -ny * (self.spd * 0.65)
                        self.shoot_timer -= 1
                        if self.shoot_timer <= 0:
                            self.shoot_timer = max(30, 50 - boss_strength * 2)
                            speed = 2.25 + boss_strength * 0.04
                            pr = Projectile(self.x, self.y - 4, nx * speed, ny * speed,
                                            C_ORANGE, self.dmg + boss_strength, False, size=4)
                            pr.source_name = self.display_name
                            pr.duckable = True
                            projectiles.append(pr)
                    elif self.phase == 2:
                        if dist > 76:
                            self.vx += nx * (self.spd * 0.8 + boss_strength * 0.02)
                            self.vy += ny * (self.spd * 0.8 + boss_strength * 0.02)
                        else:
                            side_x = -ny; side_y = nx
                            self.vx += side_x * 0.6; self.vy += side_y * 0.6
                        self.shoot_timer -= 1
                        if self.shoot_timer <= 0:
                            self.shoot_timer = max(24, 42 - boss_strength * 2)
                            base_ang = math.atan2(ny, nx)
                            for spread in (-0.18, 0, 0.18):
                                ang = base_ang + spread
                                speed = 2.2 + boss_strength * 0.05
                                pr = Projectile(self.x, self.y, math.cos(ang) * speed,
                                                math.sin(ang) * speed, C_ORANGE, self.dmg + boss_strength, False, size=4)
                                pr.source_name = self.display_name
                                pr.duckable = True
                                projectiles.append(pr)
                    else:
                        self.burst_timer -= 1
                        if self.burst_timer <= 0:
                            self.burst_timer = max(54, 86 - boss_strength * 3)
                            for i in range(8):
                                ang = i * (math.tau / 8)
                                speed = 1.8 + boss_strength * 0.04
                                pr = Projectile(self.x, self.y, math.cos(ang) * speed,
                                                math.sin(ang) * speed, C_RED, 6 + boss_strength, False, size=3)
                                pr.source_name = self.display_name
                                pr.duckable = True
                                projectiles.append(pr)
                        else:
                            self.vx += nx * (self.spd * 1.0 + boss_strength * 0.04)
                            self.vy += ny * (self.spd * 1.0 + boss_strength * 0.04)
                        self.shoot_timer -= 1
                        if self.shoot_timer <= 0:
                            self.shoot_timer = max(18, 30 - boss_strength)
                            ang = math.atan2(ny, nx)
                            speed = 2.5 + boss_strength * 0.06
                            pr = Projectile(self.x, self.y - 4, math.cos(ang) * speed,
                                            math.sin(ang) * speed, C_ORANGE, self.dmg + boss_strength + 1, False, size=4)
                            pr.source_name = self.display_name
                            pr.duckable = True
                            projectiles.append(pr)
                if dist < 22 and self.rush_active == 0:
                    if player.hurt(self.dmg, self.display_name):
                        if getattr(player, 'relic_thorns', False):
                            self.hurt(2)
        else:
            self.wander_timer -= 1
            if self.wander_timer <= 0:
                self.wander_timer = random.randint(20, 50)
                self.wander_dx = random.choice([-1, 0, 1])
                self.wander_dy = random.choice([-1, 0, 1])
            self.vx += self.wander_dx * self.spd * 0.25
            self.vy += self.wander_dy * self.spd * 0.25

        self.update_anim(abs(self.vx) > 0.05 or abs(self.vy) > 0.05, 8)
        self.move(tilemap, hitbox=self.move_hitbox)

    def draw(self, cam_x, cam_y):
        if not self.alive:
            return
        sx, sy = world_to_screen(self.x, self.y, cam_x, cam_y)
        if sx < -64 or sy < -64 or sx > W + 64 or sy > H + 64:
            return
        if self.hit_flash_timer > 0 and pyxel.frame_count % 2 == 0:
            c = C_WHITE; flashing = True
        elif self.iframes > 0 and pyxel.frame_count % 3 < 2:
            c = C_WHITE; flashing = True
        else:
            c = self.col; flashing = False

        if self.kind == 0:
            body_col = c
            dark_col = C_WHITE if flashing else C_DARK
            mark_col = C_WHITE if flashing else C_RED
            pyxel.circ(sx + 1, sy + 1, 4, body_col)
            pyxel.circb(sx + 1, sy + 1, 4, dark_col)
            pyxel.pset(sx + 1, sy, mark_col); pyxel.pset(sx + 1, sy + 2, mark_col)
            pyxel.pset(sx, sy + 1, mark_col); pyxel.pset(sx + 2, sy + 1, mark_col)
            pyxel.circ(sx - 2, sy - 1, 3, body_col)
            pyxel.circb(sx - 2, sy - 1, 3, dark_col)
            pyxel.pset(sx - 3, sy - 3, mark_col); pyxel.pset(sx - 1, sy - 3, mark_col)
            leg_wave = [0, 1, 0, -1][self.anim_frame]
            leg_offsets = [(-2, -3, -6, -5, -9, -3), (-1, -3, -4, -6, -6, -8),
                           (2, -3, 5, -5, 8, -3), (3, -2, 6, -4, 9, -6),
                           (-2, 3, -6, 5, -9, 3), (-1, 4, -4, 7, -6, 9),
                           (2, 3, 5, 5, 8, 3), (3, 3, 6, 6, 9, 7)]
            for i, (s1, s2, k1, k2, e1, e2) in enumerate(leg_offsets):
                w = leg_wave if i % 2 == 0 else -leg_wave
                pyxel.line(sx + s1, sy + s2, sx + k1, sy + k2 + w, C_DGRAY)
                pyxel.line(sx + k1, sy + k2 + w, sx + e1, sy + e2 + w, C_DGRAY)
                pyxel.pset(sx + e1, sy + e2 + w, C_BLACK)
        elif self.kind == 1:
            body_col = c
            dark_col = C_WHITE if flashing else C_DARK
            pyxel.tri(sx - 7, sy + 6, sx + 7, sy + 6, sx - 4, sy - 3, dark_col)
            pyxel.tri(sx + 7, sy + 6, sx + 4, sy - 3, sx - 4, sy - 3, dark_col)
            pyxel.rect(sx - 5, sy - 3, 10, 9, body_col)
            pyxel.line(sx - 5, sy - 3, sx - 5, sy + 6, dark_col)
            pyxel.line(sx + 4, sy - 3, sx + 4, sy + 6, dark_col)
            pyxel.tri(sx - 5, sy - 3, sx + 5, sy - 3, sx, sy - 11, body_col)
            pyxel.tri(sx - 4, sy - 4, sx + 4, sy - 4, sx, sy - 10, dark_col)
            pyxel.rect(sx - 3, sy - 7, 6, 4, C_BLACK)
            pyxel.pset(sx - 1, sy - 6, C_RED); pyxel.pset(sx + 1, sy - 6, C_RED)
            pyxel.pset(sx - 1, sy - 5, C_YELLOW); pyxel.pset(sx + 1, sy - 5, C_YELLOW)
        elif self.kind == 2:
            wing = [2, 5, 7, 5][self.anim_frame]
            body_col = c
            dark_col = C_WHITE if flashing else C_DARK
            pyxel.rect(sx - 2, sy - 2, 5, 5, body_col)
            pyxel.rectb(sx - 2, sy - 2, 5, 5, dark_col)
            pyxel.pset(sx - 1, sy - 1, C_RED); pyxel.pset(sx + 1, sy - 1, C_RED)
            pyxel.tri(sx - 2, sy, sx - 11, sy - wing, sx - 9, sy + wing, dark_col)
            pyxel.tri(sx + 3, sy, sx + 12, sy - wing, sx + 10, sy + wing, dark_col)
            pyxel.tri(sx - 3, sy - 3, sx - 1, sy - 3, sx - 2, sy - 6, body_col)
            pyxel.tri(sx + 1, sy - 3, sx + 3, sy - 3, sx + 2, sy - 6, body_col)
        elif self.kind == 4:
            body_col = c
            dark_col = C_WHITE if flashing else C_DARK
            mark_col = C_WHITE if flashing else C_RED
            pyxel.circ(sx + 2, sy + 3, 8, body_col)
            pyxel.circb(sx + 2, sy + 3, 8, dark_col)
            pyxel.rect(sx + 1, sy + 1, 3, 5, mark_col)
            pyxel.circ(sx - 4, sy - 2, 5, body_col)
            pyxel.circb(sx - 4, sy - 2, 5, dark_col)
            for eye_x, eye_y in ((-6, -5), (-3, -5), (-7, -3), (-2, -3), (-5, -1)):
                pyxel.pset(sx + eye_x, sy + eye_y, mark_col)
            if self.lunge_telegraph > 0:
                aura_col = C_RED if self.lunge_telegraph % 4 < 2 else C_ORANGE
                pyxel.circb(sx, sy, 14, aura_col)
                pyxel.circb(sx, sy, 16, C_YELLOW)

        # Status overlays
        if self.bleed_timer > 0 and (pyxel.frame_count // 4) % 2 == 0 and self.kind != 3:
            pyxel.pset(sx + random.randint(-3, 3), sy + random.randint(-1, 4), C_RED)
        if self.frost_timer > 0 and self.kind != 3:
            pyxel.pset(sx - 3, sy - 4, C_LBLUE); pyxel.pset(sx + 3, sy - 4, C_LBLUE)

        if self.kind == 3:
            self._draw_epic_dragon(sx, sy, flashing)
            self.draw_hp_bar(sx - 26, sy - 36, 52)

        if self.kind == 4:
            self.draw_hp_bar(sx - 18, sy - 20, 36)
        elif self.kind != 3:
            self.draw_hp_bar(sx - 12, sy - 15, 24)

        # Golden elite outline + crown
        if self.elite:
            pulse = C_YELLOW if (pyxel.frame_count // 6) % 2 == 0 else C_ORANGE
            pyxel.circb(sx, sy + 1, 11, pulse)
            pyxel.circb(sx, sy + 1, 12, C_ORANGE)
            pyxel.pset(sx - 3, sy - 11, pulse); pyxel.pset(sx, sy - 12, pulse); pyxel.pset(sx + 3, sy - 11, pulse)
            pyxel.line(sx - 3, sy - 10, sx + 3, sy - 10, C_YELLOW)
            pyxel.line(sx - 3, sy - 9, sx + 3, sy - 9, C_ORANGE)

    def _draw_epic_dragon(self, sx, sy, flashing):
        """TOTALLY REDESIGNED DRAGON - much bigger & more detailed."""
        dark_col = C_WHITE if flashing else C_DARK
        # Phase-based colors
        if self.phase == 1:
            body_col = C_RED if not flashing else C_WHITE
            belly = C_ORANGE if not flashing else C_WHITE
            scale1 = C_YELLOW if not flashing else C_WHITE
            scale2 = C_ORANGE if not flashing else C_WHITE
            eye_col = C_YELLOW
        elif self.phase == 2:
            body_col = C_RED if not flashing else C_WHITE
            belly = C_RED if not flashing else C_WHITE
            scale1 = C_ORANGE if not flashing else C_WHITE
            scale2 = C_YELLOW if not flashing else C_WHITE
            eye_col = C_ORANGE
        else:
            body_col = C_PURPLE if not flashing else C_WHITE
            belly = C_RED if not flashing else C_WHITE
            scale1 = C_WHITE
            scale2 = C_YELLOW if not flashing else C_WHITE
            eye_col = C_WHITE

        t = pyxel.frame_count
        breath = int(math.sin(t * 0.08) * 1)  # breathing animation

        # === LONG SEGMENTED TAIL (right side) ===
        tail_wave = math.sin(t * 0.09) * 3
        tail_pts = []
        for i in range(7):
            tx = sx + 12 + i * 5
            ty = sy + 3 + int(math.sin(t * 0.09 + i * 0.5) * 3)
            tail_pts.append((tx, ty))
        for i in range(len(tail_pts) - 1):
            x1, y1 = tail_pts[i]
            x2, y2 = tail_pts[i + 1]
            thick = max(1, 4 - i // 2)
            pyxel.line(x1, y1 - thick, x2, y2 - thick, body_col)
            pyxel.line(x1, y1, x2, y2, body_col)
            pyxel.line(x1, y1 + thick, x2, y2 + thick, dark_col)
            # Tail spikes
            if i % 2 == 0:
                pyxel.tri(x1 - 1, y1 - thick, x1 + 1, y1 - thick, x1, y1 - thick - 3, scale1)
        # Tail tip
        ttx, tty = tail_pts[-1]
        pyxel.tri(ttx, tty - 3, ttx + 6, tty, ttx, tty + 3, body_col)
        pyxel.pset(ttx + 4, tty, scale1)

        # === LEGS WITH CLAWS ===
        for leg_ox in (-10, 8):
            lx = sx + leg_ox
            pyxel.rect(lx, sy + 6, 5, 8, body_col)
            pyxel.rect(lx, sy + 6, 5, 2, dark_col)
            # Toes/claws
            for toe in range(3):
                tox = lx + toe * 2
                pyxel.rect(tox, sy + 13, 2, 2, dark_col)
                pyxel.pset(tox, sy + 15, C_WHITE)  # claw
                pyxel.pset(tox + 1, sy + 15, C_LGRAY)

        # === HUGE WINGS ===
        wing_flap = [0, 4, 8, 4][self.anim_frame]
        for side in (-1, 1):
            wx = sx + 5 * side
            wt_x = sx + 26 * side
            wt_y = sy - 22 - wing_flap
            # Wing membrane
            pyxel.tri(wx, sy - 3, wt_x, wt_y, sx + 22 * side, sy + 6, dark_col)
            pyxel.tri(wx + 1 * side, sy - 2, wt_x - 2 * side, wt_y + 1, sx + 19 * side, sy + 4, body_col)
            # Wing finger bones
            for fb in range(4):
                bx_end = sx + (22 - fb * 4) * side
                by_end = sy - (18 - fb * 3) - wing_flap
                pyxel.line(wx, sy - 2, bx_end, by_end, dark_col)
            # Wing tip claw
            pyxel.pset(wt_x, wt_y, C_WHITE)
            pyxel.pset(wt_x - side, wt_y + 1, scale1)

        # === MAIN BODY (bigger + breathing) ===
        pyxel.rect(sx - 12, sy - 8 - breath, 24, 18 + breath, body_col)
        pyxel.rectb(sx - 12, sy - 8 - breath, 24, 18 + breath, dark_col)
        # Belly with scale ridges
        pyxel.rect(sx - 9, sy - 2, 18, 9, belly)
        for by_off in (0, 3, 6):
            pyxel.line(sx - 9, sy + by_off, sx + 8, sy + by_off, body_col)
        # Back scales (scattered)
        for sx_off, sy_off in ((-7, -5), (-3, -6), (1, -5), (5, -6), (-5, -2), (3, -2)):
            pyxel.pset(sx + sx_off, sy + sy_off, scale1)
            pyxel.pset(sx + sx_off + 1, sy + sy_off, scale2)

        # === BACK SPIKES (big crest) ===
        for ox in (-10, -6, -2, 2, 6, 10):
            pyxel.tri(sx + ox - 2, sy - 8, sx + ox + 2, sy - 8, sx + ox, sy - 14, scale1)
            pyxel.tri(sx + ox - 1, sy - 8, sx + ox + 1, sy - 8, sx + ox, sy - 12, scale2)

        # === HEAD (LEFT of body, much more detail) ===
        hx = sx - 18
        hy = sy - 4
        # Jaw (lower)
        pyxel.rect(hx - 6, hy + 3, 10, 4, body_col)
        pyxel.rectb(hx - 6, hy + 3, 10, 4, dark_col)
        # Teeth on lower jaw
        for tt in range(4):
            pyxel.pset(hx - 5 + tt * 2, hy + 3, C_WHITE)
        # Upper jaw/snout
        pyxel.rect(hx - 8, hy - 3, 12, 6, body_col)
        pyxel.rectb(hx - 8, hy - 3, 12, 6, dark_col)
        # Teeth on upper jaw
        for tt in range(5):
            pyxel.pset(hx - 7 + tt * 2, hy + 3, C_WHITE)
        # Head main
        pyxel.rect(hx - 4, hy - 8, 10, 12, body_col)
        pyxel.rectb(hx - 4, hy - 8, 10, 12, dark_col)
        # Cheek scale
        pyxel.rect(hx + 1, hy, 3, 3, scale1)
        # Glowing eye (BIG)
        pyxel.rect(hx - 1, hy - 5, 4, 4, eye_col)
        pyxel.rect(hx, hy - 4, 2, 2, C_RED)
        pyxel.pset(hx + 1, hy - 4, C_WHITE)
        # Eye glow pulse
        if (t // 8) % 2 == 0:
            pyxel.pset(hx - 2, hy - 5, eye_col)
            pyxel.pset(hx + 3, hy - 5, eye_col)
        # Brow ridge
        pyxel.line(hx - 2, hy - 7, hx + 3, hy - 7, dark_col)
        pyxel.line(hx - 3, hy - 8, hx - 1, hy - 7, scale1)
        # NOSTRILS (with smoke!)
        pyxel.pset(hx - 7, hy - 1, C_BLACK)
        pyxel.pset(hx - 7, hy + 1, C_BLACK)
        # Smoke from nostrils (animated)
        smoke_phase = (t // 6) % 4
        for s in range(smoke_phase + 1):
            sy_smoke = hy - 3 - s * 2
            col = C_LGRAY if s % 2 == 0 else C_DGRAY
            pyxel.pset(hx - 8 - s, sy_smoke, col)
            if s > 0:
                pyxel.pset(hx - 9 - s, sy_smoke - 1, col)
        # HORNS (huge, curved, multi-segment)
        for horn_side in (-1, 1):
            for hseg in range(4):
                hpx = hx + (horn_side * hseg) + 1
                hpy = hy - 8 - hseg * 2
                pyxel.rect(hpx - 1, hpy, 2, 2, C_LGRAY)
                if hseg == 3:
                    pyxel.pset(hpx, hpy - 1, C_WHITE)
        # Jaw hinge crease
        pyxel.line(hx + 3, hy + 2, hx + 5, hy + 2, dark_col)
        # MULTI-COLORED FIRE BREATH (from mouth)
        if (t // 4) % 3 < 2:
            for i in range(6):
                fcol = [C_WHITE, C_YELLOW, C_ORANGE, C_RED, C_ORANGE, C_YELLOW][i]
                fx_off = -10 - i * 2
                fy_off = 2 + (i % 3 - 1)
                pyxel.pset(hx + fx_off, hy + fy_off, fcol)
                pyxel.pset(hx + fx_off - 1, hy + fy_off + 1, fcol)
                if i > 2:
                    pyxel.pset(hx + fx_off - 2, hy + fy_off - 1, C_RED)

        # Telegraphs
        if self.fire_nova_telegraph > 0:
            fr = int((50 - self.fire_nova_telegraph) * 1.4)
            pulse_col = C_RED if (t // 3) % 2 == 0 else C_ORANGE
            pyxel.circb(sx, sy, fr + 20, pulse_col)
            pyxel.circb(sx, sy, fr + 24, C_YELLOW)
        if self.rush_telegraph > 0:
            ar_col = C_RED if self.rush_telegraph % 6 < 3 else C_ORANGE
            pyxel.circb(sx, sy, 30, ar_col)
            pyxel.circb(sx, sy, 33, C_YELLOW)
        if self.rush_active > 0:
            for _tr in range(4):
                tx_s = sx - int(self._rush_dx * (4 + _tr * 6))
                ty_s = sy - int(self._rush_dy * (4 + _tr * 6))
                pyxel.pset(tx_s, ty_s, C_ORANGE if _tr % 2 == 0 else C_RED)
                pyxel.pset(tx_s + 1, ty_s, C_YELLOW)
# ==================================================
# GAME CLASS — Part A (init, sounds, music, spawns, tutorial, chests)
# ==================================================
class Game:
    def __init__(self):
        pyxel.init(W, H, title="DUNGEON MASTER", fps=60)
        self._init_sounds()

        self.state = ST_MENU
        self.prev_state = ST_MENU
        self.sel = 0
        self.level_index = 0
        self.is_boss = False
        self.cam_x = 0; self.cam_y = 0

        self.player = None
        self.tilemap = None
        self.rooms = []
        self.room_types = []
        self.portal_tiles = []
        self.enemies = []
        self.projectiles = []
        self.potions = []
        self.chests = []
        self.effects = []
        self.particles = []

        self.portal_open = False
        self.current_room_id = -1
        self.chest_choice_mode = False
        self.active_chest = None
        self.chest_selection = 0
        self.chest_choices = []

        self.boss_enrage_timer = 0
        self.boss_strength = 0
        self.shake_timer = 0
        self.shake_strength = 0

        self.tutorial_stage = 0
        self.tutorial_done = False
        self.tutorial_mage_ducked = False
        self.tutorial_auto_advance = 0
        self.boss_intro_timer = 0
        self.boss_intro_roared = False

        # Menu stars
        self.menu_stars = []
        rng = random.Random(1234)
        for _ in range(70):
            self.menu_stars.append((rng.randint(0, W - 1), rng.randint(4, 130), rng.randint(0, 100)))

        self.win_played = False
        self.death_played = False
        self.stage_clear_timer = 0
        self.crit_tick = 0

        # STATS
        self.stat_enemies_killed = 0
        self.stat_chests_opened = 0
        self.stat_level_reached = 1

        # NEW: banners/cinematics/input-lock
        self.action_lock_timer = 0
        self.stage_banner_timer = 0
        self.stage_banner_text = ""
        self.boss_taunt_timer = 0
        self.boss_taunt_text = ""
        self.victory_timer = 0  # counts UP during win cinematic
        self.music_track = -1

        pyxel.run(self.update, self.draw)

    # ---- SFX + MUSIC setup ----
    def _init_sounds(self):
        try:
            pyxel.sounds[SFX_SELECT].set("c3", "s", "3", "n", 10)
            pyxel.sounds[SFX_CONFIRM].set("c3e3g3", "s", "5", "f", 10)
            pyxel.sounds[SFX_SWORD].set("a2", "n", "4", "f", 6)
            pyxel.sounds[SFX_FIRE].set("c2f2", "p", "5", "f", 10)
            pyxel.sounds[SFX_CHARGE].set("c1f1c2", "n", "5", "f", 10)
            pyxel.sounds[SFX_HIT].set("e2", "n", "3", "f", 4)
            pyxel.sounds[SFX_HURT].set("e2c2", "p", "6", "s", 12)
            pyxel.sounds[SFX_ENEMY_DIE].set("c2a1f1", "n", "4", "f", 7)
            pyxel.sounds[SFX_BOSS_DIE].set("c3g2e2c2g1e1c1", "n", "7", "f", 14)
            pyxel.sounds[SFX_PICKUP].set("g3c4e4", "t", "4", "f", 8)
            pyxel.sounds[SFX_PORTAL].set("c3e3g3c4", "p", "5", "v", 12)
            pyxel.sounds[SFX_WIN].set("c3e3g3c4e4g4", "s", "6", "f", 14)
            pyxel.sounds[SFX_DEATH].set("g2e2c2g1", "p", "5", "s", 22)
            pyxel.sounds[SFX_CHEST].set("e3g3c4e4", "t", "5", "f", 7)
            pyxel.sounds[SFX_ROAR].set("c1c1b0a0", "n", "7", "v", 22)
            pyxel.sounds[SFX_FOOTSTEP].set("a0", "n", "2", "f", 3)
            pyxel.sounds[SFX_CHEST_OPEN].set("c3e3g3c4e4g4", "t", "6", "n", 6)
            pyxel.sounds[SFX_RELIC].set("c3g3c4e4g4c5", "s", "7", "f", 8)
            pyxel.sounds[SFX_STAGE_CLEAR].set("c3e3g3c4e4g4c5", "s", "6", "f", 10)
            pyxel.sounds[SFX_PORTAL_READY].set("c4e4g4", "t", "5", "v", 10)
            pyxel.sounds[SFX_CRIT_HP].set("c2", "t", "4", "f", 18)
            pyxel.sounds[SFX_TELEPORT].set("c3b2a2g2", "p", "4", "s", 5)
            pyxel.sounds[SFX_BITE].set("c2", "n", "5", "f", 4)
            pyxel.sounds[SFX_SCREECH].set("g4a4b4", "p", "5", "s", 3)
            pyxel.sounds[SFX_DODGE].set("e3c3", "n", "3", "f", 5)
            pyxel.sounds[SFX_BLEED].set("c2a1", "n", "3", "f", 6)
            pyxel.sounds[SFX_FROST].set("g3c3", "p", "4", "s", 8)
            # MUSIC LOOPS — channel 3
            pyxel.sounds[SFX_MUS_MENU].set(
                "a2c3e3a3 g2b2e3g3 f2a2c3f3 e2g2b2e3", "s", "2", "n", 28)
            pyxel.sounds[SFX_MUS_DUNG].set(
                "c2e2g2c3 b1d2f2b2 a1c2e2a2 g1b1d2g2", "t", "2", "n", 28)
            pyxel.sounds[SFX_MUS_BOSS].set(
                "c2g1c2g1 b1f1b1f1 c2g1c2g1 a1e1a1e1", "s", "3", "n", 22)
        except Exception:
            pass

    def play_music(self, track_id):
        """Loop background music on channel 3. track_id = SFX_MUS_*"""
        if self.music_track == track_id:
            return
        try:
            pyxel.stop(3)
            pyxel.play(3, track_id, loop=True)
        except Exception:
            pass
        self.music_track = track_id

    def stop_music(self):
        try:
            pyxel.stop(3)
        except Exception:
            pass
        self.music_track = -1

    # ---- FX helpers ----
    def add_shake(self, strength, duration):
        self.shake_strength = max(self.shake_strength, strength)
        self.shake_timer = max(self.shake_timer, duration)

    def spawn_hit_particles(self, x, y, col=C_WHITE, big=False):
        count = 14 if big else 8
        for _ in range(count):
            ang = random.random() * math.tau
            spd = random.uniform(0.7, 2.8 if big else 1.8)
            self.particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd,
                                           random.randint(10, 18 if big else 14),
                                           col, size=2 if big else 1, gravity=0.01, shrink=True))

    def spawn_enemy_death_particles(self, x, y, col):
        for _ in range(18):
            ang = random.random() * math.tau
            spd = random.uniform(0.6, 2.4)
            self.particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd,
                                           random.randint(12, 22), col,
                                           size=random.choice([1, 1, 2]), gravity=0.02, shrink=True))

    def spawn_teleport_burst(self, x, y):
        """HUGE teleport particle burst — 60+ particles, purple/white/pink."""
        for _ in range(60):
            ang = random.random() * math.tau
            spd = random.uniform(0.8, 3.5)
            col = random.choice([C_PURPLE, C_WHITE, C_PINK, C_BLUE, C_LBLUE])
            size = random.choice([1, 2, 2, 3])
            self.particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd,
                                           random.randint(20, 36), col, size=size,
                                           gravity=-0.01, shrink=True))
        # Swirling ring
        for i in range(20):
            ang = i * math.tau / 20
            self.particles.append(Particle(x, y, math.cos(ang) * 2.0, math.sin(ang) * 2.0,
                                           30, C_PURPLE, size=2, shrink=True))
        self.add_shake(3, 6)

    def spawn_portal_particles(self):
        if not self.portal_open or not self.portal_tiles or pyxel.frame_count % 2 != 0:
            return
        # Sample random tile in portal cluster
        pt = random.choice(self.portal_tiles)
        px = pt[0] * TILE + TILE // 2
        py = pt[1] * TILE + TILE // 2
        theme = THEMES[self.level_index]
        col = random.choice([theme["portal_a"], theme["portal_b"], C_WHITE, C_YELLOW])
        ang = random.random() * math.tau
        radius = random.uniform(2, 18)
        x = px + math.cos(ang) * radius
        y = py + math.sin(ang) * radius
        self.particles.append(Particle(x, y, math.cos(ang + math.pi / 2) * random.uniform(0.2, 0.6),
                                       math.sin(ang + math.pi / 2) * random.uniform(0.2, 0.6) - 0.3,
                                       random.randint(14, 28), col,
                                       size=random.choice([1, 2, 2]), gravity=-0.004, shrink=True))

    def spawn_pickup_particles(self, x, y, col):
        for _ in range(12):
            ang = random.random() * math.tau
            spd = random.uniform(0.5, 1.8)
            self.particles.append(Particle(x, y, math.cos(ang) * spd, math.sin(ang) * spd - 0.3,
                                           random.randint(10, 18), col,
                                           size=random.choice([1, 1, 2]), gravity=0.01, shrink=True))

    def _report_enemy_death(self, e):
        if getattr(e, 'death_reported', False):
            return
        e.death_reported = True
        self.spawn_enemy_death_particles(e.x, e.y, e.col)
        self.add_shake(4 if e.kind == 3 else 2, 8 if e.kind == 3 else 4)
        try_play(1, SFX_BOSS_DIE if e.kind == 3 else SFX_ENEMY_DIE)
        self.stat_enemies_killed += 1
        if self.player and self.player.relic_lifesteal:
            self.player.hp = min(self.player.max_hp, self.player.hp + 1)
        if getattr(e, 'elite', False):
            self.potions.append(HealPotion(e.x, e.y, e.room_id))
            self.spawn_pickup_particles(e.x, e.y, C_YELLOW)
        if e.kind == 4:
            self.potions.append(HealPotion(e.x, e.y, e.room_id))
            self.spawn_pickup_particles(e.x, e.y, C_PINK)

    # ---- TUTORIAL ----
    def start_tutorial(self):
        self.tutorial_stage = 0
        self._load_tutorial_stage()

    def _load_tutorial_stage(self):
        self.tilemap, self.rooms, self.portal_tiles, self.room_types = generate_tutorial_map()
        self.projectiles = []; self.potions = []; self.chests = []
        self.effects = []; self.particles = []
        self.portal_open = False
        self.current_room_id = 0
        self.chest_choice_mode = False; self.active_chest = None
        self.chest_selection = 0; self.chest_choices = []
        self.boss_enrage_timer = 0; self.boss_strength = 0
        self.shake_timer = 0; self.shake_strength = 0
        self.tutorial_mage_ducked = False
        self.tutorial_auto_advance = 0
        self.action_lock_timer = 15  # small buffer on load

        room = self.rooms[self.tutorial_stage]
        px = (room[0] + 3) * TILE + TILE // 2
        py = (room[1] + room[3] // 2) * TILE + TILE // 2
        self.player = Player(self.tutorial_stage, px, py)
        self.enemies = []
        if self.tutorial_stage == 0:
            self.enemies.append(Enemy((room[0] + 10) * TILE, (room[1] + 6) * TILE, 0, self.tutorial_stage))
            self.enemies.append(Enemy((room[0] + 11) * TILE, (room[1] + 10) * TILE, 2, self.tutorial_stage))
        elif self.tutorial_stage == 1:
            self.enemies.append(Enemy((room[0] + 10) * TILE, (room[1] + 6) * TILE, 1, self.tutorial_stage))
            self.enemies.append(Enemy((room[0] + 11) * TILE, (room[1] + 10) * TILE, 0, self.tutorial_stage))
        else:
            self.enemies.append(Enemy((room[0] + 10) * TILE, (room[1] + 5) * TILE, 0, self.tutorial_stage))
            self.enemies.append(Enemy((room[0] + 11) * TILE, (room[1] + 8) * TILE, 1, self.tutorial_stage))
            self.enemies.append(Enemy((room[0] + 10) * TILE, (room[1] + 11) * TILE, 2, self.tutorial_stage))
        for e in self.enemies:
            e.active = True
        self.state = ST_TUTORIAL
        self.update_camera()

    def tutorial_checklist(self):
        all_dead = all(not e.alive for e in self.enemies)
        p = self.player
        if self.tutorial_stage == 0:
            return [
                ("Use WASD to move", True),
                ("Press J to attack enemies", all_dead),
                ("Press K to duck/block", getattr(p, 'did_duck_this_life', False)),
            ]
        elif self.tutorial_stage == 1:
            return [
                ("Press J to cast fireballs", all_dead),
                ("Press K to duck an enemy shot", self.tutorial_mage_ducked),
                ("Double-tap K to dodge roll", getattr(p, 'did_dodge_this_life', False)),
            ]
        return [
            ("Press J to charge attack", self.player.used_charge_once),
            ("Defeat all enemies", all_dead),
        ]

    def tutorial_complete(self):
        return all(done for _, done in self.tutorial_checklist())

    def tutorial_all_dead(self):
        return all(not e.alive for e in self.enemies)

    def _update_tutorial(self):
        # Skip key
        if pyxel.btnp(pyxel.KEY_T):
            try_play(2, SFX_CONFIRM)
            self.action_lock_timer = 30
            self.start_select()
            return
        p = self.player
        old_hp = p.hp
        p.update(self.tilemap, self.projectiles, self.enemies, self.effects,
                 self.rooms, self.room_types, input_locked=(self.action_lock_timer > 0))
        if p.hp < old_hp:
            self.add_shake(8, 14)
            self.spawn_hit_particles(p.x, p.y, C_RED, big=True)
            self.effects.append(Effect(p.x, p.y, "big_hit"))
            try_play(1, SFX_HURT)

        for e in self.enemies:
            prev_alive = e.alive
            prev_hp = e.hp
            e.update(self.tilemap, p, self.projectiles, 0, game=self)
            if p.char == 2 and p.charging and e.alive and abs(p.x - e.x) < e.hit_radius and abs(p.y - e.y) < e.hit_radius:
                if e.hurt(p.dmg):
                    nx, ny = normalize(e.x - p.x, e.y - p.y)
                    e.vx += nx * 2.2; e.vy += ny * 2.2
            if e.hp < prev_hp:
                self.add_shake(2, 3)
                try_play(1, SFX_HIT)
            if prev_alive and not e.alive:
                self.spawn_enemy_death_particles(e.x, e.y, e.col)
                self.add_shake(2, 4)
                try_play(1, SFX_ENEMY_DIE)

        for proj in self.projectiles:
            proj.update(self.tilemap, self.enemies)
            if not proj.alive:
                continue
            if proj.friendly:
                for e in self.enemies:
                    if not e.alive:
                        continue
                    if abs(proj.x - e.x) < e.hit_radius and abs(proj.y - e.y) < e.hit_radius:
                        if e.hurt(proj.dmg):
                            nx, ny = normalize(e.x - proj.x, e.y - proj.y)
                            e.vx += nx * (2.4 if proj.size >= 4 else 1.5)
                            e.vy += ny * (2.4 if proj.size >= 4 else 1.5)
                            try_play(1, SFX_HIT)
                        proj.alive = False
                        break
            else:
                hit = 6 if proj.size <= 3 else 8
                if proj.from_mage and p.ducking:
                    self.tutorial_mage_ducked = True
                    continue
                if abs(proj.x - p.x) < hit and abs(proj.y - p.y) < hit:
                    p.hurt(proj.dmg)
                    proj.alive = False

        self.projectiles = [pr for pr in self.projectiles if pr.alive]
        self.update_effects()
        self.update_particles()
        self.update_camera()

        # Advance with J/Enter once complete (blocked by lock)
        if self.tutorial_complete():
            if (pyxel.btnp(pyxel.KEY_RETURN) or pyxel.btnp(pyxel.KEY_J)) and self.action_lock_timer == 0:
                try_play(2, SFX_CONFIRM)
                self.action_lock_timer = 30   # 0.5s cooldown!
                if self.tutorial_stage < 2:
                    self.tutorial_stage += 1
                    self._load_tutorial_stage()
                else:
                    self.tutorial_done = True
                    self.start_select()
        elif self.tutorial_all_dead():
            self.tutorial_auto_advance += 1
            if self.tutorial_auto_advance >= 150:
                try_play(2, SFX_CONFIRM)
                self.action_lock_timer = 30
                if self.tutorial_stage < 2:
                    self.tutorial_stage += 1
                    self._load_tutorial_stage()
                else:
                    self.tutorial_done = True
                    self.start_select()

    # ---- CHESTS ----
    def get_chest_choices(self):
        stat_upgrades = [
            {"kind": "damage", "title": "Power", "desc": "+3 damage", "col": C_RED},
            {"kind": "hp", "title": "Vitality", "desc": "+10% max HP", "col": C_PINK},
            {"kind": "speed", "title": "Swift", "desc": "+10% speed", "col": C_CYAN},
        ]
        universal_relics = [
            {"kind": "lifesteal", "title": "Vampiric", "desc": "+1 HP per kill", "col": C_PINK},
            {"kind": "thorns", "title": "Thorns", "desc": "Hurt attackers", "col": C_GREEN},
            {"kind": "second_wind", "title": "Second Wind", "desc": "Revive 1x run", "col": C_WHITE},
        ]
        specials_per_char = {
            0: [{"kind": "wide_arc", "title": "Wide Arc", "desc": "Bigger swing", "col": C_YELLOW},
                {"kind": "bleeding_edge", "title": "Bleed Edge", "desc": "Hits cause bleed", "col": C_RED}],
            1: [{"kind": "twin_shot", "title": "Twin Shot", "desc": "3 fireballs", "col": C_ORANGE},
                {"kind": "frost_shot", "title": "Frost Shot", "desc": "Hits slow foes", "col": C_LBLUE}],
            2: [{"kind": "charge_blast", "title": "Charge Blast", "desc": "AOE on hit", "col": C_ORANGE},
                {"kind": "iron_hide", "title": "Iron Hide", "desc": "-25% dmg taken", "col": C_LGRAY}],
        }
        pool = list(stat_upgrades)
        for sp in universal_relics:
            if sp["kind"] not in self.player.taken_specials:
                pool.append(sp)
        for sp in specials_per_char.get(self.player.char, []):
            if sp["kind"] not in self.player.taken_specials:
                pool.append(sp)
        if len(pool) <= 3:
            return pool
        return random.sample(pool, 3)

    def apply_chest_upgrade(self, choice):
        kind = choice["kind"]
        cx_, cy_ = self.active_chest.x, self.active_chest.y
        if kind == "damage":
            self.player.dmg += 3
            self.effects.append(Effect(cx_, cy_, "damage"))
            self.spawn_pickup_particles(cx_, cy_, C_RED)
        elif kind == "hp":
            self.player.max_hp = int(round(self.player.max_hp * 1.10))
            self.player.hp = min(self.player.max_hp, self.player.hp + max(8, self.player.max_hp // 10))
            self.effects.append(Effect(cx_, cy_, "hp"))
            self.spawn_pickup_particles(cx_, cy_, C_PINK)
        elif kind == "speed":
            self.player.speed *= 1.10
            self.effects.append(Effect(cx_, cy_, "speed"))
            self.spawn_pickup_particles(cx_, cy_, C_CYAN)
        elif kind == "wide_arc":
            self.player.relic_wide_swing = True
            self.player.taken_specials.add("wide_arc")
            self.spawn_pickup_particles(cx_, cy_, C_YELLOW)
        elif kind == "bleeding_edge":
            self.player.relic_bleeding = True
            self.player.taken_specials.add("bleeding_edge")
            self.spawn_pickup_particles(cx_, cy_, C_RED)
        elif kind == "twin_shot":
            self.player.relic_split_fire = True
            self.player.attack_cooldown = max(12, self.player.attack_cooldown - 1)
            self.player.taken_specials.add("twin_shot")
            self.spawn_pickup_particles(cx_, cy_, C_ORANGE)
        elif kind == "frost_shot":
            self.player.relic_frost = True
            self.player.taken_specials.add("frost_shot")
            self.spawn_pickup_particles(cx_, cy_, C_LBLUE)
        elif kind == "charge_blast":
            self.player.relic_charge_blast = True
            self.player.taken_specials.add("charge_blast")
            self.spawn_pickup_particles(cx_, cy_, C_ORANGE)
        elif kind == "iron_hide":
            self.player.relic_iron_hide = True
            self.player.taken_specials.add("iron_hide")
            self.spawn_pickup_particles(cx_, cy_, C_LGRAY)
        elif kind == "lifesteal":
            self.player.relic_lifesteal = True
            self.player.taken_specials.add("lifesteal")
            self.spawn_pickup_particles(cx_, cy_, C_PINK)
        elif kind == "thorns":
            self.player.relic_thorns = True
            self.player.taken_specials.add("thorns")
            self.spawn_pickup_particles(cx_, cy_, C_GREEN)
        elif kind == "second_wind":
            self.player.relic_second_wind = True
            self.player.taken_specials.add("second_wind")
            self.spawn_pickup_particles(cx_, cy_, C_WHITE)
        try_play(2, SFX_RELIC)

    # ---- Flow control ----
    def start_select(self):
        self.state = ST_SELECT
        self.sel = 0
        self.action_lock_timer = 30

    def restart_run(self):
        self.player = None
        self.level_index = 0
        self.is_boss = False
        self.win_played = False
        self.death_played = False
        self.stat_enemies_killed = 0
        self.stat_chests_opened = 0
        self.stat_level_reached = 1
        self.victory_timer = 0
        self.start_level(0)

    def start_level(self, level_index):
        self.level_index = level_index
        self.is_boss = level_index == 2
        self.stat_level_reached = max(self.stat_level_reached, level_index + 1)
        self.tilemap, self.rooms, self.portal_tiles, self.room_types = generate_dungeon(self.is_boss)
        if self.is_boss and self.rooms:
            style_boss_arena(self.tilemap, self.rooms[-1])

        r0 = self.rooms[0]
        px = (r0[0] + r0[2] // 2) * TILE + TILE // 2
        py = (r0[1] + r0[3] // 2) * TILE + TILE // 2
        old_player = self.player
        self.player = Player(self.sel, px, py)
        if old_player is not None and level_index > 0 and old_player.alive:
            self.player.max_hp = old_player.max_hp
            self.player.hp = min(old_player.hp, self.player.max_hp)
            self.player.speed = old_player.speed
            self.player.dmg = old_player.dmg
            self.player.relic_split_fire = old_player.relic_split_fire
            self.player.relic_wide_swing = old_player.relic_wide_swing
            self.player.relic_charge_blast = old_player.relic_charge_blast
            self.player.relic_bleeding = old_player.relic_bleeding
            self.player.relic_frost = old_player.relic_frost
            self.player.relic_iron_hide = old_player.relic_iron_hide
            self.player.relic_lifesteal = old_player.relic_lifesteal
            self.player.relic_thorns = old_player.relic_thorns
            self.player.relic_second_wind = old_player.relic_second_wind
            self.player.second_wind_used = old_player.second_wind_used
            self.player.taken_specials = set(old_player.taken_specials)
            self.player.attack_cooldown = old_player.attack_cooldown

        self.enemies = []; self.projectiles = []
        self.potions = []; self.chests = []
        self.effects = []; self.particles = []
        self.portal_open = False
        self.current_room_id = -1
        self.chest_choice_mode = False; self.active_chest = None
        self.chest_selection = 0; self.chest_choices = []
        self.boss_enrage_timer = 0; self.boss_strength = 0
        self.shake_timer = 0; self.shake_strength = 0

        # Populate
        for room_id in range(1, len(self.rooms)):
            room = self.rooms[room_id]
            rtype = self.room_types[room_id]
            if rtype == ROOM_TREASURE and not self.is_boss:
                cx = (room[0] + room[2] // 2) * TILE + TILE // 2
                cy = (room[1] + room[3] // 2) * TILE + TILE // 2
                self.chests.append(Chest(cx, cy, room_id))
            elif not self.is_boss:
                if random.random() < 0.18:
                    bx = random.randint(room[0] + 1, room[0] + room[2] - 2) * TILE + TILE // 2
                    by = random.randint(room[1] + 1, room[1] + room[3] - 2) * TILE + TILE // 2
                    self.chests.append(Chest(bx, by, room_id))
                if random.random() < 0.25:
                    pxx = random.randint(room[0] + 1, room[0] + room[2] - 2) * TILE + TILE // 2
                    pyy = random.randint(room[1] + 1, room[1] + room[3] - 2) * TILE + TILE // 2
                    self.potions.append(HealPotion(pxx, pyy, room_id))

        if self.is_boss:
            room_id = len(self.rooms) - 1
            room = self.rooms[room_id]
            ex = (room[0] + room[2] // 2) * TILE + TILE // 2
            ey = (room[1] + room[3] // 2) * TILE + TILE // 2
            self.enemies.append(Enemy(ex, ey, 3, room_id))
            self.boss_intro_timer = 360
            self.boss_intro_roared = False
            self.state = ST_BOSS_INTRO
            if len(self.rooms) >= 2:
                mid_room = self.rooms[1]
                bcx = (mid_room[0] + mid_room[2] // 2) * TILE + TILE // 2
                bcy = (mid_room[1] + mid_room[3] // 2) * TILE + TILE // 2
                self.chests.append(Chest(bcx, bcy, 1))
                self.potions.append(HealPotion(bcx + 20, bcy, 1))
        else:
            for room_id in range(1, len(self.rooms)):
                room = self.rooms[room_id]
                rtype = self.room_types[room_id]
                if rtype == ROOM_TREASURE:
                    continue
                if rtype == ROOM_SPIDER:
                    enemy_kinds = [0, 0, 0]; count = random.randint(1, 3)
                elif rtype == ROOM_BAT:
                    enemy_kinds = [2, 2, 2]; count = random.randint(1, 3)
                elif rtype == ROOM_MAGE:
                    enemy_kinds = [1, 1, 0]; count = random.randint(1, 2)
                else:
                    enemy_kinds = [0, 0, 2] if level_index == 0 else [0, 1, 2]
                    count = random.randint(1, 3)
                for _ in range(count):
                    ex = random.randint(room[0] + 1, room[0] + room[2] - 2) * TILE + TILE // 2
                    ey = random.randint(room[1] + 1, room[1] + room[3] - 2) * TILE + TILE // 2
                    new_enemy = Enemy(ex, ey, random.choice(enemy_kinds), room_id, level_index)
                    if random.random() < 0.125:
                        new_enemy.make_elite()
                    self.enemies.append(new_enemy)
            if level_index == 1 and len(self.rooms) >= 2:
                last_room = self.rooms[-1]
                mbx = (last_room[0] + last_room[2] // 2) * TILE + TILE // 2
                mby = (last_room[1] + last_room[3] // 2) * TILE + TILE // 2
                self.enemies.append(Enemy(mbx, mby, 4, len(self.rooms) - 1, level_index))
            self.state = ST_PLAY

        # Show stage banner
        if level_index == 0:
            self.stage_banner_text = "STAGE 1"
            self.stage_banner_timer = 120
        elif level_index == 1:
            self.stage_banner_text = "STAGE 2"
            self.stage_banner_timer = 120
        elif level_index == 2:
            self.stage_banner_text = "STAGE 3 - FINAL"
            self.stage_banner_timer = 120

        self.action_lock_timer = 30
        self.update_camera()

    def toggle_pause(self):
        if self.state in (ST_PLAY, ST_BOSS):
            self.prev_state = self.state
            self.state = ST_PAUSE
            try_play(2, SFX_SELECT)
        elif self.state == ST_PAUSE:
            self.state = self.prev_state
            try_play(2, SFX_SELECT)

    def update_camera(self):
        self.cam_x = clamp(int(self.player.x - W // 2), 0, MAP_W - W)
        self.cam_y = clamp(int(self.player.y - H // 2), 0, MAP_H - H)

    def get_player_room_id(self):
        tx = self.player.tile_x(); ty = self.player.tile_y()
        for i, room in enumerate(self.rooms):
            if point_in_room(tx, ty, room):
                return i
        best_idx = -1; best_dist = 999999
        for i, room in enumerate(self.rooms):
            cx, cy = room_center(room)
            d = abs(tx - cx) + abs(ty - cy)
            if d < best_dist:
                best_dist = d; best_idx = i
        return best_idx

    def update_room_activation(self):
        room_id = self.get_player_room_id()
        self.current_room_id = room_id
        player_tx = self.player.tile_x()
        player_ty = self.player.tile_y()
        for i, room in enumerate(self.rooms):
            cx, cy = room_center(room)
            near = abs(player_tx - cx) + abs(player_ty - cy) <= 7
            for e in self.enemies:
                if e.room_id == i and (i == room_id or near):
                    e.active = True
        for e in self.enemies:
            if not e.active and e.alive and length(e.x - self.player.x, e.y - self.player.y) < 72:
                e.active = True

    def update_chest_choice(self):
        if not self.chest_choice_mode or self.active_chest is None:
            return
        if pyxel.btnp(pyxel.KEY_A):
            self.chest_selection = (self.chest_selection - 1) % len(self.chest_choices)
            try_play(2, SFX_SELECT)
        if pyxel.btnp(pyxel.KEY_D):
            self.chest_selection = (self.chest_selection + 1) % len(self.chest_choices)
            try_play(2, SFX_SELECT)
        # GATED by action_lock_timer so attack-spam can't auto-choose
        if (pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN)) and self.action_lock_timer == 0:
            self.apply_chest_upgrade(self.chest_choices[self.chest_selection])
            self.chest_choice_mode = False
            self.active_chest = None

    def update_effects(self):
        for fx in self.effects:
            fx.update()
        self.effects = [fx for fx in self.effects if fx.alive]

    def update_particles(self):
        for p in self.particles:
            p.update()
        self.particles = [p for p in self.particles if p.alive]
            # ==================================================
    # MAIN UPDATE / LOGIC LOOP
    # ==================================================
    def update(self):
        # Universal timers
        if self.action_lock_timer > 0: self.action_lock_timer -= 1
        if self.stage_banner_timer > 0: self.stage_banner_timer -= 1
        if self.boss_taunt_timer > 0: self.boss_taunt_timer -= 1

        # MUSIC SWITCHER
        if self.state == ST_MENU:
            self.play_music(SFX_MUS_MENU)
        elif self.state in (ST_TUTORIAL, ST_PLAY):
            self.play_music(SFX_MUS_DUNG)
        elif self.state in (ST_BOSS, ST_BOSS_INTRO):
            self.play_music(SFX_MUS_BOSS)
        elif self.state in (ST_WIN, ST_DEAD):
            self.stop_music()

        if pyxel.btnp(pyxel.KEY_P) and self.state in (ST_PLAY, ST_BOSS, ST_PAUSE):
            self.toggle_pause()
            return

        if self.state == ST_MENU:
            if (pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN)) and self.action_lock_timer == 0:
                try_play(2, SFX_CONFIRM)
                self.action_lock_timer = 30
                self.start_tutorial()
        elif self.state == ST_TUTORIAL:
            self._update_tutorial()
        elif self.state == ST_SELECT:
            if pyxel.btnp(pyxel.KEY_A):
                self.sel = (self.sel - 1) % 3; try_play(2, SFX_SELECT)
            if pyxel.btnp(pyxel.KEY_D):
                self.sel = (self.sel + 1) % 3; try_play(2, SFX_SELECT)
            if (pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN)) and self.action_lock_timer == 0:
                try_play(2, SFX_CONFIRM)
                self.action_lock_timer = 30
                self.start_level(0)
        elif self.state == ST_BOSS_INTRO:
            self.boss_intro_timer -= 1
            elapsed = 360 - self.boss_intro_timer
            if elapsed == 90 and not self.boss_intro_roared:
                self.boss_intro_roared = True
                try_play(3, SFX_ROAR)
                self.add_shake(10, 40)
            if (pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN) or self.boss_intro_timer <= 0) and self.action_lock_timer == 0:
                try_play(2, SFX_CONFIRM)
                self.action_lock_timer = 30
                self.state = ST_BOSS
                self.boss_taunt_text = "You dare challenge me, mortal?"
                self.boss_taunt_timer = 160
        elif self.state in (ST_PLAY, ST_BOSS):
            self._update_game()
        elif self.state == ST_PAUSE:
            if pyxel.btnp(pyxel.KEY_R):
                try_play(2, SFX_CONFIRM); self.restart_run()
            elif pyxel.btnp(pyxel.KEY_M):
                try_play(2, SFX_CONFIRM)
                self.state = ST_MENU
                self.win_played = False; self.death_played = False
        elif self.state == ST_WIN:
            if not self.win_played:
                self.win_played = True
                try_play(3, SFX_WIN)
                self.victory_timer = 0
            self.victory_timer += 1
            if self.victory_timer > 420 and (pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN)):
                self.state = ST_MENU
                self.win_played = False
                self.victory_timer = 0
        elif self.state == ST_DEAD:
            if not self.death_played:
                self.death_played = True
                try_play(3, SFX_DEATH)
            if pyxel.btnp(pyxel.KEY_J) or pyxel.btnp(pyxel.KEY_RETURN):
                self.state = ST_MENU
                self.death_played = False

        if self.shake_timer > 0:
            self.shake_timer -= 1
        else:
            self.shake_strength = 0
        if self.stage_clear_timer > 0:
            self.stage_clear_timer -= 1

        if self.state in (ST_PLAY, ST_BOSS) and self.player and self.player.alive:
            if self.player.hp / max(1, self.player.max_hp) < 0.2:
                self.crit_tick += 1
                if self.crit_tick >= 48:
                    self.crit_tick = 0; try_play(2, SFX_CRIT_HP)
            else:
                self.crit_tick = 0

    def _update_game(self):
        self.update_room_activation()
        if self.chest_choice_mode:
            self.update_chest_choice()
            self.update_effects()
            self.update_particles()
            self.spawn_portal_particles()
            return

        p = self.player
        old_hp = p.hp
        p.update(self.tilemap, self.projectiles, self.enemies, self.effects,
                 self.rooms, self.room_types, input_locked=(self.action_lock_timer > 0))
        if not p.alive:
            self.state = ST_DEAD
            return
        if p.hp < old_hp:
            self.add_shake(8, 14)
            self.spawn_hit_particles(p.x, p.y, C_RED, big=True)
            self.effects.append(Effect(p.x, p.y, "big_hit"))
            try_play(1, SFX_HURT)

        for potion in self.potions:
            potion.update(p, self)

        for chest in self.chests:
            if not chest.opened and abs(p.x - chest.x) < 9 and abs(p.y - chest.y) < 9:
                chest.opened = True
                self.chest_choice_mode = True
                self.active_chest = chest
                self.chest_selection = 0
                self.chest_choices = self.get_chest_choices()
                self.stat_chests_opened += 1
                # 0.5s lock so attack key doesn't auto-pick a reward
                self.action_lock_timer = 30
                try_play(2, SFX_CHEST_OPEN)
                break

        if self.state == ST_BOSS:
            self.boss_enrage_timer += 1
            self.boss_strength = self.boss_enrage_timer // 720

        for _e in self.enemies:
            _e.was_alive_tick_start = _e.alive

        for e in self.enemies:
            prev_hp = e.hp
            prev_phase = e.phase
            e.update(self.tilemap, p, self.projectiles, self.boss_strength, game=self)
            if p.char == 2 and p.charging and e.alive and abs(p.x - e.x) < e.hit_radius and abs(p.y - e.y) < e.hit_radius:
                if e.hurt(p.dmg):
                    nx, ny = normalize(e.x - p.x, e.y - p.y)
                    e.vx += nx * 2.2; e.vy += ny * 2.2
                    if p.relic_charge_blast:
                        for other in self.enemies:
                            if other is e or not other.alive:
                                continue
                            if length(other.x - e.x, other.y - e.y) < 26:
                                other.hurt(max(1, p.dmg // 2))
            if e.hp < prev_hp:
                self.add_shake(2, 3)
                try_play(1, SFX_HIT)

            # BOSS TAUNTS + phase transition FX
            if e.kind == 3 and e.phase != prev_phase:
                self.add_shake(12, 30)
                try_play(3, SFX_ROAR)
                for _pidx in range(25):
                    _pang = _pidx * math.tau / 25
                    self.particles.append(Particle(e.x, e.y, math.cos(_pang) * 3.0,
                                                   math.sin(_pang) * 3.0, 36, C_ORANGE, size=2, shrink=True))
                taunts = {
                    2: "YOU CANNOT KILL WHAT IS ETERNAL!",
                    3: "I AM THE END OF ALL THINGS!",
                }
                self.boss_taunt_text = taunts.get(e.phase, "")
                self.boss_taunt_timer = 180

        for proj in self.projectiles:
            proj.update(self.tilemap, self.enemies)
            if not proj.alive:
                continue
            if proj.friendly:
                for e in self.enemies:
                    if not e.alive:
                        continue
                    if abs(proj.x - e.x) < e.hit_radius and abs(proj.y - e.y) < e.hit_radius:
                        if e.hurt(proj.dmg):
                            nx, ny = normalize(e.x - proj.x, e.y - proj.y)
                            e.vx += nx * (2.4 if proj.size >= 4 else 1.5)
                            e.vy += ny * (2.4 if proj.size >= 4 else 1.5)
                            if getattr(proj, "frost", False):
                                e.frost_timer = 120
                                try_play(1, SFX_FROST)
                            else:
                                try_play(1, SFX_HIT)
                        proj.alive = False
                        break
            else:
                hit = 6 if proj.size <= 3 else 8
                if (proj.from_mage or getattr(proj, 'duckable', False)) and p.ducking:
                    continue
                if abs(proj.x - p.x) < hit and abs(proj.y - p.y) < hit:
                    p.hurt(proj.dmg, getattr(proj, 'source_name', None))
                    proj.alive = False

        for e in self.enemies:
            if getattr(e, 'was_alive_tick_start', False) and not e.alive:
                self._report_enemy_death(e)

        self.projectiles = [pr for pr in self.projectiles if pr.alive]
        self.potions = [po for po in self.potions if po.alive]

        if getattr(p, 'second_wind_triggered', False):
            self.add_shake(12, 30)
            for _swi in range(30):
                _swa = random.random() * math.tau
                _sws = random.uniform(1.0, 3.5)
                self.particles.append(Particle(p.x, p.y, math.cos(_swa) * _sws, math.sin(_swa) * _sws, 40, C_PINK, size=2, shrink=True))
            self.effects.append(Effect(p.x, p.y - 10, "second_wind"))
            try_play(3, SFX_RELIC)
            p.second_wind_triggered = False

        self.update_effects()
        self.update_particles()
        self.spawn_portal_particles()

        all_dead = all(not e.alive for e in self.enemies)
        if all_dead and not self.portal_open:
            self.portal_open = True
            self.stage_clear_timer = 90
            self.add_shake(5, 12)
            try_play(2, SFX_PORTAL); try_play(3, SFX_STAGE_CLEAR)
            for _pt in self.portal_tiles:
                px = _pt[0] * TILE + TILE // 2
                py = _pt[1] * TILE + TILE // 2
                self.spawn_hit_particles(px, py, THEMES[self.level_index]["portal_a"], big=True)

        if self.portal_open and point_near_tiles(p.tile_x(), p.tile_y(), self.portal_tiles, 1):
            if self.level_index == 0:
                self.start_level(1)
            elif self.level_index == 1:
                self.start_level(2)
            else:
                self.state = ST_WIN
                self.victory_timer = 0
            return

        self.update_camera()

    # ==================================================
    # DRAWING: tiles, HUD, minimap, menu, tutorial, boss intro, endings
    # ==================================================
    def draw_boss_ui(self):
        if self.state != ST_BOSS:
            return
        boss = None
        for e in self.enemies:
            if e.kind == 3 and e.alive:
                boss = e; break
        if boss is None:
            return
        x = W // 2 - 90; y = 6; w = 180
        pyxel.rect(x - 2, y - 2, w + 4, 18, C_BLACK)
        pyxel.rectb(x - 2, y - 2, w + 4, 18, C_WHITE)
        pyxel.text(x + 4, y, "ANCIENT DRAGON", C_ORANGE)
        pyxel.rect(x, y + 8, w, 4, C_DARK)
        fill = int(w * boss.hp / boss.max_hp) if boss.max_hp > 0 else 0
        pyxel.rect(x, y + 8, fill, 4, C_RED)
        pyxel.text(x + w - 44, y, f"PHASE {boss.phase}", C_YELLOW)

    def draw_minimap(self):
        """Minimap wrapped in wooden-gold picture frame; 2x2 enemy dots."""
        mm_x = W - 100; mm_y = 26; mm_w = 86; mm_h = 64
        # === WOODEN-GOLD FRAME ===
        # Outer gold
        pyxel.rect(mm_x - 7, mm_y - 7, mm_w + 14, mm_h + 14, C_YELLOW)
        pyxel.rectb(mm_x - 7, mm_y - 7, mm_w + 14, mm_h + 14, C_ORANGE)
        # Wood inner
        pyxel.rect(mm_x - 5, mm_y - 5, mm_w + 10, mm_h + 10, C_BROWN)
        pyxel.rectb(mm_x - 5, mm_y - 5, mm_w + 10, mm_h + 10, C_DARK)
        # Wood grain lines
        for gy in range(mm_y - 4, mm_y + mm_h + 5, 3):
            pyxel.line(mm_x - 5, gy, mm_x + mm_w + 4, gy, C_DARK)
        # Corner ornaments (gold flourishes)
        for ox, oy in [(mm_x - 7, mm_y - 7), (mm_x + mm_w + 1, mm_y - 7),
                       (mm_x - 7, mm_y + mm_h + 1), (mm_x + mm_w + 1, mm_y + mm_h + 1)]:
            pyxel.rect(ox, oy, 6, 6, C_YELLOW)
            pyxel.rectb(ox, oy, 6, 6, C_ORANGE)
            pyxel.pset(ox + 2, oy + 2, C_WHITE)
            pyxel.pset(ox + 3, oy + 3, C_WHITE)
            pyxel.pset(ox + 1, oy + 4, C_ORANGE)
            pyxel.pset(ox + 4, oy + 1, C_ORANGE)
        # Top ornament label plaque
        pyxel.rect(mm_x + mm_w // 2 - 12, mm_y - 10, 24, 6, C_YELLOW)
        pyxel.rectb(mm_x + mm_w // 2 - 12, mm_y - 10, 24, 6, C_ORANGE)
        pyxel.text(mm_x + mm_w // 2 - 10, mm_y - 9, "MAP", C_BLACK)
        # Inner black
        pyxel.rect(mm_x - 2, mm_y - 2, mm_w + 4, mm_h + 4, C_BLACK)
        pyxel.rectb(mm_x - 2, mm_y - 2, mm_w + 4, mm_h + 4, C_WHITE)

        # Floor tiles
        for ty in range(MAP_ROWS):
            for tx in range(MAP_COLS):
                if self.tilemap[ty][tx] != T_WALL:
                    pyxel.pset(mm_x + tx * mm_w // MAP_COLS, mm_y + ty * mm_h // MAP_ROWS, C_DGRAY)
        # Rooms
        for i, room in enumerate(self.rooms):
            rx, ry, rw, rh = room
            mx = mm_x + rx * mm_w // MAP_COLS
            my = mm_y + ry * mm_h // MAP_ROWS
            mw = max(2, rw * mm_w // MAP_COLS)
            mh = max(2, rh * mm_h // MAP_ROWS)
            if i == 0: col = C_GREEN
            elif i < len(self.room_types) and self.room_types[i] == ROOM_TREASURE: col = C_YELLOW
            elif i < len(self.room_types) and self.room_types[i] == ROOM_BOSS: col = C_RED
            else: col = C_LGRAY if self.state == ST_TUTORIAL else C_DGRAY
            pyxel.rect(mx, my, mw, mh, col)
            pyxel.rectb(mx, my, mw, mh, C_BLACK)
        # Portal blink
        if self.portal_open and self.portal_tiles:
            pt = self.portal_tiles[len(self.portal_tiles) // 2]
            mx = mm_x + pt[0] * mm_w // MAP_COLS
            my = mm_y + pt[1] * mm_h // MAP_ROWS
            flick = C_YELLOW if (pyxel.frame_count // 5) % 2 == 0 else C_WHITE
            pyxel.rect(mx - 2, my - 2, 5, 5, flick)
            pyxel.rectb(mx - 3, my - 3, 7, 7, C_ORANGE)
        # ENEMY DOTS — 2x2
        for e in self.enemies:
            if e.alive:
                emx = mm_x + int(e.x / TILE) * mm_w // MAP_COLS
                emy = mm_y + int(e.y / TILE) * mm_h // MAP_ROWS
                pyxel.rect(emx, emy, 2, 2, C_RED)
        # Player
        px = mm_x + self.player.tile_x() * mm_w // MAP_COLS
        py = mm_y + self.player.tile_y() * mm_h // MAP_ROWS
        pyxel.rect(px - 1, py - 1, 3, 3, C_CYAN)

    def _draw_room_overlay(self, tx, ty, room_id, rtype):
        px = tx * TILE - self.cam_x
        py = ty * TILE - self.cam_y
        if px <= -TILE or py <= -TILE or px >= W or py >= H:
            return
        if rtype == ROOM_SPIDER and is_spider_web_tile(tx, ty, rtype):
            pyxel.line(px + 1, py + 1, px + 6, py + 6, C_LGRAY)
            pyxel.line(px + 6, py + 1, px + 1, py + 6, C_LGRAY)
            pyxel.pset(px + 4, py + 2, C_WHITE); pyxel.pset(px + 2, py + 4, C_WHITE)
        elif rtype == ROOM_BAT and (tx * 3 + ty) % 8 == 0:
            pyxel.pset(px + 2, py + 2, C_LBLUE); pyxel.pset(px + 5, py + 4, C_LBLUE)
        elif rtype == ROOM_MAGE and (tx + ty) % 7 == 0:
            pyxel.circb(px + 4, py + 4, 2, C_PURPLE)
        elif rtype == ROOM_TREASURE and (tx + ty) % 5 == 0:
            pyxel.pset(px + 3, py + 3, C_YELLOW); pyxel.pset(px + 4, py + 4, C_YELLOW)

    def _draw_portal_tile(self, px, py, theme):
        """BIG multi-ring swirl portal."""
        pyxel.rect(px, py, TILE, TILE, C_BLACK)
        if self.portal_open:
            t = pyxel.frame_count
            # Determine position inside the 5-tile cluster for nice radial effect
            col1 = theme["portal_a"] if (t // 4) % 2 == 0 else theme["portal_b"]
            col2 = theme["portal_b"] if (t // 4) % 2 == 0 else theme["portal_a"]
            # Bright center + expanding rings
            pyxel.circ(px + 4, py + 4, 3, col1)
            pyxel.circb(px + 4, py + 4, 3, col2)
            pyxel.circ(px + 4, py + 4, 2, C_WHITE)
            pyxel.circ(px + 4, py + 4, 1, C_YELLOW)
            # Swirling sparkles
            if (t + px + py) % 4 == 0:
                pyxel.pset(px + 1, py + 1, C_WHITE)
                pyxel.pset(px + 6, py + 6, C_WHITE)
            if (t + px) % 5 == 0:
                pyxel.pset(px + 6, py + 1, C_YELLOW)
                pyxel.pset(px + 1, py + 6, C_YELLOW)
        else:
            pyxel.rectb(px, py, TILE, TILE, C_RED)
            pyxel.line(px + 2, py + 1, px + 2, py + 7, C_RED)
            pyxel.line(px + 5, py + 1, px + 5, py + 7, C_RED)

    def _draw_tile(self, tx, ty, t):
        px = tx * TILE - self.cam_x
        py = ty * TILE - self.cam_y
        theme = THEMES[0] if self.state == ST_TUTORIAL else THEMES[self.level_index]
        if px <= -TILE or py <= -TILE or px >= W or py >= H:
            return
        room_id = -1
        for i, room in enumerate(self.rooms):
            if point_in_room(tx, ty, room):
                room_id = i; break
        room_type = self.room_types[room_id] if room_id >= 0 and room_id < len(self.room_types) else ROOM_NORMAL

        if t == T_WALL:
            # REDESIGNED STONE BRICK WALL with mortar, vines, moss, occasional blood
            pyxel.rect(px, py, TILE, TILE, theme["wall_mortar"])
            # Brick pattern — alternating rows offset
            brick_row = ty % 2
            if brick_row == 0:
                pyxel.rect(px, py + 1, 3, 3, theme["wall_brick"])
                pyxel.rect(px + 4, py + 1, 4, 3, theme["wall_brick"])
                pyxel.rect(px, py + 5, 3, 3, theme["wall_main"])
                pyxel.rect(px + 4, py + 5, 4, 3, theme["wall_main"])
            else:
                pyxel.rect(px, py + 1, 5, 3, theme["wall_brick"])
                pyxel.rect(px + 6, py + 1, 2, 3, theme["wall_brick"])
                pyxel.rect(px, py + 5, 5, 3, theme["wall_main"])
                pyxel.rect(px + 6, py + 5, 2, 3, theme["wall_main"])
            # Highlight
            pyxel.pset(px + 1, py + 1, theme["wall_light"])
            pyxel.pset(px + 5, py + 5, theme["wall_light"])
            # Moss
            if (tx * 3 + ty) % 11 == 0:
                pyxel.pset(px + 2, py + 7, theme["wall_moss"])
                pyxel.pset(px + 3, py + 7, theme["wall_moss"])
            # Vines
            if (tx + ty * 2) % 13 == 0:
                pyxel.line(px + 6, py, px + 6, py + 4, theme["wall_vine"])
                pyxel.pset(px + 7, py + 2, theme["wall_moss"])
            # Blood stain (rare)
            if (tx * 17 + ty * 5) % 41 == 0:
                pyxel.pset(px + 4, py + 3, theme["wall_stain"])
                pyxel.pset(px + 5, py + 4, theme["wall_stain"])
                pyxel.pset(px + 3, py + 2, theme["wall_stain"])
        elif t == T_FLOOR:
            # COBBLESTONES
            pyxel.rect(px, py, TILE, TILE, theme["floor_main"])
            # Cobble outlines — 4 mini stones per tile
            pyxel.pset(px + 3, py, theme["floor_crack"])
            pyxel.pset(px, py + 3, theme["floor_crack"])
            pyxel.pset(px + 7, py + 3, theme["floor_crack"])
            pyxel.pset(px + 3, py + 7, theme["floor_crack"])
            pyxel.pset(px + 3, py + 3, theme["floor_crack"])
            # Stone highlights
            pyxel.pset(px + 1, py + 1, theme["floor_alt"])
            pyxel.pset(px + 5, py + 1, theme["floor_alt"])
            pyxel.pset(px + 1, py + 5, theme["floor_alt"])
            pyxel.pset(px + 5, py + 5, theme["floor_alt"])
            # Random tiny details
            if (tx + ty) % 5 == 0:
                pyxel.pset(px + 6, py + 2, theme["floor_spark"])
            # Bones (rare)
            if (tx * 13 + ty * 7) % 37 == 0:
                pyxel.line(px + 2, py + 5, px + 5, py + 5, theme["floor_bone"])
                pyxel.pset(px + 2, py + 4, theme["floor_bone"])
                pyxel.pset(px + 5, py + 4, theme["floor_bone"])
            # Cracks (rare)
            if (tx * 11 + ty * 3) % 29 == 0:
                pyxel.line(px + 1, py + 4, px + 6, py + 4, theme["floor_crack"])
            # Puddles (rare)
            if (tx * 5 + ty * 11) % 43 == 0:
                pyxel.rect(px + 2, py + 5, 4, 2, theme["floor_puddle"])
                pyxel.pset(px + 3, py + 5, C_WHITE)
            if room_id >= 0:
                self._draw_room_overlay(tx, ty, room_id, room_type)
        elif t == T_PORTAL:
            self._draw_portal_tile(px, py, theme)

    def _draw_fog(self):
        px = int(self.player.x - self.cam_x)
        py = int(self.player.y - self.cam_y)
        for y in range(0, H, 4):
            for x in range(0, W, 4):
                d = math.sqrt((x - px) ** 2 + (y - py) ** 2)
                if d > 108:
                    pyxel.rect(x, y, 4, 4, C_BLACK)
                elif d > 88 and (x + y + pyxel.frame_count) % 8 < 4:
                    pyxel.rect(x, y, 4, 4, C_DARK)

    def _draw_menu(self):
        t = pyxel.frame_count
        pyxel.rect(0, 0, W, 18, C_BLACK)
        pyxel.rect(0, 18, W, 22, C_DARK)
        pyxel.rect(0, 40, W, 35, C_PURPLE)
        pyxel.rect(0, 75, W, 30, C_DARK)
        pyxel.rect(0, 105, W, 30, C_BLACK)
        for x, y, phase in self.menu_stars:
            flicker = ((t + phase) // 14) % 6
            col = [C_WHITE, C_LGRAY, C_LBLUE, C_CYAN, C_DGRAY, C_DGRAY][flicker]
            pyxel.pset(x, y, col)
        # Moon
        moon_x, moon_y = W - 70, 30
        pyxel.circb(moon_x, moon_y, 24, C_PURPLE)
        pyxel.circ(moon_x, moon_y, 16, C_LGRAY)
        pyxel.circ(moon_x, moon_y, 15, C_WHITE)
        pyxel.circ(moon_x - 5, moon_y - 3, 3, C_LGRAY)
        pyxel.circ(moon_x + 6, moon_y + 2, 2, C_LGRAY)
        # Mountains
        for i in range(-1, 9):
            mx = i * 52 - 10
            peak_h = 50 + (i * 13) % 35
            pyxel.tri(mx, 132, mx + 30, 132 - peak_h, mx + 60, 132, C_DGRAY)
            pyxel.tri(mx + 22, 132 - peak_h + 15, mx + 30, 132 - peak_h, mx + 38, 132 - peak_h + 15, C_WHITE)
        # Castle
        cx = W // 2; cy = 128
        pyxel.rect(cx - 36, cy, 72, 24, C_BLACK)
        pyxel.rect(cx - 42, cy - 4, 8, 28, C_BLACK)
        pyxel.rect(cx + 34, cy - 4, 8, 28, C_BLACK)
        pyxel.tri(cx - 43, cy - 4, cx - 38, cy - 14, cx - 33, cy - 4, C_BLACK)
        pyxel.tri(cx + 33, cy - 4, cx + 38, cy - 14, cx + 43, cy - 4, C_BLACK)
        pyxel.rect(cx - 4, cy - 16, 8, 16, C_BLACK)
        pyxel.tri(cx - 5, cy - 16, cx, cy - 26, cx + 5, cy - 16, C_BLACK)
        windows = [(-30, 4), (-20, 4), (20, 4), (30, 4), (-4, -8), (2, -8)]
        for wx, wy in windows:
            flicker = (t // 15) % 3
            col = [C_ORANGE, C_YELLOW, C_RED][flicker]
            pyxel.rect(cx + wx, cy + wy, 2, 2, col)
        # Ground
        pyxel.rect(0, 232, W, H - 232, C_DARK)
        pyxel.rect(0, 230, W, 2, C_DGRAY)
        self._draw_torch(24, 206, t)
        self._draw_torch(W - 24, 206, t)
        # TITLE: "DUNGEON MASTER"
        for dx, dy, col in [(3, 3, C_BLACK), (2, 2, C_BLACK), (1, 1, C_DARK)]:
            pyxel.text(W // 2 - 56 + dx, 72 + dy, "DUNGEON  MASTER", col)
        title_col = [C_YELLOW, C_ORANGE, C_RED, C_ORANGE][(t // 15) % 4]
        pyxel.text(W // 2 - 56, 72, "DUNGEON  MASTER", title_col)
        if (t // 8) % 4 == 0:
            pyxel.text(W // 2 - 56, 71, "DUNGEON  MASTER", C_WHITE)
        pyxel.text(W // 2 - 50, 92, "-- ENTER THE REALM --", C_PINK)
        # Hero row
        hero_y = 214
        bob = [0, 1, 0][(t // 20) % 3]
        draw_player_sprite(0, W // 2 - 54, hero_y + bob, (t // 12) % 4, 1, show_bull_aim=False)
        draw_player_sprite(1, W // 2, hero_y, (t // 12) % 4, 1, show_bull_aim=False)
        draw_player_sprite(2, W // 2 + 54, hero_y, (t // 12) % 4, 1, show_bull_aim=False)
        self._draw_controls_panel(8, 140)
        self._draw_tips_panel(W - 120, 140)
        # Prompt
        prompt_y = 258
        box_x = W // 2 - 110
        pulse_col = C_YELLOW if (t // 15) % 2 == 0 else C_ORANGE
        pyxel.rectb(box_x - 4, prompt_y - 6, 228, 18, pulse_col)
        pyxel.rect(box_x, prompt_y - 2, 220, 10, C_BLACK)
        flash = C_WHITE if (t // 18) % 2 == 0 else C_YELLOW
        draw_text_center_shadow(prompt_y, "PRESS J OR ENTER TO START", flash)
        pyxel.text(4, H - 8, "v2.0  -  DUNGEON MASTER", C_DGRAY)

    def _draw_torch(self, x, y, t):
        pyxel.rect(x - 1, y, 3, 26, C_BROWN)
        pyxel.rect(x - 3, y - 1, 7, 2, C_DGRAY)
        flame_h = 8 + ((t // 4) % 3)
        pyxel.tri(x - 3, y - 1, x + 3, y - 1, x, y - flame_h, C_RED)
        pyxel.tri(x - 2, y - 1, x + 2, y - 1, x, y - flame_h + 2, C_ORANGE)
        pyxel.tri(x - 1, y - 1, x + 1, y - 1, x, y - flame_h + 4, C_YELLOW)
        pyxel.pset(x, y - flame_h + 2, C_WHITE)

    def _draw_key_cap(self, x, y, letter, highlight=False, pulse=False):
        col = C_YELLOW if highlight else C_WHITE
        if pulse and (pyxel.frame_count // 10) % 2 == 0:
            col = C_ORANGE
        pyxel.rect(x, y, 9, 9, C_DGRAY)
        pyxel.rectb(x, y, 9, 9, col)
        pyxel.text(x + 3, y + 2, letter, col)

    def _draw_controls_panel(self, x, y):
        pyxel.rect(x, y, 112, 82, C_BLACK)
        pyxel.rectb(x, y, 112, 82, C_WHITE)
        pyxel.text(x + 4, y + 3, "CONTROLS", C_YELLOW)
        self._draw_key_cap(x + 18, y + 14, "W")
        self._draw_key_cap(x + 8, y + 24, "A")
        self._draw_key_cap(x + 18, y + 24, "S")
        self._draw_key_cap(x + 28, y + 24, "D")
        pyxel.text(x + 44, y + 19, "MOVE", C_LGRAY)
        self._draw_key_cap(x + 8, y + 38, "J", highlight=True)
        pyxel.text(x + 20, y + 40, "ATTACK", C_WHITE)
        self._draw_key_cap(x + 8, y + 48, "K")
        pyxel.text(x + 20, y + 50, "DUCK/BLOCK", C_WHITE)
        self._draw_key_cap(x + 8, y + 58, "K", highlight=True)
        self._draw_key_cap(x + 18, y + 58, "K", highlight=True)
        pyxel.text(x + 32, y + 60, "DODGE", C_CYAN)
        self._draw_key_cap(x + 8, y + 68, "P")
        pyxel.text(x + 20, y + 70, "PAUSE", C_LGRAY)

    def _draw_tips_panel(self, x, y):
        pyxel.rect(x, y, 112, 82, C_BLACK)
        pyxel.rectb(x, y, 112, 82, C_WHITE)
        pyxel.text(x + 4, y + 3, "HOW TO SURVIVE", C_YELLOW)
        pyxel.text(x + 4, y + 16, "- Clear rooms to", C_LGRAY)
        pyxel.text(x + 4, y + 23, "  unlock portals", C_LGRAY)
        pyxel.text(x + 4, y + 33, "- Open chests for", C_LGRAY)
        pyxel.text(x + 4, y + 40, "  relic upgrades", C_LGRAY)
        pyxel.text(x + 4, y + 50, "- Beat the ANCIENT", C_ORANGE)
        pyxel.text(x + 4, y + 57, "  DRAGON to win!", C_ORANGE)
        pyxel.text(x + 4, y + 67, "- Duck fire shots!", C_PINK)

    def _draw_select(self):
        draw_text_center(28, "Choose Hero", C_WHITE)
        chars = [("Knight", C_LGRAY, "HP:98 DMG:18", "SPD:1.60"),
                 ("Mage", C_LBLUE, "HP:58 DMG:13", "SPD:1.45"),
                 ("Bull", C_YELLOW, "HP:125 DMG:13", "SPD:1.48")]
        box_w = 90; gap = 20
        start_x = (W - (3 * box_w + 2 * gap)) // 2
        by = 72
        for i, (name, col, l1, l2) in enumerate(chars):
            bx = start_x + i * (box_w + gap)
            pyxel.rectb(bx, by, box_w, 108, C_WHITE if i == self.sel else C_DGRAY)
            cx = bx + box_w // 2
            frame = (pyxel.frame_count // 8 + i) % 4
            draw_player_sprite(i, cx, by + 42, frame, 1, show_bull_aim=False)
            pyxel.text(bx + 24, by + 66, name, col)
            pyxel.text(bx + 6, by + 82, l1, C_WHITE)
            pyxel.text(bx + 6, by + 92, l2, C_WHITE)
        draw_text_center(206, "A/D to pick, J to confirm", C_WHITE)

    def draw_chest_choice(self):
        if not self.chest_choice_mode:
            return
        draw_text_center(28, "Choose Reward", C_WHITE)
        box_w = 90; gap = 20
        start_x = (W - (len(self.chest_choices) * box_w + (len(self.chest_choices) - 1) * gap)) // 2
        by = 188
        for i, choice in enumerate(self.chest_choices):
            bx = start_x + i * (box_w + gap)
            pyxel.rect(bx, by, box_w, 72, C_BLACK)
            pyxel.rectb(bx, by, box_w, 72, C_WHITE if i == self.chest_selection else C_DGRAY)
            pyxel.text(bx + 14, by + 10, choice["title"], choice["col"])
            pyxel.text(bx + 6, by + 54, choice["desc"], C_LGRAY)
            draw_relic_icon(choice["kind"], bx + box_w // 2 - 5, by + 24)
        # Hint — lock countdown if active
        if self.action_lock_timer > 0:
            pyxel.text(W // 2 - 40, 268, "...take a moment...", C_DGRAY)
        else:
            draw_text_center(268, "A/D to pick, J to confirm", C_WHITE)

    def _draw_tutorial_overlay(self):
        titles = ["KNIGHT", "MAGE", "BULL"]
        # Instruction panel
        pyxel.rect(10, 8, 250, 50, C_BLACK)
        pyxel.rectb(10, 8, 250, 50, C_YELLOW)
        pyxel.rectb(11, 9, 248, 48, C_ORANGE)
        pyxel.text(18, 14, f"TUTORIAL STAGE {self.tutorial_stage + 1}/3: {titles[self.tutorial_stage]}", C_YELLOW)
        if self.tutorial_stage == 0:
            pyxel.text(18, 26, "Move with W/A/S/D", C_WHITE)
            pyxel.text(18, 36, "Press J to swing sword. K to duck.", C_WHITE)
        elif self.tutorial_stage == 1:
            pyxel.text(18, 26, "J fires homing fireballs.", C_WHITE)
            pyxel.text(18, 36, "K ducks shots.  K-K = dodge roll.", C_YELLOW)
        else:
            pyxel.text(18, 26, "J does a spinning charge attack.", C_WHITE)
            pyxel.text(18, 36, "Aim the green arrow first!", C_YELLOW)
        pyxel.text(18, 46, "Press T anytime to skip tutorial", C_LGRAY)

        # CHECKLIST with pulsing key icons for incomplete
        list_y = 64
        pyxel.rect(10, list_y, 160, 54, C_BLACK)
        pyxel.rectb(10, list_y, 160, 54, C_WHITE)
        pyxel.text(16, list_y + 4, "OBJECTIVES", C_YELLOW)
        for i, (label, done) in enumerate(self.tutorial_checklist()):
            ly = list_y + 16 + i * 10
            mark = "[x]" if done else "[ ]"
            col = C_LGREEN if done else C_WHITE
            pyxel.text(16, ly, f"{mark} {label}", col)
            # Pulsing reminder icon for incomplete
            if not done:
                pulse_x = 160
                if (pyxel.frame_count // 15) % 2 == 0:
                    pyxel.tri(pulse_x, ly, pulse_x + 4, ly + 3, pulse_x, ly + 6, C_YELLOW)
                else:
                    pyxel.tri(pulse_x, ly, pulse_x + 4, ly + 3, pulse_x, ly + 6, C_ORANGE)

        # POINTING ARROW toward nearest live enemy
        if not self.tutorial_all_dead():
            alive_enemies = [e for e in self.enemies if e.alive]
            if alive_enemies:
                nearest = min(alive_enemies, key=lambda e: length(e.x - self.player.x, e.y - self.player.y))
                sx, sy = world_to_screen(nearest.x, nearest.y - 18, self.cam_x, self.cam_y)
                if 0 < sx < W and 0 < sy < H:
                    bob = (pyxel.frame_count // 6) % 3
                    col = C_YELLOW if (pyxel.frame_count // 10) % 2 == 0 else C_ORANGE
                    pyxel.tri(sx - 4, sy - bob, sx + 4, sy - bob, sx, sy + 4 - bob, col)
                    pyxel.text(sx - 10, sy - 10 - bob, "ENEMY", col)

        if self.tutorial_complete():
            if self.action_lock_timer > 0:
                draw_text_center(H - 16, "...", C_DGRAY)
            else:
                col = C_YELLOW if (pyxel.frame_count // 15) % 2 == 0 else C_WHITE
                draw_text_center(H - 16, "Press J or Enter to continue", col)
        elif self.tutorial_all_dead():
            remaining = max(0, 150 - self.tutorial_auto_advance)
            secs = remaining // 60 + 1
            flash = C_YELLOW if (pyxel.frame_count // 6) % 2 == 0 else C_ORANGE
            draw_text_center(H - 16, f"Advancing in {secs}...", flash)

    # ==================================================
    # BOSS INTRO — cinematic
    # ==================================================
    def _draw_boss_intro(self):
        pyxel.cls(C_BLACK)
        elapsed = 360 - self.boss_intro_timer
        t = pyxel.frame_count
        pyxel.rect(0, 0, W, 30, C_BLACK)
        pyxel.rect(0, 30, W, 20, C_DARK)
        pyxel.rect(0, 50, W, 60, C_PURPLE)
        pyxel.rect(0, 110, W, 30, C_DARK)
        # Lightning — more & varied
        if elapsed in range(40, 44) or elapsed in range(80, 84) or elapsed in range(140, 144) or elapsed in range(210, 214):
            pyxel.rectb(0, 0, W, H, C_WHITE)
            for bx in range(30, W, 60):
                pyxel.line(bx, 0, bx + random.randint(-20, 20), 100, C_WHITE)
                pyxel.line(bx + 1, 0, bx + 1 + random.randint(-20, 20), 100, C_CYAN)
        # Storm clouds
        for i in range(40):
            cx = (i * 19 + t // 2) % W
            cy = 14 + (i * 11) % 80
            pyxel.pset(cx, cy, C_DGRAY if (i + t // 10) % 4 == 0 else C_DARK)
        # Embers
        for i in range(20):
            ex = (i * 31 + t * 2) % W
            ey = ((i * 47 + t * 3) % (H - 30))
            pyxel.pset(ex, ey, [C_ORANGE, C_RED, C_YELLOW][i % 3])
        # Ground cracks
        pyxel.rect(0, 230, W, H - 230, C_DARK)
        pyxel.rect(0, 229, W, 1, C_RED)
        self._draw_torch(24, 210, t)
        self._draw_torch(W - 24, 210, t)
        # PHASE A: "FINAL BOSS" text
        if elapsed >= 10:
            prog = min(1.0, (elapsed - 10) / 25.0)
            ty_text = int(10 + prog * 50)
            pyxel.text(W // 2 - 21, ty_text + 2, "FINAL BOSS", C_BLACK)
            pyxel.text(W // 2 - 20, ty_text, "FINAL BOSS", C_RED)
            if elapsed > 40 and (elapsed // 4) % 2 == 0:
                pyxel.text(W // 2 - 20, ty_text, "FINAL BOSS", C_ORANGE)
        # PHASE B: wing-unfurl + zoom
        if elapsed >= 60:
            progress = min(1.0, (elapsed - 60) / 140.0)
            cx = W // 2
            cy = 180 - int(progress * 20)
            unfurl = min(1.0, (elapsed - 60) / 40.0)  # wings open during first frames
            silhouette_col = C_DARK if elapsed < 160 else (C_RED if elapsed < 220 else C_ORANGE)
            self._draw_boss_silhouette(cx, cy, 1.3 + progress * 1.4, silhouette_col, unfurl=unfurl)
        # PHASE C: Typewriter title
        if elapsed >= 170:
            full = "ANCIENT DRAGON"
            letters = min(len(full), (elapsed - 170) // 4)
            reveal = full[:letters]
            ttext_col = C_ORANGE if (elapsed // 10) % 2 == 0 else C_RED
            pyxel.text(W // 2 - 29, 91, reveal, C_BLACK)
            pyxel.text(W // 2 - 30, 90, reveal, ttext_col)
            if elapsed > 220:
                pyxel.line(W // 2 - 34, 99, W // 2 + 34, 99, C_YELLOW)
        # PHASE D: Typewriter description
        if elapsed >= 240:
            line1 = "Guardian of the final portal."
            line2 = "Its rage grows with every passing moment..."
            c1 = min(len(line1), (elapsed - 240) // 2)
            c2 = min(len(line2), (elapsed - 270) // 2) if elapsed > 270 else 0
            draw_text_center_shadow(220, line1[:c1], C_LGRAY)
            if c2 > 0:
                draw_text_center_shadow(232, line2[:c2], C_PINK)
        if elapsed >= 300:
            if (t // 15) % 2 == 0:
                draw_text_center_shadow(258, "PRESS J OR ENTER TO BEGIN", C_YELLOW)

    def _draw_boss_silhouette(self, cx, cy, scale, col, unfurl=1.0):
        s = scale
        t = pyxel.frame_count
        wing_flap = [0, 4, 0, -4][(t // 6) % 4] if unfurl >= 1.0 else 0

        def sx(v): return cx + int(v * s)
        def sy(v): return cy + int(v * s)
        pyxel.rect(sx(5), sy(6), int(4 * s), int(7 * s), col)
        pyxel.rect(sx(-9), sy(6), int(4 * s), int(7 * s), col)
        # Wings (unfurl-scaled)
        w_open = unfurl
        for side in (-1, 1):
            wx = sx(4 * side)
            wt_x = sx(int(22 * side * w_open))
            wt_y = sy(int(-18 * w_open)) + wing_flap
            pyxel.tri(wx, sy(-2), wt_x, wt_y, sx(17 * side), sy(5), col)
        pyxel.rect(sx(-10), sy(-7), int(21 * s), int(15 * s), col)
        for off in (-8, -4, 0, 4, 8):
            pyxel.tri(sx(off - 2), sy(-7), sx(off + 2), sy(-7), sx(off), sy(-12), col)
        hx = sx(-14); hy = sy(-3)
        pyxel.rect(hx - int(4 * s), hy - int(6 * s), int(9 * s), int(11 * s), col)
        pyxel.rect(hx - int(9 * s), hy - int(2 * s), int(6 * s), int(6 * s), col)
        for horn_side in (-1, 1):
            pyxel.line(hx + horn_side, hy - int(6 * s), hx + horn_side * 3, hy - int(11 * s), col)
        if s > 1.3:
            pyxel.rect(hx - int(2 * s), hy - int(4 * s), int(3 * s), int(3 * s), C_YELLOW)
            pyxel.pset(hx - int(1 * s), hy - int(3 * s), C_RED)

    # ==================================================
    # PAUSE / END / VICTORY CINEMATIC
    # ==================================================
    def _draw_pause(self):
        px = 100; py = 72; pw = 184; ph = 132
        pyxel.rect(px, py, pw, ph, C_BLACK)
        pyxel.rectb(px, py, pw, ph, C_WHITE)
        draw_text_center(py + 8, "PAUSED", C_YELLOW)
        pyxel.text(px + 12, py + 30, "COLLECTED RELICS:", C_LGRAY)
        if self.player:
            relics = self.player._relic_list()
            if relics:
                ix = px + 12; iy = py + 42
                for kind in relics:
                    draw_relic_icon(kind, ix, iy)
                    ix += 14
                    if ix > px + pw - 18:
                        ix = px + 12; iy += 14
            else:
                pyxel.text(px + 12, py + 44, "None yet", C_DGRAY)
        draw_text_center(py + ph - 40, "P = Resume", C_WHITE)
        draw_text_center(py + ph - 28, "R = Restart Run", C_LGRAY)
        draw_text_center(py + ph - 16, "M = Back to Menu", C_LGRAY)

    def _draw_game_scene(self):
        shake_x = random.randint(-self.shake_strength, self.shake_strength) if self.shake_timer > 0 else 0
        shake_y = random.randint(-self.shake_strength, self.shake_strength) if self.shake_timer > 0 else 0
        draw_cam_x = self.cam_x + shake_x
        draw_cam_y = self.cam_y + shake_y
        start_tx = max(0, draw_cam_x // TILE)
        end_tx = min(MAP_COLS, (draw_cam_x + W) // TILE + 2)
        start_ty = max(0, draw_cam_y // TILE)
        end_ty = min(MAP_ROWS, (draw_cam_y + H) // TILE + 2)
        old_cam_x, old_cam_y = self.cam_x, self.cam_y
        self.cam_x, self.cam_y = draw_cam_x, draw_cam_y
        for ty in range(start_ty, end_ty):
            for tx in range(start_tx, end_tx):
                self._draw_tile(tx, ty, self.tilemap[ty][tx])
        for potion in self.potions:
            potion.draw(self.cam_x, self.cam_y)
        for chest in self.chests:
            chest.draw(self.cam_x, self.cam_y, selected=(self.chest_choice_mode and self.active_chest == chest))
        for e in self.enemies:
            e.draw(self.cam_x, self.cam_y)
        for pr in self.projectiles:
            pr.draw(self.cam_x, self.cam_y)
        for fx in self.effects:
            fx.draw(self.cam_x, self.cam_y)
        for p in self.particles:
            p.draw(self.cam_x, self.cam_y)
        self.player.draw(self.cam_x, self.cam_y)
        self.cam_x, self.cam_y = old_cam_x, old_cam_y
        self._draw_fog()
        self.draw_minimap()

    def _draw_game(self):
        self._draw_game_scene()
        self.player.draw_hud(self.level_index, tutorial=False)
        self.draw_boss_ui()
        self.draw_chest_choice()
        pyxel.text(6, H - 18, f"Monsters: {sum(1 for e in self.enemies if e.alive)}", C_RED)
        pyxel.text(6, H - 8, f"Potions: {len(self.potions)}", C_LGRAY)
        unopened = sum(1 for c in self.chests if not c.opened)
        if unopened > 0:
            pyxel.text(100, H - 8, f"Chests: {unopened}", C_YELLOW)
        if self.state == ST_BOSS:
            pyxel.text(W - 110, H - 8, f"Boss Rage: {1 + self.boss_enrage_timer // 720}", C_RED)
        if self.portal_open:
            # Big glowing "ENTER PORTAL" hint
            hcol = C_YELLOW if (pyxel.frame_count // 6) % 2 == 0 else C_ORANGE
            pyxel.rect(W // 2 - 46, 46, 92, 12, C_BLACK)
            pyxel.rectb(W // 2 - 46, 46, 92, 12, hcol)
            draw_text_center_shadow(48, ">> ENTER PORTAL <<", hcol)
        # Crit HP vignette
        if self.player and self.player.alive and self.player.hp / max(1, self.player.max_hp) < 0.2:
            pulse = abs(math.sin(pyxel.frame_count * 0.22))
            thickness = 2 + int(pulse * 3)
            for i in range(thickness):
                col = C_RED if (i + pyxel.frame_count // 4) % 2 == 0 else C_DARK
                pyxel.rectb(i, i, W - i * 2, H - i * 2, col)
            if (pyxel.frame_count // 20) % 2 == 0:
                draw_text_center_shadow(H - 40, "!! CRITICAL HP !!", C_RED)
        # Stage banner (STAGE 1/2/3 banner on entry)
        if self.stage_banner_timer > 0 and self.stage_banner_text:
            alpha_phase = min(1.0, self.stage_banner_timer / 30.0)
            banner_col = C_YELLOW if (self.stage_banner_timer // 6) % 2 == 0 else C_ORANGE
            by = H // 2 - 20
            pyxel.rect(W // 2 - 100, by, 200, 36, C_BLACK)
            pyxel.rectb(W // 2 - 100, by, 200, 36, banner_col)
            pyxel.rectb(W // 2 - 102, by - 2, 204, 40, C_DARK)
            draw_text_center_shadow(by + 10, self.stage_banner_text, banner_col)
            draw_text_center_shadow(by + 22, "THE ADVENTURE CONTINUES", C_WHITE)
        # Stage cleared banner
        if self.stage_clear_timer > 0 and self.state == ST_PLAY:
            tclr = self.stage_clear_timer
            banner_col = C_YELLOW if (tclr // 4) % 2 == 0 else C_ORANGE
            by = H // 2 - 50
            pyxel.rect(W // 2 - 90, by, 180, 32, C_BLACK)
            pyxel.rectb(W // 2 - 90, by, 180, 32, banner_col)
            draw_text_center_shadow(by + 6, "STAGE CLEARED!", banner_col)
            draw_text_center_shadow(by + 20, "Head to the portal", C_WHITE)
        # BOSS TAUNT speech bubble
        if self.boss_taunt_timer > 0 and self.boss_taunt_text:
            tw = len(self.boss_taunt_text) * 4 + 12
            tx = W // 2 - tw // 2
            ty = 42
            pyxel.rect(tx, ty, tw, 14, C_BLACK)
            pyxel.rectb(tx, ty, tw, 14, C_RED)
            pyxel.rectb(tx - 1, ty - 1, tw + 2, 16, C_ORANGE)
            col = C_ORANGE if (pyxel.frame_count // 6) % 2 == 0 else C_YELLOW
            draw_text_center_shadow(ty + 4, self.boss_taunt_text, col)

    def _draw_tutorial(self):
        self._draw_game_scene()
        self.player.draw_hud(0, tutorial=True)
        self._draw_tutorial_overlay()

    def _draw_victory_cinematic(self):
        """Long cinematic: zoom-out hero, crumbling castle, stats scroll up."""
        t = self.victory_timer
        # Sky gradient
        for row in range(H):
            prog = row / H
            if t < 120:
                col = C_BLACK if prog < 0.4 else (C_DARK if prog < 0.7 else C_PURPLE)
            elif t < 240:
                col = C_DARK if prog < 0.3 else (C_PURPLE if prog < 0.6 else C_ORANGE)
            else:
                col = C_PURPLE if prog < 0.25 else (C_ORANGE if prog < 0.5 else C_YELLOW)
            if row % 2 == 0:
                pyxel.line(0, row, W, row, col)

        # Rising sun behind castle
        sun_y = 200 - min(80, t // 4)
        pyxel.circ(W // 2, sun_y, 30 + min(10, t // 30), C_YELLOW)
        pyxel.circ(W // 2, sun_y, 24, C_ORANGE)
        pyxel.circ(W // 2, sun_y, 14, C_WHITE)

        # Crumbling castle (breaks apart over time)
        cx = W // 2
        cy = 190
        crumble = min(1.0, t / 300.0)
        # Main walls - pieces fall
        for i, (bx, by, bw, bh) in enumerate([(-42, -4, 8, 28), (34, -4, 8, 28),
                                                (-36, 0, 72, 24), (-4, -16, 8, 16)]):
            fall_y = int(crumble * (40 + i * 10)) if t > 60 else 0
            col = C_BLACK
            if t > 120 and (t // 8 + i) % 4 == 0:
                col = C_DARK
            pyxel.rect(cx + bx, cy + by + fall_y, bw, bh, col)
        # Crumbling dust
        if t > 60 and t < 360:
            for i in range(6):
                dx = cx + random.randint(-50, 50)
                dy = cy + random.randint(-10, 20)
                pyxel.pset(dx, dy, C_LGRAY if random.random() < 0.5 else C_DGRAY)

        # HERO stands victorious, zooms OUT (start big, shrink)
        hero_scale_phase = min(1.0, t / 180.0)
        hero_y = 230 + int(hero_scale_phase * 10)
        # Only draw hero at start; fades as camera zooms out
        if t < 300:
            draw_player_sprite(self.player.char if self.player else 0, W // 2, hero_y,
                               (t // 12) % 4, 1, show_bull_aim=False)
            # Glow aura
            if (t // 6) % 2 == 0:
                pyxel.circb(W // 2, hero_y - 4, 14, C_YELLOW)
                pyxel.circb(W // 2, hero_y - 4, 16, C_ORANGE)

        # Falling dragon scales/ash
        for i in range(15):
            ash_x = (i * 37 + t * 2) % W
            ash_y = (i * 29 + t) % H
            pyxel.pset(ash_x, ash_y, C_DARK if i % 2 == 0 else C_DGRAY)

        # TITLE "VICTORY" — appears at t=20
        if t > 20:
            vt_col = C_YELLOW if (t // 8) % 2 == 0 else C_WHITE
            for dx, dy in [(3, 3), (2, 2), (1, 1)]:
                pyxel.text(W // 2 - 22 + dx, 30 + dy, "VICTORY", C_BLACK)
            pyxel.text(W // 2 - 22, 30, "VICTORY", vt_col)
        if t > 60:
            draw_text_center_shadow(50, "The Ancient Dragon falls...", C_PINK)
        if t > 120:
            draw_text_center_shadow(62, "The realm is saved.", C_WHITE)

        # STATS scroll up from bottom starting t=180
        if t > 180:
            scroll_off = max(0, (t - 180)) // 2
            stat_y = H - scroll_off
            if stat_y < H - 10 and stat_y > 80:
                pyxel.rect(W // 2 - 80, stat_y - 2, 160, 70, C_BLACK)
                pyxel.rectb(W // 2 - 80, stat_y - 2, 160, 70, C_YELLOW)
                pyxel.text(W // 2 - 24, stat_y + 4, "FINAL RECORD", C_YELLOW)
                if self.player:
                    draw_text_center(stat_y + 18, f"Hero: {self.player.name}", C_WHITE)
                draw_text_center(stat_y + 28, f"Stages: {self.stat_level_reached}/3", C_CYAN)
                draw_text_center(stat_y + 38, f"Enemies Slain: {self.stat_enemies_killed}", C_RED)
                draw_text_center(stat_y + 48, f"Chests Opened: {self.stat_chests_opened}", C_YELLOW)
                draw_text_center(stat_y + 58, "LEGEND OF THE DUNGEON", C_PINK)

        # PROMPT — only after t=420
        if t > 420:
            prompt_col = C_WHITE if (t // 18) % 2 == 0 else C_CYAN
            pyxel.rect(W // 2 - 100, H - 18, 200, 12, C_BLACK)
            pyxel.rectb(W // 2 - 100, H - 18, 200, 12, prompt_col)
            draw_text_center(H - 14, "Press J or Enter to return", prompt_col)

    def _draw_end_dead(self):
        cause = self.player.last_damage_source if self.player else None
        panel_x = 60; panel_y = 54
        panel_w = W - 120; panel_h = H - 108
        pyxel.rect(panel_x, panel_y, panel_w, panel_h, C_BLACK)
        pyxel.rectb(panel_x, panel_y, panel_w, panel_h, C_RED)
        title_y = panel_y + 14
        pyxel.text(W // 2 - 13, title_y + 1, "YOU DIED", C_BLACK)
        pyxel.text(W // 2 - 14, title_y, "YOU DIED", C_RED)
        pyxel.line(panel_x + 18, title_y + 14, panel_x + panel_w - 18, title_y + 14, C_DGRAY)
        stats_y = title_y + 22
        row = 0
        if self.player:
            draw_text_center(stats_y + row * 12, f"Hero: {self.player.name}", C_LGRAY); row += 1
        if cause:
            draw_text_center(stats_y + row * 12, f"Slain by: {cause}", C_RED); row += 1
        draw_text_center(stats_y + row * 12, f"Stages Reached: {self.stat_level_reached}", C_WHITE); row += 1
        draw_text_center(stats_y + row * 12, f"Enemies Slain: {self.stat_enemies_killed}", C_RED); row += 1
        draw_text_center(stats_y + row * 12, f"Chests Opened: {self.stat_chests_opened}", C_YELLOW)
        prompt_col = C_WHITE if (pyxel.frame_count // 18) % 2 == 0 else C_CYAN
        draw_text_center(panel_y + panel_h - 18, "Press J to return to menu", prompt_col)

    def draw(self):
        pyxel.cls(C_BLACK)
        if self.state == ST_MENU:
            self._draw_menu()
        elif self.state == ST_TUTORIAL:
            self._draw_tutorial()
        elif self.state == ST_SELECT:
            self._draw_select()
        elif self.state == ST_BOSS_INTRO:
            self._draw_boss_intro()
        elif self.state in (ST_PLAY, ST_BOSS):
            self._draw_game()
        elif self.state == ST_PAUSE:
            self._draw_game()
            self._draw_pause()
        elif self.state == ST_WIN:
            self._draw_victory_cinematic()
        elif self.state == ST_DEAD:
            self._draw_end_dead()


Game()