// 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], }; // 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); }); } export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const; /** The built-in bake: what ships when no painted file overrides it. */ 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); } }), 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); } } }), home: bake((c) => { // A home base's floor emblem, laid over the flagstones and tinted by // its owner's color at draw time — so paint it near-white, with // transparency wherever the plain floor should show through. c.strokeStyle = "rgba(240,235,225,0.85)"; c.lineWidth = 3; c.strokeRect(4.5, 4.5, TEX - 9, TEX - 9); c.lineWidth = 2; c.beginPath(); c.moveTo(TEX / 2, 12); c.lineTo(TEX - 12, TEX / 2); c.lineTo(TEX / 2, TEX - 12); c.lineTo(12, TEX / 2); c.closePath(); c.stroke(); c.fillStyle = "rgba(240,235,225,0.35)"; c.beginPath(); c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2); c.fill(); }), underbrush: bake((c) => { // The ground beneath a hedge: leaf litter and roots to the square's // edges, so the standing tangle above never floats on bare flags. for (let i = 0; i < 46; i++) { const g = 40 + (h2(i, 61) * 45 | 0); c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`; c.beginPath(); c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4, h2(i, 89) * Math.PI, 0, Math.PI * 2); c.fill(); } }), doorframe: bake((c) => { // An empty doorway's dressing: stone posts up both sides and a // lintel across the top, transparent in the middle where the way // through stands open. Posts live in the outer ~8% columns; the // lintel in the top ~16% rows. const stone = (x: number, y: number, w: number, h: number, jit: number) => { const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0; c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`; c.fillRect(x, y, w, h); c.strokeStyle = "rgba(30,24,16,0.8)"; c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); }; for (let r = 0; r < 5; r++) { stone(0, 10 + r * 11, 6, 10, r); stone(TEX - 6, 10 + r * 11, 6, 10, r + 7); } stone(0, 0, TEX / 2, 9, 13); stone(TEX / 2, 0, TEX / 2, 9, 17); }), cracks: bake((c) => { // Battle damage, worn over any wall by accumulated harm: dark // fractures on transparency, drawn heavier as damage mounts. c.strokeStyle = "rgba(14,10,6,0.85)"; c.lineWidth = 1.6; for (let i = 0; i < 5; i++) { let x = h2(i, 21) * TEX, y = 0; c.beginPath(); c.moveTo(x, y); while (y < TEX) { x += (h2(x, y) - 0.5) * 14; y += 6 + h2(y, x) * 8; c.lineTo(x, y); } c.stroke(); } }), pit: bake((c) => { // A hole in the world: black heart, ragged broken-flag edge, a rim // highlight on the lit side selling the depth. c.fillStyle = "rgba(0,0,0,0.92)"; c.beginPath(); c.ellipse(TEX / 2, TEX / 2, TEX * 0.38, TEX * 0.34, 0, 0, Math.PI * 2); c.fill(); c.strokeStyle = "rgba(20,16,10,0.9)"; c.lineWidth = 4; c.stroke(); c.strokeStyle = "rgba(180,168,140,0.5)"; c.lineWidth = 2; c.beginPath(); c.ellipse(TEX / 2, TEX / 2 - 2, TEX * 0.38, TEX * 0.34, 0, Math.PI * 1.1, Math.PI * 1.9); c.stroke(); }), slime: bake((c) => { // A spill of green across the flags, glistening. c.fillStyle = "rgba(70,140,40,0.55)"; c.beginPath(); c.ellipse(TEX * 0.5, TEX * 0.52, TEX * 0.4, TEX * 0.32, 0.4, 0, Math.PI * 2); c.fill(); c.fillStyle = "rgba(110,190,60,0.5)"; for (let i = 0; i < 6; i++) { c.beginPath(); c.ellipse(10 + h2(i, 2) * 44, 12 + h2(i, 5) * 40, 4 + h2(i, 7) * 5, 3 + h2(i, 9) * 4, 0, 0, Math.PI * 2); c.fill(); } c.fillStyle = "rgba(220,255,200,0.35)"; c.fillRect(TEX * 0.32, TEX * 0.4, 5, 2); }), tacks: bake((c) => { // A scatter of little iron cruelties. for (let i = 0; i < 14; i++) { const x = 8 + h2(i, 3) * 48, y = 8 + h2(i, 6) * 48; c.fillStyle = "rgba(70,70,78,0.9)"; c.beginPath(); c.moveTo(x, y - 3); c.lineTo(x + 3, y + 2); c.lineTo(x - 3, y + 2); c.closePath(); c.fill(); c.fillStyle = "rgba(190,190,200,0.8)"; c.fillRect(x - 1, y - 4, 2, 2); } }), dimwarp: bake((c) => { // The dimensional warp's floor mouth: violet rings drawing inward. for (let i = 5; i >= 0; i--) { const a = 0.16 + i * 0.1; c.strokeStyle = `rgba(${140 + i * 12},${80 + i * 14},${220},${a})`; c.lineWidth = 3; c.beginPath(); c.arc(TEX / 2, TEX / 2, 5 + i * 5, 0, Math.PI * 2); c.stroke(); } c.fillStyle = "rgba(240,225,255,0.8)"; c.beginPath(); c.arc(TEX / 2, TEX / 2, 3.5, 0, Math.PI * 2); c.fill(); }), 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; }