room.js (13359B)
1 /* Puzzle room demo — a first-person view of a grid room; SemIf makes every 2 * move from the text state alone. Wrapped in an IIFE so its local `$` 3 * (getElementById) never collides with app.js's top-level `$` (querySelector). 4 * Requires room-rules.js to have defined the global `RoomRules` first. */ 5 (() => { 6 "use strict"; 7 8 const { W, H, MAX_STEPS, DIRS, makeLayout, newState, turn, forward, stateText, QUESTION, optionsFor } = RoomRules; 9 const STEP_PAUSE_MS = 400; // pacing between decisions so a human can watch 10 11 /* ── mutable game state ─────────────────────────────────────────────── */ 12 let layout = makeLayout(1); // deterministic default; Randomize reseeds 13 let state = newState(layout); 14 let mode = "idle"; // idle | auto | manual | won | lost 15 let deciding = false; 16 let stateMode = "guided"; // "guided" = FPP + compass hint; "fpp" = prose only; "map" = top-down ASCII 17 // POIs the cone has ever revealed this layout: Known-line bearings persist 18 // until a new layout is dealt (Reset / Randomize / Seed). 19 const discovered = new Set(); 20 let decisionN = 0; 21 let runToken = 0; // invalidates in-flight decisions on reset 22 23 const $ = (id) => document.getElementById(id); 24 const cv = $("room-cv"), ctx = cv.getContext("2d"); 25 26 function refreshLabel() { 27 $("room-label").textContent = 28 `Room — seed ${layout.seed} · key: ${state.hasKey ? "found" : "missing"} · ` + 29 `door: ${state.doorOpen ? "open" : "locked"} · steps: ${state.steps}/${MAX_STEPS}`; 30 } 31 32 // The game shares a page with text fields, so its keyboard shortcuts must not 33 // swallow typing or drive the game from another tab. 34 const roomActive = () => { 35 const t = document.activeElement; 36 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false; 37 return !$("panel-room").hidden; 38 }; 39 40 /* ── first-person rendering (DDA raycast over the same grid) ────────── */ 41 const RAYS = 240; // FOV comes from RoomRules.PLANE (120°) 42 43 function render() { 44 const w = cv.width, h = cv.height; 45 ctx.fillStyle = "#10141d"; // ceiling 46 ctx.fillRect(0, 0, w, h / 2); 47 ctx.fillStyle = "#242b3a"; // floor 48 ctx.fillRect(0, h / 2, w, h / 2); 49 50 const dir = DIRS[state.dir]; 51 const planeX = -dir.dy * RoomRules.PLANE, planeY = dir.dx * RoomRules.PLANE; 52 const strip = w / RAYS; 53 const zbuf = new Float32Array(RAYS); 54 55 for (let i = 0; i < RAYS; i++) { 56 const cam = 2 * i / RAYS - 1; 57 // RoomRules.cast is the single definition of sight-blocking: same math 58 // the FPP state text uses, so the canvas and "In view" can't drift. 59 const hit = RoomRules.cast(state, layout, dir.dx + planeX * cam, dir.dy + planeY * cam); 60 zbuf[i] = hit.dist; 61 const lineH = h / hit.dist; 62 const shade = Math.max(0.2, 1 - hit.dist / 12) * (hit.side === 1 ? 0.8 : 1); 63 const base = hit.cell === "D" ? [152, 98, 44] : [104, 120, 152]; 64 ctx.fillStyle = `rgb(${base.map((c) => Math.round(c * shade)).join(",")})`; 65 ctx.fillRect(Math.floor(i * strip), h / 2 - lineH / 2, Math.ceil(strip), lineH); 66 } 67 68 drawSprite(zbuf, strip, layout.exit, true, (s) => { // exit: tall green portal 69 ctx.fillStyle = "#1d3a24"; 70 ctx.fillRect(s.x0, s.top, s.width, s.height); 71 ctx.fillStyle = "#56d364"; 72 ctx.fillRect(s.x0 + s.width * 0.15, s.top + s.height * 0.1, s.width * 0.7, s.height * 0.8); 73 }); 74 if (!state.hasKey) { 75 drawSprite(zbuf, strip, layout.key, true, (s) => { // key: small yellow disc 76 ctx.fillStyle = "#e3b341"; 77 ctx.beginPath(); 78 ctx.arc(s.cx, s.cy, Math.max(2, s.width * 0.4), 0, Math.PI * 2); 79 ctx.fill(); 80 }, 0.30, 0.55); 81 } 82 83 // HUD: facing + carry, so the human view matches what the state asserts. 84 ctx.fillStyle = "rgba(11, 14, 20, 0.65)"; 85 ctx.fillRect(8, 8, 236, 22); 86 ctx.fillStyle = "#c9d1d9"; 87 ctx.font = "13px monospace"; 88 ctx.fillText(`facing ${DIRS[state.dir].name} · ${state.hasKey ? "key ✓" : "no key"}`, 14, 23); 89 } 90 91 // Project a cell-center billboard into the view, clipping each column against 92 // the wall z-buffer. scale = height fraction of a wall at that distance, 93 // lift = vertical centering (0.5 = middle). 94 function drawSprite(zbuf, strip, cell, visible, draw, scale = 0.85, lift = 0.5) { 95 if (!visible) return; 96 const { tx, ty } = RoomRules.project(state, cell); 97 if (ty <= 0.15) return; 98 const w = cv.width, h = cv.height; 99 const cx = (w / 2) * (1 + tx / ty); 100 const height = (h / ty) * scale; 101 const width = height * 0.6; 102 const s = { 103 cx, cy: h / 2 + (lift - 0.5) * (h / ty), 104 x0: cx - width / 2, width, height, 105 top: h / 2 + (lift - 0.5) * (h / ty) - height / 2, 106 }; 107 const col0 = Math.max(0, Math.floor(s.x0 / strip)); 108 const col1 = Math.min(zbuf.length - 1, Math.floor((s.x0 + width) / strip)); 109 for (let c = col0; c <= col1; c++) { 110 if (zbuf[c] <= ty) continue; // wall nearer than the sprite here 111 ctx.save(); 112 ctx.beginPath(); 113 ctx.rect(c * strip, 0, strip + 1, h); 114 ctx.clip(); 115 draw(s); 116 ctx.restore(); 117 } 118 } 119 120 /* ── SemIf decision loop ────────────────────────────────────────────── */ 121 async function requestDecision() { 122 if (deciding || mode !== "auto") return; 123 deciding = true; 124 const token = runToken; 125 setStatus("thinking…", ""); 126 // Fold whatever the cone currently sees into the discovered set, so the 127 // observation that reveals a POI is the last one without its bearing. 128 for (const o of RoomRules.visibleObjects(state, layout)) { 129 const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name]; 130 if (id) discovered.add(id); 131 } 132 const text = stateText(state, layout, stateMode, discovered); 133 $("room-state-view").textContent = text; const n = ++decisionN; 134 let result, elapsedMs, err = null; 135 try { 136 const t0 = performance.now(); 137 const resp = await fetch("/decide", { 138 method: "POST", headers: { "Content-Type": "application/json" }, 139 body: JSON.stringify({ id: `room-${n}`, state: text, question: QUESTION, options: optionsFor(state, layout) }), 140 }); 141 elapsedMs = performance.now() - t0; 142 const payload = JSON.parse(await resp.text()); 143 if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText); 144 result = payload; 145 } catch (e) { err = e; } 146 if (token !== runToken || mode !== "auto") return; // reset while we waited 147 deciding = false; 148 if (err) { 149 logError(n, err.message); 150 setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err"); 151 setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000); 152 return; 153 } 154 const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]); 155 const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a)); 156 logDecision(n, best, pairs, elapsedMs, text); 157 applyAction(best[0]); 158 render(); refreshLabel(); 159 if (mode !== "auto") return; // applyAction may have won/lost 160 if (state.steps >= MAX_STEPS) { lose(`No exit after ${MAX_STEPS} steps.`); return; } 161 setStatus(`playing… · step ${state.steps}/${MAX_STEPS}`, ""); 162 setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, STEP_PAUSE_MS); 163 } 164 165 function applyAction(action) { 166 if (action === "left" || action === "right") { 167 state = turn(state, action); 168 } else { 169 const r = forward(state, layout); 170 state = r.state; 171 if (r.outcome === "win") { win(); return; } 172 } 173 } 174 175 /* ── decision log (DOM built with textContent, like the main UI) ────── */ 176 function clearLog() { const l = $("room-log"); while (l.firstChild) l.removeChild(l.firstChild); } 177 function addEntry(n, cls) { 178 const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`; 179 const head = document.createElement("div"); head.className = "head"; 180 const num = document.createElement("span"); num.className = "n"; 181 num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`; 182 const choice = document.createElement("span"); choice.className = "choice"; 183 const ms = document.createElement("span"); ms.className = "ms"; 184 head.append(num, choice, ms); e.appendChild(head); 185 $("room-log").prepend(e); 186 return { e, choice, ms }; 187 } 188 function logDecision(n, best, pairs, elapsedMs, sentState) { 189 const { e, choice, ms } = addEntry(n); 190 choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`; 191 ms.textContent = `${elapsedMs.toFixed(0)} ms`; 192 const probs = document.createElement("div"); probs.className = "probs"; 193 probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" "); 194 e.appendChild(probs); 195 const bar = document.createElement("div"); bar.className = "bar"; 196 const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`; 197 bar.appendChild(fill); e.appendChild(bar); 198 e.title = sentState; 199 } 200 function logError(n, msg) { 201 const { e, choice } = addEntry(n, "error"); 202 choice.textContent = "request failed"; 203 const d = document.createElement("div"); d.className = "probs"; d.textContent = msg; 204 e.appendChild(d); 205 } 206 207 /* ── status / win / lose / reset ────────────────────────────────────── */ 208 function setStatus(t, cls) { const s = $("room-status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; } 209 function win() { 210 mode = "won"; deciding = false; 211 setStatus(`🏁 exit reached in ${state.steps} steps (${decisionN} SemIf decision(s)) — press Reset to try another seed`, "win"); 212 render(); refreshLabel(); 213 } 214 function lose(msg) { 215 mode = "lost"; deciding = false; 216 setStatus(`✗ ${msg} The model is lost — press Reset to retry.`, "err"); 217 render(); refreshLabel(); 218 } 219 function reset() { 220 runToken++; 221 deciding = false; 222 state = newState(layout); 223 decisionN = 0; 224 mode = "idle"; 225 discovered.clear(); 226 $("room-start").disabled = false; 227 setStatus("idle — press Start", ""); 228 $("room-state-view").textContent = stateText(state, layout, stateMode, discovered); // idle preview: same text /decide would receive 229 clearLog(); 230 const empty = document.createElement("div"); empty.className = "empty"; 231 empty.textContent = "No decisions yet."; 232 $("room-log").appendChild(empty); 233 render(); refreshLabel(); 234 } 235 236 /* ── controls ───────────────────────────────────────────────────────── */ 237 const stateModes = ["guided", "fpp", "map"]; 238 const setStateMode = (m) => { 239 stateMode = m; 240 for (const name of stateModes) $("room-mode-" + name).className = name === m ? "on" : ""; 241 }; 242 for (const name of stateModes) $("room-mode-" + name).addEventListener("click", () => setStateMode(name)); 243 244 $("room-start").addEventListener("click", () => { 245 if (mode === "auto") return; 246 if (mode === "won" || mode === "lost") reset(); 247 mode = "auto"; 248 $("room-start").disabled = true; 249 void requestDecision(); 250 }); 251 $("room-reset").addEventListener("click", reset); 252 253 /* ── seeded layout (reproducible) ───────────────────────────────────── */ 254 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; }; 255 function applySeed(seedNum) { 256 layout = makeLayout(seedNum); 257 reset(); 258 } 259 $("room-randomize").addEventListener("click", () => { 260 const s = (Math.random() * 0x100000000) >>> 0; 261 $("room-seed").value = String(s); 262 applySeed(s); 263 }); 264 $("room-seed").addEventListener("change", () => { 265 const s = parseSeed($("room-seed").value); 266 if (s !== null) applySeed(s); 267 }); 268 269 /* ── manual drive ───────────────────────────────────────────────────── */ 270 addEventListener("keydown", (e) => { 271 if (!roomActive()) return; 272 if (mode !== "idle" && mode !== "manual") return; 273 let acted = false; 274 if (e.key === "ArrowLeft" || e.key === "a") { state = turn(state, "left"); acted = true; } 275 else if (e.key === "ArrowRight" || e.key === "d") { state = turn(state, "right"); acted = true; } 276 else if (e.key === "ArrowUp" || e.key === "w" || e.key === " ") { 277 e.preventDefault(); 278 const r = forward(state, layout); 279 state = r.state; 280 acted = true; 281 if (r.outcome === "win") { win(); return; } 282 } 283 if (!acted) return; 284 if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); } 285 render(); refreshLabel(); 286 // Mirror the decision text into the pane, exactly as a /decide call would 287 // compose it — manual play doubles as a debugger for the prompt. 288 for (const o of RoomRules.visibleObjects(state, layout)) { 289 const id = { "the key": "key", "a locked door": "door", "the exit": "exit" }[o.name]; 290 if (id) discovered.add(id); 291 } 292 $("room-state-view").textContent = stateText(state, layout, stateMode, discovered); 293 if (state.steps >= MAX_STEPS) lose(`No exit after ${MAX_STEPS} steps.`); 294 }); 295 296 reset(); 297 })();