Selecting a creature opens its card, with living stats

Tapping a creature you command now does both things at once: selects
it for orders AND opens its card in the inspector corner, with a
stats line pinned beneath — hits left, moves left, attack ready or
spent (or barred on the turn it appears; the shadow reports itself
unharmed by ordinary damage). The same stats ride along on any
hold-to-peek of a creature, and they are derived live, so marching
the monster updates the numbers as you spend its moves. While
commanding, the hint strip carries the same line beside the hand,
with a "done" to dismiss. Deselecting closes the card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 10:51:46 -04:00
co-authored by Claude Fable 5
parent d34173df9f
commit 7ad3e7acc8
+63 -4
View File
@@ -5,7 +5,7 @@
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte"; import Replay from "./Replay.svelte";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -19,6 +19,8 @@
let attackNoticeSeen = $state<string | null>(null); let attackNoticeSeen = $state<string | null>(null);
/** A face-up card being peeked at from the scoresheet (view-only). */ /** A face-up card being peeked at from the scoresheet (view-only). */
let peekCard = $state<CardInstance | null>(null); let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */
let peekCreatureId = $state<string | null>(null);
let helpTab = $state<"play" | "rules" | "cards" | "about">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -151,6 +153,7 @@
function selectCard(card: CardInstance) { function selectCard(card: CardInstance) {
if (!view) return; if (!view) return;
peekCard = null; peekCard = null;
peekCreatureId = null;
if (youMustRespond) { if (youMustRespond) {
dispatch({ type: "counteract", instanceId: card.instanceId }); dispatch({ type: "counteract", instanceId: card.instanceId });
return; return;
@@ -390,6 +393,20 @@
tacks: "handful-of-tacks", pit: "create-pit", safe: "safe", tacks: "handful-of-tacks", pit: "create-pit", safe: "safe",
}; };
function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string {
const hp = Number.isFinite(c.maxDamage)
? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left`
: "unharmed by ordinary damage";
const moves = `${c.movesPerTurn - c.movementUsed} of ${c.movesPerTurn} moves`;
const atk = c.justCreated ? "no attack the turn it appears" : c.attackUsed ? "attack spent" : "attack ready";
return `${hp} · ${moves} · ${atk}`;
}
const peekNote = $derived.by(() => {
if (!view || !peekCreatureId) return null;
const c = view.creatures.find((c) => c.id === peekCreatureId);
return c ? creatureStats(c) : null;
});
/** Show the card behind whatever occupies this square (view-only). */ /** Show the card behind whatever occupies this square (view-only). */
function peekAt(cell: { x: number; y: number }): boolean { function peekAt(cell: { x: number; y: number }): boolean {
if (!view) return false; if (!view) return false;
@@ -406,6 +423,7 @@
: null; : null;
if (!cardId) return false; if (!cardId) return false;
peekCard = { instanceId: `peek-${cardId}`, cardId }; peekCard = { instanceId: `peek-${cardId}`, cardId };
peekCreatureId = creature?.id ?? null;
return true; return true;
} }
@@ -455,7 +473,15 @@
} }
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster"; const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
if (mine) { if (mine) {
selectedCreature = selectedCreature === creatureId ? null : creatureId; const selecting = selectedCreature !== creatureId;
selectedCreature = selecting ? creatureId : null;
if (selecting) {
peekCard = { instanceId: `peek-${creature.kind}`, cardId: creature.kind };
peekCreatureId = creature.id;
} else {
peekCard = null;
peekCreatureId = null;
}
} else { } else {
peekAt(creature.position); peekAt(creature.position);
} }
@@ -645,8 +671,9 @@
{#if peekCard} {#if peekCard}
<div class="inspector" role="img" aria-label="displayed card, enlarged"> <div class="inspector" role="img" aria-label="displayed card, enlarged">
<button class="inspector-close" onclick={() => (peekCard = null)} aria-label="hide card">×</button> <button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button>
<div class="inspector-card"><Card card={peekCard} /></div> <div class="inspector-card"><Card card={peekCard} /></div>
{#if peekNote}<div class="peek-note">{peekNote}</div>{/if}
</div> </div>
{/if} {/if}
@@ -1003,8 +1030,16 @@
<button class="hint-cancel" onclick={clearSelection}>cancel</button> <button class="hint-cancel" onclick={clearSelection}>cancel</button>
</div> </div>
{/if} {/if}
{#if youMustRespond || selectedDef || youMustDiscard || discardMode || discardSelection.size > 0} {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip"> <div class="hint-strip">
{#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
<button class="hint-cancel" onclick={() => { selectedCreature = null; peekCard = null; peekCreatureId = null; }}>done</button>
{/if}
{/if}
{#if youMustRespond && view.stack} {#if youMustRespond && view.stack}
<span class="hint-alert"> <span class="hint-alert">
{#if view.stack.defenderId === view.you} {#if view.stack.defenderId === view.you}
@@ -1673,6 +1708,18 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.peek-note {
margin-top: 0.4rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
text-align: center;
max-width: 11rem;
}
.inspector { .inspector {
position: fixed; position: fixed;
right: 0.9rem; right: 0.9rem;
@@ -1740,6 +1787,18 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.peek-note {
margin-top: 0.4rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
text-align: center;
max-width: 11rem;
}
.inspector { right: 0.5rem; bottom: 0.5rem; } .inspector { right: 0.5rem; bottom: 0.5rem; }
.inspector-card { transform: scale(1.3); } .inspector-card { transform: scale(1.3); }
} }