From 6189b4935c04290e756d4ba46c959fef7414cf3f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 30 Aug 2026 16:12:12 -0400 Subject: [PATCH] The casting panel: the rail becomes the spell while you build it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a card is selected (or an attack demands an answer) on a wide screen, the chronicle and chat yield their column to a casting panel: the card at reading size, attached numbers and modifiers as cards with their total, and the whole guidance strip — targeting hints, pickers, discard, cancel — moved in beside them. Counteractions get the same panel with the incoming card, its power, and every counter played so far. The under-board strip remains for phones, where a rail below the fold is no place to aim from. The guidance markup extracts into one snippet rendered wherever the mode calls it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/App.svelte | 478 +++++++++++++++++++++--------------- 1 file changed, 283 insertions(+), 195 deletions(-) diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index be91f5b..64f5337 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -437,6 +437,16 @@ const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]); const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner", "power-attack"]); + /** Casting panel: on wide screens the paper-rail's chronicle yields to + * the spell being built (or answered), controls and all — table talk + * returns the moment the cast resolves. Phones keep the under-board + * strips; a rail below the fold is no place to aim from. */ + const wideQuery = typeof matchMedia === "undefined" ? null : matchMedia("(min-width: 901px)"); + let wideScreen = $state(wideQuery?.matches ?? true); + wideQuery?.addEventListener("change", (e) => (wideScreen = e.matches)); + const castPanelOn = $derived(wideScreen && !local.active && + (selectedCard != null || (youMustRespond && !!view?.stack))); + /** The hand in the player's own order: drag a card onto another to take * its place. The order lives only in this browser — the server's hand * array is authority for contents, this for arrangement. */ @@ -2095,6 +2105,201 @@ {:else if view}
+ {#snippet tableGuidance()} + {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0} +
+ {#if selectedCreature && !selectedDef} + {@const sc = view.creatures.find((c) => c.id === selectedCreature)} + {#if sc} + Commanding {cardDef(sc.kind).name} — {creatureStats(sc)}. + Tap a square beside it to march, a target in its square to attack. + {#if dmWarnCell} + ⚠ it will claw YOU the moment it enters your square — tap again if you mean it + {/if} + + {/if} + {/if} + {#if youMustRespond && view.stack} + + {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"} + {pendingTeleport + ? "Escaping by teleport — tap the marked square again to jump." + : "Escaping by teleport — tap a square within four spaces to mark it."} + {:else if view.stack.defenderId === view.you} + {view.stack.attackerId} attacks with + {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` + : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId + ? `an illusionary ${cardDef(view.stack.params.cardId).name}` + : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name + : view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} — + tap a counteraction card, or + {:else} + {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} + {/if} + + + + {/if} + {#if youMustDiscard} + Hand over the limit — mark cards and discard. + {:else if discardMode && discardSelection.size === 0} + Mark the cards to throw away, then confirm. + {/if} + {#if discardSelection.size > 0} + + {/if} + {#if discardMode || (discardSelection.size > 0 && !youMustDiscard)} + + {/if} + {#if selectedDef} + {selectedDef.name} + {#if pendingCellFor} + — now click where {pendingCellFor} goes{selectedCard?.cardId === "mental-force" + ? " (within three walked spaces of them — no sight needed)" : ""} + {/if} + {#if selectedCard && EDGE_CARDS.has(selectedCard.cardId)} + — {EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"} + {/if} + {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber} + — first use sets the charges: tap a NUMBER card first, or cast bare for a single charge + {:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")} + — click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""} + {/if} + {#if attachedMods.length > 0} + [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}] + {/if} + {#if attachedMods.some((m) => m.cardId === "power-attack")} + + {/if} + {#if selectedCard?.cardId === "boobytrap"} + — place 4 tokens ({trapCells.length}/4; the first is real) + {/if} + {#if selectedCard?.cardId === "redirection"} + {tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"} + {:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)} + {tradeFrom ? "— now the second square" : "— click the first square"} + {/if} + {#if selectedCard?.cardId === "teleport" && !youMustRespond} + {pendingTeleport + ? "— tap the marked square again to jump, or pick another" + : "— tap a destination (up to four squares, walls ignored)"} + {#if pendingTeleport} + + {/if} + {/if} + {#if selectedCard?.cardId === "mega-monster"} + — double its + + + — then tap the monster + {/if} + {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} + + {/if} + {#if selectedCard?.cardId === "master-key" && isYourTurn && + !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)} + + {/if} + {#if selectedCard?.cardId === "rotate-sector"} + + — click the sector + {/if} + {#if selectedCard?.cardId === "relocate-sector"} + {pendingSectorFrom + ? (relocateGhosts?.length + ? "— now tap a dashed landing beside the maze" + : "— that sector has nowhere legal to go; pick another") + : "— click the sector to move"} + {/if} + {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)} + + + {#each nameSuggestions as n (n)}{/each} + + {#if selectedCard.cardId === "deja-vu"} + + {:else} + — then click the target + {/if} + {/if} + {#if selectedCard?.cardId === "swap-meet"} + {#if !swapMeetTarget} + — click the other trader + {:else if myTradables.length === 0} + — you carry no items to trade + {:else if !swapMine} + — trade away your: + {#each myTradables as c (c.key)} + + {/each} + {:else if theirTradables.length === 0} + — they show no item you could claim + {:else} + — and take their: + {#each theirTradables as c (c.key)} + + {/each} + {/if} + {/if} + {#if selectedCard?.cardId === "waterbolt"} + + (knockback {numberTotal - wbDamage}) + {/if} + {#if selectedCard?.cardId === "power-run"} + + + {/if} + {#if selectedCard && confirmCastable(selectedCard.cardId)} + + {/if} + {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn} + + {/if} + {#if selectedCard && isYourTurn && !youMustRespond} + + {/if} + {#if selectedCard?.cardId === "add"} + — ADD rides a second NUMBER: play one number for movement, + then play another, and the Add spends itself + {/if} + {#if selectedCard && isNumberCard(selectedCard.cardId)} + {@const needsAdd = view.turn.numberPlayedForMovement} + {@const haveAdd = view.yourHand.some((c) => c.cardId === "add")} + {#if needsAdd && !haveAdd} + — a second movement number needs an ADD card + {:else} + + {/if} + {/if} + + {/if} +
+ {/if} + {/snippet}
{#if prefs.liveFp && view.you && !net.spectating} @@ -2331,7 +2536,51 @@
-
+ {#if youMustRespond && view.stack} +
⚔ incoming
+ {#if view.stack.attackCard} +
(faqCardId = id)} />
+ {:else} +
a physical blow — no card behind it
+ {/if} + {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} +
+ {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} + {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} +
+ {/if} + {#if view.stack.counters.length > 0} +
counters so far
+
+ {#each view.stack.counters as c (c.card.instanceId)} + + {}} /> + {/each} +
+ {/if} + {:else if selectedCard} +
(faqCardId = id)} />
+ {#if attachedNumber || attachedMods.length > 0} +
riding along
+
+ {#if attachedNumber} + {}} /> + {/if} + {#each attachedMods as m (m.instanceId)} + {}} /> + {/each} +
+ {#if attachedNumber} +
number total {numberTotal}
+ {/if} + {/if} + {/if} + {@render tableGuidance()} +
+ {/if} +
{#if local.active} {#each local.log as line, i (i)} @@ -2355,7 +2604,7 @@ onclick={() => local.rollTableDie()}>🎲 roll the die
{/if} - {#if !local.active && net.roomId && !net.spectating} + {#if !local.active && net.roomId && !net.spectating && !castPanelOn}
{ e.preventDefault(); const t = chatDraft.trim(); @@ -2391,198 +2640,8 @@ {/if} - {#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0} -
- {#if selectedCreature && !selectedDef} - {@const sc = view.creatures.find((c) => c.id === selectedCreature)} - {#if sc} - Commanding {cardDef(sc.kind).name} — {creatureStats(sc)}. - Tap a square beside it to march, a target in its square to attack. - {#if dmWarnCell} - ⚠ it will claw YOU the moment it enters your square — tap again if you mean it - {/if} - - {/if} - {/if} - {#if youMustRespond && view.stack} - - {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"} - {pendingTeleport - ? "Escaping by teleport — tap the marked square again to jump." - : "Escaping by teleport — tap a square within four spaces to mark it."} - {:else if view.stack.defenderId === view.you} - {view.stack.attackerId} attacks with - {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` - : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId - ? `an illusionary ${cardDef(view.stack.params.cardId).name}` - : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name - : view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} — - tap a counteraction card, or - {:else} - {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} - {/if} - - - - {/if} - {#if youMustDiscard} - Hand over the limit — mark cards and discard. - {:else if discardMode && discardSelection.size === 0} - Mark the cards to throw away, then confirm. - {/if} - {#if discardSelection.size > 0} - - {/if} - {#if discardMode || (discardSelection.size > 0 && !youMustDiscard)} - - {/if} - {#if selectedDef} - {selectedDef.name} - {#if pendingCellFor} - — now click where {pendingCellFor} goes{selectedCard?.cardId === "mental-force" - ? " (within three walked spaces of them — no sight needed)" : ""} - {/if} - {#if selectedCard && EDGE_CARDS.has(selectedCard.cardId)} - — {EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"} - {/if} - {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber} - — first use sets the charges: tap a NUMBER card first, or cast bare for a single charge - {:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")} - — click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""} - {/if} - {#if attachedMods.length > 0} - [+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}] - {/if} - {#if attachedMods.some((m) => m.cardId === "power-attack")} - - {/if} - {#if selectedCard?.cardId === "boobytrap"} - — place 4 tokens ({trapCells.length}/4; the first is real) - {/if} - {#if selectedCard?.cardId === "redirection"} - {tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"} - {:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)} - {tradeFrom ? "— now the second square" : "— click the first square"} - {/if} - {#if selectedCard?.cardId === "teleport" && !youMustRespond} - {pendingTeleport - ? "— tap the marked square again to jump, or pick another" - : "— tap a destination (up to four squares, walls ignored)"} - {#if pendingTeleport} - - {/if} - {/if} - {#if selectedCard?.cardId === "mega-monster"} - — double its - - - — then tap the monster - {/if} - {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} - - {/if} - {#if selectedCard?.cardId === "master-key" && isYourTurn && - !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)} - - {/if} - {#if selectedCard?.cardId === "rotate-sector"} - - — click the sector - {/if} - {#if selectedCard?.cardId === "relocate-sector"} - {pendingSectorFrom - ? (relocateGhosts?.length - ? "— now tap a dashed landing beside the maze" - : "— that sector has nowhere legal to go; pick another") - : "— click the sector to move"} - {/if} - {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)} - - - {#each nameSuggestions as n (n)}{/each} - - {#if selectedCard.cardId === "deja-vu"} - - {:else} - — then click the target - {/if} - {/if} - {#if selectedCard?.cardId === "swap-meet"} - {#if !swapMeetTarget} - — click the other trader - {:else if myTradables.length === 0} - — you carry no items to trade - {:else if !swapMine} - — trade away your: - {#each myTradables as c (c.key)} - - {/each} - {:else if theirTradables.length === 0} - — they show no item you could claim - {:else} - — and take their: - {#each theirTradables as c (c.key)} - - {/each} - {/if} - {/if} - {#if selectedCard?.cardId === "waterbolt"} - - (knockback {numberTotal - wbDamage}) - {/if} - {#if selectedCard?.cardId === "power-run"} - - - {/if} - {#if selectedCard && confirmCastable(selectedCard.cardId)} - - {/if} - {#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn} - - {/if} - {#if selectedCard && isYourTurn && !youMustRespond} - - {/if} - {#if selectedCard?.cardId === "add"} - — ADD rides a second NUMBER: play one number for movement, - then play another, and the Add spends itself - {/if} - {#if selectedCard && isNumberCard(selectedCard.cardId)} - {@const needsAdd = view.turn.numberPlayedForMovement} - {@const haveAdd = view.yourHand.some((c) => c.cardId === "add")} - {#if needsAdd && !haveAdd} - — a second movement number needs an ADD card - {:else} - - {/if} - {/if} - - {/if} -
+ {#if !castPanelOn} + {@render tableGuidance()} {/if}
@@ -2629,7 +2688,7 @@ {/if}
- {#if selectedCard && !inspectorHidden} + {#if selectedCard && !inspectorHidden && !castPanelOn}