/**
 * Lumen Vale v2 — three rooms, two gates, dash, spores, saves, minimap.
 *
 * Generated art (gpt-image-2): hero, beetle, spore, backgrounds, pickups,
 * terrain. Procedural: hitboxes, particles, HUD + minimap, gate shimmer,
 * crystal walls, spore clouds, altar, exit portal, fog, vignette, save state,
 * all the feel.
 */
import type { Assets } from './assets';
import { Input } from './input';
import { Particles } from './particles';
import { sfx } from './sfx';
import {
  VIEW, GROUND_Y, ROOMS, ROOM_ORDER, PICKUPS, GATE, CRYSTAL_WALL, EXIT,
  type RoomId, type RoomDef, type Solid, type Box, type PickupId, type EnemySpawn,
} from './level';

// physics ------------------------------------------------------------------
const GRAVITY = 2200;
const MOVE_SPEED = 340;
const ACCEL_GROUND = 3000;
const ACCEL_AIR = 2200;
const JUMP_V = 760;          // max rise ≈ 131px  → cannot clear the 210px gate
const DOUBLE_JUMP_V = 800;   // chained near apex ≈ 276px → clears the gate with grace
const MAX_FALL = 950;
const COYOTE = 0.1;
const JUMP_BUFFER = 0.12;
const DASH_TIME = 0.12;
const DASH_SPEED = 840;      // ≈ 100px per dash
const DASH_CD = 0.6;
const DOUBLE_TAP = 0.28;     // max gap between taps to trigger a dash

type Scene = 'title' | 'play' | 'dead' | 'win';

const overlaps = (a: Box, b: Box) =>
  a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;

const rgba = (r: number, g: number, b: number, a: number) => `rgba(${r},${g},${b},${a})`;

// save state -----------------------------------------------------------------

const SAVE_KEY = 'lumen-vale-save';

interface SaveData {
  currentRoom: RoomId;
  hasWings: boolean;
  hasDash: boolean;
  playerSpawn: { x: number; y: number };
  taken: PickupId[];
  broken: string[];
}

function loadSave(): SaveData | null {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return null;
    const d = JSON.parse(raw) as SaveData;
    if (!d || !ROOMS[d.currentRoom]) return null;
    if (typeof d.playerSpawn?.x !== 'number' || typeof d.playerSpawn?.y !== 'number') return null;
    return {
      currentRoom: d.currentRoom,
      hasWings: !!d.hasWings,
      hasDash: !!d.hasDash,
      playerSpawn: { x: d.playerSpawn.x, y: d.playerSpawn.y },
      taken: Array.isArray(d.taken) ? d.taken : [],
      broken: Array.isArray(d.broken) ? d.broken : [],
    };
  } catch {
    return null;
  }
}

function writeSave(d: SaveData): void {
  try { localStorage.setItem(SAVE_KEY, JSON.stringify(d)); } catch { /* private mode */ }
}

function clearSave(): void {
  try { localStorage.removeItem(SAVE_KEY); } catch { /* private mode */ }
}

// pickup styling ---------------------------------------------------------------

const PICKUP_COLOR: Record<PickupId, string> = {
  wings: '#9fe8ff',
  dash: '#ffc87a',
  shard: '#ffe9b0',
};

/** title-screen RESET SAVE button (canvas coordinates) */
const RESET_BTN = { x: VIEW.w - 152, y: 22, w: 128, h: 34 };

// ---------------------------------------------------------------------------

class Player {
  // (x, y) is the feet-center anchor
  x: number;
  y: number;
  vx = 0;
  vy = 0;
  readonly w = 30;
  readonly h = 52;
  facing = 1;
  onGround = false;
  hearts = 3;
  hasDoubleJump = false;
  hasDash = false;
  dashT = 0;
  dashCd = 0;
  private doubleUsed = false;
  private coyote = 0;
  private jumpBuffer = 0;
  invincibleT = 0;
  private knockbackT = 0;
  squash = 0; // landing squash timer
  runT = 0;
  private t = 0;
  private lastTapDir = 0;
  private lastTapT = -9;

  constructor(spawn: { x: number; y: number }) {
    this.x = spawn.x;
    this.y = spawn.y;
  }

  get box(): Box {
    return { x: this.x - this.w / 2, y: this.y - this.h, w: this.w, h: this.h };
  }

  respawnAt(spawn: { x: number; y: number }): void {
    this.x = spawn.x;
    this.y = spawn.y;
    this.vx = this.vy = 0;
    this.hearts = 3;
    this.invincibleT = 1.2;
    this.facing = 1;
    this.dashT = 0;
    this.dashCd = 0;
  }

  hurt(fromX: number, particles: Particles): boolean {
    if (this.invincibleT > 0) return false;
    this.hearts--;
    this.invincibleT = 1.2;
    this.knockbackT = 0.25;
    this.dashT = 0;
    this.vx = this.x < fromX ? -380 : 380;
    this.vy = -360;
    this.onGround = false;
    particles.burst(this.x, this.y - this.h / 2, '#ff5566', 16);
    sfx.play(this.hearts <= 0 ? 'die' : 'hurt');
    return true;
  }

  private tryDash(dir: number, particles: Particles): void {
    if (!this.hasDash || this.dashCd > 0 || this.dashT > 0 || this.knockbackT > 0) return;
    this.facing = dir;
    this.dashT = DASH_TIME;
    this.dashCd = DASH_CD;
    this.vy = 0;
    particles.ring(this.x, this.y - this.h / 2);
    sfx.play('dash');
  }

  update(
    dt: number,
    input: Input,
    particles: Particles,
    solids: Solid[],
    roomW: number,
    onBreak: (s: Solid) => void,
  ): void {
    this.t += dt;
    this.invincibleT = Math.max(0, this.invincibleT - dt);
    this.knockbackT = Math.max(0, this.knockbackT - dt);
    this.coyote = Math.max(0, this.coyote - dt);
    this.squash = Math.max(0, this.squash - dt);
    this.dashCd = Math.max(0, this.dashCd - dt);

    // dash: SHIFT/X/K, or a quick double-tap of a direction
    if (input.dashPressed) this.tryDash(this.facing, particles);
    if (input.rightPressed) {
      if (this.lastTapDir === 1 && this.t - this.lastTapT < DOUBLE_TAP) this.tryDash(1, particles);
      this.lastTapDir = 1;
      this.lastTapT = this.t;
    }
    if (input.leftPressed) {
      if (this.lastTapDir === -1 && this.t - this.lastTapT < DOUBLE_TAP) this.tryDash(-1, particles);
      this.lastTapDir = -1;
      this.lastTapT = this.t;
    }

    if (this.dashT > 0) {
      // mid-dash: fixed horizontal burst, gravity suspended
      this.dashT -= dt;
      this.vx = this.facing * DASH_SPEED;
      this.vy = 0;
      if (Math.random() < dt * 80) {
        particles.spawn({
          x: this.x - this.facing * 12,
          y: this.y - this.h / 2 + (Math.random() - 0.5) * 28,
          vx: -this.facing * 50,
          vy: 0,
          life: 0.22,
          size: 3,
          color: 'rgba(255,205,140,0.85)',
          glow: true,
        });
      }
      this.moveAndCollide(dt, particles, solids, roomW, onBreak);
      return;
    }

    // horizontal control (suspended briefly during knockback)
    const dir = this.knockbackT > 0 ? 0 : (input.right ? 1 : 0) - (input.left ? 1 : 0);
    if (dir !== 0) this.facing = dir;
    const accel = this.onGround ? ACCEL_GROUND : ACCEL_AIR;
    const target = dir * MOVE_SPEED;
    if (this.vx < target) this.vx = Math.min(target, this.vx + accel * dt);
    else if (this.vx > target) this.vx = Math.max(target, this.vx - accel * dt);

    if (dir !== 0 && this.onGround) {
      this.runT += dt;
      if (Math.random() < dt * 4) particles.dust(this.x - this.facing * 10, this.y, 1, 30);
    } else {
      this.runT = 0;
    }

    // jumping: buffer + coyote + double
    this.jumpBuffer = input.jumpPressed ? JUMP_BUFFER : Math.max(0, this.jumpBuffer - dt);
    if (this.jumpBuffer > 0 && this.knockbackT === 0) {
      if (this.onGround || this.coyote > 0) {
        this.vy = -JUMP_V;
        this.onGround = false;
        this.coyote = 0;
        this.jumpBuffer = 0;
        this.doubleUsed = false;
        particles.dust(this.x, this.y, 5, 70);
        sfx.play('jump');
      } else if (this.hasDoubleJump && !this.doubleUsed) {
        this.vy = -DOUBLE_JUMP_V;
        this.doubleUsed = true;
        this.jumpBuffer = 0;
        particles.ring(this.x, this.y - 8);
        sfx.play('doubleJump');
      }
    }
    // variable jump height
    if (!input.jumpHeld && this.vy < -220) this.vy = -220;

    this.vy = Math.min(MAX_FALL, this.vy + GRAVITY * dt);
    this.moveAndCollide(dt, particles, solids, roomW, onBreak);
  }

  private moveAndCollide(
    dt: number,
    particles: Particles,
    solids: Solid[],
    roomW: number,
    onBreak: (s: Solid) => void,
  ): void {
    // walls shattered this frame — skip them for the rest of the resolution
    const brokenNow = new Set<Solid>();

    // X axis
    this.x += this.vx * dt;
    this.x = Math.max(this.w / 2, Math.min(roomW - this.w / 2, this.x));
    for (const s of solids) {
      if (s.type !== 'solid' || brokenNow.has(s)) continue;
      const b = this.box;
      if (!overlaps(b, s)) continue;
      if (this.dashT > 0 && s.breakable) {
        brokenNow.add(s);
        onBreak(s);
        continue; // dash straight through the shards
      }
      if (this.vx > 0) this.x = s.x - this.w / 2;
      else if (this.vx < 0) this.x = s.x + s.w + this.w / 2;
      this.vx = 0;
    }

    // Y axis
    const prevFeet = this.y;
    this.y += this.vy * dt;
    const wasAirborne = !this.onGround;
    this.onGround = false;
    for (const s of solids) {
      if (brokenNow.has(s)) continue;
      const b = this.box;
      if (!overlaps(b, s)) continue;
      if (this.vy >= 0 && prevFeet <= s.y + 1) {
        // landing (solids and one-way platforms alike)
        this.y = s.y;
        this.vy = 0;
        this.onGround = true;
        this.coyote = COYOTE;
        this.doubleUsed = false;
      } else if (s.type === 'solid' && this.vy < 0 && prevFeet - this.h >= s.y + s.h - 1) {
        // head bump
        this.y = s.y + s.h + this.h;
        this.vy = 0;
      }
    }
    if (this.onGround && wasAirborne) {
      this.squash = 0.14;
      particles.dust(this.x, this.y, 7, 90);
      sfx.play('land');
    }
  }
}

// enemies --------------------------------------------------------------------

class Beetle {
  readonly kind: 'beetle' = 'beetle';
  x: number;
  y: number;
  dir = 1;
  readonly speed = 70;
  readonly w = 46;
  readonly h = 38;
  bobT = Math.random() * 10;
  private min: number;
  private max: number;

  constructor(spawn: EnemySpawn) {
    this.min = spawn.min;
    this.max = spawn.max;
    this.y = spawn.y;
    this.x = (spawn.min + spawn.max) / 2;
  }

  get box(): Box {
    return { x: this.x - this.w / 2, y: this.y - this.h, w: this.w, h: this.h };
  }

  update(dt: number, particles: Particles): void {
    this.bobT += dt;
    this.x += this.dir * this.speed * dt;
    if (this.x > this.max) {
      this.x = this.max;
      this.dir = -1;
      particles.dust(this.x, this.y, 3, 40);
    } else if (this.x < this.min) {
      this.x = this.min;
      this.dir = 1;
      particles.dust(this.x, this.y, 3, 40);
    }
  }
}

/** a drifting spore cloud — telegraphs, swells, sinks, dissipates */
interface Cloud { x: number; y: number; t: number }

const CLOUD_LIFE = 2.8;
const CLOUD_EVERY = 3;
const cloudRadius = (c: Cloud) => 24 + 70 * Math.min(1, c.t);
const cloudActive = (c: Cloud) => c.t > 0.15 && c.t < 2.4;

class Spore {
  readonly kind: 'spore' = 'spore';
  x: number;
  dir = 1;
  readonly speed = 22;
  readonly w = 44;
  readonly h = 40;
  bobT = Math.random() * 10;
  clouds: Cloud[] = [];
  private baseY: number;
  private min: number;
  private max: number;
  private cloudT = 1.4 + Math.random() * 1.6;

  constructor(spawn: EnemySpawn) {
    this.min = spawn.min;
    this.max = spawn.max;
    this.baseY = spawn.y;
    this.x = (spawn.min + spawn.max) / 2;
  }

  /** bobbing feet line */
  get y(): number {
    return this.baseY + Math.sin(this.bobT * 1.7) * 12;
  }

  get box(): Box {
    return { x: this.x - this.w / 2, y: this.y - this.h, w: this.w, h: this.h };
  }

  update(dt: number, particles: Particles): void {
    this.bobT += dt;
    this.x += this.dir * this.speed * dt;
    if (this.x > this.max) { this.x = this.max; this.dir = -1; }
    else if (this.x < this.min) { this.x = this.min; this.dir = 1; }

    this.cloudT -= dt;
    if (this.cloudT <= 0) {
      this.cloudT = CLOUD_EVERY;
      this.clouds.push({ x: this.x, y: this.y - this.h / 2, t: 0 });
      particles.burst(this.x, this.y - this.h / 2, '#b9e88e', 8);
    }
    for (let i = this.clouds.length - 1; i >= 0; i--) {
      const c = this.clouds[i];
      c.t += dt;
      c.y = Math.min(GROUND_Y - 24, c.y + 80 * dt); // sink toward the floor
      if (c.t > CLOUD_LIFE) this.clouds.splice(i, 1);
    }
  }
}

type Foe = Beetle | Spore;

// ---------------------------------------------------------------------------

export class Game {
  scene: Scene = 'title';
  currentRoom: RoomId = 'A';
  player: Player;
  enemies: Foe[] = [];
  particles = new Particles();
  taken = new Set<PickupId>();
  broken = new Set<string>();
  private activeSolids: Solid[] = [];
  private spawnPoint = { ...ROOMS.A.entry };
  private pendingSave: SaveData | null;
  private time = 0;
  private camX = 0;
  private shake = 0;
  private fade = 1; // 1 = black, 0 = clear
  private fadeDir = -1;
  private deadT = 0;
  private winT = 0; // grace before the win overlay accepts input
  private freezeT = 0; // pickup hitstop
  private banner: { text: string; sub: string; t: number } | null = null;
  private worldCache = new Map<RoomId, HTMLCanvasElement>(); // prerendered terrain
  private vignette: HTMLCanvasElement;

  private assets: Assets;
  private input: Input;

  constructor(assets: Assets, input: Input) {
    this.assets = assets;
    this.input = input;
    this.vignette = this.prerenderVignette();
    this.pendingSave = loadSave();
    this.player = new Player(ROOMS.A.entry);
    this.enterRoom('A', ROOMS.A.entry);
  }

  get room(): RoomDef {
    return ROOMS[this.currentRoom];
  }

  // ------------------------------------------------------------------ update

  update(dt: number): void {
    this.time += dt;
    if (this.input.anyKeyThisFrame) sfx.unlock();
    this.fade = Math.max(0, Math.min(1, this.fade + this.fadeDir * dt * 2.2));
    this.shake = Math.max(0, this.shake - dt * 30);

    switch (this.scene) {
      case 'title':
        if (this.input.anyKeyThisFrame) this.startPlay();
        break;
      case 'play':
        if (this.freezeT > 0) this.freezeT -= dt;
        else this.updatePlay(dt);
        break;
      case 'dead':
        this.deadT -= dt;
        this.particles.update(dt);
        if (this.deadT <= 0) {
          this.player.respawnAt(this.spawnPoint);
          this.scene = 'play';
          this.fade = 1;
          this.fadeDir = -1;
        }
        break;
      case 'win':
        this.winT += dt;
        this.particles.update(dt);
        if (this.winT > 0.8 && this.input.anyKeyThisFrame) this.fullReset();
        break;
    }
    this.input.endFrame();
  }

  private startPlay(): void {
    const save = this.pendingSave;
    this.taken = new Set(save?.taken ?? []);
    this.broken = new Set(save?.broken ?? []);
    this.player = new Player(save ? save.playerSpawn : ROOMS.A.entry);
    if (save) {
      this.player.hasDoubleJump = save.hasWings;
      this.player.hasDash = save.hasDash;
    }
    this.enterRoom(save ? save.currentRoom : 'A', save ? save.playerSpawn : ROOMS.A.entry);
    this.scene = 'play';
    this.fade = 1;
    this.fadeDir = -1;
  }

  private fullReset(): void {
    clearSave();
    this.pendingSave = null;
    this.taken.clear();
    this.broken.clear();
    this.player = new Player(ROOMS.A.entry);
    this.enterRoom('A', ROOMS.A.entry);
    this.banner = null;
    this.scene = 'title';
    this.fade = 1;
    this.fadeDir = -1;
  }

  private enterRoom(id: RoomId, entry: { x: number; y: number }): void {
    this.currentRoom = id;
    this.spawnPoint = { ...entry };
    this.refreshSolids();
    this.enemies = ROOMS[id].enemies.map((e) =>
      e.kind === 'beetle' ? new Beetle(e) : new Spore(e),
    );
    this.player.x = entry.x;
    this.player.y = entry.y;
    this.player.vx = this.player.vy = 0;
    this.particles.list.length = 0;
    this.camX = Math.max(0, Math.min(ROOMS[id].w - VIEW.w, entry.x - VIEW.w / 2));
  }

  private refreshSolids(): void {
    this.activeSolids = this.room.solids.filter(
      (s) => !s.breakable || !this.broken.has(s.breakable),
    );
  }

  private breakWall(s: Solid): void {
    if (!s.breakable || this.broken.has(s.breakable)) return;
    this.broken.add(s.breakable);
    this.refreshSolids();
    this.shake = 9;
    sfx.play('break');
    const color = s.breakable === CRYSTAL_WALL.id ? '#ffce8a' : '#9fe8ff';
    const top = Math.max(60, s.y);
    const bot = Math.min(this.room.h, s.y + s.h);
    for (let i = 0; i <= 5; i++) {
      this.particles.burst(s.x + s.w / 2, top + ((bot - top) * i) / 5, color, 8);
    }
    this.saveGame();
  }

  private saveGame(): void {
    const d: SaveData = {
      currentRoom: this.currentRoom,
      hasWings: this.player.hasDoubleJump,
      hasDash: this.player.hasDash,
      playerSpawn: { ...this.spawnPoint },
      taken: [...this.taken],
      broken: [...this.broken],
    };
    writeSave(d);
    this.pendingSave = d;
  }

  /** scene 'dead' kickoff when hearts run dry; returns true if the player died */
  private checkDeath(): boolean {
    const p = this.player;
    if (p.hearts > 0) return false;
    this.scene = 'dead';
    this.deadT = 1.0;
    this.fadeDir = 1;
    this.particles.burst(p.x, p.y - p.h / 2, '#ffd9a0', 26);
    return true;
  }

  private updatePlay(dt: number): void {
    const p = this.player;
    p.update(dt, this.input, this.particles, this.activeSolids, this.room.w, (s) => this.breakWall(s));
    for (const e of this.enemies) e.update(dt, this.particles);
    this.particles.update(dt);
    if (this.banner) {
      this.banner.t -= dt;
      if (this.banner.t <= 0) this.banner = null;
    }

    // room transitions: walk off the edge into a door zone
    for (const d of this.room.doors) {
      if (!overlaps(p.box, d)) continue;
      this.enterRoom(d.to, d.entry);
      this.fade = 1;
      this.fadeDir = -1;
      this.banner = { text: this.room.name.toUpperCase(), sub: '', t: 2.0 };
      sfx.play('checkpoint');
      this.saveGame();
      return;
    }

    // enemy contact + spore clouds
    for (const e of this.enemies) {
      let fromX: number | null = null;
      if (overlaps(p.box, e.box)) {
        fromX = e.x;
      } else if (e.kind === 'spore') {
        for (const c of e.clouds) {
          if (!cloudActive(c)) continue;
          const dx = p.x - c.x;
          const dy = p.y - p.h / 2 - c.y;
          const r = cloudRadius(c) * 0.8;
          if (dx * dx + dy * dy < r * r) { fromX = c.x; break; }
        }
      }
      if (fromX !== null && p.hurt(fromX, this.particles)) {
        this.shake = 7;
        this.checkDeath();
      }
    }

    // hazards (the shrine's spike pit): hurt, then pull back to safe ground
    for (const hz of this.room.hazards) {
      if (overlaps(p.box, hz) && p.hurt(p.x + 40, this.particles)) {
        this.shake = 8;
        if (!this.checkDeath()) {
          p.x = this.spawnPoint.x;
          p.y = this.spawnPoint.y;
          p.vx = p.vy = 0;
        }
      }
    }

    // pickups
    for (const pk of PICKUPS) {
      if (pk.room !== this.currentRoom || this.taken.has(pk.id)) continue;
      const bobY = pk.y + Math.sin(this.time * 2.4 + pk.x) * 7;
      if (Math.random() < dt * 8) this.particles.sparkle(pk.x, bobY, PICKUP_COLOR[pk.id]);
      const reach: Box = { x: pk.x - 30, y: bobY - 30, w: 60, h: 60 };
      if (!overlaps(p.box, reach)) continue;
      this.taken.add(pk.id);
      this.freezeT = 0.35;
      this.shake = 4;
      if (pk.id === 'wings') {
        p.hasDoubleJump = true;
        this.banner = { text: 'AETHER WINGS', sub: 'press JUMP again in mid-air', t: 3.2 };
      } else if (pk.id === 'dash') {
        p.hasDash = true;
        this.banner = { text: 'EMBER DASH', sub: 'double-tap ← / → or press SHIFT', t: 3.2 };
      } else {
        this.banner = { text: 'LUMEN SHARD', sub: 'the heart of the vale, reclaimed', t: 3.2 };
      }
      this.particles.burst(pk.x, bobY, PICKUP_COLOR[pk.id], 30);
      this.particles.ring(pk.x, bobY);
      sfx.play('pickup');
      this.saveGame();
    }

    // gate shimmer — telegraphs "you need something" until the wings are found
    if (this.currentRoom === 'A' && !p.hasDoubleJump && Math.random() < dt * 22) {
      this.particles.sparkle(
        GATE.x - 6 + Math.random() * 8,
        GATE.topY + Math.random() * (GROUND_Y - GATE.topY),
        '#7fd4e8',
      );
    }

    // crystal-wall sparkle — the dash gate telegraphs the same way
    for (const s of this.activeSolids) {
      if (!s.breakable || Math.random() > dt * 10) continue;
      const top = Math.max(40, s.y);
      const bot = Math.min(this.room.h, s.y + s.h);
      this.particles.sparkle(
        s.x + Math.random() * s.w,
        top + Math.random() * (bot - top),
        s.breakable === CRYSTAL_WALL.id ? '#ffd9a0' : '#9fe8ff',
      );
    }

    // exit portal (the shrine altar)
    if (this.currentRoom === 'C') {
      const portal: Box = { x: EXIT.x - 28, y: EXIT.y - 96, w: 56, h: 96 };
      if (Math.random() < dt * 10) this.particles.sparkle(EXIT.x, EXIT.y - 50, '#b9f0d8');
      if (overlaps(p.box, portal)) {
        this.scene = 'win';
        this.winT = 0;
        this.banner = null;
        clearSave(); // the run is complete — next visit starts fresh
        this.pendingSave = null;
        sfx.play('win');
        this.particles.burst(EXIT.x, EXIT.y - 48, '#b9f0d8', 40);
      }
    }

    // ambient dust motes
    if (Math.random() < dt * 3) {
      this.particles.spawn({
        x: this.camX + Math.random() * VIEW.w,
        y: Math.random() * VIEW.h,
        vx: 6 - Math.random() * 12,
        vy: -8 - Math.random() * 8,
        life: 2.5 + Math.random() * 2,
        size: 1.2 + Math.random() * 1.4,
        color: 'rgba(170,215,235,0.4)',
      });
    }

    // camera
    const target = Math.max(0, Math.min(this.room.w - VIEW.w, p.x - VIEW.w / 2));
    this.camX += (target - this.camX) * Math.min(1, dt * 7);
  }

  // -------------------------------------------------------------------- draw

  draw(ctx: CanvasRenderingContext2D): void {
    ctx.clearRect(0, 0, VIEW.w, VIEW.h);
    if (this.scene === 'title') {
      this.drawTitle(ctx);
    } else {
      this.drawWorld(ctx);
      this.drawHud(ctx);
      if (this.scene === 'win') this.drawWinOverlay(ctx);
    }
    // screen transition fade
    if (this.fade > 0.003) {
      ctx.fillStyle = `rgba(4,6,10,${this.fade})`;
      ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    }
  }

  private drawWorld(ctx: CanvasRenderingContext2D): void {
    const shakeX = this.shake > 0 ? (Math.random() - 0.5) * this.shake : 0;
    const shakeY = this.shake > 0 ? (Math.random() - 0.5) * this.shake : 0;

    // parallax background (portrait plate, horizontal band crop)
    const bg = this.assets[this.room.bg].canvas;
    const sw = 860;
    const sh = 484;
    const sy = Math.min(bg.height - sh, Math.floor(bg.height * 0.42));
    const range = Math.max(1, this.room.w - VIEW.w);
    const sx = (this.camX / range) * (bg.width - sw) * 0.92;
    ctx.drawImage(bg, sx, sy, sw, sh, 0, 0, VIEW.w, VIEW.h);

    ctx.save();
    ctx.translate(-this.camX + shakeX, shakeY);

    this.drawFog(ctx, 0.05, 0.6); // far fog
    ctx.drawImage(this.worldFor(this.currentRoom), 0, 0);
    this.drawCrystalWalls(ctx);
    this.drawGate(ctx);
    if (this.currentRoom === 'C') this.drawPortal(ctx);
    this.drawPickups(ctx);
    for (const e of this.enemies) {
      if (e.kind === 'beetle') this.drawBeetle(ctx, e);
      else this.drawSpore(ctx, e);
    }
    this.drawPlayer(ctx);
    this.particles.draw(ctx);
    this.drawFog(ctx, 0.08, 1.4); // near fog

    ctx.restore();

    ctx.drawImage(this.vignette, 0, 0);

    // damage flash
    if (this.player.invincibleT > 0.9) {
      ctx.fillStyle = `rgba(220,40,60,${(this.player.invincibleT - 0.9) * 0.8})`;
      ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    }
  }

  private drawPlayer(ctx: CanvasRenderingContext2D): void {
    const p = this.player;
    if (this.scene === 'dead') return;
    // i-frame blink
    if (p.invincibleT > 0 && Math.floor(this.time * 14) % 2 === 0) return;

    const moving = Math.abs(p.vx) > 30;
    const sprite =
      !p.onGround || (moving && Math.floor(this.time * 9) % 2 === 0)
        ? this.assets.playerRun.canvas
        : this.assets.playerIdle.canvas;

    // squash & stretch (dash gets its own lean-forward stretch)
    let sx = 1;
    let sy = 1;
    if (p.dashT > 0) {
      sx = 1.25;
      sy = 0.82;
    } else if (p.squash > 0) {
      const k = p.squash / 0.14;
      sx = 1 + 0.18 * k;
      sy = 1 - 0.22 * k;
    } else if (!p.onGround) {
      const k = Math.max(-1, Math.min(1, p.vy / 700));
      sy = 1 + 0.1 * Math.abs(k);
      sx = 1 - 0.06 * Math.abs(k);
    }
    const bob = p.onGround && moving ? Math.abs(Math.sin(this.time * 11)) * 3 : 0;
    drawSprite(ctx, sprite, p.x, p.y + 1 - bob, 64 * sy, p.facing < 0, (64 * sx) / 64);

    // soft contact shadow (only near the floor)
    if (p.y > GROUND_Y - 60) {
      ctx.fillStyle = 'rgba(0,0,0,0.3)';
      ctx.beginPath();
      ctx.ellipse(p.x, GROUND_Y + 4, 18, 4, 0, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  private drawBeetle(ctx: CanvasRenderingContext2D, e: Beetle): void {
    const bob = Math.abs(Math.sin(e.bobT * 6)) * 3;
    const lean = Math.sin(e.bobT * 6) * 0.05;
    // source art faces LEFT — flip when walking right
    drawSprite(ctx, this.assets.enemy.canvas, e.x, e.y + 1, 46 + bob * 0.4, e.dir > 0, 1, lean);
    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.beginPath();
    ctx.ellipse(e.x, GROUND_Y + 4, 22, 4, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  private drawSpore(ctx: CanvasRenderingContext2D, e: Spore): void {
    // damage clouds beneath/around — drawn first so the spore floats on top
    for (const c of e.clouds) {
      const r = cloudRadius(c);
      const a = Math.max(0, Math.min(0.45, Math.min(c.t * 2.2, (CLOUD_LIFE - c.t) * 1.1) * 0.45));
      const g = ctx.createRadialGradient(c.x, c.y, 4, c.x, c.y, r);
      g.addColorStop(0, rgba(190, 240, 140, a));
      g.addColorStop(0.7, rgba(150, 215, 110, a * 0.55));
      g.addColorStop(1, rgba(150, 215, 110, 0));
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
      ctx.fill();
      if (cloudActive(c)) {
        ctx.strokeStyle = rgba(170, 235, 120, a * 0.9);
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(c.x, c.y, r * 0.8, 0, Math.PI * 2);
        ctx.stroke();
      }
    }
    const sway = Math.sin(e.bobT * 1.7) * 0.08;
    drawSprite(ctx, this.assets.enemySpore.canvas, e.x, e.y, 48, e.dir > 0, 1, sway);
  }

  private drawPickups(ctx: CanvasRenderingContext2D): void {
    for (const pk of PICKUPS) {
      if (pk.room !== this.currentRoom || this.taken.has(pk.id)) continue;
      const bobY = pk.y + Math.sin(this.time * 2.4 + pk.x) * 7;
      const pulse = 1 + Math.sin(this.time * 3.5) * 0.07;
      const color = PICKUP_COLOR[pk.id];
      ctx.save();
      ctx.shadowBlur = 26;
      ctx.shadowColor = color;
      if (pk.id === 'shard') {
        // procgen crystal shard — no sprite needed
        ctx.translate(pk.x, bobY);
        ctx.rotate(Math.sin(this.time * 1.4) * 0.25);
        ctx.fillStyle = '#ffe9b0';
        ctx.beginPath();
        ctx.moveTo(0, -20 * pulse);
        ctx.lineTo(13 * pulse, 0);
        ctx.lineTo(0, 20 * pulse);
        ctx.lineTo(-13 * pulse, 0);
        ctx.closePath();
        ctx.fill();
        ctx.fillStyle = 'rgba(255,255,255,0.55)';
        ctx.beginPath();
        ctx.moveTo(0, -14 * pulse);
        ctx.lineTo(6 * pulse, 0);
        ctx.lineTo(0, 14 * pulse);
        ctx.closePath();
        ctx.fill();
      } else {
        const sprite = pk.id === 'wings' ? this.assets.pickup.canvas : this.assets.pickupDash.canvas;
        drawSprite(ctx, sprite, pk.x, bobY + 22, 44 * pulse, false);
      }
      ctx.restore();
      // halo
      const g = ctx.createRadialGradient(pk.x, bobY, 4, pk.x, bobY, 46);
      g.addColorStop(0, 'rgba(160,230,255,0.25)');
      g.addColorStop(1, 'rgba(160,230,255,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(pk.x, bobY, 46, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  /** faint rune-light running up the gate wall while it still blocks you */
  private drawGate(ctx: CanvasRenderingContext2D): void {
    if (this.currentRoom !== 'A' || this.player.hasDoubleJump) return;
    const pulse = 0.25 + Math.sin(this.time * 2.2) * 0.12;
    const g = ctx.createLinearGradient(GATE.x - 14, 0, GATE.x + 10, 0);
    g.addColorStop(0, 'rgba(127,212,232,0)');
    g.addColorStop(1, `rgba(127,212,232,${pulse})`);
    ctx.fillStyle = g;
    ctx.fillRect(GATE.x - 14, GATE.topY, 14, GROUND_Y - GATE.topY);
  }

  /** dash-breakable crystal walls, drawn live so a break is instant */
  private drawCrystalWalls(ctx: CanvasRenderingContext2D): void {
    for (const s of this.activeSolids) {
      if (!s.breakable) continue;
      const amber = s.breakable === CRYSTAL_WALL.id;
      const [r, g, b] = amber ? [255, 196, 110] : [128, 216, 238];
      const top = Math.max(0, s.y);
      const bot = Math.min(this.room.h, s.y + s.h);
      const pulse = 0.55 + Math.sin(this.time * 2.1 + s.x * 0.01) * 0.12;

      const grad = ctx.createLinearGradient(s.x, 0, s.x + s.w, 0);
      grad.addColorStop(0, rgba(r, g, b, 0.25 * pulse));
      grad.addColorStop(0.5, rgba(r, g, b, 0.6 * pulse));
      grad.addColorStop(1, rgba(r, g, b, 0.3 * pulse));
      ctx.fillStyle = grad;
      ctx.fillRect(s.x, top, s.w, bot - top);

      // facet lines
      ctx.strokeStyle = rgba(255, 255, 255, 0.25 * pulse);
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(s.x + 6, top);
      let zig = true;
      for (let y = top + 46; y < bot; y += 46) {
        ctx.lineTo(zig ? s.x + s.w - 8 : s.x + 6, y);
        zig = !zig;
      }
      ctx.stroke();

      ctx.strokeStyle = rgba(r, g, b, 0.8 * pulse);
      ctx.lineWidth = 2;
      ctx.strokeRect(s.x + 1, top + 1, s.w - 2, bot - top - 2);
    }
  }

  private drawPortal(ctx: CanvasRenderingContext2D): void {
    const cx = EXIT.x;
    const cy = EXIT.y - 48;
    ctx.save();
    const breathe = 1 + Math.sin(this.time * 1.8) * 0.05;
    const g = ctx.createRadialGradient(cx, cy, 6, cx, cy, 60 * breathe);
    g.addColorStop(0, 'rgba(200,255,230,0.85)');
    g.addColorStop(0.45, 'rgba(110,220,180,0.35)');
    g.addColorStop(1, 'rgba(110,220,180,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.ellipse(cx, cy, 44 * breathe, 60 * breathe, 0, 0, Math.PI * 2);
    ctx.fill();
    // rotating arcs
    ctx.strokeStyle = 'rgba(185,240,216,0.7)';
    ctx.lineWidth = 2.5;
    for (let i = 0; i < 3; i++) {
      const a = this.time * (0.9 + i * 0.35) + (i * Math.PI * 2) / 3;
      ctx.beginPath();
      ctx.ellipse(cx, cy, 28 + i * 9, 42 + i * 9, 0, a, a + 1.7);
      ctx.stroke();
    }
    ctx.restore();
  }

  private drawFog(ctx: CanvasRenderingContext2D, alpha: number, speed: number): void {
    const [fr, fg, fb] = this.room.fog;
    ctx.save();
    for (let i = 0; i < 3; i++) {
      const fx = ((this.time * 14 * speed + i * 520) % (this.room.w + 600)) - 300;
      const fy = 150 + i * 130 + Math.sin(this.time * 0.5 + i * 2) * 24;
      const g = ctx.createRadialGradient(fx, fy, 20, fx, fy, 240);
      g.addColorStop(0, rgba(fr, fg, fb, alpha));
      g.addColorStop(1, rgba(fr, fg, fb, 0));
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(fx, fy, 240, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  // --------------------------------------------------------------------- HUD

  private drawHud(ctx: CanvasRenderingContext2D): void {
    this.drawMinimap(ctx);

    // hearts (below the minimap)
    for (let i = 0; i < 3; i++) {
      drawHeart(ctx, 30 + i * 36, 70, 11, i < this.player.hearts);
    }

    // ability slots: wings (far right) + dash
    this.drawAbilitySlot(ctx, VIEW.w - 42, this.assets.pickup.canvas, this.player.hasDoubleJump, '#9fe8ff', 0);
    this.drawAbilitySlot(
      ctx, VIEW.w - 90, this.assets.pickupDash.canvas, this.player.hasDash, '#ffc87a',
      this.player.hasDash ? this.player.dashCd / DASH_CD : 0,
    );

    // banner
    if (this.banner) {
      const a = Math.min(1, this.banner.t, (3.2 - this.banner.t) * 3);
      ctx.save();
      ctx.globalAlpha = Math.max(0, a);
      ctx.textAlign = 'center';
      ctx.fillStyle = '#cfeeff';
      ctx.shadowBlur = 18;
      ctx.shadowColor = '#7fd4e8';
      ctx.font = '700 30px Georgia, serif';
      ctx.fillText(this.banner.text, VIEW.w / 2, 120);
      ctx.shadowBlur = 0;
      ctx.font = '15px Georgia, serif';
      ctx.fillStyle = 'rgba(220,240,250,0.95)';
      ctx.fillText(this.banner.sub, VIEW.w / 2, 148);
      ctx.restore();
    }
  }

  private drawAbilitySlot(
    ctx: CanvasRenderingContext2D,
    x: number,
    sprite: HTMLCanvasElement,
    owned: boolean,
    color: string,
    cooldown: number, // 0..1 remaining
  ): void {
    ctx.save();
    ctx.globalAlpha = 0.85;
    ctx.fillStyle = 'rgba(10,16,22,0.6)';
    ctx.strokeStyle = owned ? color : 'rgba(120,140,150,0.4)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, 32, 20, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
    ctx.globalAlpha = owned ? 1 : 0.22;
    if (owned) {
      ctx.shadowBlur = 10;
      ctx.shadowColor = color;
    }
    drawSprite(ctx, sprite, x, 32 + 12, 26, false);
    ctx.shadowBlur = 0;
    if (cooldown > 0) {
      ctx.globalAlpha = 0.9;
      ctx.strokeStyle = color;
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(x, 32, 24, -Math.PI / 2, -Math.PI / 2 + (1 - cooldown) * Math.PI * 2);
      ctx.stroke();
    }
    ctx.restore();
  }

  /** three room thumbnails, current room lit, pickup dots filled when held */
  private drawMinimap(ctx: CanvasRenderingContext2D): void {
    const tw = 40;
    const th = 24;
    const gap = 8;
    ctx.save();
    ROOM_ORDER.forEach((id, i) => {
      const x = 22 + i * (tw + gap);
      const y = 18;
      const current = id === this.currentRoom;
      ctx.fillStyle = current ? 'rgba(40,70,90,0.75)' : 'rgba(10,16,22,0.6)';
      ctx.strokeStyle = current ? 'rgba(159,232,255,0.95)' : 'rgba(120,140,150,0.4)';
      ctx.lineWidth = current ? 2 : 1.5;
      ctx.beginPath();
      ctx.roundRect(x, y, tw, th, 4);
      ctx.fill();
      ctx.stroke();
      // pickup status dots
      const pks = PICKUPS.filter((p) => p.room === id);
      pks.forEach((pk, j) => {
        const cx = x + 8 + j * 11;
        const cy = y + th - 7;
        ctx.beginPath();
        ctx.arc(cx, cy, 3, 0, Math.PI * 2);
        if (this.taken.has(pk.id)) {
          ctx.fillStyle = PICKUP_COLOR[pk.id];
          ctx.fill();
        } else {
          ctx.strokeStyle = 'rgba(200,220,230,0.5)';
          ctx.lineWidth = 1;
          ctx.stroke();
        }
      });
      // player blip
      if (current && this.scene !== 'title') {
        const px = x + 3 + (this.player.x / this.room.w) * (tw - 6);
        ctx.fillStyle = '#ffffff';
        ctx.beginPath();
        ctx.arc(px, y + 7, 2.2, 0, Math.PI * 2);
        ctx.fill();
      }
    });
    ctx.restore();
  }

  private drawWinOverlay(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = 'rgba(5,10,14,0.62)';
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    ctx.save();
    ctx.textAlign = 'center';
    ctx.fillStyle = '#d9f7e8';
    ctx.shadowBlur = 24;
    ctx.shadowColor = '#6edcb4';
    ctx.font = '700 52px Georgia, serif';
    ctx.fillText('THE VALE IS LIT', VIEW.w / 2, 240);
    ctx.shadowBlur = 0;
    ctx.font = 'italic 19px Georgia, serif';
    ctx.fillStyle = 'rgba(214,235,225,0.9)';
    const sub = this.taken.has('shard')
      ? 'every shard found — the vale remembers you'
      : 'the vale remembers you';
    ctx.fillText(sub, VIEW.w / 2, 280);
    ctx.font = '15px Georgia, serif';
    ctx.fillStyle = `rgba(190,210,205,${0.5 + Math.sin(this.time * 3) * 0.3})`;
    ctx.fillText('press any key', VIEW.w / 2, 330);
    ctx.restore();
  }

  // ------------------------------------------------------------------- title

  private drawTitle(ctx: CanvasRenderingContext2D): void {
    // background, cover-cropped to a nice band
    const bg = this.assets.background.canvas;
    const drift = Math.sin(this.time * 0.18) * 26;
    ctx.drawImage(bg, 0, bg.height * 0.30 + drift, bg.width, bg.width * (VIEW.h / VIEW.w), 0, 0, VIEW.w, VIEW.h);
    ctx.fillStyle = 'rgba(6,10,16,0.45)';
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);

    // hero on a slab
    const heroY = 408 + Math.sin(this.time * 1.6) * 5;
    ctx.save();
    ctx.shadowBlur = 22;
    ctx.shadowColor = 'rgba(120,200,230,0.5)';
    drawSprite(ctx, this.assets.playerIdle.canvas, VIEW.w / 2, heroY, 92, false);
    ctx.restore();
    drawSprite(ctx, this.assets.platform.canvas, VIEW.w / 2, heroY + 42, 36, false);
    if (Math.random() < 0.12) this.particles.sparkle(VIEW.w / 2 + (Math.random() - 0.5) * 140, heroY - 60);
    this.particles.update(1 / 60);
    this.particles.draw(ctx);

    ctx.textAlign = 'center';
    ctx.fillStyle = '#dff2fa';
    ctx.shadowBlur = 28;
    ctx.shadowColor = '#5fc6e8';
    ctx.font = '700 72px Georgia, serif';
    ctx.fillText('L U M E N   V A L E', VIEW.w / 2, 175);
    ctx.shadowBlur = 0;
    ctx.font = 'italic 21px Georgia, serif';
    ctx.fillStyle = 'rgba(205,228,238,0.92)';
    ctx.fillText('a tiny metroidvania', VIEW.w / 2, 215);

    if (this.pendingSave) {
      ctx.font = '15px Georgia, serif';
      ctx.fillStyle = 'rgba(255,220,170,0.85)';
      ctx.fillText(
        `journey in progress — ${ROOMS[this.pendingSave.currentRoom].name}`,
        VIEW.w / 2, 245,
      );
    }

    ctx.font = '15px Georgia, serif';
    ctx.fillStyle = 'rgba(185,205,215,0.85)';
    ctx.fillText('← →  /  A D   move        SPACE   jump        SHIFT   dash', VIEW.w / 2, 480);
    ctx.fillStyle = `rgba(223,242,250,${0.45 + Math.sin(this.time * 3) * 0.35})`;
    ctx.font = '600 17px Georgia, serif';
    ctx.fillText(this.pendingSave ? 'press any key to continue' : 'press any key to begin', VIEW.w / 2, 512);

    ctx.drawImage(this.vignette, 0, 0);

    // RESET SAVE button (only when there is something to reset)
    if (this.pendingSave) {
      ctx.save();
      ctx.fillStyle = 'rgba(20,10,12,0.55)';
      ctx.strokeStyle = 'rgba(255,110,120,0.65)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.roundRect(RESET_BTN.x, RESET_BTN.y, RESET_BTN.w, RESET_BTN.h, 8);
      ctx.fill();
      ctx.stroke();
      ctx.fillStyle = 'rgba(255,170,175,0.9)';
      ctx.font = '600 13px Georgia, serif';
      ctx.textAlign = 'center';
      ctx.fillText('RESET SAVE', RESET_BTN.x + RESET_BTN.w / 2, RESET_BTN.y + 22);
      ctx.restore();
    }
  }

  /** canvas-space click — title screen's RESET SAVE button */
  handleClick(x: number, y: number): void {
    sfx.unlock();
    if (this.scene !== 'title' || !this.pendingSave) return;
    if (
      x < RESET_BTN.x || x > RESET_BTN.x + RESET_BTN.w ||
      y < RESET_BTN.y || y > RESET_BTN.y + RESET_BTN.h
    ) return;
    clearSave();
    this.pendingSave = null;
    this.particles.burst(RESET_BTN.x + RESET_BTN.w / 2, RESET_BTN.y + RESET_BTN.h / 2, '#ff8090', 14);
    sfx.play('break');
  }

  // ------------------------------------------------------------- prerenders

  private worldFor(id: RoomId): HTMLCanvasElement {
    let c = this.worldCache.get(id);
    if (!c) {
      c = this.prerenderWorld(ROOMS[id]);
      this.worldCache.set(id, c);
    }
    return c;
  }

  private prerenderWorld(room: RoomDef): HTMLCanvasElement {
    const c = document.createElement('canvas');
    c.width = room.w;
    c.height = room.h;
    const ctx = c.getContext('2d')!;
    const ground = this.assets.ground.canvas;
    const platform = this.assets.platform.canvas;

    for (const s of room.solids) {
      if (s.breakable) continue; // crystal walls render live
      if (s.type === 'oneway') {
        // one-way platforms: stretched slabs with a little bleed
        ctx.drawImage(platform, s.x - 14, s.y - 6, s.w + 28, 46);
        continue;
      }
      if (s.tile === 'platform') {
        // solid slabs (vault roof, shrine dais)
        ctx.drawImage(platform, s.x - 14, s.y - 6, s.w + 28, Math.max(46, s.h + 16));
        continue;
      }
      if (s.y >= GROUND_Y) {
        // floor shelf: repeated painted ground chunks, clipped to the shelf
        ctx.save();
        ctx.beginPath();
        ctx.rect(s.x - 2, s.y - 7, s.w + 4, room.h - s.y + 7);
        ctx.clip();
        const tileH = 124;
        const tileW = (ground.width / ground.height) * tileH;
        for (let x = s.x - 10, i = 0; x < s.x + s.w; x += tileW - 8, i++) {
          ctx.save();
          if (i % 2 === 1) {
            ctx.translate(x + tileW / 2, 0);
            ctx.scale(-1, 1);
            ctx.translate(-(x + tileW / 2), 0);
          }
          ctx.drawImage(ground, x, s.y - 6, tileW, tileH);
          ctx.restore();
        }
        ctx.restore();
      } else {
        // wall: two stacked chunks, slightly inflated for a painted edge
        const half = s.h / 2 + 8;
        ctx.drawImage(ground, s.x - 8, s.y - 6, s.w + 16, half);
        ctx.save();
        ctx.translate(s.x + s.w / 2, 0);
        ctx.scale(-1, 1);
        ctx.translate(-(s.x + s.w / 2), 0);
        ctx.drawImage(ground, s.x - 8, s.y + s.h / 2 - 6, s.w + 16, half);
        ctx.restore();
      }
    }

    // hazards: spike rows in the pit
    for (const hz of room.hazards) {
      ctx.fillStyle = '#10151b';
      ctx.fillRect(hz.x, hz.y, hz.w, room.h - hz.y);
      for (let x = hz.x; x < hz.x + hz.w - 2; x += 20) {
        ctx.fillStyle = '#6a7682';
        ctx.beginPath();
        ctx.moveTo(x, hz.y + hz.h);
        ctx.lineTo(x + 10, hz.y);
        ctx.lineTo(x + 20, hz.y + hz.h);
        ctx.closePath();
        ctx.fill();
        ctx.fillStyle = '#9aa8b4';
        ctx.beginPath();
        ctx.moveTo(x + 6, hz.y + hz.h);
        ctx.lineTo(x + 10, hz.y + 4);
        ctx.lineTo(x + 12, hz.y + hz.h);
        ctx.closePath();
        ctx.fill();
      }
    }

    // the shrine altar under the portal (procgen — no sprite)
    if (room.id === 'C') {
      const ax = EXIT.x;
      const top = 430;
      ctx.fillStyle = '#3c352f';
      ctx.fillRect(ax - 14, top - 30, 28, 30);
      ctx.fillStyle = '#4a423a';
      ctx.fillRect(ax - 26, top - 40, 52, 12);
      ctx.fillStyle = 'rgba(255,235,200,0.25)';
      ctx.fillRect(ax - 26, top - 40, 52, 3);
    }

    // grounding shadow under the floor line
    const g = ctx.createLinearGradient(0, GROUND_Y, 0, room.h);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(1, 'rgba(0,0,0,0.55)');
    ctx.fillStyle = g;
    ctx.fillRect(0, GROUND_Y, room.w, room.h - GROUND_Y);
    return c;
  }

  private prerenderVignette(): HTMLCanvasElement {
    const c = document.createElement('canvas');
    c.width = VIEW.w;
    c.height = VIEW.h;
    const ctx = c.getContext('2d')!;
    const g = ctx.createRadialGradient(
      VIEW.w / 2, VIEW.h / 2, VIEW.h * 0.42,
      VIEW.w / 2, VIEW.h / 2, VIEW.h * 0.95,
    );
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(1, 'rgba(2,5,9,0.62)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    // cool top light
    const t = ctx.createLinearGradient(0, 0, 0, 130);
    t.addColorStop(0, 'rgba(110,170,200,0.10)');
    t.addColorStop(1, 'rgba(110,170,200,0)');
    ctx.fillStyle = t;
    ctx.fillRect(0, 0, VIEW.w, 130);
    return c;
  }

  // ------------------------------------------------------------------- debug

  /** machine-readable state for the e2e suite */
  debugState() {
    const beetle = this.enemies.find((e): e is Beetle => e.kind === 'beetle');
    return {
      scene: this.scene,
      currentRoom: this.currentRoom,
      room: { id: this.room.id, name: this.room.name, w: this.room.w, h: this.room.h },
      player: {
        x: this.player.x,
        y: this.player.y,
        vx: this.player.vx,
        vy: this.player.vy,
        hearts: this.player.hearts,
        onGround: this.player.onGround,
        hasDoubleJump: this.player.hasDoubleJump,
        hasDash: this.player.hasDash,
        dashCooldown: this.player.dashCd,
      },
      enemy: beetle
        ? { x: beetle.x, y: beetle.y, dir: beetle.dir }
        : { x: -999, y: 0, dir: 1 },
      enemies: this.enemies.map((e) => ({ kind: e.kind, x: e.x, y: e.y, dir: e.dir })),
      pickup: { x: PICKUPS[0].x, y: PICKUPS[0].y, taken: this.taken.has('wings') },
      pickups: PICKUPS.map((pk) => ({
        id: pk.id, room: pk.room, x: pk.x, y: pk.y, taken: this.taken.has(pk.id),
      })),
      broken: [...this.broken],
      save: this.pendingSave,
      gate: GATE,
      exit: EXIT,
      fallbackSprites: this.assets.fallbacks,
    };
  }

  debugTeleport(x: number, y: number): void {
    this.player.x = x;
    this.player.y = y;
    this.player.vx = 0;
    this.player.vy = 0;
  }
}

// ---------------------------------------------------------------------------

/** draw a trimmed sprite canvas anchored at feet-center, scaled to targetH */
function drawSprite(
  ctx: CanvasRenderingContext2D,
  sprite: HTMLCanvasElement,
  cx: number,
  feetY: number,
  targetH: number,
  flip: boolean,
  stretchX = 1,
  rot = 0,
): void {
  const scale = targetH / sprite.height;
  const w = sprite.width * scale * stretchX;
  ctx.save();
  ctx.translate(cx, feetY);
  if (rot) ctx.rotate(rot);
  if (flip) ctx.scale(-1, 1);
  ctx.drawImage(sprite, -w / 2, -targetH, w, targetH);
  ctx.restore();
}

function drawHeart(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, filled: boolean): void {
  ctx.save();
  ctx.translate(x, y);
  // two lobes + point
  ctx.beginPath();
  ctx.moveTo(0, r);
  ctx.bezierCurveTo(-r * 1.45, r * 0.1, -r * 0.85, -r * 1.1, 0, -r * 0.35);
  ctx.bezierCurveTo(r * 0.85, -r * 1.1, r * 1.45, r * 0.1, 0, r);
  ctx.closePath();
  if (filled) {
    ctx.fillStyle = '#ff5d72';
    ctx.shadowBlur = 8;
    ctx.shadowColor = 'rgba(255,93,114,0.7)';
    ctx.fill();
  } else {
    ctx.strokeStyle = 'rgba(255,150,160,0.45)';
    ctx.lineWidth = 2;
    ctx.stroke();
  }
  ctx.restore();
}
