Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks

The audit Eric called for, delivered whole. Open doors are DOORWAYS
now: stone jamb posts at both ends and a lintel hung across the top
(paintable as textures/doorframe.png), the way through open to the eye.
Battle damage shows — the engine's per-edge wallDamage wears painted
cracks into any wall, heavier as harm mounts. The wall safe stands as
a strongbox volume instead of a flat token. Hedges take root: an
underbrush floor decal covers the whole square beneath them (Eric's
report — a billboard at center depth can't reach the floor trapezoid's
near edge), and the hedge itself widens past the cell. Rubble
stretches across the fallen wall's full gap, corner piles landing at
the posts, exactly as its art was painted.

The first-person effect gaps close: victory now throws SIX fireworks
climbing over the winner's home with gold washing the screen; sector
rotations flash violet and heave the whole world; the Thumb of God
lands with a burst and a shudder; walls conjured from nothing rise in
stone dust; and every hazard pratfall plays — pit falls (with a dark
drop and jolt for the faller's own eyes), ooze slips, tack yelps,
thorn snaps, slime, dust. Dying in first person fades long and dark.

And the whole tale travels: a finished game's full replay carries the
share button too — turn -1 mints a link to every turn of the game,
each through its wizard's own eyes, titled for the winner's triumph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 19:32:42 -04:00
co-authored by Claude Fable 5
parent a94ab5c6f5
commit 5fc5805354
17 changed files with 269 additions and 39 deletions
+27 -6
View File
@@ -108,6 +108,8 @@ interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[]; steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string; actor: string;
round: number; round: number;
/** A whole finished game rather than one turn. */
whole?: boolean;
} }
const shareCache = new Map<string, { at: number; data: ShareData | null }>(); const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null { function shareData(id: string): ShareData | null {
@@ -117,11 +119,23 @@ function shareData(id: string): ShareData | null {
const share = getShare(id); const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined; const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) { if (share && room?.state) {
if (share.turn < 0) {
// The whole tale: a finished game from the deal to the crown.
const steps = catchUpSteps(room, SPECTATOR, 0, true);
if (!("error" in steps) && steps.length > 0) {
let winner = "";
for (const st of steps) {
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
}
data = { steps: steps as unknown as ShareData["steps"], actor: winner, round: 0, whole: true };
}
} else {
const reel = momentSteps(room, SPECTATOR, share.turn); const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) { if (!("error" in reel) && reel.steps.length > 0) {
data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round }; data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
} }
} }
}
if (shareCache.size > 50) shareCache.clear(); if (shareCache.size > 50) shareCache.clear();
shareCache.set(id, { at: Date.now(), data }); shareCache.set(id, { at: Date.now(), data });
return data; return data;
@@ -143,8 +157,12 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "") .replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, ""); .replace(/<title>[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`; const base = `${proto}://${host}`;
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; const title = data.whole
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` + ? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`; `Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [ const metas = [
`<title>${title}</title>`, `<title>${title}</title>`,
@@ -193,7 +211,7 @@ const httpServer = createServer((req, res) => {
"cache-control": "public, max-age=60", "cache-control": "public, max-age=60",
"access-control-allow-origin": "*", "access-control-allow-origin": "*",
}); });
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round })); res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
return; return;
} }
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/); const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
@@ -607,14 +625,17 @@ wss.on("connection", (socket) => {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn); const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" }); if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now(); const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" }); return send(socket, { type: "error", message: "one moment" });
} }
session.lastCatchUpAt = now; session.lastCatchUpAt = now;
const reel = momentSteps(room, session.playerId, turn); // turn -1 shares the whole finished game; anything else, one turn.
if ("error" in reel) return send(socket, { type: "error", message: reel.error }); const check = turn === -1
? catchUpSteps(room, session.playerId, 0, true)
: momentSteps(room, session.playerId, turn);
if ("error" in check) return send(socket, { type: "error", message: check.error });
const share = mintShare(room.id, turn); const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn }); send(socket, { type: "share", id: share.id, turn });
break; break;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+3 -1
View File
@@ -1623,7 +1623,9 @@
<Replay steps={net.moment.steps} moment pov={net.moment.owner} <Replay steps={net.moment.steps} moment pov={net.moment.owner}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} /> onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0} {:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} /> <Replay steps={net.catchUp}
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0} {:else if local.replaySteps && local.replaySteps.length > 0}
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} /> <Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
{/if} {/if}
+1 -1
View File
@@ -462,7 +462,7 @@
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}> <button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button> {recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if} {/if}
{#if moment && onshare} {#if onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}> <button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link" {shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…" : shareState === "minting" ? "…"
+4 -1
View File
@@ -14,6 +14,7 @@
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[]; steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
actor: string; actor: string;
round: number; round: number;
whole?: boolean;
} }
let data = $state<ShareSteps | null>(null); let data = $state<ShareSteps | null>(null);
let failed = $state(false); let failed = $state(false);
@@ -30,7 +31,9 @@
<header class="share-head"> <header class="share-head">
<a class="share-brand" href="/">WIZ-WAR</a> <a class="share-brand" href="/">WIZ-WAR</a>
{#if data} {#if data}
<div class="share-title">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</div> <div class="share-title">
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
</div>
{/if} {/if}
</header> </header>
+1 -1
View File
@@ -96,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", "pit", "slime", "tacks", "dimwarp"] as name (name)} {#each ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] 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>
+40 -5
View File
@@ -114,10 +114,15 @@
parseInt(hex.slice(5, 7), 16) / 255, parseInt(hex.slice(5, 7), 16) / 255,
]); ]);
} }
const DECAL_KIND: Record<string, string> = {
pit: "pit", slime: "slime", tacks: "tacks",
thornbush: "underbrush", rosebush: "underbrush",
};
for (const [k, content] of Object.entries(v.squareContents)) { for (const [k, content] of Object.entries(v.squareContents)) {
if (content.kind !== "pit" && content.kind !== "slime" && content.kind !== "tacks") continue; const tex = DECAL_KIND[content.kind];
if (!tex) continue;
const [cx, cy] = k.split(",").map(Number) as [number, number]; const [cx, cy] = k.split(",").map(Number) as [number, number];
put(cx, cy, content.kind); put(cx, cy, tex);
} }
for (const w of v.dimWarps) { for (const w of v.dimWarps) {
put(w.a.x, w.a.y, "dimwarp"); put(w.a.x, w.a.y, "dimwarp");
@@ -238,6 +243,8 @@
// stands at the crossing — drawn after the sprites so bodies show // stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone. // through it, shimmering so nobody mistakes it for stone.
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = []; const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
// Open doorways collect their lintels, hung after the sprites.
const lintels: { col: number; depth: number; u: number }[] = [];
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, doors); const hit = castRay(view, ex, ey, rayAngle, doors);
@@ -250,9 +257,12 @@
u: hit.ghost.u, worldU: hit.ghost.worldU, u: hit.ghost.u, worldU: hit.ghost.worldU,
}); });
} }
if (hit.doorway) {
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
}
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 = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale. // Sample by the texture's own size: painted files may be any scale.
// Fire and warps shift their slice per world cell, so a blaze // Fire and warps shift their slice per world cell, so a blaze
// spanning edges reads as one long fire, not a repeated flame. // spanning edges reads as one long fire, not a repeated flame.
@@ -261,6 +271,17 @@
: hit.u; : hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0, ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH); Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// Harm shows: accumulated damage wears fractures into the face.
const dmg = view.wallDamage[hit.edge];
if (dmg) {
const ck = textures.cracks!;
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
ctx.globalAlpha = 1;
}
}
// Distance and orientation carve the light; overlays animate it. // Distance and orientation carve the light; overlays animate it.
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
@@ -305,7 +326,7 @@
.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) { for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.55, rise: 0 }, ex, ey); const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" }); if (s) sprites.push({ ...s, fallback: "rubble" });
} }
for (const f of fx) { for (const f of fx) {
@@ -318,7 +339,7 @@
const s = project({ const s = project({
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: f.kind === "impact" ? (f.rise ?? 0.3) : 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, 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 });
@@ -360,6 +381,20 @@
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1; if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
} }
// Doorway lintels: the frame's top rows hung across each opening.
for (const li of lintels) {
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
const lTop = half - lh / 2;
const ft = textures.doorframe!;
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width), 0,
Math.max(1, ft.width / 96), ft.height * 0.16, li.col, lTop, 1, lh * 0.16);
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(li.col, lTop, 1, lh * 0.16);
}
}
// The ghosts of walls you know are lies: drawn over everything at // The ghosts of walls you know are lies: drawn over everything at
// their columns, thin as breath, rippling. // their columns, thin as breath, rippling.
for (const gh of ghosts) { for (const gh of ghosts) {
+8
View File
@@ -27,6 +27,14 @@
view.knownIllusionEdges.push(k); view.knownIllusionEdges.push(k);
delete view.board.edges[k]; delete view.board.edges[k];
} }
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
view.openDoorEdges.push(k);
}
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
const [k, n] = spec.split("@") as [string, string?];
view.wallDamage[k] = Number(n ?? 1) || 1;
}
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer // ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth. // (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) { for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
+55 -3
View File
@@ -15,7 +15,9 @@ export type FpFx =
/** This leg flies in a warp mouth's virtual space: visible only /** This leg flies in a warp mouth's virtual space: visible only
* through the opening, on columns whose rays bent. */ * through the opening, on columns whose rays bent. */
warped?: boolean } 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;
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
rise?: 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 }; | { id: number; kind: "door"; edge: string; t0: number; dur: number };
@@ -42,11 +44,15 @@ let nextId = 1;
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 }; const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
/** Board-effect kinds that land here as an impact billboard. */ /** Board-effect kinds that land here as an impact billboard. */
const IMPACT_ART: Record<string, string> = { const IMPACT_ART: Record<string, string> = {
burst: "burst", splash: "splash", fireworks: "burst", burst: "burst", splash: "splash",
hit: "hit", pow: "hit", claw: "hit", hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer", shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer", soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
shield: "shield", absorb: "shield", shield: "shield", absorb: "shield",
"ooze-slip": "splash", "slime-stuck": "splash",
"pit-fall": "dust", "dust-puff": "dust",
"edge-dust": "rubble",
// fireworks and die-drop are staged by the raw-event pass below.
}; };
/** One flight, possibly seen from two rooms: a projectile whose straight /** One flight, possibly seen from two rooms: a projectile whose straight
@@ -137,6 +143,41 @@ export function fpFxForEvents(
} }
} }
} }
if (e.type === "wallCreated" && "edge" in e) {
// Stone conjured from nothing: dust where it rises.
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: 550 }, delay: 0 });
}
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
// The maze ITSELF moves: the whole world flashes violet and heaves.
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
}
if (e.type === "thumbOfGod" && "landedAt" in e) {
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
}
if (e.type === "gameWon" && "player" in e) {
// Victory earns real fireworks: bursts climbing over the winner's
// home, gold washing the sky, again and again.
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
for (let i = 0; i < 6; i++) {
if (home) {
out.push({
fx: {
id: nextId++, kind: "impact", art: "burst",
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
},
delay: i * 300,
});
}
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
}
}
if (e.type === "wallDestroyed" && "edge" in e) { 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, 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); (e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
@@ -153,6 +194,17 @@ export function fpFxForEvents(
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay }); out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
} else if (e.type === "lifeGained" && e.player === povId) { } else if (e.type === "lifeGained" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay }); out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
} else if (e.type === "died" && "player" in e && (e as { player: string }).player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
} else if (e.type === "fellInPit" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
} else if (
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
"player" in e && (e as { player: string }).player === povId
) {
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
} else if (e.type === "teleported" && e.player === povId) { } else if (e.type === "teleported" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 }); out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) { } else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
+33 -11
View File
@@ -7,7 +7,7 @@
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine"; import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine"; import type { GameView } from "@wizwar/engine";
import { objectArt, TERRAIN_ART } from "../art"; import { objectArt } from "../art";
export interface Hit { export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */ /** Distance along the ray (perpendicular-corrected by the caller). */
@@ -29,6 +29,11 @@ export interface Hit {
/** The first known-illusion edge the ray crossed before its solid hit: /** The first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */ * the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" }; ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
/** The first OPEN doorway the ray passed through: the renderer hangs
* the lintel of the frame across it. */
doorway?: { dist: number; u: number };
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
frame?: boolean;
} }
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 };
@@ -85,6 +90,7 @@ export function castRay(
let baseDist = 0; let baseDist = 0;
let warped = false; let warped = false;
let ghost: Hit["ghost"]; let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
for (let traversal = 0; traversal < 3; traversal++) { for (let traversal = 0; traversal < 3; traversal++) {
const dx = Math.cos(angle); const dx = Math.cos(angle);
const dy = Math.sin(angle); const dy = Math.sin(angle);
@@ -101,7 +107,7 @@ export function castRay(
// 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"]; slide: number; edge: string } | null = null; let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean } | 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 key = edgeKey({ x: cx, y: cy }, side); const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key); const kind = edgeObstacle(view, key);
@@ -109,17 +115,29 @@ export function castRay(
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)) return; if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
let slide = 0; let slide = 0;
let frame = false;
if (kind === "door") { if (kind === "door") {
const open = doors?.[key] ?? const open = doors?.[key] ??
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0); (view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
if (open > 0) { if (open >= 0.98) {
// A door standing open is a DOORWAY: posts at both ends, a
// lintel hung across, and the way through open to the eye.
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
const JAMB = 0.08;
if (u > JAMB && u < 1 - JAMB) {
if (!doorway) doorway = { dist: baseDist + h.t, u };
return;
}
frame = true;
} else if (open > 0) {
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx; const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along); const u = along - Math.floor(along);
if (u < open - 0.02) return; // the ray sails through the opening if (u < open - 0.02) return; // the ray sails through the opening
slide = open; slide = open;
} }
} }
best = { ...h, kind, slide, edge: key }; best = { ...h, kind, slide, edge: key, frame };
}; };
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;
@@ -128,13 +146,14 @@ export function castRay(
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"]; slide: number; edge: string }; const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
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);
// A sliding door carries its texture with it: sample past the gap. // A sliding door carries its texture with it: sample past the gap.
return { return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide), dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
axis: b.axis, worldU: along, edge: b.edge, warped, ghost, axis: b.axis, worldU: along, edge: b.edge, warped, ghost, doorway,
...(b.frame ? { frame: true } : {}),
}; };
} }
@@ -159,7 +178,7 @@ export function castRay(
const warp = view.board.warps.find( const warp = view.board.warps.find(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side, (w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
); );
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost }; if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost, doorway };
// Step through: re-enter at the paired mouth, heading inward, the // Step through: re-enter at the paired mouth, heading inward, the
// offset along the edge preserved (a wraparound keeps its lane). // offset along the edge preserved (a wraparound keeps its lane).
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]]; const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
@@ -176,14 +195,14 @@ export function castRay(
break; break;
} }
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") { if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost }; return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost, doorway };
} }
cx = nx; cx = nx;
cy = ny; cy = ny;
} }
if (!warped || traversal === 2) break; if (!warped || traversal === 2) break;
} }
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost }; return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost, doorway };
} }
/** Can a wizard's body (not just their eye) cross this edge? Workshop /** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -320,7 +339,7 @@ export function billboards(
if (content.kind === "thornbush" || content.kind === "rosebush") { if (content.kind === "thornbush" || content.kind === "rosebush") {
out.push({ out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind, ...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
scale: 0.5, aspect: 2, rise: 0, bias: 0.18, label: content.kind, scale: 0.5, aspect: 2.35, rise: 0, bias: 0.18, label: content.kind,
}); });
} else if (content.kind === "ooze") { } else if (content.kind === "ooze") {
out.push({ out.push({
@@ -333,7 +352,10 @@ export function billboards(
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust",
}); });
} else if (content.kind === "safe") { } else if (content.kind === "safe") {
out.push({ ...at, src: art(TERRAIN_ART["safe"]!, "terrain"), scale: 0.55, rise: 0, label: "safe" }); out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
});
} }
} }
// Dimensional warp mouths throw pillars of light from their floor rings. // Dimensional warp mouths throw pillars of light from their floor rings.
+23 -1
View File
@@ -4,7 +4,7 @@
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any // paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
// size); the bakes below stand in until painted. // size); the bakes below stand in until painted.
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow"] as const; export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
const baked = new Map<string, HTMLCanvasElement>(); const baked = new Map<string, HTMLCanvasElement>();
export function terrainFallback(name: string): HTMLCanvasElement { export function terrainFallback(name: string): HTMLCanvasElement {
@@ -70,6 +70,28 @@ export function terrainFallback(name: string): HTMLCanvasElement {
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2); g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
g2.fill(); g2.fill();
} }
} else if (name === "safe") {
// A squat iron strongbox with its combination dial.
t.width = 128;
t.height = 128;
const g2 = t.getContext("2d")!;
g2.fillStyle = "#3c3f46";
g2.fillRect(14, 34, 100, 90);
g2.fillStyle = "#54585f";
g2.fillRect(20, 40, 88, 78);
g2.strokeStyle = "#23252a";
g2.lineWidth = 4;
g2.strokeRect(16, 36, 96, 86);
g2.fillStyle = "#8b8f96";
g2.beginPath();
g2.arc(64, 78, 16, 0, Math.PI * 2);
g2.fill();
g2.fillStyle = "#2a2c31";
g2.beginPath();
g2.arc(64, 78, 10, 0, Math.PI * 2);
g2.fill();
g2.fillStyle = "#8b8f96";
g2.fillRect(96, 66, 8, 24);
} else { } else {
// warpglow: a pillar of violet light, brightest at its core. // warpglow: a pillar of violet light, brightest at its core.
t.width = 64; t.width = 64;
+49 -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", "home", "pit", "slime", "tacks", "dimwarp"] as const; export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "doorframe", "cracks", "underbrush"] 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,54 @@ 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();
}), }),
underbrush: bake((c) => {
// The ground beneath a hedge: leaf litter and roots to the square's
// edges, so the standing tangle above never floats on bare flags.
for (let i = 0; i < 46; i++) {
const g = 40 + (h2(i, 61) * 45 | 0);
c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`;
c.beginPath();
c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4,
h2(i, 89) * Math.PI, 0, Math.PI * 2);
c.fill();
}
}),
doorframe: bake((c) => {
// An empty doorway's dressing: stone posts up both sides and a
// lintel across the top, transparent in the middle where the way
// through stands open. Posts live in the outer ~8% columns; the
// lintel in the top ~16% rows.
const stone = (x: number, y: number, w: number, h: number, jit: number) => {
const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0;
c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`;
c.fillRect(x, y, w, h);
c.strokeStyle = "rgba(30,24,16,0.8)";
c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
};
for (let r = 0; r < 5; r++) {
stone(0, 10 + r * 11, 6, 10, r);
stone(TEX - 6, 10 + r * 11, 6, 10, r + 7);
}
stone(0, 0, TEX / 2, 9, 13);
stone(TEX / 2, 0, TEX / 2, 9, 17);
}),
cracks: bake((c) => {
// Battle damage, worn over any wall by accumulated harm: dark
// fractures on transparency, drawn heavier as damage mounts.
c.strokeStyle = "rgba(14,10,6,0.85)";
c.lineWidth = 1.6;
for (let i = 0; i < 5; i++) {
let x = h2(i, 21) * TEX, y = 0;
c.beginPath();
c.moveTo(x, y);
while (y < TEX) {
x += (h2(x, y) - 0.5) * 14;
y += 6 + h2(y, x) * 8;
c.lineTo(x, y);
}
c.stroke();
}
}),
pit: bake((c) => { pit: bake((c) => {
// A hole in the world: black heart, ragged broken-flag edge, a rim // A hole in the world: black heart, ragged broken-flag edge, a rim
// highlight on the lit side selling the depth. // highlight on the lit side selling the depth.
+5 -4
View File
@@ -700,12 +700,13 @@ class Net {
this.send({ type: "moment", turn }); this.send({ type: "moment", turn });
} }
/** Mint a public link to the open moment's turn. */ /** Mint a public link: the open moment's turn, or -1 for the whole
requestShare(): Promise<string> { * finished game. */
requestShare(turn: number | null = this.momentTurn): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.momentTurn === null) return reject(new Error("no turn open")); if (turn === null) return reject(new Error("no turn open"));
this.shareResolve = resolve; this.shareResolve = resolve;
this.send({ type: "share", turn: this.momentTurn }); this.send({ type: "share", turn });
setTimeout(() => { setTimeout(() => {
if (this.shareResolve === resolve) { if (this.shareResolve === resolve) {
this.shareResolve = null; this.shareResolve = null;
+16
View File
@@ -69,6 +69,22 @@ pillar of light drawn ADDITIVELY (like the old glow sprites): bright
means bright, black vanishes. Rehearse any of them in place: means bright, black vanishes. Rehearse any of them in place:
`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`. `/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`.
## Walls' dressing and damage
`public/textures/doorframe.png` dresses OPEN doorways: paint an empty
stone doorway on transparency — posts in the outer ~8% columns, the
lintel in the top ~16% rows, nothing in the middle (the way through).
`public/textures/cracks.png` is battle damage: dark fractures on
transparency, laid over any damaged wall with opacity rising as the
harm mounts. `public/textures/underbrush.png` is the ground beneath a
hedge — leaf litter to the square's edges, a floor decal like the pit.
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
strongbox (square, transparent ground).
Note on `rubble.png`: it renders stretched ACROSS the fallen wall's
whole gap — one cell wide, half a cell tall — so piles painted at the
canvas's lower corners land at the two posts.
## 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,