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
+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