From cf6349aca894a11092f1a9d848114622bc279366 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 12:41:49 -0400 Subject: [PATCH] Stage 2.5: the maze gets its masonry and the walk gets its gait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walls wear baked 64-texel textures sampled per column — running-bond brick for the maze, darker courses for the rim, rough blocks for filled stone, planked doors with an iron band and handle, flame-streaked fire, swirling violet for the warps — drawn crisp with smoothing off, shaded by distance and animated by overlay. The floor shows its tile seams: grid lines projected onto the ground plane, occluded by the walls that stand on them. And the workshop walks like the game plays: a tap glides exactly one square with an eased stride, a turn swings a clean quarter, held keys queueing the next move as each settles. Co-Authored-By: Claude Fable 5 --- packages/web/src/fpv/FirstPerson.svelte | 158 ++++++++++++++++++++---- packages/web/src/fpv/FpvWorkshop.svelte | 65 ++++++---- 2 files changed, 179 insertions(+), 44 deletions(-) diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 977cb9f..904c0f5 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -42,7 +42,7 @@ return img.complete && img.naturalWidth > 0 ? img : null; } - /** Base colors per face; distance-shading multiplies them down. */ + /** Base colors per face (fallback + tinting basis). */ const FACE: Record = { wall: [126, 118, 100], stone: [96, 96, 104], @@ -52,9 +52,89 @@ warp: [96, 60, 160], }; + // Textures are baked once at 64x64 and sampled one column at a time — + // perspective-correct verticals for the price of a single drawImage. + const TEX = 64; + function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement { + const t = document.createElement("canvas"); + t.width = TEX; t.height = TEX; + const c = t.getContext("2d")!; + paint(c); + return t; + } + function h2(a: number, b: number): number { + // Cheap deterministic jitter for brick shading. + const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; + return n - Math.floor(n); + } + function brickTexture(base: [number, number, number], rows: number, mortar: string): HTMLCanvasElement { + return bake((c) => { + const rh = TEX / rows; + for (let r = 0; r < rows; r++) { + const offset = (r % 2) * (TEX / 4); + for (let b = -1; b < 3; b++) { + const jit = 0.82 + 0.30 * h2(r, b); + c.fillStyle = `rgb(${base[0] * jit | 0},${base[1] * jit | 0},${base[2] * jit | 0})`; + c.fillRect(b * (TEX / 2) + offset, r * rh, TEX / 2 - 2, rh - 2); + } + } + c.fillStyle = mortar; + for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2); + }); + } + const textures: Record = { + wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"), + rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"), + stone: brickTexture(FACE.stone!, 2, "rgba(20,20,26,0.9)"), + door: bake((c) => { + const [r, g, b] = FACE.door!; + for (let p = 0; p < 4; p++) { + const jit = 0.86 + 0.22 * h2(p, 7); + c.fillStyle = `rgb(${r * jit | 0},${g * jit | 0},${b * jit | 0})`; + c.fillRect(p * (TEX / 4), 0, TEX / 4 - 2, TEX); + } + c.fillStyle = "rgba(30,20,10,0.9)"; + for (let p = 0; p <= 4; p++) c.fillRect(p * (TEX / 4) - 1, 0, 2, TEX); + c.fillRect(0, 0, TEX, 4); // lintel + c.fillStyle = "rgba(60,60,66,0.9)"; // iron band and handle + c.fillRect(0, TEX * 0.42, TEX, 3); + c.beginPath(); + c.arc(TEX * 0.78, TEX * 0.52, 2.5, 0, Math.PI * 2); + c.fill(); + }), + firewall: bake((c) => { + const grad = c.createLinearGradient(0, 0, 0, TEX); + grad.addColorStop(0, "#7c1a14"); + grad.addColorStop(0.55, "#d65c1c"); + grad.addColorStop(1, "#f0a13a"); + c.fillStyle = grad; + c.fillRect(0, 0, TEX, TEX); + for (let i = 0; i < 20; i++) { + c.fillStyle = `rgba(255,${180 + (h2(i, 3) * 60) | 0},80,${0.25 + h2(i, 5) * 0.3})`; + const w = 2 + h2(i, 9) * 4; + c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX); + } + }), + warp: bake((c) => { + const grad = c.createLinearGradient(0, 0, TEX, TEX); + grad.addColorStop(0, "#2c1a4e"); + grad.addColorStop(0.5, "#7a4ccc"); + grad.addColorStop(1, "#3a2266"); + c.fillStyle = grad; + c.fillRect(0, 0, TEX, TEX); + c.strokeStyle = "rgba(200,170,255,0.5)"; + for (let i = 0; i < 6; i++) { + c.beginPath(); + c.arc(TEX / 2, TEX / 2, 6 + i * 5, i, i + 4.2); + c.stroke(); + } + }), + }; + function draw(time: number) { const ctx = canvas?.getContext("2d"); if (!ctx) return; + ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew const W = width, H = height, half = H / 2; // Sky and floor: torchlight fading to the dark of the maze. @@ -64,11 +144,45 @@ ctx.fillStyle = sky; ctx.fillRect(0, 0, W, half); const floor = ctx.createLinearGradient(0, half, 0, H); - floor.addColorStop(0, "#241f18"); - floor.addColorStop(1, "#0e0c09"); + floor.addColorStop(0, "#2b251c"); + floor.addColorStop(1, "#463c2c"); ctx.fillStyle = floor; ctx.fillRect(0, half, W, H - half); + // The floor's tile seams: grid lines projected onto the ground plane, + // drawn before the walls so the walls occlude what stands behind them. + const fx = (W / 2) / Math.tan(FOV / 2); + const cosF = Math.cos(facing), sinF = Math.sin(facing); + const toCam = (wx: number, wy: number) => { + const rx = wx - x, ry = wy - y; + return { fwd: rx * cosF + ry * sinF, side: -rx * sinF + ry * cosF }; + }; + ctx.lineWidth = 1; + const seg = (ax: number, ay: number, bx: number, by: number) => { + let a = toCam(ax, ay), b = toCam(bx, by); + const NEAR = 0.12; + if (a.fwd < NEAR && b.fwd < NEAR) return; + if (a.fwd < NEAR || b.fwd < NEAR) { + const t = (NEAR - a.fwd) / (b.fwd - a.fwd); + const cut = { fwd: NEAR, side: a.side + (b.side - a.side) * t }; + if (a.fwd < NEAR) a = cut; else b = cut; + } + const mid = (a.fwd + b.fwd) / 2; + if (mid > 11) return; + ctx.strokeStyle = `rgba(18,14,10,${Math.min(0.75, 1.0 / (1 + mid * 0.35))})`; + ctx.beginPath(); + ctx.moveTo(W / 2 + (a.side / a.fwd) * fx, half + (H / 2) / a.fwd); + ctx.lineTo(W / 2 + (b.side / b.fwd) * fx, half + (H / 2) / b.fwd); + ctx.stroke(); + }; + const R = 11; + for (let gx = Math.floor(x) - R; gx <= Math.floor(x) + R; gx++) { + for (let gy = Math.floor(y) - R; gy <= Math.floor(y) + R; gy++) { + seg(gx, gy, gx + 1, gy); + seg(gx, gy, gx, gy + 1); + } + } + // Walls, one ray per column; remember each column's depth for sprites. const zbuf = new Float64Array(W); for (let col = 0; col < W; col++) { @@ -77,30 +191,32 @@ const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye zbuf[col] = depth; const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); - const [r, g, b] = FACE[hit.kind] ?? FACE.wall!; - let shade = Math.min(1, 1.35 / (1 + depth * 0.45)); - if (hit.axis === "y") shade *= 0.8; // N/S faces sit in shadow - let [cr, cg, cb] = [r * shade, g * shade, b * shade]; + const top = half - wallH / 2; + const tex = textures[hit.kind] ?? textures.wall!; + ctx.drawImage(tex, hit.u * TEX, 0, 1, TEX, col, top, 1, wallH); + // Distance and orientation carve the light; overlays animate it. + let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); + if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.kind === "firewall") { - const flicker = 0.85 + 0.15 * Math.sin(time / 90 + hit.u * 17 + col * 0.15); - [cr, cg, cb] = [cr * flicker, cg * flicker * 0.9, cb * flicker * 0.6]; + const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.u * 17 + col * 0.15); + ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`; + ctx.fillRect(col, top, 1, wallH); + dark *= 0.5; // the fire lights itself } if (hit.kind === "warp") { - const swirl = 0.75 + 0.25 * Math.sin(time / 240 + hit.u * 9); - [cr, cg, cb] = [cr * swirl, cg * swirl, cb * (0.9 + 0.3 * swirl)]; + const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.u * 9); + ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; + ctx.fillRect(col, top, 1, wallH); + } + if (dark > 0.02) { + ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`; + ctx.fillRect(col, top, 1, wallH); } if (hit.warped) { // Seen through a warp: the far side swims in violet haze. - const haze = 0.82 + 0.08 * Math.sin(time / 300 + col * 0.05); - [cr, cg, cb] = [cr * 0.7 * haze, cg * 0.6 * haze, Math.min(255, cb * 1.1 + 26)]; - } - ctx.fillStyle = `rgb(${cr | 0},${cg | 0},${cb | 0})`; - ctx.fillRect(col, half - wallH / 2, 1, wallH); - // Doors wear a lintel and panel seam so they read as doors. - if (hit.kind === "door") { - ctx.fillStyle = `rgba(0,0,0,${0.35 * shade})`; - ctx.fillRect(col, half - wallH / 2, 1, Math.max(1, wallH * 0.06)); - if (Math.abs(hit.u - 0.5) < 0.015) ctx.fillRect(col, half - wallH / 2, 1, wallH); + const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05); + ctx.fillStyle = `rgba(120,70,220,${haze})`; + ctx.fillRect(col, top, 1, wallH); } } diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index 8ada3a7..944d1ec 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -29,8 +29,9 @@ let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5); let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0); - // Held keys drive per-frame motion: turning is free, walking asks the - // board (workshop collision — walls and shut doors stop you). + // Dungeon-crawler locomotion: the maze is walked cell by cell, so the + // camera moves the same way — a tap glides one square, a turn swings a + // clean quarter. Held keys queue the next move as each one settles. const held = new Set(); function onKey(e: KeyboardEvent, down: boolean) { const k = e.key.toLowerCase(); @@ -39,29 +40,46 @@ if (down) held.add(k); else held.delete(k); } } + const SIDES4 = ["E", "S", "W", "N"] as const; + type Anim = + | { kind: "glide"; fx: number; fy: number; tx: number; ty: number; t0: number; dur: number } + | { kind: "turn"; from: number; to: number; t0: number; dur: number } + | null; + let anim: Anim = null; + const ease = (w: number) => w * w * (3 - 2 * w); + function startNext(now: number) { + const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) + + (held.has("arrowright") || held.has("d") ? 1 : 0); + if (turn !== 0) { + anim = { kind: "turn", from: facing, to: facing + turn * (Math.PI / 2), t0: now, dur: 210 }; + return; + } + const fwd = (held.has("arrowup") || held.has("w") ? 1 : 0) + + (held.has("arrowdown") || held.has("s") ? -1 : 0); + if (fwd === 0) return; + // The stride goes along the nearest cardinal (backpedal reverses it). + const q = Math.round(facing / (Math.PI / 2)); + const idx = ((q % 4) + 4 + (fwd < 0 ? 2 : 0)) % 4; + const side = SIDES4[idx]!; + const cell = { x: Math.floor(x), y: Math.floor(y) }; + if (!canWalk(view, cell, side)) return; + const tx = x + (side === "E" ? 1 : side === "W" ? -1 : 0); + const ty = y + (side === "S" ? 1 : side === "N" ? -1 : 0); + anim = { kind: "glide", fx: x, fy: y, tx, ty, t0: now, dur: 280 }; + } $effect(() => { let raf = 0; - let last = performance.now(); const tick = (now: number) => { - const dt = Math.min(0.05, (now - last) / 1000); - last = now; - const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) + - (held.has("arrowright") || held.has("d") ? 1 : 0); - facing += turn * dt * 2.6; - const fwd = (held.has("arrowup") || held.has("w") ? 1 : 0) + - (held.has("arrowdown") || held.has("s") ? -1 : 0); - if (fwd !== 0) { - const speed = fwd * dt * 2.2; - const nx = x + Math.cos(facing) * speed; - const ny = y + Math.sin(facing) * speed; - const cell = { x: Math.floor(x), y: Math.floor(y) }; - // Crossing a cell boundary asks the board's permission. - const crossX = Math.floor(nx) !== cell.x; - const crossY = Math.floor(ny) !== cell.y; - const okX = !crossX || canWalk(view, cell, nx > x ? "E" : "W"); - const okY = !crossY || canWalk(view, cell, ny > y ? "S" : "N"); - if (okX) x = nx; - if (okY) y = ny; + if (!anim) startNext(now); + if (anim) { + const w = Math.min(1, (now - anim.t0) / anim.dur); + if (anim.kind === "glide") { + x = anim.fx + (anim.tx - anim.fx) * ease(w); + y = anim.fy + (anim.ty - anim.fy) * ease(w); + } else { + facing = anim.from + (anim.to - anim.from) * ease(w); + } + if (w >= 1) anim = null; } raf = requestAnimationFrame(tick); }; @@ -121,7 +139,8 @@

The maze, through {povId}'s eyes

- Arrows / WASD walk and turn. Walls and shut doors refuse you; fire and + Arrows / WASD stride the maze square by square and swing in quarter + turns, as a wizard walks it. Walls and shut doors refuse you; fire and stranger things do not — this workshop has eyes, not rules. Seed with ?fpv&seed=N, stand anywhere with &x=&y=&dir=, or watch the clockwork's reel with &demo=1 (toggle 👁).