The masonry becomes files: first-person textures join the paintable art

The six materials move out of the renderer into public/textures/*.png —
repaint any of them (any size; the sampler reads each file's own scale)
and refresh, exactly as the token SVGs work. fpv/textures.ts keeps the
procedural bake as the shipped default and the stand-in while files
load, and the /?tokens gallery grows a masonry section showing the set
beside the tokens they will someday match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 12:48:58 -04:00
co-authored by Claude Fable 5
parent cf6349aca8
commit fb89d752ae
9 changed files with 140 additions and 79 deletions
+109
View File
@@ -0,0 +1,109 @@
// The first-person materials. Each has two lives: a hand-paintable file
// at /textures/<name>.png (the one to improve — any size, sampled by
// column like the token art is swapped by file), and the procedural bake
// below that ships as the default and stands in while a file loads.
const FACE: Record<string, [number, number, number]> = {
wall: [126, 118, 100],
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
firewall: [214, 92, 28],
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.
export 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);
});
}
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
return {
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();
}
}),
};
}
/** The material set the renderer draws from: procedural now, each entry
* replaced in place the moment its painted file arrives. */
export function materialTextures(): Record<string, CanvasImageSource & { width: number; height: number }> {
const set: Record<string, CanvasImageSource & { width: number; height: number }> = proceduralTextures();
for (const name of MATERIALS) {
const img = new Image();
img.onload = () => { set[name] = img; };
img.src = `/textures/${name}.png`;
}
return set;
}