game.js (11977B)
1 /* Platformer demo — the game loop lives inside a host page (index.html) as the 2 * "Platformer demo" tab. Wrapped in an IIFE so its local `const $` 3 * (getElementById) never collides with app.js's top-level `$` (querySelector). 4 * Requires game-rules.js to have defined the global `GameRules` first. */ 5 (() => { 6 "use strict"; 7 8 /* ── level & physics (cells: column 0..67, row 0 top .. 7 ground line) ─ */ 9 // PITS and MAX_JUMPS are read via GameRules.* getters at use time (the pit 10 // layout can be re-randomised at runtime), so they are NOT destructured here. 11 const { W, H, GROUND, GOAL, START_X, JUMP_VY, floorAt, QUESTION, OPTIONS } = GameRules; 12 const TICK_MS = 90; 13 14 /* ── mutable game state ─────────────────────────────────────────────── */ 15 const player = { x: START_X, y: GROUND, vy: 0, onGround: true }; 16 let running = false; // auto mode: player currently holds "run right" 17 let mode = "idle"; // idle | auto | manual | won | lost 18 let deciding = false; 19 let stateMode = "guided"; // "guided" = prose + hint; "unguided" = prose facts; "runlength" = run-length encoding; "ascii" = symbolic row (none but guided include a hint) 20 let jumpsLeft = GameRules.MAX_JUMPS; 21 let deniedStreak = 0; // consecutive jump-attempts with an empty budget 22 let tickTimer = null; 23 let decisionN = 0; 24 let runToken = 0; // invalidates in-flight decisions on reset 25 26 const $ = (id) => document.getElementById(id); 27 const cv = $("cv"), ctx = cv.getContext("2d"); 28 const CELL = cv.width / W, ROWH = cv.height / H; 29 function refreshLevelLabel() { 30 $("level-label").textContent = 31 `Level — pits at ${GameRules.PITS.map(([a, b]) => `${a}–${b + 1}`).join(", ")}; flag at ${GOAL}`; 32 } 33 refreshLevelLabel(); 34 35 // The game shares a page with text fields, so its keyboard shortcuts must not 36 // swallow typing or drive the game from another tab. Only act while the game 37 // panel is visible and focus is not in an editable element. 38 const gameActive = () => { 39 const t = document.activeElement; 40 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return false; 41 return !$("panel-game").hidden; 42 }; 43 44 /* ── the ONE textual state: source for rendering prompts AND the API ── */ 45 function stateText() { 46 return GameRules.stateText(player, jumpsLeft, stateMode); 47 } 48 49 /* ── rendering (uses the same coordinates as the state text) ────────── */ 50 function render() { 51 ctx.clearRect(0, 0, cv.width, cv.height); 52 for (let c = 0; c < W; c++) { 53 if (!floorAt(c)) { // pit: dark shaft 54 ctx.fillStyle = "#0a0c11"; 55 ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH); 56 continue; 57 } 58 ctx.fillStyle = "#2a3140"; 59 ctx.fillRect(c * CELL, GROUND * ROWH, CELL, cv.height - GROUND * ROWH); 60 ctx.fillStyle = "#3a4356"; 61 ctx.fillRect(c * CELL, GROUND * ROWH, CELL, 3); 62 } 63 // goal flag 64 const gx = GOAL * CELL; 65 ctx.strokeStyle = "#56d364"; ctx.lineWidth = 2; 66 ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH); ctx.lineTo(gx, GROUND * ROWH - 34); ctx.stroke(); 67 ctx.fillStyle = "#56d364"; 68 ctx.beginPath(); ctx.moveTo(gx, GROUND * ROWH - 34); 69 ctx.lineTo(gx + 18, GROUND * ROWH - 27); ctx.lineTo(gx, GROUND * ROWH - 20); ctx.fill(); 70 // player: blue when grounded, amber mid-jump 71 ctx.fillStyle = player.onGround ? "#69c0ff" : "#e3a008"; 72 ctx.beginPath(); ctx.arc(player.x * CELL, player.y * ROWH - 8, 8, 0, Math.PI * 2); ctx.fill(); 73 } 74 75 /* ── physics tick ───────────────────────────────────────────────────── */ 76 function tick() { 77 if (mode !== "auto" && mode !== "manual") return; 78 const next = GameRules.advance(player, mode === "auto" ? running : keyRun); 79 Object.assign(player, next.player); 80 if (next.fell) { lose("The player fell into a pit."); return; } 81 if (player.x >= GOAL) { win(); return; } 82 render(); 83 // Mirror the exact state text into the pane on every tick — in manual mode 84 // this is the debugging view of what the decider would receive right now. 85 $("state-view").textContent = stateText(); 86 // Running commits to one tick. A jump commits until landing. 87 if (mode === "auto" && player.onGround) { stopTicks(); void requestDecision(); } 88 } 89 90 function jump() { 91 if (!player.onGround || jumpsLeft <= 0 || mode === "won" || mode === "lost") return; 92 jumpsLeft--; deniedStreak = 0; 93 player.onGround = false; player.vy = JUMP_VY; 94 } 95 96 /* ── SemIf decision loop ────────────────────────────────────────────── */ 97 async function requestDecision() { 98 if (deciding || mode !== "auto") return; 99 deciding = true; 100 const token = runToken; 101 stopTicks(); // physics freezes while we think 102 setStatus("thinking…", ""); 103 const text = stateText(); 104 $("state-view").textContent = text; 105 const n = ++decisionN; 106 let result, elapsedMs, err = null; 107 try { 108 const t0 = performance.now(); 109 const resp = await fetch("/decide", { 110 method: "POST", headers: { "Content-Type": "application/json" }, 111 body: JSON.stringify({ id: `platformer-${n}`, state: text, question: QUESTION, options: OPTIONS }), 112 }); 113 elapsedMs = performance.now() - t0; 114 const payload = JSON.parse(await resp.text()); 115 if (!resp.ok) throw new Error(typeof payload.detail === "string" ? payload.detail : resp.statusText); 116 result = payload; 117 } catch (e) { err = e; } 118 if (token !== runToken || mode !== "auto") return; // reset (or re-run) while we waited 119 deciding = false; 120 if (err) { 121 logError(n, err.message); 122 setStatus(`decision failed: ${err.message} — retrying in 2 s`, "err"); 123 setTimeout(() => { if (token === runToken && mode === "auto") void requestDecision(); }, 2000); 124 return; 125 } 126 const pairs = result.option_ids.map((id, i) => [id, result.probabilities[i]]); 127 const best = pairs.reduce((a, b) => (b[1] > a[1] ? b : a)); 128 logDecision(n, best, pairs, elapsedMs, text); 129 setStatus(`playing… · ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} left`, ""); 130 running = best[0] === "run"; 131 if (running) deniedStreak = 0; 132 if (best[0] === "jump") { 133 if (jumpsLeft > 0) { 134 jump(); 135 } else { 136 deniedStreak++; running = false; 137 if (deniedStreak >= 3) { lose("The model is out of jumps and keeps trying to jump."); return; } 138 setStatus(`jump denied — 0 jumps left (asked ${deniedStreak}× in a row)`, "err"); 139 } 140 } 141 if (mode !== "auto") return; 142 startTicks(); 143 render(); 144 } 145 146 /* ── decision log (DOM built with textContent, like the main UI) ────── */ 147 function clearLog() { const l = $("log"); while (l.firstChild) l.removeChild(l.firstChild); } 148 function addEntry(n, cls) { 149 const e = document.createElement("div"); e.className = `entry${cls ? " " + cls : ""}`; 150 const head = document.createElement("div"); head.className = "head"; 151 const num = document.createElement("span"); num.className = "n"; 152 num.textContent = `#${n}${stateMode === "guided" ? "" : ` · ${stateMode}`}`; 153 const choice = document.createElement("span"); choice.className = "choice"; 154 const ms = document.createElement("span"); ms.className = "ms"; 155 head.append(num, choice, ms); e.appendChild(head); 156 $("log").prepend(e); 157 return { e, choice, ms }; 158 } 159 function logDecision(n, best, pairs, elapsedMs, sentState) { 160 const { e, choice, ms } = addEntry(n); 161 choice.textContent = `→ ${best[0]} (p=${best[1].toFixed(3)})`; 162 ms.textContent = `${elapsedMs.toFixed(0)} ms`; 163 const probs = document.createElement("div"); probs.className = "probs"; 164 probs.textContent = pairs.map(([id, p]) => `${id} ${p.toFixed(3)}`).join(" "); 165 e.appendChild(probs); 166 const bar = document.createElement("div"); bar.className = "bar"; 167 const fill = document.createElement("span"); fill.style.width = `${(best[1] * 100).toFixed(1)}%`; 168 bar.appendChild(fill); e.appendChild(bar); 169 e.title = sentState; // hover to see the exact state that produced this call 170 } 171 function logError(n, msg) { 172 const { e, choice } = addEntry(n, "error"); 173 choice.textContent = "request failed"; 174 const d = document.createElement("div"); d.className = "probs"; d.textContent = msg; 175 e.appendChild(d); 176 } 177 178 /* ── status / win / lose / reset ────────────────────────────────────── */ 179 function setStatus(t, cls) { const s = $("status"); s.textContent = t; s.className = `status${cls ? " " + cls : ""}`; } 180 function startTicks() { if (!tickTimer) tickTimer = setInterval(tick, TICK_MS); } 181 function stopTicks() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } } 182 function win() { 183 mode = "won"; stopTicks(); running = false; 184 setStatus(`🏁 level complete in ${decisionN} SemIf decision(s), ${jumpsLeft} jump${jumpsLeft === 1 ? "" : "s"} to spare — press Reset to run it again`, "win"); 185 render(); 186 } 187 function lose(msg) { 188 mode = "lost"; stopTicks(); running = false; 189 setStatus(`✗ ${msg} The model chose badly — press Reset to retry.`, "err"); 190 render(); 191 } 192 function reset() { 193 runToken++; 194 stopTicks(); deciding = false; running = false; keyRun = false; 195 jumpsLeft = GameRules.MAX_JUMPS; deniedStreak = 0; 196 player.x = START_X; player.y = GROUND; player.vy = 0; player.onGround = true; 197 mode = "idle"; decisionN = 0; 198 $("btn-start").disabled = false; 199 setStatus("idle — press Start", ""); 200 $("state-view").textContent = stateText(); // idle preview: same text /decide would receive 201 clearLog(); 202 const empty = document.createElement("div"); empty.className = "empty"; 203 empty.textContent = "No decisions yet."; $("log").appendChild(empty); 204 render(); 205 } 206 207 /* ── controls ───────────────────────────────────────────────────────── */ 208 const stateModes = ["guided", "unguided", "runlength", "ascii"]; 209 const setStateMode = (m) => { 210 stateMode = m; 211 for (const name of stateModes) $("mode-" + name).className = name === m ? "on" : ""; 212 }; 213 for (const name of stateModes) $("mode-" + name).addEventListener("click", () => setStateMode(name)); 214 215 $("btn-start").addEventListener("click", () => { 216 if (mode === "auto") return; 217 if (mode === "won" || mode === "lost") reset(); 218 mode = "auto"; 219 $("btn-start").disabled = true; 220 void requestDecision(); 221 }); 222 $("btn-reset").addEventListener("click", reset); 223 224 /* ── random pit layout (seeded, reproducible) ──────────────────── */ 225 const parseSeed = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n >>> 0 : null; }; 226 function applySeed(seedNum) { 227 GameRules.setPits(GameRules.makePits(seedNum)); 228 refreshLevelLabel(); 229 reset(); // floorAt/MAX_JUMPS already reflect the new layout 230 } 231 $("btn-randomize").addEventListener("click", () => { 232 const s = (Math.random() * 0x100000000) >>> 0; 233 $("seed").value = String(s); 234 applySeed(s); 235 }); 236 // Type a seed and press Enter (or blur) to reproduce that exact layout. 237 $("seed").addEventListener("change", () => { 238 const s = parseSeed($("seed").value); 239 if (s !== null) applySeed(s); 240 }); 241 242 let keyRun = false; 243 addEventListener("keydown", (e) => { 244 if (!gameActive()) return; 245 if (mode === "auto" || mode === "won" || mode === "lost") return; 246 if (e.key === "ArrowRight" || e.key === "d") { 247 keyRun = true; 248 if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); } 249 } 250 if (e.key === " " || e.key === "ArrowUp" || e.key === "w") { 251 e.preventDefault(); 252 if (mode === "idle") { mode = "manual"; setStatus("manual mode", ""); startTicks(); } 253 jump(); 254 } 255 }); 256 addEventListener("keyup", (e) => { if (e.key === "ArrowRight" || e.key === "d") keyRun = false; }); 257 258 reset(); 259 })();