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:
co-authored by
Claude Fable 5
parent
a8a63cc27b
commit
1e79f2ffb2
@@ -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<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
|
||||
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<object, { grid: Int16Array; bw: number; bh: number; decals: Decal[] }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user