Credibility pass: the reel's scars sanded, the veil finally hung
Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
88994f18a2
commit
7a32370ccb
@@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
|
|||||||
any seq to inspect full state. To ask why a bot did something, rebuild
|
any seq to inspect full state. To ask why a bot did something, rebuild
|
||||||
the state at its turn and call `automatonCommand(viewFor(state, id),
|
the state at its turn and call `automatonCommand(viewFor(state, id),
|
||||||
style, tier)` — and if its choice differs from the ledger, the engine
|
style, tier)` — and if its choice differs from the ledger, the engine
|
||||||
refused it and the fallback burned the turn (the X2XN pattern).
|
refused it and the fallback burned the turn.
|
||||||
|
|
||||||
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
|
||||||
each, and report phase / round / humans vs bots / last command's `at`.
|
each, and report phase / round / humans vs bots / last command's `at`.
|
||||||
@@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`.
|
|||||||
Strict-replays every production ledger against the local engine; one
|
Strict-replays every production ledger against the local engine; one
|
||||||
refused command fails. A room whose ledger no longer replays becomes
|
refused command fails. A room whose ledger no longer replays becomes
|
||||||
unreachable after restart. Rules changes while games are live need a
|
unreachable after restart. Rules changes while games are live need a
|
||||||
`deckRev` bump plus an engine gate (the convention survives the 2026-08
|
`deckRev` bump plus an engine gate. Production deckRev is currently 1,
|
||||||
reset to rev 1).
|
so low numbers are not stale.
|
||||||
|
|||||||
@@ -1124,7 +1124,6 @@ export function automatonCommand(
|
|||||||
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
|
||||||
const thief = thiefOfMine(view);
|
const thief = thiefOfMine(view);
|
||||||
if (!cursedIdiot) {
|
if (!cursedIdiot) {
|
||||||
|
|
||||||
// Deliver or grab treasure underfoot.
|
// Deliver or grab treasure underfoot.
|
||||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||||
if (!self.carriedTreasureId) {
|
if (!self.carriedTreasureId) {
|
||||||
@@ -1187,8 +1186,6 @@ export function automatonCommand(
|
|||||||
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
||||||
view.turn.round > 1) {
|
view.turn.round > 1) {
|
||||||
const sighted = sightedCellsFor(view);
|
const sighted = sightedCellsFor(view);
|
||||||
// A wizard under the clockwork's own BUDDY pact is off the target list:
|
|
||||||
// attacking them would tear up the pact it just paid a card for.
|
|
||||||
// Whoever cast BUDDY on you is safe from your first strike — the
|
// Whoever cast BUDDY on you is safe from your first strike — the
|
||||||
// engine refuses the attack outright. Your OWN pacts are merely
|
// engine refuses the attack outright. Your OWN pacts are merely
|
||||||
// precious: striking someone you pacted tears up your protection.
|
// precious: striking someone you pacted tears up your protection.
|
||||||
|
|||||||
@@ -348,9 +348,8 @@ describe("the clockwork respects the bush's shelter", () => {
|
|||||||
|
|
||||||
it("never attacks out of its own bush", () => {
|
it("never attacks out of its own bush", () => {
|
||||||
const { state, bot, foe } = faceOffWithFireball();
|
const { state, bot, foe } = faceOffWithFireball();
|
||||||
foe.position = { x: bot.position.x, y: bot.position.y };
|
|
||||||
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
|
||||||
foe.position = { ...bot.position };
|
foe.position = { ...bot.position };
|
||||||
|
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||||
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
|
||||||
|
|||||||
@@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => {
|
|||||||
// One attack per turn: a second wrench is refused.
|
// One attack per turn: a second wrench is refused.
|
||||||
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||||
}
|
}
|
||||||
expect(torn + kept).toBe(12);
|
|
||||||
expect(torn).toBeGreaterThan(0);
|
expect(torn).toBeGreaterThan(0);
|
||||||
expect(kept).toBeGreaterThan(0);
|
expect(kept).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
|
|||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
||||||
// (an optional `target?.kind === ...` branch is not a requirement).
|
// (an optional `target?.kind === ...` branch is not a requirement).
|
||||||
if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
|
||||||
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
!cellCards.has(id)) missing.push(`${id} (cell)`);
|
||||||
if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
|
||||||
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
!edgeCards.has(id)) missing.push(`${id} (edge)`);
|
||||||
}
|
}
|
||||||
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
runCommand,
|
runCommand,
|
||||||
seatTokenValid,
|
seatTokenValid,
|
||||||
SPECTATOR,
|
SPECTATOR,
|
||||||
|
type CatchUpStep,
|
||||||
startGame,
|
startGame,
|
||||||
summarize,
|
summarize,
|
||||||
viewForPlayer,
|
viewForPlayer,
|
||||||
@@ -105,7 +106,7 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR))
|
|||||||
// Every lookup is a full-game replay, so results rest briefly in memory.
|
// Every lookup is a full-game replay, so results rest briefly in memory.
|
||||||
|
|
||||||
interface ShareData {
|
interface ShareData {
|
||||||
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
|
steps: CatchUpStep[];
|
||||||
actor: string;
|
actor: string;
|
||||||
round: number;
|
round: number;
|
||||||
/** A whole finished game rather than one turn. */
|
/** A whole finished game rather than one turn. */
|
||||||
@@ -127,12 +128,12 @@ function shareData(id: string): ShareData | null {
|
|||||||
for (const st of steps) {
|
for (const st of steps) {
|
||||||
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
|
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 };
|
data = { steps, actor: winner, round: 0, whole: true };
|
||||||
}
|
}
|
||||||
} else {
|
} 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, actor: reel.owner, round: reel.round };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,8 +220,7 @@ const httpServer = createServer((req, res) => {
|
|||||||
const data = shareData(watch[1]!);
|
const data = shareData(watch[1]!);
|
||||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||||
if (watch[2]) {
|
if (watch[2]) {
|
||||||
const last = data.steps[data.steps.length - 1]!;
|
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||||
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
|
|
||||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
||||||
res.end(png);
|
res.end(png);
|
||||||
return;
|
return;
|
||||||
@@ -628,7 +628,7 @@ wss.on("connection", (socket) => {
|
|||||||
if (!Number.isInteger(turn) || turn < -1) 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: "catching up already — one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
// turn -1 shares the whole finished game; anything else, one turn.
|
// turn -1 shares the whole finished game; anything else, one turn.
|
||||||
|
|||||||
@@ -447,8 +447,8 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
const MAX_STEPS = 80;
|
const MAX_STEPS = 80;
|
||||||
const { state: fresh, events: dealt } = createGame(room.state.config);
|
const { state: fresh, events: dealt } = createGame(room.state.config);
|
||||||
let current = fresh;
|
let current = fresh;
|
||||||
// The deal's own events open the first turn — count them, or every
|
// The deal's own events open the first turn — count them so turn
|
||||||
// turn number would sit one behind the chronicle's.
|
// numbers match the client chronicle's.
|
||||||
let counter = -1;
|
let counter = -1;
|
||||||
let owner: PlayerId | null = null;
|
let owner: PlayerId | null = null;
|
||||||
let round = 0;
|
let round = 0;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
/** Leafing through the face-up discard pile. */
|
/** Leafing through the face-up discard pile. */
|
||||||
let showDiscards = $state(false);
|
let showDiscards = $state(false);
|
||||||
let chatDraft = $state("");
|
let chatDraft = $state("");
|
||||||
let botTier = $state<string>(prefs.botTier);
|
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
|
||||||
/** Card whose official FAQ rulings are open. */
|
/** Card whose official FAQ rulings are open. */
|
||||||
let faqCardId = $state<string | null>(null);
|
let faqCardId = $state<string | null>(null);
|
||||||
/** A discard-pile card enlarged above the pile. */
|
/** A discard-pile card enlarged above the pile. */
|
||||||
@@ -1395,9 +1395,9 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="pref-row">
|
<div class="pref-row">
|
||||||
<span>Automatons default to</span>
|
<span>Automatons default to</span>
|
||||||
{#each ["apprentice", "adept", "archmage"] as t (t)}
|
{#each ["apprentice", "adept", "archmage"] as const as t (t)}
|
||||||
<button class="stamp tiny" class:lit={prefs.botTier === t}
|
<button class="stamp tiny" class:lit={prefs.botTier === t}
|
||||||
onclick={() => { setPref("botTier", t as typeof prefs.botTier); botTier = t; }}>{t}</button>
|
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<label class="pref-row">
|
<label class="pref-row">
|
||||||
@@ -1829,7 +1829,7 @@
|
|||||||
<span class="bot-row">
|
<span class="bot-row">
|
||||||
<span class="bot-label">⚙ seat a</span>
|
<span class="bot-label">⚙ seat a</span>
|
||||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
||||||
onchange={() => setPref("botTier", botTier as typeof prefs.botTier)}>
|
onchange={() => setPref("botTier", botTier)}>
|
||||||
<option value="apprentice">apprentice</option>
|
<option value="apprentice">apprentice</option>
|
||||||
<option value="adept">adept</option>
|
<option value="adept">adept</option>
|
||||||
<option value="archmage">archmage</option>
|
<option value="archmage">archmage</option>
|
||||||
|
|||||||
@@ -302,8 +302,7 @@
|
|||||||
}
|
}
|
||||||
// Last resort, only for the reel's own wizard's step: wherever the
|
// Last resort, only for the reel's own wizard's step: wherever the
|
||||||
// fx layer is about to play something visible — an impact, a
|
// fx layer is about to play something visible — an impact, a
|
||||||
// projectile's landing — the eyes should already be. This covers
|
// projectile's landing — the eyes should already be.
|
||||||
// every effect that exists and every one added later.
|
|
||||||
if (step.actor === povId) {
|
if (step.actor === povId) {
|
||||||
for (const e of step.events) {
|
for (const e of step.events) {
|
||||||
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||||
@@ -516,9 +515,7 @@
|
|||||||
if (recorder) { recorder.stop(); return; }
|
if (recorder) { recorder.stop(); return; }
|
||||||
const cv = stageEl?.querySelector("canvas");
|
const cv = stageEl?.querySelector("canvas");
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
// Recording means capturing the WHOLE reel: rewind to the top and
|
// Recording means capturing the WHOLE reel: rewind to the top and roll.
|
||||||
// roll. (Hitting record at the end used to auto-stop a beat later —
|
|
||||||
// a one-second clip of the final frame.)
|
|
||||||
idx = 0;
|
idx = 0;
|
||||||
const comp = document.createElement("canvas");
|
const comp = document.createElement("canvas");
|
||||||
comp.width = 1280;
|
comp.width = 1280;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import FirewallEdge from "./FirewallEdge.svelte";
|
import FirewallEdge from "./FirewallEdge.svelte";
|
||||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||||
import { FX_ART } from "./fpv/fx3d";
|
import { FX_ART } from "./fpv/fx3d";
|
||||||
|
import { MATERIALS } from "./fpv/textures";
|
||||||
import { TERRAIN3D } from "./fpv/terrain3d";
|
import { TERRAIN3D } from "./fpv/terrain3d";
|
||||||
|
|
||||||
const GROUPS: { title: string; files: string[] }[] = [
|
const GROUPS: { title: string; files: string[] }[] = [
|
||||||
@@ -96,7 +97,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", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as name (name)}
|
{#each MATERIALS 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>
|
||||||
|
|||||||
@@ -55,16 +55,6 @@
|
|||||||
return img.complete && img.naturalWidth > 0 ? img : null;
|
return img.complete && img.naturalWidth > 0 ? img : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base colors per face (fallback + tinting basis). */
|
|
||||||
const FACE: Record<string, [number, number, number]> = {
|
|
||||||
wall: [126, 118, 100],
|
|
||||||
stone: [96, 96, 104],
|
|
||||||
door: [130, 92, 48],
|
|
||||||
rim: [82, 76, 66],
|
|
||||||
firewall: [214, 92, 28],
|
|
||||||
warp: [96, 60, 160],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Materials come from /textures/*.png when those files exist — the
|
// Materials come from /textures/*.png when those files exist — the
|
||||||
// independently paintable set — with the baked procedurals underneath
|
// independently paintable set — with the baked procedurals underneath
|
||||||
// so a missing or still-loading file never leaves a wall naked.
|
// so a missing or still-loading file never leaves a wall naked.
|
||||||
@@ -253,6 +243,9 @@
|
|||||||
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
|
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
|
||||||
const warpIdCol = new Int32Array(W).fill(-1);
|
const warpIdCol = new Int32Array(W).fill(-1);
|
||||||
const warpDistCol = new Float64Array(W).fill(Infinity);
|
const warpDistCol = new Float64Array(W).fill(Infinity);
|
||||||
|
// Each mouth a ray passed through hangs a translucent veil of the
|
||||||
|
// warp texture at its own depth, drawn with the other overlays.
|
||||||
|
const veils: { col: number; depth: number; u: number }[] = [];
|
||||||
// Known illusions: the ray passes, but a translucent ghost of a wall
|
// Known illusions: the ray passes, but a translucent ghost of a wall
|
||||||
// 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.
|
||||||
@@ -269,6 +262,7 @@
|
|||||||
if (hit.warpId !== undefined) {
|
if (hit.warpId !== undefined) {
|
||||||
warpIdCol[col] = hit.warpId;
|
warpIdCol[col] = hit.warpId;
|
||||||
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
||||||
|
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
|
||||||
}
|
}
|
||||||
if (hit.ghost) {
|
if (hit.ghost) {
|
||||||
ghosts.push({
|
ghosts.push({
|
||||||
@@ -289,9 +283,9 @@
|
|||||||
const top = half - wallH / 2;
|
const top = half - wallH / 2;
|
||||||
const tex = hit.frame ? textures.doorframe! : 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 shifts its slice per world cell, so a blaze spanning edges
|
||||||
// spanning edges reads as one long fire, not a repeated flame.
|
// reads as one long fire, not a repeated flame.
|
||||||
const texU = hit.kind === "firewall" || hit.kind === "warp"
|
const texU = hit.kind === "firewall"
|
||||||
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
||||||
: 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,
|
||||||
@@ -316,11 +310,6 @@
|
|||||||
ctx.fillRect(col, top, 1, wallH);
|
ctx.fillRect(col, top, 1, wallH);
|
||||||
dark *= 0.5; // the fire lights itself
|
dark *= 0.5; // the fire lights itself
|
||||||
}
|
}
|
||||||
if (hit.kind === "warp") {
|
|
||||||
const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
|
|
||||||
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
|
||||||
ctx.fillRect(col, top, 1, wallH);
|
|
||||||
}
|
|
||||||
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
|
||||||
// The same tell the board gives: an untested illusion's face
|
// The same tell the board gives: an untested illusion's face
|
||||||
// shimmers faintly — maybe stone, maybe not.
|
// shimmers faintly — maybe stone, maybe not.
|
||||||
@@ -389,7 +378,7 @@
|
|||||||
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
|
||||||
if (s.depth >= zbuf[col]!) continue;
|
if (s.depth >= zbuf[col]!) continue;
|
||||||
if (s.warped) {
|
if (s.warped) {
|
||||||
if (warpIdCol[col] !== (s.warpId ?? -2) || s.depth <= warpDistCol[col]!) continue;
|
if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue;
|
||||||
} else if (s.depth >= warpDistCol[col]!) continue;
|
} else if (s.depth >= warpDistCol[col]!) continue;
|
||||||
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
|
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
|
||||||
const texX = ((col - s.left) / (s.right - s.left));
|
const texX = ((col - s.left) / (s.right - s.left));
|
||||||
@@ -413,6 +402,22 @@
|
|||||||
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warp mouths wear their veil: the warp texture at the opening,
|
||||||
|
// translucent, swirling — the doorway between rooms that are not
|
||||||
|
// neighbors announces itself.
|
||||||
|
for (const vl of veils) {
|
||||||
|
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
|
||||||
|
const vTop = half - vh / 2;
|
||||||
|
const wt = textures.warp!;
|
||||||
|
ctx.globalAlpha = 0.3;
|
||||||
|
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width), 0,
|
||||||
|
Math.max(1, wt.width / 96), wt.height, vl.col, vTop, 1, vh);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
|
||||||
|
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
|
||||||
|
ctx.fillRect(vl.col, vTop, 1, vh);
|
||||||
|
}
|
||||||
|
|
||||||
// Conjurations rising: the wall (or stone) grows bottom-up where it
|
// Conjurations rising: the wall (or stone) grows bottom-up where it
|
||||||
// will stand, glowing swirls running over the young face.
|
// will stand, glowing swirls running over the young face.
|
||||||
for (const ri of risings) {
|
for (const ri of risings) {
|
||||||
@@ -521,7 +526,7 @@
|
|||||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
const bottom = half + wallH / 2 - b.rise * wallH;
|
||||||
// A cell-bound volume is windowed to its own square: the billboard's
|
// A cell-bound volume is windowed to its own square: the billboard's
|
||||||
// camera-facing plane is intersected with the cell, and only that
|
// camera-facing plane is intersected with the cell, and only that
|
||||||
// lateral segment may paint — an obliquely-viewed hedge can no longer
|
// lateral segment may paint, so an obliquely-viewed hedge cannot
|
||||||
// poke its ends through the neighboring walls. (Virtual warp copies
|
// poke its ends through the neighboring walls. (Virtual warp copies
|
||||||
// skip this — their clip cell lives in another frame.)
|
// skip this — their clip cell lives in another frame.)
|
||||||
let clampL: number | undefined;
|
let clampL: number | undefined;
|
||||||
|
|||||||
@@ -56,10 +56,9 @@
|
|||||||
const start = view.players.find((p) => p.id === povId)!.position;
|
const start = view.players.find((p) => p.id === povId)!.position;
|
||||||
|
|
||||||
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
|
||||||
const DIR_ANGLE: Record<string, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
|
||||||
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
|
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
|
||||||
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
|
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
|
||||||
let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
|
let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
|
||||||
|
|
||||||
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
|
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
|
||||||
// camera moves the same way — a tap glides one square, a turn swings a
|
// camera moves the same way — a tap glides one square, a turn swings a
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface Hit {
|
|||||||
/** Distance along the ray (perpendicular-corrected by the caller). */
|
/** Distance along the ray (perpendicular-corrected by the caller). */
|
||||||
dist: number;
|
dist: number;
|
||||||
/** What the ray struck. */
|
/** What the ray struck. */
|
||||||
kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
|
kind: "wall" | "door" | "firewall" | "stone" | "rim";
|
||||||
/** 0..1 across the struck face (texture coordinate). */
|
/** 0..1 across the struck face (texture coordinate). */
|
||||||
u: number;
|
u: number;
|
||||||
/** Vertical faces get a different shade than horizontal ones. */
|
/** Vertical faces get a different shade than horizontal ones. */
|
||||||
@@ -26,11 +26,13 @@ export interface Hit {
|
|||||||
edge?: string;
|
edge?: string;
|
||||||
/** The eye reached this through a warp: haze it other-worldly. */
|
/** The eye reached this through a warp: haze it other-worldly. */
|
||||||
warped?: boolean;
|
warped?: boolean;
|
||||||
/** Which warp (index in board.warps) the ray first bent through, and
|
/** Which warp (index in board.warps) the ray first bent through, how
|
||||||
* how far along the ray that mouth stood — the gate a sprite must be
|
* far along the ray that mouth stood, and where across it — the gate a
|
||||||
* beyond (its own warp) or in front of (any) to paint this column. */
|
* sprite must be beyond (its own warp) or in front of (any) to paint
|
||||||
|
* this column, and the veil the renderer hangs in the opening. */
|
||||||
warpId?: number;
|
warpId?: number;
|
||||||
warpDist?: number;
|
warpDist?: number;
|
||||||
|
warpU?: number;
|
||||||
/** 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" };
|
||||||
@@ -62,8 +64,8 @@ export function warpLaneMirrored(from: Side, to: Side): boolean {
|
|||||||
const [x, y] = LANE_AXIS[from];
|
const [x, y] = LANE_AXIS[from];
|
||||||
const rx = x * Math.cos(d) - y * Math.sin(d);
|
const rx = x * Math.cos(d) - y * Math.sin(d);
|
||||||
const ry = x * Math.sin(d) + y * Math.cos(d);
|
const ry = x * Math.sin(d) + y * Math.cos(d);
|
||||||
const [ex2, ey] = LANE_AXIS[to];
|
const [ux, uy] = LANE_AXIS[to];
|
||||||
return rx * ex2 + ry * ey < 0;
|
return rx * ux + ry * uy < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
/** Is this edge passable to the EYE (rays), and if not, what is it?
|
||||||
@@ -77,11 +79,6 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
|||||||
return "wall";
|
return "wall";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* March one ray from (ox, oy) at `angle` and return the first thing that
|
|
||||||
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
|
||||||
* is a warp mouth (then a shimmering opening).
|
|
||||||
*/
|
|
||||||
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
/** Half-thickness per material: walls are masonry, doors joinery, fire a
|
||||||
* sheet. The slab gives every wall END a visible cap face, so corners and
|
* sheet. The slab gives every wall END a visible cap face, so corners and
|
||||||
* doorways read as three-dimensional stone rather than paper. */
|
* doorways read as three-dimensional stone rather than paper. */
|
||||||
@@ -105,6 +102,14 @@ function slabHit(
|
|||||||
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a slab test found: the strike, and how the door slid under it. */
|
||||||
|
type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* March one ray from (ox, oy) at `angle` and return the first thing that
|
||||||
|
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
|
||||||
|
* is a warp mouth — the ray passes through those, remembering the first.
|
||||||
|
*/
|
||||||
export function castRay(
|
export function castRay(
|
||||||
view: GameView, ox: number, oy: number, angle: number,
|
view: GameView, ox: number, oy: number, angle: number,
|
||||||
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
||||||
@@ -121,10 +126,12 @@ export function castRay(
|
|||||||
let warped = false;
|
let warped = false;
|
||||||
let warpId: number | undefined;
|
let warpId: number | undefined;
|
||||||
let warpDist: number | undefined;
|
let warpDist: number | undefined;
|
||||||
|
let warpU: number | undefined;
|
||||||
let ghost: Hit["ghost"];
|
let ghost: Hit["ghost"];
|
||||||
let doorway: Hit["doorway"];
|
let doorway: Hit["doorway"];
|
||||||
let rising: Hit["rising"];
|
let rising: Hit["rising"];
|
||||||
for (let traversal = 0; traversal < 3; traversal++) {
|
for (let traversal = 0; traversal < 3; traversal++) {
|
||||||
|
let traversed = false;
|
||||||
const dx = Math.cos(angle);
|
const dx = Math.cos(angle);
|
||||||
const dy = Math.sin(angle);
|
const dy = Math.sin(angle);
|
||||||
let cx = Math.floor(ox);
|
let cx = Math.floor(ox);
|
||||||
@@ -140,7 +147,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; frame?: boolean } | null = null;
|
let best: SlabStrike | 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);
|
||||||
@@ -188,13 +195,13 @@ 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; frame?: boolean };
|
const b = best as SlabStrike;
|
||||||
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, warpId, warpDist, ghost, doorway, rising,
|
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
|
||||||
...(b.frame ? { frame: true } : {}),
|
...(b.frame ? { frame: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -221,8 +228,8 @@ export function castRay(
|
|||||||
(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,
|
||||||
);
|
);
|
||||||
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
|
||||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
|
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
|
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
|
||||||
// Step through: re-enter at the paired mouth, heading inward, the
|
// Step through: re-enter at the paired mouth, heading inward, the
|
||||||
// lane carried by the same proper rotation the heading turns by —
|
// lane carried by the same proper rotation the heading turns by —
|
||||||
// mirrored for the pairings whose axes land head-to-head.
|
// mirrored for the pairings whose axes land head-to-head.
|
||||||
@@ -236,23 +243,23 @@ export function castRay(
|
|||||||
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
else { ox = c.x + lane; oy = c.y + 1e-4; }
|
||||||
baseDist += dist;
|
baseDist += dist;
|
||||||
warped = true;
|
warped = true;
|
||||||
i = 64; // restart the DDA from the far mouth
|
traversed = true;
|
||||||
break;
|
break; // restart the DDA from the far mouth
|
||||||
}
|
}
|
||||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||||
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
||||||
if (sg !== undefined && sg < 1) {
|
if (sg !== undefined && sg < 1) {
|
||||||
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
||||||
} else {
|
} else {
|
||||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, ghost, doorway, rising };
|
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx = nx;
|
cx = nx;
|
||||||
cy = ny;
|
cy = ny;
|
||||||
}
|
}
|
||||||
if (!warped || traversal === 2) break;
|
if (!traversed) break;
|
||||||
}
|
}
|
||||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, ghost, doorway, rising };
|
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
@@ -436,8 +443,8 @@ export function billboards(
|
|||||||
} else if (content.kind === "safe") {
|
} else if (content.kind === "safe") {
|
||||||
out.push({
|
out.push({
|
||||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
|
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||||
clip: { x: Math.floor(at.x), y: Math.floor(at.y) },
|
clip: { x: tx, y: ty },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ const FACE: Record<string, [number, number, number]> = {
|
|||||||
stone: [96, 96, 104],
|
stone: [96, 96, 104],
|
||||||
door: [130, 92, 48],
|
door: [130, 92, 48],
|
||||||
rim: [82, 76, 66],
|
rim: [82, 76, 66],
|
||||||
firewall: [214, 92, 28],
|
|
||||||
warp: [96, 60, 160],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Textures are baked once at 64x64 and sampled one column at a time —
|
// Textures are baked once at 64x64 and sampled one column at a time —
|
||||||
// perspective-correct verticals for the price of a single drawImage.
|
// perspective-correct verticals for the price of a single drawImage.
|
||||||
export const TEX = 64;
|
const TEX = 64;
|
||||||
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
||||||
const t = document.createElement("canvas");
|
const t = document.createElement("canvas");
|
||||||
t.width = TEX; t.height = TEX;
|
t.width = TEX; t.height = TEX;
|
||||||
@@ -42,10 +40,10 @@ 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", "doorframe", "cracks", "underbrush"] as const;
|
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "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> {
|
function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||||
return {
|
return {
|
||||||
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
||||||
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ class Net {
|
|||||||
/** The turn whose moment reel is open (share links point at it). */
|
/** The turn whose moment reel is open (share links point at it). */
|
||||||
private momentTurn: number | null = null;
|
private momentTurn: number | null = null;
|
||||||
private shareResolve: ((url: string) => void) | null = null;
|
private shareResolve: ((url: string) => void) | null = null;
|
||||||
|
private shareReject: ((e: Error) => void) | null = null;
|
||||||
private seen: Record<string, number> = loadSeen();
|
private seen: Record<string, number> = loadSeen();
|
||||||
/** Room whose live stream this connection has already shown once: states
|
/** Room whose live stream this connection has already shown once: states
|
||||||
* after the first mark themselves seen while the tab is visible. */
|
* after the first mark themselves seen while the tab is visible. */
|
||||||
@@ -430,6 +431,7 @@ class Net {
|
|||||||
case "share":
|
case "share":
|
||||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||||
this.shareResolve = null;
|
this.shareResolve = null;
|
||||||
|
this.shareReject = null;
|
||||||
break;
|
break;
|
||||||
case "events": {
|
case "events": {
|
||||||
let talk = 0;
|
let talk = 0;
|
||||||
@@ -527,9 +529,7 @@ class Net {
|
|||||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||||
watch(roomId: string): void {
|
watch(roomId: string): void {
|
||||||
this.you = null;
|
this.you = null;
|
||||||
this.log = [];
|
this.resetChronicle();
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,9 +549,7 @@ class Net {
|
|||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.token = seat.token;
|
this.token = seat.token;
|
||||||
this.roomIdPending = seat.roomId;
|
this.roomIdPending = seat.roomId;
|
||||||
this.log = [];
|
this.resetChronicle();
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,9 +656,7 @@ class Net {
|
|||||||
this.view = null;
|
this.view = null;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.players = [];
|
this.players = [];
|
||||||
this.log = [];
|
this.resetChronicle();
|
||||||
this.turnCounter = -1;
|
|
||||||
this.eyeTurn = -1;
|
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.audience = 0;
|
this.audience = 0;
|
||||||
@@ -698,6 +694,13 @@ class Net {
|
|||||||
this.markSeen();
|
this.markSeen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chronicle and its turn count reset together, always. */
|
||||||
|
private resetChronicle(): void {
|
||||||
|
this.log = [];
|
||||||
|
this.turnCounter = -1;
|
||||||
|
this.eyeTurn = -1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Summon one turn's reel by its chronicle turn number. */
|
/** Summon one turn's reel by its chronicle turn number. */
|
||||||
requestMoment(turn: number): void {
|
requestMoment(turn: number): void {
|
||||||
this.momentTurn = turn;
|
this.momentTurn = turn;
|
||||||
@@ -705,15 +708,19 @@ class Net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mint a public link: the open moment's turn, or -1 for the whole
|
/** Mint a public link: the open moment's turn, or -1 for the whole
|
||||||
* finished game. */
|
* finished game. One mint in flight at a time — a newcomer supersedes
|
||||||
|
* a stranded predecessor rather than leaving it pending forever. */
|
||||||
requestShare(turn: number | null = this.momentTurn): Promise<string> {
|
requestShare(turn: number | null = this.momentTurn): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (turn === null) return reject(new Error("no turn open"));
|
if (turn === null) return reject(new Error("no turn open"));
|
||||||
|
this.shareReject?.(new Error("superseded"));
|
||||||
this.shareResolve = resolve;
|
this.shareResolve = resolve;
|
||||||
|
this.shareReject = reject;
|
||||||
this.send({ type: "share", turn });
|
this.send({ type: "share", turn });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.shareResolve === resolve) {
|
if (this.shareResolve === resolve) {
|
||||||
this.shareResolve = null;
|
this.shareResolve = null;
|
||||||
|
this.shareReject = null;
|
||||||
reject(new Error("share timed out"));
|
reject(new Error("share timed out"));
|
||||||
}
|
}
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
@@ -722,6 +729,7 @@ class Net {
|
|||||||
|
|
||||||
closeMoment(): void {
|
closeMoment(): void {
|
||||||
this.moment = null;
|
this.moment = null;
|
||||||
|
this.momentTurn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
command(command: Command): void {
|
command(command: Command): void {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
||||||
spell moments through the air as **billboard sprites**: flat images that
|
spell moments through the air as **billboard sprites**: flat images that
|
||||||
always face the camera, drawn glowing in mid-air, occluded by walls, and
|
always face the camera, drawn in mid-air, occluded by walls, and scaled
|
||||||
scaled by distance. Nine sprites cover every spell moment in the game.
|
by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
|
||||||
Each lives at `packages/web/public/textures/../fx3d/<name>.png` — replace
|
moment in the game. Each lives at `packages/web/public/fx3d/<name>.png`
|
||||||
the file, refresh, done. The current files are placeholder radial glows;
|
— replace the file, refresh, done. Missing files fall back to built-in
|
||||||
all nine are shown side by side at `/?tokens` under "The conjurations."
|
procedural glows; the whole set is shown side by side at `/?tokens`
|
||||||
|
under "The conjurations."
|
||||||
|
|
||||||
## Technical requirements
|
## Technical requirements
|
||||||
|
|
||||||
@@ -15,10 +16,9 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
|
|||||||
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
|
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
|
||||||
renderer scales freely and pixelates on magnification (the whole view
|
renderer scales freely and pixelates on magnification (the whole view
|
||||||
is deliberately chunky, like the wall textures).
|
is deliberately chunky, like the wall textures).
|
||||||
- **Drawn additively:** sprites are composited in "lighter" (additive)
|
- **Compositing:** painted sprites draw normally — inks stay true, dark
|
||||||
mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
|
outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
|
||||||
Paint light-on-transparent; pure black will be invisible. Midtones read
|
light.) Impacts fade out by alpha as they swell.
|
||||||
as translucent light.
|
|
||||||
- **No animation frames.** Each sprite is a single still. Motion comes
|
- **No animation frames.** Each sprite is a single still. Motion comes
|
||||||
from the renderer: projectiles streak across the room, impacts swell
|
from the renderer: projectiles streak across the room, impacts swell
|
||||||
from small to large while fading out. Radially symmetric (or nearly so)
|
from small to large while fading out. Radially symmetric (or nearly so)
|
||||||
@@ -39,11 +39,11 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
|
|||||||
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
|
||||||
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
|
||||||
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
| `shield.png` | impact | an attack fully stopped; absorbed spells |
|
||||||
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
|
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
|
||||||
|
|
||||||
Projectiles hold their size in flight (about a third of a wizard's
|
Projectiles hold their size in flight (about a third of a wizard's
|
||||||
height). Impacts start small and swell to roughly double while fading —
|
height). Impacts start small and swell past triple while fading — so an
|
||||||
so an impact painted as a ring or burst reads especially well.
|
impact painted as a ring or burst reads especially well.
|
||||||
|
|
||||||
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
What the sprites do NOT need to carry: screen-wide flashes when YOU are
|
||||||
hit (red), healed (green), or teleported (violet), the camera shake, the
|
hit (red), healed (green), or teleported (violet), the camera shake, the
|
||||||
@@ -81,10 +81,6 @@ 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
|
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
|
||||||
strongbox (square, transparent ground).
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user