diff --git a/packages/web/public/terrain3d/dust.png b/packages/web/public/terrain3d/dust.png new file mode 100644 index 0000000..fe67125 Binary files /dev/null and b/packages/web/public/terrain3d/dust.png differ diff --git a/packages/web/public/terrain3d/jello.png b/packages/web/public/terrain3d/jello.png new file mode 100644 index 0000000..694248b Binary files /dev/null and b/packages/web/public/terrain3d/jello.png differ diff --git a/packages/web/public/terrain3d/rosebush.png b/packages/web/public/terrain3d/rosebush.png new file mode 100644 index 0000000..136817e Binary files /dev/null and b/packages/web/public/terrain3d/rosebush.png differ diff --git a/packages/web/public/terrain3d/thornbush.png b/packages/web/public/terrain3d/thornbush.png new file mode 100644 index 0000000..dc93906 Binary files /dev/null and b/packages/web/public/terrain3d/thornbush.png differ diff --git a/packages/web/public/terrain3d/warpglow.png b/packages/web/public/terrain3d/warpglow.png new file mode 100644 index 0000000..f595084 Binary files /dev/null and b/packages/web/public/terrain3d/warpglow.png differ diff --git a/packages/web/public/textures/dimwarp.png b/packages/web/public/textures/dimwarp.png new file mode 100644 index 0000000..290c36d Binary files /dev/null and b/packages/web/public/textures/dimwarp.png differ diff --git a/packages/web/public/textures/pit.png b/packages/web/public/textures/pit.png new file mode 100644 index 0000000..6b6d155 Binary files /dev/null and b/packages/web/public/textures/pit.png differ diff --git a/packages/web/public/textures/slime.png b/packages/web/public/textures/slime.png new file mode 100644 index 0000000..2427410 Binary files /dev/null and b/packages/web/public/textures/slime.png differ diff --git a/packages/web/public/textures/tacks.png b/packages/web/public/textures/tacks.png new file mode 100644 index 0000000..5109d72 Binary files /dev/null and b/packages/web/public/textures/tacks.png differ diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte index 8b42fc5..0fa34a4 100644 --- a/packages/web/src/TokenGallery.svelte +++ b/packages/web/src/TokenGallery.svelte @@ -8,6 +8,7 @@ import FirewallEdge from "./FirewallEdge.svelte"; import IllusionShimmer from "./IllusionShimmer.svelte"; import { FX_ART } from "./fpv/fx3d"; + import { TERRAIN3D } from "./fpv/terrain3d"; const GROUPS: { title: string; files: string[] }[] = [ { title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] }, @@ -95,7 +96,7 @@ wherever a file is missing.

- {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)} + {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp"] as name (name)}
{`${name}
{name}
@@ -120,6 +121,24 @@ {/each}
+

The standing terrain — first-person volumes

+

+ Terrain that stands in the room: hedges filling a square to half + height, the killer ooze as a translucent cube, dust billowing, the + dimensional warp's pillar of light. Each lives at + public/terrain3d/<name>.png — transparent PNG, any + size; hedges render twice as wide as tall. Rehearse them in place with + /?fpv&terrain=thornbush@3,7;jello@4,7. +

+
+ {#each TERRAIN3D as name (name)} +
+ {`${name} +
{name}
+
+ {/each} +
+

Walls & doors — as the maze draws them

Wall-segment effects have never been tokens: locks picked, jammed, and diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index e4ed672..a2a90c9 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -6,6 +6,7 @@ import { castRay, billboards } from "./raycast"; import { materialTextures } from "./textures"; import { doorOpenness, fxFallback, type FpFx } from "./fx3d"; + import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; import type { GameView } from "@wizwar/engine"; @@ -88,29 +89,42 @@ } let frame: ImageData | null = null; - // Home bases wear their owner's emblem on the floor: a per-view grid of - // owner tints, indexed by integer cell for the per-pixel pass. - const homeCache = new WeakMap(); - function homesOf(v: GameView) { - let h = homeCache.get(v); + // The floor wears decals: home emblems in their owners' colors, pits + // opening downward, slime, tacks, dimensional warp rings — a per-view + // grid mapping each cell to one overlay, read by the per-pixel pass. + interface Decal { tex: string; tint: [number, number, number] | null } + const decalCache = new WeakMap(); + function decalsOf(v: GameView) { + let h = decalCache.get(v); if (!h) { const bw = v.board.width, bh = v.board.height; const grid = new Int16Array(bw * bh).fill(-1); - const tints: [number, number, number][] = []; + const decals: Decal[] = []; + const put = (x: number, y: number, tex: string, tint: Decal["tint"] = null) => { + if (x < 0 || y < 0 || x >= bw || y >= bh) return; + decals.push({ tex, tint }); + grid[y * bw + x] = decals.length - 1; + }; for (const p of v.players) { if (!p.home) continue; const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888"; - tints.push([ + put(p.home.x, p.home.y, "home", [ parseInt(hex.slice(1, 3), 16) / 255, parseInt(hex.slice(3, 5), 16) / 255, parseInt(hex.slice(5, 7), 16) / 255, ]); - if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) { - grid[p.home.y * bw + p.home.x] = tints.length - 1; - } } - h = { grid, bw, bh, tints }; - homeCache.set(v, h); + for (const [k, content] of Object.entries(v.squareContents)) { + if (content.kind !== "pit" && content.kind !== "slime" && content.kind !== "tacks") continue; + const [cx, cy] = k.split(",").map(Number) as [number, number]; + put(cx, cy, content.kind); + } + for (const w of v.dimWarps) { + put(w.a.x, w.a.y, "dimwarp"); + put(w.b.x, w.b.y, "dimwarp"); + } + h = { grid, bw, bh, decals }; + decalCache.set(v, h); } return h; } @@ -162,8 +176,7 @@ continue; } const d = (H / 2) / dz; - const homes = below ? homesOf(view) : null; - const hp = below ? pixelsOf(textures.home!) : null; + const dec = below ? decalsOf(view) : null; const tex = below ? fl : ce; const tw = tex.width, th = tex.height; const tp = tex.data; @@ -184,17 +197,19 @@ let v = wy % 1; if (v < 0) v += 1; const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4; let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!; - if (homes) { + if (dec) { const hx = (wx - u) | 0, hy = (wy - v) | 0; - if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) { - const owner = homes.grid[hy * homes.bw + hx]!; - if (owner >= 0 && hp) { - // The emblem sits over the flagstones, wearing its - // owner's color. + if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) { + const di = dec.grid[hy * dec.bw + hx]!; + if (di >= 0) { + // A decal lies over the flagstones, alpha-blended texel + // by texel; home emblems wear their owner's color. + const decal = dec.decals[di]!; + const hp = pixelsOf(textures[decal.tex] ?? textures.home!); const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4; const ha = hp.data[hi + 3]! / 255; if (ha > 0) { - const [tr, tg, tb] = homes.tints[owner]!; + const [tr, tg, tb] = decal.tint ?? [1, 1, 1]; r = r * (1 - ha) + hp.data[hi]! * tr * ha; g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha; b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha; @@ -306,16 +321,21 @@ rise: 0.3, warped: f.kind === "projectile" ? f.warped : undefined, }, ex, ey); - if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); + if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art }); } sprites.sort((a, b) => b.depth - a.depth); for (const s of sprites) { - const img = imageFor(s.src) ?? (s.fallback ? fxFallback(s.fallback) : null); + const img = imageFor(s.src) ?? + (s.fallback + ? (TERRAIN3D.includes(s.fallback as (typeof TERRAIN3D)[number]) + ? terrainFallback(s.fallback) : fxFallback(s.fallback)) + : null); const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0); const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0); - // Conjurations are painted art, not light sources: normal - // compositing keeps their inks true; only the fade is borrowed. - if (s.glow) ctx.globalAlpha = s.alpha ?? 1; + // Painted art composites normally (inks stay true); light glows + // additively. Either way translucency applies. + if (s.glow) ctx.globalCompositeOperation = "lighter"; + if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1; for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { if (s.depth >= zbuf[col]!) continue; if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue; @@ -336,7 +356,8 @@ ctx.fillRect(col, s.top, 1, s.bottom - s.top); } } - if (s.glow) ctx.globalAlpha = 1; + if (s.glow) ctx.globalCompositeOperation = "source-over"; + if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1; } // The ghosts of walls you know are lies: drawn over everything at @@ -386,7 +407,9 @@ fallback?: string; } function project( - b: { x: number; y: number; src: string; scale: number; rise: number; warped?: boolean }, + b: { x: number; y: number; src: string; scale: number; rise: number; + aspect?: number; alpha?: number; glow?: boolean; bias?: number; + fallback?: string; warped?: boolean }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; @@ -397,10 +420,12 @@ const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2); const wallH = H / depth; const size = wallH * b.scale; + const wide = size * (b.aspect ?? 1); const bottom = half + wallH / 2 - b.rise * wallH; return { - src: b.src, depth, warped: b.warped, - left: screenX - size / 2, right: screenX + size / 2, + src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped, + alpha: b.alpha, glow: b.glow, fallback: b.fallback, + left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, }; } diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index 843bff9..eed2de0 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -27,6 +27,15 @@ view.knownIllusionEdges.push(k); delete view.board.edges[k]; } + // ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer + // (and the artist) to rehearse. dimwarp@x,y opens a floor mouth. + for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) { + const [kind, at] = spec.split("@") as [string, string?]; + const [tx, ty] = (at ?? "").split(",").map(Number); + if (!Number.isFinite(tx) || !Number.isFinite(ty)) continue; + if (kind === "dimwarp") view.dimWarps.push({ a: { x: tx!, y: ty! }, b: { x: tx!, y: ty! } }); + else view.squareContents[`${tx},${ty}`] = { kind } as (typeof view.squareContents)[string]; + } const start = view.players.find((p) => p.id === povId)!.position; // ?x=&y=&dir= override the spawn — for standing the camera anywhere. diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index c8469d1..e84fdad 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -227,6 +227,17 @@ export interface Billboard { /** Lifted off the floor (0 = feet on the ground). */ rise: number; label: string; + /** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */ + aspect?: number; + /** Translucency (jello, dust); 1 or absent is opaque. */ + alpha?: number; + /** Drawn additively, as light (warp pillars). */ + glow?: boolean; + /** Pulled this much nearer for draw order: a hedge wins the tie + * against the wizard standing in its own square, who then peeks over. */ + bias?: number; + /** Procedural stand-in name while the painted file loads. */ + fallback?: string; /** A reflection seen through a warp mouth, not the thing itself. */ warped?: boolean; } @@ -298,19 +309,42 @@ export function billboards( scale: 0.4, rise: 0, label: "treasure", }); } - // The floor's furniture: bushes stand tall, hazards squat low. Stone is - // a wall to the rays and needs no sprite. - const TERRAIN_SCALE: Record = { - thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3, - tacks: 0.25, pit: 0.35, dust: 0.6, - }; + // The floor's furniture, standing in the room. Bushes fill the square + // to half height (a wizard peeks over); the killer ooze is a whole + // translucent cube of jello; dust billows. Pits, slime, tacks, and + // dimensional warps are painted INTO the floor by the decal pass, and + // stone is a wall to the rays — none of those need a sprite. for (const [k, content] of Object.entries(view.squareContents)) { - if (content.kind === "stone") continue; - const file = TERRAIN_ART[content.kind]; - const scale = TERRAIN_SCALE[content.kind]; - if (!file || !scale) continue; const [tx, ty] = k.split(",").map(Number) as [number, number]; - out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind }); + const at = { x: tx + 0.5, y: ty + 0.5 }; + if (content.kind === "thornbush" || content.kind === "rosebush") { + out.push({ + ...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind, + scale: 0.5, aspect: 2, rise: 0, bias: 0.18, label: content.kind, + }); + } else if (content.kind === "ooze") { + out.push({ + ...at, src: "/terrain3d/jello.png", fallback: "jello", + scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", + }); + } else if (content.kind === "dust") { + out.push({ + ...at, src: "/terrain3d/dust.png", fallback: "dust", + scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", + }); + } else if (content.kind === "safe") { + out.push({ ...at, src: art(TERRAIN_ART["safe"]!, "terrain"), scale: 0.55, rise: 0, label: "safe" }); + } + } + // Dimensional warp mouths throw pillars of light from their floor rings. + for (const w of view.dimWarps) { + for (const cellAt of [w.a, w.b]) { + out.push({ + x: cellAt.x + 0.5, y: cellAt.y + 0.5, + src: "/terrain3d/warpglow.png", fallback: "warpglow", + scale: 0.95, aspect: 0.5, rise: 0, alpha: 0.6, glow: true, label: "dimwarp", + }); + } } for (const [k, cards] of Object.entries(view.groundObjects)) { const file = cards[0] ? objectArt(cards[0].cardId) : null; diff --git a/packages/web/src/fpv/terrain3d.ts b/packages/web/src/fpv/terrain3d.ts new file mode 100644 index 0000000..960c3dc --- /dev/null +++ b/packages/web/src/fpv/terrain3d.ts @@ -0,0 +1,87 @@ +// Terrain that stands in the room rather than lying on its floor: bushes +// filling a square to half height, the killer ooze as a translucent cube +// of jello, dust billowing, dimensional warps throwing light. Each is a +// paintable sprite at public/terrain3d/.png (transparent PNG, any +// size); the bakes below stand in until painted. + +export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow"] as const; + +const baked = new Map(); +export function terrainFallback(name: string): HTMLCanvasElement { + let t = baked.get(name); + if (t) return t; + t = document.createElement("canvas"); + t.width = 128; + t.height = 64; + const c = t.getContext("2d")!; + const h2 = (a: number, b: number) => { + const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; + return n - Math.floor(n); + }; + if (name === "thornbush" || name === "rosebush") { + // A hedge of tangles wall to wall; roses get their blooms. + for (let i = 0; i < 40; i++) { + const x = h2(i, 1) * 128, y = 16 + h2(i, 3) * 46, r = 6 + h2(i, 5) * 9; + const g = 60 + (h2(i, 7) * 50 | 0); + c.fillStyle = `rgba(${g * 0.35 | 0},${g},${g * 0.3 | 0},0.9)`; + c.beginPath(); + c.arc(x, y, r, 0, Math.PI * 2); + c.fill(); + } + c.strokeStyle = "rgba(30,40,18,0.7)"; + for (let i = 0; i < 14; i++) { + c.beginPath(); + c.moveTo(h2(i, 11) * 128, 18 + h2(i, 13) * 40); + c.lineTo(h2(i, 11) * 128 + 10 - h2(i, 17) * 20, 8 + h2(i, 19) * 40); + c.stroke(); + } + if (name === "rosebush") { + for (let i = 0; i < 9; i++) { + c.fillStyle = "rgba(210,40,70,0.95)"; + c.beginPath(); + c.arc(6 + h2(i, 23) * 116, 14 + h2(i, 29) * 40, 3.5, 0, Math.PI * 2); + c.fill(); + } + } + } else if (name === "jello") { + // The gelatinous whole of the killer ooze, drawn as one cube face + // with a paler top edge — the renderer's alpha keeps it see-through. + t.height = 128; + const g2 = t.getContext("2d")!; + g2.fillStyle = "rgba(90,190,70,0.85)"; + g2.fillRect(4, 12, 120, 112); + g2.fillStyle = "rgba(170,240,140,0.9)"; + g2.fillRect(4, 12, 120, 10); + g2.fillStyle = "rgba(230,255,210,0.5)"; + g2.fillRect(16, 30, 22, 12); + for (let i = 0; i < 8; i++) { + g2.fillStyle = "rgba(40,120,30,0.5)"; + g2.beginPath(); + g2.arc(16 + h2(i, 31) * 96, 40 + h2(i, 37) * 72, 3 + h2(i, 41) * 3, 0, Math.PI * 2); + g2.fill(); + } + } else if (name === "dust") { + t.height = 128; + const g2 = t.getContext("2d")!; + for (let i = 0; i < 26; i++) { + const a = 0.12 + h2(i, 43) * 0.16; + g2.fillStyle = `rgba(200,190,170,${a})`; + g2.beginPath(); + g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2); + g2.fill(); + } + } else { + // warpglow: a pillar of violet light, brightest at its core. + t.width = 64; + t.height = 128; + const g2 = t.getContext("2d")!; + const grad = g2.createLinearGradient(0, 0, 64, 0); + grad.addColorStop(0, "rgba(140,80,220,0)"); + grad.addColorStop(0.5, "rgba(200,160,255,0.75)"); + grad.addColorStop(1, "rgba(140,80,220,0)"); + g2.fillStyle = grad; + g2.fillRect(0, 0, 64, 128); + } + baked.set(name, t); + return t; +} diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts index 882faee..d21cf06 100644 --- a/packages/web/src/fpv/textures.ts +++ b/packages/web/src/fpv/textures.ts @@ -42,7 +42,7 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2); }); } -export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const; +export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp"] as const; /** The built-in bake: what ships when no painted file overrides it. */ export function proceduralTextures(): Record { @@ -127,6 +127,67 @@ export function proceduralTextures(): Record { c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2); c.fill(); }), + 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"); diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md index eb5b1bc..bf468da 100644 --- a/research/fx3d-sprite-spec.md +++ b/research/fx3d-sprite-spec.md @@ -51,6 +51,24 @@ door slide (doors animate by sliding `door.png` along its own edge — no separate art), and the jammed-lock seethe (a pulsing rust glow the renderer lays over the door) — those are renderer effects, not images. +## The terrain, in two families + +**Floor decals** — `public/textures/pit.png`, `slime.png`, `tacks.png`, +`dimwarp.png` — are painted INTO the flagstones by the floor caster, +alpha-blended texel by texel like the home tile: transparent wherever +plain floor should show. The pit can carry its own painted label if +wanted. Square, any size. + +**Standing volumes** — `public/terrain3d/thornbush.png`, `rosebush.png`, +`jello.png`, `dust.png`, `warpglow.png` — are billboards in the room. +The hedges render CELL-WIDE at half wall height (paint them twice as +wide as tall; a wizard in the square peeks over the top). The jello +cube fills the whole square and is drawn ~60% opaque — paint it solid, +the renderer supplies the translucency. Dust likewise. `warpglow` is a +pillar of light drawn ADDITIVELY (like the old glow sprites): bright +means bright, black vanishes. Rehearse any of them in place: +`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`. + ## One more paintable surface: the home tile `public/textures/home.png` is a floor overlay for home-base cells,