// === HALVSIES QUEST — GAME (site-integrated build) ====================
// State machine, input handling, Web Audio synth, finale, credits, merch unlock.
// Modifications from the design-sandbox version:
//   - Renamed Game → HalvsiesQuest; accepts onFightStart/onFightEnd/onFinaleStart props
//   - makeInitialState starts in "intro" mode (conscience dialog pre-loaded, house entered)
//   - Title screen removed (site navigation replaces it)
//   - ReactDOM.createRoot removed (component is mounted by the main site script)
//   - Audio callbacks wired via _questCallbacks module ref

const { useRef, useEffect, useState } = React;

const {
  C, TILE, MAP_W, MAP_H, UI_H, W, H, PLAY_TOP,
  MOVE_MS, ATTACK_MS, INVULN_MS, ENEMY_MOVE_MIN, ENEMY_MOVE_MAX,
  NPC_MOVE_MIN, NPC_MOVE_MAX,
  INTRO_TEXT_MS, INTRO_FADE_MS, TRANSITION_OUT, TRANSITION_IN,
  DIRS, DLG_LINE_CHARS, DLG_LINES_PER_PAGE,
  T, SCREENS, isSolidTile, isCounter,
  paginate,
  drawMap, drawTopBar, drawPlayer, drawAttack,
  drawNpc, drawMonster, drawBat, drawChest, drawGlowingTape, drawPurse, drawBlock,
  drawSpeakerStack, drawCelebrationItem,
  drawDialogBox, drawScreenLabelBanner,
  drawIntroFadeIn, drawTransitionOverlay,
  drawGameOver,
  drawBard, drawGuitarHud, drawFloatingNote, drawCredits, drawMerchUnlocked,
} = window;

const easeOut = t => 1 - (1 - t) * (1 - t);

// =====================================================================
// PRE-INTRO CINEMATIC — two fading title cards on solid black before
// the conscience dialogue and house layout appear.
// =====================================================================
const PRE_INTRO = [
  { text: "wake up... please...", fadeIn: 1500, hold: 900, fadeOut: 900 },
  { text: "WAKE UP YOU LAZY @$$!!", fadeIn: 700, hold: 900, fadeOut: 700 },
];

function drawPreIntro(ctx, phase, t) {
  ctx.fillStyle = "#000000";
  ctx.fillRect(0, 0, W, H);

  const card = PRE_INTRO[phase];
  if (!card) return;

  const { text, fadeIn, hold, fadeOut } = card;
  let alpha;
  if      (t < fadeIn)                alpha = t / fadeIn;
  else if (t < fadeIn + hold)         alpha = 1;
  else                                alpha = Math.max(0, 1 - (t - fadeIn - hold) / fadeOut);

  ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
  const tw = textWidth(text);
  const x  = Math.floor((W - tw) / 2);
  const y  = Math.floor(H / 2) - 4;
  drawText(ctx, text, x, y, C.lightest);
  ctx.globalAlpha = 1;
}

// =====================================================================
// AUDIO SYNTH — 8-bit square wave, C major scale
// =====================================================================
const NOTE_HZ = {
  "C": 261.63, "D": 293.66, "E": 329.63, "F": 349.23,
  "G": 392.00, "A": 440.00, "B": 493.88,
};

class Synth {
  constructor() { this.ctx = null; this.lastNote = null; this.lastNoteAt = 0; }
  ensure() {
    if (this.ctx) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (AC) this.ctx = new AC();
  }
  resume() { if (this.ctx && this.ctx.state === "suspended") this.ctx.resume(); }
  play(note, durMs = 320) {
    this.ensure();
    if (!this.ctx) return;
    this.resume();
    const ctx = this.ctx;
    const now = ctx.currentTime;
    const freq = NOTE_HZ[note] || 440;
    const osc = ctx.createOscillator();
    osc.type = "square";
    osc.frequency.setValueAtTime(freq, now);
    osc.detune.setValueAtTime(0, now);
    osc.detune.linearRampToValueAtTime(6, now + durMs * 0.001);
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.18, now + 0.01);
    gain.gain.exponentialRampToValueAtTime(0.06, now + 0.1);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + durMs * 0.001);
    const hp = ctx.createBiquadFilter();
    hp.type = "highpass"; hp.frequency.value = 80;
    osc.connect(hp); hp.connect(gain); gain.connect(ctx.destination);
    osc.start(now); osc.stop(now + durMs * 0.001 + 0.05);
    this.lastNote = note;
    this.lastNoteAt = performance.now();
  }
  blip(freq = 600, dur = 0.05) {
    this.ensure();
    if (!this.ctx) return;
    this.resume();
    const ctx = this.ctx;
    const now = ctx.currentTime;
    const osc = ctx.createOscillator();
    osc.type = "square";
    osc.frequency.setValueAtTime(freq, now);
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.12, now + 0.005);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
    osc.connect(gain).connect(ctx.destination);
    osc.start(now); osc.stop(now + dur + 0.02);
  }
}
const synth = new Synth();

// Single-button note map per spec
function noteFromKey(k) {
  if (k === "arrowup"    || k === "w") return "C";
  if (k === "arrowdown"  || k === "s") return "D";
  if (k === "arrowleft"  || k === "a") return "E";
  if (k === "arrowright" || k === "d") return "F";
  if (k === "z" || k === "j" || k === " ") return "G";
  if (k === "enter") return "A";
  if (k === "shift") return "B";
  return null;
}

// =====================================================================
// CONSCIENCE INTRO TEXT
// =====================================================================
const CONSCIENCE_OPENING =
  "Ahh, bard! Good, you're awake! I am your Conscience. " +
  "You have been pretty lazy lately, and I think it's time " +
  "you did something with yourself. You say you want to be " +
  "a famous musician, so you should get out there and try " +
  "to impress people with your music! What's that? You don't " +
  "have a guitar anymore? Well, according to the ancient " +
  "legend passed down in this village, a cheap squire starter " +
  "guitar was left by a junkie in the cave just south of town!";

const CONSCIENCE_BATS =
  "oh no, the junkie must have booby trapped the cave! " +
  "Quick, use your shitty guitar to dispatch these flying freaks!";

const CONSCIENCE_TAPE_QUEST =
  "great, you have a guitar! Buuut you probably aren't going " +
  "to make any fans with your crappy playing. Another legend " +
  "says there's some super chill monsters that guard a " +
  "legendary backing track tape, guaranteed to make anyone " +
  "tolerate your music. Quickly, bard! Go find the tape " +
  "before your ADHD brain gets distracted by something else " +
  "and you give up on your dream of being a musician forever!";

const TAPE_FOUND =
  "You found the legendary backing track tape! With its " +
  "divine power, it will make anything you play sway the " +
  "hearts of anyone who hears you! (Mainly because the " +
  "backing tracks will drown out your god awful playing.) " +
  "Quick! Head to the stage north of town and put on a show!";

// =====================================================================
// AUDIO CALLBACKS — module-level ref updated by the component
// =====================================================================
let _questCallbacks = {};

// =====================================================================
// STATE
// =====================================================================
function makeInitialState() {
  const s = {
    mode: "preIntro",
    t: 0,
    introT: 0,
    preIntroPhase: 0,
    preIntroT: 0,

    screen: "house_interior",
    player: {
      tx: 1, ty: 1, fromX: 1, fromY: 1, x: 1, y: 1,
      dir: "down", moving: false, moveT: 0, step: 0,
    },

    // Quest flags
    hasMoney: false,
    hasGlizzy: false,
    southUnlocked: false,
    puzzleSolved: false,
    chestOpened: false,
    batsDefeated: false,
    hasGuitar: false,
    sleepingManAwake: false,
    hasTape: false,
    guitarModeActive: false,
    bannedFromStore: false,
    fieldMonstersAlive: [true, true, true, true],
    fieldCleared: false,
    tapeDropped: false,
    questComplete: false,
    merchUnlocked: false,
    notesPlayedNearMan: 0,
    justKickedOutOfShop: false,

    hearts: 3,
    invuln: 0,
    attack: 0,
    flash: 0,

    activeMonsters: [],
    activeNpcs: [],
    activeItems: [],
    activeBlock: null,
    floatingNotes: [],

    lastNote: null,
    lastNoteTimer: 0,

    finalePhase: 0,
    finaleT: 0,
    finaleCrowd: [],

    dialog: null,
    dialogPages: null,
    dialogPage: 0,
    transition: null,
    bannerT: 9999,

    menuChoice: 0,
  };
  // Enter house interior and pre-load conscience opening dialog.
  // The player passes through preIntro → intro → introFade before play begins.
  enterScreen(s, "house_interior", 1, 1, "down");
  s.mode = "preIntro";
  s.dialogPages = paginate(CONSCIENCE_OPENING, DLG_LINE_CHARS, DLG_LINES_PER_PAGE);
  s.dialogPage = 0;
  s.dialog = { text: CONSCIENCE_OPENING,
    onClose: (st) => { st.mode = "introFade"; st.introT = 0; } };
  return s;
}

function makeMonster(tx, ty, delay, id, kind = "slime") {
  return {
    id, kind, tx, ty, fromX: tx, fromY: ty, x: tx, y: ty,
    dir: "down", moving: false, moveT: 0,
    alive: true, nextMove: delay,
    wobble: Math.random() * Math.PI * 2,
  };
}

function instantiateNpc(cfg) {
  return {
    id: cfg.id,
    kind: cfg.kind || "villager",
    variant: cfg.variant || 1,
    tx: cfg.tx, ty: cfg.ty,
    fromX: cfg.tx, fromY: cfg.ty,
    x: cfg.tx, y: cfg.ty,
    dir: cfg.dir || "down",
    moving: false, moveT: 0,
    nextMove: 500 + Math.random() * 800,
    wandering: !!cfg.wandering,
    line: cfg.line,
    kickout: !!cfg.kickout,
    unlocksSouth: !!cfg.unlocksSouth,
    sleeper: !!cfg.sleeper,
    wide: !!cfg.wide,
    storming: false,
  };
}

function npcOccupies(n, x, y) {
  if (n.tx === x && n.ty === y) return true;
  if (n.wide && n.tx + 1 === x && n.ty === y) return true;
  return false;
}

// ---- Screen entry ---------------------------------------------------
function enterScreen(s, name, tx, ty, dir) {
  s.screen = name;
  const p = s.player;
  p.tx = tx; p.ty = ty;
  p.fromX = tx; p.fromY = ty;
  p.x = tx; p.y = ty;
  if (dir) p.dir = dir;
  p.moving = false; p.moveT = 0;

  // Monsters / bats
  s.activeMonsters = [];
  if (name === "monster_field") {
    const spawns = SCREENS.monster_field.monsterSpawns;
    for (let i = 0; i < spawns.length; i++) {
      if (s.fieldMonstersAlive[i]) {
        s.activeMonsters.push(makeMonster(spawns[i].tx, spawns[i].ty, 400 + i * 240, i, "slime"));
      }
    }
    // Fire fight-start when entering monster field with live enemies
    if (s.activeMonsters.length > 0) {
      try { if (_questCallbacks.onFightStart) _questCallbacks.onFightStart(); } catch (e) {}
    }
  }
  if (name === "cave_interior" && s.chestOpened && !s.batsDefeated) {
    spawnCaveBats(s);
  }

  // Items
  s.activeItems = [];
  if (name === "monster_field" && s.fieldCleared && !s.hasTape) {
    s.activeItems.push({ tx: 5, ty: 5, kind: "tape" });
  }
  if (name === "west_house" && !s.hasMoney) {
    const sc = SCREENS.west_house;
    if (sc.purse) s.activeItems.push({ tx: sc.purse.tx, ty: sc.purse.ty, kind: "purse" });
  }

  // Pushable block (cave puzzle)
  s.activeBlock = null;
  if (name === "cave_interior") {
    const bs = SCREENS.cave_interior.blockStart;
    if (bs) {
      const start = s.puzzleSolved
        ? SCREENS.cave_interior.switch
        : bs;
      s.activeBlock = {
        tx: start.tx, ty: start.ty, fromX: start.tx, fromY: start.ty,
        x: start.tx, y: start.ty, moving: false, moveT: 0,
      };
    }
  }

  // Finale crowd reset
  s.finaleCrowd = [];
  s.floatingNotes = [];

  // NPCs
  const sc = SCREENS[name];
  s.activeNpcs = (sc.npcs || [])
    .filter(cfg => {
      if (cfg.unlocksSouth && s.southUnlocked) return false;
      if (cfg.sleeper && s.sleepingManAwake) return false;
      return true;
    })
    .map(cfg => {
      const npc = instantiateNpc(cfg);
      if (s.questComplete && npc.kind === "villager") {
        npc.line = "Okay, fine, the backing tracks actually hit pretty hard.";
      }
      return npc;
    });

  s.notesPlayedNearMan = 0;
  s.bannerT = 0;
}

function softReset(s) {
  s.mode = "play";
  s.hearts = 3;
  s.invuln = 0;
  s.attack = 0;
  s.flash = 0;
  s.dialog = null;
  s.dialogPages = null;
  s.dialogPage = 0;
  s.transition = null;
  s.guitarModeActive = false;
  enterScreen(s, "house_interior", 1, 1, "down");
}

function fullReset(s) { Object.assign(s, makeInitialState()); }

// =====================================================================
// POST-TRANSITION ENTRY TRIGGERS
// =====================================================================
function runEntryTriggers(s) {
  if (s.justKickedOutOfShop) {
    s.justKickedOutOfShop = false;
    openDialog(s, "Well, that was awkward. Surely there is someone in the village who would be willing to lend you some money?");
    return;
  }
  if (s.screen === "store" && !s.hasMoney && !s.bannedFromStore) {
    openDialog(s, "Look at your outfit. You can't afford anything in this shop. GET OUT!", {
      onClose: (st) => {
        st.bannedFromStore = true;
        st.justKickedOutOfShop = true;
        st.mode = "transition";
        st.transition = {
          phase: "out", t: 0,
          to: "east_village",
          spawn: { tx: 5, ty: 2, dir: "down" },
          outDur: 1000, inDur: 500,
        };
      },
    });
    return;
  }
}

// =====================================================================
// CAVE BATS
// =====================================================================
function spawnCaveBats(s) {
  s.activeMonsters = [
    makeMonster(2, 2, 300, 0, "bat"),
    makeMonster(9, 2, 500, 1, "bat"),
  ];
  // Fight music trigger
  try { if (_questCallbacks.onFightStart) _questCallbacks.onFightStart(); } catch (e) {}
}

// =====================================================================
// FINALE
// =====================================================================
function queueFinale(s) {
  s.mode = "finale";
  s.finalePhase = 0;
  s.finaleT = 0;
  s.dialog = null;
  s.dialogPages = null;
  s.dialogPage = 0;
  openDialog(s, "The solo act takes the stage... Time to trigger the backing tracks and fake a full band performance!");
}

function startFinaleConcert(s) {
  s.mode = "finale";
  s.finalePhase = 1;
  s.finaleT = 0;
  const crowdTargets = [
    { tx: 2, ty: 7 }, { tx: 9, ty: 7 },
    { tx: 3, ty: 8 }, { tx: 8, ty: 8 },
    { tx: 4, ty: 9 }, { tx: 7, ty: 9 },
  ];
  s.finaleCrowd = crowdTargets.map((tgt, i) => ({
    id: "crowd_" + i,
    variant: 1 + (i % 3),
    kind: "villager",
    tx: i % 2 === 0 ? 0 : 11,
    ty: 7 + (i % 3),
    fromX: 0, fromY: 0, x: 0, y: 0,
    dir: i % 2 === 0 ? "right" : "left",
    moving: false, moveT: 0,
    target: tgt,
    moveCooldown: 100 + i * 150,
  }));
  for (const c of s.finaleCrowd) {
    c.x = c.tx; c.y = c.ty;
    c.fromX = c.tx; c.fromY = c.ty;
  }
  // Finale music trigger (Down 8-bit)
  try { if (_questCallbacks.onFinaleStart) _questCallbacks.onFinaleStart(); } catch (e) {}
  // Also dispatch the site-level custom events
  try {
    if (typeof window.onHalvsiesConcertStart === "function") window.onHalvsiesConcertStart();
    window.dispatchEvent(new CustomEvent("halvsies:concertStart"));
  } catch (e) {}
}

function spawnFloatingNote(s) {
  const cx = 7 * TILE - 8 + Math.random() * 64;
  const cy = 5 * TILE + PLAY_TOP - 4;
  s.floatingNotes.push({ x: cx, y: cy, t0: s.t, kind: Math.floor(Math.random() * 3) });
  while (s.floatingNotes.length > 24) s.floatingNotes.shift();
}

// =====================================================================
// DIALOG
// =====================================================================
function openDialog(s, text, opts) {
  s.dialogPages = paginate(text, DLG_LINE_CHARS, DLG_LINES_PER_PAGE);
  s.dialogPage = 0;
  s.dialog = { text, ...(opts || {}) };
  if (s.mode === "play" || s.mode === "transition" || s.mode === "finale") s.mode = "dialog";
}

function closeDialog(s, returnTo) {
  const d = s.dialog;
  s.dialog = null;
  s.dialogPages = null;
  s.dialogPage = 0;
  if (returnTo) s.mode = returnTo;
  else if (s.mode === "dialog") s.mode = "play";
  if (d && d.onClose) d.onClose(s);
}

// =====================================================================
// COMPONENT
// =====================================================================
function HalvsiesQuest({ onFightStart, onFightEnd, onFinaleStart, onIntroEnd, onGameEnd }) {
  const canvasRef = useRef(null);
  const stateRef  = useRef(makeInitialState());
  const keysRef   = useRef({});
  const [, force] = useState(0);

  // Keep _questCallbacks in sync with the latest props
  useEffect(() => {
    _questCallbacks = { onFightStart, onFightEnd, onFinaleStart, onIntroEnd, onGameEnd };
  }, [onFightStart, onFightEnd, onFinaleStart, onIntroEnd, onGameEnd]);

  // ---- Input ------------------------------------------------------
  useEffect(() => {
    const blocked = new Set([
      "arrowup","arrowdown","arrowleft","arrowright",
      " ","w","a","s","d","z","x","j","k","enter","shift",
    ]);

    const onDown = (e) => {
      const k = e.key.toLowerCase();
      if (blocked.has(k)) e.preventDefault();
      const s = stateRef.current;

      synth.ensure(); synth.resume();

      // ---- PRE-INTRO CINEMATIC — no input accepted ----
      if (s.mode === "preIntro") return;

      // ---- INTRO DIALOG ----
      if (s.mode === "intro") {
        if (isConfirmKey(k)) {
          if (s.dialogPage < s.dialogPages.length - 1) {
            s.dialogPage += 1;
            synth.blip(720, 0.04);
          } else {
            synth.blip(540, 0.04);
            const d = s.dialog;
            s.dialog = null; s.dialogPages = null; s.dialogPage = 0;
            if (d && d.onClose) d.onClose(s);
          }
        }
        return;
      }

      if (s.mode === "introFade") return;

      // ---- CREDITS ----
      if (s.mode === "credits") {
        if (isConfirmKey(k)) { s.mode = "merchUnlock"; s.finaleT = 0; }
        return;
      }

      // ---- MERCH UNLOCK ----
      if (s.mode === "merchUnlock") {
        if (isConfirmKey(k)) {
          // Exit adventure mode and return to main menu, not back to game start.
          if (_questCallbacks.onGameEnd) {
            _questCallbacks.onGameEnd();
          } else {
            // Fallback: reset game in-place if callback not wired
            Object.assign(s, makeInitialState());
            force(x => x + 1);
          }
        }
        return;
      }

      // ---- GAME OVER ----
      if (s.mode === "gameover") {
        if (k === "arrowleft" || k === "arrowright" || k === "a" || k === "d") {
          s.menuChoice = s.menuChoice === 0 ? 1 : 0;
        } else if (isConfirmKey(k)) {
          if (s.menuChoice === 0) softReset(s);
          else                    fullReset(s);
        }
        force(x => x + 1);
        return;
      }

      // ---- TRANSITION ----
      if (s.mode === "transition") return;

      // ---- DIALOG ----
      if (s.mode === "dialog") {
        const ch = s.dialog && s.dialog.choice;
        if (ch) {
          if (k === "arrowleft" || k === "arrowright" || k === "a" || k === "d") {
            ch.selected = 1 - ch.selected;
            synth.blip(720, 0.03);
          } else if (isConfirmKey(k)) {
            const sel = ch.selected;
            const onChoice = s.dialog.onChoice;
            s.dialog = null; s.dialogPages = null; s.dialogPage = 0;
            s.mode = "play";
            synth.blip(540, 0.04);
            if (onChoice) onChoice(s, sel);
          }
          return;
        }
        if (isConfirmKey(k)) {
          if (s.dialogPage < s.dialogPages.length - 1) {
            s.dialogPage += 1;
            synth.blip(720, 0.04);
          } else {
            synth.blip(540, 0.04);
            closeDialog(s);
          }
        }
        return;
      }

      // ---- GUITAR MODE ----
      if (s.guitarModeActive && s.mode === "play") {
        if (k === "x") {
          s.guitarModeActive = false;
          synth.blip(220, 0.06);
          return;
        }
        const note = noteFromKey(k);
        if (note) {
          synth.play(note, 360);
          s.lastNote = note;
          s.lastNoteTimer = 600;
          checkSleeperWake(s);
        }
        return;
      }

      // ---- PLAY ----
      if (s.mode === "play") {
        if (k === "x") {
          if (s.hasGuitar) {
            s.guitarModeActive = true;
            synth.blip(880, 0.06);
          }
          return;
        }
        if (isAButton(k)) {
          if (!tryInteract(s)) tryAttack(s);
        }
      }

      if (k.startsWith("arrow") || ["w","a","s","d"].includes(k) || k === "shift") {
        keysRef.current[k] = true;
      }
    };

    const onUp = (e) => { keysRef.current[e.key.toLowerCase()] = false; };

    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup",   onUp);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup",   onUp);
    };
  }, []);

  function isConfirmKey(k) {
    return k === "z" || k === " " || k === "enter" || k === "j";
  }
  function isAButton(k) {
    return k === "z" || k === " " || k === "j" || k === "k";
  }

  // ---- Sleeper 4-note puzzle counter --------------------------------
  function checkSleeperWake(s) {
    if (s.sleepingManAwake) return;
    if (s.screen !== "monster_gate") return;
    const sleeper = s.activeNpcs.find(n => n.kind === "sleeper");
    if (!sleeper) return;
    const adj = adjToSleeper(s.player, sleeper);
    if (!adj) return;
    s.notesPlayedNearMan = (s.notesPlayedNearMan || 0) + 1;
    if (s.notesPlayedNearMan < 4) {
      synth.blip(420, 0.04);
      return;
    }
    s.notesPlayedNearMan = 0;
    s.guitarModeActive = false;
    openDialog(s, "WHAT IS THAT GOD AWFUL NOISE?? CAN'T A MAN REST IN THE MIDDLE OF THE ROAD IN PEACE??", {
      onClose: (st) => {
        const sl = st.activeNpcs.find(n => n.kind === "sleeper");
        if (sl) { sl.storming = true; sl.dir = "right"; }
      },
    });
  }

  function adjToSleeper(p, sleeper) {
    const dx1 = Math.abs(sleeper.tx - p.tx);
    const dx2 = sleeper.wide ? Math.abs(sleeper.tx + 1 - p.tx) : Infinity;
    const dy  = Math.abs(sleeper.ty - p.ty);
    return (Math.min(dx1, dx2) + dy) <= 1 || (Math.min(dx1, dx2) <= 1 && dy <= 1);
  }

  // ---- Interactions -----------------------------------------------
  function tryInteract(s) {
    const { dx, dy } = DIRS[s.player.dir];
    const fx = s.player.tx + dx;
    const fy = s.player.ty + dy;
    const sc = SCREENS[s.screen];

    for (let i = 0; i < s.activeItems.length; i++) {
      const it = s.activeItems[i];
      if (it.tx === fx && it.ty === fy) return collectItem(s, i);
    }

    for (const n of s.activeNpcs) {
      if (npcOccupies(n, fx, fy)) return talkToNpc(s, n);
    }

    if (fx >= 0 && fy >= 0 && fx < MAP_W && fy < MAP_H) {
      const facingTile = sc.map[fy][fx];
      if (isCounter(facingTile)) {
        const beyondX = fx + dx;
        const beyondY = fy + dy;
        for (const n of s.activeNpcs) {
          if (npcOccupies(n, beyondX, beyondY)) return talkToNpc(s, n);
        }
      }
    }

    if (sc.speaker) {
      const sp = sc.speaker;
      if (fx >= sp.tx && fx < sp.tx + sp.w && fy >= sp.ty && fy < sp.ty + sp.h) {
        interactSpeaker(s);
        return true;
      }
    }

    if (sc.chest && sc.chest.tx === fx && sc.chest.ty === fy && s.puzzleSolved && !s.chestOpened) {
      openChestCave(s);
      return true;
    }

    return false;
  }

  function interactSpeaker(s) {
    if (!s.hasGuitar || !s.hasTape) {
      openDialog(s, "Looks like a powerful sound system. Too bad you have nothing to play on it.");
      return;
    }
    if (s.questComplete) {
      openDialog(s, "The backing tracks have played their part. Time to take a bow.");
      return;
    }
    openDialog(s, "Insert Backing Track Tape?", {
      choice: { options: ["YES", "NO"], selected: 0 },
      onChoice: (st, sel) => {
        if (sel === 0) {
          openDialog(st, "You put the tape into the sound system and crank the volume. The sound shakes the entire village, stirring the villagers from their daily routines...", {
            onClose: (st2) => startFinaleConcert(st2),
          });
        }
      },
    });
  }

  function collectItem(s, idx) {
    const it = s.activeItems[idx];
    s.activeItems.splice(idx, 1);
    if (it.kind === "purse") {
      s.hasMoney = true;
      synth.blip(900, 0.07);
      openDialog(s, "You stole money out of a sweet old grandma's purse. Unbelievable.", {
        celebrate: true, celebrationItem: "coin",
      });
      return true;
    }
    if (it.kind === "tape") {
      s.hasTape = true;
      synth.blip(900, 0.08);
      openDialog(s, TAPE_FOUND, {
        celebrate: true, celebrationItem: "tape",
      });
      return true;
    }
    return false;
  }

  function talkToNpc(s, n) {
    n.dir = oppositeDir(s.player.dir);

    if (n.kind === "guard") {
      if (!s.hasGlizzy) {
        openDialog(s, "Can't let you pass, kid. Orders from the top. Though... I am starving. Go get me a glizzy from the Shop in the east village and we'll talk.");
      } else {
        openDialog(s, "[CHOMP] Wow, what an exquisite glizzy. Alright, proceed.", {
          onClose: (st) => {
            st.southUnlocked = true;
            st.hasGlizzy = false;
            st.activeNpcs = st.activeNpcs.filter(x => x.kind !== "guard");
            synth.blip(660, 0.1);
          },
        });
      }
      return true;
    }

    if (n.kind === "clerk") {
      if (s.hasMoney) {
        openDialog(s, "Oh, you actually have cash? Fine. Here's your glizzy. Now leave.", {
          celebrate: true, celebrationItem: "glizzy",
          onClose: (st) => {
            st.hasGlizzy = true;
            st.hasMoney = false;
            synth.blip(880, 0.08);
          },
        });
      } else {
        openDialog(s, n.line, {
          onClose: (st) => {
            st.bannedFromStore = true;
            st.justKickedOutOfShop = true;
            st.mode = "transition";
            st.transition = {
              phase: "out", t: 0,
              to: "east_village",
              spawn: { tx: 5, ty: 2, dir: "down" },
              outDur: 1000, inDur: 500,
            };
          },
        });
      }
      return true;
    }

    if (n.kind === "sleeper") {
      openDialog(s, "a strange sleepy man taking a nap in the middle of the road. Maybe you should wake him with an energizing song?");
      return true;
    }

    openDialog(s, n.line);
    return true;
  }

  function openChestCave(s) {
    s.chestOpened = true;
    s.hasGuitar = true;
    synth.blip(1000, 0.12);
    openDialog(s, "You found a shitty guitar! What adventure awaits you now, wanna-be rockstar?", {
      celebrate: true, celebrationItem: "guitar",
      onClose: (st) => {
        spawnCaveBats(st);
        openDialog(st, CONSCIENCE_BATS);
      },
    });
  }

  function oppositeDir(d) {
    return { up: "down", down: "up", left: "right", right: "left" }[d] || d;
  }

  // ---- Attack -----------------------------------------------------
  function tryAttack(s) {
    if (!s.hasGuitar) return;
    if (s.attack > 0 || s.player.moving) return;
    s.attack = ATTACK_MS;
    synth.blip(380, 0.05);
    const { dx, dy } = DIRS[s.player.dir];
    const fx = s.player.tx + dx;
    const fy = s.player.ty + dy;
    for (const m of s.activeMonsters) {
      if (m.alive && m.tx === fx && m.ty === fy) {
        m.alive = false;
        if (m.kind === "slime") s.fieldMonstersAlive[m.id] = false;
        s.flash = 120;
        synth.blip(200, 0.08);
        // Monster field: all slimes cleared → tape drop
        if (s.screen === "monster_field" && !s.fieldCleared) {
          if (s.fieldMonstersAlive.every(a => !a)) {
            s.fieldCleared = true;
            if (!s.tapeDropped) {
              s.activeItems.push({ tx: 5, ty: 5, kind: "tape" });
              s.tapeDropped = true;
            }
            // Fight end — bg music resumes
            try { if (_questCallbacks.onFightEnd) _questCallbacks.onFightEnd(); } catch (e) {}
          }
        }
        // Cave: all bats cleared → batsDefeated → unlock + next dialog
        if (s.screen === "cave_interior" && s.chestOpened && !s.batsDefeated) {
          if (s.activeMonsters.every(b => !b.alive)) {
            s.batsDefeated = true;
            // Fight end — bg music resumes
            try { if (_questCallbacks.onFightEnd) _questCallbacks.onFightEnd(); } catch (e) {}
            openDialog(s, CONSCIENCE_TAPE_QUEST);
          }
        }
      }
    }
  }

  // ---- Move -------------------------------------------------------
  function tryMove(s, dir) {
    if (s.guitarModeActive) return;
    const p = s.player;
    p.dir = dir;
    const { dx, dy } = DIRS[dir];
    const nx = p.tx + dx;
    const ny = p.ty + dy;
    if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
    const sc = SCREENS[s.screen];
    const tile = sc.map[ny][nx];

    if (tile === T.FENCE && !s.southUnlocked) return;

    if (tile === T.STORE_DOOR && s.bannedFromStore && !s.hasMoney) {
      openDialog(s, "you are too broke to look at merchandise, loser.");
      return;
    }

    if (s.activeBlock && s.activeBlock.tx === nx && s.activeBlock.ty === ny) {
      const bx = nx + dx, by = ny + dy;
      if (bx < 0 || by < 0 || bx >= MAP_W || by >= MAP_H) return;
      const bTile = sc.map[by][bx];
      if (isSolidTile(bTile)) return;
      if (sc.chest && sc.chest.tx === bx && sc.chest.ty === by && s.puzzleSolved) return;
      for (const n of s.activeNpcs) if (npcOccupies(n, bx, by)) return;
      for (const m of s.activeMonsters) if (m.alive && m.tx === bx && m.ty === by) return;
      s.activeBlock.fromX = s.activeBlock.tx;
      s.activeBlock.fromY = s.activeBlock.ty;
      s.activeBlock.tx = bx; s.activeBlock.ty = by;
      s.activeBlock.moving = true; s.activeBlock.moveT = 0;
      p.fromX = p.tx; p.fromY = p.ty;
      p.tx = nx; p.ty = ny;
      p.moving = true; p.moveT = 0;
      synth.blip(160, 0.06);
      return;
    }

    if (sc.speaker) {
      const sp = sc.speaker;
      if (nx >= sp.tx && nx < sp.tx + sp.w && ny >= sp.ty && ny < sp.ty + sp.h) return;
    }

    if (isSolidTile(tile) && tile !== T.FENCE) return;
    if (sc.chest && sc.chest.tx === nx && sc.chest.ty === ny && s.puzzleSolved) return;
    for (const n of s.activeNpcs) if (npcOccupies(n, nx, ny)) return;
    for (const m of s.activeMonsters) if (m.alive && m.tx === nx && m.ty === ny) return;

    p.fromX = p.tx; p.fromY = p.ty;
    p.tx = nx; p.ty = ny;
    p.moving = true; p.moveT = 0;
  }

  // ---- NPC AI -----------------------------------------------------
  function stepNpc(n, dt, s) {
    if (n.moving) {
      n.moveT += dt;
      const u = Math.min(1, n.moveT / MOVE_MS);
      const k = easeOut(u);
      n.x = n.fromX + (n.tx - n.fromX) * k;
      n.y = n.fromY + (n.ty - n.fromY) * k;
      if (u >= 1) { n.moving = false; n.x = n.tx; n.y = n.ty; }
      return;
    }

    if (n.storming) {
      n.dir = "right";
      n.fromX = n.tx; n.fromY = n.ty;
      n.tx = n.tx + 1;
      if (n.tx > MAP_W + 1) {
        const idx = s.activeNpcs.indexOf(n);
        if (idx >= 0) s.activeNpcs.splice(idx, 1);
        s.sleepingManAwake = true;
        return;
      }
      n.moving = true; n.moveT = 0;
      return;
    }

    if (!n.wandering) return;
    n.nextMove -= dt;
    if (n.nextMove > 0) return;
    n.nextMove = NPC_MOVE_MIN + Math.random() * (NPC_MOVE_MAX - NPC_MOVE_MIN);
    const choices = ["up","down","left","right"];
    const dir = choices[(Math.random() * 4) | 0];
    n.dir = dir;
    const { dx, dy } = DIRS[dir];
    const nx = n.tx + dx, ny = n.ty + dy;
    if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
    const sc = SCREENS[s.screen];
    if (isSolidTile(sc.map[ny][nx])) return;
    if (sc.map[ny][nx] === T.STORE_DOOR) return;
    if (isTransitionTile(s.screen, nx, ny)) return;
    if (s.player.tx === nx && s.player.ty === ny) return;
    if (sc.chest && sc.chest.tx === nx && sc.chest.ty === ny) return;
    if (s.activeBlock && s.activeBlock.tx === nx && s.activeBlock.ty === ny) return;
    for (const o of s.activeNpcs) if (o !== n && npcOccupies(o, nx, ny)) return;
    n.fromX = n.tx; n.fromY = n.ty;
    n.tx = nx; n.ty = ny;
    n.moving = true; n.moveT = 0;
  }

  function isTransitionTile(screen, x, y) {
    const sc = SCREENS[screen];
    for (const tr of sc.transitions) if (tr.tx === x && tr.ty === y) return true;
    return false;
  }

  function stepBlock(b, dt) {
    if (!b.moving) return;
    b.moveT += dt;
    const u = Math.min(1, b.moveT / MOVE_MS);
    const k = easeOut(u);
    b.x = b.fromX + (b.tx - b.fromX) * k;
    b.y = b.fromY + (b.ty - b.fromY) * k;
    if (u >= 1) { b.moving = false; b.x = b.tx; b.y = b.ty; }
  }

  // ---- Monster AI -------------------------------------------------
  function stepMonster(e, dt, s) {
    if (!e.alive) return;
    if (e.moving) {
      e.moveT += dt;
      const u = Math.min(1, e.moveT / MOVE_MS);
      const k = easeOut(u);
      e.x = e.fromX + (e.tx - e.fromX) * k;
      e.y = e.fromY + (e.ty - e.fromY) * k;
      if (u >= 1) { e.moving = false; e.x = e.tx; e.y = e.ty; }
      return;
    }
    e.nextMove -= dt;
    if (e.nextMove > 0) return;
    const interval = e.kind === "bat"
      ? ENEMY_MOVE_MIN * 0.6 + Math.random() * (ENEMY_MOVE_MAX - ENEMY_MOVE_MIN) * 0.6
      : ENEMY_MOVE_MIN + Math.random() * (ENEMY_MOVE_MAX - ENEMY_MOVE_MIN);
    e.nextMove = interval;
    const choices = ["up","down","left","right"];
    let dir;
    if (Math.random() < 0.65) {
      const dxp = s.player.tx - e.tx;
      const dyp = s.player.ty - e.ty;
      if (Math.abs(dxp) > Math.abs(dyp))      dir = dxp > 0 ? "right" : "left";
      else if (dyp !== 0)                      dir = dyp > 0 ? "down" : "up";
      else                                     dir = choices[(Math.random() * 4) | 0];
    } else {
      dir = choices[(Math.random() * 4) | 0];
    }
    e.dir = dir;
    const { dx, dy } = DIRS[dir];
    const nx = e.tx + dx, ny = e.ty + dy;
    if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
    const sc = SCREENS[s.screen];
    if (isSolidTile(sc.map[ny][nx])) return;
    if (isTransitionTile(s.screen, nx, ny)) return;
    for (const o of s.activeMonsters) if (o !== e && o.alive && o.tx === nx && o.ty === ny) return;
    e.fromX = e.tx; e.fromY = e.ty;
    e.tx = nx; e.ty = ny;
    e.moving = true; e.moveT = 0;
  }

  // ---- Finale crowd movement --------------------------------------
  function stepFinaleCrowd(c, dt, s) {
    if (c.moving) {
      c.moveT += dt;
      const u = Math.min(1, c.moveT / MOVE_MS);
      const k = easeOut(u);
      c.x = c.fromX + (c.tx - c.fromX) * k;
      c.y = c.fromY + (c.ty - c.fromY) * k;
      if (u >= 1) { c.moving = false; c.x = c.tx; c.y = c.ty; }
      return;
    }
    c.moveCooldown -= dt;
    if (c.moveCooldown > 0) return;
    c.moveCooldown = 220;

    const dxp = c.target.tx - c.tx;
    const dyp = c.target.ty - c.ty;
    if (dxp === 0 && dyp === 0) return;
    let dir;
    if (Math.abs(dxp) > Math.abs(dyp)) dir = dxp > 0 ? "right" : "left";
    else                                dir = dyp > 0 ? "down"  : "up";
    c.dir = dir;
    const { dx, dy } = DIRS[dir];
    const nx = c.tx + dx, ny = c.ty + dy;
    if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
    const sc = SCREENS[s.screen];
    if (isSolidTile(sc.map[ny][nx])) return;
    for (const o of s.finaleCrowd) if (o !== c && o.tx === nx && o.ty === ny) return;
    c.fromX = c.tx; c.fromY = c.ty;
    c.tx = nx; c.ty = ny;
    c.moving = true; c.moveT = 0;
  }

  // ---- Transition trigger -----------------------------------------
  function checkTransition(s) {
    const sc = SCREENS[s.screen];
    for (const tr of sc.transitions) {
      if (s.player.tx !== tr.tx || s.player.ty !== tr.ty) continue;
      if (tr.requireSleeperAwake && !s.sleepingManAwake) return false;
      if (tr.requireBatsDefeated && s.chestOpened && !s.batsDefeated) return false;
      s.mode = "transition";
      s.transition = { phase: "out", t: 0, to: tr.to, spawn: tr.spawn };
      return true;
    }
    return false;
  }

  // ---- Update -----------------------------------------------------
  function update(dt) {
    const s = stateRef.current;
    s.t += dt;
    if (s.bannerT >= 0) s.bannerT += dt;
    if (s.lastNoteTimer > 0) s.lastNoteTimer = Math.max(0, s.lastNoteTimer - dt);

    if (s.mode === "preIntro") {
      s.preIntroT += dt;
      const card = PRE_INTRO[s.preIntroPhase];
      if (card) {
        const total = card.fadeIn + card.hold + card.fadeOut;
        if (s.preIntroT >= total) {
          s.preIntroPhase += 1;
          s.preIntroT = 0;
          if (s.preIntroPhase >= PRE_INTRO.length) {
            // Both title cards done — enter conscience dialogue mode
            s.mode = "intro";
          }
        }
      }
      return;
    }

    if (s.mode === "intro") return;

    if (s.mode === "introFade") {
      s.introT += dt;
      if (s.introT >= INTRO_FADE_MS) {
        s.mode = "play";
        if (_questCallbacks.onIntroEnd) _questCallbacks.onIntroEnd();
      }
      return;
    }

    if (s.mode === "credits") {
      s.finaleT += dt;
      return;
    }

    if (s.mode === "merchUnlock") {
      s.finaleT += dt;
      if (!s.merchUnlocked) {
        s.merchUnlocked = true;
        try {
          window.halvsiesMerchUnlocked = true;
          if (typeof window.onHalvsiesMerchUnlock === "function") window.onHalvsiesMerchUnlock();
          window.dispatchEvent(new CustomEvent("halvsies:unlockMerch"));
        } catch (e) {}
      }
      return;
    }

    if (s.mode === "transition") {
      const tr = s.transition;
      const outDur = tr.outDur || TRANSITION_OUT;
      const inDur  = tr.inDur  || TRANSITION_IN;
      tr.t += dt;
      if (tr.phase === "out" && tr.t >= outDur) {
        enterScreen(s, tr.to, tr.spawn.tx, tr.spawn.ty, tr.spawn.dir);
        tr.phase = "in"; tr.t = 0;
      } else if (tr.phase === "in" && tr.t >= inDur) {
        s.mode = "play";
        s.transition = null;
        runEntryTriggers(s);
      }
      return;
    }

    if (s.mode === "dialog") return;
    if (s.mode === "gameover") return;

    if (s.mode === "finale") {
      s.finaleT += dt;
      if (s.finalePhase === 1) {
        for (const c of s.finaleCrowd) stepFinaleCrowd(c, dt, s);
        if (Math.random() < dt / 280) spawnFloatingNote(s);
        s.floatingNotes = s.floatingNotes.filter(n => s.t - n.t0 < 3500);
        if (s.finaleT > 6000) {
          s.finalePhase = 2;
          openDialog(s, "Okay, fine, the backing tracks actually hit pretty hard.", {
            onClose: (st) => {
              st.finalePhase = 3;
              st.questComplete = true;
              try {
                window.halvsiesQuestComplete = true;
                window.dispatchEvent(new CustomEvent("halvsies:questComplete"));
              } catch (e) {}
              st.mode = "credits";
              st.finaleT = 0;
            },
          });
        }
      }
      return;
    }

    // === PLAY ===
    const p = s.player;

    if (s.activeBlock) stepBlock(s.activeBlock, dt);

    if (p.moving) {
      p.moveT += dt;
      const u = Math.min(1, p.moveT / MOVE_MS);
      const k = easeOut(u);
      p.x = p.fromX + (p.tx - p.fromX) * k;
      p.y = p.fromY + (p.ty - p.fromY) * k;
      p.step += dt;
      if (u >= 1) {
        p.moving = false;
        p.x = p.tx; p.y = p.ty;
        for (let i = 0; i < s.activeItems.length; i++) {
          const it = s.activeItems[i];
          if (it.tx === p.tx && it.ty === p.ty) { collectItem(s, i); return; }
        }
        if (checkTransition(s)) return;
      }
    }

    if (s.activeBlock && !s.activeBlock.moving && !s.puzzleSolved && s.screen === "cave_interior") {
      const sw = SCREENS.cave_interior.switch;
      if (s.activeBlock.tx === sw.tx && s.activeBlock.ty === sw.ty) {
        s.puzzleSolved = true;
        synth.blip(660, 0.12);
        openDialog(s, "wow, that was easy. whoever made this game definitely didn't put a lot of effort into the puzzles.");
        return;
      }
    }

    if (!p.moving && s.attack <= 0 && s.mode === "play" && !s.guitarModeActive) {
      const k = keysRef.current;
      let dir = null;
      if      (k["arrowup"]    || k["w"]) dir = "up";
      else if (k["arrowdown"]  || k["s"]) dir = "down";
      else if (k["arrowleft"]  || k["a"]) dir = "left";
      else if (k["arrowright"] || k["d"]) dir = "right";
      if (dir) tryMove(s, dir);
    }

    if (s.attack > 0) s.attack = Math.max(0, s.attack - dt);
    if (s.flash  > 0) s.flash  = Math.max(0, s.flash  - dt);
    if (s.invuln > 0) s.invuln = Math.max(0, s.invuln - dt);

    for (const n of s.activeNpcs) stepNpc(n, dt, s);
    for (const m of s.activeMonsters) stepMonster(m, dt, s);

    if (s.invuln <= 0 && s.activeMonsters.length) {
      for (const m of s.activeMonsters) {
        if (!m.alive) continue;
        const dx = m.x - p.x, dy = m.y - p.y;
        if (Math.hypot(dx, dy) < 0.6) {
          s.hearts -= 1;
          s.invuln = INVULN_MS;
          s.flash  = 200;
          synth.blip(140, 0.12);
          if (s.hearts <= 0) {
            s.hearts = 0;
            s.mode = "gameover";
            s.menuChoice = 0;
          }
          break;
        }
      }
    }
  }

  // ---- Loop --------------------------------------------------------
  useEffect(() => {
    const ctx = canvasRef.current.getContext("2d");
    ctx.imageSmoothingEnabled = false;
    let raf = 0;
    let last = performance.now();
    let started = false;
    const loop = (now) => {
      const dt = Math.min(64, now - last);
      last = now;
      update(dt);
      render(ctx);
      raf = requestAnimationFrame(loop);
    };
    const start = () => {
      if (started) return;
      started = true;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    if (document.fonts && document.fonts.load) {
      Promise.all([
        document.fonts.load("8px 'Press Start 2P'"),
        document.fonts.load("16px 'Press Start 2P'"),
      ]).then(start, start);
      setTimeout(start, 1500);
    } else {
      start();
    }
    return () => cancelAnimationFrame(raf);
  }, []);

  // =================================================================
  // RENDER
  // =================================================================
  function render(ctx) {
    const s = stateRef.current;

    if (s.mode === "preIntro") {
      drawPreIntro(ctx, s.preIntroPhase, s.preIntroT);
      return;
    }
    if (s.mode === "intro") {
      ctx.fillStyle = C.darkest;
      ctx.fillRect(0, 0, W, H);
      drawDialogBox(ctx, s.dialogPages[s.dialogPage], s.dialogPage < s.dialogPages.length - 1, s.t, s.dialog && s.dialog.choice);
      return;
    }
    if (s.mode === "credits") {
      const done = drawCredits(ctx, s.finaleT);
      if (done) { s.mode = "merchUnlock"; s.finaleT = 0; }
      return;
    }
    if (s.mode === "merchUnlock") { drawMerchUnlocked(ctx, s.finaleT); return; }

    drawWorld(ctx, s);

    if (s.mode === "introFade") { drawIntroFadeIn(ctx, s.introT / INTRO_FADE_MS); return; }
    if (s.mode === "transition") {
      const tr = s.transition;
      const outDur = tr.outDur || TRANSITION_OUT;
      const inDur  = tr.inDur  || TRANSITION_IN;
      const a = tr.phase === "out" ? tr.t / outDur : 1 - tr.t / inDur;
      drawTransitionOverlay(ctx, a);
      return;
    }
    if (s.mode === "dialog") {
      drawDialogBox(ctx, s.dialogPages[s.dialogPage], s.dialogPage < s.dialogPages.length - 1, s.t, s.dialog && s.dialog.choice);
      return;
    }
    if (s.mode === "gameover") { drawGameOver(ctx, s.menuChoice, s.t); return; }
  }

  function drawWorld(ctx, s) {
    ctx.fillStyle = C.lightest;
    ctx.fillRect(0, 0, W, H);

    drawTopBar(ctx, SCREENS[s.screen].label, s.hearts, {
      hasGuitar: s.hasGuitar,
      hasSound: s.hasTape,
      guitarMode: s.guitarModeActive,
    });

    drawMap(ctx, s.screen, s);

    if (SCREENS[s.screen].dark) drawCaveVignette(ctx, s.player);

    for (const it of s.activeItems) {
      if (it.kind === "tape")  drawGlowingTape(ctx, it, s.t);
      if (it.kind === "purse") drawPurse(ctx, it, s.t);
    }

    if (s.activeBlock) drawBlock(ctx, s.activeBlock, s.t);

    for (const n of s.activeNpcs) drawNpc(ctx, n, s.t);
    for (const c of s.finaleCrowd) drawNpc(ctx, c, s.t);

    const sc = SCREENS[s.screen];
    if (sc.chest && s.puzzleSolved) drawChest(ctx, sc.chest, s.chestOpened, s.t);
    if (sc.speaker) drawSpeakerStack(ctx, sc.speaker, s.t);

    for (const m of s.activeMonsters) {
      if (!m.alive) continue;
      if (m.kind === "bat") drawBat(ctx, m, s.t);
      else                   drawMonster(ctx, m, s.t);
    }

    for (const n of s.floatingNotes) drawFloatingNote(ctx, n, s.t);

    const variant = (s.mode === "dialog" && s.dialog && s.dialog.celebrate)
      ? "celebration"
      : (s.hasGuitar ? "withGuitar" : "noGuitar");
    const celebItem = (s.dialog && s.dialog.celebrationItem) || "guitar";
    const hidden = s.invuln > 0 && Math.floor(s.t / 80) % 2 === 0;
    drawPlayer(ctx, s.player, s.t, variant, hidden && s.mode === "play", celebItem);

    if (s.mode === "play") drawAttack(ctx, s.player, s.attack);

    if (s.flash > 0 && s.mode === "play") {
      ctx.globalAlpha = 0.18 * (s.flash / 200);
      ctx.fillStyle = C.darkest;
      ctx.fillRect(0, PLAY_TOP, W, H - PLAY_TOP);
      ctx.globalAlpha = 1;
    }

    if (s.mode === "play" && s.bannerT < 1400) {
      drawScreenLabelBanner(ctx, SCREENS[s.screen].label, s.bannerT);
    }

    if (s.guitarModeActive && s.mode === "play") {
      drawGuitarHud(ctx, s.lastNote, s.lastNoteTimer);
    }

    if (s.mode === "finale" && s.finalePhase === 0 && !s.dialog) {
      startFinaleConcert(s);
    }
  }

  function drawCaveVignette(ctx, p) {
    const cx = p.x * TILE + TILE / 2;
    const cy = p.y * TILE + TILE / 2 + PLAY_TOP;
    const grad = ctx.createRadialGradient(cx, cy, 8, cx, cy, 88);
    grad.addColorStop(0,    "rgba(15,56,15,0)");
    grad.addColorStop(0.45, "rgba(15,56,15,0.35)");
    grad.addColorStop(1,    "rgba(15,56,15,0.85)");
    ctx.fillStyle = grad;
    ctx.fillRect(0, PLAY_TOP, W, H - PLAY_TOP);
  }

  // =================================================================
  // JSX — just the raw canvas; the site shell wraps it
  // =================================================================
  return (
    <canvas
      ref={canvasRef}
      width={W}
      height={H}
      style={{ width: '100%', height: '100%', imageRendering: 'pixelated', display: 'block' }}
    />
  );
}

// Export to window so the main site script can mount it
window.HalvsiesQuest = HalvsiesQuest;
