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:
Eric Wagoner
2026-08-15 20:13:14 -04:00
co-authored by Claude Fable 5
parent 67743dd17e
commit 2d88b4ab40
6 changed files with 1029 additions and 27 deletions
+110 -7
View File
@@ -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}