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
303 lines
7.8 KiB
Svelte
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>
|