Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.
Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.
Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4528c66057
commit
d2b40ec6f8
@@ -232,18 +232,22 @@ function thiefOfMine(view: GameView) {
|
||||
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
return Math.max(0, view.yourHand.length - 7);
|
||||
/** The hand limit as this seat experiences it (a displayed BRAINSTONE
|
||||
* raises it by two — mirrors the engine's handLimit). */
|
||||
function handLimitOf(view: GameView): number {
|
||||
return me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||
}
|
||||
|
||||
function overLimit(view: GameView): number {
|
||||
return Math.max(0, view.yourHand.length - handLimitOf(view));
|
||||
}
|
||||
|
||||
/** End the turn drawing fully — shedding dead weight first if the hand is
|
||||
* too full to take the draw. doEndTurn caps the draw by the room left, so a
|
||||
* clogged hand never refreshes; only cards the brain has no play for are
|
||||
* shed (good counters and attacks are hoarded, as a human would). */
|
||||
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
|
||||
const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||
const deficit = tier.draw - (limit - view.yourHand.length);
|
||||
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
|
||||
if (tier.sheds && deficit > 0) {
|
||||
const shed = [...view.yourHand]
|
||||
.filter((c) => discardValue(c) <= 3)
|
||||
|
||||
@@ -138,6 +138,10 @@ const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"]
|
||||
|
||||
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
|
||||
const PERMANENT_TURNS = 1_000_000_000;
|
||||
/** Whether a sustained duration means "until used/removed", not a count. */
|
||||
export function isPermanentDuration(turns: number): boolean {
|
||||
return turns >= PERMANENT_TURNS / 2;
|
||||
}
|
||||
|
||||
/** Which square contents block line of sight. */
|
||||
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
|
||||
@@ -4760,11 +4764,11 @@ function doCounteract(
|
||||
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
|
||||
if (!targetCounter) return err("no counteraction to nullify");
|
||||
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
|
||||
// (the card face; rules rev 6 — earlier games replay their old chains).
|
||||
if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") {
|
||||
return err("ANTI-ANTI does not work against escape");
|
||||
}
|
||||
if (targetCounter.card.cardId === "invisible") {
|
||||
// (the card face). Teleport is rev-6-gated — earlier games replay their
|
||||
// old chains; invisible needs no gate, since no earlier engine ever
|
||||
// accepted it as a counteraction for a ledger to contain.
|
||||
if (targetCounter.card.cardId === "invisible" ||
|
||||
((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) {
|
||||
return err("ANTI-ANTI does not work against escape");
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
@@ -4801,6 +4805,8 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult {
|
||||
stack.counters.some((c) =>
|
||||
!c.nullified &&
|
||||
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
||||
// Wall-of-fire counters pre-date the total-stop rule; their stored
|
||||
// exchanges bounced, and replay so (rules rev 12).
|
||||
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
|
||||
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
||||
if (!totalStop) {
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("automaton vs automaton", () => {
|
||||
});
|
||||
|
||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
|
||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||
// burn round 1
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -131,8 +131,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||
state = r.state;
|
||||
}
|
||||
const human = state.players.find((p: { id: string }) => p.id === "human")!;
|
||||
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
|
||||
const human = state.players.find((p) => p.id === "human")!;
|
||||
const bot = state.players.find((p) => p.id === "bot")!;
|
||||
bot.position = { ...human.position };
|
||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||
@@ -160,8 +160,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||
], (s, defender) => {
|
||||
const d = s.players.find((p: { id: string }) => p.id === defender)!;
|
||||
const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!;
|
||||
const d = s.players.find((p) => p.id === defender)!;
|
||||
const t = s.treasures.find((t) => t.owner !== defender)!;
|
||||
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
||||
});
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
|
||||
@@ -466,28 +466,23 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
||||
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
||||
remainingTurns: 3, data: {},
|
||||
});
|
||||
return { state, me, victim, trollId: bones.id };
|
||||
return { state, me, victim, skeletonId: bones.id };
|
||||
}
|
||||
|
||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||
// Deterministic seed: this particular swing misses and the blow dissipates.
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(13);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
const rolled = state.rng;
|
||||
expect(rolled).toBeDefined();
|
||||
const after = state.players.find((p) => p.id === victim.id)!;
|
||||
const missed = after.life === lifeBefore;
|
||||
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
|
||||
expect(missed || after.life < lifeBefore).toBe(true);
|
||||
// The die was consumed, whichever way it landed.
|
||||
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||
});
|
||||
|
||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
||||
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
|
||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||
const rngBefore = JSON.stringify(state.rng);
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
||||
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||
state = must(state, victim.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||
// No die was consumed on the way.
|
||||
|
||||
@@ -193,22 +193,25 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
||||
*/
|
||||
const BOT_STEP_MS = 1000;
|
||||
|
||||
/** What a step's event means to this bot — its own deed when it acted,
|
||||
* its own suffering when somebody else did. Null: nothing worth a word. */
|
||||
/** What a step's event means to this bot — its own deeds when it acted,
|
||||
* its own suffering whoever caused it. Null: nothing worth a word. */
|
||||
function banterTrigger(
|
||||
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
||||
): BanterTrigger | null {
|
||||
if (actor === seat) {
|
||||
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
||||
if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
|
||||
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
|
||||
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
||||
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
||||
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
||||
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||
}
|
||||
// A counter-teleport escape resolves during the ATTACKER's step, and a
|
||||
// last-standing win can land on the victim's turn: self-referential
|
||||
// triggers hold whoever acted.
|
||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||
if (e.type === "damaged" && e.player === seat) return "takePain";
|
||||
if (e.type === "died" && e.player === seat) return "die";
|
||||
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
||||
|
||||
+18
-12
@@ -7,10 +7,10 @@
|
||||
import Help from "./Help.svelte";
|
||||
import Replay from "./Replay.svelte";
|
||||
import FxGallery from "./FxGallery.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
@@ -37,22 +37,28 @@
|
||||
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
|
||||
}
|
||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||
const fxWorkshop = new URLSearchParams(location.search).has("fx");
|
||||
let attackNoticeSeen = $state<string | null>(null);
|
||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
function playFx(events: Parameters<typeof fxForEvents>[0]) {
|
||||
let fxCancels: (() => void)[] = [];
|
||||
function playFx(events: Parameters<typeof scheduleFx>[0]) {
|
||||
if (!view) return;
|
||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||
setTimeout(() => {
|
||||
boardFx = [...boardFx, fx];
|
||||
setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind));
|
||||
}, delay);
|
||||
}
|
||||
fxCancels.push(scheduleFx(
|
||||
events, view,
|
||||
(fx) => (boardFx = [...boardFx, fx]),
|
||||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||
));
|
||||
}
|
||||
$effect(() => {
|
||||
net.onFx = playFx;
|
||||
local.onFx = playFx;
|
||||
return () => { net.onFx = null; local.onFx = null; };
|
||||
return () => {
|
||||
net.onFx = null;
|
||||
local.onFx = null;
|
||||
fxCancels.forEach((c) => c());
|
||||
fxCancels = [];
|
||||
};
|
||||
});
|
||||
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
||||
function dismissRolls() {
|
||||
@@ -906,7 +912,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if new URLSearchParams(location.search).has("fx")}
|
||||
{#if fxWorkshop}
|
||||
<FxGallery />
|
||||
{:else}
|
||||
<div class="table">
|
||||
@@ -1413,7 +1419,7 @@
|
||||
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
||||
peekCreatureId = null;
|
||||
}}>
|
||||
✦ {spellName(e.cardId)}{e.remainingTurns < 9000 ? ` · ${e.remainingTurns}` : ""}
|
||||
✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import TokenArt from "./TokenArt.svelte";
|
||||
@@ -51,11 +53,10 @@
|
||||
ghostSlots?: { x: number; y: number }[] | null;
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: import("./fx").BoardFx[] | null;
|
||||
effects?: BoardFx[] | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
* negative coordinates (the maze renormalizes after the landing). The
|
||||
@@ -587,8 +588,9 @@
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
<Sprite fx={fx as never} />
|
||||
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
|
||||
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||
<Sprite {fx} />
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
@@ -749,57 +751,6 @@
|
||||
stroke-width: 1.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
||||
.fx-dust.late { animation-delay: 0.09s; }
|
||||
.fx-dust.later { animation-delay: 0.17s; }
|
||||
|
||||
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes fx-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
@keyframes fx-ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes fx-ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
@keyframes fx-shield-pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
@keyframes fx-drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
@keyframes fx-drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
@keyframes fx-pow-hit {
|
||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
||||
55% { transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(1.05); }
|
||||
}
|
||||
@keyframes fx-claw-rake {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
20% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
@keyframes fx-swallow {
|
||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
||||
}
|
||||
@keyframes fx-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
// The flourish workshop: every effect sprite on a loop, labeled, against a
|
||||
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
|
||||
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
|
||||
// file hot-reload here mid-animation.
|
||||
import type { Component } from "svelte";
|
||||
import { FX_SPRITES } from "./fx-sprites";
|
||||
import type { BoardFx } from "./fx";
|
||||
import type { BoardFx, FxShape } from "./fx";
|
||||
|
||||
let tick = $state(0);
|
||||
$effect(() => {
|
||||
@@ -42,7 +43,7 @@
|
||||
{ kind: "edge-dust", cell: mid, side: "S" },
|
||||
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
|
||||
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
|
||||
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
|
||||
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
|
||||
|
||||
const isSector = (k: string) => k.startsWith("sector-");
|
||||
</script>
|
||||
@@ -55,7 +56,7 @@
|
||||
</p>
|
||||
<div class="grid">
|
||||
{#each SAMPLES as fx (fx.kind)}
|
||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
||||
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||
<figure>
|
||||
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
|
||||
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
|
||||
@@ -64,7 +65,7 @@
|
||||
{/each}
|
||||
{/each}
|
||||
{#key tick}
|
||||
<g class="fx-layer"><Sprite fx={fx as never} /></g>
|
||||
<g class="fx-layer"><Sprite {fx} /></g>
|
||||
{/key}
|
||||
</svg>
|
||||
<figcaption>{fx.kind}</figcaption>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -26,14 +26,12 @@
|
||||
$effect(() => {
|
||||
const step = steps[Math.min(idx, steps.length - 1)];
|
||||
if (!step) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
||||
timers.push(setTimeout(() => {
|
||||
boardFx = [...boardFx, fx];
|
||||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
}
|
||||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
||||
const cancel = scheduleFx(
|
||||
step.events, step.view,
|
||||
(fx) => (boardFx = [...boardFx, fx]),
|
||||
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||
);
|
||||
return () => { cancel(); boardFx = []; };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// are cached per URL, with their ids namespaced so 45 tokens' worth of
|
||||
// filter/pattern defs cannot collide in one document.
|
||||
type Entry = { vb: string; inner: string };
|
||||
const cache = new SvelteMap<string, Entry | "pending">();
|
||||
const cache = new SvelteMap<string, Entry | "pending" | "failed">();
|
||||
|
||||
function load(href: string): void {
|
||||
if (cache.has(href)) return;
|
||||
@@ -24,7 +24,9 @@
|
||||
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
|
||||
cache.set(href, { vb, inner: m?.[2] ?? "" });
|
||||
})
|
||||
.catch(() => cache.delete(href));
|
||||
// A failed fetch stays failed: deleting would retrigger the effect
|
||||
// and hammer a missing file forever.
|
||||
.catch(() => cache.set(href, "failed"));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
{#if isSvg}
|
||||
{#if entry && entry !== "pending"}
|
||||
{#if entry && entry !== "pending" && entry !== "failed"}
|
||||
<svg {x} {y} {width} {height} viewBox={entry.vb}
|
||||
preserveAspectRatio="xMidYMid slice" class={cls}>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
.bolt .fork {
|
||||
fill: none;
|
||||
stroke: #fff6b0;
|
||||
stroke: #f5d54a;
|
||||
stroke-width: 1.6;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@@ -61,7 +61,6 @@
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.bolt .fork { stroke: #f5d54a; }
|
||||
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
|
||||
@keyframes bolt-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
</script>
|
||||
|
||||
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
|
||||
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff" />
|
||||
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
|
||||
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
|
||||
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff later" />
|
||||
<g class="grit">
|
||||
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
|
||||
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
|
||||
// register it here.
|
||||
import type { Component } from "svelte";
|
||||
import type { BoardFx } from "../fx";
|
||||
import type { BoardFx, FxOf } from "../fx";
|
||||
import Absorb from "./Absorb.svelte";
|
||||
import Bolt from "./Bolt.svelte";
|
||||
import Burst from "./Burst.svelte";
|
||||
@@ -29,7 +29,7 @@ import ThornSnap from "./ThornSnap.svelte";
|
||||
import Waterbolt from "./Waterbolt.svelte";
|
||||
import Whiff from "./Whiff.svelte";
|
||||
|
||||
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> } = {
|
||||
fireball: Fireball,
|
||||
waterbolt: Waterbolt,
|
||||
bolt: Bolt,
|
||||
@@ -58,4 +58,4 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
||||
"edge-dust": DustPuff,
|
||||
"sector-spin": SectorGrind,
|
||||
"sector-slide": SectorGrind,
|
||||
} as never;
|
||||
};
|
||||
|
||||
+24
-3
@@ -9,12 +9,12 @@ type Cell = { x: number; y: number };
|
||||
type Pt = { x: number; y: number };
|
||||
type Side = "N" | "E" | "S" | "W";
|
||||
|
||||
const CELL = 48;
|
||||
import { CELL } from "./fx-sprites/geom";
|
||||
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
|
||||
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
|
||||
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
|
||||
|
||||
type FxShape =
|
||||
export type FxShape =
|
||||
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
|
||||
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
||||
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
||||
@@ -134,7 +134,7 @@ export function fxForEvents(
|
||||
break;
|
||||
case "creatureAttacked": {
|
||||
// The target may be a wizard or a fellow creature.
|
||||
const at = posOf(typeof e.target === "string" ? e.target : null) ??
|
||||
const at = posOf(e.target) ??
|
||||
(() => {
|
||||
const c = view.creatures.find((c) => c.id === e.target);
|
||||
return c ? { ...c.position } : null;
|
||||
@@ -306,3 +306,24 @@ export function fxForEvents(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Schedule a batch's effects into `add`, expiring each after its run.
|
||||
* Returns a cancel that stops pending starts and sweeps what began. */
|
||||
export function scheduleFx(
|
||||
events: GameEvent[], view: GameView,
|
||||
add: (fx: BoardFx) => void, remove: (id: number) => void,
|
||||
): () => void {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
add(fx);
|
||||
timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind)));
|
||||
}, delay));
|
||||
}
|
||||
return () => {
|
||||
timers.forEach(clearTimeout);
|
||||
started.forEach(remove);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ class LocalGame {
|
||||
/** Set while the device should be handed to the named player. */
|
||||
handoffTo = $state<PlayerId | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate command results. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** The finished game as a reel, each step from its actor's own seat. */
|
||||
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||
view = $derived(
|
||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||
|
||||
@@ -250,11 +250,11 @@ class Net {
|
||||
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
|
||||
/** Moves you haven't watched yet in the current room. */
|
||||
missedMoves = $state(0);
|
||||
/** A catch-up reel delivered by the server. */
|
||||
/** The opening roll-off, shown once as the boards flip. */
|
||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||
/** A catch-up reel delivered by the server. */
|
||||
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||
private seen: Record<string, number> = loadSeen();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
@@ -326,8 +326,8 @@ class Net {
|
||||
// Only the FIRST state after arriving carries a gap worth
|
||||
// announcing. Later states were watched live: a caught-up
|
||||
// watcher stays caught up, and an announced gap stays FROZEN
|
||||
// (not grown, not wiped) until watched or skipped. A hidden
|
||||
// tab accumulates its gap honestly.
|
||||
// until watched or skipped. A hidden tab accumulates its gap
|
||||
// honestly.
|
||||
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
||||
if (this.missedMoves === 0) this.markSeen();
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user