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>
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -8,6 +8,7 @@
|
|||||||
import FirewallEdge from "./FirewallEdge.svelte";
|
import FirewallEdge from "./FirewallEdge.svelte";
|
||||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||||
import { FX_ART } from "./fpv/fx3d";
|
import { FX_ART } from "./fpv/fx3d";
|
||||||
|
import { TERRAIN3D } from "./fpv/terrain3d";
|
||||||
|
|
||||||
const GROUPS: { title: string; files: string[] }[] = [
|
const GROUPS: { title: string; files: string[] }[] = [
|
||||||
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
|
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
wherever a file is missing.
|
wherever a file is missing.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#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)}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||||
<figcaption>{name}</figcaption>
|
<figcaption>{name}</figcaption>
|
||||||
@@ -120,6 +121,24 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>The standing terrain — first-person volumes</h2>
|
||||||
|
<p class="sub">
|
||||||
|
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
|
||||||
|
<code>public/terrain3d/<name>.png</code> — transparent PNG, any
|
||||||
|
size; hedges render twice as wide as tall. Rehearse them in place with
|
||||||
|
<code>/?fpv&terrain=thornbush@3,7;jello@4,7</code>.
|
||||||
|
</p>
|
||||||
|
<div class="grid">
|
||||||
|
{#each TERRAIN3D as name (name)}
|
||||||
|
<figure>
|
||||||
|
<img class="masonry conjuration" src={`/terrain3d/${name}.png`} alt={`${name} sprite`} />
|
||||||
|
<figcaption>{name}</figcaption>
|
||||||
|
</figure>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Walls & doors — as the maze draws them</h2>
|
<h2>Walls & doors — as the maze draws them</h2>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Wall-segment effects have never been tokens: locks picked, jammed, and
|
Wall-segment effects have never been tokens: locks picked, jammed, and
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { castRay, billboards } from "./raycast";
|
import { castRay, billboards } from "./raycast";
|
||||||
import { materialTextures } from "./textures";
|
import { materialTextures } from "./textures";
|
||||||
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
|
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
|
||||||
|
import { terrainFallback, TERRAIN3D } from "./terrain3d";
|
||||||
import { tokenArt } from "../art";
|
import { tokenArt } from "../art";
|
||||||
import { PLAYER_COLORS } from "../colors";
|
import { PLAYER_COLORS } from "../colors";
|
||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
@@ -88,29 +89,42 @@
|
|||||||
}
|
}
|
||||||
let frame: ImageData | null = null;
|
let frame: ImageData | null = null;
|
||||||
|
|
||||||
// Home bases wear their owner's emblem on the floor: a per-view grid of
|
// The floor wears decals: home emblems in their owners' colors, pits
|
||||||
// owner tints, indexed by integer cell for the per-pixel pass.
|
// opening downward, slime, tacks, dimensional warp rings — a per-view
|
||||||
const homeCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
|
// grid mapping each cell to one overlay, read by the per-pixel pass.
|
||||||
function homesOf(v: GameView) {
|
interface Decal { tex: string; tint: [number, number, number] | null }
|
||||||
let h = homeCache.get(v);
|
const decalCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; decals: Decal[] }>();
|
||||||
|
function decalsOf(v: GameView) {
|
||||||
|
let h = decalCache.get(v);
|
||||||
if (!h) {
|
if (!h) {
|
||||||
const bw = v.board.width, bh = v.board.height;
|
const bw = v.board.width, bh = v.board.height;
|
||||||
const grid = new Int16Array(bw * bh).fill(-1);
|
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) {
|
for (const p of v.players) {
|
||||||
if (!p.home) continue;
|
if (!p.home) continue;
|
||||||
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
|
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(1, 3), 16) / 255,
|
||||||
parseInt(hex.slice(3, 5), 16) / 255,
|
parseInt(hex.slice(3, 5), 16) / 255,
|
||||||
parseInt(hex.slice(5, 7), 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;
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
h = { grid, bw, bh, tints };
|
for (const w of v.dimWarps) {
|
||||||
homeCache.set(v, h);
|
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;
|
return h;
|
||||||
}
|
}
|
||||||
@@ -162,8 +176,7 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const d = (H / 2) / dz;
|
const d = (H / 2) / dz;
|
||||||
const homes = below ? homesOf(view) : null;
|
const dec = below ? decalsOf(view) : null;
|
||||||
const hp = below ? pixelsOf(textures.home!) : null;
|
|
||||||
const tex = below ? fl : ce;
|
const tex = below ? fl : ce;
|
||||||
const tw = tex.width, th = tex.height;
|
const tw = tex.width, th = tex.height;
|
||||||
const tp = tex.data;
|
const tp = tex.data;
|
||||||
@@ -184,17 +197,19 @@
|
|||||||
let v = wy % 1; if (v < 0) v += 1;
|
let v = wy % 1; if (v < 0) v += 1;
|
||||||
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
|
||||||
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
|
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;
|
const hx = (wx - u) | 0, hy = (wy - v) | 0;
|
||||||
if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) {
|
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
|
||||||
const owner = homes.grid[hy * homes.bw + hx]!;
|
const di = dec.grid[hy * dec.bw + hx]!;
|
||||||
if (owner >= 0 && hp) {
|
if (di >= 0) {
|
||||||
// The emblem sits over the flagstones, wearing its
|
// A decal lies over the flagstones, alpha-blended texel
|
||||||
// owner's color.
|
// 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 hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
|
||||||
const ha = hp.data[hi + 3]! / 255;
|
const ha = hp.data[hi + 3]! / 255;
|
||||||
if (ha > 0) {
|
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;
|
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
|
||||||
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
|
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
|
||||||
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
|
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
|
||||||
@@ -306,16 +321,21 @@
|
|||||||
rise: 0.3,
|
rise: 0.3,
|
||||||
warped: f.kind === "projectile" ? f.warped : undefined,
|
warped: f.kind === "projectile" ? f.warped : undefined,
|
||||||
}, ex, ey);
|
}, 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);
|
sprites.sort((a, b) => b.depth - a.depth);
|
||||||
for (const s of sprites) {
|
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 iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0);
|
||||||
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
|
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
|
||||||
// Conjurations are painted art, not light sources: normal
|
// Painted art composites normally (inks stay true); light glows
|
||||||
// compositing keeps their inks true; only the fade is borrowed.
|
// additively. Either way translucency applies.
|
||||||
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
|
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++) {
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||||
if (s.depth >= zbuf[col]!) continue;
|
if (s.depth >= zbuf[col]!) continue;
|
||||||
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
|
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
|
||||||
@@ -336,7 +356,8 @@
|
|||||||
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
|
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
|
// The ghosts of walls you know are lies: drawn over everything at
|
||||||
@@ -386,7 +407,9 @@
|
|||||||
fallback?: string;
|
fallback?: string;
|
||||||
}
|
}
|
||||||
function project(
|
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,
|
ex: number, ey: number,
|
||||||
): Projected | null {
|
): Projected | null {
|
||||||
const relX = b.x - ex, relY = b.y - ey;
|
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 screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
|
||||||
const wallH = H / depth;
|
const wallH = H / depth;
|
||||||
const size = wallH * b.scale;
|
const size = wallH * b.scale;
|
||||||
|
const wide = size * (b.aspect ?? 1);
|
||||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
const bottom = half + wallH / 2 - b.rise * wallH;
|
||||||
return {
|
return {
|
||||||
src: b.src, depth, warped: b.warped,
|
src: b.src, depth: depth - (b.bias ?? 0), warped: b.warped,
|
||||||
left: screenX - size / 2, right: screenX + size / 2,
|
alpha: b.alpha, glow: b.glow, fallback: b.fallback,
|
||||||
|
left: screenX - wide / 2, right: screenX + wide / 2,
|
||||||
top: bottom - size, bottom,
|
top: bottom - size, bottom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
view.knownIllusionEdges.push(k);
|
view.knownIllusionEdges.push(k);
|
||||||
delete view.board.edges[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;
|
const start = view.players.find((p) => p.id === povId)!.position;
|
||||||
|
|
||||||
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
||||||
|
|||||||
@@ -227,6 +227,17 @@ export interface Billboard {
|
|||||||
/** Lifted off the floor (0 = feet on the ground). */
|
/** Lifted off the floor (0 = feet on the ground). */
|
||||||
rise: number;
|
rise: number;
|
||||||
label: string;
|
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. */
|
/** A reflection seen through a warp mouth, not the thing itself. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
}
|
}
|
||||||
@@ -298,19 +309,42 @@ export function billboards(
|
|||||||
scale: 0.4, rise: 0, label: "treasure",
|
scale: 0.4, rise: 0, label: "treasure",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
|
// The floor's furniture, standing in the room. Bushes fill the square
|
||||||
// a wall to the rays and needs no sprite.
|
// to half height (a wizard peeks over); the killer ooze is a whole
|
||||||
const TERRAIN_SCALE: Record<string, number> = {
|
// translucent cube of jello; dust billows. Pits, slime, tacks, and
|
||||||
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
|
// dimensional warps are painted INTO the floor by the decal pass, and
|
||||||
tacks: 0.25, pit: 0.35, dust: 0.6,
|
// stone is a wall to the rays — none of those need a sprite.
|
||||||
};
|
|
||||||
for (const [k, content] of Object.entries(view.squareContents)) {
|
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];
|
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)) {
|
for (const [k, cards] of Object.entries(view.groundObjects)) {
|
||||||
const file = cards[0] ? objectArt(cards[0].cardId) : null;
|
const file = cards[0] ? objectArt(cards[0].cardId) : null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
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. */
|
/** The built-in bake: what ships when no painted file overrides it. */
|
||||||
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||||
@@ -127,6 +127,67 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
|||||||
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
|
||||||
c.fill();
|
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) => {
|
warp: bake((c) => {
|
||||||
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
const grad = c.createLinearGradient(0, 0, TEX, TEX);
|
||||||
grad.addColorStop(0, "#2c1a4e");
|
grad.addColorStop(0, "#2c1a4e");
|
||||||
|
|||||||
@@ -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
|
separate art), and the jammed-lock seethe (a pulsing rust glow the
|
||||||
renderer lays over the door) — those are renderer effects, not images.
|
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
|
## One more paintable surface: the home tile
|
||||||
|
|
||||||
`public/textures/home.png` is a floor overlay for home-base cells,
|
`public/textures/home.png` is a floor overlay for home-base cells,
|
||||||
|
|||||||