Waving Hands: a browser duel after Bartle's 1977 game
SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
865555e911
38 files changed
+8112
No files matched your search
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
|
||||
import GestureIcon from './GestureIcon.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
|
||||
|
||||
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: you must repeat last turn.'
|
||||
: duel.you.constraints.paralysedHand === hand
|
||||
? 'Paralysed in last turn’s position.'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<fieldset class="hand">
|
||||
<legend>{hand === 'left' ? 'Left hand' : '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> Charmed: {duel.foe.name} chooses this hand’s 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: no clap, digit, fingers or snap this turn.</p>
|
||||
{/if}
|
||||
{#if set === 'main' && duel.showPlans && 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 if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
|
||||
<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 options.length > 0}
|
||||
<div class="cast">
|
||||
<label>
|
||||
<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>
|
||||
<span>at</span>
|
||||
<select bind:value={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>
|
||||
<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>
|
||||
<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:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.cast label {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.cast label span {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--frost);
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--frost);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user