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
|
||||
the state at its turn and call `automatonCommand(viewFor(state, id),
|
||||
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
|
||||
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
|
||||
refused command fails. A room whose ledger no longer replays becomes
|
||||
unreachable after restart. Rules changes while games are live need a
|
||||
`deckRev` bump plus an engine gate (the convention survives the 2026-08
|
||||
reset to rev 1).
|
||||
`deckRev` bump plus an engine gate. Production deckRev is currently 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 thief = thiefOfMine(view);
|
||||
if (!cursedIdiot) {
|
||||
|
||||
// Deliver or grab treasure underfoot.
|
||||
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
|
||||
if (!self.carriedTreasureId) {
|
||||
@@ -1187,8 +1186,6 @@ export function automatonCommand(
|
||||
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
|
||||
view.turn.round > 1) {
|
||||
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
|
||||
// engine refuses the attack outright. Your OWN pacts are merely
|
||||
// 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", () => {
|
||||
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 };
|
||||
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
|
||||
?? automatonFallback(viewFor(state, "bot"), "archmage");
|
||||
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.
|
||||
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||
}
|
||||
expect(torn + kept).toBe(12);
|
||||
expect(torn).toBeGreaterThan(0);
|
||||
expect(kept).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
|
||||
if (!id) continue;
|
||||
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
|
||||
// (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)`);
|
||||
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)`);
|
||||
}
|
||||
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
runCommand,
|
||||
seatTokenValid,
|
||||
SPECTATOR,
|
||||
type CatchUpStep,
|
||||
startGame,
|
||||
summarize,
|
||||
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.
|
||||
|
||||
interface ShareData {
|
||||
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
|
||||
steps: CatchUpStep[];
|
||||
actor: string;
|
||||
round: number;
|
||||
/** 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 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 {
|
||||
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 };
|
||||
data = { steps: reel.steps, actor: reel.owner, round: reel.round };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +220,7 @@ const httpServer = createServer((req, res) => {
|
||||
const data = shareData(watch[1]!);
|
||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||
if (watch[2]) {
|
||||
const last = data.steps[data.steps.length - 1]!;
|
||||
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
|
||||
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
||||
res.end(png);
|
||||
return;
|
||||
@@ -628,7 +628,7 @@ wss.on("connection", (socket) => {
|
||||
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" });
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
// 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 { state: fresh, events: dealt } = createGame(room.state.config);
|
||||
let current = fresh;
|
||||
// The deal's own events open the first turn — count them, or every
|
||||
// turn number would sit one behind the chronicle's.
|
||||
// The deal's own events open the first turn — count them so turn
|
||||
// numbers match the client chronicle's.
|
||||
let counter = -1;
|
||||
let owner: PlayerId | null = null;
|
||||
let round = 0;
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
/** Leafing through the face-up discard pile. */
|
||||
let showDiscards = $state(false);
|
||||
let chatDraft = $state("");
|
||||
let botTier = $state<string>(prefs.botTier);
|
||||
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
|
||||
/** Card whose official FAQ rulings are open. */
|
||||
let faqCardId = $state<string | null>(null);
|
||||
/** A discard-pile card enlarged above the pile. */
|
||||
@@ -1395,9 +1395,9 @@
|
||||
</label>
|
||||
<div class="pref-row">
|
||||
<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}
|
||||
onclick={() => { setPref("botTier", t as typeof prefs.botTier); botTier = t; }}>{t}</button>
|
||||
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="pref-row">
|
||||
@@ -1829,7 +1829,7 @@
|
||||
<span class="bot-row">
|
||||
<span class="bot-label">⚙ seat a</span>
|
||||
<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="adept">adept</option>
|
||||
<option value="archmage">archmage</option>
|
||||
|
||||
@@ -302,8 +302,7 @@
|
||||
}
|
||||
// Last resort, only for the reel's own wizard's step: wherever the
|
||||
// fx layer is about to play something visible — an impact, a
|
||||
// projectile's landing — the eyes should already be. This covers
|
||||
// every effect that exists and every one added later.
|
||||
// projectile's landing — the eyes should already be.
|
||||
if (step.actor === povId) {
|
||||
for (const e of step.events) {
|
||||
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
|
||||
@@ -516,9 +515,7 @@
|
||||
if (recorder) { recorder.stop(); return; }
|
||||
const cv = stageEl?.querySelector("canvas");
|
||||
if (!cv) return;
|
||||
// Recording means capturing the WHOLE reel: rewind to the top and
|
||||
// roll. (Hitting record at the end used to auto-stop a beat later —
|
||||
// a one-second clip of the final frame.)
|
||||
// Recording means capturing the WHOLE reel: rewind to the top and roll.
|
||||
idx = 0;
|
||||
const comp = document.createElement("canvas");
|
||||
comp.width = 1280;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import FirewallEdge from "./FirewallEdge.svelte";
|
||||
import IllusionShimmer from "./IllusionShimmer.svelte";
|
||||
import { FX_ART } from "./fpv/fx3d";
|
||||
import { MATERIALS } from "./fpv/textures";
|
||||
import { TERRAIN3D } from "./fpv/terrain3d";
|
||||
|
||||
const GROUPS: { title: string; files: string[] }[] = [
|
||||
@@ -96,7 +97,7 @@
|
||||
wherever a file is missing.
|
||||
</p>
|
||||
<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>
|
||||
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
|
||||
<figcaption>{name}</figcaption>
|
||||
|
||||
@@ -55,16 +55,6 @@
|
||||
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
|
||||
// independently paintable set — with the baked procedurals underneath
|
||||
// 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.
|
||||
const warpIdCol = new Int32Array(W).fill(-1);
|
||||
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
|
||||
// stands at the crossing — drawn after the sprites so bodies show
|
||||
// through it, shimmering so nobody mistakes it for stone.
|
||||
@@ -269,6 +262,7 @@
|
||||
if (hit.warpId !== undefined) {
|
||||
warpIdCol[col] = hit.warpId;
|
||||
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
|
||||
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
|
||||
}
|
||||
if (hit.ghost) {
|
||||
ghosts.push({
|
||||
@@ -289,9 +283,9 @@
|
||||
const top = half - wallH / 2;
|
||||
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.
|
||||
const texU = hit.kind === "firewall" || hit.kind === "warp"
|
||||
// Fire shifts its slice per world cell, so a blaze spanning edges
|
||||
// reads as one long fire, not a repeated flame.
|
||||
const texU = hit.kind === "firewall"
|
||||
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
|
||||
: hit.u;
|
||||
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
|
||||
@@ -316,11 +310,6 @@
|
||||
ctx.fillRect(col, top, 1, wallH);
|
||||
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") {
|
||||
// The same tell the board gives: an untested illusion's face
|
||||
// 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++) {
|
||||
if (s.depth >= zbuf[col]!) continue;
|
||||
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;
|
||||
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
|
||||
const texX = ((col - s.left) / (s.right - s.left));
|
||||
@@ -413,6 +402,22 @@
|
||||
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
|
||||
// will stand, glowing swirls running over the young face.
|
||||
for (const ri of risings) {
|
||||
@@ -521,7 +526,7 @@
|
||||
const bottom = half + wallH / 2 - b.rise * wallH;
|
||||
// A cell-bound volume is windowed to its own square: the billboard's
|
||||
// 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
|
||||
// skip this — their clip cell lives in another frame.)
|
||||
let clampL: number | undefined;
|
||||
|
||||
@@ -56,10 +56,9 @@
|
||||
const start = view.players.find((p) => p.id === povId)!.position;
|
||||
|
||||
// ?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 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
|
||||
// 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). */
|
||||
dist: number;
|
||||
/** 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). */
|
||||
u: number;
|
||||
/** Vertical faces get a different shade than horizontal ones. */
|
||||
@@ -26,11 +26,13 @@ export interface Hit {
|
||||
edge?: string;
|
||||
/** The eye reached this through a warp: haze it other-worldly. */
|
||||
warped?: boolean;
|
||||
/** Which warp (index in board.warps) the ray first bent through, and
|
||||
* how far along the ray that mouth stood — the gate a sprite must be
|
||||
* beyond (its own warp) or in front of (any) to paint this column. */
|
||||
/** Which warp (index in board.warps) the ray first bent through, how
|
||||
* far along the ray that mouth stood, and where across it — the gate a
|
||||
* 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;
|
||||
warpDist?: number;
|
||||
warpU?: number;
|
||||
/** 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" };
|
||||
@@ -62,8 +64,8 @@ export function warpLaneMirrored(from: Side, to: Side): boolean {
|
||||
const [x, y] = LANE_AXIS[from];
|
||||
const rx = x * Math.cos(d) - y * Math.sin(d);
|
||||
const ry = x * Math.sin(d) + y * Math.cos(d);
|
||||
const [ex2, ey] = LANE_AXIS[to];
|
||||
return rx * ex2 + ry * ey < 0;
|
||||
const [ux, uy] = LANE_AXIS[to];
|
||||
return rx * ux + ry * uy < 0;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* sheet. The slab gives every wall END a visible cap face, so corners and
|
||||
* 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" };
|
||||
}
|
||||
|
||||
/** 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(
|
||||
view: GameView, ox: number, oy: number, angle: number,
|
||||
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
|
||||
@@ -121,10 +126,12 @@ export function castRay(
|
||||
let warped = false;
|
||||
let warpId: number | undefined;
|
||||
let warpDist: number | undefined;
|
||||
let warpU: number | undefined;
|
||||
let ghost: Hit["ghost"];
|
||||
let doorway: Hit["doorway"];
|
||||
let rising: Hit["rising"];
|
||||
for (let traversal = 0; traversal < 3; traversal++) {
|
||||
let traversed = false;
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
let cx = Math.floor(ox);
|
||||
@@ -140,7 +147,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; frame?: boolean } | null = null;
|
||||
let best: SlabStrike | 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);
|
||||
@@ -188,13 +195,13 @@ 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; frame?: boolean };
|
||||
const b = best as SlabStrike;
|
||||
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, warpId, warpDist, ghost, doorway, rising,
|
||||
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
|
||||
...(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,
|
||||
);
|
||||
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 (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; }
|
||||
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; warpU = texU; }
|
||||
// Step through: re-enter at the paired mouth, heading inward, the
|
||||
// lane carried by the same proper rotation the heading turns by —
|
||||
// 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; }
|
||||
baseDist += dist;
|
||||
warped = true;
|
||||
i = 64; // restart the DDA from the far mouth
|
||||
break;
|
||||
traversed = true;
|
||||
break; // restart the DDA from the far mouth
|
||||
}
|
||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||
const sg = growing?.[cellKey({ x: nx, y: ny })];
|
||||
if (sg !== undefined && sg < 1) {
|
||||
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
|
||||
} 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;
|
||||
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
|
||||
@@ -436,8 +443,8 @@ export function billboards(
|
||||
} else if (content.kind === "safe") {
|
||||
out.push({
|
||||
...at, src: "/terrain3d/safe.png", fallback: "safe",
|
||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe",
|
||||
clip: { x: Math.floor(at.x), y: Math.floor(at.y) },
|
||||
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
|
||||
clip: { x: tx, y: ty },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,11 @@ const FACE: Record<string, [number, number, number]> = {
|
||||
stone: [96, 96, 104],
|
||||
door: [130, 92, 48],
|
||||
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 —
|
||||
// perspective-correct verticals for the price of a single drawImage.
|
||||
export const TEX = 64;
|
||||
const TEX = 64;
|
||||
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
|
||||
const t = document.createElement("canvas");
|
||||
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);
|
||||
});
|
||||
}
|
||||
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. */
|
||||
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||
function proceduralTextures(): Record<string, HTMLCanvasElement> {
|
||||
return {
|
||||
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
|
||||
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). */
|
||||
private momentTurn: number | null = null;
|
||||
private shareResolve: ((url: string) => void) | null = null;
|
||||
private shareReject: ((e: Error) => void) | null = null;
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
* after the first mark themselves seen while the tab is visible. */
|
||||
@@ -430,6 +431,7 @@ class Net {
|
||||
case "share":
|
||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||
this.shareResolve = null;
|
||||
this.shareReject = null;
|
||||
break;
|
||||
case "events": {
|
||||
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. */
|
||||
watch(roomId: string): void {
|
||||
this.you = null;
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.resetChronicle();
|
||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||
}
|
||||
|
||||
@@ -549,9 +549,7 @@ class Net {
|
||||
this.spectating = false;
|
||||
this.token = seat.token;
|
||||
this.roomIdPending = seat.roomId;
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.resetChronicle();
|
||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
}
|
||||
|
||||
@@ -658,9 +656,7 @@ class Net {
|
||||
this.view = null;
|
||||
this.started = false;
|
||||
this.players = [];
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.resetChronicle();
|
||||
this.token = null;
|
||||
this.spectating = false;
|
||||
this.audience = 0;
|
||||
@@ -698,6 +694,13 @@ class Net {
|
||||
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. */
|
||||
requestMoment(turn: number): void {
|
||||
this.momentTurn = turn;
|
||||
@@ -705,15 +708,19 @@ class Net {
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (turn === null) return reject(new Error("no turn open"));
|
||||
this.shareReject?.(new Error("superseded"));
|
||||
this.shareResolve = resolve;
|
||||
this.shareReject = reject;
|
||||
this.send({ type: "share", turn });
|
||||
setTimeout(() => {
|
||||
if (this.shareResolve === resolve) {
|
||||
this.shareResolve = null;
|
||||
this.shareReject = null;
|
||||
reject(new Error("share timed out"));
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -722,6 +729,7 @@ class Net {
|
||||
|
||||
closeMoment(): void {
|
||||
this.moment = null;
|
||||
this.momentTurn = null;
|
||||
}
|
||||
|
||||
command(command: Command): void {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
|
||||
spell moments through the air as **billboard sprites**: flat images that
|
||||
always face the camera, drawn glowing in mid-air, occluded by walls, and
|
||||
scaled by distance. Nine sprites cover every spell moment in the game.
|
||||
Each lives at `packages/web/public/textures/../fx3d/<name>.png` — replace
|
||||
the file, refresh, done. The current files are placeholder radial glows;
|
||||
all nine are shown side by side at `/?tokens` under "The conjurations."
|
||||
always face the camera, drawn in mid-air, occluded by walls, and scaled
|
||||
by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
|
||||
moment in the game. Each lives at `packages/web/public/fx3d/<name>.png`
|
||||
— replace the file, refresh, done. Missing files fall back to built-in
|
||||
procedural glows; the whole set is shown side by side at `/?tokens`
|
||||
under "The conjurations."
|
||||
|
||||
## 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
|
||||
renderer scales freely and pixelates on magnification (the whole view
|
||||
is deliberately chunky, like the wall textures).
|
||||
- **Drawn additively:** sprites are composited in "lighter" (additive)
|
||||
mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
|
||||
Paint light-on-transparent; pure black will be invisible. Midtones read
|
||||
as translucent light.
|
||||
- **Compositing:** painted sprites draw normally — inks stay true, dark
|
||||
outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
|
||||
light.) Impacts fade out by alpha as they swell.
|
||||
- **No animation frames.** Each sprite is a single still. Motion comes
|
||||
from the renderer: projectiles streak across the room, impacts swell
|
||||
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 |
|
||||
| `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 |
|
||||
| `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
|
||||
height). Impacts start small and swell to roughly double while fading —
|
||||
so an impact painted as a ring or burst reads especially well.
|
||||
height). Impacts start small and swell past triple while fading — so an
|
||||
impact painted as a ring or burst reads especially well.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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