A cast that resolved at once draws the sight line it was accepted on

The attack on the stack has always shown its line; a creation or a
curse resolved in the same breath showed nothing, so a legal aim
through a Visionstone and out a lettered opening (PPL7, move 309)
looked like a cheat. The line a cast was accepted on — plain, through
the one wall a VISIONSTONE dissolves, or AROUND THE CORNER's two legs —
now draws on the live board for a few seconds and on that move of any
reel, traced after the fact so the cast's own dust cloud cannot blind it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 23:05:32 -04:00
co-authored by Claude Fable 5.1
parent 12a655868b
commit 7d0c1c68a3
3 changed files with 112 additions and 42 deletions
+17 -2
View File
@@ -18,7 +18,7 @@
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, castSightTrace, type GameView, type SightLine, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
@@ -176,6 +176,16 @@
}
function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return;
// The events arrive before the state that follows them: the view here
// is the board as the caster saw it, dust and all.
for (const e of events) {
if (e.type !== "spellCast") continue;
const line = castSightTrace(view, e, events);
if (!line) continue;
castTrace = line;
if (castTraceTimer) clearTimeout(castTraceTimer);
castTraceTimer = setTimeout(() => { if (castTrace === line) castTrace = null; }, 4500);
}
for (const e of events) {
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) {
@@ -1640,7 +1650,12 @@
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
// While an LOS attack sits on the stack, draw the line it traveled — the
// answer to "how can he even see me?" when sight ran through a warp mouth.
const sightTrace = $derived(view ? stackSightTrace(view) : null);
/** The attack on the stack draws its sight line; a cast that resolved
* at once (a creation, a curse) draws the line it was accepted on for a
* few seconds, so the table can see how the aim was legal. */
let castTrace = $state<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
/** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
+14 -2
View File
@@ -9,7 +9,7 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -187,7 +187,19 @@
// The reel draws the same sight line the live table shows for an LOS
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
/** The attack's line, or the line a cast in this step was accepted on —
* traced after the fact, so the cast's own dust cloud does not blind it. */
const sightTrace = $derived.by(() => {
const onStack = stackSightTrace(step.view);
if (onStack) return onStack;
for (let i = step.events.length - 1; i >= 0; i--) {
const e = step.events[i]!;
if (e.type !== "spellCast") continue;
const line = castSightTrace(step.view, e, step.events, true);
if (line) return line;
}
return null;
});
/** Every wall destroyed so far in the reel leaves a mound of rubble on
* the first-person floor for the rest of it. */