Card wave 3: terrain, thrown objects, drag, and control spells
Terrain layer: FILL SQUARE WITH STONE (impassable, blocks LOS via new cell-blocking sight checks), THORNBUSH (enter = 1 damage + turn ends + next turn lost; no attacking in or into a bush), WALL OF FIRE (new firewall edge state — passable for 4 magical damage, blocks LOS, expires with its duration), WATERWALL (instant wave: players within two spaces washed back two, 1 damage per blocked space), and DISPEL CREATION with provenance tracking (only conjured walls/fire/stone/ bushes dispel — printed maze is safe). Objects: DAGGER (3) and LARGE ROCK (2) are physical throws Full Shield cannot stop; they land on the floor and anyone may pick them up (ending their turn's actions, hand limit enforced); DROP OBJECT forces a named object or carried treasure to the ground; DRAG pulls floor objects, treasures, or players straight toward the caster. Control: LOCK IN PLACE (no moving or being moved — teleports, swaps, knockbacks and drags all respect it), BUDDY (a pact the caster breaks by attacking), MIST-BODY (through walls and doors, cannot attack or be attacked, still burns in firewalls), REUSE SPELL (retrieve your last spell). Client renders terrain, firewalls, and ground objects, with cell/edge/two-stage targeting and card-name inputs. 40 cards implemented; 63 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
67743dd17e
commit
2d88b4ab40
@@ -8,7 +8,7 @@ import boardsData from "../data/boards.json";
|
||||
|
||||
export type Cell = { readonly x: number; readonly y: number };
|
||||
export type Side = "N" | "S" | "E" | "W";
|
||||
export type EdgeState = "open" | "wall" | "door";
|
||||
export type EdgeState = "open" | "wall" | "door" | "firewall";
|
||||
export type Rotation = 0 | 90 | 180 | 270;
|
||||
|
||||
export interface SectorPlacement {
|
||||
@@ -252,17 +252,40 @@ export function stepTarget(
|
||||
|
||||
/**
|
||||
* Line of sight from the center of `from` to the center of `to`, blocked by
|
||||
* wall/door edges the segment crosses. Grazing a wall endpoint (passing
|
||||
* exactly through a corner adjacent to a wall) counts as blocked — strict
|
||||
* reading; revisit against FAQ rulings if needed. LOS through wraparound
|
||||
* openings is not yet modeled (TODO).
|
||||
* wall/door/firewall edges the segment crosses and by any `blockedCells`
|
||||
* (solid stone, thornbushes) it passes through. Grazing a wall endpoint
|
||||
* (passing exactly through a corner adjacent to a wall) counts as blocked —
|
||||
* strict reading; revisit against FAQ rulings if needed. LOS through
|
||||
* wraparound openings is not yet modeled (TODO).
|
||||
*/
|
||||
export function hasLineOfSight(board: AssembledBoard, from: Cell, to: Cell): boolean {
|
||||
export function hasLineOfSight(
|
||||
board: AssembledBoard,
|
||||
from: Cell,
|
||||
to: Cell,
|
||||
blockedCells?: Record<string, true>,
|
||||
): boolean {
|
||||
if (cellKey(from) === cellKey(to)) return true;
|
||||
// Centers of cells: (x + 0.5, y + 0.5).
|
||||
const x0 = from.x + 0.5, y0 = from.y + 0.5;
|
||||
const x1 = to.x + 0.5, y1 = to.y + 0.5;
|
||||
|
||||
if (blockedCells) {
|
||||
for (const key of Object.keys(blockedCells)) {
|
||||
const [bx, by] = key.split(",").map(Number) as [number, number];
|
||||
if ((bx === from.x && by === from.y) || (bx === to.x && by === to.y)) continue;
|
||||
// The sight line is blocked if it crosses any side of the solid cell.
|
||||
const sides: [number, number, number, number][] = [
|
||||
[bx, by, bx + 1, by],
|
||||
[bx, by + 1, bx + 1, by + 1],
|
||||
[bx, by, bx, by + 1],
|
||||
[bx + 1, by, bx + 1, by + 1],
|
||||
];
|
||||
if (sides.some(([ax, ay, cx, cy]) => segmentsIntersect(x0, y0, x1, y1, ax, ay, cx, cy))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, state] of Object.entries(board.edges)) {
|
||||
if (state === "open") continue;
|
||||
// Reconstruct the wall segment for this edge.
|
||||
|
||||
+506
-11
@@ -76,6 +76,16 @@ export interface SustainedEffect {
|
||||
remainingTurns: number;
|
||||
/** Per-card scratch (e.g. SLOW's turn parity counter). */
|
||||
data: Record<string, number>;
|
||||
/** For edge-bound spells (WALL OF FIRE): the edge to clean up on expiry. */
|
||||
edge?: string;
|
||||
}
|
||||
|
||||
/** Something occupying a whole square (FILL SQUARE WITH STONE, THORNBUSH). */
|
||||
export interface SquareContent {
|
||||
kind: "stone" | "thornbush";
|
||||
/** Damage taken so far; thornbushes die at 5. Stone is indestructible. */
|
||||
damage: number;
|
||||
createdBy: PlayerId;
|
||||
}
|
||||
|
||||
export interface TurnState {
|
||||
@@ -131,6 +141,14 @@ export interface GameState {
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Door edges unlocked until the end of the current turn. */
|
||||
openDoorEdges: string[];
|
||||
/** Walls/firewalls conjured during play (dispellable), by edge key. */
|
||||
createdEdges: Record<string, true>;
|
||||
/** Square-filling creations, by cell key. */
|
||||
squareContents: Record<string, SquareContent>;
|
||||
/** Object cards lying on the floor, by cell key. */
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
/** The last spell card each player used (for REUSE SPELL). */
|
||||
lastSpellUsed: Record<PlayerId, string>;
|
||||
players: PlayerState[];
|
||||
treasures: TreasureState[];
|
||||
sustained: SustainedEffect[];
|
||||
@@ -155,6 +173,25 @@ export function sustainedOn(state: GameState, playerId: PlayerId, cardId?: strin
|
||||
return state.sustained.filter((s) => s.targetId === playerId && (!cardId || s.cardId === cardId));
|
||||
}
|
||||
|
||||
/** LOS including square-filling blockers (stone, thornbushes). */
|
||||
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
|
||||
const blockers: Record<string, true> = {};
|
||||
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
|
||||
return hasLineOfSight(boardView(state), from, to, blockers);
|
||||
}
|
||||
|
||||
function inThornbush(state: GameState, p: PlayerState): boolean {
|
||||
return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
|
||||
}
|
||||
|
||||
function isMisted(state: GameState, playerId: PlayerId): boolean {
|
||||
return sustainedOn(state, playerId, "mist-body").length > 0;
|
||||
}
|
||||
|
||||
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
|
||||
@@ -191,6 +228,19 @@ export type GameEvent =
|
||||
| { type: "handRevealed"; player: PlayerId; to: PlayerId }
|
||||
| { type: "handRevealedPrivate"; visibleTo: PlayerId; player: PlayerId; cards: CardInstance[] }
|
||||
| { type: "wallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "firewallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side }; turns: number }
|
||||
| { type: "firewallExpired"; edge: string }
|
||||
| { type: "firewallBurned"; player: PlayerId }
|
||||
| { type: "waterwallCrashes"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "washedBack"; player: PlayerId; from: Cell; to: Cell; blockedSpaces: number }
|
||||
| { type: "squareFilled"; caster: PlayerId; cell: Cell; kind: "stone" | "thornbush" }
|
||||
| { type: "creationDispelled"; caster: PlayerId; what: string }
|
||||
| { type: "enteredThornbush"; player: PlayerId; at: Cell }
|
||||
| { type: "objectThrown"; attacker: PlayerId; cardId: string; landedAt: Cell }
|
||||
| { type: "objectDropped"; player: PlayerId; card: CardInstance; at: Cell; forced: boolean }
|
||||
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
|
||||
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
|
||||
| { type: "spellReused"; player: PlayerId; card: CardInstance }
|
||||
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
|
||||
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
|
||||
| { type: "doorsRelocked"; count: number }
|
||||
@@ -249,6 +299,8 @@ export type Command =
|
||||
| { type: "counteract"; instanceId: string }
|
||||
| { type: "pass" }
|
||||
| { type: "pickUpTreasure" }
|
||||
| { type: "pickUpObject"; instanceId: string }
|
||||
| { type: "dropObject"; instanceId: string }
|
||||
| { type: "dropTreasure" }
|
||||
| { type: "discard"; instanceIds: string[] }
|
||||
| { type: "endTurn"; draw: number };
|
||||
@@ -263,6 +315,8 @@ export type CommandResult =
|
||||
type AttackEffect = {
|
||||
kind: "attack";
|
||||
requiresLos?: boolean;
|
||||
/** Physical attacks (thrown DAGGER/ROCK): FULL SHIELD does not stop them. */
|
||||
physical?: boolean;
|
||||
/** Attacker must share the target's square (WIZARDBLADE). */
|
||||
sameSquare?: boolean;
|
||||
baseDamage: (numberValue: number | null, params: CastParams | null) => number;
|
||||
@@ -416,10 +470,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const cell = cmd.params?.cell;
|
||||
if (!cell) return "teleport opponent needs a destination cell";
|
||||
if (!boardView(state).cells[cellKey(cell)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
|
||||
const to = ctx.stack.params!.cell!;
|
||||
const from = ctx.defender.position;
|
||||
ctx.defender.position = to;
|
||||
@@ -435,6 +491,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
baseDamage: () => 0,
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
if (isLockedInPlace(ctx.state, ctx.defender.id) || isLockedInPlace(ctx.state, ctx.attacker.id)) return;
|
||||
const a = ctx.attacker.position;
|
||||
ctx.attacker.position = ctx.defender.position;
|
||||
ctx.defender.position = a;
|
||||
@@ -561,6 +618,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line";
|
||||
state.edgeOverrides[key] = "wall";
|
||||
state.createdEdges[key] = true;
|
||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
||||
return null;
|
||||
},
|
||||
@@ -577,6 +635,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall";
|
||||
state.edgeOverrides[key] = "open";
|
||||
delete state.doorStates[key];
|
||||
delete state.createdEdges[key];
|
||||
events.push({ type: "wallDestroyed", caster: caster.id, edge: { cell, side }, wasDoor: current === "door" });
|
||||
for (const c of [cell, neighbor(cell, side)]) {
|
||||
for (const p of state.players) {
|
||||
@@ -643,9 +702,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// ... your movement ends after you play it."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "cell") return "teleport needs a destination cell";
|
||||
if (isLockedInPlace(state, caster.id)) return "you are locked in place";
|
||||
const to = cmd.target.cell;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(to)]) return "destination is off the board";
|
||||
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
|
||||
if (wallIgnoringDistance(view, caster.position, to) > 4) {
|
||||
return "teleport reaches at most four spaces";
|
||||
}
|
||||
@@ -700,8 +761,328 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"mist-body": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, _cmd, magnitude) => {
|
||||
attachSustained(state, events, "mist-body", caster.id, caster.id, magnitude.duration);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// --- More attacks ---------------------------------------------------------
|
||||
"lock-in-place": { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||
buddy: {
|
||||
kind: "neutral",
|
||||
// "Opponent will not attack you unless you attack first. This is
|
||||
// permanent until you attack." Neutral, LOS per card.
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "player") return "buddy targets a player";
|
||||
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
// Effectively permanent: broken by the caster attacking the target.
|
||||
attachSustained(state, events, "buddy", caster.id, target.id, 1_000_000_000);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
dagger: {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
physical: true,
|
||||
keepInHand: false,
|
||||
// "You may throw it. Does three points physical damage. ... Retrievable
|
||||
// by anyone after it is thrown."
|
||||
baseDamage: () => 3,
|
||||
onResolved: (ctx) => { landThrownObject(ctx, "dagger"); },
|
||||
},
|
||||
"large-rock": {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
physical: true,
|
||||
baseDamage: () => 2,
|
||||
onResolved: (ctx) => { landThrownObject(ctx, "large-rock"); },
|
||||
},
|
||||
"drop-object": {
|
||||
kind: "attack",
|
||||
requiresLos: true,
|
||||
baseDamage: () => 0,
|
||||
validate: (_state, cmd) => (cmd.params?.cardId ? null : "name the object to drop"),
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped) return;
|
||||
const wanted = ctx.stack.params!.cardId!;
|
||||
if (wanted === "treasure") {
|
||||
if (!ctx.defender.carriedTreasureId) return;
|
||||
const t = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId)!;
|
||||
t.carriedBy = null;
|
||||
t.position = ctx.defender.position;
|
||||
ctx.defender.carriedTreasureId = null;
|
||||
ctx.events.push({
|
||||
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
|
||||
at: ctx.defender.position, onHomeOf: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
||||
if (idx === -1) return;
|
||||
const [card] = ctx.defender.hand.splice(idx, 1);
|
||||
ctx.defender.displayed = ctx.defender.displayed.filter((id) => id !== card!.instanceId);
|
||||
const key = cellKey(ctx.defender.position);
|
||||
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card!];
|
||||
ctx.events.push({
|
||||
type: "objectDropped", player: ctx.defender.id, card: card!,
|
||||
at: ctx.defender.position, forced: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// --- Terrain --------------------------------------------------------------
|
||||
"fill-square-with-stone": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const problem = emptySquareTarget(state, cmd, caster);
|
||||
if (typeof problem === "string") return problem;
|
||||
state.squareContents[cellKey(problem)] = { kind: "stone", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "stone" });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
thornbush: {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const problem = emptySquareTarget(state, cmd, caster);
|
||||
if (typeof problem === "string") return problem;
|
||||
state.squareContents[cellKey(problem)] = { kind: "thornbush", damage: 0, createdBy: caster.id };
|
||||
events.push({ type: "squareFilled", caster: caster.id, cell: problem, kind: "thornbush" });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"wall-of-fire": {
|
||||
kind: "neutral",
|
||||
// Neutral use: a burning barrier for [duration] turns. (Counteraction
|
||||
// use vs WATERBOLT: TODO.)
|
||||
resolve: (state, events, caster, cmd, magnitude) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the fire must span a corridor between two spaces";
|
||||
}
|
||||
const key = edgeKey(cell, side);
|
||||
if ((view.edges[key] ?? "open") !== "open") return "that corridor is not open";
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
state.edgeOverrides[key] = "firewall";
|
||||
state.createdEdges[key] = true;
|
||||
const fx: SustainedEffect = {
|
||||
id: `fx-${state.nextEffectId++}`,
|
||||
cardId: "wall-of-fire",
|
||||
casterId: caster.id,
|
||||
targetId: caster.id,
|
||||
remainingTurns: Math.max(1, magnitude.duration),
|
||||
data: {},
|
||||
edge: key,
|
||||
};
|
||||
state.sustained.push(fx);
|
||||
events.push({ type: "firewallCreated", caster: caster.id, edge: { cell, side }, turns: fx.remainingTurns });
|
||||
return null;
|
||||
},
|
||||
},
|
||||
waterwall: {
|
||||
kind: "neutral",
|
||||
// "The moment you create it, it collapses, washing away any player within
|
||||
// two spaces back two spaces (including the caster)."
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (!cmd.target || cmd.target.kind !== "edge") return "waterwall targets a corridor edge";
|
||||
const { cell, side } = cmd.target;
|
||||
const view = boardView(state);
|
||||
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
|
||||
return "the wave must span a corridor between two spaces";
|
||||
}
|
||||
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
|
||||
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||
// The two sides of the edge, and the push directions away from it.
|
||||
const a = cell;
|
||||
const b = neighbor(cell, side);
|
||||
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
|
||||
const pushes: { start: Cell; dir: Side }[] = [
|
||||
{ start: a, dir: away(side) },
|
||||
{ start: b, dir: side },
|
||||
];
|
||||
for (const { start, dir } of pushes) {
|
||||
// Players on the two cells extending away from the edge on this side.
|
||||
let probe = start;
|
||||
for (let dist = 0; dist < 2; dist++) {
|
||||
for (const p of state.players) {
|
||||
if (!p.alive || cellKey(p.position) !== cellKey(probe)) continue;
|
||||
washBack(state, events, p, dir);
|
||||
}
|
||||
probe = neighbor(probe, dir);
|
||||
}
|
||||
}
|
||||
checkVictory(state, events);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"dispel-creation": {
|
||||
kind: "neutral",
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
const view = boardView(state);
|
||||
if (cmd.target?.kind === "edge") {
|
||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
||||
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
|
||||
const was = view.edges[key];
|
||||
delete state.edgeOverrides[key];
|
||||
delete state.createdEdges[key];
|
||||
state.sustained = state.sustained.filter((s) => s.edge !== key);
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: was === "firewall" ? "wall of fire" : "created wall" });
|
||||
return null;
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
const content = state.squareContents[key];
|
||||
if (!content) return "nothing created there";
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
delete state.squareContents[key];
|
||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||
return null;
|
||||
}
|
||||
return "dispel targets a created wall, fire, stone, or bush";
|
||||
},
|
||||
},
|
||||
drag: {
|
||||
kind: "neutral",
|
||||
// "Drags any moveable object within L.O.S. towards you ..." (and, per the
|
||||
// rulebook's Objects section, players can be DRAGged too).
|
||||
resolve: (state, events, caster, cmd) => {
|
||||
if (cmd.target?.kind === "player") {
|
||||
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
|
||||
if (!target || !target.alive) return "no such living player";
|
||||
if (target.id === caster.id) return "you cannot drag yourself";
|
||||
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
|
||||
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||
const from = target.position;
|
||||
dragToward(state, target, caster.position);
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: target.id, from, to: target.position });
|
||||
return null;
|
||||
}
|
||||
if (cmd.target?.kind === "cell") {
|
||||
const key = cellKey(cmd.target.cell);
|
||||
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
|
||||
const objects = state.groundObjects[key];
|
||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||
if (objects && objects.length > 0) {
|
||||
const card = objects[objects.length - 1]!;
|
||||
objects.pop();
|
||||
if (objects.length === 0) delete state.groundObjects[key];
|
||||
const destKey = cellKey(caster.position);
|
||||
state.groundObjects[destKey] = [...(state.groundObjects[destKey] ?? []), card];
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: card.cardId, from: cmd.target.cell, to: caster.position });
|
||||
return null;
|
||||
}
|
||||
if (treasure) {
|
||||
const from = treasure.position!;
|
||||
treasure.position = { ...caster.position };
|
||||
events.push({ type: "objectDragged", caster: caster.id, what: treasure.id, from, to: caster.position });
|
||||
checkVictory(state, events);
|
||||
return null;
|
||||
}
|
||||
return "nothing to drag there";
|
||||
}
|
||||
return "drag targets an object square or a player";
|
||||
},
|
||||
},
|
||||
"reuse-spell": {
|
||||
kind: "neutral",
|
||||
// "You may retrieve any spell you use immediately after you use it (but
|
||||
// not the NUMBER card)."
|
||||
resolve: (state, events, caster) => {
|
||||
const lastId = state.lastSpellUsed[caster.id];
|
||||
if (!lastId || lastId === "reuse-spell") return "no spell to retrieve";
|
||||
// The most recent copy of that card in the discard pile is yours.
|
||||
for (let i = state.discard.length - 1; i >= 0; i--) {
|
||||
if (state.discard[i]!.cardId === lastId) {
|
||||
const [card] = state.discard.splice(i, 1);
|
||||
caster.hand.push(card!);
|
||||
events.push({ type: "spellReused", player: caster.id, card: card! });
|
||||
if (caster.hand.length > HAND_LIMIT) state.pendingDiscard = caster.id;
|
||||
delete state.lastSpellUsed[caster.id];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "that spell is no longer in the discard pile";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Thrown weapons land in the target's square, whatever the counters did. */
|
||||
function landThrownObject(ctx: ResolutionContext, cardId: string): void {
|
||||
const card = ctx.stack.attackCard!;
|
||||
const key = cellKey(ctx.defender.position);
|
||||
ctx.state.groundObjects[key] = [...(ctx.state.groundObjects[key] ?? []), card];
|
||||
// The card was discarded on cast; move it from the discard to the floor.
|
||||
const di = ctx.state.discard.findIndex((c) => c.instanceId === card.instanceId);
|
||||
if (di !== -1) ctx.state.discard.splice(di, 1);
|
||||
ctx.events.push({ type: "objectThrown", attacker: ctx.attacker.id, cardId, landedAt: ctx.defender.position });
|
||||
}
|
||||
|
||||
/** Validate a cell target for square-filling creations. */
|
||||
function emptySquareTarget(
|
||||
state: GameState,
|
||||
cmd: Extract<Command, { type: "cast" }>,
|
||||
caster: PlayerState,
|
||||
): Cell | string {
|
||||
if (!cmd.target || cmd.target.kind !== "cell") return "target a square";
|
||||
const cell = cmd.target.cell;
|
||||
const key = cellKey(cell);
|
||||
const view = boardView(state);
|
||||
if (!view.cells[key]) return "off the board";
|
||||
if (state.squareContents[key]) return "that square is occupied";
|
||||
if (view.homes.some((h) => cellKey(h) === key)) return "you cannot create on a home base";
|
||||
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
|
||||
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
|
||||
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
|
||||
if (!gameLos(state, caster.position, cell)) return "no line of sight";
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** WATERWALL: push a player 2 spaces along dir; 1 damage per blocked space. */
|
||||
function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Side): void {
|
||||
if (isLockedInPlace(state, p.id)) return;
|
||||
const view = boardView(state);
|
||||
const from = p.position;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const step = stepTarget(view, p.position, dir);
|
||||
if (step.kind === "blocked") break;
|
||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") break;
|
||||
p.position = step.to;
|
||||
moved++;
|
||||
}
|
||||
const blockedSpaces = 2 - moved;
|
||||
events.push({ type: "washedBack", player: p.id, from, to: p.position, blockedSpaces });
|
||||
if (blockedSpaces > 0) {
|
||||
applyDamage(state, events, p, blockedSpaces, "waterwall crush", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** DRAG a player straight toward the caster, stopping at walls. */
|
||||
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
|
||||
const view = boardView(state);
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
if (cellKey(target.position) === cellKey(dest)) return;
|
||||
const dx = dest.x - target.position.x;
|
||||
const dy = dest.y - target.position.y;
|
||||
let dir: Side;
|
||||
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
|
||||
else dir = dy > 0 ? "S" : "N";
|
||||
const step = stepTarget(view, target.position, dir);
|
||||
if (step.kind === "blocked") return;
|
||||
if (state.squareContents[cellKey(step.to)]?.kind === "stone") return;
|
||||
target.position = step.to;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect helpers
|
||||
|
||||
@@ -873,6 +1254,10 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
edgeOverrides: {},
|
||||
doorStates: {},
|
||||
openDoorEdges: [],
|
||||
createdEdges: {},
|
||||
squareContents: {},
|
||||
groundObjects: {},
|
||||
lastSpellUsed: {},
|
||||
players,
|
||||
treasures,
|
||||
sustained: [],
|
||||
@@ -937,6 +1322,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
case "counteract": return err("nothing to counteract");
|
||||
case "pass": return err("nothing to pass on");
|
||||
case "pickUpTreasure": return doPickUpTreasure(state);
|
||||
case "pickUpObject": return doPickUpObject(state, command.instanceId);
|
||||
case "dropObject": return doDropObject(state, command.instanceId);
|
||||
case "dropTreasure": return doDropTreasure(state);
|
||||
case "discard": return doDiscard(state, playerId, command.instanceIds);
|
||||
case "endTurn": return doEndTurn(state, command.draw);
|
||||
@@ -976,25 +1363,37 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
|
||||
const mover = activePlayer(prev);
|
||||
if (sustainedOn(prev, mover.id, "medusa").length > 0) return err("you are paralyzed by Medusa");
|
||||
if (isLockedInPlace(prev, mover.id)) return err("you are locked in place");
|
||||
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
const target = stepTarget(view, p.position, direction);
|
||||
const events: GameEvent[] = [];
|
||||
const misted = isMisted(state, p.id);
|
||||
|
||||
const from = p.position;
|
||||
let via: "step" | "warp" | "passWall";
|
||||
let crossedFirewall = false;
|
||||
if (target.kind === "blocked") {
|
||||
const key = edgeKey(p.position, direction);
|
||||
const edge = view.edges[key] ?? "open";
|
||||
const dest = neighbor(p.position, direction);
|
||||
// A locked door that has been unlocked or de-locked is passable.
|
||||
if (!view.cells[cellKey(dest)]) return err("blocked");
|
||||
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
|
||||
if (!view.cells[cellKey(dest)]) return err("blocked");
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
} else if (edge === "wall" && p.passWallCharges > 0 && view.cells[cellKey(dest)]) {
|
||||
// PASS THROUGH WALL: one charge, one wall.
|
||||
} else if (edge === "firewall") {
|
||||
// "Passing through it does four points of magical damage." Firewalls
|
||||
// burn even a MIST-BODY.
|
||||
p.position = dest;
|
||||
via = "step";
|
||||
crossedFirewall = true;
|
||||
} else if (misted && (edge === "wall" || edge === "door")) {
|
||||
// MIST-BODY passes through anything but solid stone.
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
} else if (edge === "wall" && p.passWallCharges > 0) {
|
||||
p.passWallCharges--;
|
||||
p.position = dest;
|
||||
via = "passWall";
|
||||
@@ -1006,12 +1405,30 @@ function doMove(prev: GameState, direction: Side): CommandResult {
|
||||
via = target.kind;
|
||||
}
|
||||
|
||||
// Square contents at the destination.
|
||||
const content = state.squareContents[cellKey(p.position)];
|
||||
if (content?.kind === "stone") return err("that square is solid stone");
|
||||
|
||||
state.turn.movementUsed++;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "moved", player: p.id, from, to: p.position, direction, via }],
|
||||
};
|
||||
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
|
||||
|
||||
if (crossedFirewall) {
|
||||
events.push({ type: "firewallBurned", player: p.id });
|
||||
applyDamage(state, events, p, 4, "wall of fire", null);
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
// THORNBUSH: "his turn ends, he loses his following turn, and he takes one
|
||||
// point of physical damage from thorns."
|
||||
if (content?.kind === "thornbush" && p.alive) {
|
||||
events.push({ type: "enteredThornbush", player: p.id, at: p.position });
|
||||
applyDamage(state, events, p, 1, "thorns", null);
|
||||
p.lostTurns++;
|
||||
state.turn.actionsEnded = true;
|
||||
checkVictory(state, events);
|
||||
}
|
||||
|
||||
return { ok: true, state, events };
|
||||
}
|
||||
|
||||
function doPlayNumberForMovement(prev: GameState, instanceId: string): CommandResult {
|
||||
@@ -1063,6 +1480,20 @@ function castingBlocked(state: GameState, playerId: PlayerId): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */
|
||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
|
||||
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
|
||||
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
|
||||
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
|
||||
// BUDDY: "Opponent will not attack you unless you attack first."
|
||||
const buddy = state.sustained.find(
|
||||
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
|
||||
);
|
||||
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
|
||||
return null;
|
||||
}
|
||||
|
||||
function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
const pre = attackPreconditions(prev);
|
||||
if (pre) return err(pre);
|
||||
@@ -1075,6 +1506,11 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
|
||||
if (cellKey(target.position) !== cellKey(attacker.position)) {
|
||||
return err("you must be in the same square to punch");
|
||||
}
|
||||
const bushOrMist = attackBlockedByStatus(state, attacker, target);
|
||||
if (bushOrMist) return err(bushOrMist);
|
||||
state.sustained = state.sustained.filter(
|
||||
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
|
||||
);
|
||||
|
||||
state.turn.attackUsed = true;
|
||||
state.stack = {
|
||||
@@ -1224,9 +1660,15 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) {
|
||||
return err("you must be in the same square");
|
||||
}
|
||||
if (effect.requiresLos && !hasLineOfSight(boardView(state), caster.position, target.position)) {
|
||||
const statusBlock = attackBlockedByStatus(state, caster, target);
|
||||
if (statusBlock) return err(statusBlock);
|
||||
if (effect.requiresLos && !gameLos(state, caster.position, target.position)) {
|
||||
return err("no line of sight to the target");
|
||||
}
|
||||
// Attacking someone breaks any BUDDY pact you swore to them.
|
||||
state.sustained = state.sustained.filter(
|
||||
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
|
||||
);
|
||||
if (effect.validate) {
|
||||
const problem = effect.validate(state, cmd);
|
||||
if (problem) return err(problem);
|
||||
@@ -1250,10 +1692,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
amplifyFactor: 2 ** mods.amplifies.length,
|
||||
extendFactor: mods.extend ? 2 : 1,
|
||||
params: cmd.params ?? null,
|
||||
kind: "spell",
|
||||
kind: effect.physical ? "physical" : "spell",
|
||||
counters: [],
|
||||
waitingOn: target.id,
|
||||
};
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
const events: GameEvent[] = [{
|
||||
type: "spellCast",
|
||||
caster: caster.id,
|
||||
@@ -1293,6 +1736,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (effect.keepInHand) {
|
||||
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
|
||||
}
|
||||
if (cardDef(inHand.cardId).cardType !== "object" && inHand.cardId !== "reuse-spell") {
|
||||
state.lastSpellUsed[caster.id] = inHand.cardId;
|
||||
}
|
||||
const result = (effect as NeutralEffect).resolve(state, events, caster, cmd, mods.magnitude);
|
||||
if (result) return err(result); // unreachable after preview
|
||||
return { ok: true, state, events };
|
||||
@@ -1512,13 +1958,17 @@ function knockBack(
|
||||
}
|
||||
|
||||
const from = defender.position;
|
||||
if (isLockedInPlace(state, defender.id)) return;
|
||||
let moved = 0;
|
||||
const view = boardView(state);
|
||||
for (let i = 0; i < squares; i++) {
|
||||
const step = stepTarget(view, defender.position, dir);
|
||||
if (step.kind === "blocked") break;
|
||||
const content = state.squareContents[cellKey(step.to)];
|
||||
if (content?.kind === "stone") break;
|
||||
defender.position = step.to;
|
||||
moved++;
|
||||
if (content?.kind === "thornbush") break; // tangled in the thorns
|
||||
}
|
||||
if (moved > 0) {
|
||||
events.push({ type: "knockedBack", player: defender.id, from, to: defender.position, squares: moved });
|
||||
@@ -1606,6 +2056,45 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
||||
};
|
||||
}
|
||||
|
||||
function doPickUpObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const blocked = requireActionsAvailable(prev);
|
||||
if (blocked) return err(blocked);
|
||||
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const key = cellKey(p.position);
|
||||
const here = state.groundObjects[key] ?? [];
|
||||
const idx = here.findIndex((c) => c.instanceId === instanceId);
|
||||
if (idx === -1) return err("that object is not here");
|
||||
const [card] = here.splice(idx, 1);
|
||||
if (here.length === 0) delete state.groundObjects[key];
|
||||
p.hand.push(card!);
|
||||
// "YOUR TURN ENDS IF YOU PICK UP ANY OBJECT."
|
||||
state.turn.actionsEnded = true;
|
||||
if (p.hand.length > HAND_LIMIT) state.pendingDiscard = p.id;
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "objectPickedUp", player: p.id, card: card!, at: p.position }],
|
||||
};
|
||||
}
|
||||
|
||||
function doDropObject(prev: GameState, instanceId: string): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
const card = p.hand.find((c) => c.instanceId === instanceId);
|
||||
if (!card) return err("card not in hand");
|
||||
if (cardDef(card.cardId).cardType !== "object") return err("only objects can be dropped");
|
||||
takeFromHand(p, instanceId);
|
||||
const key = cellKey(p.position);
|
||||
state.groundObjects[key] = [...(state.groundObjects[key] ?? []), card];
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "objectDropped", player: p.id, card, at: p.position, forced: false }],
|
||||
};
|
||||
}
|
||||
|
||||
function doDropTreasure(prev: GameState): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
@@ -1708,6 +2197,12 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
|
||||
s.remainingTurns--;
|
||||
if (s.remainingTurns <= 0) {
|
||||
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
|
||||
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
|
||||
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
|
||||
delete state.edgeOverrides[s.edge];
|
||||
delete state.createdEdges[s.edge];
|
||||
events.push({ type: "firewallExpired", edge: s.edge });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type CastStack,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
type SquareContent,
|
||||
type SustainedEffect,
|
||||
type TreasureState,
|
||||
type TurnState,
|
||||
} from "./game";
|
||||
@@ -23,6 +25,7 @@ export interface PlayerPublicView {
|
||||
carriedTreasureId: string | null;
|
||||
lostTurns: number;
|
||||
extraTurns: number;
|
||||
displayed: CardInstance[];
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
@@ -41,6 +44,12 @@ export interface GameView {
|
||||
/** Cards on the stack are face-up: the whole exchange is public. */
|
||||
stack: CastStack | null;
|
||||
pendingDiscard: PlayerId | null;
|
||||
/** Duration spells in play (public knowledge). */
|
||||
sustained: SustainedEffect[];
|
||||
squareContents: Record<string, SquareContent>;
|
||||
groundObjects: Record<string, CardInstance[]>;
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
openDoorEdges: string[];
|
||||
}
|
||||
|
||||
export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
@@ -62,6 +71,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
carriedTreasureId: p.carriedTreasureId,
|
||||
lostTurns: p.lostTurns,
|
||||
extraTurns: p.extraTurns,
|
||||
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
|
||||
})),
|
||||
yourHand: you ? [...you.hand] : [],
|
||||
treasures: state.treasures.map((t) => ({ ...t })),
|
||||
@@ -69,5 +79,12 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
discardCount: state.discard.length,
|
||||
stack: state.stack,
|
||||
pendingDiscard: state.pendingDiscard,
|
||||
sustained: state.sustained.map((s) => ({ ...s })),
|
||||
squareContents: { ...state.squareContents },
|
||||
groundObjects: Object.fromEntries(
|
||||
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
|
||||
),
|
||||
doorStates: { ...state.doorStates },
|
||||
openDoorEdges: [...state.openDoorEdges],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
activePlayer,
|
||||
createGame,
|
||||
boardView,
|
||||
gameLos,
|
||||
sustainedOn,
|
||||
type Command,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src/game";
|
||||
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
|
||||
function newGame(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
|
||||
}
|
||||
|
||||
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
||||
const result = applyCommand(state, player, command);
|
||||
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||
const p = state.players.find((p) => p.id === playerId)!;
|
||||
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||
p.hand[slot] = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
function toRound2(state: GameState): GameState {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
return state;
|
||||
}
|
||||
|
||||
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
defender.position = { ...attacker.position };
|
||||
return { attacker: attacker.id, defender: defender.id };
|
||||
}
|
||||
|
||||
function castAt(
|
||||
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
|
||||
extra: Partial<Extract<Command, { type: "cast" }>> = {},
|
||||
): GameState {
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: card.instanceId,
|
||||
target: { kind: "player", playerId: defender }, ...extra,
|
||||
});
|
||||
return must(state, defender, { type: "pass" });
|
||||
}
|
||||
|
||||
/** An empty visible cell adjacent to the player (not home, no treasure). */
|
||||
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
|
||||
const view = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, of, side);
|
||||
if (t.kind !== "step") continue;
|
||||
const key = cellKey(t.to);
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
|
||||
if (state.players.some((p) => cellKey(p.position) === key)) continue;
|
||||
return { cell: t.to, side };
|
||||
}
|
||||
throw new Error("no empty neighbor");
|
||||
}
|
||||
|
||||
describe("terrain", () => {
|
||||
it("fill square with stone blocks movement and line of sight", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, me.position);
|
||||
const fs = giveCard(state, me.id, "fill-square-with-stone");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: fs.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||
});
|
||||
expect(applyCommand(state, me.id, { type: "move", direction: spot.side }).ok).toBe(false);
|
||||
// LOS straight through the stone is blocked.
|
||||
const beyond = neighbor(spot.cell, spot.side);
|
||||
if (boardView(state).cells[cellKey(beyond)]) {
|
||||
expect(gameLos(state, activePlayer(state).position, beyond)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("thornbush entry costs a life point, the turn, and the next turn", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, me.position);
|
||||
const tb = giveCard(state, me.id, "thornbush");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||
});
|
||||
state = must(state, me.id, { type: "move", direction: spot.side });
|
||||
const p = state.players.find((p) => p.id === me.id)!;
|
||||
expect(p.life).toBe(14);
|
||||
expect(p.lostTurns).toBe(1);
|
||||
expect(state.turn.actionsEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("wizards in a thornbush cannot attack or be attacked", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
// Test surgery: plant a bush and stand the defender in it.
|
||||
state.squareContents[cellKey(d.position)] = { kind: "thornbush", damage: 0, createdBy: attacker };
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const refused = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("wall of fire burns crossers and expires with its duration", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, me.position);
|
||||
const key = edgeKey(me.position, spot.side);
|
||||
const wof = giveCard(state, me.id, "wall-of-fire");
|
||||
giveCard(state, me.id, "number-2", "N", 1);
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: wof.instanceId, numberInstanceIds: ["number-2#N"],
|
||||
target: { kind: "edge", cell: me.position, side: spot.side },
|
||||
});
|
||||
expect(boardView(state).edges[key]).toBe("firewall");
|
||||
|
||||
// Walking through it hurts.
|
||||
state = must(state, me.id, { type: "move", direction: spot.side });
|
||||
expect(state.players.find((p) => p.id === me.id)!.life).toBe(11);
|
||||
|
||||
// Duration 2: expires at the start of the caster's second following turn.
|
||||
state = must(state, me.id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
expect(boardView(state).edges[key]).toBe("firewall"); // 1 turn left
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
expect(boardView(state).edges[key]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dispel creation removes a created wall", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, me.position);
|
||||
const key = edgeKey(me.position, spot.side);
|
||||
const cw = giveCard(state, me.id, "create-wall");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: cw.instanceId,
|
||||
target: { kind: "edge", cell: me.position, side: spot.side },
|
||||
});
|
||||
expect(boardView(state).edges[key]).toBe("wall");
|
||||
const dc = giveCard(state, me.id, "dispel-creation");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: dc.instanceId,
|
||||
target: { kind: "edge", cell: me.position, side: spot.side },
|
||||
});
|
||||
expect(boardView(state).edges[key]).toBeUndefined();
|
||||
|
||||
// But a printed (original) wall cannot be dispelled.
|
||||
const dc2 = giveCard(state, me.id, "dispel-creation", "T2");
|
||||
const view = boardView(state);
|
||||
const wallEntry = Object.entries(view.edges).find(([, s]) => s === "wall")!;
|
||||
const [kind, coords] = wallEntry[0].split(":") as [string, string];
|
||||
const [x, y] = coords.split(",").map(Number) as [number, number];
|
||||
const refused = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: dc2.instanceId,
|
||||
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects", () => {
|
||||
it("a thrown dagger does physical damage full shield cannot stop, then lies on the floor", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const dagger = giveCard(state, attacker, "dagger");
|
||||
giveCard(state, defender, "full-shield", "FS", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: dagger.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
expect(d.life).toBe(12); // full shield "does not stop any physical attack"
|
||||
const floor = state.groundObjects[cellKey(d.position)] ?? [];
|
||||
expect(floor.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
|
||||
// Anyone may pick it up — and doing so ends the turn's actions.
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
state = must(state, defender, { type: "pickUpObject", instanceId: dagger.instanceId });
|
||||
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
|
||||
expect(state.turn.actionsEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("blunt halves a thrown rock's physical damage", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const rock = giveCard(state, attacker, "large-rock");
|
||||
giveCard(state, defender, "blunt", "B", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: rock.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: "blunt#B" });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender)!.life).toBe(14); // ceil(2/2)=1
|
||||
});
|
||||
|
||||
it("drop object forces a named object to the floor; drag pulls a treasure home", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
giveCard(state, defender, "dagger", "D2", 0);
|
||||
const dobj = giveCard(state, attacker, "drop-object");
|
||||
state = castAt(state, attacker, defender, dobj, { params: { cardId: "dagger" } });
|
||||
const dPos = state.players.find((p) => p.id === defender)!.position;
|
||||
expect((state.groundObjects[cellKey(dPos)] ?? []).some((c) => c.cardId === "dagger")).toBe(true);
|
||||
|
||||
// Drag an enemy treasure across open floor toward the caster.
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
const enemyTreasure = state.treasures.find((t) => t.owner === defender && t.position)!;
|
||||
const me2 = state.players.find((p) => p.id === attacker)!;
|
||||
// Stand adjacent-ish to the treasure with clear LOS: same square works.
|
||||
me2.position = { ...enemyTreasure.position! };
|
||||
const drag = giveCard(state, attacker, "drag");
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: drag.instanceId,
|
||||
target: { kind: "cell", cell: enemyTreasure.position! },
|
||||
});
|
||||
const t = state.treasures.find((tr) => tr.id === enemyTreasure.id)!;
|
||||
expect(cellKey(t.position!)).toBe(cellKey(state.players.find((p) => p.id === attacker)!.position));
|
||||
});
|
||||
});
|
||||
|
||||
describe("control effects", () => {
|
||||
it("lock in place stops moving and being moved", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const lip = giveCard(state, attacker, "lock-in-place");
|
||||
giveCard(state, attacker, "number-3", "N", 1);
|
||||
state = castAt(state, attacker, defender, lip, { numberInstanceIds: ["number-3#N"] });
|
||||
expect(sustainedOn(state, defender, "lock-in-place").length).toBe(1);
|
||||
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
|
||||
// Teleport Opponent fizzles against a locked target.
|
||||
const tpo = giveCard(state, attacker, "teleport-opponent");
|
||||
const before = state.players.find((p) => p.id === defender)!.position;
|
||||
const anywhere = state.board.homes.find((h) => cellKey(h) !== cellKey(before))!;
|
||||
state = castAt(state, attacker, defender, tpo, { params: { cell: anywhere } });
|
||||
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(before));
|
||||
});
|
||||
|
||||
it("buddy prevents attacks until the caster breaks the pact", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const buddy = giveCard(state, attacker, "buddy");
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: buddy.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
|
||||
// The defender cannot bring themselves to attack the caster.
|
||||
const fb = giveCard(state, defender, "fireball", "F", 0);
|
||||
const refused = applyCommand(state, defender, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
|
||||
// The caster attacks first: the pact breaks; next turn the defender may.
|
||||
const fb2 = giveCard(state, attacker, "fireball", "F2", 0);
|
||||
state = castAt(state, attacker, defender, fb2);
|
||||
expect(state.sustained.some((s) => s.cardId === "buddy")).toBe(false);
|
||||
});
|
||||
|
||||
it("mist body passes through walls but cannot attack or be attacked", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const mist = giveCard(state, defender, "mist-body", "M", 0);
|
||||
giveCard(state, defender, "number-3", "N", 1);
|
||||
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
||||
state = must(state, defender, {
|
||||
type: "cast", instanceId: mist.instanceId, numberInstanceIds: ["number-3#N"],
|
||||
});
|
||||
|
||||
// Misted wizard walks through a wall if one is adjacent.
|
||||
const view = boardView(state);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
for (const side of SIDES) {
|
||||
const k = edgeKey(d.position, side);
|
||||
if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(d.position, side))]) {
|
||||
state = must(state, defender, { type: "move", direction: side });
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = must(state, defender, { type: "endTurn", draw: 0 });
|
||||
|
||||
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
||||
const dd = state.players.find((p) => p.id === defender)!;
|
||||
state.players.find((p) => p.id === attacker)!.position = { ...dd.position };
|
||||
const refused = applyCommand(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reuse spell retrieves the last spell you cast", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, me.position);
|
||||
const cw = giveCard(state, me.id, "create-wall");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: cw.instanceId,
|
||||
target: { kind: "edge", cell: me.position, side: spot.side },
|
||||
});
|
||||
const ru = giveCard(state, me.id, "reuse-spell");
|
||||
state = must(state, me.id, { type: "cast", instanceId: ru.instanceId });
|
||||
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
|
||||
});
|
||||
});
|
||||
+110
-7
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { net } from "./net.svelte";
|
||||
import Board from "./Board.svelte";
|
||||
import { cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
||||
import type { CardInstance, Side } from "@wizwar/engine";
|
||||
|
||||
net.connect();
|
||||
@@ -16,6 +16,12 @@
|
||||
let attachedNumber = $state<CardInstance | null>(null);
|
||||
/** Waterbolt split. */
|
||||
let wbDamage = $state(0);
|
||||
/** teleport-opponent: player chosen, waiting for the destination cell. */
|
||||
let pendingCellFor = $state<string | null>(null);
|
||||
/** card-erasure / drop-object: the named card. */
|
||||
let nameInput = $state("");
|
||||
/** power-run points. */
|
||||
let runPoints = $state(1);
|
||||
/** Cards marked for discard. */
|
||||
let discardSelection = $state<Set<string>>(new Set());
|
||||
|
||||
@@ -25,8 +31,20 @@
|
||||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||||
|
||||
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
|
||||
const edgeSelectMode = $derived(
|
||||
selectedCard?.cardId === "create-wall" || selectedCard?.cardId === "destroy-wall",
|
||||
const EDGE_CARDS = new Set([
|
||||
"create-wall", "destroy-wall", "wall-of-fire", "waterwall",
|
||||
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
||||
]);
|
||||
const CELL_CARDS = new Set([
|
||||
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
|
||||
]);
|
||||
const SELF_CARDS = new Set([
|
||||
"speed", "invisible", "shrink", "mist-body", "pass-through-wall", "reuse-spell",
|
||||
]);
|
||||
const NAMED_CARDS = new Set(["card-erasure", "drop-object"]);
|
||||
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
||||
const cellSelectMode = $derived(
|
||||
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
||||
);
|
||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||
|
||||
@@ -34,6 +52,19 @@
|
||||
selectedCard = null;
|
||||
attachedNumber = null;
|
||||
wbDamage = 0;
|
||||
pendingCellFor = null;
|
||||
nameInput = "";
|
||||
}
|
||||
|
||||
/** Map a typed card name to its id (case-insensitive). */
|
||||
function nameToCardId(name: string): string | null {
|
||||
const wanted = name.trim().toLowerCase();
|
||||
if (!wanted) return null;
|
||||
if (wanted === "treasure") return "treasure";
|
||||
for (const def of allCardDefs()) {
|
||||
if (def.name.toLowerCase() === wanted || def.id === wanted) return def.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectCard(card: CardInstance) {
|
||||
@@ -66,17 +97,51 @@
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
// Self-targeting / untargeted spells cast immediately.
|
||||
if (card.cardId === "speed") {
|
||||
// Instant untargeted spells cast immediately; duration self-spells wait
|
||||
// so a number card can be attached (cast via the button in the hint bar).
|
||||
if (card.cardId === "speed" || card.cardId === "pass-through-wall" || card.cardId === "reuse-spell") {
|
||||
net.command({ type: "cast", instanceId: card.instanceId });
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function castSelfWithNumber() {
|
||||
if (!selectedCard) return;
|
||||
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
|
||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
net.command(cmd);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function castPowerRun() {
|
||||
if (!selectedCard) return;
|
||||
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function clickCell(cell: { x: number; y: number }) {
|
||||
if (!view || !isYourTurn) return;
|
||||
if (pendingCellFor && selectedCard) {
|
||||
// Stage 2 of teleport-opponent: destination chosen.
|
||||
net.command({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "player", playerId: pendingCellFor },
|
||||
params: { cell },
|
||||
});
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||||
net.command({
|
||||
type: "cast", instanceId: selectedCard.instanceId,
|
||||
target: { kind: "cell", cell },
|
||||
});
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const me = view.players.find((p) => p.id === view.you)!;
|
||||
// A cell click is a move if the cell is one legal step away.
|
||||
// A cell click is a move if the cell is one legal step away (the server
|
||||
// also lets doors/walls pass when unlocked/misted — try the direction).
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view.board, me.position, side);
|
||||
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||||
@@ -84,6 +149,16 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
|
||||
// and pass-through-wall are resolved server-side.
|
||||
for (const side of SIDES) {
|
||||
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||||
if (cellKey(n) === cellKey(cell)) {
|
||||
net.command({ type: "move", direction: side });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
||||
@@ -107,15 +182,24 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedCard.cardId === "teleport-opponent") {
|
||||
pendingCellFor = playerId;
|
||||
return; // next: click the destination cell
|
||||
}
|
||||
const cmd: Parameters<typeof net.command>[0] = {
|
||||
type: "cast",
|
||||
instanceId: selectedCard.instanceId,
|
||||
target: { kind: "player", playerId },
|
||||
};
|
||||
if (attachedNumber) cmd.numberInstanceId = attachedNumber.instanceId;
|
||||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||||
if (selectedCard.cardId === "waterbolt") {
|
||||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
||||
}
|
||||
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
||||
const id = nameToCardId(nameInput);
|
||||
if (!id) return; // needs a card name typed first
|
||||
cmd.params = { cardId: id };
|
||||
}
|
||||
net.command(cmd);
|
||||
clearSelection();
|
||||
}
|
||||
@@ -245,6 +329,25 @@
|
||||
<label>damage <input type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
|
||||
(knockback {numberTotal - wbDamage})
|
||||
{/if}
|
||||
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
||||
<label>card name <input bind:value={nameInput} placeholder="e.g. Fireball or treasure" /></label>
|
||||
— then click the target wizard
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "power-run"}
|
||||
<label>life to trade <input type="number" min="1" max="10" bind:value={runPoints} /></label>
|
||||
<button onclick={castPowerRun}>Run!</button>
|
||||
{/if}
|
||||
{#if selectedCard && SELF_CARDS.has(selectedCard.cardId)}
|
||||
<button onclick={castSelfWithNumber}>
|
||||
Cast{attachedNumber ? ` with the ${numberTotal}` : " (duration 1)"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if pendingCellFor}
|
||||
— now click the destination square for {pendingCellFor}
|
||||
{/if}
|
||||
{#if selectedCard && CELL_CARDS.has(selectedCard.cardId)}
|
||||
— click a square on the board
|
||||
{/if}
|
||||
<button class="link" onclick={clearSelection}>cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -101,19 +101,45 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- walls & doors -->
|
||||
<!-- terrain: solid stone and thornbushes -->
|
||||
{#each Object.entries(view.squareContents) as [key, content] (key)}
|
||||
{@const sx = Number(key.split(",")[0])}
|
||||
{@const sy = Number(key.split(",")[1])}
|
||||
{#if content.kind === "stone"}
|
||||
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
|
||||
{:else}
|
||||
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36} class="bush" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- ground objects -->
|
||||
{#each Object.entries(view.groundObjects) as [key, objects] (key)}
|
||||
{@const gx = Number(key.split(",")[0])}
|
||||
{@const gy = Number(key.split(",")[1])}
|
||||
{#each objects as o, i (o.instanceId)}
|
||||
<rect
|
||||
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
||||
width={12} height={10} rx="2" class="ground-object"
|
||||
>
|
||||
<title>{o.cardId}</title>
|
||||
</rect>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<!-- walls & doors & firewalls -->
|
||||
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
||||
{@const cls = e.state === "door" ? "door" : e.state === "firewall" ? "firewall" : "wall"}
|
||||
{#if e.kind === "V"}
|
||||
<rect
|
||||
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
||||
width={WALL} height={CELL + WALL}
|
||||
class={e.state === "door" ? "door" : "wall"}
|
||||
class={cls}
|
||||
/>
|
||||
{:else}
|
||||
<rect
|
||||
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
||||
width={CELL + WALL} height={WALL}
|
||||
class={e.state === "door" ? "door" : "wall"}
|
||||
class={cls}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -178,6 +204,10 @@
|
||||
.floor:hover { fill: #f2ecda; }
|
||||
.wall { fill: #4a4438; }
|
||||
.door { fill: #8b5a2b; }
|
||||
.firewall { fill: #e0442a; }
|
||||
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
||||
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
||||
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
|
||||
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
|
||||
.treasure { stroke: #111; stroke-width: 1.2; }
|
||||
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
|
||||
|
||||
Reference in New Issue
Block a user