Attacks announce themselves; the response moves beside the hand

Being attacked was announced only by a slip in the paper rail, easy
to miss mid-turn — the playtest complaint was real. Now an attack on
you (or a counter to your spell) opens a modal: the attacker's name
in red Oswald caps, the actual card face enlarged, the number backing
it if any, a bare 👊 for a punch, and a single "To arms!" stamp.
Dismissing it leaves the actionable prompt in the hint strip beside
your hand — where the counteraction cards you would tap actually
live — with the "let it resolve" escape. The rail keeps a one-line
reminder. Each new decision point in a counter-chain re-announces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 10:26:58 -04:00
co-authored by Claude Fable 5
parent f9eff3341b
commit a0d862ca10
+115 -6
View File
@@ -15,6 +15,8 @@
let joinCode = $state(""); let joinCode = $state("");
let claimPhrase = $state(""); let claimPhrase = $state("");
let showHelp = $state(false); let showHelp = $state(false);
/** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $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("");
@@ -70,6 +72,11 @@
const view = $derived(local.active ? local.view : net.view); const view = $derived(local.active ? local.view : net.view);
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack); const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you); const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
const attackNoticeKey = $derived(
youMustRespond && view?.stack
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? "punch"}:${view.stack.counters.length}`
: null,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null); const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
@@ -557,6 +564,31 @@
<Help initialTab={helpTab} onclose={() => (showHelp = false)} /> <Help initialTab={helpTab} onclose={() => (showHelp = false)} />
{/if} {/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice">
<div class="attack-headline">
{#if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you!
{:else}
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
{/if}
</div>
{#if view.stack.defenderId === view.you && view.stack.attackCard}
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
{#if view.stack.numberValue != null}
<div class="attack-power">powered by a {view.stack.numberValue}</div>
{/if}
{:else if view.stack.defenderId === view.you}
<div class="attack-fist">👊 a bare-knuckled punch</div>
{:else if view.stack.counters.length > 0}
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
{/if}
<button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
</div>
</div>
{/if}
{#if net.catchUp && net.catchUp.length > 0} {#if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} /> <Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{/if} {/if}
@@ -790,13 +822,10 @@
{:else if youMustRespond} {:else if youMustRespond}
<div class="slip urgent"> <div class="slip urgent">
{#if view.stack?.defenderId === view.you} {#if view.stack?.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you Under attack — respond by your hand.
{#if view.stack.attackCard}with <strong>{cardDef(view.stack.attackCard.cardId).name}</strong>{:else}with a punch{/if}!
Play a counteraction, or
{:else} {:else}
Respond to the counteraction, or Your spell is countered — respond by your hand.
{/if} {/if}
<button class="stamp tiny" onclick={pass}>let it resolve</button>
</div> </div>
{:else if view.stack} {:else if view.stack}
<div class="slip">Waiting on {view.stack.waitingOn}</div> <div class="slip">Waiting on {view.stack.waitingOn}</div>
@@ -864,8 +893,20 @@
<button class="hint-cancel" onclick={clearSelection}>cancel</button> <button class="hint-cancel" onclick={clearSelection}>cancel</button>
</div> </div>
{/if} {/if}
{#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0} {#if youMustRespond || selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip"> <div class="hint-strip">
{#if youMustRespond && view.stack}
<span class="hint-alert">
{#if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with
{view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
tap a counteraction card, or
{:else}
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
{/if}
</span>
<button class="stamp tiny" onclick={pass}>let it resolve</button>
{/if}
{#if youMustDiscard} {#if youMustDiscard}
<span class="hint-alert">Hand over the limit — mark cards and discard.</span> <span class="hint-alert">Hand over the limit — mark cards and discard.</span>
{:else if discardMode && discardSelection.size === 0} {:else if discardMode && discardSelection.size === 0}
@@ -1470,6 +1511,40 @@
.actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; } .actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; }
.draw-pick { color: #a49c86; font-size: 0.9rem; } .draw-pick { color: #a49c86; font-size: 0.9rem; }
.attack-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 45;
padding: 1rem;
}
.attack-notice {
background: #efe8d4;
border: 2px solid #43331f;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
padding: 1.2rem 1.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
max-width: min(22rem, 92vw);
text-align: center;
}
.attack-headline {
font-family: "Oswald", sans-serif;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #b3372b;
}
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.inspector { .inspector {
position: fixed; position: fixed;
right: 0.9rem; right: 0.9rem;
@@ -1503,6 +1578,40 @@
cursor: pointer; cursor: pointer;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.attack-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 45;
padding: 1rem;
}
.attack-notice {
background: #efe8d4;
border: 2px solid #43331f;
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
padding: 1.2rem 1.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
max-width: min(22rem, 92vw);
text-align: center;
}
.attack-headline {
font-family: "Oswald", sans-serif;
font-size: 1.15rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #b3372b;
}
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; }
.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); }
} }