import math
import random
from dataclasses import dataclass, field
from typing import List, Optional

import pyxel

# =========================================================
# CONFIG
# =========================================================

SCREEN_W = 512
SCREEN_H = 512

GROUND_Y = 390
CEILING_Y = 60

PLAYER_SIZE = 14
PLAYER_X = 80

GRAVITY = 0.38
JUMP_POWER = -6.0
BALL_GRAVITY = 0.44
BALL_JUMP = -5.2
WAVE_SPEED = 3.8

STATE_MENU = "menu"
STATE_PLAY = "play"
STATE_DEAD = "dead"
STATE_WIN = "win"

MODE_CUBE = "cube"
MODE_WAVE = "wave"
MODE_BALL = "ball"

# Pyxel default 16-color palette indices
# 0=black 1=navy 2=purple 3=dark-green 4=brown 5=dark-gray
# 6=light-gray 7=white 8=red 9=orange 10=yellow 11=green
# 12=cyan 13=indigo 14=pink 15=peach


# =========================================================
# HELPERS
# =========================================================

def clamp(value, low, high):
    return max(low, min(high, value))


def rects_overlap(ax, ay, aw, ah, bx, by, bw, bh):
    return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by


def rnd(a, b):
    return a + random.random() * (b - a)


# =========================================================
# CLASS: Particle
# =========================================================

class Particle:
    def __init__(self, x, y, col, vx=None, vy=None, life=30):
        self.x = x
        self.y = y
        self.col = col
        self.vx = vx if vx is not None else rnd(-2.5, 2.5)
        self.vy = vy if vy is not None else rnd(-3.5, 0)
        self.life = life
        self.max_life = life

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

    def draw(self):
        if self.life <= 0:
            return
        pyxel.pset(int(self.x), int(self.y), self.col)
        if self.life > self.max_life * 0.5:
            pyxel.pset(int(self.x) + 1, int(self.y), self.col)
            pyxel.pset(int(self.x), int(self.y) + 1, self.col)

    @property
    def dead(self):
        return self.life <= 0


# =========================================================
# CLASS: Hazard
# =========================================================

class Hazard:
    def __init__(self, x, y, w, h, kind="block"):
        self.x = float(x)
        self.y = float(y)
        self.orig_y = float(y)
        self.w = w
        self.h = h
        self.kind = kind

        # Dynamic hazard fields
        self.dynamic = False
        self.dyn_trigger_x = 0.0   # scroll_x threshold that activates movement
        self.target_y = float(y)
        self.move_speed = 2.5
        self.moving = False
        self.warned = False
        self.warn_anim = 0

    def set_dynamic(self, trigger_x: float, target_y: float, speed: float = 2.5):
        """Turn this hazard into a last-second dynamic obstacle."""
        self.dynamic = True
        self.dyn_trigger_x = trigger_x
        self.target_y = float(target_y)
        self.move_speed = speed

    def update(self, scroll_x: float):
        if not self.dynamic:
            return
        self.warn_anim = (self.warn_anim + 1) % 20
        if not self.moving and scroll_x >= self.dyn_trigger_x:
            self.moving = True
        if self.moving:
            dy = self.target_y - self.y
            if abs(dy) > 1.0:
                self.y += math.copysign(self.move_speed, dy)
            else:
                self.y = self.target_y

    def draw(self, scroll_x: float):
        sx = int(self.x - scroll_x)
        sy = int(self.y)
        w, h = self.w, self.h

        if sx + w < -4 or sx > SCREEN_W + 4:
            return

        # Warning flash for dynamic hazards not yet triggered
        if self.dynamic and not self.moving:
            if self.warn_anim < 10:
                pyxel.rectb(sx - 2, sy - 2, w + 4, h + 4, 8)

        if self.kind == "block":
            pyxel.rect(sx, sy, w, h, 1)
            pyxel.rect(sx + 2, sy + 2, max(1, w - 4), max(1, h - 4), 13)
            pyxel.rect(sx + 4, sy + 4, max(1, w - 8), max(1, h - 8), 5)
            pyxel.rectb(sx, sy, w, h, 6)

        elif self.kind == "spike_up":
            pyxel.tri(sx, sy + h,
                      sx + w // 2, sy,
                      sx + w, sy + h, 8)
            pyxel.tri(sx + 2, sy + h,
                      sx + w // 2, sy + 3,
                      sx + w - 2, sy + h, 9)
            pyxel.line(sx + 3, sy + h - 2, sx + w // 2, sy + 4, 7)

        elif self.kind == "spike_down":
            pyxel.tri(sx, sy,
                      sx + w // 2, sy + h,
                      sx + w, sy, 8)
            pyxel.tri(sx + 2, sy,
                      sx + w // 2, sy + h - 3,
                      sx + w - 2, sy, 9)
            pyxel.line(sx + 3, sy + 2, sx + w // 2, sy + h - 4, 7)

        elif self.kind == "spike_left":
            pyxel.tri(sx + w, sy,
                      sx, sy + h // 2,
                      sx + w, sy + h, 8)
            pyxel.tri(sx + w - 1, sy + 2,
                      sx + 3, sy + h // 2,
                      sx + w - 1, sy + h - 2, 9)

        elif self.kind == "spike_right":
            pyxel.tri(sx, sy,
                      sx + w, sy + h // 2,
                      sx, sy + h, 8)
            pyxel.tri(sx + 1, sy + 2,
                      sx + w - 3, sy + h // 2,
                      sx + 1, sy + h - 2, 9)


# =========================================================
# CLASS: Orb
# =========================================================

class Orb:
    def __init__(self, x, y, orb_type):
        self.x = float(x)
        self.y = float(y)
        self.orb_type = orb_type
        self.used = False
        self.anim = 0

    def update(self):
        self.anim = (self.anim + 1) % 60

    def draw(self, scroll_x: float):
        if self.used:
            return
        sx = int(self.x - scroll_x)
        sy = int(self.y)
        if sx < -14 or sx > SCREEN_W + 14:
            return

        pulse = 1 if self.anim < 30 else 0

        if self.orb_type == "jump":
            main_col, glow_col = 10, 9
        elif self.orb_type == "gravity":
            main_col, glow_col = 14, 2
        else:
            main_col, glow_col = 12, 1

        pyxel.circ(sx, sy, 6 + pulse, glow_col)
        pyxel.circ(sx, sy, 5, main_col)
        pyxel.circb(sx, sy, 5, 7)

        if self.orb_type == "jump":
            pyxel.line(sx, sy + 2, sx, sy - 3, 7)
            pyxel.line(sx - 2, sy - 1, sx, sy - 3, 7)
            pyxel.line(sx + 2, sy - 1, sx, sy - 3, 7)
        elif self.orb_type == "gravity":
            pyxel.line(sx - 3, sy - 2, sx + 3, sy - 2, 7)
            pyxel.line(sx - 3, sy + 2, sx + 3, sy + 2, 7)
        elif self.orb_type == "dash":
            pyxel.line(sx - 3, sy, sx + 3, sy, 7)
            pyxel.line(sx + 3, sy, sx + 1, sy - 2, 7)
            pyxel.line(sx + 3, sy, sx + 1, sy + 2, 7)


# =========================================================
# CLASS: Portal
# =========================================================

class Portal:
    def __init__(self, x, y, w, h, target_mode):
        self.x = float(x)
        self.y = float(y)
        self.w = w
        self.h = h
        self.target_mode = target_mode
        self.activated = False
        self.anim = 0

    def update(self):
        self.anim = (self.anim + 1) % 40

    def draw(self, scroll_x: float):
        sx = int(self.x - scroll_x)
        sy = int(self.y)
        if sx + self.w < -10 or sx > SCREEN_W + 10:
            return

        pulse = 1 if self.anim < 20 else 0

        if self.target_mode == MODE_WAVE:
            col_main, col_glow = 14, 2
        elif self.target_mode == MODE_BALL:
            col_main, col_glow = 12, 1
        else:
            col_main, col_glow = 9, 8

        pyxel.circb(sx + self.w // 2, sy + self.h // 2, 10 + pulse, col_glow)
        pyxel.rect(sx, sy, self.w, self.h, col_main)
        pyxel.rectb(sx, sy, self.w, self.h, 7)

        # Mode letter
        cx = sx + self.w // 2 - 2
        cy = sy + self.h // 2 - 3
        label = self.target_mode[0].upper()
        pyxel.text(cx, cy, label, 7)


# =========================================================
# CLASS: Player
# =========================================================

class Player:
    def __init__(self):
        self.reset()

    def reset(self):
        self.x = float(PLAYER_X)
        self.y = float(GROUND_Y - PLAYER_SIZE)
        self.vy = 0.0
        self.gravity_dir = 1
        self.on_surface = True
        self.alive = True
        self.rotation_frame = 0
        self.dash_timer = 0
        self.mode = MODE_CUBE
        self.trail: list = []
        self.ball_angle = 0.0

    def jump(self):
        if not self.alive:
            return
        if self.mode == MODE_CUBE and self.on_surface:
            self.vy = JUMP_POWER * self.gravity_dir
            self.on_surface = False
            pyxel.play(0, 0)
        elif self.mode == MODE_BALL:
            self.vy = BALL_JUMP * self.gravity_dir
            self.on_surface = False
            pyxel.play(0, 0)

    def flip_gravity(self):
        if not self.alive or self.mode == MODE_WAVE:
            return
        self.gravity_dir *= -1
        self.vy = 0.0
        self.on_surface = False
        pyxel.play(0, 0)

    def dash(self):
        if self.alive:
            self.dash_timer = 10

    def set_mode(self, mode: str):
        self.mode = mode
        self.vy = 0.0
        if mode != MODE_WAVE:
            self.y = clamp(self.y, CEILING_Y, GROUND_Y - PLAYER_SIZE)
        else:
            self.on_surface = False
        pyxel.play(1, 1)

    def update(self, held: bool, pressed: bool):
        if not self.alive:
            return

        if self.dash_timer > 0:
            self.dash_timer -= 1

        if self.mode == MODE_CUBE:
            self.vy += GRAVITY * self.gravity_dir
            self.y += self.vy

            if self.gravity_dir == 1:
                if self.y + PLAYER_SIZE >= GROUND_Y:
                    self.y = GROUND_Y - PLAYER_SIZE
                    self.vy = 0.0
                    self.on_surface = True
                else:
                    self.on_surface = False
                if self.y < CEILING_Y:
                    self.y = float(CEILING_Y)
                    self.vy = 0.0
            else:
                if self.y <= CEILING_Y:
                    self.y = float(CEILING_Y)
                    self.vy = 0.0
                    self.on_surface = True
                else:
                    self.on_surface = False
                if self.y + PLAYER_SIZE > GROUND_Y:
                    self.y = GROUND_Y - PLAYER_SIZE
                    self.vy = 0.0

        elif self.mode == MODE_BALL:
            self.vy += BALL_GRAVITY * self.gravity_dir
            self.vy = clamp(self.vy, -8.0, 8.0)
            self.y += self.vy
            self.ball_angle += 9.0 * self.gravity_dir

            if self.gravity_dir == 1:
                if self.y + PLAYER_SIZE >= GROUND_Y:
                    self.y = GROUND_Y - PLAYER_SIZE
                    self.vy = 0.0
                    self.on_surface = True
                else:
                    self.on_surface = False
                if self.y < CEILING_Y:
                    self.y = float(CEILING_Y)
                    self.vy = abs(self.vy) * 0.55
            else:
                if self.y <= CEILING_Y:
                    self.y = float(CEILING_Y)
                    self.vy = 0.0
                    self.on_surface = True
                else:
                    self.on_surface = False
                if self.y + PLAYER_SIZE > GROUND_Y:
                    self.y = GROUND_Y - PLAYER_SIZE
                    self.vy = -abs(self.vy) * 0.55

        elif self.mode == MODE_WAVE:
            if held:
                self.y -= WAVE_SPEED
            else:
                self.y += WAVE_SPEED
            self.y = clamp(self.y, CEILING_Y, GROUND_Y - PLAYER_SIZE)

        self.rotation_frame = (self.rotation_frame + 1) % 40
        cx = int(self.x + PLAYER_SIZE // 2)
        cy = int(self.y + PLAYER_SIZE // 2)
        self.trail.append((cx, cy))
        if len(self.trail) > 16:
            self.trail.pop(0)

    def get_hitbox(self):
        if self.mode == MODE_CUBE:
            return self.x + 2, self.y + 2, PLAYER_SIZE - 4, PLAYER_SIZE - 4
        elif self.mode == MODE_BALL:
            return self.x + 3, self.y + 3, PLAYER_SIZE - 6, PLAYER_SIZE - 6
        else:
            return self.x + 3, self.y + 3, PLAYER_SIZE - 6, PLAYER_SIZE - 6

    def draw(self):
        # Trail
        for i, (tx, ty) in enumerate(self.trail):
            if i < len(self.trail) // 2:
                col = 1
            else:
                col = (9 if self.mode == MODE_BALL
                       else 12 if self.mode == MODE_WAVE else 13)
            pyxel.pset(tx, ty, col)
            if i > len(self.trail) * 0.6:
                pyxel.pset(tx + 1, ty, col)

        x = int(self.x)
        y = int(self.y)

        if self.mode == MODE_CUBE:
            body = 9 if self.gravity_dir == 1 else 12
            shade = 10 if self.gravity_dir == 1 else 11

            pyxel.rect(x, y, PLAYER_SIZE, PLAYER_SIZE, body)
            pyxel.rect(x + 2, y + 2, PLAYER_SIZE - 4, PLAYER_SIZE - 4, shade)
            pyxel.rectb(x, y, PLAYER_SIZE, PLAYER_SIZE, 7)

            # Spinning detail lines
            phase = (self.rotation_frame // 5) % 4
            if phase == 0:
                pyxel.line(x + 3, y + 3, x + PLAYER_SIZE - 3, y + PLAYER_SIZE - 3, 0)
            elif phase == 1:
                pyxel.line(x + PLAYER_SIZE - 3, y + 3, x + 3, y + PLAYER_SIZE - 3, 0)
            elif phase == 2:
                pyxel.line(x + 3, y + PLAYER_SIZE // 2, x + PLAYER_SIZE - 3, y + PLAYER_SIZE // 2, 0)
            else:
                pyxel.line(x + PLAYER_SIZE // 2, y + 3, x + PLAYER_SIZE // 2, y + PLAYER_SIZE - 3, 0)

            # Eyes
            pyxel.pset(x + 4, y + 4, 0)
            pyxel.pset(x + PLAYER_SIZE - 4, y + 4, 0)

        elif self.mode == MODE_BALL:
            cx = x + PLAYER_SIZE // 2
            cy = y + PLAYER_SIZE // 2
            r = PLAYER_SIZE // 2

            body = 12 if self.gravity_dir == 1 else 9
            shade = 11 if self.gravity_dir == 1 else 14

            pyxel.circ(cx, cy, r, body)
            pyxel.circ(cx, cy, r - 3, shade)
            pyxel.circb(cx, cy, r, 7)

            # Rotating line
            ang = math.radians(self.ball_angle)
            lx = int(cx + math.cos(ang) * (r - 2))
            ly = int(cy + math.sin(ang) * (r - 2))
            pyxel.line(cx, cy, lx, ly, 0)
            pyxel.pset(cx, cy, 0)

        else:  # WAVE
            pyxel.tri(x, y + PLAYER_SIZE,
                      x + PLAYER_SIZE // 2, y,
                      x + PLAYER_SIZE, y + PLAYER_SIZE, 14)
            pyxel.tri(x + 2, y + PLAYER_SIZE - 2,
                      x + PLAYER_SIZE // 2, y + 3,
                      x + PLAYER_SIZE - 2, y + PLAYER_SIZE - 2, 2)
            pyxel.line(x + 3, y + PLAYER_SIZE - 3, x + PLAYER_SIZE // 2, y + 4, 7)
            pyxel.line(x + PLAYER_SIZE - 3, y + PLAYER_SIZE - 3, x + PLAYER_SIZE // 2, y + 4, 7)


# =========================================================
# CLASS: Level
# =========================================================

@dataclass
class LevelConfig:
    name: str
    speed: float
    length: int
    difficulty: str
    bg_color: int
    hazard_defs: list
    orb_defs: list
    portal_defs: list


class Level:
    def __init__(self, cfg: LevelConfig):
        self.name = cfg.name
        self.speed = cfg.speed
        self.length = cfg.length
        self.difficulty = cfg.difficulty
        self.bg_color = cfg.bg_color
        self.hazards: List[Hazard] = []
        self.orbs: List[Orb] = []
        self.portals: List[Portal] = []
        self._cfg = cfg
        self._build()

    def _build(self):
        self.hazards = []
        for d in self._cfg.hazard_defs:
            h = Hazard(d["x"], d["y"], d["w"], d["h"], d.get("kind", "block"))
            if d.get("dynamic"):
                h.set_dynamic(
                    d["trigger_x"],
                    d["target_y"],
                    d.get("speed", 2.5)
                )
            self.hazards.append(h)

        self.orbs = [
            Orb(o["x"], o["y"], o["type"])
            for o in self._cfg.orb_defs
        ]
        self.portals = [
            Portal(p["x"], p["y"], p.get("w", 14), p.get("h", 28), p["mode"])
            for p in self._cfg.portal_defs
        ]

    def clone(self) -> "Level":
        return Level(self._cfg)


# =========================================================
# LEVEL DEFINITIONS
# =========================================================

def make_levels() -> List[Level]:
    levels = []
    GY, CY = GROUND_Y, CEILING_Y

    # ----------------------------------------------------------
    # Level 1 – Starter (Easy, Cube only)
    # Dynamic: a wall drops from ceiling and a spike rises
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Starter", speed=2.2, length=1100,
        difficulty="Easy", bg_color=0,
        hazard_defs=[
            {"x": 150, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 164, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 260, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            # Dynamic wall drops from ceiling when scroll_x hits 280
            {"x": 360, "y": CY - 38, "w": 14, "h": 38, "kind": "block",
             "dynamic": True, "trigger_x": 280, "target_y": CY, "speed": 3.0},
            {"x": 440, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 454, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 560, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 660, "y": CY,       "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 674, "y": CY,       "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 780, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 792, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            # Dynamic: floor spike rises!
            {"x": 900, "y": GY + 20, "w": 12, "h": 20, "kind": "spike_up",
             "dynamic": True, "trigger_x": 820, "target_y": GY - 12, "speed": 2.2},
            {"x": 920, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
        ],
        orb_defs=[
            {"x": 310, "y": 220, "type": "jump"},
            {"x": 600, "y": 190, "type": "gravity"},
            {"x": 840, "y": 170, "type": "gravity"},
        ],
        portal_defs=[],
    )))

    # ----------------------------------------------------------
    # Level 2 – Wave Mix (Normal, Cube + Wave)
    # Dynamic: wave tunnel wall narrows at last second
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Wave Mix", speed=2.4, length=1400,
        difficulty="Normal", bg_color=1,
        hazard_defs=[
            {"x": 120, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 134, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 146, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 240, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 310, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 400, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 414, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            # Static wave tunnel walls
            {"x": 680, "y": GY - 24, "w": 26, "h": 24, "kind": "block"},
            {"x": 680, "y": CY,       "w": 26, "h": 24, "kind": "block"},
            {"x": 770, "y": GY - 26, "w": 28, "h": 26, "kind": "block"},
            {"x": 770, "y": CY,       "w": 28, "h": 26, "kind": "block"},
            # Dynamic: bottom block grows upward, narrowing the gap!
            {"x": 860, "y": GY - 18, "w": 24, "h": 18, "kind": "block",
             "dynamic": True, "trigger_x": 750, "target_y": GY - 30, "speed": 1.5},
            {"x": 860, "y": CY, "w": 24, "h": 24, "kind": "block"},
            {"x": 950, "y": GY - 28, "w": 26, "h": 28, "kind": "block"},
            {"x": 950, "y": CY,       "w": 26, "h": 28, "kind": "block"},
            {"x": 1150, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1164, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1176, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
        ],
        orb_defs=[
            {"x": 350, "y": 225, "type": "jump"},
            {"x": 520, "y": 185, "type": "gravity"},
            {"x": 580, "y": 165, "type": "gravity"},
            {"x": 1090, "y": 215, "type": "jump"},
        ],
        portal_defs=[
            {"x": 628, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_WAVE},
            {"x": 1060, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_CUBE},
        ],
    )))

    # ----------------------------------------------------------
    # Level 3 – Ball Bounce (Normal, Cube + Ball)
    # Dynamic: spikes shoot up from floor right as you arrive
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Ball Bounce", speed=2.3, length=1300,
        difficulty="Normal", bg_color=2,
        hazard_defs=[
            {"x": 120, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 134, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 220, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            # Ball section obstacles
            {"x": 500, "y": CY, "w": 12, "h": 16, "kind": "spike_down"},
            {"x": 514, "y": CY, "w": 12, "h": 16, "kind": "spike_down"},
            {"x": 590, "y": GY - 16, "w": 12, "h": 16, "kind": "spike_up"},
            {"x": 604, "y": GY - 16, "w": 12, "h": 16, "kind": "spike_up"},
            {"x": 670, "y": CY, "w": 12, "h": 18, "kind": "spike_down"},
            {"x": 684, "y": CY, "w": 12, "h": 18, "kind": "spike_down"},
            # Dynamic: two spikes shoot up from the floor!
            {"x": 750, "y": GY + 2, "w": 12, "h": 2, "kind": "spike_up",
             "dynamic": True, "trigger_x": 650, "target_y": GY - 20, "speed": 2.0},
            {"x": 764, "y": GY + 2, "w": 12, "h": 2, "kind": "spike_up",
             "dynamic": True, "trigger_x": 668, "target_y": GY - 20, "speed": 2.0},
            {"x": 840, "y": CY, "w": 12, "h": 14, "kind": "spike_down"},
            # Back to cube
            {"x": 1050, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1064, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1140, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
        ],
        orb_defs=[
            {"x": 290, "y": 225, "type": "jump"},
            {"x": 920, "y": 185, "type": "jump"},
            {"x": 980, "y": 225, "type": "jump"},
        ],
        portal_defs=[
            {"x": 400, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_BALL},
            {"x": 990, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_CUBE},
        ],
    )))

    # ----------------------------------------------------------
    # Level 4 – Gravity Run (Hard, Cube + Wave)
    # Dynamic: ceiling block drops to close a gap
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Gravity Run", speed=2.5, length=1280,
        difficulty="Hard", bg_color=0,
        hazard_defs=[
            {"x": 100, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 114, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 200, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 214, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 300, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 332, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 420, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 434, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 448, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            # Wave section
            {"x": 540, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 540, "y": CY, "w": 22, "h": 22, "kind": "block"},
            {"x": 630, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 630, "y": CY, "w": 22, "h": 22, "kind": "block"},
            # Dynamic: block drops from ceiling to close gap!
            {"x": 720, "y": CY - 50, "w": 22, "h": 22, "kind": "block",
             "dynamic": True, "trigger_x": 610, "target_y": CY, "speed": 2.8},
            {"x": 720, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 980, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 994, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1090, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1104, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1118, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
        ],
        orb_defs=[
            {"x": 260, "y": 225, "type": "jump"},
            {"x": 490, "y": 195, "type": "gravity"},
            {"x": 880, "y": 195, "type": "gravity"},
            {"x": 1040, "y": 225, "type": "jump"},
        ],
        portal_defs=[
            {"x": 520, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_WAVE},
            {"x": 840, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_CUBE},
        ],
    )))

    # ----------------------------------------------------------
    # Level 5 – Triple Mix (Hard, all 3 modes)
    # Dynamic: rising platform + narrowing wave tunnel
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Triple Mix", speed=2.6, length=1600,
        difficulty="Hard", bg_color=1,
        hazard_defs=[
            {"x": 90, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 104, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 180, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 240, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 254, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 330, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 344, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            # Ball section
            {"x": 460, "y": CY, "w": 12, "h": 16, "kind": "spike_down"},
            {"x": 474, "y": CY, "w": 12, "h": 16, "kind": "spike_down"},
            {"x": 540, "y": GY - 16, "w": 12, "h": 16, "kind": "spike_up"},
            {"x": 554, "y": GY - 16, "w": 12, "h": 16, "kind": "spike_up"},
            # Dynamic: platform rises to block path mid-ball section!
            {"x": 620, "y": GY + 30, "w": 60, "h": 20, "kind": "block",
             "dynamic": True, "trigger_x": 520, "target_y": GY - 60, "speed": 1.8},
            {"x": 700, "y": CY, "w": 12, "h": 18, "kind": "spike_down"},
            # Wave section
            {"x": 840, "y": GY - 26, "w": 28, "h": 26, "kind": "block"},
            {"x": 840, "y": CY, "w": 28, "h": 26, "kind": "block"},
            {"x": 920, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 920, "y": CY, "w": 22, "h": 22, "kind": "block"},
            {"x": 1000, "y": GY - 28, "w": 28, "h": 28, "kind": "block"},
            {"x": 1000, "y": CY, "w": 28, "h": 28, "kind": "block"},
            # Dynamic: wave gap narrows!
            {"x": 1080, "y": GY - 20, "w": 24, "h": 20, "kind": "block",
             "dynamic": True, "trigger_x": 960, "target_y": GY - 32, "speed": 1.0},
            {"x": 1080, "y": CY, "w": 24, "h": 20, "kind": "block"},
            # End cube
            {"x": 1270, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1284, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1296, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1380, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1394, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1460, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
        ],
        orb_defs=[
            {"x": 270, "y": 225, "type": "jump"},
            {"x": 400, "y": 185, "type": "gravity"},
            {"x": 760, "y": 195, "type": "jump"},
            {"x": 790, "y": 155, "type": "jump"},
            {"x": 1200, "y": 225, "type": "gravity"},
            {"x": 1330, "y": 195, "type": "jump"},
        ],
        portal_defs=[
            {"x": 415, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_BALL},
            {"x": 790, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_WAVE},
            {"x": 1220, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_CUBE},
        ],
    )))

    # ----------------------------------------------------------
    # Level 6 – Nightmare (Extreme, all modes, fast)
    # Dynamic: spikes shoot from BOTH floor and ceiling simultaneously,
    #          plus a final surprise wall
    # ----------------------------------------------------------
    levels.append(Level(LevelConfig(
        name="Nightmare", speed=2.9, length=1600,
        difficulty="Extreme", bg_color=2,
        hazard_defs=[
            {"x": 80, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 94, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 108, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 180, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 194, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 208, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 280, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 308, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            # Tight ball section
            {"x": 430, "y": CY, "w": 12, "h": 20, "kind": "spike_down"},
            {"x": 444, "y": CY, "w": 12, "h": 20, "kind": "spike_down"},
            {"x": 458, "y": CY, "w": 12, "h": 20, "kind": "spike_down"},
            {"x": 490, "y": GY - 20, "w": 12, "h": 20, "kind": "spike_up"},
            {"x": 504, "y": GY - 20, "w": 12, "h": 20, "kind": "spike_up"},
            # Dynamic: spikes shoot from BOTH sides at once!
            {"x": 570, "y": CY - 28, "w": 12, "h": 18, "kind": "spike_down",
             "dynamic": True, "trigger_x": 470, "target_y": CY, "speed": 4.5},
            {"x": 584, "y": CY - 28, "w": 12, "h": 18, "kind": "spike_down",
             "dynamic": True, "trigger_x": 480, "target_y": CY, "speed": 4.5},
            {"x": 570, "y": GY + 8, "w": 12, "h": 18, "kind": "spike_up",
             "dynamic": True, "trigger_x": 470, "target_y": GY - 18, "speed": 4.5},
            {"x": 584, "y": GY + 8, "w": 12, "h": 18, "kind": "spike_up",
             "dynamic": True, "trigger_x": 480, "target_y": GY - 18, "speed": 4.5},
            {"x": 550, "y": CY, "w": 12, "h": 18, "kind": "spike_down"},
            {"x": 620, "y": GY - 20, "w": 12, "h": 20, "kind": "spike_up"},
            # Very tight wave tunnels
            {"x": 790, "y": GY - 18, "w": 18, "h": 18, "kind": "block"},
            {"x": 790, "y": CY, "w": 18, "h": 18, "kind": "block"},
            {"x": 850, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            {"x": 850, "y": CY, "w": 22, "h": 22, "kind": "block"},
            {"x": 910, "y": GY - 16, "w": 16, "h": 16, "kind": "block"},
            {"x": 910, "y": CY, "w": 16, "h": 16, "kind": "block"},
            {"x": 970, "y": GY - 24, "w": 24, "h": 24, "kind": "block"},
            {"x": 970, "y": CY, "w": 24, "h": 24, "kind": "block"},
            # Dynamic: wave tunnel shifts
            {"x": 1030, "y": GY - 18, "w": 20, "h": 18, "kind": "block",
             "dynamic": True, "trigger_x": 900, "target_y": GY - 30, "speed": 0.9},
            {"x": 1030, "y": CY, "w": 20, "h": 18, "kind": "block"},
            # Final cube gauntlet
            {"x": 1180, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1194, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1208, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1260, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1274, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1288, "y": CY, "w": 12, "h": 12, "kind": "spike_down"},
            {"x": 1350, "y": GY - 22, "w": 22, "h": 22, "kind": "block"},
            # Dynamic: final surprise wall from ceiling!
            {"x": 1440, "y": CY - 70, "w": 16, "h": 80, "kind": "block",
             "dynamic": True, "trigger_x": 1340, "target_y": CY, "speed": 3.2},
            {"x": 1460, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1474, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
            {"x": 1488, "y": GY - 12, "w": 12, "h": 12, "kind": "spike_up"},
        ],
        orb_defs=[
            {"x": 250, "y": 225, "type": "jump"},
            {"x": 370, "y": 195, "type": "gravity"},
            {"x": 660, "y": 185, "type": "jump"},
            {"x": 690, "y": 155, "type": "jump"},
            {"x": 1110, "y": 195, "type": "gravity"},
            {"x": 1150, "y": 225, "type": "jump"},
            {"x": 1310, "y": 185, "type": "gravity"},
        ],
        portal_defs=[
            {"x": 390, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_BALL},
            {"x": 730, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_WAVE},
            {"x": 1130, "y": CY + 22, "w": 14, "h": GY - CY - 44, "mode": MODE_CUBE},
        ],
    )))

    return levels


# =========================================================
# CLASS: Game
# =========================================================

class Game:
    def __init__(self):
        pyxel.init(SCREEN_W, SCREEN_H, title="Pixel Dash Wave", fps=60)
        self._init_sounds()

        self.state = STATE_MENU
        self.player = Player()
        self.level_templates = make_levels()
        self.level_index = 0
        self.level: Optional[Level] = None
        self.scroll_x = 0.0
        self.score = 0
        self.best_scores = [0] * len(self.level_templates)
        self.flash_timer = 0
        self.win_timer = 0
        self.menu_anim = 0
        self.particles: List[Particle] = []
        self.warning_anim = 0

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

    # ----------------------------------------------------------
    # Sound setup (uses pyxel sound system, no external files)
    # ----------------------------------------------------------
    def _init_sounds(self):
        # Sound 0: Jump – short ascending beep
        pyxel.sound(0).set(
            notes="C3E3G3",
            tones="SSS",
            volumes="754",
            effects="NNN",
            speed=8,
        )
        # Sound 1: Portal – chord sweep
        pyxel.sound(1).set(
            notes="G2C3E3G3",
            tones="SSSS",
            volumes="5677",
            effects="FFFF",
            speed=6,
        )
        # Sound 2: Death – descending noise
        pyxel.sound(2).set(
            notes="G2F2E2D2C2",
            tones="NNNNN",
            volumes="77654",
            effects="NNNNN",
            speed=5,
        )
        # Sound 3: Orb pickup – sparkle
        pyxel.sound(3).set(
            notes="E3G3B3E4",
            tones="SSSS",
            volumes="5677",
            effects="FFFF",
            speed=5,
        )
        # Sound 4: Win jingle
        pyxel.sound(4).set(
            notes="C2E2G2C3E3G3C4",
            tones="SSSSSSS",
            volumes="5566777",
            effects="NNNNNNN",
            speed=8,
        )
        # Sound 5: Menu navigate
        pyxel.sound(5).set(
            notes="C4E4",
            tones="SS",
            volumes="54",
            effects="NN",
            speed=10,
        )

    # ----------------------------------------------------------
    # Flow control
    # ----------------------------------------------------------
    def load_level(self, index: int):
        index = clamp(index, 0, len(self.level_templates) - 1)
        self.level_index = index
        self.level = self.level_templates[index].clone()
        self.player.reset()
        self.scroll_x = 0.0
        self.score = 0
        self.flash_timer = 0
        self.win_timer = 0
        self.particles = []
        self.state = STATE_PLAY

    def back_to_menu(self):
        self.state = STATE_MENU
        self.level = None
        self.player.reset()
        self.scroll_x = 0.0
        self.particles = []

    def kill_player(self):
        self.player.alive = False
        self.state = STATE_DEAD
        self.flash_timer = 8
        pyxel.play(2, 2)
        # Death particles
        for _ in range(18):
            col = [8, 9, 10, 7][_ % 4]
            self.particles.append(Particle(
                self.player.x + PLAYER_SIZE // 2,
                self.player.y + PLAYER_SIZE // 2,
                col,
                rnd(-4.0, 4.0),
                rnd(-5.0, 0.0),
                40,
            ))
        self.best_scores[self.level_index] = max(
            self.best_scores[self.level_index], self.score
        )

    def spawn_orb_particles(self, x: float, y: float, col: int):
        for _ in range(8):
            self.particles.append(Particle(x, y, col, rnd(-3, 3), rnd(-3, 1), 22))

    # ----------------------------------------------------------
    # Input helpers
    # ----------------------------------------------------------
    def input_pressed(self) -> bool:
        return (
            pyxel.btnp(pyxel.KEY_SPACE)
            or pyxel.btnp(pyxel.KEY_X)
            or pyxel.btnp(pyxel.KEY_UP)
            or pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT)
        )

    def input_held(self) -> bool:
        return (
            pyxel.btn(pyxel.KEY_SPACE)
            or pyxel.btn(pyxel.KEY_X)
            or pyxel.btn(pyxel.KEY_UP)
            or pyxel.btn(pyxel.MOUSE_BUTTON_LEFT)
        )

    def get_speed(self) -> float:
        if self.level is None:
            return 0.0
        return self.level.speed + (1.8 if self.player.dash_timer > 0 else 0.0)

    # ----------------------------------------------------------
    # Update states
    # ----------------------------------------------------------
    def update_menu(self):
        self.menu_anim = (self.menu_anim + 1) % 120
        if not self.level_templates:
            return
        if pyxel.btnp(pyxel.KEY_RIGHT) or pyxel.btnp(pyxel.KEY_D):
            self.level_index = (self.level_index + 1) % len(self.level_templates)
            pyxel.play(3, 5)
        if pyxel.btnp(pyxel.KEY_LEFT) or pyxel.btnp(pyxel.KEY_A):
            self.level_index = (self.level_index - 1) % len(self.level_templates)
            pyxel.play(3, 5)
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_RETURN):
            self.load_level(self.level_index)

    def update_play(self):
        if self.level is None:
            self.back_to_menu()
            return

        if pyxel.btnp(pyxel.KEY_ESCAPE):
            self.back_to_menu()
            return
        if pyxel.btnp(pyxel.KEY_R):
            self.load_level(self.level_index)
            return

        pressed = self.input_pressed()
        held = self.input_held()
        self.warning_anim = (self.warning_anim + 1) % 30

        # Update orbs, portals, dynamic hazards
        for orb in self.level.orbs:
            orb.update()
        for portal in self.level.portals:
            portal.update()
        for hazard in self.level.hazards:
            hazard.update(self.scroll_x)

        # Update particles
        self.particles = [p for p in self.particles if not p.dead]
        for p in self.particles:
            p.update()

        # Portal collision
        for portal in self.level.portals:
            if portal.activated:
                continue
            sx = portal.x - self.scroll_x
            if rects_overlap(
                self.player.x, self.player.y, PLAYER_SIZE, PLAYER_SIZE,
                sx, portal.y, portal.w, portal.h,
            ):
                portal.activated = True
                self.player.set_mode(portal.target_mode)

        # Orb collision
        orb_triggered = False
        if self.player.mode != MODE_WAVE:
            for orb in self.level.orbs:
                if orb.used:
                    continue
                sx = orb.x - self.scroll_x
                if rects_overlap(
                    self.player.x, self.player.y, PLAYER_SIZE, PLAYER_SIZE,
                    sx - 6, orb.y - 6, 12, 12,
                ):
                    if pressed:
                        orb.used = True
                        orb_triggered = True
                        col = 10 if orb.orb_type == "jump" else 14
                        self.spawn_orb_particles(sx, orb.y, col)
                        pyxel.play(3, 3)
                        if orb.orb_type == "jump":
                            self.player.vy = JUMP_POWER * 1.4 * self.player.gravity_dir
                            self.player.on_surface = False
                        elif orb.orb_type == "gravity":
                            self.player.flip_gravity()
                        elif orb.orb_type == "dash":
                            self.player.dash()
                        break

        # Jump input
        if pressed and not orb_triggered:
            if self.player.mode == MODE_CUBE:
                self.player.jump()
            elif self.player.mode == MODE_BALL:
                self.player.flip_gravity()

        self.scroll_x += self.get_speed()
        self.score = int(self.scroll_x)
        self.player.update(held, pressed)

        # Wave boundary death
        if self.player.mode == MODE_WAVE:
            if self.player.y <= CEILING_Y or self.player.y + PLAYER_SIZE >= GROUND_Y:
                self.kill_player()
                return

        # Hazard collision
        px, py, pw, ph = self.player.get_hitbox()
        for hazard in self.level.hazards:
            sx = hazard.x - self.scroll_x
            if rects_overlap(px, py, pw, ph, sx, hazard.y, hazard.w, hazard.h):
                self.kill_player()
                return

        # Win condition
        if self.scroll_x >= self.level.length:
            self.state = STATE_WIN
            self.win_timer = 0
            pyxel.play(0, 4)
            self.best_scores[self.level_index] = max(
                self.best_scores[self.level_index], self.score
            )
            for _ in range(30):
                col = [10, 9, 12, 14, 7][_ % 5]
                self.particles.append(Particle(
                    rnd(120, 400), rnd(120, 380), col,
                    rnd(-3, 3), rnd(-4, 0), 60
                ))

    def update_dead(self):
        if self.flash_timer > 0:
            self.flash_timer -= 1
        self.particles = [p for p in self.particles if not p.dead]
        for p in self.particles:
            p.update()
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_R):
            self.load_level(self.level_index)
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            self.back_to_menu()

    def update_win(self):
        self.win_timer += 1
        self.particles = [p for p in self.particles if not p.dead]
        for p in self.particles:
            p.update()
        if pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.KEY_R):
            self.load_level(self.level_index)
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            self.back_to_menu()

    def update(self):
        if self.state == STATE_MENU:
            self.update_menu()
        elif self.state == STATE_PLAY:
            self.update_play()
        elif self.state == STATE_DEAD:
            self.update_dead()
        elif self.state == STATE_WIN:
            self.update_win()

    # ----------------------------------------------------------
    # Draw helpers
    # ----------------------------------------------------------
    def draw_background(self):
        bg = self.level.bg_color if self.level else 0
        pyxel.cls(bg)

        # Scrolling star-like dots (parallax)
        for i in range(0, SCREEN_W, 14):
            x1 = int((i - self.scroll_x * 0.18) % SCREEN_W)
            x2 = int((i * 2 - self.scroll_x * 0.32) % SCREEN_W)
            y1 = 10 + (i % 30)
            y2 = 25 + (i % 22)
            if 0 <= x1 < SCREEN_W and CEILING_Y < y1 < GROUND_Y:
                pyxel.pset(x1, y1, 5)
            if 0 <= x2 < SCREEN_W and CEILING_Y < y2 < GROUND_Y:
                pyxel.pset(x2, y2, 1)

        # Ceiling zone
        pyxel.rect(0, 0, SCREEN_W, CEILING_Y, 2)
        pyxel.line(0, CEILING_Y, SCREEN_W, CEILING_Y, 6)
        # Ceiling detail
        for x in range(0, SCREEN_W, 20):
            ox = int((x - self.scroll_x * 0.55) % SCREEN_W)
            pyxel.line(ox, CEILING_Y - 6, ox + 5, CEILING_Y - 6, 1)

        # Ground zone
        pyxel.rect(0, GROUND_Y, SCREEN_W, SCREEN_H - GROUND_Y, 1)
        pyxel.line(0, GROUND_Y, SCREEN_W, GROUND_Y, 6)
        # Ground tile pattern
        for x in range(0, SCREEN_W, 16):
            ox = int((x - self.scroll_x * 0.8) % SCREEN_W)
            pyxel.line(ox, GROUND_Y + 4, ox + 8, GROUND_Y + 4, 5)
            pyxel.line(ox, GROUND_Y + 10, ox + 8, GROUND_Y + 10, 2)

    def draw_progress(self):
        if self.level is None:
            return
        bx, by = 50, SCREEN_H - 20
        bw, bh = SCREEN_W - 100, 8
        pyxel.rect(bx, by, bw, bh, 1)
        p = min(1.0, self.scroll_x / max(1, self.level.length))
        pyxel.rect(bx, by, int(bw * p), bh, 12)
        pyxel.rectb(bx, by, bw, bh, 6)
        pct = int(p * 100)
        pyxel.text(bx + bw + 4, by, f"{pct}%", 7)

    def draw_hud(self):
        if self.level is None:
            return
        # Semi-transparent background box (simulated with a dark rect)
        pyxel.rect(4, 4, 190, 72, 0)
        pyxel.rectb(4, 4, 190, 72, 5)

        pyxel.text(8, 8, f"LEVEL: {self.level.name}", 7)
        pyxel.text(8, 18, f"SCORE: {self.score}", 7)
        pyxel.text(8, 28, f"BEST:  {self.best_scores[self.level_index]}", 7)

        mode_col = {MODE_CUBE: 9, MODE_WAVE: 14, MODE_BALL: 12}
        mc = mode_col.get(self.player.mode, 7)
        pyxel.text(8, 38, f"MODE:  {self.player.mode.upper()}", mc)
        pyxel.text(8, 50, "SPACE=ACT  R=RESTART", 5)
        pyxel.text(8, 60, "ESC=MENU", 5)

        # Difficulty badge (top right)
        diff_col = {"Easy": 11, "Normal": 10, "Hard": 9, "Extreme": 8}
        dc = diff_col.get(self.level.difficulty, 7)
        dtext = self.level.difficulty
        pyxel.text(SCREEN_W - len(dtext) * 4 - 6, 6, dtext, dc)

        # Dynamic hazard incoming warning
        if self.level:
            incoming = any(
                h.dynamic and not h.moving
                and h.dyn_trigger_x > self.scroll_x
                and h.dyn_trigger_x < self.scroll_x + 360
                for h in self.level.hazards
            )
            if incoming and self.warning_anim < 15:
                pyxel.rect(SCREEN_W - 100, SCREEN_H - 42, 96, 14, 8)
                pyxel.text(SCREEN_W - 96, SCREEN_H - 39, "! INCOMING !", 7)

    def draw_center_box(self, title: str, subtitle: str, border_col: int):
        w, h = 220, 70
        bx = (SCREEN_W - w) // 2
        by = (SCREEN_H - h) // 2
        pyxel.rect(bx, by, w, h, 0)
        pyxel.rectb(bx, by, w, h, border_col)
        tx = bx + (w - len(title) * 4) // 2
        pyxel.text(tx, by + 12, title, border_col)
        sx = bx + (w - len(subtitle) * 4) // 2
        pyxel.text(sx, by + 28, subtitle, 7)
        hint = "ESC = MENU"
        hx = bx + (w - len(hint) * 4) // 2
        pyxel.text(hx, by + 44, hint, 5)

    def draw_menu(self):
        pyxel.cls(0)
        t = self.menu_anim

        # Animated background particles
        for i in range(0, SCREEN_W, 16):
            x = int((i * 3 + t * 0.9) % SCREEN_W)
            y = int(CEILING_Y + 20 + (i * 19 + t * 0.4) % (GROUND_Y - CEILING_Y - 40))
            pyxel.pset(x, y, 1 if (i % 3 == 0) else 5)

        # Title
        pyxel.text(160, 40, "PIXEL DASH WAVE", 12)
        pyxel.text(166, 50, "Cube + Ball + Wave", 7)

        # Level selector box
        bx, by, bw, bh = 80, 80, 352, 130
        pyxel.rect(bx, by, bw, bh, 1)
        pyxel.rectb(bx, by, bw, bh, 6)

        if self.level_templates:
            lv = self.level_templates[self.level_index]

            # Arrows
            if self.level_index > 0:
                pyxel.text(bx + 6, by + 26, "<", 7)
            if self.level_index < len(self.level_templates) - 1:
                pyxel.text(bx + bw - 10, by + 26, ">", 7)

            # Level name centered
            name_x = bx + (bw - len(lv.name) * 4) // 2
            pyxel.text(name_x, by + 14, lv.name, 10)

            # Difficulty
            diff_col = {"Easy": 11, "Normal": 10, "Hard": 9, "Extreme": 8}
            dc = diff_col.get(lv.difficulty, 7)
            diff_x = bx + (bw - len(lv.difficulty) * 4) // 2
            pyxel.text(diff_x, by + 28, lv.difficulty, dc)

            # Best score
            pyxel.text(bx + 10, by + 44, f"Best: {self.best_scores[self.level_index]}", 7)

            # Feature label
            pyxel.text(bx + 10, by + 60, "Featuring: DYNAMIC obstacles", 9)

            # Level dots
            dot_start = bx + (bw - len(self.level_templates) * 10) // 2
            for i in range(len(self.level_templates)):
                dc2 = 12 if i == self.level_index else 5
                pyxel.circ(dot_start + i * 10, by + 84, 3, dc2)

            # Speed/length info
            pyxel.text(bx + 10, by + 100, f"Speed: {lv.speed:.1f}  Len: {lv.length}", 5)

        # Controls
        pyxel.text(120, 225, "LEFT/RIGHT = choose level", 5)
        pyxel.text(150, 238, "SPACE/ENTER = play", 7)

        # Mode icons
        iy = 270
        # Cube icon
        pyxel.rect(80, iy, 14, 14, 9)
        pyxel.rectb(80, iy, 14, 14, 7)
        pyxel.text(80, iy + 18, "CUBE", 9)
        # Ball icon
        pyxel.circ(190, iy + 7, 7, 12)
        pyxel.circb(190, iy + 7, 7, 7)
        pyxel.text(183, iy + 18, "BALL", 12)
        # Wave icon
        pyxel.tri(280, iy + 14, 287, iy, 294, iy + 14, 14)
        pyxel.text(278, iy + 18, "WAVE", 14)
        # Dynamic icon (flash)
        flash_col = 8 if (t % 20) < 10 else 9
        pyxel.text(340, iy + 6, "! DYN", flash_col)
        pyxel.text(336, iy + 18, "DYNAMIC", 8)

        # Ground strip
        pyxel.rect(0, GROUND_Y, SCREEN_W, SCREEN_H - GROUND_Y, 1)
        pyxel.line(0, GROUND_Y, SCREEN_W, GROUND_Y, 6)

    # ----------------------------------------------------------
    # Main draw
    # ----------------------------------------------------------
    def draw(self):
        if self.state == STATE_MENU:
            self.draw_menu()
            return

        self.draw_background()

        if self.level is not None:
            for hazard in self.level.hazards:
                hazard.draw(self.scroll_x)
            for orb in self.level.orbs:
                orb.draw(self.scroll_x)
            for portal in self.level.portals:
                portal.draw(self.scroll_x)

        for p in self.particles:
            p.draw()

        self.player.draw()
        self.draw_hud()
        self.draw_progress()

        if self.state == STATE_DEAD:
            self.draw_center_box("** GAME OVER **", "SPACE/R=Restart", 8)
        elif self.state == STATE_WIN:
            self.draw_center_box("** LEVEL CLEAR! **", "SPACE/R=Play Again", 12)

        # Flash effect on death
        if self.flash_timer > 0:
            pyxel.rect(0, 0, SCREEN_W, SCREEN_H, 7)
            self.flash_timer -= 1

        # Win glow effect
        if self.state == STATE_WIN and self.win_timer < 20:
            pyxel.rect(0, 0, SCREEN_W, SCREEN_H, 10)


Game()