Files
waving-hands/src/lib/components/HandPicker.svelte
T
Eric WagonerandClaude Opus 5.5 ee630276b3 Easier to make a move, and plainer words around the game
From feedback on the BoardGameGeek thread. The hands are framed and
labelled as yours, so they no longer read as sitting under your
opponent's columns. A move can be typed: the first letter goes to the
left hand, the second to the right, K stabs, a dash rests, Backspace
takes one back and Enter seals. The chosen pair, what it will do, any
warning and the button now sit in one row that stays in view. A clap
with one hand is warned of before the turn is sealed. The button says
what the player does: Seal the turn. The chronicle reads oldest first
and scrolls to the newest line. Headings, tabs and notes lose the
label-and-colon construction and the abstract headings; the chronicle's
imagery stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:29:49 -04:00

303 lines
7.8 KiB
Svelte

<script lang="ts">
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
import GestureIcon from './GestureIcon.svelte';
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
const draft = $derived(duel.sets[set][hand]);
const forced = $derived(duel.forcedFor(set, hand));
const charmed = $derived(duel.charmedHand === hand);
const options = $derived(duel.completions[set][hand]);
const chosen = $derived(duel.chosen[set][hand]);
const spell = $derived(chosen?.spell);
const other = $derived<Hand>(hand === 'left' ? 'right' : 'left');
const key = $derived(castKey(set, hand));
const banking = $derived(duel.willBank && castKey(duel.willBank.set, duel.willBank.hand) === key);
const extending = $derived(duel.willExtend && castKey(duel.willExtend.set, duel.willExtend.hand) === key);
let morePlans = $state(false);
const plans = $derived(morePlans ? duel.plans[hand] : duel.plans[hand].slice(0, duel.planLimit(hand)));
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
const whyForced = $derived(
duel.you.constraints.amnesia
? 'Amnesia holds this hand to last turn\'s gesture.'
: duel.you.constraints.paralysedHand === hand
? "Paralysis has locked this hand in last turn's position."
: ''
);
</script>
<fieldset class="hand">
<legend>{hand === 'left' ? 'Your left hand' : 'Your right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
{#if forced}
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
{:else if charmed}
<p class="locked"><span class="letter big">?</span> {duel.foe.name} has charmed this hand and chooses its gesture.</p>
{:else}
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
{#each GESTURES as g (g)}
<button
type="button"
class="gesture"
class:on={draft.gesture === g}
disabled={!duel.allowed[hand].includes(g)}
aria-pressed={draft.gesture === g}
onclick={() => duel.choose(set, hand, g)}
>
<GestureIcon gesture={g} {hand} />
<span class="letter">{glyph(g)}</span>
<span class="name">{GESTURE_SHORT[g]}</span>
</button>
{/each}
</div>
{#if duel.you.constraints.fear && !duel.timeStopped}
<p class="hint">Fear keeps this hand from clapping, pointing, wriggling or snapping this turn.</p>
{/if}
{#if set === 'main' && duel.showPlans}
{#if duel.plans[hand].length === 0}
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
{:else}
<ul class="plans" aria-label="{hand} hand, spells under way">
{#each plans as plan (plan.spell.id)}
<li class:pinned={plan.pinned}>
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
<span class="pname">{plan.spell.name}</span>
</button>
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
</li>
{/each}
{#if hiddenPlans > 0 || morePlans}
<li class="more">
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
</li>
{/if}
</ul>
{/if}
{/if}
{/if}
{#if options.length > 0}
<div class="cast">
<label class="field">
<span>Finishes</span>
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
{#each options as c (c.spell.id + c.seqIndex)}
<option value={c.spell.id}>{c.spell.name} ({c.spell.sequences[c.seqIndex]})</option>
{/each}
<option value="">nothing, let it pass</option>
</select>
</label>
{#if spell}
<label class="field">
<span>at</span>
<select bind:value={draft.target} onchange={() => duel.focusOn(draft.target)}>
{#each duel.targetsFor(spell) as t (t.id)}
<option value={t.id}>{t.label}</option>
{/each}
</select>
</label>
{#if spell.id === 'summon_elemental'}
<label class="field">
<span>of</span>
<select bind:value={draft.elemental}>
<option value="fire">fire</option>
<option value="ice">ice</option>
</select>
</label>
{/if}
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
<label class="field">
<span>their</span>
<select bind:value={draft.chosenHand}>
<option value="left">left hand</option>
<option value="right">right hand</option>
</select>
</label>
{/if}
<p class="summary">
{spell.summary}
{#if banking}<strong>This spell will be banked by your delayed effect, not cast.</strong>{/if}
{#if extending}<strong>Your permanency will make this enchantment last the whole duel.</strong>{/if}
</p>
{/if}
</div>
{/if}
</fieldset>
<style>
.hand {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
legend {
font-style: italic;
font-size: 1.1rem;
padding: 0;
margin-bottom: 0.4rem;
}
.again {
color: var(--frost);
}
.gestures {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
}
.gesture {
background: var(--slate-raised);
border: 1px solid transparent;
border-radius: 4px;
padding: 0.3rem 0.2rem 0.25rem;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
color: var(--bone);
transition: background 120ms ease;
}
.gesture:hover:not(:disabled) {
border-color: var(--rule-strong);
}
.gesture.on {
background: var(--ember);
color: var(--slate-deep);
}
.gesture .letter {
font-size: 1.25rem;
margin-top: 0.05rem;
}
.gesture .name {
font-size: 0.65rem;
margin-top: 0.2rem;
opacity: 0.85;
}
.locked {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--bone-dim);
background: var(--slate-raised);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.big {
font-size: 1.8rem;
color: var(--bone);
}
.cast {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
}
.plans {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
font-size: 0.82rem;
line-height: 1.4;
}
.plans li {
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.plan {
flex: 1;
min-width: 0;
background: none;
border: 0;
padding: 0.1rem 0.2rem;
text-align: left;
display: flex;
gap: 0.6rem;
align-items: baseline;
border-radius: 3px;
}
.plan:hover {
background: var(--slate-raised);
}
.seq {
letter-spacing: 0.04em;
white-space: nowrap;
color: var(--bone-dim);
}
.seq .done {
color: var(--ember);
}
.seq .arrow {
color: var(--bone-faint);
margin: 0 0.35em;
}
.seq .next {
color: var(--bone);
}
.pname {
color: var(--bone-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
li.pinned .pname {
color: var(--frost);
}
li.more .plan {
color: var(--frost);
font-size: 0.8rem;
}
.pin {
background: none;
border: 0;
color: var(--bone-faint);
padding: 0 0.2rem;
font-size: 0.8rem;
}
li.pinned .pin {
color: var(--frost);
}
.summary,
.hint {
width: 100%;
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.3rem;
}
.summary strong {
display: block;
font-weight: 500;
color: var(--frost);
}
</style>