diff --git a/packages/web/public/textures/door.png b/packages/web/public/textures/door.png new file mode 100644 index 0000000..d1ea538 Binary files /dev/null and b/packages/web/public/textures/door.png differ diff --git a/packages/web/public/textures/firewall.png b/packages/web/public/textures/firewall.png new file mode 100644 index 0000000..1166586 Binary files /dev/null and b/packages/web/public/textures/firewall.png differ diff --git a/packages/web/public/textures/rim.png b/packages/web/public/textures/rim.png new file mode 100644 index 0000000..e639edb Binary files /dev/null and b/packages/web/public/textures/rim.png differ diff --git a/packages/web/public/textures/stone.png b/packages/web/public/textures/stone.png new file mode 100644 index 0000000..6050b79 Binary files /dev/null and b/packages/web/public/textures/stone.png differ diff --git a/packages/web/public/textures/wall.png b/packages/web/public/textures/wall.png new file mode 100644 index 0000000..4d8eae1 Binary files /dev/null and b/packages/web/public/textures/wall.png differ diff --git a/packages/web/public/textures/warp.png b/packages/web/public/textures/warp.png new file mode 100644 index 0000000..21e898c Binary files /dev/null and b/packages/web/public/textures/warp.png differ diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte index 3edcaef..8499c33 100644 --- a/packages/web/src/TokenGallery.svelte +++ b/packages/web/src/TokenGallery.svelte @@ -86,6 +86,22 @@ {/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; +}