Idiot enforces its card (rev 23) + attack sight-line tracing
Rules rev 23 — IDIOT, per the card and FAQ: - No handling items: pick up / drop of treasures and objects refused (dropping was the exploit: capturing a stolen treasure on your own home, or dropping your own treasure underfoot for an instant cure) - No punching, no thrown dagger / large rock (attacks on players) - No effect on a victim already carrying one of their own treasures Ungated (permissive): counteractions are now castable while idiotized (the one thing the card expressly allows — the gate wrongly blocked them), and goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per the FAQ's 'you could, however, destroy a wall'. Sight tracing: while an LOS attack sits on the stack, the board draws the line it traveled — straight when direct, leg by leg through both warp mouths (with pulsing rings) when the maze's wraparound carried it. Engine traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown live and in replays. Verified against H3PC's cross-board Idiot cast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
co-authored by
Claude Fable 5
parent
7372bfc5e8
commit
cdeb3d5103
@@ -360,6 +360,41 @@ export function hasWarpLineOfSight(
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
return warpSightTrace(board, from, to, blockedCells) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a sight line reached its target — the material for drawing it. A warp
|
||||
* trace carries the two mouths and the exact rim-crossing points (in board
|
||||
* coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the
|
||||
* near leg to `entry` and the far leg from `exit`.
|
||||
*/
|
||||
export type SightTrace =
|
||||
| { kind: "direct" }
|
||||
| {
|
||||
kind: "warp";
|
||||
mouthA: { cell: Cell; side: Side };
|
||||
mouthB: { cell: Cell; side: Side };
|
||||
entry: { x: number; y: number };
|
||||
exit: { x: number; y: number };
|
||||
};
|
||||
|
||||
export function traceSight(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): SightTrace | null {
|
||||
if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" };
|
||||
return warpSightTrace(board, from, to, blockedCells);
|
||||
}
|
||||
|
||||
function warpSightTrace(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): Extract<SightTrace, { kind: "warp" }> | null {
|
||||
const DIR: Record<Side, { x: number; y: number }> = {
|
||||
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
|
||||
};
|
||||
@@ -423,10 +458,16 @@ export function hasWarpLineOfSight(
|
||||
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
|
||||
[cellKey(to), cellKey(mouthB)])
|
||||
) {
|
||||
return true;
|
||||
return {
|
||||
kind: "warp",
|
||||
mouthA: { cell: mouthA, side: sideA },
|
||||
mouthB: { cell: mouthB, side: w.to.side },
|
||||
entry: P,
|
||||
exit: Pfar,
|
||||
};
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
|
||||
|
||||
@@ -2227,6 +2227,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
const hasTreasureOut = ctx.state.treasures.some((t) => t.owner === ctx.defender.id && t.position);
|
||||
if (!hasTreasureOut) return; // "ends if both treasures are being carried"
|
||||
// FAQ: "If you happen to already be carrying one of your treasures
|
||||
// when you are hit with this spell, it has no effect on you."
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 23) {
|
||||
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
|
||||
if (carried?.owner === ctx.defender.id) return;
|
||||
}
|
||||
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
|
||||
},
|
||||
},
|
||||
@@ -3987,10 +3993,43 @@ function attackPreconditions(state: GameState): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function castingBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||
/**
|
||||
* IDIOT's FAQ carves out spells that "help you in your goal" of reaching the
|
||||
* treasure — barrier-removal, passage, and movement boosts. Attacks and
|
||||
* everything aimed at other players stay forbidden.
|
||||
*/
|
||||
const IDIOT_AIDS = new Set([
|
||||
"destroy-wall", "pass-through-wall", "teleport", "power-run", "mad-dash",
|
||||
"speed", "mist-body", "flight", "exploding-door", "stone-to-water",
|
||||
"create-door", "vampire", "werewolf",
|
||||
]);
|
||||
|
||||
function castingBlocked(
|
||||
state: GameState, playerId: PlayerId,
|
||||
opts?: { counteraction?: boolean; cardId?: string },
|
||||
): string | null {
|
||||
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
|
||||
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
|
||||
if (sustainedOn(state, playerId, "idiot").length > 0) return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
|
||||
// needed)" — and per the FAQ, spells that aid the march to the treasure.
|
||||
if (sustainedOn(state, playerId, "idiot").length > 0 &&
|
||||
!opts?.counteraction && !(opts?.cardId && IDIOT_AIDS.has(opts.cardId))) {
|
||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDIOT forbids handling items and attacking players, not just casting:
|
||||
* "it does not allow you to attack players or pick up other items".
|
||||
* Dropping is the sharp edge — an idiot could otherwise capture a stolen
|
||||
* treasure on their own home, or drop their own treasure underfoot for an
|
||||
* instant cure.
|
||||
*/
|
||||
function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||
if ((state.config.deckRev ?? 1) >= 23 && sustainedOn(state, playerId, "idiot").length > 0) {
|
||||
return "What am I doing here...? (you can do nothing but head for your treasure)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4131,7 +4170,7 @@ function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||
}
|
||||
|
||||
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
const pre = attackPreconditions(prev);
|
||||
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
|
||||
if (pre) return err(pre);
|
||||
|
||||
const state = clone(prev);
|
||||
@@ -4348,11 +4387,16 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
// NO SPELL cast on you."
|
||||
const isSpell = def.cardType !== "object" && !NOT_SPELLS.has(inHand.cardId) && !isWand;
|
||||
if (isSpell) {
|
||||
const castBlock = castingBlocked(state, caster.id);
|
||||
const castBlock = castingBlocked(state, caster.id, { cardId: inHand.cardId });
|
||||
if (castBlock) return err(castBlock);
|
||||
} else if (sustainedOn(state, caster.id, "medusa").length > 0) {
|
||||
return err("you are paralyzed by Medusa");
|
||||
}
|
||||
// Thrown objects are attacks on players — not among IDIOT's permitted aids.
|
||||
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
|
||||
const ib = idiotBlocked(state, caster.id);
|
||||
if (ib) return err(ib);
|
||||
}
|
||||
|
||||
const mods = gatherModifiers(caster, cmd);
|
||||
if (typeof mods === "string") return err(mods);
|
||||
@@ -4855,8 +4899,9 @@ function doCounteract(
|
||||
const def = cardDef(card.cardId);
|
||||
|
||||
// "Opponent cannot move or cast spells, including COUNTERACTIONs" (MEDUSA);
|
||||
// NO SPELL blocks all spells too.
|
||||
const castBlock = castingBlocked(state, playerId);
|
||||
// NO SPELL blocks all spells too. IDIOT does not — counteractions are the
|
||||
// one thing its victim is expressly allowed.
|
||||
const castBlock = castingBlocked(state, playerId, { counteraction: true });
|
||||
if (castBlock) return err(castBlock);
|
||||
|
||||
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
|
||||
@@ -5431,7 +5476,7 @@ function homeOwnerAt(state: GameState, cell: Cell): PlayerId | null {
|
||||
// --- Treasures ---------------------------------------------------------------
|
||||
|
||||
function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
|
||||
if (blocked) return err(blocked);
|
||||
|
||||
const state = clone(prev);
|
||||
@@ -5480,7 +5525,7 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
|
||||
}
|
||||
|
||||
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
const blocked = requireActionsAvailable(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
|
||||
if (blocked) return err(blocked);
|
||||
|
||||
const state = clone(prev);
|
||||
@@ -5523,6 +5568,8 @@ export function isMovableObject(cardId: string): boolean {
|
||||
}
|
||||
|
||||
function doDropObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const ib = idiotBlocked(prev, activePlayer(prev).id);
|
||||
if (ib) return err(ib);
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const card = p.hand.find((c) => c.instanceId === instanceId);
|
||||
@@ -5539,6 +5586,8 @@ function doDropObject(prev: GameState, instanceId: string): CommandResult {
|
||||
}
|
||||
|
||||
function doDropTreasure(prev: GameState): CommandResult {
|
||||
const ib = idiotBlocked(prev, activePlayer(prev).id);
|
||||
if (ib) return err(ib);
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
if (!p.carriedTreasureId) return err("you are not carrying a treasure");
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// The server sends this after every state change; clients never see the
|
||||
// deck order or other players' hands.
|
||||
|
||||
import { sightBetween, type AssembledBoard } from "./board";
|
||||
import { type CardInstance } from "./cards";
|
||||
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
|
||||
import { cardDef, type CardInstance } from "./cards";
|
||||
import {
|
||||
boardView,
|
||||
LOS_BLOCKING_CONTENT,
|
||||
@@ -173,6 +173,16 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const me = view.players.find((p) => p.id === view.you);
|
||||
if (!me) return out;
|
||||
const { board, blockers } = sightBasis(view);
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The board-as-seen and sight blockers this view's sight rules run against. */
|
||||
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||
// Held-open doors are open doorways to the eye (rules rev 15).
|
||||
let board = view.board;
|
||||
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
|
||||
@@ -189,11 +199,38 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
||||
blockers[`${p.position.x},${p.position.y}`] = true;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(board.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
return { board, blockers };
|
||||
}
|
||||
|
||||
/**
|
||||
* How one square sees another under this viewer's knowledge of the board
|
||||
* (believed illusion walls block; held doors admit). Null when no sight
|
||||
* exists — the renderer's material for drawing the line an attack traveled.
|
||||
*/
|
||||
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
|
||||
const { board, blockers } = sightBasis(view);
|
||||
return traceSight(board, from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sight line behind the attack currently on the stack — the board's
|
||||
* answer to "how can he even see me?". Null when nothing should draw:
|
||||
* no stack, a creature's or physical attack, a non-LOS card, attacker and
|
||||
* defender sharing a square, or no sight under this viewer's knowledge
|
||||
* (a believed illusion wall can honestly hide the line).
|
||||
*/
|
||||
export function stackSightTrace(
|
||||
view: GameView,
|
||||
): { from: Cell; to: Cell; trace: SightTrace } | null {
|
||||
const stack = view.stack;
|
||||
if (!stack || stack.creatureId) return null;
|
||||
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
|
||||
const a = view.players.find((p) => p.id === stack.attackerId);
|
||||
const d = view.players.find((p) => p.id === stack.defenderId);
|
||||
if (!a || !d) return null;
|
||||
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
|
||||
const trace = traceSightFor(view, a.position, d.position);
|
||||
return trace ? { from: a.position, to: d.position, trace } : null;
|
||||
}
|
||||
|
||||
const CREATION_CARD_IDS = new Set([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
|
||||
import { cellKey, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
||||
@@ -161,6 +161,58 @@ describe("expansion combat cards", () => {
|
||||
expect(r.error).toMatch(/blocked/);
|
||||
}
|
||||
});
|
||||
|
||||
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
// The victim carries an OPPONENT'S treasure into the spell.
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||
stolen.carriedBy = defender;
|
||||
stolen.position = null;
|
||||
d.carriedTreasureId = stolen.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
|
||||
// No dropping the carried treasure (no capturing it on your home, either).
|
||||
const drop = applyCommand(state, defender, { type: "dropTreasure" });
|
||||
expect(drop.ok).toBe(false);
|
||||
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
|
||||
// No punching the tormentor.
|
||||
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
|
||||
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
|
||||
const sp = giveCard(state, defender, "speed", "SP", 0);
|
||||
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
|
||||
// Counteractions are expressly allowed: absorb an incoming fireball.
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
giveCard(state, defender, "absorb-spell", "AB", 1);
|
||||
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
|
||||
let { state } = createGame({
|
||||
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
|
||||
});
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const own = state.treasures.find((t) => t.owner === defender)!;
|
||||
own.carriedBy = defender;
|
||||
own.position = null;
|
||||
d.carriedTreasureId = own.id;
|
||||
const id = giveCard(state, attacker, "idiot");
|
||||
state = castAt(state, attacker, defender, id);
|
||||
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap home bases", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, type Cell } from "../src/board";
|
||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
@@ -463,3 +463,40 @@ describe("junction alterations roll for their sector (rules rev 20)", () => {
|
||||
expect(createdKeys.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sight tracing", () => {
|
||||
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
|
||||
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
||||
const board = boardView(state);
|
||||
const cells = Object.keys(board.cells).map((k) => {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
return { x, y };
|
||||
});
|
||||
let warped = 0;
|
||||
for (const from of cells) {
|
||||
for (const to of cells) {
|
||||
const t = traceSight(board, from, to);
|
||||
expect(t !== null).toBe(sightBetween(board, from, to));
|
||||
if (!t) continue;
|
||||
if (hasLineOfSight(board, from, to)) {
|
||||
expect(t.kind).toBe("direct");
|
||||
} else {
|
||||
expect(t.kind).toBe("warp");
|
||||
if (t.kind === "warp") {
|
||||
warped++;
|
||||
// Each crossing point lies on its mouth's one-cell rim segment.
|
||||
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
|
||||
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
|
||||
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
|
||||
};
|
||||
expect(onRim(t.entry, t.mouthA)).toBe(true);
|
||||
expect(onRim(t.exit, t.mouthB)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(warped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
||||
const RULES_REV = 22;
|
||||
const RULES_REV = 23;
|
||||
|
||||
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
import { local } from "./local.svelte";
|
||||
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
@@ -854,6 +854,10 @@
|
||||
t.position.y === me.position.y && !t.carriedBy),
|
||||
);
|
||||
/** 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);
|
||||
|
||||
const litCells = $derived.by(() => {
|
||||
if (!view || !selectedDef || !yourMoment) return null;
|
||||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||||
@@ -1386,6 +1390,7 @@
|
||||
onGhostClick={clickGhostSlot}
|
||||
onCellPeek={(c) => peekAt(c)}
|
||||
{litCells}
|
||||
{sightTrace}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import type { GameView, SightTrace } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
@@ -28,6 +28,7 @@
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
sightTrace = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -54,6 +55,9 @@
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: BoardFx[] | null;
|
||||
/** The sight line an attack in progress traveled — proof against "how
|
||||
* can he even see me?", drawn leg by leg through any warp mouth. */
|
||||
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -601,6 +605,23 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
|
||||
{#if sightTrace}
|
||||
{@const A = { x: sightTrace.from.x * CELL + CELL / 2, y: sightTrace.from.y * CELL + CELL * 0.36 }}
|
||||
{@const B = { x: sightTrace.to.x * CELL + CELL / 2, y: sightTrace.to.y * CELL + CELL * 0.36 }}
|
||||
<g class="sight-layer" aria-hidden="true">
|
||||
{#if sightTrace.trace.kind === "direct"}
|
||||
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="sight-line" />
|
||||
{:else}
|
||||
{@const entry = { x: sightTrace.trace.entry.x * CELL, y: sightTrace.trace.entry.y * CELL }}
|
||||
{@const exit = { x: sightTrace.trace.exit.x * CELL, y: sightTrace.trace.exit.y * CELL }}
|
||||
<line x1={A.x} y1={A.y} x2={entry.x} y2={entry.y} class="sight-line" />
|
||||
<line x1={exit.x} y1={exit.y} x2={B.x} y2={B.y} class="sight-line" />
|
||||
<circle cx={entry.x} cy={entry.y} r={7} class="sight-mouth" />
|
||||
<circle cx={exit.x} cy={exit.y} r={7} class="sight-mouth" />
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
@@ -757,6 +778,24 @@
|
||||
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
||||
|
||||
.fx-layer { pointer-events: none; }
|
||||
.sight-layer { pointer-events: none; }
|
||||
.sight-line {
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 7 6;
|
||||
opacity: 0.85;
|
||||
animation: sight-march 0.8s linear infinite;
|
||||
}
|
||||
.sight-mouth {
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 2;
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes sight-march {
|
||||
to { stroke-dashoffset: -13; }
|
||||
}
|
||||
.mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); }
|
||||
.mover.snap { transition: none; }
|
||||
|
||||
@@ -783,6 +822,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.sight-line, .sight-mouth { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
.firewall, :global(.token-art.ghosted) { animation: none; }
|
||||
.mover { transition: none; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Board from "./Board.svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { stackSightTrace } from "@wizwar/engine";
|
||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||
|
||||
let {
|
||||
@@ -21,6 +22,10 @@
|
||||
);
|
||||
const atEnd = $derived(idx >= steps.length - 1);
|
||||
|
||||
// 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));
|
||||
|
||||
/** Each step's spells flare on the reel exactly as they did at the table. */
|
||||
let boardFx = $state<BoardFx[]>([]);
|
||||
$effect(() => {
|
||||
@@ -61,7 +66,7 @@
|
||||
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
|
||||
</header>
|
||||
<div class="replay-board">
|
||||
<Board view={step.view} effects={boardFx} />
|
||||
<Board view={step.view} effects={boardFx} {sightTrace} />
|
||||
</div>
|
||||
<div class="replay-caption">
|
||||
<strong>{step.actor}</strong>
|
||||
|
||||
@@ -136,7 +136,7 @@ class LocalGame {
|
||||
seed,
|
||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||
...(colors ? { colors } : {}),
|
||||
deckRev: 22,
|
||||
deckRev: 23,
|
||||
};
|
||||
const { state, events } = createGame(config);
|
||||
for (const e of events) {
|
||||
|
||||
Reference in New Issue
Block a user