CONSUMED
by ShadowLegend17548 lines42.4 KB
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 (
<div ref={wrapRef} style={{ position: "relative", width: "100vw", height: "100vh", overflow: "hidden", background: "#050505", touchAction: "none", userSelect: "none", WebkitUserSelect: "none", fontFamily: fCond }}>
<canvas ref={canvasRef} style={{ display: "block", position: "absolute", inset: 0 }} />
{phase === "playing" && (
<>
<div style={{ position: "absolute", top: 14, left: 14, right: 14, display: "flex", justifyContent: "space-between", pointerEvents: "none" }}>
<div>
<div style={{ color: "#ffd9a0", fontSize: 12, letterSpacing: 3, textTransform: "uppercase", opacity: .8 }}>Depth</div>
<div style={{ color: "#fff", fontSize: 32, fontWeight: 800, lineHeight: 1, fontFamily: fMono }}>{String(hud.depth).padStart(2, "0")}</div>
<div style={{ marginTop: 9, width: 150, height: 11, background: "rgba(255,255,255,.08)", borderRadius: 6, overflow: "hidden", border: "1px solid rgba(255,255,255,.12)" }}>
<div style={{ width: `${hud.hp}%`, height: "100%", background: hud.hp > 30 ? "linear-gradient(90deg,#7bd88f,#46e08a)" : "linear-gradient(90deg,#ff6b6b,#b0303a)" }} />
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ color: "#ffd9a0", fontSize: 12, letterSpacing: 3, textTransform: "uppercase", opacity: .8 }}>Score</div>
<div style={{ color: "#fff", fontSize: 26, fontWeight: 800, lineHeight: 1, fontFamily: fMono }}>{hud.score}</div>
{hud.mode === "maze"
? <div style={{ marginTop: 7, color: "#ffd35c", fontSize: 19, fontWeight: 800, fontFamily: fMono }}>{`🔑 ${hud.keys}/${hud.need}`}</div>
: <div style={{ marginTop: 7, color: "#ff4d6a", fontSize: 16, fontWeight: 800, fontFamily: fMono, letterSpacing: 1 }}>RUN!</div>}
</div>
</div>
{hud.mode === "chase" && (
<>
{/* danger (how close it is) */}
<div style={{ position: "absolute", top: 58, left: 14, width: 150, pointerEvents: "none" }}>
<div style={{ color: "#ff4d6a", fontSize: 11, letterSpacing: 2, textTransform: "uppercase", opacity: .85 }}>Behind you</div>
<div style={{ marginTop: 3, height: 9, background: "rgba(255,255,255,.08)", borderRadius: 5, overflow: "hidden", border: "1px solid rgba(255,80,110,.3)" }}>
<div style={{ width: `${hud.danger * 100}%`, height: "100%", background: "linear-gradient(90deg,#ff9a3c,#ff2b4d)" }} />
</div>
{/* progress to exit */}
<div style={{ marginTop: 8, color: "#46e08a", fontSize: 11, letterSpacing: 2, textTransform: "uppercase", opacity: .85 }}>To exit</div>
<div style={{ marginTop: 3, height: 9, background: "rgba(255,255,255,.08)", borderRadius: 5, overflow: "hidden", border: "1px solid rgba(70,224,138,.3)" }}>
<div style={{ width: `${hud.prog * 100}%`, height: "100%", background: "linear-gradient(90deg,#2f8f5f,#46e08a)" }} />
</div>
</div>
{/* JUMP */}
<div onPointerDown={doJump} style={{ position: "absolute", right: 24, bottom: 34, width: 104, height: 104, borderRadius: "50%", background: "radial-gradient(circle at 35% 30%, rgba(120,210,255,.25), rgba(120,210,255,.05))", border: "2px solid rgba(120,210,255,.45)", color: "#bfe6ff", fontWeight: 800, fontSize: 17, display: "flex", alignItems: "center", justifyContent: "center", letterSpacing: 1, touchAction: "none" }}>JUMP</div>
{/* LOOK BACK */}
<div onPointerDown={lbDown} onPointerUp={lbUp} onPointerCancel={lbUp} onPointerLeave={lbUp} style={{ position: "absolute", right: 140, bottom: 44, width: 78, height: 78, borderRadius: "50%", background: "radial-gradient(circle at 35% 30%, rgba(255,120,150,.18), rgba(255,120,150,.04))", border: "2px solid rgba(255,120,150,.4)", color: "#ffb3c4", fontWeight: 800, fontSize: 12, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", lineHeight: 1.1, touchAction: "none" }}>LOOK<br />BACK</div>
<div style={{ position: "absolute", left: 16, bottom: 16, color: "rgba(255,255,255,.3)", fontSize: 12, letterSpacing: 1, pointerEvents: "none" }}>left: strafe · JUMP hurdles · dodge blockers</div>
</>
)}
<button onClick={toggleMute} style={{ position: "absolute", top: 92, right: 14, width: 40, height: 40, borderRadius: 10, background: "rgba(255,255,255,.06)", border: "1px solid rgba(255,255,255,.14)", color: "#ffd9a0", fontSize: 18 }}>{muted ? "🔇" : "🔊"}</button>
{hud.mode === "maze" && <div style={{ position: "absolute", left: 16, bottom: 16, color: "rgba(255,255,255,.3)", fontSize: 12, letterSpacing: 1, pointerEvents: "none" }}>left: move · right: look · push to run</div>}
</>
)}
{phase === "title" && (
<div style={overlay}>
<div style={{ color: "#ff2b5c", fontSize: 12, letterSpacing: 8, textTransform: "uppercase", marginBottom: 6 }}>the walls go on forever</div>
<h1 style={{ margin: 0, color: "#e9dfb0", fontSize: "clamp(46px,12vw,104px)", fontWeight: 800, letterSpacing: 4, lineHeight: .9, textShadow: "0 0 40px rgba(255,43,92,.3)" }}>THE LONG<br />ROOM</h1>
<p style={{ color: "rgba(233,223,176,.62)", maxWidth: 370, textAlign: "center", fontSize: 15, lineHeight: 1.5, margin: "18px 0 26px" }}>
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.
</p>
<button onClick={startGame} style={btn}>ENTER ▸</button>
<div style={{ marginTop: 24, color: "rgba(233,223,176,.42)", fontSize: 13, textAlign: "center", lineHeight: 1.7 }}>
<div><b style={{ color: "#ffd35c" }}>Find the keys</b> → teleport to the chase</div>
<div><b style={{ color: "#6fd0ff" }}>Jump</b> hurdles · <b style={{ color: "#ff6b8a" }}>strafe</b> around blockers</div>
<div style={{ opacity: .7, marginTop: 6 }}>Touch controls on screen · Keys: WASD · ←→ · Space · Q</div>
</div>
</div>
)}
{phase === "dead" && (
<div style={overlay}>
<div style={{ color: "#ff2b5c", fontSize: 13, letterSpacing: 8, textTransform: "uppercase" }}>the hall goes quiet</div>
<h1 style={{ margin: "10px 0 0", color: "#e9dfb0", fontSize: "clamp(40px,11vw,84px)", fontWeight: 800, letterSpacing: 3 }}>CONSUMED</h1>
<div style={{ display: "flex", gap: 38, margin: "22px 0 26px" }}>
{[["DEPTH", hud.depth, "#fff"], ["SCORE", hud.score, "#ffd35c"], ["BEST", best, "#46e08a"]].map(([l, v, c]) => (
<div key={l} style={{ textAlign: "center" }}>
<div style={{ color: "rgba(255,255,255,.5)", fontSize: 12, letterSpacing: 3 }}>{l}</div>
<div style={{ color: c, fontSize: 38, fontWeight: 800, fontFamily: fMono }}>{v}</div>
</div>
))}
</div>
<button onClick={startGame} style={btn}>DESCEND AGAIN ▸</button>
</div>
)}
</div>
);
}
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" };
Game Source: CONSUMED
Creator: ShadowLegend17
Libraries: none
Complexity: complex (548 lines, 42.4 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: consumed-shadowlegend17" to link back to the original. Then publish at arcadelab.ai/publish.