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}
+33 -3
View File
@@ -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; }