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:
co-authored by
Claude Fable 5
parent
a94ab5c6f5
commit
5fc5805354
@@ -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<string, { at: number; data: ShareData | null }>();
|
||||
function shareData(id: string): ShareData | null {
|
||||
@@ -117,11 +119,23 @@ function shareData(id: string): ShareData | null {
|
||||
const share = getShare(id);
|
||||
const room = share ? getRoom(share.roomId) : undefined;
|
||||
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);
|
||||
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();
|
||||
shareCache.set(id, { at: Date.now(), 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(/<title>[^<]*<\/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. ` +
|
||||
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>${title}</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;
|
||||
|
||||
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 |
@@ -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}
|
||||
|
||||
@@ -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" ? "…"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user