import React, { useRef, useEffect, useState, useCallback } from "react"; // THE LONG ROOM — first person, with an escape sequence. // MAZE: explore sickly-yellow halls, collect the keys. // On the last key you're teleported into a huge hallway where a thing with a // giant open mouth and long legs chases you. Auto-run to the far exit while you // JUMP hurdles and STRAFE around blockers. Reach the exit to descend deeper. // Touch — maze: left=move, right=look. chase: left=strafe, JUMP + LOOK BACK buttons. // Keys — WASD move, ←→/JL turn, Space jump, Shift run, hold Q look back, M mute. const FOV = 0.72, STRIP = 3; const MOVE = 0.045, RUN = 0.075, TURN = 0.045; const MON_WANDER = 0.028, MON_CHASE = 0.041, DETECT = 5.5, LOSE = 8.5; const P_R = 0.22, M_R = 0.30, FOG = 11; const JUMP_V = 0.145, GRAV = 0.014, HURDLE_TOP = 0.42; // ---------- maze gen ---------- function genMaze(cw, ch) { const W = cw * 2 + 1, H = ch * 2 + 1; const g = Array.from({ length: H }, () => Array(W).fill(1)); const vis = Array.from({ length: ch }, () => Array(cw).fill(false)); const st = [[0, 0]]; vis[0][0] = true; g[1][1] = 0; const D = [[0, -1], [1, 0], [0, 1], [-1, 0]]; while (st.length) { const [cx, cy] = st[st.length - 1]; const o = []; for (const [dx, dy] of D) { const nx = cx + dx, ny = cy + dy; if (nx >= 0 && ny >= 0 && nx < cw && ny < ch && !vis[ny][nx]) o.push([nx, ny, dx, dy]); } if (o.length) { const [nx, ny, dx, dy] = o[(Math.random() * o.length) | 0]; vis[ny][nx] = true; g[cy * 2 + 1 + dy][cx * 2 + 1 + dx] = 0; g[ny * 2 + 1][nx * 2 + 1] = 0; st.push([nx, ny]); } else st.pop(); } for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) if (g[y][x] === 1 && Math.random() < 0.11) g[y][x] = 0; return g; } function bfsFrom(g, sx, sy) { const H = g.length, W = g[0].length; const d = Array.from({ length: H }, () => Array(W).fill(-1)); const q = [[sx, sy]]; d[sy][sx] = 0; let h = 0; while (h < q.length) { const [x, y] = q[h++]; for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H || g[ny][nx] === 1 || d[ny][nx] !== -1) continue; d[ny][nx] = d[y][x] + 1; q.push([nx, ny]); } } return d; } function bfsNext(g, sx, sy, tx, ty) { if (sx === tx && sy === ty) return null; const H = g.length, W = g[0].length; const prev = new Map(); prev.set(sx + "," + sy, null); const q = [[sx, sy]]; let h = 0, f = false; while (h < q.length) { const [x, y] = q[h++]; if (x === tx && y === ty) { f = true; break; } for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H || g[ny][nx] === 1) continue; const k = nx + "," + ny; if (prev.has(k)) continue; prev.set(k, [x, y]); q.push([nx, ny]); } } if (!f) return null; let c = [tx, ty]; while (true) { const p = prev.get(c[0] + "," + c[1]); if (!p) return null; if (p[0] === sx && p[1] === sy) return c; c = p; } } const floorCells = (g) => { const o = []; for (let y = 1; y < g.length - 1; y++) for (let x = 1; x < g[0].length - 1; x++) if (g[y][x] === 0) o.push([x, y]); return o; }; const rnd = (a) => a[(Math.random() * a.length) | 0]; const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); // ---------- textures ---------- function texWall() { const c = document.createElement("canvas"); c.width = 64; c.height = 64; const x = c.getContext("2d"); const g = x.createLinearGradient(0, 0, 0, 64); g.addColorStop(0, "#6f6733"); g.addColorStop(.12, "#9b8f45"); g.addColorStop(.85, "#8a7f3d"); g.addColorStop(1, "#5c5228"); x.fillStyle = g; x.fillRect(0, 0, 64, 64); for (let i = 0; i < 64; i += 8) { x.fillStyle = "rgba(60,54,26,.35)"; x.fillRect(i, 0, 1, 64); x.fillStyle = "rgba(190,176,96,.10)"; x.fillRect(i + 1, 0, 1, 64); } for (let i = 0; i < 5; i++) { const sx = Math.random() * 64, sy = 8 + Math.random() * 48, r = 6 + Math.random() * 12; const rg = x.createRadialGradient(sx, sy, 1, sx, sy, r); rg.addColorStop(0, "rgba(45,38,18,.5)"); rg.addColorStop(1, "rgba(45,38,18,0)"); x.fillStyle = rg; x.fillRect(sx - r, sy - r, r * 2, r * 2); } const id = x.getImageData(0, 0, 64, 64), d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - .5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n * .6; } x.putImageData(id, 0, 0); x.fillStyle = "#3c3418"; x.fillRect(0, 58, 64, 6); x.fillStyle = "rgba(0,0,0,.4)"; x.fillRect(0, 57, 64, 1); return c; } function texMon() { const c = document.createElement("canvas"); c.width = 64; c.height = 112; const x = c.getContext("2d"); x.clearRect(0, 0, 64, 112); x.fillStyle = "#0a0b12"; x.beginPath(); x.moveTo(32, 4); x.bezierCurveTo(46, 8, 44, 34, 40, 52); x.bezierCurveTo(50, 74, 44, 104, 38, 110); x.lineTo(26, 110); x.bezierCurveTo(20, 104, 14, 74, 24, 52); x.bezierCurveTo(20, 34, 18, 8, 32, 4); x.fill(); x.strokeStyle = "#0a0b12"; x.lineWidth = 6; x.lineCap = "round"; x.beginPath(); x.moveTo(26, 40); x.lineTo(12, 86); x.stroke(); x.beginPath(); x.moveTo(38, 40); x.lineTo(52, 86); x.stroke(); x.strokeStyle = "rgba(120,40,90,.5)"; x.lineWidth = 1.5; x.beginPath(); x.moveTo(32, 6); x.bezierCurveTo(45, 10, 42, 34, 39, 52); x.stroke(); for (const ex of [27, 37]) { const rg = x.createRadialGradient(ex, 20, .5, ex, 20, 6); rg.addColorStop(0, "#ffd0dc"); rg.addColorStop(.4, "#ff2b5c"); rg.addColorStop(1, "rgba(255,43,92,0)"); x.fillStyle = rg; x.fillRect(ex - 6, 14, 12, 12); } return c; } function texKey() { const c = document.createElement("canvas"); c.width = 40; c.height = 40; const x = c.getContext("2d"); const rg = x.createRadialGradient(20, 20, 1, 20, 20, 20); rg.addColorStop(0, "rgba(255,220,120,.7)"); rg.addColorStop(1, "rgba(255,220,120,0)"); x.fillStyle = rg; x.fillRect(0, 0, 40, 40); x.strokeStyle = "#ffd35c"; x.lineWidth = 3; x.fillStyle = "#ffd35c"; x.beginPath(); x.arc(20, 13, 5, 0, 6.28); x.stroke(); x.fillRect(18.5, 16, 3, 16); x.fillRect(18.5, 28, 7, 3); x.fillRect(18.5, 23, 6, 3); return c; } function texExit(open) { const c = document.createElement("canvas"); c.width = 64; c.height = 112; const x = c.getContext("2d"); const col = open ? "70,224,138" : "176,48,58"; const rg = x.createRadialGradient(32, 56, 4, 32, 56, 54); rg.addColorStop(0, `rgba(${col},.55)`); rg.addColorStop(1, `rgba(${col},0)`); x.fillStyle = rg; x.fillRect(0, 0, 64, 112); x.fillStyle = `rgb(${col})`; x.fillRect(10, 6, 44, 106); x.fillStyle = open ? "#03150c" : "#1a0508"; x.fillRect(15, 11, 34, 101); const ig = x.createLinearGradient(0, 11, 0, 112); ig.addColorStop(0, `rgba(${col},${open ? .5 : .2})`); ig.addColorStop(1, "rgba(0,0,0,.9)"); x.fillStyle = ig; x.fillRect(15, 11, 34, 101); x.fillStyle = `rgb(${col})`; x.font = "bold 20px monospace"; x.textAlign = "center"; x.fillText(open ? "▼" : "⌧", 32, 64); return c; } function texHurdle() { const c = document.createElement("canvas"); c.width = 128; c.height = 30; const x = c.getContext("2d"); const rg = x.createLinearGradient(0, 0, 0, 30); rg.addColorStop(0, "rgba(255,120,40,0)"); rg.addColorStop(.5, "#ff7a2a"); rg.addColorStop(1, "#b03a10"); x.fillStyle = rg; x.fillRect(0, 6, 128, 24); for (let i = 0; i < 128; i += 16) { x.fillStyle = "#1a1108"; x.fillRect(i, 12, 8, 18); } x.fillStyle = "#ffdf7a"; for (let i = 8; i < 128; i += 40) { x.beginPath(); x.moveTo(i, 4); x.lineTo(i - 5, 12); x.lineTo(i + 5, 12); x.fill(); } return c; } function texBlock() { const c = document.createElement("canvas"); c.width = 56; c.height = 120; const x = c.getContext("2d"); x.clearRect(0, 0, 56, 120); const g = x.createLinearGradient(0, 0, 0, 120); g.addColorStop(0, "#181820"); g.addColorStop(1, "#05050a"); x.fillStyle = g; x.fillRect(6, 0, 44, 120); x.strokeStyle = "rgba(120,40,90,.5)"; x.lineWidth = 2; x.strokeRect(6, 0, 44, 120); x.fillStyle = "rgba(0,0,0,.6)"; x.fillRect(26, 0, 4, 120); for (const ey of [30, 60, 90]) { const rg = x.createRadialGradient(28, ey, .5, 28, ey, 7); rg.addColorStop(0, "#ff7aa0"); rg.addColorStop(.5, "#c02050"); rg.addColorStop(1, "rgba(192,32,80,0)"); x.fillStyle = rg; x.fillRect(21, ey - 7, 14, 14); } return c; } function texBig() { const c = document.createElement("canvas"); c.width = 150; c.height = 150; const x = c.getContext("2d"); x.clearRect(0, 0, 150, 150); // huge striding legs x.strokeStyle = "#07080c"; x.lineWidth = 9; x.lineCap = "round"; const legs = [[45, 150], [30, 150], [105, 150], [120, 150], [15, 148], [135, 148]]; for (const [lx, ly] of legs) { x.beginPath(); x.moveTo(75, 92); x.quadraticCurveTo((75 + lx) / 2 + (lx < 75 ? -22 : 22), 96, lx, ly); x.stroke(); } // body const bg = x.createRadialGradient(75, 66, 8, 75, 66, 66); bg.addColorStop(0, "#15101a"); bg.addColorStop(1, "#06060a"); x.fillStyle = bg; x.beginPath(); x.arc(75, 66, 62, 0, 6.28); x.fill(); x.strokeStyle = "rgba(150,40,90,.55)"; x.lineWidth = 3; x.beginPath(); x.arc(75, 66, 61, 0, 6.28); x.stroke(); // giant open mouth const mg = x.createRadialGradient(75, 74, 3, 75, 74, 42); mg.addColorStop(0, "#ffec9a"); mg.addColorStop(.18, "#ff3a2a"); mg.addColorStop(.6, "#6e0410"); mg.addColorStop(1, "#1a0206"); x.fillStyle = mg; x.beginPath(); x.arc(75, 74, 40, 0, 6.28); x.fill(); // teeth ring x.fillStyle = "#f3ede0"; for (let i = 0; i < 22; i++) { const a = (i / 22) * 6.28; const ox = 75 + Math.cos(a) * 40, oy = 74 + Math.sin(a) * 40; const ix = 75 + Math.cos(a) * 30, iy = 74 + Math.sin(a) * 30; const px = 75 + Math.cos(a + .13) * 40, py = 74 + Math.sin(a + .13) * 40; x.beginPath(); x.moveTo(ox, oy); x.lineTo(ix, iy); x.lineTo(px, py); x.fill(); } // eyes for (const ex of [52, 98]) { const rg = x.createRadialGradient(ex, 32, .5, ex, 32, 10); rg.addColorStop(0, "#fff"); rg.addColorStop(.4, "#ff2b5c"); rg.addColorStop(1, "rgba(255,43,92,0)"); x.fillStyle = rg; x.fillRect(ex - 10, 22, 20, 20); } return c; } export default function TheLongRoom() { const canvasRef = useRef(null), wrapRef = useRef(null), S = useRef(null), rafRef = useRef(0); const tex = useRef(null), audioRef = useRef(null), mutedRef = useRef(false), keysDown = useRef({}); const jumpQ = useRef(false), lookBackRef = useRef(false); const [phase, setPhase] = useState("title"); const [hud, setHud] = useState({ mode: "maze", depth: 1, hp: 100, keys: 0, need: 3, score: 0, unlocked: false, danger: 0, prog: 0 }); const [best, setBest] = useState(() => { try { return parseInt(localStorage.getItem("longroom_best")) || 0; } catch (e) { return 0; } }); const [muted, setMuted] = useState(false); const beep = useCallback((f, dur, type = "sine", vol = .06, slide = 0) => { if (mutedRef.current || !audioRef.current) return; const ac = audioRef.current, t = ac.currentTime; const o = ac.createOscillator(), gn = ac.createGain(); o.type = type; o.frequency.setValueAtTime(f, t); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, f + slide), t + dur); gn.gain.setValueAtTime(vol, t); gn.gain.exponentialRampToValueAtTime(.0001, t + dur); o.connect(gn); gn.connect(ac.destination); o.start(t); o.stop(t + dur + .02); }, []); const ensureAudio = useCallback(() => { if (!audioRef.current) { try { audioRef.current = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} } else if (audioRef.current.state === "suspended") audioRef.current.resume(); }, []); // ---------- build MAZE ---------- const buildMaze = useCallback((depth, score, hp) => { const cw = 9 + Math.min(depth, 7), ch = 9 + Math.min(depth, 7); const g = genMaze(cw, ch), cells = floorCells(g), dist = bfsFrom(g, 1, 1); let exitCell = [1, 1], far = -1; for (const [x, y] of cells) if (dist[y][x] > far) { far = dist[y][x]; exitCell = [x, y]; } const want = Math.min(3 + Math.floor(depth * .6), 7); const pool = cells.filter(([x, y]) => dist[y][x] > 3 && !(x === exitCell[0] && y === exitCell[1])); const keys = []; for (let i = 0; i < want && pool.length; i++) { const [kx, ky] = pool.splice((Math.random() * pool.length) | 0, 1)[0]; keys.push({ x: kx + .5, y: ky + .5, got: false, ph: Math.random() * 6.28, kind: "key" }); } const monN = Math.min(2 + depth, 9), mp = cells.filter(([x, y]) => dist[y][x] > 6), monsters = []; for (let i = 0; i < monN; i++) { const [mx, my] = rnd(mp.length ? mp : cells); monsters.push({ x: mx + .5, y: my + .5, aware: 0, pathT: (Math.random() * 12) | 0, path: null, tx: mx + .5, ty: my + .5, wanderT: 0, ph: Math.random() * 6.28, kind: "mon" }); } S.current = { mode: "maze", g, W: g[0].length, H: g.length, depth, need: keys.length, px: 1.5, py: 1.5, dir: .3, pz: 0, vz: 0, grounded: true, hp: hp ?? 100, score: score ?? 0, keys, keysGot: 0, monsters, exit: { x: exitCell[0] + .5, y: exitCell[1] + .5, kind: "exit" }, unlocked: false, bob: 0, walk: 0, shake: 0, hurt: 0, flick: 1, dmgFlash: 0, flash: 0, flashCol: "255,255,255", pending: null, t: 0, }; }, []); // ---------- build CHASE ---------- const buildChase = useCallback((depth, score, hp) => { const W = 7, H = 46 + depth * 6; jumpQ.current = false; lookBackRef.current = false; const g = Array.from({ length: H }, () => Array(W).fill(0)); for (let y = 0; y < H; y++) { g[y][0] = 1; g[y][W - 1] = 1; } for (let x = 0; x < W; x++) { g[0][x] = 1; g[H - 1][x] = 1; } const exitY = H - 3, mid = (1 + (W - 1)) / 2; const obstacles = []; let y = 8, tog = 0; while (y < exitY - 3) { if (tog % 2 === 0) obstacles.push({ type: "hurdle", y, xMin: .8, xMax: W - .8, cx: W / 2, hit: false }); else { const openLeft = Math.random() < .5; obstacles.push({ type: "block", y, xMin: openLeft ? mid : .8, xMax: openLeft ? W - .8 : mid, cx: openLeft ? (mid + W - .8) / 2 : (.8 + mid) / 2, hit: false }); } tog++; y += 4 + ((Math.random() * 2) | 0); } S.current = { mode: "chase", g, W, H, depth, px: W / 2, py: 3, dir: Math.PI / 2, pz: 0, vz: 0, grounded: true, hp: hp ?? 100, score: score ?? 0, obstacles, exit: { x: W / 2, y: exitY, kind: "exit" }, exitY, big: { x: W / 2, y: 3 - 5, kind: "big" }, gap: 6, closeRate: .0032 + depth * .0004, ramp: 0, chaseSpeed: .07 + depth * .004, stumble: 0, lookBack: false, caught: false, bob: 0, walk: 0, shake: 0, hurt: 0, flick: 1, dmgFlash: 0, flash: 0, flashCol: "70,224,138", pending: null, t: 0, }; }, []); const pushHud = useCallback(() => { const s = S.current; if (!s) return; if (s.mode === "maze") setHud({ mode: "maze", depth: s.depth, hp: Math.max(0, Math.round(s.hp)), keys: s.keysGot, need: s.need, score: Math.round(s.score), unlocked: s.unlocked, danger: 0, prog: 0 }); else setHud({ mode: "chase", depth: s.depth, hp: Math.max(0, Math.round(s.hp)), keys: 0, need: 0, score: Math.round(s.score), unlocked: true, danger: clamp(1 - (s.gap - .8) / 5.2, 0, 1), prog: clamp(s.py / s.exitY, 0, 1) }); }, []); const startGame = useCallback(() => { ensureAudio(); if (!tex.current) tex.current = { wall: texWall(), mon: texMon(), key: texKey(), exitLocked: texExit(false), exitOpen: texExit(true), hurdle: texHurdle(), block: texBlock(), big: texBig() }; buildMaze(1, 0, 100); pushHud(); setPhase("playing"); beep(160, .3, "sine", .05, 120); }, [ensureAudio, buildMaze, pushHud, beep]); // ---------- keyboard ---------- useEffect(() => { const dn = (e) => { const k = e.key.toLowerCase(); keysDown.current[k] = true; if (["arrowup", "arrowdown", "arrowleft", "arrowright", " "].includes(k)) e.preventDefault(); if (k === " " && !e.repeat) jumpQ.current = true; if (k === "q") lookBackRef.current = true; if (k === "m") { const m = !mutedRef.current; mutedRef.current = m; setMuted(m); } }; const up = (e) => { const k = e.key.toLowerCase(); keysDown.current[k] = false; if (k === "q") lookBackRef.current = false; }; window.addEventListener("keydown", dn, { passive: false }); window.addEventListener("keyup", up); return () => { window.removeEventListener("keydown", dn); window.removeEventListener("keyup", up); }; }, []); // ---------- pointer (mode-aware) ---------- const moveP = useRef({ id: null, dx: 0, dy: 0, mag: 0, ox: 0, oy: 0 }); const lookP = useRef({ id: null, lastX: 0 }); useEffect(() => { const cv = canvasRef.current; if (!cv) return; const half = () => window.innerWidth / 2; const down = (e) => { ensureAudio(); if (e.clientX < half() && moveP.current.id === null) { moveP.current.id = e.pointerId; moveP.current.ox = e.clientX; moveP.current.oy = e.clientY; moveP.current.dx = 0; moveP.current.dy = 0; moveP.current.mag = 0; } else if (lookP.current.id === null) { lookP.current.id = e.pointerId; lookP.current.lastX = e.clientX; } try { cv.setPointerCapture(e.pointerId); } catch (er) {} }; const move = (e) => { if (e.pointerId === moveP.current.id) { let dx = e.clientX - moveP.current.ox, dy = e.clientY - moveP.current.oy; const d = Math.hypot(dx, dy) || 1, cl = Math.min(d, 55); moveP.current.mag = cl / 55; moveP.current.dx = dx / d; moveP.current.dy = dy / d; if (d > 55) { moveP.current.ox = e.clientX - moveP.current.dx * 55; moveP.current.oy = e.clientY - moveP.current.dy * 55; } } else if (e.pointerId === lookP.current.id) { const s = S.current; if (s && s.mode === "maze") s.dir += (e.clientX - lookP.current.lastX) * .005; lookP.current.lastX = e.clientX; } }; const up = (e) => { if (e.pointerId === moveP.current.id) { moveP.current.id = null; moveP.current.mag = 0; moveP.current.dx = 0; moveP.current.dy = 0; } if (e.pointerId === lookP.current.id) lookP.current.id = null; }; cv.addEventListener("pointerdown", down); cv.addEventListener("pointermove", move); cv.addEventListener("pointerup", up); cv.addEventListener("pointercancel", up); return () => { cv.removeEventListener("pointerdown", down); cv.removeEventListener("pointermove", move); cv.removeEventListener("pointerup", up); cv.removeEventListener("pointercancel", up); }; }, [ensureAudio]); // ---------- loop ---------- useEffect(() => { if (phase !== "playing") return; const cv = canvasRef.current, ctx = cv.getContext("2d"); let dw = 0, dh = 0, dpr = 1, RW = 0, zBuf = null; const resize = () => { const r = wrapRef.current.getBoundingClientRect(); dw = r.width; dh = r.height; dpr = Math.min(window.devicePixelRatio || 1, 2); cv.width = dw * dpr; cv.height = dh * dpr; cv.style.width = dw + "px"; cv.style.height = dh + "px"; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); RW = Math.ceil(dw / STRIP); zBuf = new Float32Array(RW); }; resize(); window.addEventListener("resize", resize); let hudTick = 0; const blocked = (nx, ny, r) => { const s = S.current; for (const [ox, oy] of [[-r, -r], [r, -r], [-r, r], [r, r]]) { const gx = Math.floor(nx + ox), gy = Math.floor(ny + oy); if (gx < 0 || gy < 0 || gx >= s.W || gy >= s.H || s.g[gy][gx] === 1) return true; } return false; }; const die = (s) => { s.hp = 0; const nb = Math.max(best, Math.round(s.score)); setBest(nb); try { localStorage.setItem("longroom_best", String(nb)); } catch (e) {} pushHud(); setPhase("dead"); beep(80, .6, "sawtooth", .09, -50); }; const step = () => { const s = S.current; if (!s) { rafRef.current = requestAnimationFrame(step); return; } s.t++; if (s.flash > 0) { s.flash--; if (s.flash === 23) { if (s.pending === "chase") buildChase(s.depth, s.score, s.hp); else if (s.pending === "descend") buildMaze(s.depth + 1, s.score + 250, Math.min(100, s.hp + 25)); pushHud(); } } else if (s.mode === "maze") { // ---- MAZE update ---- let fwd = 0, strafe = 0, turn = 0; const K = keysDown.current; if (K["w"] || K["arrowup"]) fwd += 1; if (K["s"] || K["arrowdown"]) fwd -= 1; if (K["a"]) strafe -= 1; if (K["d"]) strafe += 1; if (K["arrowleft"] || K["j"]) turn -= 1; if (K["arrowright"] || K["l"]) turn += 1; s.dir += turn * TURN; let running = K["shift"]; if (moveP.current.id !== null && moveP.current.mag > .08) { fwd = -moveP.current.dy * moveP.current.mag; strafe = moveP.current.dx * moveP.current.mag; if (moveP.current.mag > .9) running = true; } const len = Math.hypot(fwd, strafe); if (len > 1) { fwd /= len; strafe /= len; } const spd = running ? RUN : MOVE, dx = Math.cos(s.dir), dy = Math.sin(s.dir); const mvx = (dx * fwd + Math.cos(s.dir + Math.PI / 2) * strafe) * spd, mvy = (dy * fwd + Math.sin(s.dir + Math.PI / 2) * strafe) * spd; if (!blocked(s.px + mvx, s.py, P_R)) s.px += mvx; if (!blocked(s.px, s.py + mvy, P_R)) s.py += mvy; const moving = len > .05; if (moving) { s.walk += spd * 6; if (s.t % 16 === 0) beep(70 + Math.random() * 16, .05, "square", .012, -20); } s.bob = moving ? Math.sin(s.walk) * (running ? 5 : 3) : s.bob * .9; if (s.hurt > 0) s.hurt--; for (const k of s.keys) { if (k.got) continue; if (Math.hypot(k.x - s.px, k.y - s.py) < .55) { k.got = true; s.keysGot++; s.score += 100; beep(680, .12, "triangle", .06, 320); if (s.keysGot >= s.need) { s.flash = 46; s.flashCol = "255,255,255"; s.pending = "chase"; beep(240, .5, "sine", .06, 500); beep(120, .6, "sawtooth", .05, 300); } } } let nearest = 99; for (const m of s.monsters) { m.ph += .05; const dP = Math.hypot(s.px - m.x, s.py - m.y); nearest = Math.min(nearest, dP); if (dP < DETECT) m.aware = 90; else if (m.aware > 0 && dP > LOSE) m.aware = 0; if (m.aware > 0) m.aware--; const mgx = Math.floor(m.x), mgy = Math.floor(m.y), pgx = Math.floor(s.px), pgy = Math.floor(s.py); let ax, ay, msp; if (m.aware > 0) { msp = MON_CHASE; if (m.pathT <= 0) { m.path = bfsNext(s.g, mgx, mgy, pgx, pgy); m.pathT = 8; } m.pathT--; if (Math.abs(mgx - pgx) <= 1 && Math.abs(mgy - pgy) <= 1) { ax = s.px; ay = s.py; } else if (m.path) { ax = m.path[0] + .5; ay = m.path[1] + .5; } else { ax = s.px; ay = s.py; } } else { msp = MON_WANDER; if (m.wanderT <= 0 || Math.hypot(m.tx - m.x, m.ty - m.y) < .2) { const [tx, ty] = rnd(floorCells(s.g)); m.tx = tx + .5; m.ty = ty + .5; m.wanderT = 140; } m.wanderT--; ax = m.tx; ay = m.ty; } const ang = Math.atan2(ay - m.y, ax - m.x), nmx = m.x + Math.cos(ang) * msp, nmy = m.y + Math.sin(ang) * msp; if (!blocked(nmx, m.y, M_R)) m.x = nmx; else m.pathT = 0; if (!blocked(m.x, nmy, M_R)) m.y = nmy; else m.pathT = 0; if (dP < .5 && s.hurt === 0) { s.hp -= 12; s.hurt = 42; s.shake = 14; s.dmgFlash = 1; beep(110, .22, "sawtooth", .08, -60); const ka = Math.atan2(s.py - m.y, s.px - m.x); if (!blocked(s.px + Math.cos(ka) * .35, s.py, P_R)) s.px += Math.cos(ka) * .35; if (!blocked(s.px, s.py + Math.sin(ka) * .35, P_R)) s.py += Math.sin(ka) * .35; if (s.hp <= 0) die(s); } } if (nearest < 4 && s.t % 26 === 0) beep(48, .16, "sine", .05 * (1 - nearest / 4), 8); if (s.t % 30 === 0) s.score += 1; } else { // ---- CHASE update ---- const K = keysDown.current; // look back s.lookBack = lookBackRef.current; const target = s.lookBack ? -Math.PI / 2 : Math.PI / 2; s.dir += (target - s.dir) * .28; // strafe input let strafe = 0; if (K["a"] || K["arrowleft"]) strafe -= 1; if (K["d"] || K["arrowright"]) strafe += 1; if (moveP.current.id !== null && moveP.current.mag > .08) strafe = moveP.current.dx * moveP.current.mag; // jump if (jumpQ.current) { jumpQ.current = false; if (s.grounded) { s.vz = JUMP_V; s.grounded = false; beep(300, .12, "square", .04, 120); } } s.vz -= GRAV; s.pz += s.vz; if (s.pz <= 0) { s.pz = 0; s.vz = 0; s.grounded = true; } // forward auto-run (+y), slowed while stumbling const fspeed = s.chaseSpeed * (s.stumble > 0 ? .18 : 1); s.py += fspeed; s.px = clamp(s.px + strafe * .06, 1.25, s.W - 1.25); if (s.stumble > 0) s.stumble--; s.walk += fspeed * 6; s.bob = s.grounded ? Math.sin(s.walk) * 4 : 0; if (s.t % 14 === 0 && s.grounded) beep(60, .06, "square", .012, -18); // obstacle collision for (const o of s.obstacles) { if (o.hit) continue; const band = o.type === "hurdle" ? .34 : .5; if (Math.abs(s.py - o.y) < band) { const inX = s.px > o.xMin && s.px < o.xMax; if (o.type === "hurdle") { if (inX && s.pz < HURDLE_TOP) { hitObstacle(s, o); } else if (s.pz >= HURDLE_TOP) { o.hit = true; s.score += 25; beep(520, .08, "triangle", .04, 120); } } else { if (inX) hitObstacle(s, o); else if (s.py > o.y) { o.hit = true; s.score += 12; } } } } // the big one closes in s.ramp += .0000045; s.gap -= (s.closeRate + s.ramp); s.big.y = s.py - s.gap; if (s.gap < 3 && s.t % 22 === 0) beep(42, .2, "sawtooth", .05 * (1 - s.gap / 3), 6); // heavy footsteps if (s.gap <= .8) { s.caught = true; s.shake = 20; s.dmgFlash = 1; die(s); } // reached exit if (s.py >= s.exitY - .4) { s.flash = 46; s.flashCol = "70,224,138"; s.pending = "descend"; s.score += 400; beep(150, .4, "sine", .06, 180); } if (s.t % 20 === 0) s.score += 2; } if (s.shake > .2) s.shake *= .85; else s.shake = 0; if (s.dmgFlash > 0) s.dmgFlash *= .9; s.flick = Math.random() < .03 ? .55 + Math.random() * .3 : 1; if (++hudTick % 6 === 0) pushHud(); render(ctx, s, dw, dh, RW, zBuf); rafRef.current = requestAnimationFrame(step); }; const hitObstacle = (s, o) => { o.hit = true; s.stumble = 20; s.gap -= 1.1; s.hp -= 8; s.shake = 14; s.dmgFlash = 1; beep(100, .2, "sawtooth", .08, -50); if (s.gap <= .8) { s.caught = true; die(s); } else if (s.hp <= 0) die(s); }; rafRef.current = requestAnimationFrame(step); return () => { cancelAnimationFrame(rafRef.current); window.removeEventListener("resize", resize); }; }, [phase, buildMaze, buildChase, pushHud, beep, best]); // ---------- render ---------- const render = (ctx, s, dw, dh, RW, zBuf) => { const T = tex.current, shx = (Math.random() - .5) * s.shake; const chase = s.mode === "chase"; const vMove = chase ? s.pz * dh * .5 : 0; const wallH = chase ? 1.28 : 1.0; const horizon = dh / 2 + s.bob + (Math.random() - .5) * s.shake; let cg = ctx.createLinearGradient(0, 0, 0, horizon); cg.addColorStop(0, "#141308"); cg.addColorStop(1, "#050505"); ctx.fillStyle = cg; ctx.fillRect(0, 0, dw, horizon); let fg = ctx.createLinearGradient(0, horizon, 0, dh); fg.addColorStop(0, "#050504"); fg.addColorStop(1, "#1a1508"); ctx.fillStyle = fg; ctx.fillRect(0, horizon, dw, dh - horizon); const dirX = Math.cos(s.dir), dirY = Math.sin(s.dir), planeX = -dirY * FOV, planeY = dirX * FOV; for (let i = 0; i < RW; i++) { const cam = 2 * i / RW - 1, rdx = dirX + planeX * cam, rdy = dirY + planeY * cam; let mapX = Math.floor(s.px), mapY = Math.floor(s.py); const ddx = Math.abs(1 / rdx), ddy = Math.abs(1 / rdy); let stepX, stepY, sideX, sideY; if (rdx < 0) { stepX = -1; sideX = (s.px - mapX) * ddx; } else { stepX = 1; sideX = (mapX + 1 - s.px) * ddx; } if (rdy < 0) { stepY = -1; sideY = (s.py - mapY) * ddy; } else { stepY = 1; sideY = (mapY + 1 - s.py) * ddy; } let hit = 0, side = 0, guard = 0; while (!hit && guard++ < 80) { if (sideX < sideY) { sideX += ddx; mapX += stepX; side = 0; } else { sideY += ddy; mapY += stepY; side = 1; } if (mapX < 0 || mapY < 0 || mapX >= s.W || mapY >= s.H || s.g[mapY][mapX] === 1) hit = 1; } const perp = side === 0 ? (sideX - ddx) : (sideY - ddy), d = Math.max(.05, perp); zBuf[i] = d; let wallX = side === 0 ? s.py + d * rdy : s.px + d * rdx; wallX -= Math.floor(wallX); let texX = Math.floor(wallX * 64); if ((side === 0 && rdx > 0) || (side === 1 && rdy < 0)) texX = 63 - texX; const lineH = (dh / d) * wallH, y0 = horizon - lineH / 2 - vMove / d, xpx = i * STRIP; ctx.drawImage(T.wall, texX, 0, 1, 64, xpx, y0, STRIP, lineH); let fog = clamp(d / FOG, 0, .92); if (side === 1) fog = clamp(fog + .14, 0, .94); fog *= s.flick; ctx.fillStyle = `rgba(5,5,8,${fog})`; ctx.fillRect(xpx, y0, STRIP, lineH); } // sprite list const list = []; if (chase) { for (const o of s.obstacles) { if (o.hit) continue; list.push({ x: o.cx, y: o.y, img: o.type === "hurdle" ? T.hurdle : T.block, scale: o.type === "hurdle" ? .5 : 1.15, voff: o.type === "hurdle" ? .42 : 0, glow: o.type === "hurdle" ? "255,140,60" : "200,40,90", wide: o.type === "hurdle" ? (o.xMax - o.xMin) : (o.xMax - o.xMin) }); } list.push({ x: s.big.x, y: s.big.y, img: T.big, scale: 3.4, voff: 0, glow: "255,43,92" }); list.push({ x: s.exit.x, y: s.exit.y, img: T.exitOpen, scale: 1.3, voff: 0, glow: "70,224,138" }); } else { for (const m of s.monsters) list.push({ x: m.x, y: m.y, img: T.mon, scale: 1, voff: 0, glow: m.aware > 0 ? "255,43,92" : "150,50,90" }); for (const k of s.keys) if (!k.got) list.push({ x: k.x, y: k.y, img: T.key, scale: .42, voff: -.12 + Math.sin(s.t * .08 + k.ph) * .03, glow: "255,211,92" }); list.push({ x: s.exit.x, y: s.exit.y, img: s.unlocked ? T.exitOpen : T.exitLocked, scale: 1.25, voff: 0, glow: s.unlocked ? "70,224,138" : "176,48,58" }); } for (const sp of list) sp._d = (sp.x - s.px) ** 2 + (sp.y - s.py) ** 2; list.sort((a, b) => b._d - a._d); const invDet = 1 / (planeX * dirY - dirX * planeY); for (const sp of list) { const relX = sp.x - s.px, relY = sp.y - s.py; const tX = invDet * (dirY * relX - dirX * relY), tY = invDet * (-planeY * relX + planeX * relY); if (tY <= .2) continue; const hpx = Math.abs(dh / tY) * sp.scale, wpx = hpx * (sp.img.width / sp.img.height) * (sp.wide ? clamp(sp.wide / 2, 1, 4) : 1); const cxpx = (RW / 2) * (1 + tX / tY) * STRIP + shx, topY = horizon - hpx / 2 + sp.voff * hpx - vMove / tY, startX = cxpx - wpx / 2; const shade = clamp(1 - tY / (FOG + 1), .16, 1) * (.6 + .4 * s.flick); for (let xpx = Math.floor(startX); xpx < startX + wpx; xpx += STRIP) { const col = Math.floor(xpx / STRIP); if (col < 0 || col >= RW) continue; if (tY >= zBuf[col]) continue; const sxTex = clamp(Math.floor(((xpx - startX) / wpx) * sp.img.width), 0, sp.img.width - 1); ctx.globalAlpha = shade; ctx.drawImage(sp.img, sxTex, 0, 1, sp.img.height, xpx, topY, STRIP, hpx); } ctx.globalAlpha = 1; const gc = Math.floor(cxpx / STRIP); if (gc >= 0 && gc < RW && tY < zBuf[gc]) { const gy = topY + hpx * .3, gr = hpx * .4; ctx.save(); ctx.globalCompositeOperation = "lighter"; const rg = ctx.createRadialGradient(cxpx, gy, 1, cxpx, gy, gr); rg.addColorStop(0, `rgba(${sp.glow},${.5 * shade})`); rg.addColorStop(1, `rgba(${sp.glow},0)`); ctx.fillStyle = rg; ctx.fillRect(cxpx - gr, gy - gr, gr * 2, gr * 2); ctx.restore(); } } if (!chase) { const chased = s.monsters.some((m) => m.aware > 0); const rem = s.keys.filter((k) => !k.got); if (chased && rem.length) drawKeyBeacon(ctx, s, dw, dh, RW, horizon, dirX, dirY, planeX, planeY, rem); else drawLookHint(ctx, s, dw, dh); } const vg = ctx.createRadialGradient(dw / 2, dh / 2, dh * .3, dw / 2, dh / 2, dh * .8); vg.addColorStop(0, "rgba(0,0,0,0)"); vg.addColorStop(1, "rgba(0,0,0,.7)"); ctx.fillStyle = vg; ctx.fillRect(0, 0, dw, dh); if (chase && s.gap < 2.2) { ctx.fillStyle = `rgba(120,0,20,${(1 - s.gap / 2.2) * .4})`; ctx.fillRect(0, 0, dw, dh); } // breath at your neck if (s.dmgFlash > .02) { ctx.fillStyle = `rgba(150,10,25,${s.dmgFlash * .5})`; ctx.fillRect(0, 0, dw, dh); } if (s.hp <= 30 && s.hp > 0) { ctx.fillStyle = `rgba(140,15,30,${.1 + Math.sin(s.t * .2) * .06})`; ctx.fillRect(0, 0, dw, dh); } if (s.flash > 0) { const a = 1 - Math.abs(s.flash - 23) / 23; ctx.fillStyle = `rgba(${s.flashCol},${a * .95})`; ctx.fillRect(0, 0, dw, dh); } }; const drawKeyBeacon = (ctx, s, dw, dh, RW, horizon, dirX, dirY, planeX, planeY, rem) => { let k = null, bd = Infinity; for (const kk of rem) { const d = (kk.x - s.px) ** 2 + (kk.y - s.py) ** 2; if (d < bd) { bd = d; k = kk; } } const dist = Math.sqrt(bd), col = "255,211,92", pulse = .6 + .4 * Math.sin(s.t * .18); const invDet = 1 / (planeX * dirY - dirX * planeY), relX = k.x - s.px, relY = k.y - s.py; const tX = invDet * (dirY * relX - dirX * relY), tY = invDet * (-planeY * relX + planeX * relY); let onScreen = false, cx = 0, right = false; if (tY > .25) { cx = (RW / 2) * (1 + tX / tY) * STRIP; if (cx >= 26 && cx <= dw - 26) onScreen = true; else right = cx > dw - 26; } else right = tX > 0; if (onScreen) { const my = horizon; ctx.save(); ctx.globalCompositeOperation = "lighter"; const gr = ctx.createRadialGradient(cx, my, 1, cx, my, 30 * pulse + 18); gr.addColorStop(0, `rgba(${col},${.7 * pulse})`); gr.addColorStop(1, `rgba(${col},0)`); ctx.fillStyle = gr; ctx.fillRect(cx - 54, my - 54, 108, 108); ctx.restore(); ctx.save(); ctx.translate(cx, my); ctx.rotate(Math.PI / 4); ctx.strokeStyle = `rgba(${col},.95)`; ctx.lineWidth = 3; const r = 9 + 2 * pulse; ctx.strokeRect(-r, -r, 2 * r, 2 * r); ctx.restore(); ctx.fillStyle = `rgba(${col},.95)`; ctx.font = "bold 12px ui-monospace,monospace"; ctx.textAlign = "center"; ctx.fillText("KEY · " + Math.round(dist) + "m", cx, my - 24); } else { const ax = right ? dw - 30 : 30, ay = dh / 2; ctx.save(); ctx.translate(ax, ay); ctx.globalCompositeOperation = "lighter"; const gr = ctx.createRadialGradient(0, 0, 1, 0, 0, 36); gr.addColorStop(0, `rgba(${col},${.55 * pulse})`); gr.addColorStop(1, `rgba(${col},0)`); ctx.fillStyle = gr; ctx.fillRect(-42, -42, 84, 84); ctx.restore(); ctx.save(); ctx.translate(ax, ay); ctx.rotate(right ? 0 : Math.PI); ctx.fillStyle = `rgba(${col},${.7 + .3 * pulse})`; ctx.beginPath(); ctx.moveTo(15, 0); ctx.lineTo(-11, -15); ctx.lineTo(-11, 15); ctx.closePath(); ctx.fill(); ctx.restore(); } }; const drawLookHint = (ctx, s, dw, dh) => { let tx, ty, col; const rem = s.keys.filter((k) => !k.got); if (rem.length && !s.unlocked) { let bd = Infinity; for (const k of rem) { const d = (k.x - s.px) ** 2 + (k.y - s.py) ** 2; if (d < bd) { bd = d; tx = k.x; ty = k.y; } } col = "255,211,92"; } else { tx = s.exit.x; ty = s.exit.y; col = "70,224,138"; } const ang = Math.atan2(ty - s.py, tx - s.px); let diff = ang - s.dir; while (diff > Math.PI) diff -= 2 * Math.PI; while (diff < -Math.PI) diff += 2 * Math.PI; if (Math.abs(diff) < .5) return; const right = diff > 0, ax = right ? dw - 34 : 34, a = .3 + .3 * Math.sin(s.t * .12); ctx.save(); ctx.translate(ax, dh / 2); ctx.rotate(right ? 0 : Math.PI); ctx.fillStyle = `rgba(${col},${a})`; ctx.beginPath(); ctx.moveTo(10, 0); ctx.lineTo(-8, -12); ctx.lineTo(-8, 12); ctx.closePath(); ctx.fill(); ctx.restore(); }; const toggleMute = () => { const m = !mutedRef.current; mutedRef.current = m; setMuted(m); }; const doJump = (e) => { e.preventDefault(); ensureAudio(); jumpQ.current = true; }; const lbDown = (e) => { e.preventDefault(); lookBackRef.current = true; }; const lbUp = (e) => { e.preventDefault(); lookBackRef.current = false; }; const fCond = "'Arial Narrow','Helvetica Neue',system-ui,sans-serif", fMono = "ui-monospace,'SF Mono',Menlo,monospace"; return (
{phase === "playing" && ( <>
Depth
{String(hud.depth).padStart(2, "0")}
30 ? "linear-gradient(90deg,#7bd88f,#46e08a)" : "linear-gradient(90deg,#ff6b6b,#b0303a)" }} />
Score
{hud.score}
{hud.mode === "maze" ?
{`🔑 ${hud.keys}/${hud.need}`}
:
RUN!
}
{hud.mode === "chase" && ( <> {/* danger (how close it is) */}
Behind you
{/* progress to exit */}
To exit
{/* JUMP */}
JUMP
{/* LOOK BACK */}
LOOK
BACK
left: strafe · JUMP hurdles · dodge blockers
)} {hud.mode === "maze" &&
left: move · right: look · push to run
} )} {phase === "title" && (
the walls go on forever

THE LONG
ROOM

Explore the endless yellow halls and find every key. The moment you do, you're pulled into the long hallway — and the thing with the open mouth is already running. Jump what you can, dodge what you can't, reach the exit.

Find the keys → teleport to the chase
Jump hurdles · strafe around blockers
Touch controls on screen · Keys: WASD · ←→ · Space · Q
)} {phase === "dead" && (
the hall goes quiet

CONSUMED

{[["DEPTH", hud.depth, "#fff"], ["SCORE", hud.score, "#ffd35c"], ["BEST", best, "#46e08a"]].map(([l, v, c]) => (
{l}
{v}
))}
)}
); } const overlay = { position: "absolute", inset: 0, zIndex: 10, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "radial-gradient(circle at 50% 40%, rgba(20,14,8,.7), rgba(5,5,5,.95))", padding: 24, textAlign: "center" }; const btn = { padding: "16px 40px", fontSize: 20, fontWeight: 800, letterSpacing: 2, color: "#05060a", background: "linear-gradient(90deg,#ffd35c,#ff9a3c)", border: "none", borderRadius: 14, cursor: "pointer", boxShadow: "0 0 30px rgba(255,154,60,.4)", touchAction: "manipulation" };