The terrain moves in: pits open downward, hedges stand waist-high, ooze is jello

The flat token billboards give way to terrain that inhabits the room.
Pits, slime, tacks, and the dimensional warp's rings are painted INTO
the flagstones — the home-tile decal pass grows into a general per-cell
overlay grid, one texture per cell, alpha-blended texel by texel. Thorn
and rose hedges fill their square wall to wall at half height, drawn
with a near-bias so the wizard standing among the thorns peeks over the
top. The killer ooze rises as a full translucent cube of jello, bricks
and bodies visible through it; dust billows; and each dimensional warp
mouth throws an additive pillar of violet light up from its floor ring.
Billboards learn aspect, translucency, glow, and draw-order bias to
carry it all.

Nine paintable starters ship — four floor decals under textures/, five
standing volumes under terrain3d/ (spec updated for the artist, hedges
wide, jello solid, warpglow as light) — shown in the token workshop,
rehearsable in place with /?fpv&terrain=thornbush@3,7;jello@4,7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 18:25:01 -04:00
co-authored by Claude Fable 5
parent a8a63cc27b
commit 1e79f2ffb2
16 changed files with 296 additions and 43 deletions
+87
View File
@@ -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/<name>.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<string, HTMLCanvasElement>();
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;
}