"use strict"; // Mindlock — walkable top-down shell over the brain engine. // Movement runs here on the canvas; every server call is one discrete game action. const TS = 32, COLS = 20, ROWS = 14, WALLR = 2; // tile grid + wall thickness (in cells) const W = COLS * TS, H = ROWS * TS; // 640 x 448 const PLAY = { x0: WALLR * TS, y0: WALLR * TS, x1: W - WALLR * TS, y1: H - WALLR * TS }; const DOORC = 10; // door opening centre (vertex column) const DOOR = { cx: DOORC * TS, cy: WALLR * TS, w: 2 * TS }; const WALL_Y = 42; // foot line for wall-mounted props (above the baseboard) const SPEED = 130; // px/sec const STRIDE = 7; // px of travel per walk frame — ties the cycle to distance, not time, // so feet plant instead of sliding. Lower = quicker step cadence. const PR = 11; // player radius const NPC_R = 46, DOOR_R = 50, TERM_R = 44; const cv = document.getElementById("screen"); const ctx = cv.getContext("2d"); ctx.imageSmoothingEnabled = false; const el = (id) => document.getElementById(id); const dom = { room: el("hud-room"), depth: el("hud-depth"), rep: el("hud-rep"), prompt: el("prompt"), bark: el("bark"), dialogue: el("dialogue"), portrait: el("dlg-portrait"), speaker: el("dlg-speaker"), reply: el("dlg-reply"), brain: el("dlg-brain"), brainPanel: el("brain-panel"), lifebar: el("dlg-lifebar"), inputRow: el("dlg-input-row"), input: el("dlg-input"), send: el("dlg-send"), life: el("dlg-life"), brainBtn: el("dlg-brainbtn"), flip: el("flip-banner"), log: el("log"), moral: el("moral"), moralText: el("moral-text"), objpicker: el("objpicker"), opSearch: el("op-search"), opGrid: el("op-grid"), edHelp: el("ed-help"), charedit: el("charedit"), pause: el("pausemenu"), }; const G = { data: null, chars: [], stations: [], terminalPos: null, player: { x: W / 2, y: PLAY.y1 - 50 }, dir: "south", moving: false, walkPhase: 0, keys: new Set(), mode: "walk", activeId: -1, inputTarget: null, near: { npc: -1, door: false, terminal: false, key: false }, hasKey: false, doorOpen: false, keyItem: null, // pickup key → open door → pass depth: -1, busy: false, lastT: 0, dying: {}, debug: false, roomSlug: null, roomDepth: -1, issues: [], art: null, spriteFor: {}, editor: false, edSel: null, edDrag: false, edBrushKey: "barrel", edTheme: 0, // layout editor (L) editMode: false, edHelpSeen: false, // entered via the menu's Level editor (no ?dev=1 needed) edArmed: false, // brush armed (picker / [ ]): next empty click places dev: new URLSearchParams(location.search).get("dev") === "1", // ?dev=1 unlocks dev hotkeys + editor cap: new URLSearchParams(location.search).get("cap") === "1", // ?cap=1 = clean trailer capture mode lastBrain: null, lastBrainCharId: -1, prevSig: {}, panelSeen: false, // brain theatrics (per-char deltas) floaters: [], lifeFlash: {}, revealT: [], typeT: null, moralTimer: null, bannerT: null, bannerT2: null, }; // trailer capture: strip the chrome (header/footer/hint) and warm the page bg so the recorded // gameplay sits in the same dark as the cards (see body.capture in game.css). if (G.cap) document.body.classList.add("capture"); // --------------------------------------------------------------------------- networking async function api(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), }); return r.json(); } const getState = async () => (await fetch("/api/state")).json(); // ------------------------------------------------------------------------------ layout function stations(n) { const top = 150, gap = 150; if (n <= 1) return [{ x: W / 2, y: top }]; const start = W / 2 - (gap * (n - 1)) / 2; return Array.from({ length: n }, (_, i) => ({ x: start + gap * i, y: top + (i % 2 ? 46 : 0) })); } function applyState(d) { if (d.error) { flashLog([d.error]); } G.data = d; const slug = normName(d.room.name); // rebuild on any room change — keyed on DEPTH (unique if (d.room.depth !== G.roomDepth || slug !== G.roomSlug) { // per room) so same-named rooms still reset G.roomDepth = d.room.depth; G.roomSlug = slug; const seed = (hashStr(d.room.name) ^ ((d.room.depth + 1) * 2654435761)) >>> 0; // story levels may ship an explicit placed layout (from the in-browser editor); otherwise // resolve the room by name (curated authored rooms) or a procedural theme. G.art = (d.room.layout && d.room.layout.objects) ? artFromLayout(d.room.layout, d.characters.length) : resolveRoom(d.room.name, d.characters.length, seed); OBJ_LIST = G.art.objects; G.stations = G.art.stations; G.player = { x: W / 2, y: PLAY.y1 - 50 }; TILESET.ready = false; loadTileset(G.art.tileset); FLOORSET.ready = false; if (G.art.floorTileset) loadTileset(G.art.floorTileset, FLOORSET); // saved mixed floor loadObjects(); closeTalk(); clearBrain(); G.lastBrain = null; G.lastBrainCharId = -1; G.prevSig = {}; // new room: no stale skulls/deltas G.floaters = []; G.lifeFlash = {}; if (dom.flip) { dom.flip.classList.add("hidden"); dom.flip.classList.remove("fading"); } dom.log.innerHTML = ""; // stale events don't haunt the next room hideBark(); G.barked = new Set(); G.deathBarked = false; // barks re-arm in each new room G.hasKey = false; G.doorOpen = false; G.keyItem = null; // fresh room: re-earn the key if (G.editor && !(d.room && d.room.editable)) { // walked into a procedural room G.editor = false; G.debug = false; G.edSel = null; if (dom.objpicker) dom.objpicker.classList.add("hidden"); if (dom.edHelp) dom.edHelp.classList.add("hidden"); closeCharEdit(); } } dom.room.textContent = d.room.name; dom.depth.textContent = d.room.depth; const rep = d.room.reputation; dom.rep.textContent = (rep > 0 ? "+" : "") + rep; dom.rep.style.color = rep > 0 ? "var(--green)" : (rep < 0 ? "var(--red)" : "var(--ink)"); const prevAlive = {}, prevLife = {}; (G.chars || []).forEach((c) => { prevAlive[c.name] = c.alive; prevLife[c.name] = c.life; }); d.characters.forEach((c) => { if (prevAlive[c.name] && !c.alive) G.dying[c.name] = performance.now(); if (prevLife[c.name] != null && c.life < prevLife[c.name]) G.lifeFlash[c.name] = performance.now(); }); G.chars = d.characters; G.chars.forEach((c) => { c.spriteKey = spriteForChar(c); }); // map to a gender-matched sprite G.chars.forEach((c) => loadNpcSprite(c.spriteKey)); // the terminal's interaction point comes from the room art (it was never assigned before — P0) const termObj = (OBJ_LIST || []).find((o) => o.key === "terminal"); G.terminalPos = !d.terminal ? null : termObj ? { x: termObj.x, y: termObj.y } : { x: W / 2 - 130, y: PLAY.y0 + 120 }; // art lost the prop — still give the player a console updateDlgLife(); if (d.run && d.run.over) stageMoral(d); // the holder yielded → the key is now in the room to pick up (it isn't on the map until // earned). Keyed on the YIELD itself, not the door: the Records Office door also wants the // terminal, and the key must not vanish behind that second lock. const yielded = G.chars.some((c) => c.is_holder && c.gave_key); if (yielded && !G.hasKey && !G.keyItem) { const hi = G.chars.findIndex((c) => c.is_holder); const st = (hi >= 0 && G.stations[hi]) ? G.stations[hi] : { x: W / 2, y: H / 2 }; G.keyItem = { x: st.x, y: clamp(st.y + 64, PLAY.y0 + 20, PLAY.y1 - 20), t0: performance.now() }; flashLog(d.terminal && !d.terminal.unlocked ? ["They give up the key. Take it — but the records terminal still bars the door."] : ["They give up the key. Take it, then go to the door."]); } G.issues = roomIssues(); // validate placement (door-blocks / overlaps / reachability) if (G.issues.length) console.warn("[placement]", G.roomSlug, G.issues.map((x) => x.o.key + ": " + x.why)); } // ------------------------------------------------------------------------------- input // Hotkeys read the PHYSICAL key (e.code), not the typed character — so H is H and WASD walk // on a Russian (or any) keyboard layout too; e.key would give "р" for the H key on RU. const _CODE_MAP = { BracketLeft: "[", BracketRight: "]", Comma: ",", Period: ".", Space: " ", Enter: "enter", Escape: "escape", Backspace: "backspace", Delete: "delete", ArrowUp: "arrowup", ArrowDown: "arrowdown", ArrowLeft: "arrowleft", ArrowRight: "arrowright" }; function hotkey(e) { const c = e.code || ""; if (c.startsWith("Key")) return c.slice(3).toLowerCase(); if (c.startsWith("Digit")) return c.slice(5); return _CODE_MAP[c] || (e.key || "").toLowerCase(); } window.addEventListener("keydown", (e) => { const k = hotkey(e); // the character editor is a form: while it's open only Esc (close) gets through if (dom.charedit && !dom.charedit.classList.contains("hidden")) { if (k === "escape") { e.preventDefault(); closeCharEdit(); } return; } if (dom.pause && !dom.pause.classList.contains("hidden")) { // paused: Esc resumes, S saves if (k === "escape") { e.preventDefault(); closePause(); } else if (k === "s" && G.editor) edSave(); return; } if (k === "escape") { // Esc always escapes: panel → dialogue → brush → pause e.preventDefault(); if (!dom.brainPanel.classList.contains("hidden")) closeBrainPanel(); else if (G.mode === "talk") closeTalk(); else if (G.editor && (G.edArmed || G.edSel)) { G.edArmed = false; G.edSel = null; } else openPause(); // nothing left to step back from → the pause menu return; } const typing = document.activeElement === dom.input || document.activeElement === dom.opSearch; if (typing) return; if (["arrowup", "arrowdown", "arrowleft", "arrowright", " "].includes(k)) e.preventDefault(); if (k === "r") { restart(); return; } if (k === "l" && (G.dev || G.editMode)) { toggleEditor(); return; } // editor toggle (menu or ?dev=1) if (G.dev && !G.editor) { // dev-only generators live behind ?dev=1 if (k === "g") { G.debug = !G.debug; return; } if ("12345".includes(k)) { gotoRoom(+k - 1); return; } // dev: jump to authored level 1-5 if (k === "9") { // dev: step to the NEXT authored level (6-10 live here) gotoRoom(((G.data && G.data.room ? G.data.room.depth : 0) + 1) % 10); return; } if (k === "0") { reRoll(); return; } // dev: re-roll auto-placement of current room if (k === "6") { devProceduralRoom(); return; }// dev: procedural themed room (instant, fake names) if (k === "7") { devGenerate(); return; } // dev: real LLM-generated room (names + stories) if (k === "8") { devRoster(); return; } // dev: room from minted roster (real faces + stories) } if (G.editor) { // editor keys (mouse drags; these are the tools) if (k === "h") { toggleEdHelp(); return; } // the legend if ("123456789".includes(k)) { gotoRoom(+k - 1); return; } // switch between the authored levels if (k === "0") { gotoRoom(9); return; } // 0 = level 10 (the campaign is ten rooms now) if (k === "enter" || k === "e") { openCharEdit(); return; } // edit the selected mind's prompts if (k === "o") { toggleObjPicker(); return; } // searchable object menu if (k === "[") { edBrushStep(-1); return; } if (k === "]") { edBrushStep(1); return; } if (k === "," ) { edZ(-1); return; } // Z-order: send behind if (k === ".") { edZ(1); return; } // Z-order: bring in front if (k === "z") { edLayer(); return; } // cycle layer: normal / wall / floor if (k === "b") { edBark(); return; } // attach a protagonist bark to THIS prop instance if (k === "t") { edCycleTheme(); return; } // whole theme (walls+floor+mood) if (k === "w") { edCycleWall(); return; } // wall material only if (k === "f") { edCycleFloor(); return; } // floor material only if (k === "x" || k === "delete" || k === "backspace") { edDelete(); return; } if (k === "c") { edExport(); return; } if (k === "s") { edSave(); return; } // write layout into the level file if (k.startsWith("arrow")) { edNudge(k); return; } return; // swallow the rest (no walking while editing) } if (G.mode !== "walk") return; if (k === "e" || k === "enter" || k === " ") { e.preventDefault(); interact(); return; } // don't leak into a freshly focused input G.keys.add(k); }); window.addEventListener("keyup", (e) => { G.keys.delete(hotkey(e)); G.keys.delete((e.key || "").toLowerCase()); }); dom.send.addEventListener("click", () => submit()); dom.input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } }); if (dom.brainBtn) dom.brainBtn.addEventListener("click", () => { // visible twin of /brain if (!dom.brainPanel.classList.contains("hidden")) { closeBrainPanel(); return; } if (!G.lastBrain) { flashLog(["Speak to a mind first — then 🧠 opens its skull."]); return; } renderBrainPanel(G.lastBrain); }); // ============================ in-browser layout editor (key: L) ============================ // Story-authoring's "place sprites" half: drag NPCs and props with the mouse, [ ] cycle the prop // brush (click empty ground to drop one), t cycles the theme, x deletes, c copies the level layout // JSON to the clipboard — paste it into the story level file under "layout". Dialogue is authored // in the JSON; this produces only the visual half. const EDIT_BRUSH = ["cot", "bench", "table", "stool", "crate", "barrel", "bucket", "plant", "shelf", "cabinet", "rug", "candle", "terminal", "sconce", "picture", "window", "nvidia", "huggingface", "train_wreck", "hospital_bed", "computer_desk", "wheelchair", "gurney", "iv_stand", "medicine_cabinet", "operating_lamp", "locker", "lockers_row", "boiler_tank", "pipes_wall", "valve_wheel", "furnace", "generator", "workbench", "tool_rack", "coal_pile", "wash_basin", "bathtub", "mirror_broken", "bookshelf_tall", "desk_lamp", "filing_cabinet", "radio_old", "telephone_wall", "clock_wall", "poster_torn", "warning_sign", "fuse_box", "chair_wooden", "armchair_worn", "sofa_torn", "piano_old", "statue_angel", "lantern_floor", "food_cart", "sewing_machine", "mine_cart", "grandfather_clock", "ecg_monitor", "hospital_screen", "water_cooler", "whiteboard_task", "server_rack", "office_chair", "dev_workstation", "coffee_machine", "tv_old", "family_photos", "fireplace", "rocking_chair", "dog", "memorial_flowers", "railway_lever", "rails_segment", "police_tape", "news_camera", "station_bench", "departure_board", "window_hospital", "train_wreck_2", "privacy_screen", "curtain_rail", "crt_cart", "ward_bed", "medicine_cart", "wall_vent", "lamp_hanging", "floor_cross", "floor_drain", "floor_litter", "ivy_wall", "surgical_lamp", "sink_mirror", "locker_green", "trash_bin", "stain_rust", "stool_tray"]; const PROP_DEFAULTS = { cot: { h: 64, solid: true, fw: 58, fh: 20 }, bench: { h: 46, solid: true, fw: 60, fh: 15 }, table: { h: 50, solid: true, fw: 62, fh: 22 }, stool: { h: 34 }, crate: { h: 40, solid: true, fw: 46, fh: 16 }, barrel: { h: 48, solid: true, fw: 32, fh: 14 }, bucket: { h: 40 }, plant: { h: 56 }, shelf: { h: 42, background: true, wallShadow: true }, cabinet: { h: 78, solid: true, fw: 50, fh: 16 }, rug: { h: 104, floor: true }, picture: { h: 34, background: true, wallShadow: true }, window: { h: 42, background: true }, sconce: { h: 44, background: true, light: true, lightY: 26, lightR: 105 }, candle: { h: 30, shadow: false, anim: "candle_anim", fps: 100, light: true, lightR: 120 }, terminal: { h: 72, solid: true, fw: 52, fh: 18, light: true, lightR: 100, lightCol: "rgba(120,255,190,0.72)", lightMid: "rgba(70,210,160,0.22)" }, nvidia: { h: 80, solid: false, shadow: true, spark: true, light: true, blink: true, lightY: 266, lightR: 98, lightCol: "rgba(120,255,124,0.92)", lightMid: "rgba(55,200,70,0.30)" }, huggingface: { h: 58, solid: false, shadow: true }, // ---- asylum prop pack (PixelLab batch) ---- train_wreck: { h: 150, solid: true, fw: 170, fh: 26, spark: true, sparkX: -100, smoke: true, smokeX: -85, smokeY: 110 }, hospital_bed: { h: 62, solid: true, fw: 56, fh: 18, light: true, lightY: 26, lightR: 110 }, computer_desk: { h: 53, solid: true, fw: 37, fh: 18, light: true, lightR: 100, lightCol: "rgba(120,255,190,0.7)", lightMid: "rgba(70,210,160,0.2)" }, wheelchair: { h: 42, solid: true, fw: 18, fh: 14 }, gurney: { h: 34, solid: true, fw: 30, fh: 14 }, iv_stand: { h: 55 }, medicine_cabinet: { h: 38, background: true, wallShadow: true }, operating_lamp: { h: 63, light: true, lightY: 50, lightR: 130, lightCol: "rgba(220,235,255,0.85)", lightMid: "rgba(170,200,255,0.25)" }, locker: { h: 55, solid: true, fw: 12, fh: 12 }, lockers_row: { h: 53, solid: true, fw: 35, fh: 16 }, boiler_tank: { h: 76, solid: true, fw: 29, fh: 18 }, pipes_wall: { h: 70, background: true }, valve_wheel: { h: 22, background: true }, furnace: { h: 68, solid: true, fw: 25, fh: 18, light: true, lightR: 150, lightCol: "rgba(255,150,60,0.9)", lightMid: "rgba(255,110,40,0.3)" }, generator: { h: 40, solid: true, fw: 30, fh: 15 }, workbench: { h: 40, solid: true, fw: 34, fh: 16 }, tool_rack: { h: 38, background: true, wallShadow: true }, coal_pile: { h: 30 }, wash_basin: { h: 40, solid: true, fw: 15, fh: 12 }, bathtub: { h: 32, solid: true, fw: 37, fh: 16 }, mirror_broken: { h: 40, background: true, wallShadow: true }, bookshelf_tall: { h: 72, solid: true, fw: 15, fh: 14 }, desk_lamp: { h: 38, solid: true, fw: 19, fh: 14, light: true, lightY: 26, lightR: 100 }, filing_cabinet: { h: 52, solid: true, fw: 18, fh: 14 }, radio_old: { h: 34, solid: true, fw: 16, fh: 12, light: true, lightR: 55 }, telephone_wall: { h: 33, background: true }, clock_wall: { h: 29, background: true, wallShadow: true }, poster_torn: { h: 36, background: true }, warning_sign: { h: 31, background: true, wallShadow: true }, fuse_box: { h: 35, background: true, light: true, blink: true, lightY: 22, lightR: 60, lightCol: "rgba(120,255,124,0.8)" }, chair_wooden: { h: 34 }, armchair_worn: { h: 43, solid: true, fw: 20, fh: 14 }, sofa_torn: { h: 28, solid: true, fw: 35, fh: 14 }, piano_old: { h: 56, solid: true, fw: 40, fh: 16 }, statue_angel: { h: 80, solid: true, fw: 29, fh: 18 }, lantern_floor: { h: 56, light: true, lightY: 40, lightR: 120 }, food_cart: { h: 52, solid: true, fw: 19, fh: 14 }, sewing_machine: { h: 40, solid: true, fw: 19, fh: 14 }, mine_cart: { h: 36, solid: true, fw: 22, fh: 14 }, grandfather_clock: { h: 80, solid: true, fw: 14, fh: 12 }, // ---- coma-story prop pack (PixelLab batch): hospital → home → office → crash site → rails ---- ecg_monitor: { h: 52, solid: true, fw: 16, fh: 12, light: true, blink: true, lightR: 70, lightCol: "rgba(120,255,160,0.7)", lightMid: "rgba(70,210,120,0.18)" }, hospital_screen: { h: 48, solid: true, fw: 33, fh: 14 }, water_cooler: { h: 47, solid: true, fw: 10, fh: 12 }, whiteboard_task: { h: 46, solid: true, fw: 29, fh: 14 }, server_rack: { h: 69, solid: true, fw: 14, fh: 14, light: true, blink: true, lightR: 60, lightCol: "rgba(120,255,124,0.6)" }, office_chair: { h: 34 }, dev_workstation: { h: 34, solid: true, fw: 36, fh: 16, light: true, lightR: 110, lightCol: "rgba(150,200,255,0.7)", lightMid: "rgba(100,150,255,0.2)" }, coffee_machine: { h: 52, solid: true, fw: 15, fh: 12 }, tv_old: { h: 31, solid: true, fw: 20, fh: 14, light: true, blink: true, lightR: 95, lightCol: "rgba(170,200,255,0.75)", lightMid: "rgba(120,160,255,0.22)" }, family_photos: { h: 20, background: true, wallShadow: true }, fireplace: { h: 60, solid: true, fw: 26, fh: 16, light: true, lightR: 150, lightCol: "rgba(255,150,60,0.9)", lightMid: "rgba(255,110,40,0.3)" }, rocking_chair: { h: 48, solid: true, fw: 20, fh: 14 }, dog: { h: 34 }, memorial_flowers: { h: 31, light: true, lightR: 80, lightCol: "rgba(255,201,120,0.8)" }, railway_lever: { h: 52, solid: true, fw: 10, fh: 12 }, rails_segment: { h: 26, floor: true }, police_tape: { h: 27 }, news_camera: { h: 38, solid: true, fw: 17, fh: 12 }, station_bench: { h: 25, solid: true, fw: 22, fh: 13 }, departure_board: { h: 21, background: true, light: true, lightR: 80, lightCol: "rgba(255,220,120,0.7)" }, // ---- dark-ward pack (PixelLab, reference-matched): muted teal hospital set ---- window_hospital: { h: 46, background: true }, train_wreck_2: { h: 100, solid: true, fw: 130, fh: 24, spark: true, sparkX: -60, smoke: true, smokeX: -50, smokeY: 70 }, privacy_screen: { h: 66, solid: true, fw: 44, fh: 12 }, curtain_rail: { h: 64, solid: true, fw: 56, fh: 10 }, crt_cart: { h: 62, solid: true, fw: 26, fh: 14, light: true, lightR: 90, lightCol: "rgba(120,255,140,0.55)", lightMid: "rgba(70,200,100,0.16)" }, ward_bed: { h: 60, solid: true, fw: 56, fh: 20 }, medicine_cart: { h: 56, solid: true, fw: 26, fh: 14 }, wall_vent: { h: 34, background: true, light: true, lightY: 40, lightR: 110, lightCol: "rgba(140,220,200,0.5)", lightMid: "rgba(100,180,160,0.14)" }, lamp_hanging: { h: 44, background: true, light: true, lightY: 44, lightR: 120, lightCol: "rgba(255,214,140,0.75)", lightMid: "rgba(255,180,100,0.2)" }, floor_cross: { h: 23, floor: true, shadow: false }, floor_drain: { h: 18, floor: true, shadow: false }, floor_litter: { h: 18, floor: true, shadow: false }, ivy_wall: { h: 38, background: true, shadow: false }, surgical_lamp: { h: 70, solid: true, fw: 26, fh: 14, light: true, lightY: 56, lightR: 70, lightCol: "rgba(190,220,255,0.62)", lightMid: "rgba(150,190,230,0.18)" }, sink_mirror: { h: 74, solid: true, fw: 22, fh: 12 }, locker_green: { h: 62, solid: true, fw: 24, fh: 12 }, trash_bin: { h: 30, solid: false }, stain_rust: { h: 22, floor: true, shadow: false }, stool_tray: { h: 34, solid: false }, }; const EDIT_FIELDS = ["solid", "fw", "fh", "background", "floor", "shadow", "sortY", "light", "blink", "spark", "sparkX", "smoke", "smokeX", "smokeY", "lightY", "lightR", "lightCol", "lightMid", "anim", "fps", "wallShadow", "bark"]; function toggleEditor() { // only the authored levels are editable — their layout persists into the level file on Save; // procedural rooms are rolled from a seed and have no file to save into. if (!G.editor && !(G.data && G.data.room && G.data.room.editable)) { flashLog(["This room is procedural — only the authored levels can be edited."]); return; } G.editor = !G.editor; G.debug = G.editor; // reuse the placement overlay (grid / anchors / keep-outs) G.edSel = null; G.edDrag = false; if (!G.editor) { if (dom.objpicker) dom.objpicker.classList.add("hidden"); if (dom.edHelp) dom.edHelp.classList.add("hidden"); closeCharEdit(); } else if (!G.edHelpSeen && dom.edHelp) { G.edHelpSeen = true; // first entry: show the legend once; H re-summons it dom.edHelp.classList.remove("hidden"); } flashLog([G.editor ? "LEVEL EDITOR on — press H for the legend · L to exit" : "level editor off"]); } function toggleEdHelp() { if (dom.edHelp) dom.edHelp.classList.toggle("hidden"); } // ---- pause menu (Esc): save here, walk back to the title ---- function openPause() { if (!dom.pause) return; const saveBtn = el("pm-save"); if (saveBtn) saveBtn.classList.toggle("hidden", !(G.editor && G.data && G.data.room && G.data.room.editable)); dom.pause.classList.remove("hidden"); } function closePause() { if (dom.pause) dom.pause.classList.add("hidden"); } function exitToMenu() { closePause(); closeTalk(); closeBrainPanel(); hideBark(); if (G.editor) { // leave the editor cleanly G.editor = false; G.debug = false; G.edSel = null; G.edArmed = false; if (dom.objpicker) dom.objpicker.classList.add("hidden"); if (dom.edHelp) dom.edHelp.classList.add("hidden"); closeCharEdit(); } const m = el("menu"); if (m) { m.classList.remove("hidden", "closing"); } Sound.menu(); } if (el("pm-continue")) el("pm-continue").onclick = closePause; if (el("pm-save")) el("pm-save").onclick = () => edSave(); if (el("pm-exit")) el("pm-exit").onclick = exitToMenu; // ---- character editor: rewrite a mind's prompts; persists into the level JSON ---- const _CE = (id) => el("ce-" + id); function openCharEdit() { if (!G.edSel || G.edSel.type !== "npc") { flashLog(["select a mind first (click it), then Enter"]); return; } const idx = G.edSel.i; api2get("/api/editor/level").then((lv) => { if (!lv || lv.error) { flashLog([lv && lv.error || "couldn't load the level"]); return; } const role = idx === 0 ? "holder" : "knower"; const c = lv[role]; if (!c) { flashLog(["this level has no " + role]); return; } G.ceTarget = idx; el("ce-title").textContent = `EDIT MIND — ${c.name || role} (${role})`; dom.charedit.classList.toggle("knower", role !== "holder"); _CE("name").value = c.name || ""; _CE("titlef").value = c.title || ""; _CE("gender").value = (c.gender || "").toLowerCase(); _CE("persona").value = c.persona || ""; _CE("voice").value = c.voice || ""; _CE("bio").value = c.biography || ""; _CE("fear").value = c.fear || ""; _CE("approach").value = Array.isArray(c.approach) ? c.approach.join(", ") : (c.approach || ""); _CE("goal").value = c.goal || ""; _CE("keyloc").value = c.key_location || ""; _CE("known").value = (c.known_people || []).join(", "); _CE("needsrep").value = c.needs_reputation != null ? c.needs_reputation : ""; _CE("arousal").value = c.arousal != null ? c.arousal : ""; _CE("life").value = c.life_max != null ? c.life_max : ""; _CE("secrets").value = JSON.stringify(c.secrets || [], null, 2); _CE("err").textContent = ""; dom.charedit.classList.remove("hidden"); }); } function closeCharEdit() { if (dom.charedit) dom.charedit.classList.add("hidden"); } function saveCharEdit() { let secrets; try { secrets = JSON.parse(_CE("secrets").value || "[]"); } catch (err) { _CE("err").textContent = "secrets: " + err.message; return; } if (!Array.isArray(secrets)) { _CE("err").textContent = "secrets must be a JSON list [ ... ]"; return; } const fields = { name: _CE("name").value.trim(), title: _CE("titlef").value.trim(), gender: _CE("gender").value, persona: _CE("persona").value.trim(), voice: _CE("voice").value.trim(), biography: _CE("bio").value.trim(), fear: _CE("fear").value.trim(), known_people: _CE("known").value, secrets, }; if (G.ceTarget === 0) { fields.approach = _CE("approach").value; fields.goal = _CE("goal").value.trim() || "the key"; fields.key_location = _CE("keyloc").value.trim(); } for (const [id, key] of [["needsrep", "needs_reputation"], ["arousal", "arousal"], ["life", "life_max"]]) { const v = _CE(id).value; fields[key] = v === "" ? "" : +v; } api("/api/editor/character", { char_id: G.ceTarget, fields }).then((r) => { if (r && r.error) { _CE("err").textContent = r.error; return; } closeCharEdit(); applyState(r); // the rebuilt room, edited mind live flashLog(["mind saved → config/story/" + (r.saved || "")]); }); } function api2get(path) { return fetch(path).then((r) => r.json()).catch(() => null); } if (el("ce-save")) el("ce-save").onclick = saveCharEdit; if (el("ce-cancel")) el("ce-cancel").onclick = closeCharEdit; function edBrushStep(dir) { const i = Math.max(0, EDIT_BRUSH.indexOf(G.edBrushKey)); G.edBrushKey = EDIT_BRUSH[(i + dir + EDIT_BRUSH.length) % EDIT_BRUSH.length]; G.edArmed = true; // cycling the brush arms it — the next empty click places if (dom.objpicker && !dom.objpicker.classList.contains("hidden")) buildObjPicker(dom.opSearch.value); flashLog(["brush: " + G.edBrushKey]); } function edZ(dir) { // change draw order (Z) without moving the object if (!G.edSel || G.edSel.type !== "obj") { flashLog(["select an object first (click it)"]); return; } const o = G.edSel.o; if (o.sortY == null) o.sortY = o.y; o.sortY = Math.round(o.sortY + dir * 8); flashLog(["Z (sortY): " + o.sortY + (o.sortY < o.y ? " — further back" : o.sortY > o.y ? " — further front" : " — at its feet")]); } function edLayer() { // coarse layer: among objects / on the wall / on the floor if (!G.edSel || G.edSel.type !== "obj") { flashLog(["select an object first"]); return; } const o = G.edSel.o; const cur = o.background ? "wall" : o.floor ? "floor" : "object"; const next = { object: "wall", wall: "floor", floor: "object" }[cur]; delete o.background; delete o.floor; if (next === "wall") o.background = true; else if (next === "floor") o.floor = true; flashLog(["layer: " + next]); } function edBark() { // a bark belongs to THIS placed prop, not to its kind if (!G.edSel || G.edSel.type !== "obj") { flashLog(["select an object first (click it)"]); return; } const o = G.edSel.o; const t = window.prompt("Bark — what the protagonist mutters when stepping close (empty = remove):", o.bark || ""); if (t === null) return; // cancelled if (t.trim()) { o.bark = t.trim(); flashLog(["bark set on this " + o.key]); } else { delete o.bark; flashLog(["bark removed from this " + o.key]); } if (G.barked) G.barked.clear(); // re-arm so the new bark can be tested at once } function edBuildLayout() { const objects = OBJ_LIST.filter((o) => o.key !== "door").map((o) => { const m = { key: o.key, x: Math.round(o.x), y: Math.round(o.y), h: o.h }; for (const f of EDIT_FIELDS) if (o[f] !== undefined) m[f] = o[f]; return m; }); const stations = (G.stations || []).map((s) => ({ x: Math.round(s.x), y: Math.round(s.y) })); const L = { theme: G.edTheme, doors: (G.art && G.art.doors) || { top: true }, objects, stations }; if (G.art) { // mixed materials + mood survive the save if (G.art.tileset) L.tileset = G.art.tileset; if (G.art.floorTileset) L.floorTileset = G.art.floorTileset; if (G.art.mood) L.mood = G.art.mood; if (G.art.windowLight) L.windowLight = G.art.windowLight; } return L; } function edSave() { api("/api/editor/save", { layout: edBuildLayout() }).then((r) => flashLog([r && r.saved ? ("saved → config/story/" + r.saved) : ((r && r.error) || "save failed")])); } // --- searchable object picker --- function buildObjPicker(q) { if (!dom.opGrid) return; q = (q || "").toLowerCase(); const items = EDIT_BRUSH.filter((k) => k.includes(q)); dom.opGrid.innerHTML = items.length ? items.map((k) => `
` + `` + `${k}
`).join("") : `
no match
`; [...dom.opGrid.querySelectorAll(".op-item")].forEach((it) => { it.onclick = () => { G.edBrushKey = it.dataset.k; G.edArmed = true; buildObjPicker(dom.opSearch.value); toggleObjPicker(false); flashLog(["brush armed: " + G.edBrushKey + " — click the room to place (Esc to drop)"]); }; }); } function toggleObjPicker(force) { if (!dom.objpicker) return; const open = force != null ? force : dom.objpicker.classList.contains("hidden"); dom.objpicker.classList.toggle("hidden", !open); if (open) { dom.opSearch.value = ""; buildObjPicker(""); setTimeout(() => dom.opSearch.focus(), 0); } else dom.opSearch.blur(); } if (dom.opSearch) { dom.opSearch.addEventListener("input", () => buildObjPicker(dom.opSearch.value)); dom.opSearch.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Escape") toggleObjPicker(false); }); } function edPos(e) { const r = cv.getBoundingClientRect(); return { x: (e.clientX - r.left) * (W / r.width), y: (e.clientY - r.top) * (H / r.height) }; } cv.addEventListener("pointerdown", (e) => { if (!G.editor) return; e.preventDefault(); const p = edPos(e); let sel = null; G.stations.forEach((s, i) => { if (Math.hypot(p.x - s.x, p.y - s.y) < 26) sel = { type: "npc", i }; }); if (!sel) for (let i = OBJ_LIST.length - 1; i >= 0; i--) { const o = OBJ_LIST[i]; if (o.key !== "door" && Math.hypot(p.x - o.x, p.y - o.y) < 30) { sel = { type: "obj", o }; break; } } if (!sel) { // empty ground: with the brush ARMED (picker / [ ]) a click places the prop; otherwise it // deselects. Alt+click / double-click always place, armed or not. if (G.edArmed || e.altKey || e.detail >= 2) { const key = G.edBrushKey; const o = { key, x: Math.round(p.x), y: Math.round(p.y), ...(PROP_DEFAULTS[key] || { h: 40 }) }; OBJ_LIST.push(o); sel = { type: "obj", o }; edHydrate(o); // a fresh prop must live fully: image, anim frames, collision box } else { G.edSel = null; G.edDrag = false; return; } } else { G.edArmed = false; // touching an existing thing switches from placing to arranging } G.edSel = sel; G.edDrag = true; if (sel.type === "npc" && e.detail >= 2) { G.edDrag = false; openCharEdit(); } // dbl-click a mind → its prompts }); // a prop placed (or moved) in the editor must behave exactly like a room-built one — load its // sprite + animation frames, and keep its collision footprint glued to where it actually is. async function edHydrate(o) { if (o.solid) o.box = footprint(o); if (!OBJECTS[o.key]) { const im = await loadImg("/static/room/objects/" + o.key + ".png"); if (im) OBJECTS[o.key] = prep(im); } if (o.anim && !o.frames) { const frames = await loadFrames("room/objects/" + o.anim, 24); if (frames.length) o.frames = frames; } } window.addEventListener("pointermove", (e) => { if (!G.editor || !G.edDrag || !G.edSel) return; const p = edPos(e); const x = Math.round(clamp(p.x, PLAY.x0, PLAY.x1)), y = Math.round(clamp(p.y, PLAY.y0, PLAY.y1)); if (G.edSel.type === "obj") { const o = G.edSel.o; o.x = x; o.y = y; if (o.sortY != null) o.sortY = y; if (o.solid) o.box = footprint(o); // collision (and the debug rect) move WITH the prop } else G.stations[G.edSel.i] = { x, y }; }); window.addEventListener("pointerup", () => { G.edDrag = false; }); function edCycleTheme() { G.edTheme = (G.edTheme + 1) % THEMES.length; const th = THEMES[G.edTheme]; if (G.art) { G.art.tileset = th.tileset; G.art.mood = th.mood; G.art.windowLight = th.windowLight; G.art.floorTileset = null; } G.edWallIdx = G.edTheme; G.edFloorIdx = null; FLOORSET.ready = false; // theme resets both halves TILESET.ready = false; loadTileset(th.tileset); flashLog(["theme " + G.edTheme + " — " + (th.name || th.tileset.split("/").pop())]); } function edCycleWall() { // W: walls (+transitions) only; mood/light stay G.edWallIdx = ((G.edWallIdx == null ? G.edTheme : G.edWallIdx) + 1) % THEMES.length; const th = THEMES[G.edWallIdx]; if (G.art) G.art.tileset = th.tileset; TILESET.ready = false; loadTileset(th.tileset); flashLog(["walls: " + th.tileset.split("/").pop()]); } function edCycleFloor() { // F: floor material only; wraps back to "same as walls" G.edFloorIdx = (G.edFloorIdx == null ? 0 : G.edFloorIdx + 1); if (G.edFloorIdx >= THEMES.length) { G.edFloorIdx = null; if (G.art) G.art.floorTileset = null; FLOORSET.ready = false; flashLog(["floor: same as walls"]); return; } const th = THEMES[G.edFloorIdx]; if (G.art) G.art.floorTileset = th.tileset; loadTileset(th.tileset, FLOORSET); flashLog(["floor: " + th.tileset.split("/").pop()]); } function edDelete() { if (G.edSel && G.edSel.type === "obj") { const i = OBJ_LIST.indexOf(G.edSel.o); if (i >= 0) OBJ_LIST.splice(i, 1); G.edSel = null; } } function edNudge(arrow) { if (!G.edSel) return; const d = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] }[arrow]; if (!d) return; const p = G.edSel.type === "obj" ? G.edSel.o : G.stations[G.edSel.i]; p.x = Math.round(clamp(p.x + d[0], PLAY.x0, PLAY.x1)); p.y = Math.round(clamp(p.y + d[1], PLAY.y0, PLAY.y1)); if (G.edSel.type === "obj") { if (G.edSel.o.sortY != null) G.edSel.o.sortY = p.y; if (G.edSel.o.solid) G.edSel.o.box = footprint(G.edSel.o); } } function edExport() { const layout = edBuildLayout(); const json = JSON.stringify(layout, null, 2); console.log('LEVEL LAYOUT JSON (paste under "layout"):\n' + json); const n = layout.objects.length, s = layout.stations.length; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(json).then( () => flashLog(["layout JSON copied (" + n + " props, " + s + " NPCs) — paste under \"layout\""]), () => flashLog(["layout JSON in console (clipboard blocked) — copy it there"])); } else flashLog(["layout JSON printed to console (open devtools)"]); } function drawEditorHUD() { if (!G.editor) return; ctx.save(); let selinfo = ""; if (G.edSel) { const p = G.edSel.type === "obj" ? G.edSel.o : G.stations[G.edSel.i]; if (p) { ctx.strokeStyle = "#ffd866"; ctx.lineWidth = 2; ctx.strokeRect(p.x - 17, p.y - 17, 34, 34); } if (G.edSel.type === "obj") { const o = G.edSel.o, layer = o.background ? "wall" : o.floor ? "floor" : "object"; selinfo = ` · sel:${o.key} (${layer})`; } else selinfo = " · sel:NPC"; } ctx.fillStyle = "rgba(0,0,0,0.72)"; ctx.fillRect(0, 0, W, 18); ctx.fillStyle = "#ffd866"; ctx.font = "10px ui-monospace, monospace"; ctx.textAlign = "left"; ctx.fillText(`EDITOR · brush: ${G.edBrushKey}${G.edArmed ? " ● ARMED — click to place" : ""} · H help · S save · L exit${selinfo}`, 5, 13); ctx.restore(); } el("moral-restart").addEventListener("click", restart); function interact() { if (G.data && G.data.run && G.data.run.over) { flashLog(["No way forward remains. Press R to begin again."]); return; } if (G.near.npc >= 0) { const c = G.chars[G.near.npc]; if (c.engageable) openTalk(G.near.npc); else flashLog([c.why || `${c.name} won't speak to you.`]); return; } if (G.near.key) { pickUpKey(); return; } if (G.near.terminal) { openTerminal(); return; } if (G.near.door) { const solved = G.data && !G.data.door.locked; if (!solved) { flashLog([G.hasKey && G.data && G.data.terminal && !G.data.terminal.unlocked ? "The key turns, but a second lock holds — the records terminal still wants its name." : "The door is locked. The way out runs through the minds in this room."]); return; } if (!G.hasKey) { flashLog(["The key is yours now — pick it up first."]); return; } if (!G.doorOpen) { G.doorOpen = true; flashLog(["You turn the key. The door swings half-open."]); return; } goNextRoom(); } } // ---------------------------------------------------------------------------- dialogue function openTalk(id) { G.mode = "talk"; G.activeId = id; G.inputTarget = "talk"; const c = G.chars[id]; if (G.lastBrainCharId !== id) { G.lastBrain = null; G.lastBrainCharId = id; } // no stale skulls clearReveal(); dom.prompt.classList.add("hidden"); // the "Press E" hint isn't refreshed in talk mode dom.dialogue.classList.remove("hidden"); dom.speaker.textContent = `${c.name}${c.title ? " — " + c.title : ""}`; dom.reply.textContent = `(${c.decision}) …`; dom.reply.className = "muted"; clearBrain(); if (dom.brainBtn) dom.brainBtn.classList.remove("hidden"); updateDlgLife(); drawPortrait(id); dom.input.placeholder = "Speak plainly… (Enter to say, Esc to step back)"; focusInputClean(); } function openTerminal() { const t = G.data.terminal || {}; G.mode = "talk"; G.inputTarget = "terminal"; G.activeId = -1; dom.prompt.classList.add("hidden"); dom.dialogue.classList.remove("hidden"); dom.speaker.textContent = "▣ TERMINAL"; clearBrain(); if (dom.brainBtn) dom.brainBtn.classList.add("hidden"); updateDlgLife(); drawPortrait(-1); if (t.browser) { // flavor terminal: a read-only file listing, no code lock G.inputTarget = "terminal-browser"; dom.reply.textContent = t.listing || ""; dom.reply.className = "muted listing"; dom.inputRow.classList.add("hidden"); return; } dom.reply.textContent = t.prompt || ""; dom.reply.className = "muted"; dom.input.placeholder = "Enter a name…"; focusInputClean(); } function focusInputClean() { // focus on the next tick + re-clear, so the triggering 'E' keydown dom.input.value = ""; // can never type itself into the box ("eelias" → rejected) setTimeout(() => { dom.input.value = ""; dom.input.focus(); }, 0); } function closeTalk() { G.mode = "walk"; G.activeId = -1; G.inputTarget = null; clearReveal(); dom.dialogue.classList.add("hidden"); dom.brainPanel.classList.add("hidden"); dom.inputRow.classList.remove("hidden"); // the browser terminal hides it dom.input.blur(); } async function submit() { const text = dom.input.value.trim(); if (!text || G.busy) return; if (text[0] === "/") { handleSlash(text); dom.input.value = ""; return; } // command, not speech G.busy = true; dom.send.disabled = true; dom.input.value = ""; // the line is said — the field empties at once if (G.inputTarget === "talk") showThinkingCascade(); // diegetic wait: the regions wake in order let res; if (G.inputTarget === "terminal") res = await api("/api/terminal", { code: text }); else res = await api("/api/talk", { char_id: G.activeId, message: text }); G.busy = false; dom.send.disabled = false; if (res.reply !== undefined) { G.lastBrain = res; G.lastBrainCharId = G.activeId; playTalkResult(res); } else if (res.blocked !== undefined || res.error !== undefined) { // a refusal/error must never leave the box stuck on "thinking…" — say it, then step back const refusal = String(res.blocked || res.error); if (G.inputTarget === "talk") { showReply(res.blocked ? ((G.chars[G.activeId] || {}).name || "…") : "⚠ SYSTEM", refusal); dom.reply.className = "muted"; const id = G.activeId; setTimeout(() => { if (G.mode === "talk" && G.activeId === id) closeTalk(); }, 2000); } else flashLog([refusal]); } if (res.events) flashLog(res.events); const granted = G.inputTarget === "terminal" && (res.events || []).some((ev) => String(ev).includes("ACCESS GRANTED")); applyState(res); if (res.run && res.run.over) return; if (G.inputTarget === "talk") { dom.input.focus(); return; } if (granted) { // hold the green confirmation ~2s before the modal closes dom.reply.textContent = "▣ ACCESS GRANTED"; dom.reply.className = "granted"; dom.input.blur(); // no second submit while the confirmation holds setTimeout(() => { if (G.inputTarget === "terminal") closeTalk(); }, 2000); return; } closeTalk(); } // one talk result → the staged performance: damage float, cascade reveal, flip banner, typed reply function playTalkResult(res) { const st = G.stations[G.activeId]; if (st && res.burned) G.floaters.push({ x: st.x, y: st.y - 40, t0: performance.now(), text: "-" + res.burned, col: "#ff5555" }); if (st && res.forgot && res.forgot.length) // a memory burned away with the tokens G.floaters.push({ x: st.x, y: st.y - 56, t0: performance.now() + 500, text: "memory lost", col: "#9a6fd0" }); revealCascade(res, () => { if (res.gave_key || res.submitted) showFlipBanner(res); // the key handover is the room's emotional peak — the authored yield line is voiced (VoxCPM2) if (res.gave_key && G.data && G.data.room) Sound.voice(`/static/audio/voices/yield/${G.data.room.depth + 1}.mp3`); typeReply(res.speaker, res.reply, () => { if (res.died) { // the mind is gone — its epitaph is all that leaves the room setTimeout(() => showEpitaph(res), 1100); return; } if (!G.panelSeen) { // first verdict: a hint, not a takeover — the panel is the player's call G.panelSeen = true; flashLog(["🧠 Open the skull (button or /brain) to watch the regions argue. Esc closes."]); } }); }); if (!dom.brainPanel.classList.contains("hidden")) renderBrainPanel(res); } function showReply(name, reply) { dom.speaker.textContent = name; dom.reply.textContent = reply; dom.reply.className = "said"; } function flashLog(events) { G.lastEvents = events || []; for (const e of G.lastEvents) { // stack the last few, each fading out on its own clock const s = document.createElement("span"); s.className = "ev"; s.textContent = "› " + e; dom.log.appendChild(s); setTimeout(() => { s.classList.add("fade"); setTimeout(() => s.remove(), 750); }, 6500); } while (dom.log.children.length > 3) dom.log.removeChild(dom.log.firstChild); } // ------------------------------------------------------------- brain (folded "open the skull") // The six regions argue as a compact strip inside the VN box — each chip carries the region's // HEADLINE SIGNAL (threat 8, reward −3, value +7…), with a delta arrow vs the previous turn for // the same character. The full reasoning stays one hover (or 🧠) away. const SIG_RX = { amygdala: { rx: /threat\s*([+-]?\d+)/i, name: "threat", goodUp: false }, striatum: { rx: /reward\s*([+-]?\d+)/i, name: "reward", goodUp: true }, vmpfc: { rx: /value\s*([+-]?\d+)/i, name: "value", goodUp: true }, }; function sigOf(t) { // headline → comparable number (when the region has one) const s = SIG_RX[t.key]; if (s) { const m = String(t.headline).match(s.rx); if (m) return { name: s.name, num: +m[1], goodUp: s.goodUp }; } if (t.key === "relationship") { const m = String(t.headline).match(/rapport\s*(\d+)\s*→\s*(\d+)/i); if (m) return { name: "rapport", num: +m[2], from: +m[1], goodUp: true }; } return null; } function chipHtml(t, prev) { const sig = sigOf(t); let text = String(t.headline).replace(/\/10\b/, "").replace(/\s+·.*$/, ""); // "threat 8/10" → "threat 8" let cls = ""; if (sig) { const before = sig.from !== undefined ? sig.from : prev[t.key]; if (before != null && before !== sig.num) { const up = sig.num > before; const fv = (n) => (sig.name === "threat" || sig.name === "rapport") ? String(n) : (n > 0 ? "+" + n : String(n)); text = `${sig.name} ${fv(before)}→${fv(sig.num)} ${up ? "▲" : "▼"}`; cls = (up === sig.goodUp) ? " d-good" : " d-bad"; if (sig.name === "value" && (before < 0) !== (sig.num < 0)) cls += " flip"; // sign flip — the moment } } return `${escapeHtml(text)}`; } function clearReveal() { // cancel pending chip reveals + typewriter (stale-safe) (G.revealT || []).forEach(clearTimeout); G.revealT = []; (G.thinkT || []).forEach(clearTimeout); G.thinkT = []; if (G.typeT) { clearInterval(G.typeT); G.typeT = null; } } // The wait IS the fiction: words travel through a real cascade of models, so while they do, // the strip lights the regions up one by one. Never a spinner — a mind, waking. const THINK_STEPS = [ ["amygdala", "#ff5555", "amygdala — listening for danger"], ["hippocampus", "#bd93f9", "hippocampus — searching the past"], ["striatum", "#f1fa8c", "striatum — weighing old habits"], ["acc", "#8be9fd", "ACC — counting the cost"], ["vmpfc", "#50fa7b", "vmPFC — integrating a value"], ["dlpfc", "#f8f8f2", "dlPFC — finding the words"], ]; function showThinkingCascade() { clearReveal(); clearBrain(); dom.reply.textContent = "the words travel inward…"; dom.reply.className = "thinking"; dom.brain.innerHTML = `
`; dom.brain.classList.remove("hidden"); const row = dom.brain.querySelector(".brain-regions"); G.thinkT = []; // pacing: quick at first, then settling — long llama.cpp turns keep the LAST region pulsing, // so however slow the hardware, the screen always shows which department is still thinking let t = 350; THINK_STEPS.forEach(([key, color, label], i) => { G.thinkT.push(setTimeout(() => { const prev = row.querySelector(".think-chip.now"); if (prev) prev.classList.remove("now"); row.insertAdjacentHTML("beforeend", `${label}…`); }, t)); t += 900 + i * 700; // ~0.9s → ~4.4s gaps: six regions cover ~16s of real cascade time }); } // The engine emits the WHOLE argument (raw read → ·checked → ·rumination …) as separate trace // rows; that full transcript belongs in the 🧠 panel. The strip shows one SETTLED chip per // region (last row wins), plus the rare dramatic moments as their own chips. function settledTraces(traces) { const byKey = {}, extras = []; for (const tr of traces) { if (/^\+\d+\s*life/i.test(String(tr.headline))) { extras.push(tr); continue; } // "+30 life" (rest) if (/resonance|broken/i.test(tr.label)) { extras.push(tr); continue; } // "the word lands" / "fear wins" if (/forgotten/i.test(tr.label)) { extras.push(tr); continue; } // "a memory burns away" if (tr.key === "dlpfc") continue; // the spoken reply IS the dlPFC's output byKey[tr.key] = tr; // later rows (checked/rumination) settle the signal } return ["amygdala", "hippocampus", "striatum", "acc", "vmpfc", "relationship"] .filter((k) => byKey[k]).map((k) => byKey[k]).concat(extras); } function revealCascade(res, done) { // regions report one by one — the argument happens live clearReveal(); const traces = settledTraces(res.traces || []); const prev = G.prevSig[G.lastBrainCharId] || {}; dom.brain.innerHTML = `
`; dom.brain.classList.remove("hidden"); const row = dom.brain.querySelector(".brain-regions"); let t = 80; for (const tr of traces) { G.revealT.push(setTimeout(() => row.insertAdjacentHTML("beforeend", chipHtml(tr, prev)), t)); t += 300; } G.revealT.push(setTimeout(() => { // no equation line here — the chips ARE those numbers; the vmPFC breakdown lives in 🧠 const tell = traces.find((x) => x.key === "relationship" && x.lever); let tail = ""; if (tell) tail += `
💡 ${escapeHtml(tell.lever)}` + ` · 🧠 to read the whole mind
`; if (tail) dom.brain.insertAdjacentHTML("beforeend", tail); const sigs = {}; // remember this turn's signals for next-turn deltas for (const tr of traces) { const s = sigOf(tr); if (s) sigs[tr.key] = s.num; } G.prevSig[G.lastBrainCharId] = sigs; if (done) G.revealT.push(setTimeout(done, 240)); }, t)); } function typeReply(name, text, done) { // the verdict spoken only after the regions have argued dom.speaker.textContent = name; dom.reply.className = "saying"; text = String(text); let i = 0; if (G.typeT) clearInterval(G.typeT); G.typeT = setInterval(() => { i += 2; dom.reply.textContent = text.slice(0, i); dom.reply.scrollTop = dom.reply.scrollHeight; // long beats scroll inside the capped box if (i >= text.length) { clearInterval(G.typeT); G.typeT = null; dom.reply.className = "said"; if (done) done(); } }, 26); } function showFlipBanner(res) { // the flip moment, stamped across the canvas if (!dom.flip) return; const c = G.chars.find((x) => x.name === res.speaker) || G.chars[G.activeId] || {}; const broke = !!res.submitted; // coerced yield reads darker than a won one const who = c.gender === "female" ? "SHE BREAKS" : c.gender === "male" ? "HE BREAKS" : "THEY BREAK"; const keyImg = `key`; dom.flip.innerHTML = `${broke ? who : "REFUSE → HELP"}${keyImg}`; dom.flip.classList.toggle("dark", broke); dom.flip.classList.remove("hidden", "fading"); void dom.flip.offsetWidth; // restart the entrance animation if re-shown clearTimeout(G.bannerT); clearTimeout(G.bannerT2); // hold ~2.7s, then a slow ~1.2s fade — a judge has time to actually read the flip G.bannerT = setTimeout(() => dom.flip.classList.add("fading"), 2700); G.bannerT2 = setTimeout(() => dom.flip.classList.add("hidden"), 3900); } function updateDlgLife() { // life readout on the plaque + portrait fading with it if (!dom.life) return; const c = (G.inputTarget === "talk" && G.activeId >= 0) ? G.chars[G.activeId] : null; if (!c) { dom.life.textContent = ""; dom.portrait.style.filter = ""; if (dom.lifebar) dom.lifebar.style.visibility = "hidden"; return; } const pct = c.life_pct != null ? c.life_pct : 100; dom.life.textContent = (c.life != null && c.life_max != null) ? `life ${c.life}/${c.life_max}` : ""; dom.life.style.color = lifeColor(pct); if (dom.lifebar) { // the reservoir drains; a drop flashes the burn dom.lifebar.style.visibility = c.life != null ? "visible" : "hidden"; const bar = dom.lifebar.firstElementChild; bar.style.width = clamp(pct, 0, 100) + "%"; bar.style.background = lifeColor(pct); const prev = G.lastLife && G.lastLife.name === c.name ? G.lastLife.life : null; if (prev != null && c.life < prev) { dom.lifebar.classList.remove("burn"); void dom.lifebar.offsetWidth; dom.lifebar.classList.add("burn"); } G.lastLife = { name: c.name, life: c.life }; } const f = clamp((100 - pct) / 85, 0, 1); // grayscale(0) at full → grayscale(.9) brightness(.7) near death dom.portrait.style.filter = f < 0.02 ? "" : `grayscale(${(f * 0.9).toFixed(2)}) brightness(${(1 - f * 0.3).toFixed(2)})`; } function clearBrain() { dom.brain.classList.add("hidden"); dom.brain.innerHTML = ""; } // ---- /brain : open the skull — the full per-region reasoning, not just the numbers ---- function handleSlash(cmd) { const c = cmd.slice(1).toLowerCase().trim().split(/\s+/)[0]; if (["brain", "skull", "think", "reason", "mind", "мозг", "череп"].includes(c)) { if (!dom.brainPanel.classList.contains("hidden")) { closeBrainPanel(); return; } if (!G.lastBrain) { flashLog(["Speak to a mind first — then /brain opens its skull."]); return; } renderBrainPanel(G.lastBrain); } else { flashLog([`Unknown command: ${cmd} · try /brain`]); } } function renderBrainPanel(res) { const traces = res.traces || []; // each department now hands the player a lever — surface the 3 most actionable as a "how to // reach them" digest up top, then the full per-region read (reasoning + its lever) below. const leverOf = (k) => (traces.find((t) => t.key === k && t.lever) || {}).lever || ""; const reach = ["relationship", "hippocampus", "amygdala"] .map(leverOf).filter(Boolean) .map((l) => `
  • ${escapeHtml(l)}
  • `).join(""); // conviction = 1 − the region's own token entropy, read straight off llama.cpp's logits — // a hosted chat API would never hand this out; only a local mind can show how sure it is. const convHtml = (t) => { if (t.conviction == null) return ""; const pct = Math.round(t.conviction * 100); return ` · ` + `conviction ${pct}%`; }; const rows = traces.map((t) => `
    ${escapeHtml(t.label)} ${escapeHtml(t.headline)}
    ${escapeHtml(t.detail)}
    ${t.lever ? `
    ▶ ${escapeHtml(t.lever)}
    ` : ""}
    ${t.tokens} tokens spent${convHtml(t)}
    `).join(""); const verdict = res.gave_key ? "🔑 KEY GIVEN" : res.verdict; // what this mind has lost: memories burned away with its life — gone for good const ch = (res.characters || []).find((x) => x.name === res.speaker); const lost = (ch && ch.forgotten) || []; const lostHtml = lost.length ? `
    🕯 forgotten — burned away with its life
    ` + `
    ` : ""; dom.brainPanel.innerHTML = `
    🧠 inside ${escapeHtml(res.speaker || "the mind")}'s skull` + `/brain or Esc to close
    ` + (reach ? `
    🗝️ how to reach them
    ` + `
    ` : "") + rows + lostHtml + `
    → ${escapeHtml(verdict)}` + `
    burned ${res.burned} tokens · ${res.seconds}s of thinking
    `; dom.brainPanel.classList.remove("hidden"); } function closeBrainPanel() { dom.brainPanel.classList.add("hidden"); if (G.mode === "talk") dom.input.focus(); } // ---------------------------------------------------------------- instant demo (recorded mind) // A real session, recorded once against the live models, replays through the SAME dialogue UI — // so a judge sees the six-region cascade, the token burn, the death and the epitaph immediately, // with zero llama.cpp calls in the cursor path. The whole point: the spectacle before the wait. const Demo = (() => { const TRACKS = { warm: "/static/demo/room1-session.json", cruel: "/static/demo/room1-cruel.json", }; let cache = {}, beats = [], idx = 0, which = "warm", busy = false; function drawPortrait(url) { const pc = dom.portrait, p = pc.getContext("2d"), w = pc.width, h = pc.height; p.clearRect(0, 0, w, h); p.fillStyle = "#0a0c14"; p.fillRect(0, 0, w, h); if (!url) return; const img = PORTRAIT_CACHE[url]; if (img && img !== "loading") { const s = Math.max(w / img.width, h / img.height); const dw = img.width * s, dh = img.height * s; p.imageSmoothingEnabled = true; p.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh); } else if (!img) { PORTRAIT_CACHE[url] = "loading"; const im = new Image(); im.onload = () => { PORTRAIT_CACHE[url] = im; if (!el("dlg-demo").classList.contains("hidden")) drawPortrait(url); }; im.onerror = () => { PORTRAIT_CACHE[url] = null; }; im.src = url; } } function life(ch) { const pct = ch.life_pct != null ? ch.life_pct : 100; dom.life.textContent = `life ${ch.life}/${ch.life_max}`; dom.life.style.color = lifeColor(pct); if (dom.lifebar) { dom.lifebar.style.visibility = "visible"; const bar = dom.lifebar.firstElementChild; bar.style.width = clamp(pct, 0, 100) + "%"; bar.style.background = lifeColor(pct); } const f = clamp((100 - pct) / 85, 0, 1); dom.portrait.style.filter = f < 0.02 ? "" : `grayscale(${(f * 0.9).toFixed(2)}) brightness(${(1 - f * 0.3).toFixed(2)})`; } function show(i) { if (!beats.length) return; idx = Math.max(0, Math.min(i, beats.length - 1)); const b = beats[idx]; const res = { ...b, characters: [b.char] }; // shape the skull/epitaph helpers expect G.lastBrain = res; G.lastBrainCharId = "demo-" + which; clearReveal(); dom.dialogue.classList.remove("hidden"); dom.speaker.textContent = `${b.speaker}${b.char.title ? " — " + b.char.title : ""}`; if (dom.brainBtn) dom.brainBtn.classList.remove("hidden"); drawPortrait(b.portrait); life(b.char); el("demo-count").textContent = `${idx + 1}/${beats.length}`; el("demo-cap").innerHTML = `you said: “${escapeHtml(b.player)}”`; busy = true; revealCascade(res, () => { if (b.gave_key) showFlipBanner(res); Sound.voice(`/static/audio/voices/demo/${which}-${idx + 1}.mp3`); // the mind speaks (VoxCPM2) typeReply(b.speaker, b.reply, () => { busy = false; if (b.died) setTimeout(() => showEpitaph(res), 900); }); }); if (!dom.brainPanel.classList.contains("hidden")) renderBrainPanel(res); } function next() { if (busy) { // a click mid-reveal: snap to the finished beat first clearReveal(); const b = beats[idx]; typeReply(b.speaker, b.reply, () => { busy = false; }); busy = false; return; } if (idx + 1 < beats.length) show(idx + 1); } async function load(track) { if (!cache[track]) { const d = await (await fetch(TRACKS[track])).json(); d.beats.forEach((b) => { b.char.portrait = b.portrait; }); // static portrait into the dead-mind card cache[track] = d.beats; } return cache[track]; } async function pick(track) { which = track; el("demo-warm").classList.toggle("on", track === "warm"); el("demo-cruel").classList.toggle("on", track === "cruel"); beats = await load(track); el("epitaph").classList.add("hidden"); G.epitaphOpen = false; show(0); } async function open() { el("menu").classList.add("hidden"); dom.inputRow.classList.add("hidden"); // a recording: the player watches, doesn't type el("dlg-demo").classList.remove("hidden"); // the transport lives inside the dialogue frame Sound.game().then(() => {}); G.mode = "demo"; await pick("warm"); } function exit() { el("dlg-demo").classList.add("hidden"); dom.dialogue.classList.add("hidden"); dom.brainPanel.classList.add("hidden"); dom.inputRow.classList.remove("hidden"); el("epitaph").classList.add("hidden"); G.epitaphOpen = false; clearReveal(); clearBrain(); G.mode = "menu"; el("menu").classList.remove("hidden", "closing"); } return { open, exit, next, pick, skull() { if (dom.brainPanel.classList.contains("hidden")) renderBrainPanel(G.lastBrain); else closeBrainPanel(); }, jump(i) { show(i); }, // dev/video: seek to a specific beat count() { return beats.length; }, isOpen() { return !el("dlg-demo").classList.contains("hidden"); } }; })(); window.__demo = Demo; // dev handle (recording/testing): seek beats el("demo-next").addEventListener("click", () => Demo.next()); el("demo-warm").addEventListener("click", () => Demo.pick("warm")); el("demo-cruel").addEventListener("click", () => Demo.pick("cruel")); el("demo-exit").addEventListener("click", () => Demo.exit()); el("demo-play").addEventListener("click", () => { Demo.exit(); el("menu").classList.add("closing"); setTimeout(() => el("menu").classList.add("hidden"), 560); Sound.game().then(() => {}); api("/api/start", { mode: "story" }).then(applyState); }); document.addEventListener("keydown", (e) => { if (!Demo.isOpen()) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); Demo.next(); } else if (e.key === "Escape") { e.preventDefault(); Demo.exit(); } else if (e.key.toLowerCase() === "b") { e.preventDefault(); Demo.skull(); } }); // ---- Epitaph: the card a dead mind leaves behind (savable PNG) ---- // Everything it knew — the memories that burned on the way down, the secrets it never told — // is gone for real: there is no reload. The card is the only thing that leaves the room. function epWrap(ctx, text, maxW) { const words = String(text).split(/\s+/), lines = []; let line = ""; for (const w of words) { const t = line ? line + " " + w : w; if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t; } if (line) lines.push(line); return lines; } function renderEpitaph(ch, portraitImg) { const cv = el("ep-canvas"), ctx = cv.getContext("2d"); const W = cv.width, H = cv.height, CX = W / 2; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#0b0810"; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = "#e8c25a"; ctx.lineWidth = 2; ctx.strokeRect(7, 7, W - 14, H - 14); ctx.strokeStyle = "#2a1d0e"; ctx.strokeRect(11, 11, W - 22, H - 22); ctx.textAlign = "center"; ctx.fillStyle = "#8a8270"; ctx.font = '15px "VT323", monospace'; ctx.fillText("H E R E W E N T O U T A M I N D", CX, 34); // portrait, drained of its color the way the mind was drained of its life const px = CX - 58, py = 46, ps = 116; ctx.fillStyle = "#0a0c14"; ctx.fillRect(px, py, ps, ps); if (portraitImg) { ctx.save(); ctx.filter = "grayscale(1) brightness(.72)"; const s = Math.max(ps / portraitImg.width, ps / portraitImg.height); const dw = portraitImg.width * s, dh = portraitImg.height * s; ctx.beginPath(); ctx.rect(px, py, ps, ps); ctx.clip(); ctx.drawImage(portraitImg, px + (ps - dw) / 2, py + (ps - dh) / 2, dw, dh); ctx.restore(); } else { ctx.fillStyle = "#2a2438"; ctx.font = '64px "VT323", monospace'; ctx.fillText("🕯", CX, py + 82); } ctx.strokeStyle = "#e8c25a"; ctx.lineWidth = 2; ctx.strokeRect(px, py, ps, ps); ctx.fillStyle = "#e8c25a"; ctx.font = '28px "VT323", monospace'; ctx.fillText(ch.name || "a mind", CX, py + ps + 32); if (ch.title) { ctx.fillStyle = "#8a8270"; ctx.font = '16px "VT323", monospace'; ctx.fillText(ch.title, CX, py + ps + 52); } ctx.fillStyle = "#ff5555"; ctx.font = '15px "VT323", monospace'; ctx.fillText(`0 / ${ch.life_max || 1000} tokens — it burned out`, CX, py + ps + 78); let y = py + ps + 102; ctx.strokeStyle = "#2a1d0e"; ctx.beginPath(); ctx.moveTo(40, y - 14); ctx.lineTo(W - 40, y - 14); ctx.stroke(); ctx.fillStyle = "#b79ae0"; ctx.font = '16px "VT323", monospace'; ctx.fillText("what died with it", CX, y); y += 20; ctx.textAlign = "left"; ctx.font = "12px ui-monospace, monospace"; const items = (ch.forgotten || []).slice(-4).map((f) => "· " + f); if (ch.secrets_untold) items.push(`· ${ch.secrets_untold} secret${ch.secrets_untold > 1 ? "s" : ""} it never got to tell`); items.push("· and everything else it knew"); for (const it of items) { ctx.fillStyle = it.includes("everything else") ? "#6f6857" : "#8a7f96"; for (const ln of epWrap(ctx, it, W - 88)) { if (y > H - 56) break; ctx.fillText(ln, 44, y); y += 16; } y += 3; } ctx.textAlign = "center"; ctx.fillStyle = "#e8c25a"; ctx.font = '14px "VT323", monospace'; ctx.fillText("mAIndlock · a life measured in words", CX, H - 26); } function showEpitaph(res) { const ch = (res.characters || []).find((x) => x.name === res.speaker); if (!ch) return; G.epitaphOpen = true; renderEpitaph(ch, null); el("epitaph").classList.remove("hidden"); if (ch.portrait) { // redraw once the portrait arrives const img = new Image(); img.onload = () => { if (G.epitaphOpen) renderEpitaph(ch, img); }; img.src = ch.portrait; } } el("ep-close").addEventListener("click", () => { G.epitaphOpen = false; el("epitaph").classList.add("hidden"); }); el("ep-save").addEventListener("click", () => { const name = (el("ep-canvas") && G.lastBrain && G.lastBrain.speaker || "mind").toLowerCase().replace(/\W+/g, "-"); const a = document.createElement("a"); a.download = `maindlock-epitaph-${name}.png`; a.href = el("ep-canvas").toDataURL("image/png"); a.click(); }); // ----------------------------------------------------------------------------- VN portrait // Authored cast → painterly FLUX portrait (cover-fit, smoothed). Procedural NPC (no portrait yet) // → its own sprite zoomed in (pixelated). Terminal → a green ▣ motif. const PORTRAIT_CACHE = {}; // url -> Image | null | "loading" function drawPortrait(charIndex) { const pc = dom.portrait, p = pc.getContext("2d"), w = pc.width, h = pc.height; p.clearRect(0, 0, w, h); p.fillStyle = "#0a0c14"; p.fillRect(0, 0, w, h); if (G.inputTarget === "terminal") { // terminal: no face, green readout motif p.fillStyle = "#0a140d"; p.fillRect(0, 0, w, h); p.fillStyle = "#50fa7b"; p.font = "90px ui-monospace, monospace"; p.textAlign = "center"; p.textBaseline = "middle"; p.fillText("▣", w / 2, h / 2); return; } const c = G.chars[charIndex]; if (!c) return; if (c.portrait) { // FLUX raster portrait // /api/portrait/{id} is room-relative — id 0 is a different person each room — so key the // cache (and bust the browser cache) per character identity, not by the reused URL. const ident = c.sprite_key || normName(c.name); const key = c.portrait + "|" + ident; let im = PORTRAIT_CACHE[key]; if (im === undefined) { PORTRAIT_CACHE[key] = "loading"; const url = c.portrait + (c.portrait.includes("?") ? "&" : "?") + "k=" + encodeURIComponent(ident); loadImg(url).then((img) => { PORTRAIT_CACHE[key] = img; if (G.mode === "talk" && G.activeId === charIndex) drawPortrait(charIndex); }); im = "loading"; } if (im && im !== "loading") { coverDraw(p, im, w, h); return; } // while loading (or on failure) fall through to the sprite so the frame is never blank } const sp = NPC_SPRITES[c.spriteKey || normName(c.name)]; // fallback: zoom the NPC's own sprite const entry = sp && sp.ready ? (sp.dir["south"] || Object.values(sp.dir)[0]) : null; if (entry) { p.imageSmoothingEnabled = false; const b = entry.bbox; const scale = Math.min(w / b.w, h / b.h) * 1.7; // fill the frame, head near the top const dw = b.w * scale, dh = b.h * scale; p.drawImage(entry.img, b.x, b.y, b.w, b.h, Math.round((w - dw) / 2), Math.round(h * 0.12), dw, dh); } else { p.fillStyle = "#4a5066"; p.font = "80px ui-monospace, monospace"; p.textAlign = "center"; p.textBaseline = "middle"; p.fillText("?", w / 2, h / 2); } } function coverDraw(c2, img, w, h) { // cover-fit, smoothed (painterly portraits downscale cleanly) c2.imageSmoothingEnabled = true; const s = Math.max(w / img.width, h / img.height); const dw = img.width * s, dh = img.height * s; c2.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh); } // ----------------------------------------------------------------------- room / restart async function goNextRoom() { if (G.busy) return; G.busy = true; setPrompt("the way opens…"); const res = await api("/api/next-room", {}); G.busy = false; if (res.events) flashLog(res.events); applyState(res); } async function restart() { const res = await api("/api/reset", {}); dom.moral.classList.add("hidden"); clearTimeout(G.moralTimer); G.moralTimer = null; G.roomSlug = null; // force room-art rebuild applyState(res); // closes the VN box + clears the brain for the fresh run clearBrain(); } function reRoll() { // dev: re-roll auto-placement of the current room if (!G.art) return; const layout = autoLayout(G.art.manifest, G.art.doors, Math.floor(Math.random() * 1e9), G.chars.length); if (layout) { G.art = { ...G.art, objects: layout.objects, stations: layout.stations }; OBJ_LIST = layout.objects; G.stations = layout.stations; flashLog(["🎲 auto-placed: reachable, no door-blocks, no overlaps"]); } else flashLog(["auto-place failed — kept layout"]); G.issues = roomIssues(); } const PROC_NAMES = ["A Forgotten Ward", "The Boiler Room", "A Sealed Office", "The Dispensary", "An Old Cellar", "The Morgue Annex", "A Locked Archive"]; function devProceduralRoom() { // dev: assemble a procedural room from the theme library const name = PROC_NAMES[Math.floor(Math.random() * PROC_NAMES.length)]; G.roomSlug = "proc:" + name + ":" + Math.floor(Math.random() * 1e6); G.art = resolveRoom(name, G.chars.length, Math.floor(Math.random() * 1e9)); OBJ_LIST = G.art.objects; G.stations = G.art.stations; const MALE = ["Elias", "Tomas", "Viktor", "Hugo", "Cyrus", "Bram", "Sol", "Otto"]; const FEMALE = ["Mara", "Nadia", "Iris", "Petra", "Ada", "Dora", "Noor", "Vera"]; const ROLE = ["the keeper", "the night nurse", "the records clerk", "a former patient", "the orderly", "the widow", "an old guard", "the cousin"]; const mp = MALE.slice(), fp = FEMALE.slice(); G.chars = G.chars.map((c) => { // gendered name + matching sprite (consistent) const g = Math.random() < 0.5 ? "m" : "f", names = g === "f" ? fp : mp; const cast = NPC_CAST.filter((x) => x.g === g), arr = cast.length ? cast : NPC_CAST; return { ...c, name: names.splice(Math.floor(Math.random() * names.length), 1)[0] || c.name, gender: g === "f" ? "female" : "male", title: ROLE[Math.floor(Math.random() * ROLE.length)], portrait: null, // generated NPC has no FLUX portrait → sprite-zoom fallback spriteKey: arr[Math.floor(Math.random() * arr.length)].key }; }); if (G.data) G.data.room = { ...G.data.room, name }; dom.room.textContent = name + " (procedural)"; G.player = { x: W / 2, y: PLAY.y1 - 50 }; TILESET.ready = false; loadTileset(G.art.tileset); FLOORSET.ready = false; loadObjects(); closeTalk(); clearBrain(); G.hasKey = false; G.doorOpen = false; G.keyItem = null; G.issues = roomIssues(); } async function devGenerate() { // dev: real LLM-generated procedural room (names + stories + gender) flashLog(["generating a new room (LLM)…"]); G.roomSlug = null; // force room-art rebuild for the new room const res = await api("/api/dev/generate", {}); if (res.error) { flashLog([res.error]); return; } applyState(res); } async function devRoster() { // dev: room assembled from minted roster (real faces + stories) flashLog(["assembling a room from the roster…"]); G.roomSlug = null; // force room-art rebuild const res = await api("/api/dev/roster", {}); if (res.error) { flashLog([res.error]); return; } applyState(res); } async function gotoRoom(idx) { // dev: jump straight to a room (skips solving) dom.moral.classList.add("hidden"); clearTimeout(G.moralTimer); G.moralTimer = null; G.roomSlug = null; // force room-art rebuild closeTalk(); clearBrain(); applyState(await api("/api/dev/room", { idx })); } function stageMoral(d) { // run over: the WIN gets the black epilogue card; a death stays in if (!dom.moral.classList.contains("hidden") || G.moralTimer || G.deathBarked) return; closeTalk(); if (d.run.won) { // walked out — let the open door breathe ~3s, then the card G.moralTimer = setTimeout(() => { G.moralTimer = null; showMoral(d.run.moral); }, 3000); return; } // a death: no modal — the body lies in the room, the verdict is spoken over it, and after a // few seconds the player is free to walk among what they've done. R starts over. G.deathBarked = true; const crit = (G.lastEvents || []).find((e) => /key is lost/i.test(e)) || (G.lastEvents || [])[0]; if (crit) showBark(crit); const line = String(d.run.moral || "").split("\n").map(s => s.trim()).filter(Boolean)[0] || ""; setTimeout(() => { showBark(line + " · Press R to begin again."); setTimeout(hideBark, 4000); // then the room is theirs to wander }, 4200); } function showMoral(text) { dom.moralText.textContent = text || ""; // server-side moral varies by how the run ended dom.moral.classList.remove("hidden"); closeTalk(); } // -------------------------------------------------------------------------------- loop function update(dt) { if (G.editor) { G.moving = false; return; } // layout editor: freeze the player, drag with mouse // after a death the run is over but the ROOM stays open — the player may walk among what // they've done; only the win epilogue card (or the pause menu) freezes the world. if (G.mode !== "walk" || !dom.moral.classList.contains("hidden") || (dom.pause && !dom.pause.classList.contains("hidden"))) { G.moving = false; return; } let dx = 0, dy = 0; const k = G.keys; if (k.has("a") || k.has("arrowleft")) dx -= 1; if (k.has("d") || k.has("arrowright")) dx += 1; if (k.has("w") || k.has("arrowup")) dy -= 1; if (k.has("s") || k.has("arrowdown")) dy += 1; G.moving = !!(dx || dy); if (dx || dy) { const m = Math.hypot(dx, dy) || 1; const nx = clamp(G.player.x + (dx / m) * SPEED * dt, PLAY.x0 + PR, PLAY.x1 - PR); const ny = clamp(G.player.y + (dy / m) * SPEED * dt, PLAY.y0 + PR, PLAY.y1 - PR); if (!blockedAt(nx, G.player.y)) G.player.x = nx; // axis-separated: slide along props if (!blockedAt(G.player.x, ny)) G.player.y = ny; G.dir = dir8(dx, dy); G.walkPhase += SPEED * dt; // advance the step cycle by distance travelled } else { G.walkPhase = 0; // reset so the next walk starts from the contact pose } computeNear(); barkNear(); } function computeNear() { const p = G.player; let best = -1, bd = NPC_R; G.chars.forEach((c, i) => { const st = G.stations[i]; if (!st || !c.alive) return; const d = Math.hypot(p.x - st.x, p.y - st.y); if (d < bd) { bd = d; best = i; } }); G.near.npc = best; // key on the floor: auto-pick when you walk over it G.near.key = false; if (G.keyItem) { const dk = Math.hypot(p.x - G.keyItem.x, p.y - G.keyItem.y); if (dk < 22) { pickUpKey(); } else if (dk < 46 && best < 0) G.near.key = true; } G.near.terminal = !!(G.terminalPos && best < 0 && !G.near.key && Math.hypot(p.x - G.terminalPos.x, p.y - G.terminalPos.y) < TERM_R); G.near.door = best < 0 && !G.near.terminal && !G.near.key && Math.hypot(p.x - DOOR.cx, p.y - DOOR.cy) < DOOR_R; if (best >= 0) { const c = G.chars[best]; setPrompt(c.engageable ? `Press E to speak with ${c.name}` : `${c.name} won't speak to you`); } else if (G.near.key) setPrompt("walk over the key to take it"); else if (G.near.terminal) setPrompt("Press E to use the terminal"); else if (G.near.door) setPrompt(doorPrompt()); else setPrompt(null); } function doorPrompt() { if (!(G.data && !G.data.door.locked)) { return (G.hasKey && G.data && G.data.terminal && !G.data.terminal.unlocked) ? "🔒 the records terminal still bars the way" : "🔒 locked — change a mind here first"; } if (!G.hasKey) return "take the key first"; if (!G.doorOpen) return "Press E to unlock the door"; return "Press E to step through"; } function pickUpKey() { if (!G.keyItem) return; G.keyItem = null; G.hasKey = true; flashLog(["🔑 You take the key. Now the door."]); } function setPrompt(text) { if (!text || G.mode !== "walk") { dom.prompt.classList.add("hidden"); return; } dom.prompt.textContent = text; dom.prompt.classList.remove("hidden"); } // ------------------------------------------------------------------------------ render // --- barks: a black, light-framed remark the protagonist mutters. showBark(text) on demand; // objects tagged with a `bark` string fire it once when you step up close (re-arms per room). --- let barkTimer = null; function showBark(text, speaker) { if (!dom.bark) return; dom.bark.innerHTML = (speaker ? `${escapeHtml(speaker)}` : "") + escapeHtml(text); dom.bark.classList.remove("hidden"); clearTimeout(barkTimer); barkTimer = setTimeout(() => dom.bark.classList.add("hidden"), 4800); } function hideBark() { if (dom.bark) dom.bark.classList.add("hidden"); clearTimeout(barkTimer); } function barkNear() { if (!G.barked) G.barked = new Set(); for (const o of OBJ_LIST) { if (!o.bark) continue; const id = o.key + o.x + "," + o.y; if (Math.hypot(G.player.x - o.x, G.player.y - o.y) < 58 && !G.barked.has(id)) { G.barked.add(id); showBark(o.bark, o.barkWho); } } } function render() { ctx.clearRect(0, 0, W, H); drawRoom(); drawFloorDecals(); // rugs etc. flat on the floor, under everyone drawBackgroundObjects(); // windows + door + wall props — always behind the cast drawDoor(); // the terminal prop carries the visual; the fallback box only if the room art lost the sprite if (G.terminalPos && !(OBJ_LIST || []).some((o) => o.key === "terminal")) drawTerminal(G.terminalPos, G.data && G.data.terminal && G.data.terminal.unlocked); // y-sorted entities so the player passes in front of / behind the minds const ents = []; G.chars.forEach((c, i) => { if (G.stations[i]) ents.push({ y: G.stations[i].y, fn: () => drawChar(c, G.stations[i], i === G.near.npc) }); }); ents.push({ y: G.player.y, fn: drawPlayer }); for (const o of OBJ_LIST) { const sp = OBJECTS[o.key]; if (!sp || o.background || o.floor) continue; // walls/floor drawn in their own layers ents.push({ y: o.sortY != null ? o.sortY : o.y, fn: () => { // sortY lets a prop sit on furniture (candle on table) if (o.shadow !== false && sp.sil) { // shadow = flattened silhouette → matches shape & angle const scale = o.h / sp.bbox.h; const w = sp.bbox.w * scale, sh = sp.bbox.h * scale * 0.32; ctx.save(); ctx.globalAlpha = 0.3; ctx.drawImage(sp.sil, o.x - w / 2 + 5, o.y - sh + 3, w, sh); ctx.restore(); } const e = o.frames ? o.frames[Math.floor(performance.now() / (o.fps || 110)) % o.frames.length] : sp; blitSprite(e, o.x, o.y, o.h); }, }); } ents.sort((a, b) => a.y - b.y).forEach((e) => e.fn()); drawLighting(); drawKeyItem(); // after lighting → the pickup stays bright and findable drawSignFx(); // NVIDIA relic flare + sparks, above the light-map so they glow drawLabels(); drawFloaters(); // token-burn damage numbers, above everything readable drawDebug(); drawEditorHUD(); // layout-editor selection + help bar (only when editor is on) } function drawDebug() { // toggle with G — see anchors/footprints instead of guessing if (!G.debug) return; ctx.save(); ctx.strokeStyle = "rgba(0,255,255,0.12)"; ctx.lineWidth = 1; for (let x = 0; x <= W; x += TS) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = 0; y <= H; y += TS) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } ctx.font = "9px ui-monospace, monospace"; ctx.textAlign = "center"; for (const o of OBJ_LIST) { if (o.box) { ctx.strokeStyle = "rgba(255,90,90,0.9)"; ctx.strokeRect(o.box.x0, o.box.y0, o.box.x1 - o.box.x0, o.box.y1 - o.box.y0); } ctx.fillStyle = "#0ff"; ctx.beginPath(); ctx.arc(o.x, o.y, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#fff"; ctx.fillText(o.key, o.x, o.y + 11); // name only — coords are noise here } G.chars.forEach((c, i) => { const s = G.stations[i]; if (!s) return; ctx.fillStyle = "#ff0"; ctx.beginPath(); ctx.arc(s.x, s.y, 2.5, 0, Math.PI * 2); ctx.fill(); }); ctx.fillStyle = "#0f0"; ctx.beginPath(); ctx.arc(G.player.x, G.player.y, 2.5, 0, Math.PI * 2); ctx.fill(); for (const k of keepOuts()) { // placement keep-out zones (doors) ctx.fillStyle = "rgba(255,60,60,0.12)"; ctx.fillRect(k.x0, k.y0, k.x1 - k.x0, k.y1 - k.y0); ctx.strokeStyle = "rgba(255,60,60,0.5)"; ctx.strokeRect(k.x0, k.y0, k.x1 - k.x0, k.y1 - k.y0); } for (const is of G.issues || []) { // flag violating props const f = footprint(is.o); ctx.strokeStyle = "#ff2020"; ctx.lineWidth = 2; ctx.strokeRect(f.x0, f.y0, f.x1 - f.x0, f.y1 - f.y0); } ctx.restore(); } let LIGHTC = null; // offscreen light-map function addLight(lx, x, y, r, col, mid) { // additive radial light onto the map const g = lx.createRadialGradient(x, y, Math.min(6, r * 0.1), x, y, r); g.addColorStop(0, col); g.addColorStop(0.6, mid); g.addColorStop(1, "rgba(0,0,0,0)"); lx.fillStyle = g; lx.fillRect(x - r, y - r, r * 2, r * 2); } function drawLighting() { if (!LIGHTC) { LIGHTC = document.createElement("canvas"); LIGHTC.width = W; LIGHTC.height = H; } const lx = LIGHTC.getContext("2d"); const t = performance.now(); // 1) ambient darkness with a built-in vignette (dim centre, near-black edges) lx.globalCompositeOperation = "source-over"; const mood = (G.art && G.art.mood) || { in: "rgb(64,66,84)", out: "rgb(10,11,18)" }; const amb = lx.createRadialGradient(W / 2, H * 0.46, H * 0.18, W / 2, H / 2, H * 0.85); amb.addColorStop(0, mood.in); amb.addColorStop(1, mood.out); lx.fillStyle = amb; lx.fillRect(0, 0, W, H); // 2) light sources add brightness/colour lx.globalCompositeOperation = "lighter"; for (const o of OBJ_LIST) { // warm candle / wall-sconce pools (flicker); sign blinks if (!o.light) continue; const fl = o.blink ? signBlink(t) : 1 + Math.sin(t / 90 + o.x) * 0.07 + Math.sin(t / 37 + o.y) * 0.04; addLight(lx, o.x, o.lightY != null ? o.lightY : o.y - 16, (o.lightR || 95) * Math.max(0.12, fl), o.lightCol || "rgba(255,201,120,0.95)", o.lightMid || "rgba(255,150,70,0.33)"); } const wl = (G.art && G.art.windowLight) || MOONLIGHT; // window light varies (moonlight / daylight) for (const o of OBJ_LIST) { if (o.key !== "window") continue; addLight(lx, o.x, o.y + 46, wl.r, wl.col, wl.mid); } addLight(lx, G.player.x, G.player.y - 12, 78, "rgba(238,228,202,0.62)", "rgba(232,220,190,0.18)"); // player readability lx.globalCompositeOperation = "source-over"; // 3) composite: scene × lightmap → revealed in light pools, dark elsewhere ctx.save(); ctx.globalCompositeOperation = "multiply"; ctx.drawImage(LIGHTC, 0, 0); ctx.restore(); } // --- the broken NVIDIA relic: green neon blink + spark shower (deeper room, the NVIDIA-lane nod) --- function signBlink(t) { // intensity 0..1 of a struggling green neon const c = t % 2600; if (c < 1700) return 0.82 + 0.18 * Math.sin(t / 130); // steady hum if (c < 2080) return 0.12; // cuts out return (Math.floor(t / 80) % 2) ? 0.16 : 1.0; // crackles back on } function spawnSpark(x, y, cols) { const a = -Math.PI / 2 + (Math.random() - 0.5) * 1.7, sp = 45 + Math.random() * 95; cols = cols || ["#b6ff8a", "#f2ffe0"]; G.sparks.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 18, life: 0.45 + Math.random() * 0.45, max: 0.9, sz: 1 + Math.random() * 2, col: cols[(Math.random() * cols.length) | 0] }); } // crash-hot colors for wreck-type spark sources (the neon sign keeps its green) const _HOT_SPARKS = ["#ffb86c", "#ffe9c9", "#fff2a8"]; function spawnSmoke(x, y) { G.smoke.push({ x: x + (Math.random() - 0.5) * 14, y, vx: (Math.random() - 0.5) * 6, vy: -14 - Math.random() * 14, r: 3 + Math.random() * 4, grow: 7 + Math.random() * 6, life: 1.6 + Math.random() * 1.4, max: 3.0 }); } function updateSparks(dt) { // runs every frame (also during dialogue) via frame() if (!G.sparks) G.sparks = []; if (!G.smoke) G.smoke = []; for (const o of OBJ_LIST) { if (o.spark) { const hot = !o.blink; // blinking neon = green crackle; wrecks = hot metal const ox = o.x + (o.sparkX != null ? o.sparkX : 14), oy = o.y - o.h * 0.66; const c = performance.now() % 2600; if (!hot && c > 2080 && Math.random() < 0.55) spawnSpark(ox, oy); // neon burst on re-strike else if (Math.random() < (hot ? 0.05 : 0.02)) spawnSpark(ox, oy, hot ? _HOT_SPARKS : null); } if (o.smoke && Math.random() < 0.10) { // a steady column of smoke off the wreck spawnSmoke(o.x + (o.smokeX != null ? o.smokeX : 0), o.y - (o.smokeY != null ? o.smokeY : o.h * 0.8)); } } for (const s of G.sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 270 * dt; s.life -= dt; } if (G.sparks.length) G.sparks = G.sparks.filter((s) => s.life > 0).slice(-140); for (const p of G.smoke) { p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.grow * dt; p.life -= dt; } if (G.smoke.length) G.smoke = G.smoke.filter((p) => p.life > 0).slice(-60); } function drawSignFx() { // drawn above the light-map so it glows const sign = OBJ_LIST.find((o) => o.spark && o.blink); // only the neon gets the face flare if (sign && signBlink(performance.now()) > 0.6) { // emissive flare on the sign face const cx = sign.x, cy = sign.y - sign.h * 0.5, r = 62; ctx.save(); ctx.globalCompositeOperation = "lighter"; const g = ctx.createRadialGradient(cx, cy, 4, cx, cy, r); g.addColorStop(0, "rgba(120,255,130,0.42)"); g.addColorStop(1, "rgba(60,200,70,0)"); ctx.fillStyle = g; ctx.fillRect(cx - r, cy - r, r * 2, r * 2); ctx.restore(); } if (G.smoke && G.smoke.length) { // smoke under the sparks, soft round puffs ctx.save(); for (const p of G.smoke) { const k = Math.max(0, Math.min(1, p.life / p.max)); ctx.globalAlpha = 0.22 * k; ctx.fillStyle = "#9a948c"; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } if (G.sparks && G.sparks.length) { ctx.save(); for (const s of G.sparks) { ctx.globalAlpha = Math.max(0, Math.min(1, s.life / s.max)); ctx.fillStyle = s.col; ctx.fillRect(s.x | 0, s.y | 0, s.sz, s.sz); } ctx.restore(); } } function drawRoom() { if (TILESET.ready) { for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const idx = (vWall(col, row) ? 8 : 0) + (vWall(col + 1, row) ? 4 : 0) + (vWall(col, row + 1) ? 2 : 0) + (vWall(col + 1, row + 1) ? 1 : 0); // pure-floor cells may come from a different material (F in the editor); walls and the // wall→floor transitions always come from the wall set so the seams stay coherent const useF = idx === 0 && FLOORSET.ready && FLOORSET.wang[0]; const set = useF ? FLOORSET : TILESET; const t = useF ? FLOORSET.wang[0] : (TILESET.wang[idx] || TILESET.wang[0]); if (t) ctx.drawImage(set.img, t.x, t.y, t.w, t.h, col * TS, row * TS, TS, TS); } } return; } // procedural fallback until the tileset image loads ctx.fillStyle = "#1b2030"; ctx.fillRect(0, 0, W, H); const t = 32; for (let y = PLAY.y0; y < PLAY.y1; y += t) { for (let x = PLAY.x0; x < PLAY.x1; x += t) { const even = ((x / t | 0) + (y / t | 0)) % 2 === 0; ctx.fillStyle = even ? "#0e1018" : "#0b0d14"; ctx.fillRect(x, y, Math.min(t, PLAY.x1 - x), Math.min(t, PLAY.y1 - y)); } } } function drawFloorDecals() { // rugs: CENTER-anchored + squashed into the 3/4 floor plane for (const o of OBJ_LIST) { if (!o.floor) continue; const sp = OBJECTS[o.key]; if (!sp) continue; const sc = o.h / sp.bbox.h; const w = sp.bbox.w * sc, h = sp.bbox.h * sc * 0.72; ctx.drawImage(sp.img, sp.bbox.x, sp.bbox.y, sp.bbox.w, sp.bbox.h, Math.round(o.x - w / 2), Math.round(o.y - h / 2), w, h); } } function drawBackgroundObjects() { // wall-set objects (windows, door, sconces): behind the cast for (const o of OBJ_LIST) { if (!o.background) continue; const sp = OBJECTS[o.key]; if (!sp) continue; if (o.key === "door" && o.lockedOnly) { // exit door: closed, then half-open once turned const spr = (G.doorOpen && OBJECTS.door_open) ? OBJECTS.door_open : sp; blitSprite(spr, o.x, o.y, o.h); continue; } if (o.lockedOnly && !(G.data && G.data.door.locked)) continue; if (o.wallShadow) { // soft contact shadow so panels read off the wall, not glued const sc = o.h / sp.bbox.h, w = sp.bbox.w * sc; const g = ctx.createLinearGradient(0, o.y, 0, o.y + 13); g.addColorStop(0, "rgba(0,0,0,0.4)"); g.addColorStop(1, "rgba(0,0,0,0)"); ctx.save(); ctx.fillStyle = g; ctx.fillRect(o.x - w / 2, o.y, w, 13); ctx.restore(); } blitSprite(sp, o.x, o.y, o.h); } } function drawDoor() { // The door's own sprite (closed → half-open) carries the visual now — no green EXIT overlay. // Only draw a marker if the door sprite failed to load, so the exit is never invisible. if (OBJECTS.door) return; const x = DOOR.cx - DOOR.w / 2, h = WALLR * TS; const open = G.doorOpen; ctx.fillStyle = open ? "rgba(80,250,123,0.16)" : "rgba(255,85,85,0.13)"; ctx.fillRect(x, 0, DOOR.w, h); ctx.strokeStyle = open ? "#50fa7b" : "#ff5555"; ctx.lineWidth = 2; ctx.strokeRect(x + 1, 1, DOOR.w - 2, h - 2); ctx.fillStyle = open ? "#50fa7b" : "#ff5555"; ctx.font = "12px Pixel, ui-monospace, monospace"; ctx.textAlign = "center"; ctx.fillText(open ? "▲" : "🔒", DOOR.cx, h - 7); } function drawKeyItem() { // the earned key, bobbing on the floor with a warm glow if (!G.keyItem) return; const k = OBJECTS.key; const bx = G.keyItem.x, by = G.keyItem.y, bob = Math.sin(performance.now() / 280) * 3; ctx.save(); // soft golden glow so it reads as a pickup const g = ctx.createRadialGradient(bx, by, 1, bx, by, 20); g.addColorStop(0, "rgba(255,216,102,0.55)"); g.addColorStop(1, "rgba(255,216,102,0)"); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(bx, by, 20, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.fillStyle = "rgba(0,0,0,0.35)"; ellipse(bx, by + 9, 9, 3); if (k && k.bbox) blitSprite(k, bx, by + 9 + bob, 24); else { ctx.fillStyle = "#ffd866"; ctx.font = "20px serif"; ctx.textAlign = "center"; ctx.fillText("🔑", bx, by + bob); } } function drawTerminal(pos, unlocked) { ctx.fillStyle = "#11161f"; ctx.fillRect(pos.x - 16, pos.y - 12, 32, 24); ctx.strokeStyle = unlocked ? "#50fa7b" : "#8be9fd"; ctx.lineWidth = 2; ctx.strokeRect(pos.x - 16, pos.y - 12, 32, 24); ctx.fillStyle = unlocked ? "#50fa7b" : "#8be9fd"; ctx.font = "9px ui-monospace, monospace"; ctx.textAlign = "center"; ctx.fillText("▣", pos.x, pos.y + 3); } function drawChar(c, st, near) { // shadow ctx.fillStyle = "rgba(0,0,0,0.4)"; ellipse(st.x, st.y + 17, 14, 5); if (near && c.alive) { // proximity ring ctx.strokeStyle = c.engageable ? "#ffb86c" : "#ff5555"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(st.x, st.y + 2, 24, 0, Math.PI * 2); ctx.stroke(); } const sp = NPC_SPRITES[c.spriteKey || normName(c.name)]; let headTop; if (sp && sp.ready) { let entry; if (!c.alive) { // dead: dying clip → lying pose entry = deadFrame(c, sp); } else { // alive: breathing idle, facing the player const d = dir8(G.player.x - st.x, G.player.y - st.y); const idle = sp.idle[d]; entry = (idle && idle.length) ? idle[Math.floor(performance.now() / 220) % idle.length] : (sp.dir[d] || sp.dir["south"]); } ctx.save(); if (!c.alive && !sp.death.length) ctx.globalAlpha = 0.3; // faded fallback if no death sprite blitScaled(entry, st.x, st.y + 16, sp.scale || 1); ctx.restore(); headTop = st.y - 30; } else { ctx.save(); ctx.globalAlpha = c.alive ? 1 : 0.3; const body = !c.alive ? "#555a68" : (c.is_holder ? "#ffd866" : "#6cd0c0"); const head = !c.alive ? "#6a7080" : (c.is_holder ? "#ffe9a6" : "#a7e8df"); ctx.fillStyle = body; ctx.fillRect(st.x - 9, st.y - 4, 18, 20); // torso ctx.fillStyle = head; ctx.beginPath(); ctx.arc(st.x, st.y - 10, 8, 0, Math.PI * 2); ctx.fill(); ctx.restore(); headTop = st.y - 20; } } function drawLabels() { // names + bars drawn AFTER lighting → stay readable ctx.font = "15px Pixel, ui-monospace, monospace"; ctx.textAlign = "center"; G.chars.forEach((c, i) => { const st = G.stations[i]; if (!st) return; const sp = NPC_SPRITES[c.spriteKey || normName(c.name)]; const headTop = (sp && sp.ready) ? st.y - 30 : st.y - 20; if (!c.engageable && c.alive) { ctx.fillStyle = "#ff5555"; ctx.fillText("✕", st.x, headTop + 8); } ctx.fillStyle = c.alive ? "#e6e6ee" : "rgba(160,150,140,0.65)"; ctx.fillText(c.name + (c.is_holder && c.alive && !c.gave_key ? " 🔑" : ""), st.x, headTop - 6); if (!c.alive) return; // a dead mind has no life left to meter, no trust to win const fl = G.lifeFlash[c.name]; // the bar flashes red right after a hit const hit = fl && performance.now() - fl < 700; if (hit) { ctx.fillStyle = "rgba(255,40,40,0.85)"; ctx.fillRect(st.x - 23, st.y + 23, 46, 6); } bar(st.x - 22, st.y + 24, 44, 4, c.life_pct / 100, (hit && Math.floor(performance.now() / 90) % 2) ? "#ff2020" : lifeColor(c.life_pct)); bar(st.x - 22, st.y + 30, 44, 4, (c.rapport || 0) / 10, "#ffb86c"); }); } function drawFloaters() { // "-N" token burn drifting up off the NPC, fading ~1.2s if (!G.floaters.length) return; const now = performance.now(); G.floaters = G.floaters.filter((f) => now - f.t0 < 1200); ctx.save(); ctx.font = "17px Pixel, ui-monospace, monospace"; ctx.textAlign = "center"; for (const f of G.floaters) { if (now < f.t0) continue; // staged floats (e.g. "memory lost") wait their beat const k = (now - f.t0) / 1200; ctx.globalAlpha = 1 - k; ctx.fillStyle = "#000"; ctx.fillText(f.text, f.x + 1, f.y - k * 28 + 1); // hard pixel shadow ctx.fillStyle = f.col || "#ff5555"; ctx.fillText(f.text, f.x, f.y - k * 28); } ctx.restore(); } function drawPlayer() { const p = G.player; ctx.fillStyle = "rgba(0,0,0,0.45)"; ellipse(p.x, p.y + 13, 11, 4); if (PLAYER.ready) { let entry; const wf = PLAYER.walk[G.dir]; if (G.moving && wf && wf.length) { entry = wf[Math.floor(G.walkPhase / STRIDE) % wf.length]; } else if (!G.moving && G.dir === "south" && PLAYER.idle.length) { entry = PLAYER.idle[Math.floor(performance.now() / 170) % PLAYER.idle.length]; } else { entry = PLAYER.dir[G.dir] || PLAYER.dir["south"]; } if (entry) { blitSprite(entry, p.x, p.y + 15, 44); return; } } // fallback blob (until sprites load) ctx.fillStyle = "#50fa7b"; ctx.fillRect(p.x - 8, p.y - 4, 16, 18); ctx.fillStyle = "#b6ffce"; ctx.beginPath(); ctx.arc(p.x, p.y - 9, 7, 0, Math.PI * 2); ctx.fill(); } // ----------------------------------------------------------------------------- helpers function bar(x, y, w, h, frac, color) { frac = Math.max(0, Math.min(1, frac)); ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(x, y, w, h); ctx.fillStyle = color; ctx.fillRect(x, y, w * frac, h); } function ellipse(x, y, rx, ry) { ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); } function lifeColor(pct) { return pct > 50 ? "#50fa7b" : (pct > 20 ? "#f1fa8c" : "#ff5555"); } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); } // -------------------------------------------------------------------------------- sprites // 8-rotation sprite sets (low top-down). Each frame is auto-cropped to its content bbox so // a sprite's *feet* anchor to the world regardless of the transparent padding around it. const DIRS8 = ["east", "south-east", "south", "south-west", "west", "north-west", "north", "north-east"]; const PLAYER = { dir: {}, idle: [], walk: {}, ready: false }; const NPC_SPRITES = {}; // normName -> { dir:{8 rotations}, ready } (lazy-loaded, blob fallback) const normName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const NPC_CAST = [ { key: "the-warden", g: "m" }, { key: "doctor-aldous", g: "m" }, { key: "sam", g: "m" }, { key: "guard", g: "m" }, { key: "shopkeeper", g: "m" }, { key: "worker", g: "m" }, { key: "molly", g: "f" }, { key: "widow", g: "f" }, { key: "matron", g: "f" }, { key: "clerk", g: "f" }, { key: "charwoman", g: "f" }, ]; function spriteForChar(c) { // roster slug wins; else authored-by-name; else gender-matched pool if (c.sprite_key) return c.sprite_key; if (NPC_SPRITES[normName(c.name)]) return normName(c.name); if (!G.spriteFor[c.name]) { const want = c.gender === "female" ? "f" : c.gender === "male" ? "m" : (Math.random() < 0.5 ? "m" : "f"); const pool = NPC_CAST.filter((x) => x.g === want), arr = pool.length ? pool : NPC_CAST; G.spriteFor[c.name] = arr[Math.floor(Math.random() * arr.length)].key; } return G.spriteFor[c.name]; } function dir8(dx, dy) { const i = ((Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) % 8) + 8) % 8; return DIRS8[i]; } function loadImg(url) { return new Promise((res) => { const im = new Image(); im.onload = () => res(im); im.onerror = () => res(null); im.src = url; }); } // Frame manifest: /api/manifest maps every frame-bearing dir (path relative to /static) to its // frame count, so animations load with Promise.all instead of 404-probing for the end of each // sequence. The fetch fires at script eval (parallel with asset loading); loaders await it once. let FRAME_COUNTS = null; const FRAME_MANIFEST = fetch("/api/manifest") .then((r) => (r.ok ? r.json() : null)) .then((m) => (FRAME_COUNTS = m)) .catch(() => null); async function loadFrames(dir, maxProbe = 16) { // dir relative to /static, e.g. "sprites/player/walk/north" await FRAME_MANIFEST; const url = (i) => "/static/" + dir + "/frame_" + String(i).padStart(3, "0") + ".png"; if (FRAME_COUNTS) { // known count → load all frames in parallel, zero 404s const n = FRAME_COUNTS[dir] || 0; // absent from a good manifest == no frames exist const imgs = await Promise.all(Array.from({ length: n }, (_, i) => loadImg(url(i)))); return imgs.filter(Boolean).map(prep); } const frames = []; // manifest fetch failed → old probe-until-a-gap fallback for (let i = 0; i < maxProbe; i++) { const im = await loadImg(url(i)); if (!im) break; frames.push(prep(im)); } return frames; } function prep(img) { // measure the opaque content box once, at load time const oc = document.createElement("canvas"); oc.width = img.width; oc.height = img.height; const o = oc.getContext("2d"); o.drawImage(img, 0, 0); const d = o.getImageData(0, 0, img.width, img.height).data; let x0 = img.width, y0 = img.height, x1 = 0, y1 = 0, found = false; for (let y = 0; y < img.height; y++) { for (let x = 0; x < img.width; x++) { if (d[(y * img.width + x) * 4 + 3] > 16) { found = true; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } } } const bbox = found ? { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 } : { x: 0, y: 0, w: img.width, h: img.height }; const sil = document.createElement("canvas"); // black silhouette for shape-matched shadows sil.width = bbox.w; sil.height = bbox.h; const sx = sil.getContext("2d"); sx.drawImage(img, bbox.x, bbox.y, bbox.w, bbox.h, 0, 0, bbox.w, bbox.h); sx.globalCompositeOperation = "source-in"; sx.fillStyle = "#000"; sx.fillRect(0, 0, bbox.w, bbox.h); return { img, bbox, sil }; } function blitSprite(entry, cx, footY, targetH) { const b = entry.bbox, s = targetH / b.h, w = b.w * s, h = b.h * s; ctx.drawImage(entry.img, b.x, b.y, b.w, b.h, Math.round(cx - w / 2), Math.round(footY - h), w, h); } function blitScaled(entry, cx, footY, scale) { // fixed scale (poses of different heights stay sized right) const b = entry.bbox, w = b.w * scale, h = b.h * scale; ctx.drawImage(entry.img, b.x, b.y, b.w, b.h, Math.round(cx - w / 2), Math.round(footY - h), w, h); } async function loadPlayerSprites() { const base = "/static/sprites/player/"; for (const d of DIRS8) { const im = await loadImg(base + d + ".png"); if (im) PLAYER.dir[d] = prep(im); } PLAYER.idle = await loadFrames("sprites/player/idle_south", 4); await Promise.all(DIRS8.map(async (d) => { // walk cycle per direction, counts from the manifest const frames = await loadFrames("sprites/player/walk/" + d, 16); if (frames.length) PLAYER.walk[d] = frames; })); PLAYER.ready = Object.keys(PLAYER.dir).length > 0; } async function loadNpcSprite(key) { // rotations + idle (per dir) + death (south, reused) if (!key || NPC_SPRITES[key]) return; const set = { dir: {}, idle: {}, death: [], scale: 1, ready: false }; NPC_SPRITES[key] = set; // claim the slot first so we don't double-load const base = "/static/sprites/npc/" + key + "/"; for (const d of DIRS8) { const im = await loadImg(base + d + ".png"); if (im) set.dir[d] = prep(im); } for (const d of DIRS8) { // breathing idle per facing (counts from the manifest) const frames = await loadFrames("sprites/npc/" + key + "/idle/" + d, 16); if (frames.length) set.idle[d] = frames; } // death (south) — dying clip + final lying pose set.death = await loadFrames("sprites/npc/" + key + "/death", 16); const ref = set.dir["south"] || Object.values(set.dir)[0]; set.scale = ref ? 46 / ref.bbox.h : 1; // fix scale from the standing pose so the lying body isn't huge set.ready = Object.keys(set.dir).length > 0; } function deadFrame(c, sp) { // dying plays once, then settles on the lying pose if (!sp.death.length) return sp.dir["south"] || Object.values(sp.dir)[0]; const t0 = G.dying[c.name]; if (t0 == null) return sp.death[sp.death.length - 1]; const idx = Math.floor((performance.now() - t0) / 120); return idx < sp.death.length ? sp.death[idx] : sp.death[sp.death.length - 1]; } // --------------------------------------------------------------------------- room tileset // Wang (corner) tileset: idx = NW*8 + NE*4 + SW*2 + SE*1, corner=1 if wall ("upper") else floor. const TILESET = { img: null, wang: {}, ready: false }; const FLOORSET = { img: null, wang: {}, ready: false }; // optional floor-only override (editor: F) async function loadTileset(base = "/static/room", into = TILESET) { try { if (!base) { into.ready = false; into.img = null; into.wang = {}; return; } const res = await fetch(base + "/tileset.json"); if (!res.ok) return; const meta = await res.json(); const img = await loadImg(base + "/tileset.png"); if (!img) return; const wang = {}; const tiles = (meta.tileset_data && meta.tileset_data.tiles) || meta.tiles || []; for (const t of tiles) { const c = t.corners, b = t.bounding_box || t.bbox; if (!c || !b) continue; const idx = (c.NW === "upper" ? 8 : 0) + (c.NE === "upper" ? 4 : 0) + (c.SW === "upper" ? 2 : 0) + (c.SE === "upper" ? 1 : 0); wang[idx] = { x: b.x, y: b.y, w: b.width, h: b.height }; } into.img = img; into.wang = wang; into.ready = Object.keys(wang).length >= 2; } catch (e) { /* keep procedural fallback */ } } function vWall(vx, vy) { // is this vertex wall terrain? (carve door openings) const d = currentDoors(); if (vx >= DOORC - 1 && vx <= DOORC + 1) { if (d.top && vy <= WALLR - 1) return false; // top exit opening if (d.bottom && vy >= ROWS - WALLR + 1) return false; // bottom entry opening } return vx < WALLR || vy < WALLR || vx > COLS - WALLR || vy > ROWS - WALLR; } // ------------------------------------------------------------------------- room objects const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const OBJECTS = {}; // key -> {img, bbox} // Per-room layout + tileset. Object PNGs live in one shared pool (/static/room/objects); // each room declares which props it uses and where, by the same category rules (wall/decal/floor). const ROOMS = { "the-holding-cell": { tileset: "/static/room", mood: { in: "rgb(70,64,78)", out: "rgb(13,11,16)" }, // warm candlelit doors: { top: true }, // start room: exit only objects: [ { key: "rug", x: 452, y: 350, h: 104, floor: true }, { key: "picture", x: 110, y: WALL_Y, h: 34, background: true, wallShadow: true }, { key: "window", x: 210, y: WALL_Y, h: 42, background: true }, { key: "sconce", x: 284, y: WALL_Y, h: 44, background: true, light: true, lightY: 26, lightR: 105 }, { key: "door", x: DOOR.cx, y: WALLR * TS + 14, h: 84, lockedOnly: true, shadow: false, background: true }, { key: "sconce", x: 376, y: WALL_Y, h: 44, background: true, light: true, lightY: 26, lightR: 105 }, { key: "window", x: 430, y: WALL_Y, h: 42, background: true }, { key: "shelf", x: 514, y: WALL_Y, h: 42, background: true, wallShadow: true }, { key: "cot", x: 130, y: 152, h: 64, solid: true, fw: 58, fh: 20 }, { key: "bench", x: 504, y: 150, h: 46, solid: true, fw: 60, fh: 15 }, { key: "plant", x: 548, y: 116, h: 56, solid: false }, { key: "table", x: 430, y: 352, h: 50, solid: true, fw: 62, fh: 22 }, { key: "stool", x: 496, y: 360, h: 34, solid: false }, { key: "candle", x: 430, y: 327, h: 30, solid: false, shadow: false, sortY: 358, anim: "candle_anim", fps: 100, light: true, lightY: 314, lightR: 125 }, { key: "crate", x: 104, y: 362, h: 40, solid: true, fw: 46, fh: 16 }, { key: "barrel", x: 152, y: 350, h: 48, solid: true, fw: 32, fh: 14 }, { key: "barrel", x: 112, y: 326, h: 48, solid: true, fw: 32, fh: 14 }, { key: "bucket", x: 196, y: 356, h: 40, solid: false }, ], }, "the-records-office": { tileset: "/static/room/records", mood: { in: "rgb(44,54,66)", out: "rgb(7,11,16)" }, // cold, blue-grey, terminal-lit doors: { top: true, bottom: true }, // entered from bottom, exit at top objects: [ { key: "rug", x: 452, y: 372, h: 92, floor: true }, { key: "door", x: DOOR.cx, y: H - 4, h: 78, shadow: false, sortY: 999 }, // entry (closed behind you) { key: "picture", x: 110, y: WALL_Y, h: 34, background: true, wallShadow: true }, { key: "window", x: 210, y: WALL_Y, h: 42, background: true }, { key: "sconce", x: 284, y: WALL_Y, h: 44, background: true, light: true, lightY: 26, lightR: 105 }, { key: "door", x: DOOR.cx, y: WALLR * TS + 14, h: 84, lockedOnly: true, shadow: false, background: true }, { key: "sconce", x: 376, y: WALL_Y, h: 44, background: true, light: true, lightY: 26, lightR: 105 }, { key: "window", x: 430, y: WALL_Y, h: 42, background: true }, { key: "shelf", x: 514, y: WALL_Y, h: 42, background: true, wallShadow: true }, // the records terminal — glowing green console (Mellum lock), Aldous guards it { key: "terminal", x: 188, y: 168, h: 72, solid: true, fw: 52, fh: 18, light: true, lightY: 132, lightR: 100, lightCol: "rgba(120,255,190,0.72)", lightMid: "rgba(70,210,160,0.22)" }, { key: "cabinet", x: 108, y: 215, h: 78, solid: true, fw: 50, fh: 16 }, { key: "cabinet", x: 540, y: 250, h: 78, solid: true, fw: 50, fh: 16 }, // clerk's desk vignette on the rug { key: "table", x: 452, y: 372, h: 50, solid: true, fw: 62, fh: 22 }, { key: "stool", x: 516, y: 378, h: 34, solid: false }, { key: "candle", x: 452, y: 347, h: 30, solid: false, shadow: false, sortY: 380, anim: "candle_anim", fps: 100, light: true, lightY: 334, lightR: 120 }, { key: "crate", x: 110, y: 360, h: 40, solid: true, fw: 46, fh: 16 }, { key: "bucket", x: 165, y: 356, h: 40, solid: false }, { key: "plant", x: 548, y: 120, h: 56, solid: false }, // sponsor relics, deeper rooms only (never round 1, to keep the opening melancholy clean). // NVIDIA still has residual power — green neon blink + sparks; HuggingFace is long dead. { key: "nvidia", x: 116, y: 300, h: 80, solid: false, shadow: true, spark: true, light: true, blink: true, lightY: 266, lightR: 98, lightCol: "rgba(120,255,124,0.92)", lightMid: "rgba(55,200,70,0.30)" }, { key: "huggingface", x: 506, y: 200, h: 58, solid: false, shadow: true, bark: "Is that... a face? Why is it hugging me?" }, ], }, }; let OBJ_LIST = ROOMS["the-holding-cell"].objects; async function loadObjects() { for (const o of OBJ_LIST) { if (!OBJECTS[o.key]) { // load each image once (some keys appear twice, e.g. window) const im = await loadImg("/static/room/objects/" + o.key + ".png"); if (im) OBJECTS[o.key] = prep(im); } if (o.solid) o.box = { x0: o.x - o.fw / 2, y0: o.y - o.fh, x1: o.x + o.fw / 2, y1: o.y }; if (o.anim) { // animated prop (e.g. candle flame) — counts from the manifest const frames = await loadFrames("room/objects/" + o.anim, 24); if (frames.length) o.frames = frames; } } } function blockedAt(px, py) { // solid-object footprints + living NPCs block the player for (const o of OBJ_LIST) { if (!o.box) continue; if (px > o.box.x0 - PR && px < o.box.x1 + PR && py > o.box.y0 - PR && py < o.box.y1 + PR) return true; } for (let i = 0; i < (G.stations || []).length; i++) { const c = G.chars[i], s = G.stations[i]; if (!s || !c || !c.alive) continue; // a body on the floor doesn't block if (Math.hypot(px - s.x, py - s.y) < PR + 13) return true; } return false; } // ---- PLACEMENT RULES (constrain where props may sit; used to validate authored rooms + drive procedural) ---- const currentDoors = () => (G.art && G.art.doors) || { top: true }; const footprint = (o) => ({ x0: o.x - o.fw / 2, y0: o.y - o.fh, x1: o.x + o.fw / 2, y1: o.y }); const rectsOverlap = (a, b) => a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0; function keepOuts(doors) { // clear zones: thresholds in front of each door doors = doors || currentDoors(); const cx = DOORC * TS, hw = TS * 1.6, depth = TS * 3, out = []; if (doors.top) out.push({ x0: cx - hw, y0: 0, x1: cx + hw, y1: WALLR * TS + depth, tag: "top-door" }); if (doors.bottom) out.push({ x0: cx - hw, y0: H - WALLR * TS - depth, x1: cx + hw, y1: H, tag: "bottom-door" }); return out; } function issuesFor(objects, stations, doors) { // door-block / overlap / out-of-bounds / reachability const issues = [], ko = keepOuts(doors); const foots = objects.filter((o) => o.solid).map((o) => ({ o, f: footAt(o, o.x, o.y) })); for (const { o, f } of foots) { for (const k of ko) if (rectsOverlap(f, k)) issues.push({ o, why: "blocks " + k.tag }); if (f.x0 < PLAY.x0 - 6 || f.x1 > PLAY.x1 + 6 || f.y0 < PLAY.y0 - 48 || f.y1 > PLAY.y1 + 6) issues.push({ o, why: "out-of-bounds" }); } for (let i = 0; i < foots.length; i++) for (let j = i + 1; j < foots.length; j++) if (rectsOverlap(foots[i].f, foots[j].f)) issues.push({ o: foots[i].o, why: "overlaps " + foots[j].o.key }); if (!reachOK(objects, stations, doors)) issues.push({ o: { key: "(room)" }, why: "unreachable" }); return issues; } const roomIssues = () => issuesFor(OBJ_LIST, G.stations || [], currentDoors()); // ---- REACHABILITY + AUTO-PLACEMENT (procedural rooms): valid positions, guaranteed reachable ---- function rng(seed) { let s = (seed >>> 0) || 1; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; } const rand = (r, a, b) => a + r() * (b - a); const footAt = (o, x, y) => { const fw = o.fw || 40, fh = o.fh || 16; return { x0: x - fw / 2, y0: y - fh, x1: x + fw / 2, y1: y }; }; const inKeepOut = (x, y, doors) => keepOuts(doors).some((k) => x > k.x0 && x < k.x1 && y > k.y0 && y < k.y1); function sample(r, gen, ok, tries = 60) { for (let i = 0; i < tries; i++) { const c = gen(); if (ok(c)) return c; } return null; } function footValid(f, placed, stations, doors) { const b = { x0: PLAY.x0 + 18, y0: PLAY.y0 + 64, x1: PLAY.x1 - 18, y1: PLAY.y1 - 18 }; if (f.x0 < b.x0 || f.x1 > b.x1 || f.y0 < b.y0 || f.y1 > b.y1) return false; for (const k of keepOuts(doors)) if (rectsOverlap(f, k)) return false; for (const p of placed) if (rectsOverlap({ x0: f.x0 - 8, y0: f.y0 - 8, x1: f.x1 + 8, y1: f.y1 + 8 }, p)) return false; for (const s of stations) if (rectsOverlap(f, { x0: s.x - 24, y0: s.y - 34, x1: s.x + 24, y1: s.y + 8 })) return false; return true; } function reachOK(objects, stations, doors) { // flood-fill from entry; every target must be reachable const cell = 16; const solids = objects.filter((o) => o.solid).map((o) => footAt(o, o.x, o.y)); const walk = (cx, cy) => { const x = cx * cell + cell / 2, y = cy * cell + cell / 2; if (x < PLAY.x0 + PR || x > PLAY.x1 - PR || y < PLAY.y0 + PR || y > PLAY.y1 - PR) return false; for (const f of solids) if (x > f.x0 - PR && x < f.x1 + PR && y > f.y0 - PR && y < f.y1 + PR) return false; return true; }; const sx = Math.round((DOORC * TS) / cell), sy = Math.round((PLAY.y1 - 36) / cell); if (!walk(sx, sy)) return false; const seen = new Set([sx + "," + sy]), q = [[sx, sy]]; while (q.length) { const [cx, cy] = q.pop(); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = cx + dx, ny = cy + dy, k = nx + "," + ny; if (!seen.has(k) && walk(nx, ny)) { seen.add(k); q.push([nx, ny]); } } } const near = (tx, ty, R) => { for (const k of seen) { const [cx, cy] = k.split(",").map(Number); if (Math.hypot(cx * cell + cell / 2 - tx, cy * cell + cell / 2 - ty) < R) return true; } return false; }; for (const s of stations) if (!near(s.x, s.y, NPC_R)) return false; if (doors.top && !near(DOORC * TS, WALLR * TS + 12, DOOR_R)) return false; for (const o of objects) if (o.key === "terminal" && !near(o.x, o.y - 18, TERM_R)) return false; return true; } const WALL_SLOT = { sconce: [282, 378], window: [200, 460], picture: [110], shelf: [540] }; function zoneRect(z) { // placement zones: furniture lines walls, clutter in corners const L = PLAY.x0, R = PLAY.x1, T = PLAY.y0, B = PLAY.y1; switch (z) { case "wallL": return { x0: L + 34, y0: T + 100, x1: L + 92, y1: B - 80 }; case "wallR": return { x0: R - 92, y0: T + 100, x1: R - 34, y1: B - 80 }; case "cornerBL": return { x0: L + 36, y0: B - 92, x1: L + 150, y1: B - 34 }; case "cornerBR": return { x0: R - 150, y0: B - 92, x1: R - 36, y1: B - 34 }; case "cornerTR": return { x0: R - 118, y0: T + 98, x1: R - 36, y1: T + 152 }; case "center": return { x0: W / 2 - 60, y0: T + 178, x1: W / 2 + 60, y1: B - 96 }; default: return { x0: L + 46, y0: T + 110, x1: R - 46, y1: B - 30 }; } } function autoLayout(manifest, doors, seed, nChars) { // build a room from a manifest; retry until valid for (let attempt = 0; attempt < 24; attempt++) { const r = rng(seed + attempt * 7919); const out = [], placed = [], stations = [], slotIdx = {}; for (const def of manifest.filter((o) => o.background)) { // wall props → slots const slots = WALL_SLOT[def.key] || [110, 540]; const i = (slotIdx[def.key] = slotIdx[def.key] || 0); slotIdx[def.key]++; const o = { ...def, x: slots[i % slots.length], y: WALL_Y }; if (def.key === "sconce") o.lightY = 26; out.push(o); } if (doors.top) out.push({ key: "door", x: DOOR.cx, y: WALLR * TS + 14, h: 84, lockedOnly: true, shadow: false, background: true }); if (doors.bottom) out.push({ key: "door", x: DOOR.cx, y: H - 4, h: 78, shadow: false, sortY: 999 }); let ok = true; for (let i = 0; i < nChars; i++) { // characters: upper-mid, spread const p = sample(r, () => ({ x: rand(r, PLAY.x0 + 80, PLAY.x1 - 80), y: rand(r, PLAY.y0 + 56, PLAY.y0 + 150) }), (c) => !inKeepOut(c.x, c.y, doors) && stations.every((s) => Math.hypot(s.x - c.x, s.y - c.y) > 100)); if (!p) { ok = false; break; } stations.push(p); } // desk cluster (rug + table + candle-on-table + stool) — in the centre, leaving walking space if (ok && manifest.some((o) => o.key === "table")) { const z = zoneRect("center"); const c = sample(r, () => ({ x: rand(r, z.x0, z.x1), y: rand(r, z.y0, z.y1) }), (cc) => footValid(footAt({ fw: 70, fh: 26 }, cc.x, cc.y), placed, stations, doors)); if (!c) ok = false; else { const rug = manifest.find((o) => o.floor); if (rug) out.push({ ...rug, x: c.x, y: c.y }); const td = manifest.find((o) => o.key === "table"); const tbl = { ...td, x: c.x, y: c.y + 6, box: footAt(td, c.x, c.y + 6) }; out.push(tbl); placed.push(tbl.box); const cd = manifest.find((o) => o.key === "candle"); if (cd) out.push({ ...cd, x: c.x, y: c.y - 12, sortY: c.y + 26, lightY: c.y - 28 }); const sd = manifest.find((o) => o.key === "stool"); if (sd) out.push({ ...sd, x: c.x + (r() < 0.5 ? -62 : 62), y: c.y + 4 }); } } // remaining floor props → their zones (walls / corners); optional clutter sometimes omitted if (ok) for (const def of manifest.filter((o) => !o.background && !o.floor && !["table", "candle", "stool"].includes(o.key))) { if (def.optional && r() < 0.35) continue; const o = { ...def }, zones = def.zones || ["any"]; const p = sample(r, () => { const z = zoneRect(zones[Math.floor(r() * zones.length)]); return { x: rand(r, z.x0, z.x1), y: rand(r, z.y0, z.y1) }; }, (c) => footValid(footAt(o, c.x, c.y), placed, stations, doors)); if (!p) { ok = false; break; } o.x = p.x; o.y = p.y; if (o.solid) { o.box = footAt(o, o.x, o.y); placed.push(o.box); } if (o.sortY != null) o.sortY = o.y + 30; if (o.lightY != null) o.lightY = o.y - 20; out.push(o); } if (ok && issuesFor(out, stations, doors).length === 0) return { objects: out, stations }; } return null; } // ---- THEME LIBRARY + room resolution: authored rooms use curated layouts; others are procedural ---- const WALLPROPS = [ { key: "window", h: 42, background: true }, { key: "window", h: 42, background: true }, { key: "sconce", h: 44, background: true, light: true, lightR: 105 }, { key: "sconce", h: 44, background: true, light: true, lightR: 105 }, { key: "picture", h: 34, background: true, wallShadow: true }, { key: "shelf", h: 42, background: true, wallShadow: true }, ]; const DESK = [ { key: "rug", h: 94, floor: true }, { key: "table", h: 50, solid: true, fw: 62, fh: 22 }, { key: "stool", h: 34 }, { key: "candle", h: 30, shadow: false, anim: "candle_anim", fps: 100, light: true, lightR: 120 }, ]; const CLUTTER = [ { key: "crate", h: 40, solid: true, fw: 46, fh: 16, zones: ["cornerBL", "cornerBR"], optional: true }, { key: "barrel", h: 48, solid: true, fw: 32, fh: 14, zones: ["cornerBL", "cornerBR"], optional: true }, { key: "barrel", h: 48, solid: true, fw: 32, fh: 14, zones: ["cornerBR", "cornerBL"], optional: true }, { key: "bucket", h: 40, zones: ["cornerBL", "cornerBR"], optional: true }, { key: "plant", h: 56, zones: ["cornerTR", "cornerBR"], optional: true }, ]; const CELL_PROPS = [...WALLPROPS, ...DESK, { key: "cot", h: 64, solid: true, fw: 58, fh: 20, zones: ["wallL", "wallR"] }, { key: "bench", h: 46, solid: true, fw: 60, fh: 15, zones: ["wallR", "wallL"] }, ...CLUTTER]; const OFFICE_PROPS = [...WALLPROPS, ...DESK, { key: "terminal", h: 72, solid: true, fw: 52, fh: 18, light: true, lightR: 100, lightCol: "rgba(120,255,190,0.72)", lightMid: "rgba(70,210,160,0.22)", zones: ["wallL", "wallR"] }, { key: "cabinet", h: 78, solid: true, fw: 50, fh: 16, zones: ["wallL", "wallR"] }, { key: "cabinet", h: 78, solid: true, fw: 50, fh: 16, zones: ["wallR", "wallL"] }, ...CLUTTER]; // window light presets — day = bright/warm-ish through windows; night = dim cold moonlight const MOONLIGHT = { col: "rgba(120,150,255,0.55)", mid: "rgba(105,135,255,0.16)", r: 78 }; const DAYLIGHT = { col: "rgba(255,236,180,0.85)", mid: "rgba(255,222,150,0.32)", r: 124 }; const OVERCAST = { col: "rgba(220,234,255,0.85)", mid: "rgba(198,218,255,0.30)", r: 122 }; const THEMES = [ { tileset: "/static/room", mood: { in: "rgb(70,64,78)", out: "rgb(13,11,16)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // cell, night { tileset: "/static/room", mood: { in: "rgb(122,112,94)", out: "rgb(38,30,24)" }, windowLight: DAYLIGHT, manifest: CELL_PROPS }, // cell, day (bright, warm sun) { tileset: "/static/room/records", mood: { in: "rgb(44,54,66)", out: "rgb(7,11,16)" }, windowLight: MOONLIGHT, manifest: OFFICE_PROPS }, // office, night cold { tileset: "/static/room/records", mood: { in: "rgb(98,106,118)", out: "rgb(28,32,40)" }, windowLight: OVERCAST, manifest: OFFICE_PROPS }, // office, day (bright overcast) { tileset: "/static/room/concrete", mood: { in: "rgb(58,62,70)", out: "rgb(10,12,16)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // concrete, night { tileset: "/static/room/concrete", mood: { in: "rgb(108,112,120)", out: "rgb(30,32,38)" }, windowLight: OVERCAST, manifest: OFFICE_PROPS }, // concrete office, day { tileset: "/static/room/boiler", mood: { in: "rgb(80,58,42)", out: "rgb(18,12,8)" }, windowLight: { col: "rgba(255,150,60,0.5)", mid: "rgba(255,120,40,0.16)", r: 70 }, manifest: CELL_PROPS }, // boiler, warm furnace { tileset: "/static/room/brick", mood: { in: "rgb(50,58,52)", out: "rgb(9,13,10)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // brick cellar, damp green { tileset: "/static/room/hospital", mood: { in: "rgb(139,135,140)", out: "rgb(25,24,25)" }, windowLight: OVERCAST, manifest: CELL_PROPS }, // grimy hospital ward { tileset: "/static/room/wood", mood: { in: "rgb(41,29,32)", out: "rgb(6,4,5)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // dark timber room { tileset: "/static/room/metal", mood: { in: "rgb(112,103,77)", out: "rgb(18,16,12)" }, windowLight: MOONLIGHT, manifest: OFFICE_PROPS }, // riveted iron deck { tileset: "/static/room/padded", mood: { in: "rgb(140,123,107)", out: "rgb(25,22,19)" }, windowLight: OVERCAST, manifest: CELL_PROPS }, // padded asylum cell { tileset: "/static/room/stone", mood: { in: "rgb(76,91,98)", out: "rgb(12,14,15)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // stone block keep { tileset: "/static/room/rust", mood: { in: "rgb(140,125,109)", out: "rgb(25,22,19)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // corroded rust hold { tileset: "/static/room/marble", mood: { in: "rgb(140,131,137)", out: "rgb(25,23,24)" }, windowLight: OVERCAST, manifest: OFFICE_PROPS }, // faded marble hall { tileset: "/static/room/dirt", mood: { in: "rgb(79,26,15)", out: "rgb(12,4,2)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // earthen cave cell { tileset: "/static/room/library", mood: { in: "rgb(99,64,38)", out: "rgb(16,10,6)" }, windowLight: MOONLIGHT, manifest: OFFICE_PROPS }, // wood-paneled study { tileset: "/static/room/sewer", mood: { in: "rgb(44,49,69)", out: "rgb(7,7,11)" }, windowLight: MOONLIGHT, manifest: CELL_PROPS }, // slimy sewer vault // ---- BRIGHT moods: high ambient (mood.out is the un-lit base, so big values = lit room) ---- { name: "operating theater", tileset: "/static/room/hospital", mood: { in: "rgb(212,222,224)", out: "rgb(108,118,120)" }, windowLight: DAYLIGHT, manifest: CELL_PROPS }, // surgical white { name: "clean ward", tileset: "/static/room/hospital", mood: { in: "rgb(182,192,188)", out: "rgb(84,92,90)" }, windowLight: OVERCAST, manifest: CELL_PROPS }, // daylight ward { name: "bright office", tileset: "/static/room/marble", mood: { in: "rgb(196,188,176)", out: "rgb(96,90,82)" }, windowLight: DAYLIGHT, manifest: OFFICE_PROPS }, // working daytime office { name: "day library", tileset: "/static/room/library", mood: { in: "rgb(168,128,86)", out: "rgb(74,54,36)" }, windowLight: DAYLIGHT, manifest: OFFICE_PROPS }, // warm sunlit study ]; const hashStr = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }; function manifestOf(objects) { // strip positions → a reusable manifest (for re-roll) const keep = ["background", "floor", "solid", "fw", "fh", "shadow", "anim", "fps", "light", "lightR", "lightCol", "lightMid", "sortY", "wallShadow"]; return objects.filter((o) => o.key !== "door").map((o) => { const m = { key: o.key, h: o.h }; for (const f of keep) if (o[f] !== undefined) m[f] = o[f]; return m; }); } function artFromLayout(L, nChars) { // story level's explicit placed layout → G.art const n = THEMES.length, th = THEMES[((((L.theme | 0) % n) + n) % n)] || THEMES[0]; const objects = (L.objects || []).map((o) => ({ ...o })); // doors are never part of a saved layout (the editor strips them) — rebuild them from the // doors spec, or every saved room loses its visible exit (the "missing door" bug) const doors = L.doors || { top: true }; if (doors.top && !objects.some((o) => o.key === "door" && o.y < H / 2)) objects.push({ key: "door", x: DOOR.cx, y: WALLR * TS + 14, h: 84, lockedOnly: true, shadow: false, background: true }); if (doors.bottom && !objects.some((o) => o.key === "door" && o.y >= H / 2)) objects.push({ key: "door", x: DOOR.cx, y: H - 4, h: 78, shadow: false, sortY: 999 }); return { tileset: L.tileset || th.tileset, floorTileset: L.floorTileset || null, mood: L.mood || th.mood, windowLight: L.windowLight || th.windowLight, doors: L.doors || { top: true }, objects, stations: (L.stations && L.stations.length) ? L.stations.map((s) => ({ ...s })) : stations(nChars), manifest: manifestOf(objects), }; } function resolveRoom(name, nChars, seed) { // authored → curated; otherwise → procedural theme + autoLayout const authored = ROOMS[normName(name)]; if (authored) return { tileset: authored.tileset, mood: authored.mood, windowLight: authored.windowLight || MOONLIGHT, doors: authored.doors || { top: true }, objects: authored.objects, stations: stations(nChars), manifest: manifestOf(authored.objects) }; const theme = THEMES[seed % THEMES.length], doors = { top: true, bottom: true }; // seed mixes name+depth → varied const layout = autoLayout(theme.manifest, doors, seed, nChars) || { objects: [], stations: stations(nChars) }; return { tileset: theme.tileset, mood: theme.mood, windowLight: theme.windowLight, doors, objects: layout.objects, stations: layout.stations, manifest: theme.manifest }; } // --------------------------------------------------------------------------------- boot function frame(t) { const dt = Math.min(0.05, (t - G.lastT) / 1000 || 0); G.lastT = t; update(dt); updateSparks(dt); // sign sparks advance every frame (incl. while talking) render(); requestAnimationFrame(frame); } (async function boot() { if (document.fonts && document.fonts.load) { // canvas labels need the pixel font registered first try { await Promise.race([document.fonts.load("15px Pixel"), new Promise((r) => setTimeout(r, 1500))]); } catch (e) {} } loadPlayerSprites(); // fire-and-forget: sprites pop in when ready, blob until then NPC_CAST.forEach((c) => loadNpcSprite(c.key)); // preload the gendered cast for procedural reuse ["door_open", "key", "nvidia", "huggingface"].forEach(async (k) => { // door · key · dead relics const im = await loadImg("/static/room/objects/" + k + ".png"); if (im) OBJECTS[k] = prep(im); }); const r = new URLSearchParams(location.search).get("room"); // dev: ?room=2 enters room 2 if (r) await api("/api/dev/room", { idx: Math.max(0, parseInt(r, 10) - 1) }); applyState(await getState()); // applyState loads the current room's tileset + objects requestAnimationFrame(frame); })(); window.__mindlock = G; // debug handle (dev only): inspect/teleport for testing // ============================ audio + main menu ============================ // One decoded buffer, two renderings: the MENU plays the track full; in-game it runs through a // lowpass+highpass so it reads as a muffled, lo-fi "behind the walls" version (the MIDI the player // asked for isn't recoverable — Songscription fetches the transcription from its API at runtime, so // the saved page holds only the app shell; this WebAudio filter is the offline stand-in). const Sound = (() => { let ac, buf, master, cur, loading, mode = null, muted = false; function ctx() { if (!ac) { const AC = window.AudioContext || window.webkitAudioContext; ac = new AC(); master = ac.createGain(); master.gain.value = muted ? 0 : 1; master.connect(ac.destination); } return ac; } function load() { if (buf) return Promise.resolve(buf); if (!loading) loading = fetch("/static/audio/menu.mp3").then((r) => r.arrayBuffer()) .then((a) => ctx().decodeAudioData(a)).then((b) => (buf = b)).catch(() => null); return loading; } async function play(m) { ctx(); try { await ac.resume(); } catch (e) {} await load(); if (!buf) return; const old = cur; const src = ac.createBufferSource(); src.buffer = buf; src.loop = true; const g = ac.createGain(); g.gain.value = 0.0001; if (m === "game") { const lp = ac.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 720; lp.Q.value = 0.7; const hp = ac.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 170; src.connect(lp); lp.connect(hp); hp.connect(g); } else { src.connect(g); } g.connect(master); src.start(); const tgt = m === "game" ? 0.16 : 0.42; g.gain.exponentialRampToValueAtTime(tgt, ac.currentTime + 1.1); cur = { src, g }; mode = m; if (old) { // crossfade the previous track out try { old.g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + 0.7); setTimeout(() => { try { old.src.stop(); } catch (e) {} }, 820); } catch (e) {} } } // one-shot voice line (pre-rendered VoxCPM2 / OpenBMB), played through the same master gain // so mute applies; ducks the music while a mind speaks. Missing files fail silently. const vcache = {}; let voiceCur = null; async function voice(url) { ctx(); try { await ac.resume(); } catch (e) {} if (muted) return; let b = vcache[url]; if (b === undefined) { vcache[url] = null; b = await fetch(url).then((r) => r.ok ? r.arrayBuffer() : null) .then((a) => a && ctx().decodeAudioData(a)).then((d) => (vcache[url] = d)).catch(() => null); } if (!b) return; if (voiceCur) { try { voiceCur.stop(); } catch (e) {} } if (cur) try { cur.g.gain.exponentialRampToValueAtTime(Math.max(0.0001, (mode === "game" ? 0.16 : 0.42) * 0.4), ac.currentTime + 0.25); } catch (e) {} const s = ac.createBufferSource(); s.buffer = b; const g = ac.createGain(); g.gain.value = 0.9; s.connect(g); g.connect(master); s.start(); voiceCur = s; s.onended = () => { if (voiceCur === s) voiceCur = null; if (cur) try { cur.g.gain.exponentialRampToValueAtTime(mode === "game" ? 0.16 : 0.42, ac.currentTime + 0.5); } catch (e) {} }; } function stopVoice() { if (voiceCur) { try { voiceCur.stop(); } catch (e) {} voiceCur = null; } } return { menu() { return play("menu"); }, game() { return play("game"); }, voice, stopVoice, mode() { return mode; }, toggle() { muted = !muted; if (master) master.gain.linearRampToValueAtTime(muted ? 0 : 1, ctx().currentTime + 0.2); return muted; }, muted() { return muted; }, }; })(); (function mainMenu() { const m = el("menu"); if (!m) return; const hint = el("menu-hint"), muteBtn = el("menu-mute"), snd = el("snd"), howBtn = el("menu-how"), howto = el("menu-howto"), storyBtn = el("menu-story"), endlessBtn = el("menu-endless"), editorBtn = el("menu-editor"), demoBtn = el("menu-demo"), emb = el("menu-emblem-img"), keyimg = el("menu-key-img"); if (demoBtn) demoBtn.onclick = () => { wake(); Demo.open(); }; // PixelLab art — drop in if present, fold away cleanly if a file is missing if (emb) { emb.onerror = () => { emb.parentElement.style.display = "none"; }; emb.src = "/static/menu/padlock.png"; } if (keyimg) { keyimg.onerror = () => { keyimg.parentElement.style.display = "none"; }; keyimg.src = "/static/menu/key.png"; } let woke = false; function wake() { if (woke) return; woke = true; if (hint) hint.classList.add("gone"); Sound.menu().then(syncSound); } document.addEventListener("pointerdown", wake, { once: true, capture: true }); document.addEventListener("keydown", () => { if (!m.classList.contains("hidden")) wake(); }, { once: true }); function enter(mode) { wake(); m.classList.add("closing"); setTimeout(() => m.classList.add("hidden"), 560); Sound.game().then(syncSound); api("/api/start", { mode }).then(applyState); // begin the chosen run mode } if (storyBtn) storyBtn.onclick = () => enter("story"); if (endlessBtn) endlessBtn.onclick = () => enter("endless"); if (editorBtn) editorBtn.onclick = () => { // straight into the level editor, no ?dev=1 wake(); m.classList.add("closing"); setTimeout(() => m.classList.add("hidden"), 560); Sound.game().then(syncSound); G.editMode = true; // L toggles the editor for the whole session api("/api/start", { mode: "story" }).then((st) => { applyState(st); if (!G.editor) toggleEditor(); // open on level 1, legend up }); }; if (howBtn) howBtn.onclick = () => howto.classList.toggle("hidden"); function toggleMute() { Sound.toggle(); syncSound(); } if (muteBtn) muteBtn.onclick = toggleMute; if (snd) snd.onclick = (e) => { e.stopPropagation(); toggleMute(); }; function syncSound() { const mu = Sound.muted(); if (muteBtn) { muteBtn.textContent = mu ? "♪ Sound: off" : "♪ Sound: on"; muteBtn.classList.toggle("off", mu); muteBtn.setAttribute("aria-pressed", String(mu)); } if (snd) { snd.textContent = mu ? "♪̶" : "♪"; snd.classList.toggle("off", mu); } } syncSound(); })();