From fb89d752ae6fa4f5b1cc5457ed2f2d7a87f0c5fe Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 12:48:58 -0400 Subject: [PATCH] The masonry becomes files: first-person textures join the paintable art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/web/public/textures/door.png | Bin 0 -> 650 bytes packages/web/public/textures/firewall.png | Bin 0 -> 3762 bytes packages/web/public/textures/rim.png | Bin 0 -> 536 bytes packages/web/public/textures/stone.png | Bin 0 -> 412 bytes packages/web/public/textures/wall.png | Bin 0 -> 517 bytes packages/web/public/textures/warp.png | Bin 0 -> 7552 bytes packages/web/src/TokenGallery.svelte | 23 +++++ packages/web/src/fpv/FirstPerson.svelte | 87 ++--------------- packages/web/src/fpv/textures.ts | 109 ++++++++++++++++++++++ 9 files changed, 140 insertions(+), 79 deletions(-) create mode 100644 packages/web/public/textures/door.png create mode 100644 packages/web/public/textures/firewall.png create mode 100644 packages/web/public/textures/rim.png create mode 100644 packages/web/public/textures/stone.png create mode 100644 packages/web/public/textures/wall.png create mode 100644 packages/web/public/textures/warp.png create mode 100644 packages/web/src/fpv/textures.ts diff --git a/packages/web/public/textures/door.png b/packages/web/public/textures/door.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ea53854a03307f8c23a598f4e2e959e4d3f14b GIT binary patch literal 650 zcmV;50(Jd~P)Ner7$bU&i#E*|_~S=fsrddR`M2Txx7sb0j37g0e^f{LFQjYgwRGLu*% zOc>iCTIi*xD)fVNO+Tpj>dmwo9n5sb%ULo;1~R7z&*x7J0bf!pBTVA z0HD%I3l?Wb$T+G#l+nD4ememx3u9!2`OB3*Kt;g!`(K*>4O9etc?EEatd}H!_4rY| zBmhldy(9sw$B*JA0cZm2#V!H*w8a435pH`)0Jn+jdL;q6Bi#0q0B#f4^-2PCN4V{k zO4!i!EHWyU%5WlSTR#8ERY1inzK47w!h8q0RCM{pp()tDE|KB7*2a&~zXOHxB&;82 z$q4f|&hkJ-!1w##tQLWafG^*Gw%Y{T^2q4x>l}M-%k%l^L>s6G^HWwEsCdO!$R{G; ztJ=vf{3~6Pv9`84eSWZ0y*b^3x#2r9!u*+Ew?IX}_xm3&)?otif3OB0MZh=y707)b zgYByVY$5hRlkCrSHC{fYpyt@!{YU;7ZGggSSQpvED^aiY+8b?v z)2KIfarsnBFZJ9TZNOvW#<{N^LN3{Yg582i{1B$`M(e3qduX%)z80rZgQ-&eaXT*w zh}%S|dL#i<$KrNg5)ikEQuRmzsE)<$yd)rQ6Q$~r1W+A|+j&WV=@b2I|IYvb0RR7Y kv+l$I000I_L_t&o0Dz-87Y$N25dZ)H07*qoM6N<$f>G}*_5c6? literal 0 HcmV?d00001 diff --git a/packages/web/public/textures/firewall.png b/packages/web/public/textures/firewall.png new file mode 100644 index 0000000000000000000000000000000000000000..116658629e92ea1c788f28ef5be9e93b8d3c7335 GIT binary patch literal 3762 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 Cv+IEX literal 0 HcmV?d00001 diff --git a/packages/web/public/textures/warp.png b/packages/web/public/textures/warp.png new file mode 100644 index 0000000000000000000000000000000000000000..21e898c6f4779aa4f521d594c502d42c3de59642 GIT binary patch literal 7552 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 {/each} +

The masonry — first-person wall textures

+

+ The materials the raycaster (/?fpv) wraps its walls in. + Each lives at public/textures/<name>.png — repaint the + file (any size) and refresh; the built-in procedural bake stands in + wherever a file is missing. +

+
+ {#each ["wall", "rim", "stone", "door", "firewall", "warp"] as name (name)} +
+ {`${name} +
{name}
+
+ {/each} +
+

Walls & doors — as the maze draws them

Wall-segment effects have never been tokens: locks picked, jammed, and @@ -172,4 +188,11 @@ text-align: center; padding-top: 0.35rem; } + .masonry { + width: 128px; + height: 128px; + image-rendering: pixelated; + border: 1px solid #3a3428; + background: #101318; + } diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 904c0f5..3a91382 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -4,6 +4,7 @@ // billboarded for whatever stands in the corridors, occluded per column // by the same depth buffer the walls wrote. import { castRay, billboards, type Billboard } from "./raycast"; + import { materialTextures } from "./textures"; import { tokenArt } from "../art"; import type { GameView } from "@wizwar/engine"; @@ -52,84 +53,10 @@ 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 = { - 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(); - } - }), - }; + // Materials come from /textures/*.png when those files exist — the + // independently paintable set — with the baked procedurals underneath + // so a missing or still-loading file never leaves a wall naked. + const textures = materialTextures(); function draw(time: number) { const ctx = canvas?.getContext("2d"); @@ -193,7 +120,9 @@ const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); 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); + // Sample by the texture's own size: painted files may be any scale. + ctx.drawImage(tex, Math.min(tex.width - 1, hit.u * tex.width), 0, + Math.max(1, tex.width / 96), tex.height, 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; diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts new file mode 100644 index 0000000..9d42b87 --- /dev/null +++ b/packages/web/src/fpv/textures.ts @@ -0,0 +1,109 @@ +// The first-person materials. Each has two lives: a hand-paintable file +// at /textures/.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 = { + 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 { + 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 { + const set: Record = proceduralTextures(); + for (const name of MATERIALS) { + const img = new Image(); + img.onload = () => { set[name] = img; }; + img.src = `/textures/${name}.png`; + } + return set; +}