diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 8fe5b9b..4910b8f 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -108,6 +108,8 @@ interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
actor: string;
round: number;
+ /** A whole finished game rather than one turn. */
+ whole?: boolean;
}
const shareCache = new Map();
function shareData(id: string): ShareData | null {
@@ -117,9 +119,21 @@ function shareData(id: string): ShareData | null {
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
- const reel = momentSteps(room, SPECTATOR, share.turn);
- if (!("error" in reel) && reel.steps.length > 0) {
- data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
+ 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);
+ if (!("error" in reel) && reel.steps.length > 0) {
+ data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
+ }
}
}
if (shareCache.size > 50) shareCache.clear();
@@ -143,9 +157,13 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
.replace(/]*>\s*/g, "")
.replace(/[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`;
- const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
- const desc = `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.`;
+ const title = data.whole
+ ? "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.`;
const metas = [
`${title}`,
``,
@@ -193,7 +211,7 @@ const httpServer = createServer((req, res) => {
"cache-control": "public, max-age=60",
"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;
}
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;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
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();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" });
}
session.lastCatchUpAt = now;
- const reel = momentSteps(room, session.playerId, turn);
- if ("error" in reel) return send(socket, { type: "error", message: reel.error });
+ // turn -1 shares the whole finished game; anything else, one turn.
+ 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);
send(socket, { type: "share", id: share.id, turn });
break;
diff --git a/packages/web/public/terrain3d/safe.png b/packages/web/public/terrain3d/safe.png
new file mode 100644
index 0000000..7cfb3b6
Binary files /dev/null and b/packages/web/public/terrain3d/safe.png differ
diff --git a/packages/web/public/textures/cracks.png b/packages/web/public/textures/cracks.png
new file mode 100644
index 0000000..fe46b76
Binary files /dev/null and b/packages/web/public/textures/cracks.png differ
diff --git a/packages/web/public/textures/doorframe.png b/packages/web/public/textures/doorframe.png
new file mode 100644
index 0000000..204fd7d
Binary files /dev/null and b/packages/web/public/textures/doorframe.png differ
diff --git a/packages/web/public/textures/underbrush.png b/packages/web/public/textures/underbrush.png
new file mode 100644
index 0000000..b3f85a4
Binary files /dev/null and b/packages/web/public/textures/underbrush.png differ
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 1930d96..a0f4ab8 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -1623,7 +1623,9 @@
net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
- net.closeCatchUp()} />
+ net.requestShare(-1) : null}
+ onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
(local.replaySteps = null)} />
{/if}
diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte
index 88a4f51..72e6aaa 100644
--- a/packages/web/src/Replay.svelte
+++ b/packages/web/src/Replay.svelte
@@ -462,7 +462,7 @@
{/if}
- {#if moment && onshare}
+ {#if onshare}
- {#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)}
{name}
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte
index a2a90c9..9b70ca3 100644
--- a/packages/web/src/fpv/FirstPerson.svelte
+++ b/packages/web/src/fpv/FirstPerson.svelte
@@ -114,10 +114,15 @@
parseInt(hex.slice(5, 7), 16) / 255,
]);
}
+ const DECAL_KIND: Record = {
+ 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) {
diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte
index eed2de0..bd8247a 100644
--- a/packages/web/src/fpv/FpvWorkshop.svelte
+++ b/packages/web/src/fpv/FpvWorkshop.svelte
@@ -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)) {
diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts
index 4786b77..27b3392 100644
--- a/packages/web/src/fpv/fx3d.ts
+++ b/packages/web/src/fpv/fx3d.ts
@@ -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 = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
/** Board-effect kinds that land here as an impact billboard. */
const IMPACT_ART: Record = {
- 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) {
diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts
index e84fdad..102ea79 100644
--- a/packages/web/src/fpv/raycast.ts
+++ b/packages/web/src/fpv/raycast.ts
@@ -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 = { 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.
diff --git a/packages/web/src/fpv/terrain3d.ts b/packages/web/src/fpv/terrain3d.ts
index 960c3dc..458a4b2 100644
--- a/packages/web/src/fpv/terrain3d.ts
+++ b/packages/web/src/fpv/terrain3d.ts
@@ -4,7 +4,7 @@
// paintable sprite at public/terrain3d/.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();
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;
diff --git a/packages/web/src/fpv/textures.ts b/packages/web/src/fpv/textures.ts
index d21cf06..5c8b7ac 100644
--- a/packages/web/src/fpv/textures.ts
+++ b/packages/web/src/fpv/textures.ts
@@ -42,7 +42,7 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
});
}
-export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "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 {
@@ -127,6 +127,54 @@ export function proceduralTextures(): Record {
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.
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts
index f204ba7..de39fd6 100644
--- a/packages/web/src/net.svelte.ts
+++ b/packages/web/src/net.svelte.ts
@@ -700,12 +700,13 @@ class Net {
this.send({ type: "moment", turn });
}
- /** Mint a public link to the open moment's turn. */
- requestShare(): Promise {
+ /** Mint a public link: the open moment's turn, or -1 for the whole
+ * finished game. */
+ requestShare(turn: number | null = this.momentTurn): Promise {
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;
diff --git a/research/fx3d-sprite-spec.md b/research/fx3d-sprite-spec.md
index bf468da..233b6a4 100644
--- a/research/fx3d-sprite-spec.md
+++ b/research/fx3d-sprite-spec.md
@@ -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:
`/?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
`public/textures/home.png` is a floor overlay for home-base cells,