Six answers to the first wave of player feedback
The turn slip says 'turn spent' instead of counting phantom moves once actions end; the attacking card (or the counter being countered) shows in the respond slip so counters are chosen with eyes open; the absorb chronicle line names the card it swallowed — which answers the KTXQ mystery, where an automaton's ABSORB SPELL took Blind into its hand and Reuse Spell rightly found nothing to retrieve. POWER ATTACK gains its missing UI: attach it like any modifier and pick the life to burn. Hand cards drag to reorder (browser-local arrangement over the server's authoritative contents), and the left-hand layout scales its two columns to the space without ever growing a scrollbar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
e8a7fa1b73
commit
9aaccf4f30
@@ -436,7 +436,57 @@
|
|||||||
]);
|
]);
|
||||||
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
|
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
|
||||||
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
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<string[]>([]);
|
||||||
|
let draggingId = $state<string | null>(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<HTMLDivElement | null>(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 NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
|
||||||
const attackVsWall = $derived(
|
const attackVsWall = $derived(
|
||||||
@@ -486,6 +536,10 @@
|
|||||||
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
|
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
|
||||||
else if (m.cardId === "extend") cmd.extendInstanceId = 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 === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
|
||||||
|
else if (m.cardId === "power-attack") {
|
||||||
|
cmd.powerAttackInstanceId = m.instanceId;
|
||||||
|
cmd.powerAttackPoints = paPoints;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2120,8 +2174,17 @@
|
|||||||
<button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
|
<button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
|
||||||
{:else if view.stack?.defenderId === view.you}
|
{:else if view.stack?.defenderId === view.you}
|
||||||
Under attack — respond by your hand.
|
Under attack — respond by your hand.
|
||||||
|
{#if view.stack.attackCard}
|
||||||
|
<span class="respond-card"><Card card={view.stack.attackCard} onclick={() => {}} /></span>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
Your spell is countered — respond by your hand.
|
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}
|
||||||
|
<span class="respond-card"><Card card={latest.card} onclick={() => {}} /></span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if view.stack}
|
{:else if view.stack}
|
||||||
@@ -2131,7 +2194,11 @@
|
|||||||
{:else if isYourTurn}
|
{:else if isYourTurn}
|
||||||
<div class="slip yours">
|
<div class="slip yours">
|
||||||
Your turn — round {view.turn.round}.
|
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}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="slip">{view.activePlayerId} is taking their turn…</div>
|
<div class="slip">{view.activePlayerId} is taking their turn…</div>
|
||||||
@@ -2386,6 +2453,15 @@
|
|||||||
{#if attachedMods.length > 0}
|
{#if attachedMods.length > 0}
|
||||||
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
|
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if attachedMods.some((m) => m.cardId === "power-attack")}
|
||||||
|
<label class="inline">burn
|
||||||
|
<select class="tier-pick" bind:value={paPoints} aria-label="life points to burn">
|
||||||
|
{#each [1, 2, 3, 4, 5] as n (n)}
|
||||||
|
{#if me && n < me.life}<option value={n}>{n}</option>{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
life for +{paPoints} damage</label>
|
||||||
|
{/if}
|
||||||
{#if selectedCard?.cardId === "boobytrap"}
|
{#if selectedCard?.cardId === "boobytrap"}
|
||||||
<span>— place 4 tokens ({trapCells.length}/4; the first is real)</span>
|
<span>— place 4 tokens ({trapCells.length}/4; the first is real)</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -2589,11 +2665,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
|
<div class="hand" bind:this={handEl} class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
|
||||||
{#if net.spectating}
|
{#if net.spectating}
|
||||||
<span class="gallery-note">👁 You watch from the Peanut Gallery — hands stay secret, even from you.</span>
|
<span class="gallery-note">👁 You watch from the Peanut Gallery — hands stay secret, even from you.</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
|
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
|
||||||
|
<div class="hand-slot"
|
||||||
|
draggable="true"
|
||||||
|
role="listitem"
|
||||||
|
ondragstart={(e) => { draggingId = card.instanceId; e.dataTransfer?.setData("text/plain", card.instanceId); }}
|
||||||
|
ondragover={(e) => e.preventDefault()}
|
||||||
|
ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }}
|
||||||
|
ondragend={() => (draggingId = null)}>
|
||||||
<Card
|
<Card
|
||||||
{card}
|
{card}
|
||||||
selected={selectedCard?.instanceId === card.instanceId ||
|
selected={selectedCard?.instanceId === card.instanceId ||
|
||||||
@@ -2608,6 +2691,7 @@
|
|||||||
onclick={() => selectCard(card)}
|
onclick={() => selectCard(card)}
|
||||||
onfaq={(id) => (faqCardId = id)}
|
onfaq={(id) => (faqCardId = id)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2697,6 +2781,9 @@
|
|||||||
}
|
}
|
||||||
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
|
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
|
||||||
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
|
.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; }
|
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
||||||
.mind-read { max-width: min(92vw, 46rem); }
|
.mind-read { max-width: min(92vw, 46rem); }
|
||||||
.caution-reason { padding: 0.15rem 0; }
|
.caution-reason { padding: 0.15rem 0; }
|
||||||
@@ -3121,8 +3208,9 @@
|
|||||||
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
|
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
|
||||||
@media (min-width: 901px) {
|
@media (min-width: 901px) {
|
||||||
.game.hand-left {
|
.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 { display: contents; }
|
||||||
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
|
||||||
.game.hand-left .table-edge > .hand {
|
.game.hand-left .table-edge > .hand {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
}
|
}
|
||||||
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
|
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 "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":
|
case "attackResolved":
|
||||||
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
||||||
if (e.fullyStopped) return `The attack is completely stopped.`;
|
if (e.fullyStopped) return `The attack is completely stopped.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user