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:
co-authored by
Claude Fable 5
parent
65092f50af
commit
cf6349aca8
@@ -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<string, [number, number, number]> = {
|
||||
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<string, HTMLCanvasElement> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
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);
|
||||
facing += turn * dt * 2.6;
|
||||
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) {
|
||||
const speed = fwd * dt * 2.2;
|
||||
const nx = x + Math.cos(facing) * speed;
|
||||
const ny = y + Math.sin(facing) * speed;
|
||||
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) };
|
||||
// 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 (!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;
|
||||
const tick = (now: number) => {
|
||||
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 👁).
|
||||
|
||||
Reference in New Issue
Block a user