The ground gets cast: per-pixel floor and ceiling, both paintable
True floor-casting replaces the gradients and the projected seam lines: every screen row lies at one depth, walked with a constant world-space step and sampled from floor.png underfoot and ceiling.png overhead — each maze cell wearing exactly one tile, so the artist's border IS the cell seam. Distance shade and fog fold into the same per-pixel pass, pixel buffers cache per texture (re-read when a painted file lands), and the whole frame still clears 60fps with room to spare. Flagstones and dark slabs ship as the starter files beside the other masonry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b2a6809a92
commit
0f01f8c356
Binary file not shown.
|
After Width: | Height: | Size: 351 B |
Binary file not shown.
|
After Width: | Height: | Size: 380 B |
@@ -94,7 +94,7 @@
|
|||||||
wherever a file is missing.
|
wherever a file is missing.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)}
|
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||||
<figcaption>{name}</figcaption>
|
<figcaption>{name}</figcaption>
|
||||||
|
|||||||
@@ -58,57 +58,83 @@
|
|||||||
// so a missing or still-loading file never leaves a wall naked.
|
// so a missing or still-loading file never leaves a wall naked.
|
||||||
const textures = materialTextures();
|
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<object, ImageData>();
|
||||||
|
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) {
|
function draw(time: number) {
|
||||||
const ctx = canvas?.getContext("2d");
|
const ctx = canvas?.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
|
||||||
const W = width, H = height, half = H / 2;
|
const W = width, H = height, half = H / 2;
|
||||||
|
|
||||||
// Sky and floor: torchlight fading to the dark of the maze.
|
// The ground and the vault overhead, cast per pixel: each screen row
|
||||||
const sky = ctx.createLinearGradient(0, 0, 0, half);
|
// below (or above) the horizon lies at one fixed depth, so the row is
|
||||||
sky.addColorStop(0, "#0d0c12");
|
// walked with a constant world-space step and sampled from the floor
|
||||||
sky.addColorStop(1, "#2a2620");
|
// or ceiling texture — every maze cell wearing one full tile of it.
|
||||||
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.
|
|
||||||
const fx = (W / 2) / Math.tan(FOV / 2);
|
const fx = (W / 2) / Math.tan(FOV / 2);
|
||||||
const cosF = Math.cos(facing), sinF = Math.sin(facing);
|
const cosF = Math.cos(facing), sinF = Math.sin(facing);
|
||||||
const toCam = (wx: number, wy: number) => {
|
if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H);
|
||||||
const rx = wx - x, ry = wy - y;
|
const buf = frame.data;
|
||||||
return { fwd: rx * cosF + ry * sinF, side: -rx * sinF + ry * cosF };
|
const fl = pixelsOf(textures.floor!);
|
||||||
};
|
const ce = pixelsOf(textures.ceiling!);
|
||||||
ctx.lineWidth = 1;
|
const FOG = 13;
|
||||||
const seg = (ax: number, ay: number, bx: number, by: number) => {
|
for (let row = 0; row < H; row++) {
|
||||||
let a = toCam(ax, ay), b = toCam(bx, by);
|
const below = row > half;
|
||||||
const NEAR = 0.12;
|
const dz = below ? row - half : half - row;
|
||||||
if (a.fwd < NEAR && b.fwd < NEAR) return;
|
if (dz < 1) {
|
||||||
if (a.fwd < NEAR || b.fwd < NEAR) {
|
// The horizon sliver: fog it flat.
|
||||||
const t = (NEAR - a.fwd) / (b.fwd - a.fwd);
|
for (let col = 0; col < W; col++) {
|
||||||
const cut = { fwd: NEAR, side: a.side + (b.side - a.side) * t };
|
const o = (row * W + col) * 4;
|
||||||
if (a.fwd < NEAR) a = cut; else b = cut;
|
buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; buf[o + 3] = 255;
|
||||||
}
|
}
|
||||||
const mid = (a.fwd + b.fwd) / 2;
|
continue;
|
||||||
if (mid > 11) return;
|
}
|
||||||
ctx.strokeStyle = `rgba(18,14,10,${Math.min(0.75, 1.0 / (1 + mid * 0.35))})`;
|
const d = (H / 2) / dz;
|
||||||
ctx.beginPath();
|
const tex = below ? fl : ce;
|
||||||
ctx.moveTo(W / 2 + (a.side / a.fwd) * fx, half + (H / 2) / a.fwd);
|
const tw = tex.width, th = tex.height;
|
||||||
ctx.lineTo(W / 2 + (b.side / b.fwd) * fx, half + (H / 2) / b.fwd);
|
const tp = tex.data;
|
||||||
ctx.stroke();
|
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 R = 11;
|
const sideStep = d / fx;
|
||||||
for (let gx = Math.floor(x) - R; gx <= Math.floor(x) + R; gx++) {
|
const side0 = -(W / 2) * sideStep;
|
||||||
for (let gy = Math.floor(y) - R; gy <= Math.floor(y) + R; gy++) {
|
let wx = x + d * cosF - side0 * sinF;
|
||||||
seg(gx, gy, gx + 1, gy);
|
let wy = y + d * sinF + side0 * cosF;
|
||||||
seg(gx, gy, gx, gy + 1);
|
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.
|
// Walls, one ray per column; remember each column's depth for sprites.
|
||||||
const zbuf = new Float64Array(W);
|
const zbuf = new Float64Array(W);
|
||||||
|
|||||||
@@ -79,6 +79,34 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
|||||||
c.fillRect(h2(i, 1) * TEX, TEX - h2(i, 7) * TEX, w, h2(i, 7) * TEX);
|
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) => {
|
warp: bake((c) => {
|
||||||
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
||||||
grad.addColorStop(0, "#2c1a4e");
|
grad.addColorStop(0, "#2c1a4e");
|
||||||
|
|||||||
Reference in New Issue
Block a user