diff --git a/packages/web/public/fx3d/rubble.png b/packages/web/public/fx3d/rubble.png
new file mode 100644
index 0000000..fd435e1
Binary files /dev/null and b/packages/web/public/fx3d/rubble.png differ
diff --git a/packages/web/public/textures/home.png b/packages/web/public/textures/home.png
new file mode 100644
index 0000000..e74899e
Binary files /dev/null and b/packages/web/public/textures/home.png differ
diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte
index 46b93bd..4b6ffbf 100644
--- a/packages/web/src/Replay.svelte
+++ b/packages/web/src/Replay.svelte
@@ -5,7 +5,7 @@
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
- import { castRay } from "./fpv/raycast";
+ import { castRay, edgeMid } from "./fpv/raycast";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -51,6 +51,24 @@
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
+ /** Every wall destroyed so far in the reel leaves a mound of rubble on
+ * the first-person floor for the rest of it. */
+ const rubbleSpots = $derived.by(() => {
+ const spots: { x: number; y: number }[] = [];
+ const seen = new Set();
+ for (let i = 0; i <= Math.min(idx, steps.length - 1); i++) {
+ for (const e of steps[i]!.events) {
+ if (e.type !== "wallDestroyed" || !("edge" in e)) continue;
+ const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
+ const key = `${edge.cell.x},${edge.cell.y}:${edge.side}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ spots.push(edgeMid(edge.cell, edge.side));
+ }
+ }
+ return spots;
+ });
+
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state([]);
$effect(() => {
@@ -320,7 +338,7 @@
{#if fp}
+ fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}
{/if}
diff --git a/packages/web/src/TokenGallery.svelte b/packages/web/src/TokenGallery.svelte
index 5d7f45c..8b42fc5 100644
--- a/packages/web/src/TokenGallery.svelte
+++ b/packages/web/src/TokenGallery.svelte
@@ -95,7 +95,7 @@
wherever a file is missing.
- {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling"] as name (name)}
+ {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte
index 933ef36..e3a1fb3 100644
--- a/packages/web/src/fpv/FirstPerson.svelte
+++ b/packages/web/src/fpv/FirstPerson.svelte
@@ -5,8 +5,9 @@
// by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast";
import { materialTextures } from "./textures";
- import { fxFallback, type FpFx } from "./fx3d";
+ import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
import { tokenArt } from "../art";
+ import { PLAYER_COLORS } from "../colors";
import type { GameView } from "@wizwar/engine";
let {
@@ -19,6 +20,7 @@
height = 440,
fx = [],
posOverride,
+ rubble = [],
}: {
view: GameView;
povId: string;
@@ -33,6 +35,8 @@
fx?: FpFx[];
/** Bodies mid-glide: world positions that override the view's cells. */
posOverride?: Record;
+ /** Where walls have died: a mound of stone marks each fallen edge. */
+ rubble?: { x: number; y: number }[];
} = $props();
const FOV = Math.PI / 2.9;
@@ -84,6 +88,33 @@
}
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);
+ if (!h) {
+ const bw = v.board.width, bh = v.board.height;
+ const grid = new Int16Array(bw * bh).fill(-1);
+ const tints: [number, number, number][] = [];
+ for (const p of v.players) {
+ if (!p.home) continue;
+ const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
+ tints.push([
+ 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);
+ }
+ return h;
+ }
+
function draw(time: number) {
const ctx = canvas?.getContext("2d");
if (!ctx) return;
@@ -100,6 +131,14 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
}
+ // Doors mid-swing this frame, from the animated fx.
+ let doors: Record | undefined;
+ for (const f of fx) {
+ if (f.kind !== "door") continue;
+ const a = doorOpenness((time - f.t0) / f.dur);
+ if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
+ }
+
// The ground and the vault overhead, cast per pixel: each screen row
// below (or above) the horizon lies at one fixed depth, so the row is
// walked with a constant world-space step and sampled from the floor
@@ -123,6 +162,8 @@
continue;
}
const d = (H / 2) / dz;
+ const homes = below ? homesOf(view) : null;
+ const hp = below ? pixelsOf(textures.home!) : null;
const tex = below ? fl : ce;
const tw = tex.width, th = tex.height;
const tp = tex.data;
@@ -142,9 +183,28 @@
let u = wx % 1; if (u < 0) u += 1;
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
- buf[o] = tp[ti]! * shade;
- buf[o + 1] = tp[ti + 1]! * shade;
- buf[o + 2] = tp[ti + 2]! * shade;
+ let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
+ if (homes) {
+ 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.
+ 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]!;
+ 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;
+ }
+ }
+ }
+ }
+ buf[o] = r * shade;
+ buf[o + 1] = g * shade;
+ buf[o + 2] = b * shade;
}
buf[o + 3] = 255;
wx += stepX;
@@ -153,13 +213,18 @@
}
ctx.putImageData(frame, 0, 0);
- // Walls, one ray per column; remember each column's depth for sprites.
+ // Walls, one ray per column; remember each column's depth for
+ // sprites, and whether its ray bent through a warp — a sprite's warp
+ // side must MATCH its column's, or bodies near a far mouth would
+ // ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
+ const warpedCol = new Uint8Array(W);
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
- const hit = castRay(view, ex, ey, rayAngle);
+ const hit = castRay(view, ex, ey, rayAngle, doors);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
+ warpedCol[col] = hit.warped ? 1 : 0;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!;
@@ -185,6 +250,12 @@
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH);
}
+ if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
+ // A jammed lock seethes: a rusty seal pulsing across the wood.
+ const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22);
+ ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
+ ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
+ }
if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(col, top, 1, wallH);
@@ -201,6 +272,10 @@
const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null);
+ for (const spot of rubble) {
+ const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.4, rise: 0 }, ex, ey);
+ if (s) sprites.push({ ...s, fallback: "rubble" });
+ }
for (const f of fx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur;
@@ -212,6 +287,7 @@
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
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 });
}
@@ -225,6 +301,7 @@
if (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;
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(
diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts
index 04d7201..c536226 100644
--- a/packages/web/src/fpv/fx3d.ts
+++ b/packages/web/src/fpv/fx3d.ts
@@ -6,21 +6,35 @@
import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom";
-import { castRay, warpMotion } from "./raycast";
+import { castRay, edgeMid, warpMotion } from "./raycast";
+import { edgeKey } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx =
- | { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number }
+ | { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
+ /** This leg flies in a warp mouth's virtual space: visible only
+ * through the opening, on columns whose rays bent. */
+ warped?: boolean }
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
- | { id: number; kind: "shake"; mag: number; t0: number; dur: number };
+ | { id: number; kind: "shake"; mag: number; t0: number; dur: number }
+ | { id: number; kind: "door"; edge: string; t0: number; dur: number };
+
+/** How far open an animated door stands at progress p: swings open,
+ * holds for the crossing, swings shut. */
+export function doorOpenness(p: number): number {
+ if (p < 0 || p >= 1) return 0;
+ if (p < 0.3) return p / 0.3;
+ if (p < 0.7) return 1;
+ return (1 - p) / 0.3;
+}
/** The paintable conjuration sprites: public/fx3d/.png, square with
* transparent ground, drawn as a billboard mid-air. A procedural glow
* stands in wherever a file is missing. */
export const FX_ART = [
"fireball", "bolt", "waterbolt", "spark",
- "burst", "splash", "hit", "shimmer", "shield",
+ "burst", "splash", "hit", "shimmer", "shield", "rubble",
] as const;
let nextId = 1;
@@ -42,7 +56,7 @@ const IMPACT_ART: Record = {
* clipped by the depth buffer to the side the camera stands on. */
function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
-): { from: { x: number; y: number }; to: { x: number; y: number } }[] {
+): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }];
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
@@ -54,7 +68,7 @@ function projectileLegs(
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
if (hit.warped && hit.dist >= vlen - 0.4) {
return [
- { from: a, to: vt },
+ { from: a, to: vt, warped: true },
{ from: motion.toReal(a), to: b },
];
}
@@ -76,7 +90,7 @@ export function fpFxForEvents(
out.push({
fx: {
id: nextId++, kind: "projectile", art,
- from: leg.from, to: leg.to,
+ from: leg.from, to: leg.to, warped: leg.warped,
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
@@ -94,6 +108,30 @@ export function fpFxForEvents(
});
}
}
+ // The maze itself performs: doors swing for whoever steps through,
+ // walls die into rubble, and walking THROUGH stone shimmers.
+ for (const e of events) {
+ if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) {
+ const via = e.type === "moved" ? e.via : "step";
+ const key = edgeKey(e.from, e.direction);
+ if (via === "step" && view.board.edges[key] === "door") {
+ out.push({ fx: { id: nextId++, kind: "door", edge: key, t0: 0, dur: 1100 }, delay: 0 });
+ }
+ if (e.type === "moved" && e.via === "passWall") {
+ out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.from.x + 0.5, y: e.from.y + 0.5 }, t0: 0, dur: 500 }, delay: 0 });
+ out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: e.to.x + 0.5, y: e.to.y + 0.5 }, t0: 0, dur: 500 }, delay: 200 });
+ if (e.player === povId) {
+ out.push({ fx: { id: nextId++, kind: "flash", color: "#9a8fb0", peak: 0.35, t0: 0, dur: 450 }, delay: 0 });
+ }
+ }
+ }
+ if (e.type === "wallDestroyed" && "edge" in e) {
+ const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
+ (e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
+ out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 700 }, delay: 0 });
+ out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
+ }
+ }
// Blows that land on the point of view: give impacts a beat to arrive
// when something visibly flew first.
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
@@ -129,6 +167,7 @@ const FX_COLORS: Record = {
hit: ["#ffd0c0", "#c02020"],
shimmer: ["#f0e0ff", "#7040c0"],
shield: ["#ffffff", "#4060d0"],
+ rubble: ["#b0a898", "#5c544a"],
};
const baked = new Map();
export function fxFallback(name: string): HTMLCanvasElement {
@@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement {
t.height = 64;
const c = t.getContext("2d")!;
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
- const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
- g.addColorStop(0, core);
- g.addColorStop(0.55, rim);
- g.addColorStop(1, "rgba(0,0,0,0)");
- c.fillStyle = g;
- c.fillRect(0, 0, 64, 64);
+ if (name === "rubble") {
+ // A mound of broken stone, opaque — this one is debris, not light.
+ for (let i = 0; i < 9; i++) {
+ const jx = 12 + ((i * 37) % 40);
+ const jy = 40 + ((i * 23) % 18);
+ const r = 6 + ((i * 13) % 8);
+ c.fillStyle = i % 2 ? core : rim;
+ c.beginPath();
+ c.arc(jx, jy, r, 0, Math.PI * 2);
+ c.fill();
+ }
+ } else {
+ const g = c.createRadialGradient(32, 32, 2, 32, 32, 30);
+ g.addColorStop(0, core);
+ g.addColorStop(0.55, rim);
+ g.addColorStop(1, "rgba(0,0,0,0)");
+ c.fillStyle = g;
+ c.fillRect(0, 0, 64, 64);
+ }
baked.set(name, t);
}
return t;
diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts
index 6164371..09c9048 100644
--- a/packages/web/src/fpv/raycast.ts
+++ b/packages/web/src/fpv/raycast.ts
@@ -21,6 +21,8 @@ export interface Hit {
/** The un-wrapped along-face coordinate: u plus the world cell it lies
* in, so multi-cell surfaces (a long wall of fire) can read as one. */
worldU: number;
+ /** The edge struck, for state the renderer dresses (jammed locks). */
+ edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
}
@@ -28,15 +30,13 @@ export interface Hit {
export const SIDE_ANGLE: Record = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export const OPPOSITE: Record = { E: "W", W: "E", N: "S", S: "N" };
-/** Is this edge passable to the EYE (rays), and if not, what is it? */
+/** Is this edge passable to the EYE (rays), and if not, what is it?
+ * Doors always report as doors; how far each stands open — fully for an
+ * open or held door, mid-swing during an animation — is castRay's call. */
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open";
if (e === "open") return null;
- if (e === "door") {
- // An open or held door is a doorway; the eye passes through the gap.
- if (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key)) return null;
- return "door";
- }
+ if (e === "door") return "door";
if (e === "firewall") return "firewall";
return "wall";
}
@@ -69,7 +69,13 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
-export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
+export function castRay(
+ view: GameView, ox: number, oy: number, angle: number,
+ /** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
+ * view holds open stand at 1 without an entry. Wolf3D-style: an open
+ * door has slid along its own edge, the gap growing from u=0. */
+ doors?: Record,
+): Hit {
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0;
@@ -90,12 +96,25 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
// Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY);
- let best: { t: number; axis: "x" | "y"; kind: Hit["kind"] } | null = null;
+ let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
- const kind = edgeObstacle(view, edgeKey({ x: cx, y: cy }, side));
+ const key = edgeKey({ x: cx, y: cy }, side);
+ const kind = edgeObstacle(view, key);
if (!kind) return;
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
- if (h && h.t <= exit + 0.15 && (!best || h.t < best.t)) best = { ...h, kind };
+ if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
+ let slide = 0;
+ if (kind === "door") {
+ const open = doors?.[key] ??
+ (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
+ if (open > 0) {
+ const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
+ const u = along - Math.floor(along);
+ if (u < open - 0.02) return; // the ray sails through the opening
+ slide = open;
+ }
+ }
+ best = { ...h, kind, slide, edge: key };
};
const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
@@ -104,10 +123,14 @@ export function castRay(view: GameView, ox: number, oy: number, angle: number):
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) {
- const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"] };
+ const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
- return { dist: baseDist + b.t, kind: b.kind, u, axis: b.axis, worldU: along, warped };
+ // A sliding door carries its texture with it: sample past the gap.
+ return {
+ dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
+ axis: b.axis, worldU: along, edge: b.edge, warped,
+ };
}
// No slab in this cell: advance through the open boundary.
diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts
index f47533c..882faee 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"] as const;
+export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
export function proceduralTextures(): Record {
@@ -107,6 +107,26 @@ export function proceduralTextures(): Record {
}
}
}),
+ 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();
+ }),
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 89247f4..eb5b1bc 100644
--- a/research/fx3d-sprite-spec.md
+++ b/research/fx3d-sprite-spec.md
@@ -39,11 +39,22 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
| `shield.png` | impact | an attack fully stopped; absorbed spells |
+| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
Projectiles hold their size in flight (about a third of a wizard's
height). Impacts start small and swell to roughly double while fading —
so an impact painted as a ring or burst reads especially well.
What the sprites do NOT need to carry: screen-wide flashes when YOU are
-hit (red), healed (green), or teleported (violet), and the camera shake —
-those are renderer effects, not images.
+hit (red), healed (green), or teleported (violet), the camera shake, the
+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.
+
+## One more paintable surface: the home tile
+
+`public/textures/home.png` is a floor overlay for home-base cells,
+drawn over the flagstones and TINTED by the owning wizard's color at
+draw time. Paint it near-white/neutral with transparency wherever the
+plain floor should show through; the tint does the rest. Square, any
+size, same as the other textures.