import json
import math
import os
import random
import pyxel

TILE = 16
MAP_W = 14
MAP_H = 10
UI_H = 40
WIDTH = MAP_W * TILE
HEIGHT = MAP_H * TILE + UI_H
FPS = 60
SAVE_FILE = "body_steal_timeattack_pb.json"

FLOOR = 0
WALL = 1
DOOR = 2    # ghost blocked
GATE = 3    # body blocked
EXIT = 4
TELEPORT = 5
POWERUP_SPEED = 6
POWERUP_FREEZE = 7

# Event timing
EVENT_INTERVAL = 240   # frames between toggle events (~4s)
EVENT_WARN      = 60   # frames of warning blink before toggle

LEVELS = [
    {
        "name": "Sprint+",
        "tip": "Two steals. Gate toggles — watch it!",
        "map": [
            "##############",
            "#P.ND..#######",
            "######.#######",
            "##ND...G....X#",
            "##.###########",
            "##############",
        ],
        "toggle_tiles": [(7, 3)],
        "teleports": [],
        "powerups": [(10, 1, POWERUP_SPEED)],
    },
    {
        "name": "Switchback",
        "tip": "Body opens the door. Freeze buys time.",
        "map": [
            "##############",
            "#P.ND.....####",
            "#########.####",
            "#####.DN..####",
            "#####.########",
            "###X..G.......",
        ],
        "toggle_tiles": [],
        "teleports": [(12, 1, 12, 5)],
        "powerups": [(11, 3, POWERUP_FREEZE)],
    },
    {
        "name": "Loop",
        "tip": "Teleporter skips the long way round.",
        "map": [
            "##############",
            "#P.ND.........",
            "############.#",
            "#X...G.......#",
            "#.###########.",
            "#.ND..........",
        ],
        "toggle_tiles": [(5, 3)],
        "teleports": [(13, 1, 13, 4)],
        "powerups": [(8, 5, POWERUP_SPEED)],
    },
    {
        "name": "Dungeon",
        "tip": "Two rooms, one door flickers.",
        "map": [
            "##############",
            "#P.ND.D.......",
            "########.#####",
            "##ND....G...X#",
            "########.#####",
            "##############",
        ],
        "toggle_tiles": [(6, 1), (8, 3)],
        "teleports": [],
        "powerups": [(11, 1, POWERUP_FREEZE), (5, 3, POWERUP_SPEED)],
    },
    {
        "name": "Heist",
        "tip": "Speed boost or you won't make it.",
        "map": [
            "##############",
            "#P.ND.........",
            "############.#",
            "#..ND...G....#",
            "#.############",
            "#.............",
            "#.##########.#",
            "######X.......",
        ],
        "toggle_tiles": [(8, 3)],
        "teleports": [(13, 2, 13, 6)],
        "powerups": [(10, 5, POWERUP_SPEED), (7, 7, POWERUP_FREEZE)],
    },
    {
        "name": "Maze",
        "tip": "Teleporter is the secret shortcut.",
        "map": [
            "##############",
            "#P.ND..#######",
            "#.#####.######",
            "#.#.ND..G...X#",
            "#.#.##########",
            "#.............",
            "#.#.ND.#######",
            "#...###.......",
        ],
        "toggle_tiles": [(8, 3)],
        "teleports": [(1, 7, 7, 5)],
        "powerups": [(11, 1, POWERUP_SPEED), (1, 5, POWERUP_FREEZE)],
    },
    {
        "name": "Finale",
        "tip": "Everything fires. Stay sharp.",
        "map": [
            "##############",
            "#P.ND.........",
            "############.#",
            "#.ND....G....#",
            "#.############",
            "#.....G......#",
            "############.#",
            "######X.ND...#",
        ],
        "toggle_tiles": [(8, 3), (6, 5)],
        "teleports": [(13, 2, 13, 6)],
        "powerups": [(10, 1, POWERUP_SPEED), (10, 5, POWERUP_FREEZE), (9, 7, POWERUP_SPEED)],
    },
]


class NPC:
    def __init__(self, x, y, name):
        self.x = x
        self.y = y
        self.name = name


class Game:
    def __init__(self):
        pyxel.init(WIDTH, HEIGHT, title="Body Steal: Time Attack", fps=FPS)
        self.setup_sounds()
        pyxel.playm(0, loop=True)
        self.load_pbs()
        self.intro = True
        self.intro_page = 0
        self.anim_t = 0
        self.reset_run()
        pyxel.run(self.update, self.draw)

    # ------------------------------------------------------------------ sounds
    def setup_sounds(self):
        # gameplay SFX
        pyxel.sound(0).set("c3", "p", "5", "n", 6)
        pyxel.sound(1).set("c3e3g3", "s", "664", "f", 12)
        pyxel.sound(2).set("g2d2", "t", "53", "n", 10)
        pyxel.sound(3).set("c2", "n", "7", "n", 8)
        pyxel.sound(4).set("c3g3c4e4", "p", "7653", "f", 20)
        pyxel.sound(5).set("e4f4", "p", "76", "n", 8)
        pyxel.sound(6).set("a3b3c4", "t", "765", "n", 10)

        # extra retro SFX
        pyxel.sound(7).set("c4e4g4c4", "p", "7654", "f", 12)
        pyxel.sound(8).set("f3a3c4", "s", "654", "f", 10)
        pyxel.sound(9).set("c2g1", "n", "76", "s", 12)

        # retro background music
        pyxel.sound(10).set(
            "c2c2g2g2a2a2g2r f2f2e2e2d2d2c2r",
            "t",
            "5555555055555550",
            "n",
            12
        )

        pyxel.sound(11).set(
            "c3e3g3c4g3e3c3r d3f3a3d4a3f3d3r",
            "p",
            "6666765066667650",
            "n",
            10
        )

        pyxel.sound(12).set(
            "c4g3e3c3e4c4g3e3 f4d4a3f3g4d4b3g3",
            "s",
            "4321432143214321",
            "f",
            8
        )

        pyxel.sound(13).set(
            "c1r c1r c1c1 r c1",
            "n",
            "70507705",
            "n",
            16
        )

        pyxel.music(0).set([10], [11], [12], [])
    # ------------------------------------------------------------------ pb i/o
    def load_pbs(self):
        self.pb_total_ms = None
        self.pb_splits = [None] * len(LEVELS)
        if not os.path.exists(SAVE_FILE):
            return
        try:
            with open(SAVE_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
            total = data.get("best_total_ms")
            splits = data.get("best_splits_ms", [])
            if isinstance(total, int) and total > 0:
                self.pb_total_ms = total
            if isinstance(splits, list):
                for i in range(min(len(splits), len(LEVELS))):
                    if isinstance(splits[i], int) and splits[i] > 0:
                        self.pb_splits[i] = splits[i]
        except Exception:
            self.pb_total_ms = None
            self.pb_splits = [None] * len(LEVELS)

    def save_pbs(self):
        data = {"best_total_ms": self.pb_total_ms, "best_splits_ms": self.pb_splits}
        try:
            with open(SAVE_FILE, "w", encoding="utf-8") as f:
                json.dump(data, f)
        except Exception:
            pass

    # ------------------------------------------------------------------ reset
    def reset_run(self):
        self.level_index = 0
        self.form = "ghost"
        self.message = "WASD move | SPACE steal/drop | R restart"
        self.level_name = ""
        self.level_tip = ""
        self.anim_t = 0
        self.shake = 0
        self.flash = 0
        self.win = False
        self.started = False
        self.start_frame = pyxel.frame_count
        self.finish_frame = None
        self.level_start_frame = self.start_frame
        self.current_level_ms = 0
        self.total_ms = 0
        self.splits_ms = []
        self.trail = []
        self.stars = [
            [random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1), random.choice([1, 1, 2])]
            for _ in range(72)
        ]
        self.speed_frames = 0
        self.freeze_frames = 0
        self.move_cooldown = 0
        self.load_level(0)

    def start_timer_if_needed(self):
        if not self.started:
            self.started = True
            self.start_frame = pyxel.frame_count
            self.level_start_frame = pyxel.frame_count

    # ------------------------------------------------------------------ level load
    def load_level(self, idx):
        lev = LEVELS[idx]
        raw = lev["map"]
        self.level_name = lev["name"]
        self.level_tip = lev["tip"]
        self.grid = [[WALL for _ in range(MAP_W)] for _ in range(MAP_H)]
        self.npcs = []
        self.form = "ghost"
        self.px = 1
        self.py = 1
        self.trail = []

        for y in range(MAP_H):
            row = raw[y] if y < len(raw) else "#" * MAP_W
            row = row.ljust(MAP_W, "#")[:MAP_W]
            for x, ch in enumerate(row):
                if ch == '#':
                    self.grid[y][x] = WALL
                elif ch == '.':
                    self.grid[y][x] = FLOOR
                elif ch == 'D':
                    self.grid[y][x] = DOOR
                elif ch == 'G':
                    self.grid[y][x] = GATE
                elif ch == 'X':
                    self.grid[y][x] = EXIT
                elif ch == 'P':
                    self.grid[y][x] = FLOOR
                    self.px, self.py = x, y
                elif ch == 'N':
                    self.grid[y][x] = FLOOR
                    self.npcs.append(NPC(x, y, f"N{len(self.npcs)+1}"))
                else:
                    self.grid[y][x] = FLOOR

        self.toggle_tiles = {}
        for (tx, ty) in lev.get("toggle_tiles", []):
            orig = self.grid[ty][tx]
            self.toggle_tiles[(tx, ty)] = {
                "tile": orig,
                "open": False,
                "timer": random.randint(0, EVENT_INTERVAL),
                "warn": False
            }

        self.teleport_pairs = []
        for (x1, y1, x2, y2) in lev.get("teleports", []):
            self.grid[y1][x1] = TELEPORT
            self.grid[y2][x2] = TELEPORT
            self.teleport_pairs.append(((x1, y1), (x2, y2)))

        self.powerups = []
        for (px2, py2, kind) in lev.get("powerups", []):
            if self.grid[py2][px2] == FLOOR:
                self.powerups.append({"x": px2, "y": py2, "kind": kind, "active": True})

        self.speed_frames = 0
        self.freeze_frames = 0
        self.move_cooldown = 0

        self.message = f"L{idx+1}: {self.level_tip}"
        if self.started:
            self.level_start_frame = pyxel.frame_count

    # ------------------------------------------------------------------ helpers
    def tile_at(self, x, y):
        if x < 0 or y < 0 or x >= MAP_W or y >= MAP_H:
            return WALL
        return self.grid[y][x]

    def npc_at(self, x, y):
        for i, npc in enumerate(self.npcs):
            if npc.x == x and npc.y == y:
                return i
        return -1

    def powerup_at(self, x, y):
        for i, p in enumerate(self.powerups):
            if p["active"] and p["x"] == x and p["y"] == y:
                return i
        return -1

    def teleport_partner(self, x, y):
        for (a, b) in self.teleport_pairs:
            if (x, y) == a:
                return b
            if (x, y) == b:
                return a
        return None

    # ------------------------------------------------------------------ movement
    def move(self, dx, dy):
        if self.win:
            return
        if self.move_cooldown > 0:
            return

        self.start_timer_if_needed()
        nx, ny = self.px + dx, self.py + dy
        tile = self.tile_at(nx, ny)

        if tile == WALL:
            self.bump("wall")
            return
        if self.form == "ghost" and tile == DOOR:
            self.bump("need body for orange door")
            return
        if self.form == "body" and tile == GATE:
            self.bump("need ghost for blue gate")
            return

        self.px, self.py = nx, ny
        self.move_cooldown = 1 if self.speed_frames > 0 else 0
        pyxel.play(0, 0)
        self.flash = 2
        self.trail.append([self.px * TILE + TILE // 2, self.py * TILE + TILE // 2, 7])
        if len(self.trail) > 18:
            self.trail.pop(0)

        partner = self.teleport_partner(self.px, self.py)
        if partner:
            self.px, self.py = partner
            self.message = "teleported!"
            self.shake = 4
            pyxel.play(0, 5)

        pi = self.powerup_at(self.px, self.py)
        if pi != -1:
            p = self.powerups[pi]
            p["active"] = False
            if p["kind"] == POWERUP_SPEED:
                self.speed_frames = 180
                self.message = "SPEED BOOST!"
                pyxel.play(0, 5)
            elif p["kind"] == POWERUP_FREEZE:
                self.freeze_frames = 120
                self.message = "TIME FROZEN!"
                pyxel.play(0, 6)
            self.shake = 5
            self.flash = 6

        if tile == EXIT:
            self.next_level()

    def bump(self, text):
        pyxel.play(0, 3)
        self.message = text
        self.shake = 5

    def action(self):
        if self.win:
            self.reset_run()
            return
        self.start_timer_if_needed()
        i = self.npc_at(self.px, self.py)
        if i != -1:
            npc = self.npcs.pop(i)
            self.form = "body"
            self.message = f"stole {npc.name}"
            self.shake = 6
            pyxel.play(0, 1)
            return
        if self.form == "body":
            self.form = "ghost"
            self.message = "dropped body"
            pyxel.play(0, 2)
            return
        self.bump("stand on an npc to steal it")

    # ------------------------------------------------------------------ timing
    def finish_level_time_ms(self):
        if not self.started:
            return 0
        frames = pyxel.frame_count - self.level_start_frame
        return max(0, int(frames * 1000 / FPS))

    def current_total_ms(self):
        if self.finish_frame is not None:
            return self.total_ms
        if not self.started:
            return 0
        frames = pyxel.frame_count - self.start_frame
        return max(0, int(frames * 1000 / FPS))

    def next_level(self):
        pyxel.play(0, 4)
        split_ms = self.finish_level_time_ms()
        self.splits_ms.append(split_ms)
        self.message = f"split {self.fmt_ms(split_ms)}"
        self.shake = 8
        self.flash = 8

        if (
            self.level_index >= len(self.pb_splits)
            or self.pb_splits[self.level_index] is None
            or split_ms < self.pb_splits[self.level_index]
        ):
            self.pb_splits[self.level_index] = split_ms

        self.level_index += 1

        if self.level_index >= len(LEVELS):
            self.win = True

            # calculate total BEFORE setting finish_frame
            frames = pyxel.frame_count - self.start_frame
            self.total_ms = max(0, int(frames * 1000 / FPS))
            self.finish_frame = pyxel.frame_count

            new_pb = self.pb_total_ms is None or self.total_ms < self.pb_total_ms
            if new_pb:
                self.pb_total_ms = self.total_ms
                self.message = "new personal best!"
                pyxel.play(0, 7)
            else:
                self.message = "run complete"

            self.save_pbs()
        else:
            self.load_level(self.level_index)

    # ------------------------------------------------------------------ update
    def update(self):
        self.anim_t += 1

        if self.intro:
            if pyxel.btnp(pyxel.KEY_RIGHT) or pyxel.btnp(pyxel.KEY_D):
                self.intro_page = min(1, self.intro_page + 1)
            if pyxel.btnp(pyxel.KEY_LEFT) or pyxel.btnp(pyxel.KEY_A):
                self.intro_page = max(0, self.intro_page - 1)
            if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
                if self.intro_page == 1:
                    self.intro = False
                else:
                    self.intro_page = 1
            return

        for s in self.stars:
            s[1] += s[2]
            if s[1] >= HEIGHT:
                s[0] = random.randint(0, WIDTH - 1)
                s[1] = 0
                s[2] = random.choice([1, 1, 2])

        for t in self.trail:
            t[2] -= 0.35
        self.trail = [t for t in self.trail if t[2] > 0]

        if self.shake > 0:
            self.shake -= 1
        if self.flash > 0:
            self.flash -= 1

        if self.speed_frames > 0:
            self.speed_frames -= 1
        if self.freeze_frames > 0:
            self.freeze_frames -= 1
        if self.move_cooldown > 0:
            self.move_cooldown -= 1

        for pos, info in self.toggle_tiles.items():
            info["timer"] += 1
            if info["timer"] >= EVENT_INTERVAL:
                info["timer"] = 0
                info["open"] = not info["open"]
                x, y = pos

                if info["open"]:
                    self.grid[y][x] = FLOOR
                    self.message = "door opened!"
                    pyxel.play(1, 8)
                else:
                    self.grid[y][x] = info["tile"]
                    self.message = "door closed!"
                    pyxel.play(1, 9)

                    if self.px == x and self.py == y:
                        self.px = max(0, x - 1)

                self.shake = 3

            info["warn"] = info["timer"] >= EVENT_INTERVAL - EVENT_WARN

        if self.started and not self.win:
            if self.freeze_frames > 0:
                self.level_start_frame += 1
                self.start_frame += 1
            self.current_level_ms = self.finish_level_time_ms()
        elif not self.started:
            self.current_level_ms = 0

        if pyxel.btnp(pyxel.KEY_W) or pyxel.btnp(pyxel.KEY_UP):
            self.move(0, -1)
        elif pyxel.btnp(pyxel.KEY_S) or pyxel.btnp(pyxel.KEY_DOWN):
            self.move(0, 1)
        elif pyxel.btnp(pyxel.KEY_A) or pyxel.btnp(pyxel.KEY_LEFT):
            self.move(-1, 0)
        elif pyxel.btnp(pyxel.KEY_D) or pyxel.btnp(pyxel.KEY_RIGHT):
            self.move(1, 0)

        if pyxel.btnp(pyxel.KEY_SPACE):
            self.action()
        if pyxel.btnp(pyxel.KEY_R):
            self.reset_run()

    # ------------------------------------------------------------------ fmt
    def fmt_ms(self, ms):
        total = max(0, int(ms))
        return f"{total//60000:02d}:{(total//1000)%60:02d}.{total%1000:03d}"

    # ------------------------------------------------------------------ intro
    def draw_intro(self):
        pyxel.cls(0)

        for sx, sy, _ in self.stars:
            pyxel.pset(sx, sy, 7)

        pyxel.rect(8, 8, WIDTH - 16, HEIGHT - 16, 1)
        pyxel.rectb(8, 8, WIDTH - 16, HEIGHT - 16, 11)

        title_col = 10 if (self.anim_t // 10) % 2 == 0 else 11
        pyxel.text(38, 16, "BODY STEAL: TIME ATTACK", title_col)
        pyxel.line(12, 25, WIDTH - 13, 25, 5)

        if self.intro_page == 0:
            pyxel.text(18, 32, "CONTROLS", 7)
            pyxel.text(18, 44, "WASD / Arrows  move", 6)
            pyxel.text(18, 54, "SPACE          steal body / drop body", 6)
            pyxel.text(18, 64, "R              restart full run", 6)
            pyxel.line(12, 76, WIDTH - 13, 76, 5)

            pyxel.text(18, 82, "HOW TO PLAY", 7)
            pyxel.text(18, 94, "You are a GHOST.", 13)
            pyxel.text(18, 104, "Stand on an NPC and press SPACE", 6)
            pyxel.text(18, 114, "to steal their body.", 6)
            pyxel.text(18, 124, "Reach the EXIT tile to finish.", 6)
            pyxel.line(12, 136, WIDTH - 13, 136, 5)

            pyxel.text(18, 142, "GOAL: lowest total time wins.", 10)
            pyxel.text(18, 152, "Splits per level are saved as PB.", 10)

        else:
            pyxel.text(18, 32, "TILES & ITEMS", 7)

            pyxel.circ(22, 50, 4, 13)
            pyxel.rect(18, 51, 9, 4, 13)
            pyxel.text(34, 46, "GHOST  = you (default form)", 13)
            pyxel.text(34, 55, "moves through blue gates", 6)

            pyxel.rect(18, 66, 10, 10, 10)
            pyxel.rectb(18, 66, 10, 10, 9)
            pyxel.text(34, 66, "BODY   = stolen NPC form", 10)
            pyxel.text(34, 75, "passes orange doors", 6)

            pyxel.rect(18, 88, 10, 10, 8)
            pyxel.rectb(19, 88, 8, 10, 10)
            pyxel.text(34, 88, "DOOR   = body only", 8)

            pyxel.rect(18, 102, 10, 10, 12)
            pyxel.text(34, 102, "GATE   = ghost only", 12)

            col_tp = 2 if (self.anim_t // 5) % 2 == 0 else 13
            pyxel.rect(18, 116, 10, 10, col_tp)
            pyxel.text(34, 116, "TP     = teleport to partner", 6)

            pyxel.rect(18, 130, 10, 10, 9)
            pyxel.text(34, 130, "SPD    = 3s speed boost", 9)

            pyxel.rect(18, 144, 10, 10, 12)
            pyxel.text(34, 144, "FRZ    = 2s timer freeze", 12)

            pyxel.rectb(18, 158, 10, 10, 10)
            pyxel.text(34, 158, "blink  = door will toggle soon!", 10)

        dot0 = 10 if self.intro_page == 0 else 5
        dot1 = 10 if self.intro_page == 1 else 5
        pyxel.circ(WIDTH // 2 - 5, HEIGHT - 18, 2, dot0)
        pyxel.circ(WIDTH // 2 + 5, HEIGHT - 18, 2, dot1)

        if self.intro_page == 0:
            nav_col = 11 if (self.anim_t // 15) % 2 == 0 else 7
            pyxel.text(28, HEIGHT - 28, "SPACE / D  next page", nav_col)
        else:
            nav_col = 10 if (self.anim_t // 15) % 2 == 0 else 7
            pyxel.text(24, HEIGHT - 28, "A  back     SPACE  start run!", nav_col)

    # ------------------------------------------------------------------ draw helpers
    def draw_tile(self, x, y, tile):
        sx, sy = x * TILE, y * TILE

        warn = False
        if (x, y) in self.toggle_tiles:
            warn = self.toggle_tiles[(x, y)]["warn"]
            open_ = self.toggle_tiles[(x, y)]["open"]
            if open_:
                tile = FLOOR

        if tile == WALL:
            pyxel.rect(sx, sy, TILE, TILE, 1)
            pyxel.rectb(sx, sy, TILE, TILE, 5)
            pyxel.line(sx + 2, sy + 3, sx + 13, sy + 3, 13)
        elif tile == FLOOR:
            pyxel.rect(sx, sy, TILE, TILE, 0)
            if warn:
                if (self.anim_t // 4) % 2 == 0:
                    pyxel.rectb(sx + 1, sy + 1, TILE - 2, TILE - 2, 10)
            pyxel.pset(sx + 3, sy + 4, 1)
            pyxel.pset(sx + 11, sy + 9, 1)
            pyxel.pset(sx + 8, sy + 12, 1)
        elif tile == DOOR:
            col = 9 if warn and (self.anim_t // 4) % 2 == 0 else 8
            pyxel.rect(sx, sy, TILE, TILE, col)
            pyxel.rectb(sx + 2, sy + 1, TILE - 4, TILE - 2, 10)
            pyxel.pset(sx + 11, sy + 8, 7)
        elif tile == GATE:
            col = 14 if warn and (self.anim_t // 4) % 2 == 0 else 12
            pyxel.rect(sx, sy, TILE, TILE, col)
            for i in range(3, TILE, 4):
                pyxel.line(sx + i, sy + 1, sx + i, sy + TILE - 2, 6)
        elif tile == EXIT:
            blink = 11 if (self.anim_t // 6) % 2 == 0 else 10
            pyxel.rect(sx, sy, TILE, TILE, 3)
            pyxel.rect(sx + 4, sy + 4, TILE - 8, TILE - 8, blink)
            pyxel.text(sx + 4, sy + 5, "EXIT", 0)
        elif tile == TELEPORT:
            col = 2 if (self.anim_t // 5) % 2 == 0 else 13
            pyxel.rect(sx, sy, TILE, TILE, col)
            pyxel.rectb(sx + 1, sy + 1, TILE - 2, TILE - 2, 13)
            pyxel.text(sx + 5, sy + 5, "TP", 7)

    def draw_powerup(self, p):
        if not p["active"]:
            return

        sx, sy = p["x"] * TILE, p["y"] * TILE
        blink = (self.anim_t // 8) % 2 == 0

        if p["kind"] == POWERUP_SPEED:
            col = 10 if blink else 9
            pyxel.rect(sx + 3, sy + 3, TILE - 6, TILE - 6, col)
            pyxel.text(sx + 5, sy + 5, "SPD", 7)
        elif p["kind"] == POWERUP_FREEZE:
            col = 12 if blink else 6
            pyxel.rect(sx + 3, sy + 3, TILE - 6, TILE - 6, col)
            pyxel.text(sx + 5, sy + 5, "FRZ", 7)

    def draw_npc(self, npc, highlighted):
        sx, sy = npc.x * TILE, npc.y * TILE
        body_col = 8 if highlighted else 15
        outline = 10 if highlighted else 13

        pyxel.rect(sx + 2, sy + 3, 12, 11, body_col)
        pyxel.rectb(sx + 2, sy + 3, 12, 11, outline)
        pyxel.rect(sx + 4, sy + 5, 2, 2, 0)
        pyxel.rect(sx + 10, sy + 5, 2, 2, 0)
        pyxel.line(sx + 5, sy + 10, sx + 11, sy + 10, 8)

        if highlighted:
            pyxel.circb(sx + 8, sy + 8, 7, 10)

    def draw_player(self, ox, oy):
        sx, sy = self.px * TILE + ox, self.py * TILE + oy
        bob = int(math.sin(self.anim_t * 0.2) * 2)

        if self.speed_frames > 0 and (self.anim_t // 3) % 2 == 0:
            pyxel.circb(sx + 8, sy + 8, 8, 10)

        if self.freeze_frames > 0 and (self.anim_t // 3) % 2 == 0:
            pyxel.circb(sx + 8, sy + 8, 9, 12)

        if self.form == "ghost":
            pyxel.circ(sx + 8, sy + 7 + bob, 5, 13)
            pyxel.rect(sx + 3, sy + 8 + bob, 10, 5, 13)
            pyxel.pset(sx + 6, sy + 6 + bob, 7)
            pyxel.pset(sx + 10, sy + 6 + bob, 7)
            pyxel.line(sx + 5, sy + 13 + bob, sx + 11, sy + 13 + bob, 7)
        else:
            pyxel.rect(sx + 2, sy + 2, 12, 12, 10)
            pyxel.rectb(sx + 2, sy + 2, 12, 12, 9)
            pyxel.rect(sx + 4, sy + 5, 2, 2, 0)
            pyxel.rect(sx + 10, sy + 5, 2, 2, 0)
            pyxel.line(sx + 5, sy + 10, sx + 11, sy + 10, 0)

    def draw_ui(self):
        y = MAP_H * TILE
        bg = 1 if self.flash == 0 else 2
        pyxel.rect(0, y, WIDTH, UI_H, bg)

        form_text = "GHOST" if self.form == "ghost" else "BODY"
        form_col = 13 if self.form == "ghost" else 10
        pyxel.rect(4, y + 4, 38, 10, form_col)
        pyxel.text(9, y + 6, form_text, 0)

        bar_x = 4
        if self.speed_frames > 0:
            bw = int(38 * self.speed_frames / 180)
            pyxel.rect(bar_x, y + 16, bw, 4, 10)
            pyxel.text(bar_x, y + 22, "SPD", 10)

        if self.freeze_frames > 0:
            bw = int(38 * self.freeze_frames / 120)
            pyxel.rect(bar_x, y + 27, bw, 4, 12)
            pyxel.text(bar_x, y + 33, "FRZ", 12)

        total_ms = self.current_total_ms()
        freeze_ind = " [FRZ]" if self.freeze_frames > 0 else ""
        pb_text = self.fmt_ms(self.pb_total_ms) if self.pb_total_ms is not None else "--:--.---"

        pyxel.text(48, y + 5, f"TOTAL {self.fmt_ms(total_ms)}{freeze_ind}", 11)
        pyxel.text(48, y + 14, f"L{self.level_index+1}/{len(LEVELS)} {self.level_name[:12]}", 7)
        pyxel.text(48, y + 23, self.message[:30], 6)
        pyxel.text(156, y + 5, f"PB {pb_text}", 10)
        pyxel.text(156, y + 14, f"SPLIT {self.fmt_ms(self.current_level_ms)}", 7)
        pyxel.text(156, y + 23, "LOWER = BETTER", 9)

    def draw_endscreen(self):
        pyxel.rect(12, 18, WIDTH - 24, HEIGHT - 36, 1)
        pyxel.rectb(12, 18, WIDTH - 24, HEIGHT - 36, 11)

        title_col = 10 if self.pb_total_ms == self.total_ms else 7
        pyxel.text(86, 26, "RUN CLEAR", title_col)

        pyxel.text(34, 40, f"TOTAL  {self.fmt_ms(self.total_ms)}", 7)

        pb_text = self.fmt_ms(self.pb_total_ms) if self.pb_total_ms is not None else "--:--.---"
        pyxel.text(34, 50, f"BEST   {pb_text}", 10)

        y = 66
        for i in range(len(LEVELS)):
            split = self.splits_ms[i] if i < len(self.splits_ms) else None
            best = self.pb_splits[i]

            split_text = self.fmt_ms(split) if split is not None else "--:--.---"
            best_text = self.fmt_ms(best) if best is not None else "--:--.---"
            col = 10 if split is not None and best is not None and split == best else 7

            pyxel.text(
                24,
                y + i * 10,
                f"{i+1}. {LEVELS[i]['name'][:10]:10} {split_text}  PB {best_text}",
                col
            )

        pyxel.text(32, HEIGHT - 26, "SPACE restart run   R reset now", 11)

    # ------------------------------------------------------------------ draw
    def draw(self):
        pyxel.cls(0)

        if self.intro:
            self.draw_intro()
            return

        for sx, sy, _ in self.stars:
            pyxel.pset(sx, sy, 7)

        ox = random.randint(-1, 1) if self.shake > 0 else 0
        oy = random.randint(-1, 1) if self.shake > 0 else 0

        for y in range(MAP_H):
            for x in range(MAP_W):
                self.draw_tile(x, y, self.grid[y][x])

        for p in self.powerups:
            self.draw_powerup(p)

        for tx, ty, r in self.trail:
            pyxel.circb(tx + ox, ty + oy, int(r), 5)

        here = self.npc_at(self.px, self.py)
        for idx, npc in enumerate(self.npcs):
            self.draw_npc(npc, idx == here)

        self.draw_player(ox, oy)
        pyxel.text(4, 2, self.level_tip[:40], 13)
        self.draw_ui()

        if self.win:
            self.draw_endscreen()


Game()