/**
 * APHELION — game core.
 *
 * Generated art (gpt-image-2): hero, wisp, scuttler, boss, pickup, terrain,
 * four background plates. Procedural: hitboxes, particles, HUD + minimap,
 * door glows, beacon, attack arcs, boss projectiles, fog, vignette, save
 * state, and all the feel.
 */
import type { Assets } from './assets';
import { Input } from './input';
import { Particles } from './particles';
import { sfx } from './sfx';
import {
  VIEW, ROOMS, ROOM_ORDER, PICKUP, BEACON,
  type RoomId, type RoomDef, type Solid, type Box, type EnemySpawn,
} from './level';

// physics ------------------------------------------------------------------
const GRAVITY = 2300;
const MOVE_SPEED = 215;
const ACCEL_GROUND = 2600;
const ACCEL_AIR = 1900;
const JUMP_V = 730;            // max rise ≈ 116px
const MAX_FALL = 920;
const COYOTE = 0.1;
const JUMP_BUFFER = 0.12;
const WALL_SLIDE_MAX = 135;    // capped fall speed while gripping
const WALL_JUMP_VY = 710;
const WALL_JUMP_VX = 330;
const WALL_JUMP_LOCK = 0.14;   // steering suspended right after a kick
const WALL_COYOTE = 0.09;
const ATTACK_TIME = 0.12;
const ATTACK_CD = 0.34;
const ATTACK_REACH = 72;

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})`;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));

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

const SAVE_KEY = 'aphelion-save-v1';

interface SaveData {
  room: RoomId;
  ability: boolean; // the STARGRIP
  checkpoint: { room: RoomId; x: number; y: number };
  deaths: number;
  playTime: number;
}

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.room] || !d.checkpoint || !ROOMS[d.checkpoint.room]) return null;
    if (typeof d.checkpoint.x !== 'number' || typeof d.checkpoint.y !== 'number') return null;
    return {
      room: d.room,
      ability: !!d.ability,
      checkpoint: { room: d.checkpoint.room, x: d.checkpoint.x, y: d.checkpoint.y },
      deaths: typeof d.deaths === 'number' ? d.deaths : 0,
      playTime: typeof d.playTime === 'number' ? d.playTime : 0,
    };
  } 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 */ }
}

/** title-screen RESET SAVE button (canvas coordinates) */
const RESET_BTN = { x: VIEW.w - 118, y: 16, w: 102, h: 32 };

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

class Player {
  // (x, y) is the feet-center anchor
  x: number;
  y: number;
  vx = 0;
  vy = 0;
  readonly w = 26;
  readonly h = 46;
  facing = 1;
  onGround = false;
  hearts = 3;
  hasGrip = false;
  invincibleT = 0;
  squash = 0;
  attackT = 0;
  attackCd = 0;
  attackDir = 1;
  wallSliding = 0; // -1 left wall, 1 right wall, 0 none
  private wallCoyote = 0;
  private wallCoyoteDir = 0;
  private lockT = 0;
  private coyote = 0;
  private jumpBuffer = 0;
  private knockbackT = 0;
  runT = 0;
  private t = 0;

  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 };
  }

  /** the star-lash hitbox while a swing is active, else null */
  get attackBox(): Box | null {
    if (this.attackT <= 0) return null;
    const x0 = this.attackDir > 0 ? this.x + 4 : this.x - 4 - ATTACK_REACH;
    return { x: x0, y: this.y - this.h - 8, w: ATTACK_REACH, h: this.h + 14 };
  }

  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.attackT = 0;
    this.attackCd = 0;
    this.wallSliding = 0;
  }

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

  update(dt: number, input: Input, particles: Particles, solids: Solid[], roomW: number): 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.wallCoyote = Math.max(0, this.wallCoyote - dt);
    this.lockT = Math.max(0, this.lockT - dt);
    this.squash = Math.max(0, this.squash - dt);
    this.attackT = Math.max(0, this.attackT - dt);
    this.attackCd = Math.max(0, this.attackCd - dt);

    // the star lash — available from the start; the boss needs it
    if (input.attackPressed && this.attackCd === 0 && this.knockbackT === 0) {
      this.attackT = ATTACK_TIME;
      this.attackCd = ATTACK_CD;
      this.attackDir = this.facing;
      sfx.play('attack');
    }

    // horizontal control (suspended during knockback / right after a wall kick)
    const steer = this.knockbackT > 0 || this.lockT > 0;
    const dir = steer ? 0 : (input.right ? 1 : 0) - (input.left ? 1 : 0);
    if (dir !== 0) this.facing = dir;
    const accel = this.onGround ? ACCEL_GROUND : ACCEL_AIR;
    if (!steer) {
      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 + wall kick
    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;
        particles.dust(this.x, this.y, 5, 70);
        sfx.play('jump');
      } else if (this.hasGrip && (this.wallSliding !== 0 || this.wallCoyote > 0)) {
        const wallDir = this.wallSliding !== 0 ? this.wallSliding : this.wallCoyoteDir;
        this.vy = -WALL_JUMP_VY;
        this.vx = -wallDir * WALL_JUMP_VX;
        this.lockT = WALL_JUMP_LOCK;
        this.facing = -wallDir;
        this.jumpBuffer = 0;
        this.wallSliding = 0;
        this.wallCoyote = 0;
        particles.ring(this.x + wallDir * this.w * 0.5, this.y - this.h * 0.5);
        sfx.play('wallJump');
      }
    }
    // variable jump height
    if (!input.jumpHeld && this.vy < -240) this.vy = -240;

    this.vy = Math.min(MAX_FALL, this.vy + GRAVITY * dt);

    // wall slide: gripping a wall while falling caps the descent
    if (this.wallSliding !== 0 && this.vy > 0) {
      this.vy = Math.min(this.vy, WALL_SLIDE_MAX);
      if (Math.random() < dt * 24) {
        particles.dust(this.x + this.wallSliding * this.w * 0.5, this.y - 8, 1, 20);
      }
    }

    this.moveAndCollide(dt, particles, solids, roomW, dir);
  }

  private moveAndCollide(
    dt: number,
    particles: Particles,
    solids: Solid[],
    roomW: number,
    inputDir: number,
  ): void {
    // X axis
    this.x += this.vx * dt;
    this.x = clamp(this.x, this.w / 2, roomW - this.w / 2);
    let touchedWall = 0;
    for (const s of solids) {
      if (s.type !== 'solid') continue;
      const b = this.box;
      if (!overlaps(b, s)) continue;
      if (this.vx > 0) { this.x = s.x - this.w / 2; touchedWall = 1; }
      else if (this.vx < 0) { this.x = s.x + s.w + this.w / 2; touchedWall = -1; }
      this.vx = 0;
    }

    // wall-grip check: still pressed up against a solid wall?
    this.wallSliding = 0;
    if (this.hasGrip && !this.onGround) {
      const probeDir = touchedWall !== 0 ? touchedWall : inputDir;
      if (probeDir !== 0 && (inputDir === probeDir || touchedWall !== 0)) {
        const probe: Box = {
          x: this.x - this.w / 2 + probeDir * 3, y: this.y - this.h, w: this.w, h: this.h,
        };
        for (const s of solids) {
          if (s.type !== 'solid') continue;
          if (overlaps(probe, s)) {
            if (this.vy > 0 && inputDir === probeDir) this.wallSliding = probeDir;
            this.wallCoyote = WALL_COYOTE;
            this.wallCoyoteDir = probeDir;
            break;
          }
        }
      }
    }

    // Y axis
    const prevFeet = this.y;
    this.y += this.vy * dt;
    const wasAirborne = !this.onGround;
    this.onGround = false;
    for (const s of solids) {
      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;
      } 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;
      this.wallSliding = 0;
      particles.dust(this.x, this.y, 7, 90);
      sfx.play('land');
    }
  }
}

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

class Scuttler {
  readonly kind: 'scuttler' = 'scuttler';
  x: number;
  y: number;
  dir = 1;
  hp = 2;
  hitT = 0;
  hurtCd = 0;
  readonly speed = 64;
  readonly w = 42;
  readonly h = 30;
  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.hitT = Math.max(0, this.hitT - dt);
    this.hurtCd = Math.max(0, this.hurtCd - 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);
    }
  }
}

class Wisp {
  readonly kind: 'wisp' = 'wisp';
  x: number;
  dir = 1;
  hp = 1;
  hitT = 0;
  hurtCd = 0;
  readonly speed = 34;
  readonly w = 36;
  readonly h = 34;
  bobT = Math.random() * 10;
  private baseY: number;
  private min: number;
  private max: number;

  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.9) * 14;
  }

  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.hitT = Math.max(0, this.hitT - dt);
    this.hurtCd = Math.max(0, this.hurtCd - 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; }
  }
}

type Foe = Scuttler | Wisp;

// the boss -------------------------------------------------------------------

const BOSS_MAX_HP = 10;
const BOSS_HOME_Y = 270;

interface Shard { x: number; y: number; vy: number }
interface Warn { x: number; t: number }
interface Comet { x: number; y: number; vx: number; vy: number; life: number }

type BossState = 'enter' | 'hover' | 'telegraph' | 'dive' | 'linger' | 'rise' | 'dying';

class Boss {
  x = VIEW.w / 2;
  y = -140;
  hp = BOSS_MAX_HP;
  state: BossState = 'enter';
  t = 0;
  iframeT = 0;
  wobbleT = 0;
  shards: Shard[] = [];
  warns: Warn[] = [];
  comets: Comet[] = [];
  roared = false; // phase-2 transition ceremony fired
  dieT = 0;
  private targetX = VIEW.w / 2;
  private nextAttack: 'dive' | 'rain' | 'burst' = 'dive';
  private attackCount = 0;

  get phase(): 1 | 2 {
    return this.hp > BOSS_MAX_HP / 2 ? 1 : 2;
  }

  get box(): Box {
    return { x: this.x - 56, y: this.y - 58, w: 112, h: 116 };
  }

  get alive(): boolean {
    return this.state !== 'dying' || this.dieT < 2.2;
  }

  takeHit(particles: Particles): boolean {
    if (this.iframeT > 0 || this.state === 'dying' || this.state === 'enter') return false;
    this.hp--;
    this.iframeT = 0.5;
    particles.burst(this.x, this.y, '#ffd98a', 20);
    sfx.play('bossHit');
    if (this.hp <= 0) {
      this.state = 'dying';
      this.t = 0;
      this.dieT = 0;
      this.shards.length = 0;
      this.warns.length = 0;
      this.comets.length = 0;
    }
    return true;
  }

  update(dt: number, px: number, floorY: number, particles: Particles): void {
    this.t += dt;
    this.wobbleT += dt * (this.phase === 2 ? 1.7 : 1);
    this.iframeT = Math.max(0, this.iframeT - dt);

    const speedMul = this.phase === 2 ? 1.45 : 1;

    switch (this.state) {
      case 'enter':
        this.y += (BOSS_HOME_Y - this.y) * Math.min(1, dt * 2.2);
        if (this.t > 1.4) {
          this.state = 'hover';
          this.t = 0;
          sfx.play('bossRoar');
        }
        break;

      case 'hover': {
        // drift over the player, breathing
        this.x += (clamp(px, 80, VIEW.w - 80) - this.x) * Math.min(1, dt * 1.1);
        this.y = BOSS_HOME_Y + Math.sin(this.wobbleT * 2.1) * 18;
        const hoverFor = this.phase === 2 ? 0.85 : 1.35;
        if (this.t > hoverFor) {
          this.state = 'telegraph';
          this.t = 0;
          this.targetX = px;
          // attack rotation: phase 1 alternates dive/rain; phase 2 adds burst
          const cycle: Array<'dive' | 'rain' | 'burst'> =
            this.phase === 2 ? ['dive', 'rain', 'burst'] : ['dive', 'rain'];
          this.nextAttack = cycle[this.attackCount % cycle.length];
          this.attackCount++;
        }
        break;
      }

      case 'telegraph':
        // shiver + glow — every attack is announced
        this.x += Math.sin(this.t * 40) * 1.6;
        if (this.t > 0.55) {
          this.t = 0;
          if (this.nextAttack === 'dive') {
            this.state = 'dive';
          } else if (this.nextAttack === 'rain') {
            const n = this.phase === 2 ? 5 : 3;
            for (let i = 0; i < n; i++) {
              const x = clamp(this.targetX + (i - (n - 1) / 2) * 64, 30, VIEW.w - 30);
              this.warns.push({ x, t: 0.7 });
            }
            this.state = 'hover';
          } else {
            // radial comet burst (phase 2 only)
            for (let i = 0; i < 8; i++) {
              const a = (i / 8) * Math.PI * 2 + 0.4;
              this.comets.push({
                x: this.x, y: this.y,
                vx: Math.cos(a) * 225, vy: Math.sin(a) * 225,
                life: 2.4,
              });
            }
            sfx.play('bossRoar');
            this.state = 'hover';
          }
        }
        break;

      case 'dive': {
        // swoop down to the floor at the player's last position — the window
        const tx = clamp(this.targetX, 70, VIEW.w - 70);
        this.x += (tx - this.x) * Math.min(1, dt * 6);
        this.y += 620 * speedMul * dt;
        if (this.y >= floorY - 66) {
          this.y = floorY - 66;
          this.state = 'linger';
          this.t = 0;
          particles.dust(this.x, floorY, 12, 130);
        }
        break;
      }

      case 'linger':
        // grounded and vulnerable — the player's opening
        if (this.t > (this.phase === 2 ? 0.62 : 0.95)) {
          this.state = 'rise';
          this.t = 0;
        }
        break;

      case 'rise':
        this.y += (BOSS_HOME_Y - this.y) * Math.min(1, dt * 2.6);
        if (this.y < BOSS_HOME_Y + 14) {
          this.state = 'hover';
          this.t = 0;
        }
        break;

      case 'dying':
        this.dieT += dt;
        this.y += Math.sin(this.dieT * 30) * 1.2;
        if (Math.random() < dt * 18) {
          particles.burst(
            this.x + (Math.random() - 0.5) * 110,
            this.y + (Math.random() - 0.5) * 110,
            Math.random() < 0.5 ? '#ffd98a' : '#aee6ff',
            10,
          );
        }
        break;
    }

    // shard rain: warnings count down, then shards fall
    for (let i = this.warns.length - 1; i >= 0; i--) {
      const w = this.warns[i];
      w.t -= dt;
      if (w.t <= 0) {
        this.shards.push({ x: w.x, y: 60, vy: 540 });
        this.warns.splice(i, 1);
      }
    }
    for (let i = this.shards.length - 1; i >= 0; i--) {
      const s = this.shards[i];
      s.y += s.vy * dt;
      if (s.y > floorY - 6) {
        particles.burst(s.x, floorY - 8, '#aee6ff', 8);
        this.shards.splice(i, 1);
      }
    }
    for (let i = this.comets.length - 1; i >= 0; i--) {
      const c = this.comets[i];
      c.life -= dt;
      c.x += c.vx * dt;
      c.y += c.vy * dt;
      if (Math.random() < dt * 20) {
        particles.spawn({
          x: c.x, y: c.y, vx: -c.vx * 0.1, vy: -c.vy * 0.1,
          life: 0.25, size: 2.5, color: 'rgba(255,205,140,0.8)', glow: true,
        });
      }
      if (c.life <= 0 || c.x < -30 || c.x > VIEW.w + 30 || c.y < -30 || c.y > floorY + 30) {
        this.comets.splice(i, 1);
      }
    }
  }
}

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

export class Game {
  scene: Scene = 'title';
  currentRoom: RoomId = 'vestibule';
  player: Player;
  enemies: Foe[] = [];
  boss: Boss | null = null;
  bossDefeated = false;
  particles = new Particles();
  gripTaken = false;
  deaths = 0;
  playTime = 0;
  private checkpoint: { room: RoomId; x: number; y: number };
  private beaconLit = false;
  private pendingSave: SaveData | null;
  private time = 0;
  private camY = 0;
  private shake = 0;
  private fade = 1; // 1 = black, 0 = clear
  private fadeDir = -1;
  private deadT = 0;
  private winT = 0;
  private freezeT = 0; // pickup / phase-shift hitstop
  private flashT = 0; // white ceremony flash
  private doorCd = 0;
  private hintCd = 0;
  private banner: { text: string; sub: string; t: number; max: 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.checkpoint = { room: 'vestibule', ...ROOMS.vestibule.entry };
    this.player = new Player(ROOMS.vestibule.entry);
    this.enterRoom('vestibule', ROOMS.vestibule.entry, true);
  }

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

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

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

    switch (this.scene) {
      case 'title':
        if (this.input.anyKeyThisFrame) this.startPlay();
        break;
      case 'play':
        this.playTime += dt;
        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) {
          const cp = this.checkpoint;
          this.enterRoom(cp.room, { x: cp.x, y: cp.y }, true);
          this.player.respawnAt({ x: cp.x, y: cp.y });
          this.scene = 'play';
          this.fade = 1;
          this.fadeDir = -1;
        }
        break;
      case 'win':
        this.winT += dt;
        this.particles.update(dt);
        if (this.winT > 1.2 && this.input.anyKeyThisFrame) this.fullReset();
        break;
    }
    this.input.endFrame();
  }

  private startPlay(): void {
    const save = this.pendingSave;
    if (save) {
      this.gripTaken = save.ability;
      this.deaths = save.deaths;
      this.playTime = save.playTime;
      this.checkpoint = { ...save.checkpoint };
      this.beaconLit =
        save.checkpoint.room === BEACON.room && save.checkpoint.x === BEACON.x;
      this.player = new Player(save.checkpoint);
      this.player.hasGrip = save.ability;
      this.enterRoom(save.checkpoint.room, save.checkpoint, true);
    } else {
      this.gripTaken = false;
      this.deaths = 0;
      this.playTime = 0;
      this.beaconLit = false;
      this.checkpoint = { room: 'vestibule', ...ROOMS.vestibule.entry };
      this.player = new Player(ROOMS.vestibule.entry);
      this.enterRoom('vestibule', ROOMS.vestibule.entry, true);
    }
    this.scene = 'play';
    this.fade = 1;
    this.fadeDir = -1;
    this.banner = this.makeBanner(this.room.name.toUpperCase(), '');
  }

  private fullReset(): void {
    clearSave();
    this.pendingSave = null;
    this.gripTaken = false;
    this.bossDefeated = false;
    this.boss = null;
    this.deaths = 0;
    this.playTime = 0;
    this.beaconLit = false;
    this.checkpoint = { room: 'vestibule', ...ROOMS.vestibule.entry };
    this.player = new Player(ROOMS.vestibule.entry);
    this.enterRoom('vestibule', ROOMS.vestibule.entry, true);
    this.banner = null;
    this.scene = 'title';
    this.fade = 1;
    this.fadeDir = -1;
  }

  enterRoom(id: RoomId, entry: { x: number; y: number }, snapCamera = false): void {
    this.currentRoom = id;
    this.enemies = ROOMS[id].enemies.map((e) =>
      e.kind === 'scuttler' ? new Scuttler(e) : new Wisp(e),
    );
    this.boss = id === 'orrery' && !this.bossDefeated ? new Boss() : null;
    this.player.x = entry.x;
    this.player.y = entry.y;
    this.player.vx = this.player.vy = 0;
    this.particles.list.length = 0;
    this.doorCd = 0.45;
    const target = clamp(entry.y - VIEW.h * 0.58, 0, ROOMS[id].h - VIEW.h);
    if (snapCamera) this.camY = target;
  }

  private saveGame(): void {
    const d: SaveData = {
      room: this.currentRoom,
      ability: this.gripTaken,
      checkpoint: { ...this.checkpoint },
      deaths: this.deaths,
      playTime: this.playTime,
    };
    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.deaths++;
    this.fadeDir = 1;
    this.particles.burst(p.x, p.y - p.h / 2, '#ffd9a0', 26);
    this.saveGame();
    return true;
  }

  private makeBanner(text: string, sub: string, t = 2.4) {
    return { text, sub, t, max: t };
  }

  private updatePlay(dt: number): void {
    const p = this.player;
    p.update(dt, this.input, this.particles, this.room.solids, this.room.w);
    for (const e of this.enemies) e.update(dt, this.particles);
    this.boss?.update(dt, p.x, this.room.floorY, this.particles);
    this.particles.update(dt);
    this.doorCd = Math.max(0, this.doorCd - dt);
    this.hintCd = Math.max(0, this.hintCd - dt);
    if (this.banner) {
      this.banner.t -= dt;
      if (this.banner.t <= 0) this.banner = null;
    }

    // room transitions
    if (this.doorCd === 0) {
      for (const d of this.room.doors) {
        if (!overlaps(p.box, d)) continue;
        this.enterRoom(d.to, d.entry);
        this.checkpoint = { room: d.to, ...ROOMS[d.to].entry };
        this.fade = 1;
        this.fadeDir = -1;
        this.banner = this.makeBanner(this.room.name.toUpperCase(), '');
        sfx.play('door');
        this.saveGame();
        return;
      }
    }

    // the star lash vs enemies
    const atk = p.attackBox;
    if (atk) {
      for (let i = this.enemies.length - 1; i >= 0; i--) {
        const e = this.enemies[i];
        if (e.hurtCd > 0 || !overlaps(atk, e.box)) continue;
        e.hp--;
        e.hurtCd = 0.3;
        e.hitT = 0.15;
        e.x += p.attackDir * 16;
        this.particles.burst(e.x, e.y - e.h / 2, '#cfeaff', 10);
        sfx.play('hit');
        if (e.hp <= 0) {
          this.particles.burst(e.x, e.y - e.h / 2, '#aee6ff', 22);
          this.particles.ring(e.x, e.y - e.h / 2);
          sfx.play('enemyDie');
          this.enemies.splice(i, 1);
        }
      }
      // ... and vs the boss
      if (this.boss && this.boss.state !== 'dying' && overlaps(atk, this.boss.box)) {
        if (this.boss.takeHit(this.particles)) {
          this.shake = 6;
          this.freezeT = Math.max(this.freezeT, 0.05);
          if (this.boss.hp === BOSS_MAX_HP / 2 && !this.boss.roared) {
            // phase shift ceremony
            this.boss.roared = true;
            this.freezeT = 0.45;
            this.shake = 12;
            this.flashT = 0.25;
            this.banner = this.makeBanner('THE WARDEN WAKES', 'its patterns sharpen', 2.2);
            sfx.play('bossRoar');
          }
          if (this.boss.hp <= 0) {
            this.freezeT = 0.5;
            this.shake = 14;
            this.flashT = 0.4;
          }
        }
      }
    }

    // boss defeat → victory
    if (this.boss && this.boss.state === 'dying' && this.boss.dieT > 2.2) {
      this.bossDefeated = true;
      this.boss = null;
      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(VIEW.w / 2, this.camY + 300, '#ffe9b0', 44);
    }

    // enemy contact
    for (const e of this.enemies) {
      if (overlaps(p.box, e.box) && p.hurt(e.x, this.particles)) {
        this.shake = 7;
        this.checkDeath();
      }
    }

    // boss contact + projectiles
    if (this.boss && this.boss.state !== 'dying' && this.boss.state !== 'enter') {
      let fromX: number | null = null;
      if (overlaps(p.box, this.boss.box)) fromX = this.boss.x;
      for (const s of this.boss.shards) {
        const sb: Box = { x: s.x - 9, y: s.y - 13, w: 18, h: 26 };
        if (overlaps(p.box, sb)) { fromX = s.x + 20; break; }
      }
      if (fromX === null) {
        for (const c of this.boss.comets) {
          const cb: Box = { x: c.x - 8, y: c.y - 8, w: 16, h: 16 };
          if (overlaps(p.box, cb)) { fromX = c.x + 20; break; }
        }
      }
      if (fromX !== null && p.hurt(fromX, this.particles)) {
        this.shake = 8;
        this.checkDeath();
      }
    }

    // the STARGRIP pickup
    if (!this.gripTaken && this.currentRoom === PICKUP.room) {
      const bobY = PICKUP.y + Math.sin(this.time * 2.4) * 7;
      if (Math.random() < dt * 8) this.particles.sparkle(PICKUP.x, bobY, '#aee6ff');
      const reach: Box = { x: PICKUP.x - 32, y: bobY - 34, w: 64, h: 68 };
      if (overlaps(p.box, reach)) {
        this.gripTaken = true;
        p.hasGrip = true;
        this.freezeT = 0.55;
        this.flashT = 0.45;
        this.shake = 5;
        this.banner = this.makeBanner(
          'S T A R G R I P', 'slide down walls — press JUMP to kick off', 3.8,
        );
        this.particles.burst(PICKUP.x, bobY, '#aee6ff', 34);
        this.particles.ring(PICKUP.x, bobY, '#ffe9b0');
        sfx.play('pickup');
        this.saveGame();
      }
    }

    // the beacon checkpoint at the shaft top
    if (this.currentRoom === BEACON.room && !this.beaconLit) {
      const reach: Box = { x: BEACON.x - 34, y: BEACON.y - 80, w: 68, h: 84 };
      if (overlaps(p.box, reach)) {
        this.beaconLit = true;
        this.checkpoint = { room: BEACON.room, x: BEACON.x, y: BEACON.y };
        this.banner = this.makeBanner('BEACON LIT', 'you will return here', 2.6);
        this.particles.ring(BEACON.x, BEACON.y - 60, '#ffe9b0');
        sfx.play('checkpoint');
        this.saveGame();
      }
    }

    // gate telegraphs: the chimney shimmers while the grip is missing
    if (this.currentRoom === 'vestibule' && !p.hasGrip) {
      if (Math.random() < dt * 14) {
        this.particles.sparkle(86 + Math.random() * 164, 400 + Math.random() * 420, '#8fd8f0');
      }
      // touching the smooth chimney walls without the grip → a gentle hint
      if (this.hintCd === 0 && !p.onGround && p.y < 870) {
        const nearLeft = p.x < 86 + p.w;
        const nearRight = p.x > 250 - p.w && p.y < 850;
        if (nearLeft || nearRight) {
          this.banner = this.makeBanner('', 'too smooth to climb — there must be another way', 2.2);
          this.hintCd = 9;
        }
      }
    }

    // ambient snow motes
    if (Math.random() < dt * 5) {
      this.particles.spawn({
        x: Math.random() * VIEW.w,
        y: this.camY - 8,
        vx: 8 - Math.random() * 16,
        vy: 26 + Math.random() * 22,
        life: 4 + Math.random() * 3,
        size: 1 + Math.random() * 1.6,
        color: 'rgba(210,228,245,0.5)',
      });
    }

    // vertical camera
    const target = clamp(p.y - VIEW.h * 0.58, 0, this.room.h - VIEW.h);
    this.camY += (target - this.camY) * 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);
    }
    if (this.flashT > 0) {
      ctx.fillStyle = `rgba(240,250,255,${Math.min(0.85, this.flashT * 2)})`;
      ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    }
    if (this.fade > 0.003) {
      ctx.fillStyle = `rgba(4,6,12,${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;

    // vertical-parallax background plate
    const bg = this.assets[this.room.bg].canvas;
    let sh = bg.height * 0.82;
    let sw = sh * (VIEW.w / VIEW.h);
    if (sw > bg.width) {
      sw = bg.width;
      sh = sw * (VIEW.h / VIEW.w);
    }
    const range = Math.max(1, this.room.h - VIEW.h);
    const sy = (this.camY / range) * (bg.height - sh);
    const sx = (bg.width - sw) / 2;
    ctx.drawImage(bg, sx, sy, sw, sh, 0, 0, VIEW.w, VIEW.h);

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

    this.drawFog(ctx, 0.05, 0.6);
    ctx.drawImage(this.worldFor(this.currentRoom), 0, 0);
    this.drawDoors(ctx);
    if (this.currentRoom === 'shaft') this.drawBeacon(ctx);
    this.drawPickup(ctx);
    for (const e of this.enemies) {
      if (e.kind === 'scuttler') this.drawScuttler(ctx, e);
      else this.drawWisp(ctx, e);
    }
    if (this.boss) this.drawBoss(ctx, this.boss);
    this.drawPlayer(ctx);
    this.particles.draw(ctx);
    this.drawFog(ctx, 0.07, 1.3);

    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; lean into a wall slide
    let sxs = 1;
    let sys = 1;
    let rot = 0;
    if (p.squash > 0) {
      const k = p.squash / 0.14;
      sxs = 1 + 0.18 * k;
      sys = 1 - 0.22 * k;
    } else if (p.wallSliding !== 0) {
      rot = p.wallSliding * 0.08;
    } else if (!p.onGround) {
      const k = clamp(p.vy / 700, -1, 1);
      sys = 1 + 0.1 * Math.abs(k);
      sxs = 1 - 0.06 * Math.abs(k);
    }
    const bob = p.onGround && moving ? Math.abs(Math.sin(this.time * 11)) * 3 : 0;
    const flip = p.wallSliding !== 0 ? p.wallSliding > 0 : p.facing < 0;
    drawSprite(ctx, sprite, p.x, p.y + 1 - bob, 56 * sys, flip, sxs, rot);

    // the star lash arc
    if (p.attackT > 0) {
      const k = 1 - p.attackT / ATTACK_TIME; // 0→1 over the swing
      const cx = p.x + p.attackDir * 16;
      const cy = p.y - p.h * 0.55;
      ctx.save();
      ctx.translate(cx, cy);
      ctx.scale(p.attackDir, 1);
      ctx.strokeStyle = `rgba(220,242,255,${0.9 - k * 0.7})`;
      ctx.lineWidth = 5 - k * 3;
      ctx.shadowBlur = 14;
      ctx.shadowColor = '#aee6ff';
      ctx.beginPath();
      ctx.arc(0, 0, 30 + k * 26, -1.25 + k * 0.5, 0.95 + k * 0.5);
      ctx.stroke();
      ctx.restore();
    }

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

  private drawScuttler(ctx: CanvasRenderingContext2D, e: Scuttler): void {
    const bob = Math.abs(Math.sin(e.bobT * 6)) * 2.5;
    const lean = Math.sin(e.bobT * 6) * 0.05;
    if (e.hitT > 0 && Math.floor(this.time * 30) % 2 === 0) return;
    // source art faces LEFT — flip when walking right
    drawSprite(ctx, this.assets.scuttler.canvas, e.x, e.y + 1, 38 + bob * 0.4, e.dir > 0, 1, lean);
    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.beginPath();
    ctx.ellipse(e.x, e.y + 4, 18, 3.5, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  private drawWisp(ctx: CanvasRenderingContext2D, e: Wisp): void {
    if (e.hitT > 0 && Math.floor(this.time * 30) % 2 === 0) return;
    const sway = Math.sin(e.bobT * 1.9) * 0.08;
    const g = ctx.createRadialGradient(e.x, e.y - e.h / 2, 4, e.x, e.y - e.h / 2, 34);
    g.addColorStop(0, 'rgba(160,225,255,0.3)');
    g.addColorStop(1, 'rgba(160,225,255,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(e.x, e.y - e.h / 2, 34, 0, Math.PI * 2);
    ctx.fill();
    drawSprite(ctx, this.assets.wisp.canvas, e.x, e.y, 42, e.dir > 0, 1, sway);
  }

  private drawBoss(ctx: CanvasRenderingContext2D, b: Boss): void {
    // shard warnings: pale columns where the rain will fall
    for (const w of b.warns) {
      const a = 0.16 + Math.sin(this.time * 18) * 0.07;
      const g = ctx.createLinearGradient(0, 0, 0, this.room.floorY);
      g.addColorStop(0, rgba(174, 230, 255, a));
      g.addColorStop(1, rgba(174, 230, 255, a * 0.3));
      ctx.fillStyle = g;
      ctx.fillRect(w.x - 13, 0, 26, this.room.floorY);
    }
    // falling shards
    for (const s of b.shards) {
      ctx.save();
      ctx.translate(s.x, s.y);
      ctx.fillStyle = '#dff4ff';
      ctx.shadowBlur = 12;
      ctx.shadowColor = '#aee6ff';
      ctx.beginPath();
      ctx.moveTo(0, -14);
      ctx.lineTo(8, 0);
      ctx.lineTo(0, 14);
      ctx.lineTo(-8, 0);
      ctx.closePath();
      ctx.fill();
      ctx.restore();
    }
    // comets
    for (const c of b.comets) {
      ctx.save();
      ctx.fillStyle = '#ffd98a';
      ctx.shadowBlur = 12;
      ctx.shadowColor = '#ffb84d';
      ctx.beginPath();
      ctx.arc(c.x, c.y, 7, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    }

    if (b.iframeT > 0 && Math.floor(this.time * 24) % 2 === 0) return;

    const phase2 = b.phase === 2;
    // menace glow behind the warden
    const glowR = 120 + Math.sin(this.time * 2.2) * 10;
    const g = ctx.createRadialGradient(b.x, b.y, 20, b.x, b.y, glowR);
    if (phase2) {
      g.addColorStop(0, 'rgba(255,150,90,0.3)');
      g.addColorStop(1, 'rgba(255,150,90,0)');
    } else {
      g.addColorStop(0, 'rgba(150,210,255,0.22)');
      g.addColorStop(1, 'rgba(150,210,255,0)');
    }
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(b.x, b.y, glowR, 0, Math.PI * 2);
    ctx.fill();

    // telegraph ring — "something is coming"
    if (b.state === 'telegraph') {
      const k = clamp(b.t / 0.55, 0, 1);
      ctx.strokeStyle = rgba(255, 220, 150, 0.9 - k * 0.5);
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(b.x, b.y, 70 + k * 50, 0, Math.PI * 2);
      ctx.stroke();
    }

    const rot = Math.sin(b.wobbleT * 1.6) * (phase2 ? 0.12 : 0.06);
    const dieK = b.state === 'dying' ? 1 - clamp(b.dieT / 2.2, 0, 0.5) : 1;
    drawSpriteCentered(ctx, this.assets.boss.canvas, b.x, b.y, 215 * dieK, false, rot);

    // procedural orbit ring accent (phase-tinted)
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.rotate(this.time * (phase2 ? 1.4 : 0.7));
    ctx.strokeStyle = phase2 ? 'rgba(255,170,110,0.5)' : 'rgba(160,220,255,0.4)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.ellipse(0, 0, 96, 30, 0.4, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();
  }

  private drawPickup(ctx: CanvasRenderingContext2D): void {
    if (this.gripTaken || this.currentRoom !== PICKUP.room) return;
    const bobY = PICKUP.y + Math.sin(this.time * 2.4) * 7;
    const pulse = 1 + Math.sin(this.time * 3.5) * 0.07;
    ctx.save();
    ctx.shadowBlur = 26;
    ctx.shadowColor = '#aee6ff';
    drawSprite(ctx, this.assets.pickup.canvas, PICKUP.x, bobY + 24, 48 * pulse, false);
    ctx.restore();
    const g = ctx.createRadialGradient(PICKUP.x, bobY, 4, PICKUP.x, bobY, 48);
    g.addColorStop(0, 'rgba(160,230,255,0.25)');
    g.addColorStop(1, 'rgba(160,230,255,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(PICKUP.x, bobY, 48, 0, Math.PI * 2);
    ctx.fill();
  }

  /** door glows: brass-framed archways; the gated one shimmers hardest */
  private drawDoors(ctx: CanvasRenderingContext2D): void {
    for (const d of this.room.doors) {
      const isGate = this.currentRoom === 'vestibule' && d.to === 'shaft';
      const isHatch = this.currentRoom === 'shaft' && d.to === 'orrery';
      const pulse = 0.35 + Math.sin(this.time * 2.4 + d.y) * 0.15;
      const cx = d.x + d.w / 2;
      const cy = d.y + d.h / 2;
      ctx.save();
      if (isHatch) {
        // glowing circular hatch in the dome
        const g = ctx.createRadialGradient(cx, cy, 4, cx, cy, 56);
        g.addColorStop(0, rgba(255, 233, 176, 0.7 * pulse + 0.25));
        g.addColorStop(1, rgba(255, 233, 176, 0));
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(cx, cy, 56, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = rgba(220, 190, 120, 0.8);
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.ellipse(cx, cy, 46, 18, 0, 0, Math.PI * 2);
        ctx.stroke();
      } else {
        const grad = ctx.createLinearGradient(d.x, 0, d.x + d.w + 26, 0);
        const inward = d.x < 50; // glow bleeds into the room
        const [r, gg, bb] = isGate ? [174, 230, 255] : [255, 224, 160];
        grad.addColorStop(inward ? 1 : 0, rgba(r, gg, bb, 0));
        grad.addColorStop(inward ? 0 : 1, rgba(r, gg, bb, (isGate ? 0.5 : 0.32) * (pulse + 0.4)));
        ctx.fillStyle = grad;
        ctx.fillRect(d.x - (inward ? 0 : 26), d.y, d.w + 26, d.h);
        if (isGate && !this.player.hasGrip && Math.random() < 0.25) {
          this.particles.sparkle(d.x + d.w + 8, d.y + Math.random() * d.h, '#aee6ff');
        }
      }
      ctx.restore();
    }
  }

  private drawBeacon(ctx: CanvasRenderingContext2D): void {
    const bx = BEACON.x;
    const by = BEACON.y;
    const lit = this.beaconLit;
    ctx.save();
    // brass pole
    ctx.fillStyle = '#7a6234';
    ctx.fillRect(bx - 3, by - 54, 6, 54);
    ctx.fillStyle = '#9a7c44';
    ctx.fillRect(bx - 10, by - 6, 20, 6);
    // the star orb
    const flicker = lit ? 0.85 + Math.sin(this.time * 6) * 0.15 : 0.25;
    const g = ctx.createRadialGradient(bx, by - 64, 2, bx, by - 64, lit ? 36 : 18);
    g.addColorStop(0, rgba(255, 240, 200, flicker));
    g.addColorStop(1, rgba(255, 220, 150, 0));
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(bx, by - 64, lit ? 36 : 18, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = lit ? '#ffeec0' : '#5a523e';
    ctx.beginPath();
    ctx.arc(bx, by - 64, 7, 0, Math.PI * 2);
    ctx.fill();
    if (lit && Math.random() < 0.15) this.particles.sparkle(bx, by - 64, '#ffe9b0');
    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 fy = ((this.time * 12 * speed + i * 560) % (this.room.h + 500)) - 250;
      const fx = VIEW.w / 2 + Math.sin(this.time * 0.4 + i * 2.1) * 130;
      const g = ctx.createRadialGradient(fx, fy, 20, fx, fy, 220);
      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, 220, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

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

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

    // hearts (below the minimap strip)
    for (let i = 0; i < 3; i++) {
      drawHeart(ctx, 26 + i * 30, 64, 9, i < this.player.hearts);
    }

    // boss HP bar
    if (this.boss && this.currentRoom === 'orrery' && this.boss.state !== 'enter') {
      const total = BOSS_MAX_HP;
      const bw = 230;
      const x0 = (VIEW.w - bw) / 2;
      const y0 = 88;
      ctx.save();
      ctx.fillStyle = 'rgba(8,12,20,0.6)';
      ctx.beginPath();
      ctx.roundRect(x0 - 6, y0 - 6, bw + 12, 22, 6);
      ctx.fill();
      for (let i = 0; i < total; i++) {
        const sx = x0 + (i * bw) / total;
        ctx.fillStyle = i < this.boss.hp
          ? (this.boss.phase === 2 ? '#ff9a5a' : '#ffd98a')
          : 'rgba(120,110,90,0.3)';
        ctx.fillRect(sx + 1, y0, bw / total - 2, 10);
      }
      ctx.font = '600 9px Georgia, serif';
      ctx.fillStyle = 'rgba(255,225,180,0.85)';
      ctx.textAlign = 'center';
      ctx.fillText('THE ORRERY WARDEN', VIEW.w / 2, y0 - 10);
      ctx.restore();
    }

    // banner
    if (this.banner) {
      const a = Math.min(1, this.banner.t, (this.banner.max - this.banner.t) * 3);
      ctx.save();
      ctx.globalAlpha = Math.max(0, a);
      ctx.textAlign = 'center';
      if (this.banner.text) {
        ctx.fillStyle = '#dceeff';
        ctx.shadowBlur = 18;
        ctx.shadowColor = '#8fd4f0';
        ctx.font = '700 26px Georgia, serif';
        ctx.fillText(this.banner.text, VIEW.w / 2, 170);
      }
      ctx.shadowBlur = 0;
      ctx.font = '13px Georgia, serif';
      ctx.fillStyle = 'rgba(225,240,250,0.95)';
      ctx.fillText(this.banner.sub, VIEW.w / 2, this.banner.text ? 196 : 180);
      ctx.restore();
    }
  }

  /** four room cells, current lit, ability dot in the archive, boss mark last */
  private drawMinimap(ctx: CanvasRenderingContext2D): void {
    const tw = 34;
    const th = 20;
    const gap = 6;
    ctx.save();
    ROOM_ORDER.forEach((id, i) => {
      const x = 16 + i * (tw + gap);
      const y = 16;
      const current = id === this.currentRoom;
      ctx.fillStyle = current ? 'rgba(46,72,100,0.8)' : 'rgba(10,16,26,0.6)';
      ctx.strokeStyle = current ? 'rgba(174,230,255,0.95)' : 'rgba(120,140,160,0.4)';
      ctx.lineWidth = current ? 2 : 1.2;
      ctx.beginPath();
      ctx.roundRect(x, y, tw, th, 4);
      ctx.fill();
      ctx.stroke();
      if (id === PICKUP.room) {
        // the ability dot — hollow until claimed
        ctx.beginPath();
        ctx.arc(x + tw - 8, y + th - 7, 3, 0, Math.PI * 2);
        if (this.gripTaken) {
          ctx.fillStyle = '#aee6ff';
          ctx.fill();
        } else {
          ctx.strokeStyle = 'rgba(174,230,255,0.7)';
          ctx.lineWidth = 1;
          ctx.stroke();
        }
      }
      if (id === 'orrery') {
        // boss diamond
        ctx.fillStyle = this.bossDefeated ? 'rgba(140,150,160,0.5)' : '#ffb86a';
        ctx.save();
        ctx.translate(x + tw - 8, y + th - 7);
        ctx.rotate(Math.PI / 4);
        ctx.fillRect(-2.6, -2.6, 5.2, 5.2);
        ctx.restore();
      }
      // player blip (vertical position within the room)
      if (current && this.scene !== 'title') {
        const py = y + 3 + (this.player.y / this.room.h) * (th - 6);
        ctx.fillStyle = '#ffffff';
        ctx.beginPath();
        ctx.arc(x + 7, py, 2, 0, Math.PI * 2);
        ctx.fill();
      }
    });
    ctx.restore();
  }

  private drawWinOverlay(ctx: CanvasRenderingContext2D): void {
    const a = Math.min(0.66, this.winT * 0.6);
    ctx.fillStyle = `rgba(5,8,16,${a})`;
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    ctx.save();
    ctx.textAlign = 'center';
    ctx.fillStyle = '#ffeec8';
    ctx.shadowBlur = 26;
    ctx.shadowColor = '#ffd98a';
    ctx.font = '700 34px Georgia, serif';
    ctx.fillText('THE STAR IS HOME', VIEW.w / 2, 290);
    ctx.shadowBlur = 0;
    ctx.font = 'italic 15px Georgia, serif';
    ctx.fillStyle = 'rgba(235,228,210,0.92)';
    ctx.fillText('aphelion turns toward the sun once more', VIEW.w / 2, 322);

    // the little stats recap
    const mm = Math.floor(this.playTime / 60);
    const ss = Math.floor(this.playTime % 60).toString().padStart(2, '0');
    ctx.font = '15px Georgia, serif';
    ctx.fillStyle = 'rgba(210,225,240,0.92)';
    ctx.fillText(`time  ${mm}:${ss}`, VIEW.w / 2, 386);
    ctx.fillText(`deaths  ${this.deaths}`, VIEW.w / 2, 412);

    ctx.font = '13px Georgia, serif';
    ctx.fillStyle = `rgba(200,215,225,${0.5 + Math.sin(this.time * 3) * 0.3})`;
    ctx.fillText('tap or press any key', VIEW.w / 2, 480);
    ctx.restore();
  }

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

  private drawTitle(ctx: CanvasRenderingContext2D): void {
    const bg = this.assets.bgVestibule.canvas;
    const drift = Math.sin(this.time * 0.16) * 20;
    let sh = bg.height * 0.7;
    let sw = sh * (VIEW.w / VIEW.h);
    if (sw > bg.width) { sw = bg.width; sh = sw * (VIEW.h / VIEW.w); }
    ctx.drawImage(bg, (bg.width - sw) / 2, bg.height * 0.12 + drift, sw, sh, 0, 0, VIEW.w, VIEW.h);
    ctx.fillStyle = 'rgba(5,8,16,0.5)';
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);

    // hero on a slab beneath the title
    const heroY = 560 + Math.sin(this.time * 1.6) * 5;
    ctx.save();
    ctx.shadowBlur = 22;
    ctx.shadowColor = 'rgba(150,205,240,0.5)';
    drawSprite(ctx, this.assets.playerIdle.canvas, VIEW.w / 2, heroY, 84, false);
    ctx.restore();
    drawSprite(ctx, this.assets.platform.canvas, VIEW.w / 2, heroY + 36, 30, false);
    if (Math.random() < 0.12) {
      this.particles.sparkle(VIEW.w / 2 + (Math.random() - 0.5) * 130, heroY - 70);
    }
    this.particles.update(1 / 60);
    this.particles.draw(ctx);

    ctx.textAlign = 'center';
    ctx.fillStyle = '#e4f0fb';
    ctx.shadowBlur = 26;
    ctx.shadowColor = '#6fb6e8';
    ctx.font = '700 52px Georgia, serif';
    ctx.fillText('APHELION', VIEW.w / 2, 240);
    ctx.shadowBlur = 0;
    ctx.font = 'italic 16px Georgia, serif';
    ctx.fillStyle = 'rgba(205,225,240,0.92)';
    ctx.fillText('the frozen observatory', VIEW.w / 2, 272);

    if (this.pendingSave) {
      ctx.font = '13px Georgia, serif';
      ctx.fillStyle = 'rgba(255,225,180,0.85)';
      ctx.fillText(
        `expedition in progress — ${ROOMS[this.pendingSave.room].name}`,
        VIEW.w / 2, 304,
      );
    }

    ctx.font = '13px Georgia, serif';
    ctx.fillStyle = 'rgba(190,208,220,0.85)';
    ctx.fillText('A D move · SPACE jump · J attack', VIEW.w / 2, 688);
    ctx.fillText('or use the touch pads', VIEW.w / 2, 710);
    ctx.fillStyle = `rgba(228,240,250,${0.45 + Math.sin(this.time * 3) * 0.35})`;
    ctx.font = '600 15px Georgia, serif';
    ctx.fillText(
      this.pendingSave ? 'tap or press any key to continue' : 'tap or press any key to begin',
      VIEW.w / 2, 754,
    );

    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 12px Georgia, serif';
      ctx.textAlign = 'center';
      ctx.fillText('RESET SAVE', RESET_BTN.x + RESET_BTN.w / 2, RESET_BTN.y + 20);
      ctx.restore();
    }
  }

  /** canvas-space tap — title screen's RESET SAVE button swallows the tap */
  handleTap(x: number, y: number): boolean {
    sfx.unlock();
    if (this.scene !== 'title' || !this.pendingSave) return false;
    if (
      x < RESET_BTN.x || x > RESET_BTN.x + RESET_BTN.w ||
      y < RESET_BTN.y || y > RESET_BTN.y + RESET_BTN.h
    ) return false;
    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('hit');
    return true;
  }

  // ------------------------------------------------------------- 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;
  }

  /** paint a room's terrain once: overlap + mirror defeats visible tiling */
  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 wall = this.assets.groundWall.canvas;
    const platform = this.assets.platform.canvas;

    for (const s of room.solids) {
      if (s.type === 'oneway') {
        // one-way ledges: stretched slabs with a little bleed
        ctx.drawImage(platform, s.x - 10, s.y - 5, s.w + 20, 38);
        continue;
      }
      if (s.tile === 'platform') {
        ctx.drawImage(platform, s.x - 10, s.y - 5, s.w + 20, Math.max(38, s.h + 14));
        continue;
      }
      if (s.h > s.w) {
        // wall / pillar: stacked masonry-body chunks, mirrored alternately
        const tileW = s.w + 16;
        const tileH = tileW * (wall.height / wall.width);
        ctx.save();
        ctx.beginPath();
        ctx.rect(s.x - 4, s.y - 4, s.w + 8, s.h + 8);
        ctx.clip();
        for (let y = s.y - 6, i = 0; y < s.y + s.h; y += tileH - 12, i++) {
          ctx.save();
          if (i % 2 === 1) {
            ctx.translate(0, y + tileH / 2);
            ctx.scale(1, -1);
            ctx.translate(0, -(y + tileH / 2));
          }
          ctx.drawImage(wall, s.x - 8, y, tileW, tileH);
          ctx.restore();
        }
        ctx.restore();
        // cap the wall top with a railed walkway slab (it is a landable ledge)
        if (s.y > 0) ctx.drawImage(platform, s.x - 6, s.y - 7, s.w + 12, 36);
      } else {
        // floor shelf: repeated painted chunks, mirrored alternately, clipped
        ctx.save();
        ctx.beginPath();
        ctx.rect(s.x - 2, s.y - 7, s.w + 4, room.h - s.y + 7);
        ctx.clip();
        const tileH = Math.max(s.h + 26, 120);
        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();
      }
    }

    // grounding shadow under the main floor line
    const g = ctx.createLinearGradient(0, room.floorY, 0, room.h);
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(1, 'rgba(0,0,0,0.5)');
    ctx.fillStyle = g;
    ctx.fillRect(0, room.floorY, room.w, room.h - room.floorY);
    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.38,
      VIEW.w / 2, VIEW.h / 2, VIEW.h * 0.82,
    );
    g.addColorStop(0, 'rgba(0,0,0,0)');
    g.addColorStop(1, 'rgba(3,6,12,0.6)');
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, VIEW.w, VIEW.h);
    // cold starlight from above
    const t = ctx.createLinearGradient(0, 0, 0, 150);
    t.addColorStop(0, 'rgba(130,180,220,0.10)');
    t.addColorStop(1, 'rgba(130,180,220,0)');
    ctx.fillStyle = t;
    ctx.fillRect(0, 0, VIEW.w, 150);
    return c;
  }

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

  /** machine-readable state for the e2e suite */
  debugState() {
    return {
      phase: this.scene,
      room: this.currentRoom,
      hasAbility: this.gripTaken,
      hp: this.player.hearts,
      bossHp: this.boss ? this.boss.hp : (this.bossDefeated ? 0 : null),
      bossPhase: this.boss?.phase ?? null,
      bossState: this.boss?.state ?? null,
      bossY: this.boss?.y ?? null,
      bossX: this.boss?.x ?? null,
      player: {
        x: this.player.x,
        y: this.player.y,
        vx: this.player.vx,
        vy: this.player.vy,
        onGround: this.player.onGround,
        wallSliding: this.player.wallSliding,
      },
      checkpoint: { ...this.checkpoint },
      beaconLit: this.beaconLit,
      deaths: this.deaths,
      playTime: this.playTime,
      enemies: this.enemies.map((e) => ({ kind: e.kind, x: e.x, y: e.y, hp: e.hp })),
      pickup: { ...PICKUP, taken: this.gripTaken },
      fallbackSprites: this.assets.fallbacks,
    };
  }

  /** jump straight to a room (e2e + debugging). Optional explicit feet pos. */
  debugTeleport(room: RoomId, x?: number, y?: number): void {
    if (!ROOMS[room]) return;
    if (this.scene === 'title') this.startPlay();
    const entry = { x: x ?? ROOMS[room].entry.x, y: y ?? ROOMS[room].entry.y };
    this.player.hasGrip = this.gripTaken;
    this.enterRoom(room, entry, true);
    this.checkpoint = { room, ...ROOMS[room].entry };
    this.scene = 'play';
    this.fade = 0.6;
    this.fadeDir = -1;
  }
}

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

/** 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();
}

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

function drawHeart(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, filled: boolean): void {
  ctx.save();
  ctx.translate(x, y);
  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();
}
