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
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}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{: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}
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
{/if}
+1 -1
View File
@@ -462,7 +462,7 @@
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if}
{#if moment && onshare}
{#if onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…"
+4 -1
View File
@@ -14,6 +14,7 @@
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
actor: string;
round: number;
whole?: boolean;
}
let data = $state<ShareSteps | null>(null);
let failed = $state(false);
@@ -30,7 +31,9 @@
<header class="share-head">
<a class="share-brand" href="/">WIZ-WAR</a>
{#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}
</header>
+1 -1
View File
@@ -96,7 +96,7 @@
wherever a file is missing.
</p>
<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>
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
<figcaption>{name}</figcaption>
+40 -5
View File
@@ -114,10 +114,15 @@
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)) {
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];
put(cx, cy, content.kind);
put(cx, cy, tex);
}
for (const w of v.dimWarps) {
put(w.a.x, w.a.y, "dimwarp");
@@ -238,6 +243,8 @@
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
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++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle, doors);
@@ -250,9 +257,12 @@
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 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.
// Fire and warps shift their slice per world cell, so a blaze
// spanning edges reads as one long fire, not a repeated flame.
@@ -261,6 +271,17 @@
: hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
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.
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
@@ -305,7 +326,7 @@
.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.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" });
}
for (const f of fx) {
@@ -318,7 +339,7 @@
const s = project({
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,
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
warped: f.kind === "projectile" ? f.warped : undefined,
}, ex, ey);
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;
}
// 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
// their columns, thin as breath, rippling.
for (const gh of ghosts) {
+8
View File
@@ -27,6 +27,14 @@
view.knownIllusionEdges.push(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
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
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
* 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;
/** 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: "shake"; mag: number; 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 };
/** Board-effect kinds that land here as an impact billboard. */
const IMPACT_ART: Record<string, string> = {
burst: "burst", splash: "splash", fireworks: "burst",
hit: "hit", pow: "hit", claw: "hit",
burst: "burst", splash: "splash",
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
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
@@ -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) {
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);
@@ -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 });
} 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 });
} 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) {
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) {
+33 -11
View File
@@ -7,7 +7,7 @@
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine";
import { objectArt, TERRAIN_ART } from "../art";
import { objectArt } from "../art";
export interface Hit {
/** 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 eye passes, but a translucent ghost of a wall stands there. */
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 };
@@ -85,6 +90,7 @@ export function castRay(
let baseDist = 0;
let warped = false;
let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
for (let traversal = 0; traversal < 3; traversal++) {
const dx = Math.cos(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
// nearest strike inside this cell's span wins.
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 key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
@@ -109,17 +115,29 @@ export function castRay(
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
let slide = 0;
let frame = false;
if (kind === "door") {
const open = doors?.[key] ??
(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 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 };
best = { ...h, kind, slide, edge: key, frame };
};
const hw = (side: Side) =>
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("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"]; 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 u = along - Math.floor(along);
// 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, 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(
(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
// offset along the edge preserved (a wraparound keeps its lane).
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
@@ -176,14 +195,14 @@ export function castRay(
break;
}
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;
cy = ny;
}
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
@@ -320,7 +339,7 @@ export function billboards(
if (content.kind === "thornbush" || content.kind === "rosebush") {
out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
scale: 0.5, aspect: 2, rise: 0, bias: 0.18, label: content.kind,
scale: 0.5, aspect: 2.35, rise: 0, bias: 0.18, label: content.kind,
});
} else if (content.kind === "ooze") {
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",
});
} 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.
+23 -1
View File
@@ -4,7 +4,7 @@
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
// size); the bakes below stand in until painted.
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow"] as const;
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
const baked = new Map<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.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 {
// warpglow: a pillar of violet light, brightest at its core.
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);
});
}
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. */
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.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) => {
// A hole in the world: black heart, ragged broken-flag edge, a rim
// highlight on the lit side selling the depth.
+5 -4
View File
@@ -700,12 +700,13 @@ class Net {
this.send({ type: "moment", turn });
}
/** Mint a public link to the open moment's turn. */
requestShare(): Promise<string> {
/** Mint a public link: the open moment's turn, or -1 for the whole
* finished game. */
requestShare(turn: number | null = this.momentTurn): Promise<string> {
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.send({ type: "share", turn: this.momentTurn });
this.send({ type: "share", turn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;