Stage 2.5: the maze gets its masonry and the walk gets its gait

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 12:41:49 -04:00
co-authored by Claude Fable 5
parent 65092f50af
commit cf6349aca8
2 changed files with 179 additions and 44 deletions
+42 -23
View File
@@ -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<string>();
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 @@
<div class="fpv-shop">
<h1>The maze, through {povId}'s eyes</h1>
<p class="hint">
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
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>,
or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁).