diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a75f36c..6d9f051 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -436,7 +436,57 @@ ]); 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"]); + const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner", "power-attack"]); + /** 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. */ + let handOrder = $state([]); + let draggingId = $state(null); + const orderedHand = $derived.by(() => { + const hand = view?.yourHand ?? []; + const rank = new Map(handOrder.map((id, i) => [id, i])); + return [...hand].sort((a, b) => + (rank.get(a.instanceId) ?? handOrder.length) - (rank.get(b.instanceId) ?? handOrder.length)); + }); + function dropCardOn(targetId: string) { + if (!draggingId || draggingId === targetId) return; + const ids = orderedHand.map((c) => c.instanceId); + const from = ids.indexOf(draggingId); + const to = ids.indexOf(targetId); + if (from === -1 || to === -1) return; + ids.splice(to, 0, ...ids.splice(from, 1)); + handOrder = ids; + draggingId = null; + } + + /** Hand-at-left card scale: the two columns grow to fill their column's + * width, capped so the full hand still fits without a scrollbar. */ + let handEl = $state(null); + function fitHandCards(el: HTMLDivElement) { + if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; } + const cardW = 8.2 * 16, gap = 0.45 * 16, pad = 0.6 * 16; + const zoomW = (el.clientWidth - pad - gap) / (2 * cardW); + const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2)); + const cardH = 1.42 * cardW; + const maxH = window.innerHeight - 5 * 16; + const zoomH = (maxH - pad) / (rows * (cardH + gap)); + el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3))); + } + $effect(() => { + const el = handEl; + if (!el) return; + const ro = new ResizeObserver(() => fitHandCards(el)); + ro.observe(el); + return () => ro.disconnect(); + }); + $effect(() => { + void view?.yourHand.length; + void prefs.handLeft; + const el = handEl; + if (el) requestAnimationFrame(() => fitHandCards(el)); + }); + /** POWER ATTACK: life points burned into the attached attack. */ + let paPoints = $state(1); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); const attackVsWall = $derived( @@ -486,6 +536,10 @@ else if (m.cardId === "add") cmd.addInstanceId = m.instanceId; else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId; else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId; + else if (m.cardId === "power-attack") { + cmd.powerAttackInstanceId = m.instanceId; + cmd.powerAttackPoints = paPoints; + } } } @@ -2120,8 +2174,17 @@ {:else if view.stack?.defenderId === view.you} Under attack — respond by your hand. + {#if view.stack.attackCard} + {}} /> + {/if} {:else} Your spell is countered — respond by your hand. + {#if view.stack?.counters.length} + {@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)} + {#if latest} + {}} /> + {/if} + {/if} {/if} {:else if view.stack} @@ -2131,7 +2194,11 @@ {:else if isYourTurn}
Your turn — round {view.turn.round}. - {view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} + {#if view.turn.actionsEnded} + turn spent — end it when ready + {:else} + {view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""} + {/if}
{:else}
{view.activePlayerId} is taking their turn…
@@ -2386,6 +2453,15 @@ {#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} @@ -2589,11 +2665,18 @@ {/if} -
+
{#if net.spectating} 👁 You watch from the Peanut Gallery — hands stay secret, even from you. {/if} - {#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)} + {#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)} +
{ draggingId = card.instanceId; e.dataTransfer?.setData("text/plain", card.instanceId); }} + ondragover={(e) => e.preventDefault()} + ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }} + ondragend={() => (draggingId = null)}> selectCard(card)} onfaq={(id) => (faqCardId = id)} /> +
{/each}
@@ -2697,6 +2781,9 @@ } .fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; } .fb-thanks { color: #43331f; padding: 0.6rem 0; } + .respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; } + .hand-slot { display: flex; cursor: grab; } + .hand-slot:active { cursor: grabbing; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } .mind-read { max-width: min(92vw, 46rem); } .caution-reason { padding: 0.15rem 0; } @@ -3121,8 +3208,9 @@ * Phones keep the bottom hand; the one-column flow re-orders anyway. */ @media (min-width: 901px) { .game.hand-left { - grid-template-columns: minmax(17.4rem, 18.2rem) minmax(340px, 1fr) minmax(250px, 330px); + grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px); } + .game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); } .game.hand-left .table-edge { display: contents; } .game.hand-left .table-edge > * { grid-column: 2; min-width: 0; } .game.hand-left .table-edge > .hand { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 5453cc4..96207ba 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -45,7 +45,7 @@ export function humanize(e: GameEvent): string | null { } case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`; case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`; - case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`; + case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`; case "attackResolved": if (e.redirected) return `The spell is reflected back at ${e.attacker}!`; if (e.fullyStopped) return `The attack is completely stopped.`;