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}
+
+ {/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;
+}