diff --git a/packages/web/public/textures/ceiling.png b/packages/web/public/textures/ceiling.png
new file mode 100644
index 0000000..5675180
Binary files /dev/null and b/packages/web/public/textures/ceiling.png differ
diff --git a/packages/web/public/textures/floor.png b/packages/web/public/textures/floor.png
new file mode 100644
index 0000000..0428da6
Binary files /dev/null and b/packages/web/public/textures/floor.png differ
diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte
index 8499c33..e2d36c7 100644
--- a/packages/web/src/TokenGallery.svelte
+++ b/packages/web/src/TokenGallery.svelte
@@ -94,7 +94,7 @@
wherever a file is missing.
- {#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)}
+ {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte
index 3a91382..6f425d7 100644
--- a/packages/web/src/fpv/FirstPerson.svelte
+++ b/packages/web/src/fpv/FirstPerson.svelte
@@ -58,57 +58,83 @@
// so a missing or still-loading file never leaves a wall naked.
const textures = materialTextures();
+ // Floor-casting samples per PIXEL, so the two ground-plane textures are
+ // cached as raw pixel buffers — re-extracted whenever a painted file
+ // finishes loading and swaps the entry.
+ const pixelCache = new WeakMap();
+ function pixelsOf(src: CanvasImageSource & { width: number; height: number }): ImageData {
+ let px = pixelCache.get(src);
+ if (!px) {
+ const t = document.createElement("canvas");
+ t.width = src.width;
+ t.height = src.height;
+ const c = t.getContext("2d")!;
+ c.drawImage(src, 0, 0);
+ px = c.getImageData(0, 0, t.width, t.height);
+ pixelCache.set(src, px);
+ }
+ return px;
+ }
+ let frame: ImageData | null = null;
+
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.
- const sky = ctx.createLinearGradient(0, 0, 0, half);
- sky.addColorStop(0, "#0d0c12");
- sky.addColorStop(1, "#2a2620");
- ctx.fillStyle = sky;
- ctx.fillRect(0, 0, W, half);
- const floor = ctx.createLinearGradient(0, half, 0, H);
- 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.
+ // The ground and the vault overhead, cast per pixel: each screen row
+ // below (or above) the horizon lies at one fixed depth, so the row is
+ // walked with a constant world-space step and sampled from the floor
+ // or ceiling texture — every maze cell wearing one full tile of it.
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;
+ if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
+ const buf = frame.data;
+ const fl = pixelsOf(textures.floor!);
+ const ce = pixelsOf(textures.ceiling!);
+ const FOG = 13;
+ for (let row = 0; row < H; row++) {
+ const below = row > half;
+ const dz = below ? row - half : half - row;
+ if (dz < 1) {
+ // The horizon sliver: fog it flat.
+ for (let col = 0; col < W; col++) {
+ const o = (row * W + col) * 4;
+ buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; buf[o + 3] = 255;
+ }
+ continue;
}
- 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);
+ const d = (H / 2) / dz;
+ const tex = below ? fl : ce;
+ const tw = tex.width, th = tex.height;
+ const tp = tex.data;
+ const shade = Math.max(0, Math.min(1, 1.25 / (1 + d * 0.45)) * (1 - d / FOG));
+ // World position at column 0 and its per-column step, both at depth d.
+ const sideStep = d / fx;
+ const side0 = -(W / 2) * sideStep;
+ let wx = x + d * cosF - side0 * sinF;
+ let wy = y + d * sinF + side0 * cosF;
+ const stepX = -sideStep * sinF;
+ const stepY = sideStep * cosF;
+ for (let col = 0; col < W; col++) {
+ const o = (row * W + col) * 4;
+ if (shade <= 0.02) {
+ buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9;
+ } else {
+ let u = wx % 1; if (u < 0) u += 1;
+ let v = wy % 1; if (v < 0) v += 1;
+ const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
+ buf[o] = tp[ti]! * shade;
+ buf[o + 1] = tp[ti + 1]! * shade;
+ buf[o + 2] = tp[ti + 2]! * shade;
+ }
+ buf[o + 3] = 255;
+ wx += stepX;
+ wy += stepY;
}
}
+ ctx.putImageData(frame, 0, 0);
// Walls, one ray per column; remember each column's depth for sprites.
const zbuf = new Float64Array(W);
diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts
index 9d42b87..7bebed6 100644
--- a/packages/web/src/fpv/textures.ts
+++ b/packages/web/src/fpv/textures.ts
@@ -79,6 +79,34 @@ export function proceduralTextures(): Record {
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
}
}),
+ floor: bake((c) => {
+ // Flagstones: a 2x2 of worn slabs per cell, the mortar border carrying
+ // the tile seam so every cell reads as one square of the maze.
+ c.fillStyle = "#3a3226";
+ c.fillRect(0, 0, TEX, TEX);
+ for (let r = 0; r < 2; r++) {
+ for (let q = 0; q < 2; q++) {
+ const jit = 0.8 + 0.3 * h2(r * 3 + 1, q * 7 + 2);
+ c.fillStyle = `rgb(${74 * jit | 0},${64 * jit | 0},${48 * jit | 0})`;
+ c.fillRect(q * (TEX / 2) + 2, r * (TEX / 2) + 2, TEX / 2 - 4, TEX / 2 - 4);
+ }
+ }
+ c.strokeStyle = "rgba(16,12,8,0.9)";
+ c.lineWidth = 3;
+ c.strokeRect(0.5, 0.5, TEX - 1, TEX - 1);
+ }),
+ ceiling: bake((c) => {
+ // Heavy dark slabs overhead, barely catching the torchlight.
+ c.fillStyle = "#161310";
+ c.fillRect(0, 0, TEX, TEX);
+ for (let r = 0; r < 2; r++) {
+ for (let q = 0; q < 2; q++) {
+ const jit = 0.75 + 0.3 * h2(r * 5 + 3, q * 11 + 4);
+ c.fillStyle = `rgb(${34 * jit | 0},${30 * jit | 0},${26 * jit | 0})`;
+ c.fillRect(q * (TEX / 2) + 1, r * (TEX / 2) + 1, TEX / 2 - 2, TEX / 2 - 2);
+ }
+ }
+ }),
warp: bake((c) => {
const grad = c.createLinearGradient(0, 0, TEX, TEX);
grad.addColorStop(0, "#2c1a4e");