The maze performs: doors swing, walls die loudly, homes wear their colors

Entertainment polish for the instant replay, all through one wizard's
eyes. Doors now slide open along their own edge — Wolf3D style — when
anyone steps through in a reel, hold for the crossing, and swing shut;
the raycaster takes a per-edge openness map and doors carry their
texture with them, leaving a sliver of jamb at the far post. A wall
that dies bursts into stone and shakes the camera, then leaves a mound
of rubble at the fallen edge for the rest of the reel (a tenth
paintable sprite, drawn opaque — debris, not light). Jammed locks
seethe: a rusty pulse across the wood, keyed off the view's own door
states. Walking THROUGH a wall shimmers on both faces and washes the
walker's own screen stone-gray. And every home base wears its owner's
emblem on the flagstones — a near-white paintable overlay tinted by
wizard color per cell in the floor cast.

Testing surfaced a real ghost: a body standing near a far warp mouth
also rendered at its virtual position, which can overlap real
corridors — the depth buffer alone cannot clip it. Every column now
remembers whether its ray bent, and a sprite draws only where its warp
side matches the column's; projectile legs carry the same flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 16:30:35 -04:00
co-authored by Claude Fable 5
parent 8a27c06b55
commit cdfae39398
9 changed files with 238 additions and 37 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+20 -2
View File
@@ -5,7 +5,7 @@
import { humanize } from "./net.svelte"; import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx"; import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { prefs } from "./prefs.svelte"; import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine"; import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } 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. // attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view)); 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<string>();
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. */ /** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]); let boardFx = $state<BoardFx[]>([]);
$effect(() => { $effect(() => {
@@ -320,7 +338,7 @@
{#if fp} {#if fp}
<FirstPerson view={step.view} {povId} <FirstPerson view={step.view} {povId}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360} x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} /> fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else} {:else}
<Board view={step.view} effects={boardFx} {sightTrace} /> <Board view={step.view} effects={boardFx} {sightTrace} />
{/if} {/if}
+1 -1
View File
@@ -95,7 +95,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"] as name (name)} {#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] 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>
+83 -6
View File
@@ -5,8 +5,9 @@
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast"; import { castRay, billboards } from "./raycast";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
import { fxFallback, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
import { tokenArt } from "../art"; import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors";
import type { GameView } from "@wizwar/engine"; import type { GameView } from "@wizwar/engine";
let { let {
@@ -19,6 +20,7 @@
height = 440, height = 440,
fx = [], fx = [],
posOverride, posOverride,
rubble = [],
}: { }: {
view: GameView; view: GameView;
povId: string; povId: string;
@@ -33,6 +35,8 @@
fx?: FpFx[]; fx?: FpFx[];
/** Bodies mid-glide: world positions that override the view's cells. */ /** Bodies mid-glide: world positions that override the view's cells. */
posOverride?: Record<string, { x: number; y: number }>; posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[];
} = $props(); } = $props();
const FOV = Math.PI / 2.9; const FOV = Math.PI / 2.9;
@@ -84,6 +88,33 @@
} }
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
// 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);
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) { function draw(time: number) {
const ctx = canvas?.getContext("2d"); const ctx = canvas?.getContext("2d");
if (!ctx) return; if (!ctx) return;
@@ -100,6 +131,14 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087); ey += f.mag * (1 - p) * Math.cos(time * 0.087);
} }
// Doors mid-swing this frame, from the animated fx.
let doors: Record<string, number> | 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 // 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 // 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 // walked with a constant world-space step and sampled from the floor
@@ -123,6 +162,8 @@
continue; continue;
} }
const d = (H / 2) / dz; const d = (H / 2) / dz;
const homes = below ? homesOf(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;
@@ -142,9 +183,28 @@
let u = wx % 1; if (u < 0) u += 1; let u = wx % 1; if (u < 0) u += 1;
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;
buf[o] = tp[ti]! * shade; let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
buf[o + 1] = tp[ti + 1]! * shade; if (homes) {
buf[o + 2] = tp[ti + 2]! * shade; 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; buf[o + 3] = 255;
wx += stepX; wx += stepX;
@@ -153,13 +213,18 @@
} }
ctx.putImageData(frame, 0, 0); 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 zbuf = new Float64Array(W);
const warpedCol = new Uint8Array(W);
for (let col = 0; col < W; col++) { for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2)); 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 const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth; zbuf[col] = depth;
warpedCol[col] = hit.warped ? 1 : 0;
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2; const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!; const tex = textures[hit.kind] ?? textures.wall!;
@@ -185,6 +250,12 @@
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`; ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH); 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) { if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`; ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
@@ -201,6 +272,10 @@
const sprites = billboards(view, povId, tokenArt, posOverride) const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey)) .map((b) => project(b, ex, ey))
.filter((s): s is Projected => s !== null); .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) { for (const f of fx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue; if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
@@ -212,6 +287,7 @@
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`, x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p, scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: 0.3, rise: 0.3,
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, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
} }
@@ -225,6 +301,7 @@
if (s.glow) ctx.globalAlpha = s.alpha ?? 1; if (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;
const texX = ((col - s.left) / (s.right - s.left)); const texX = ((col - s.left) / (s.right - s.left));
if (img) { if (img) {
ctx.drawImage( ctx.drawImage(
+65 -13
View File
@@ -6,21 +6,35 @@
import { fxForEvents } from "../fx"; import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom"; 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"; import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx = 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: "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: "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/<name>.png, square with /** The paintable conjuration sprites: public/fx3d/<name>.png, square with
* transparent ground, drawn as a billboard mid-air. A procedural glow * transparent ground, drawn as a billboard mid-air. A procedural glow
* stands in wherever a file is missing. */ * stands in wherever a file is missing. */
export const FX_ART = [ export const FX_ART = [
"fireball", "bolt", "waterbolt", "spark", "fireball", "bolt", "waterbolt", "spark",
"burst", "splash", "hit", "shimmer", "shield", "burst", "splash", "hit", "shimmer", "shield", "rubble",
] as const; ] as const;
let nextId = 1; let nextId = 1;
@@ -42,7 +56,7 @@ const IMPACT_ART: Record<string, string> = {
* clipped by the depth buffer to the side the camera stands on. */ * clipped by the depth buffer to the side the camera stands on. */
function projectileLegs( function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number }, 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); const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }]; 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)); 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)); 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) { if (hit.warped && hit.dist >= vlen - 0.4) {
return [ return [
{ from: a, to: vt }, { from: a, to: vt, warped: true },
{ from: motion.toReal(a), to: b }, { from: motion.toReal(a), to: b },
]; ];
} }
@@ -76,7 +90,7 @@ export function fpFxForEvents(
out.push({ out.push({
fx: { fx: {
id: nextId++, kind: "projectile", art, 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]!, t0: 0, dur: PROJECTILE_DUR[art]!,
}, },
delay, 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 // Blows that land on the point of view: give impacts a beat to arrive
// when something visibly flew first. // when something visibly flew first.
const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0; const povDelay = out.some((o) => o.fx.kind === "projectile") ? 380 : 0;
@@ -129,6 +167,7 @@ const FX_COLORS: Record<string, [string, string]> = {
hit: ["#ffd0c0", "#c02020"], hit: ["#ffd0c0", "#c02020"],
shimmer: ["#f0e0ff", "#7040c0"], shimmer: ["#f0e0ff", "#7040c0"],
shield: ["#ffffff", "#4060d0"], shield: ["#ffffff", "#4060d0"],
rubble: ["#b0a898", "#5c544a"],
}; };
const baked = new Map<string, HTMLCanvasElement>(); const baked = new Map<string, HTMLCanvasElement>();
export function fxFallback(name: string): HTMLCanvasElement { export function fxFallback(name: string): HTMLCanvasElement {
@@ -139,12 +178,25 @@ export function fxFallback(name: string): HTMLCanvasElement {
t.height = 64; t.height = 64;
const c = t.getContext("2d")!; const c = t.getContext("2d")!;
const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!; const [core, rim] = FX_COLORS[name] ?? FX_COLORS.spark!;
const g = c.createRadialGradient(32, 32, 2, 32, 32, 30); if (name === "rubble") {
g.addColorStop(0, core); // A mound of broken stone, opaque — this one is debris, not light.
g.addColorStop(0.55, rim); for (let i = 0; i < 9; i++) {
g.addColorStop(1, "rgba(0,0,0,0)"); const jx = 12 + ((i * 37) % 40);
c.fillStyle = g; const jy = 40 + ((i * 23) % 18);
c.fillRect(0, 0, 64, 64); 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); baked.set(name, t);
} }
return t; return t;
+35 -12
View File
@@ -21,6 +21,8 @@ export interface Hit {
/** The un-wrapped along-face coordinate: u plus the world cell it lies /** 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. */ * in, so multi-cell surfaces (a long wall of fire) can read as one. */
worldU: number; 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. */ /** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean; warped?: boolean;
} }
@@ -28,15 +30,13 @@ export interface Hit {
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 }; export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" }; export const OPPOSITE: Record<Side, Side> = { 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 { function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
const e = view.board.edges[key] ?? "open"; const e = view.board.edges[key] ?? "open";
if (e === "open") return null; if (e === "open") return null;
if (e === "door") { if (e === "door") return "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 === "firewall") return "firewall"; if (e === "firewall") return "firewall";
return "wall"; return "wall";
} }
@@ -69,7 +69,13 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" }; 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<string, number>,
): Hit {
// Sight runs THROUGH warps as the rules say it does: a ray that reaches // 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. // a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0; 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 // Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins. // nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY); 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 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; if (!kind) return;
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY); 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) => const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07; 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("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N")); trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) { 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 along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along); 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. // No slab in this cell: advance through the open boundary.
+21 -1
View File
@@ -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"] 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. */ /** The built-in bake: what ships when no painted file overrides it. */
export function proceduralTextures(): Record<string, HTMLCanvasElement> { export function proceduralTextures(): Record<string, HTMLCanvasElement> {
@@ -107,6 +107,26 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
} }
} }
}), }),
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) => { 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");
+13 -2
View File
@@ -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 | | `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 | | `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 | | `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 Projectiles hold their size in flight (about a third of a wizard's
height). Impacts start small and swell to roughly double while fading — height). Impacts start small and swell to roughly double while fading —
so an impact painted as a ring or burst reads especially well. 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 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 hit (red), healed (green), or teleported (violet), the camera shake, the
those are renderer effects, not images. 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.