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:
@@ -0,0 +1,541 @@
|
||||
<script lang="ts">
|
||||
import Chronicle from '$lib/components/Chronicle.svelte';
|
||||
import HandPicker from '$lib/components/HandPicker.svelte';
|
||||
import Ledger from '$lib/components/Ledger.svelte';
|
||||
import MobileBar from '$lib/components/MobileBar.svelte';
|
||||
import SpellSheet from '$lib/components/SpellSheet.svelte';
|
||||
import TurnSummary from '$lib/components/TurnSummary.svelte';
|
||||
import WizardStatus from '$lib/components/WizardStatus.svelte';
|
||||
import { duel, FOE, YOU, castKey, tokensText } from '$lib/game/duel.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
let confirmingNew = $state(false);
|
||||
|
||||
function startNew() {
|
||||
if (duel.inProgress && !confirmingNew) {
|
||||
confirmingNew = true;
|
||||
return;
|
||||
}
|
||||
confirmingNew = false;
|
||||
duel.newGame();
|
||||
}
|
||||
|
||||
// Save the duel whenever anything about it changes, and follow the window width.
|
||||
$effect(() => {
|
||||
duel.persist();
|
||||
});
|
||||
$effect(() => {
|
||||
const query = window.matchMedia('(max-width: 860px)');
|
||||
const apply = () => (duel.compact = query.matches);
|
||||
apply();
|
||||
query.addEventListener('change', apply);
|
||||
return () => query.removeEventListener('change', apply);
|
||||
});
|
||||
|
||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner === FOE));
|
||||
const outcome = $derived(duel.state.over);
|
||||
const won = $derived(outcome?.winner === YOU);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Waving Hands</title>
|
||||
<meta name="description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game." />
|
||||
</svelte:head>
|
||||
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<h1>Waving Hands</h1>
|
||||
{#if duel.state.turn === 0 && !duel.timeStopped}
|
||||
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>. Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.</p>
|
||||
{:else}
|
||||
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<nav class="actions">
|
||||
<a class="quiet" href="/rules">Rules</a>
|
||||
<button type="button" class="quiet" onclick={startNew}>New duel</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{#if confirmingNew}
|
||||
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
|
||||
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
|
||||
<div class="choices">
|
||||
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
|
||||
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<Ledger {duel} />
|
||||
<TurnSummary {duel} />
|
||||
|
||||
{#if outcome}
|
||||
<div class="verdict" class:won>
|
||||
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
|
||||
<p>{outcome.reason}</p>
|
||||
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
|
||||
</div>
|
||||
{:else}
|
||||
<section class="move" aria-labelledby="move-heading">
|
||||
{#if duel.timeStopped}
|
||||
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
|
||||
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
|
||||
{:else}
|
||||
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
|
||||
{/if}
|
||||
|
||||
{#if duel.timeStopped}
|
||||
<!-- nobody else moves -->
|
||||
{:else if duel.threats.length}
|
||||
<div class="threats">
|
||||
{#if duel.threatsNow.length}
|
||||
<p>Their hands could finish this turn:</p>
|
||||
<ul>
|
||||
{#each duel.threatsNow as t (t.hand + t.spell.id)}
|
||||
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from}
|
||||
<li>
|
||||
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if duel.threatsNext.length}
|
||||
<details class="next" open={!duel.compact}>
|
||||
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
|
||||
<ul>
|
||||
{#each duel.threatsNext as t (t.hand + t.spell.id)}
|
||||
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
|
||||
<li>
|
||||
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="threats muted">Nothing hostile is one gesture away.</p>
|
||||
{/if}
|
||||
|
||||
<div class="hands">
|
||||
<HandPicker {duel} hand="left" />
|
||||
<HandPicker {duel} hand="right" />
|
||||
</div>
|
||||
<div class="prefs">
|
||||
<label class="pref">
|
||||
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
|
||||
Show spells under way beneath each hand
|
||||
</label>
|
||||
<label class="pref">
|
||||
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
|
||||
Explain rules as they come up
|
||||
</label>
|
||||
</div>
|
||||
{#if duel.hasted}
|
||||
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
|
||||
<div class="hands">
|
||||
<HandPicker {duel} hand="left" set="haste" />
|
||||
<HandPicker {duel} hand="right" set="haste" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="orders">
|
||||
{#if duel.stabbing}
|
||||
<label>
|
||||
<span>Stab</span>
|
||||
<select bind:value={duel.stabTarget}>
|
||||
<option value={FOE}>{duel.foe.name}</option>
|
||||
{#each foeMonsters as m (m.id)}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#each duel.yourMonsters as m (m.id)}
|
||||
<label>
|
||||
<span>{m.name} attacks</span>
|
||||
<select bind:value={duel.monsterOrders[m.id]}>
|
||||
<option value={FOE}>{duel.foe.name}</option>
|
||||
{#each foeMonsters as fm (fm.id)}
|
||||
<option value={fm.id}>{fm.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/each}
|
||||
{#if duel.bankedSpell}
|
||||
<label>
|
||||
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
|
||||
<select bind:value={duel.release}>
|
||||
<option value="">not yet</option>
|
||||
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
|
||||
<option value={t.id}>at {t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.bankCandidates.length > 1}
|
||||
<label>
|
||||
<span>Bank</span>
|
||||
<select bind:value={duel.bankPick}>
|
||||
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
|
||||
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.permanentCandidates.length > 1}
|
||||
<label>
|
||||
<span>Make permanent</span>
|
||||
<select bind:value={duel.permanentPick}>
|
||||
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
|
||||
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.youCharmedFoe}
|
||||
<label>
|
||||
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
|
||||
<select bind:value={duel.charmGesture}>
|
||||
{#each MAGIC_GESTURES as g (g)}
|
||||
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if duel.surrendering}
|
||||
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
|
||||
{/if}
|
||||
{#if duel.conflict}
|
||||
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
<WizardStatus wizard={duel.foe} monsters={foeMonsters} />
|
||||
<SpellSheet {duel} />
|
||||
<Chronicle log={duel.state.log} />
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<MobileBar {duel} />
|
||||
|
||||
<footer class="colophon">
|
||||
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem var(--gutter) 0.6rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||
font-weight: 300;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.standfirst {
|
||||
max-width: 38em;
|
||||
color: var(--bone-dim);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.standfirst em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
white-space: nowrap;
|
||||
color: var(--bone);
|
||||
text-decoration: none;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.quiet:hover {
|
||||
border-color: var(--bone);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0.75rem;
|
||||
padding: 0.75rem var(--gutter);
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
color: var(--blood);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.choices {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
details.next summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
details.next summary::before {
|
||||
content: '\25B8 ';
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
details.next[open] summary::before {
|
||||
content: '\25BE ';
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||
padding: 0 var(--gutter);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.play {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.move {
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.move h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.threats {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.threats ul {
|
||||
list-style: none;
|
||||
margin: 0.15rem 0 0.4rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.threat {
|
||||
background: var(--slate);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.threat.soon {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.threat:hover,
|
||||
.threat.active {
|
||||
border-color: var(--frost);
|
||||
}
|
||||
|
||||
.threat.active {
|
||||
background: rgba(140, 200, 224, 0.12);
|
||||
}
|
||||
|
||||
.thand {
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tseq {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tseq strong {
|
||||
font-weight: 600;
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.prefs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.pref {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.hands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.orders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.orders label {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.orders label span {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.haste-note {
|
||||
color: var(--frost);
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
margin-top: 0.8rem;
|
||||
background: var(--bone);
|
||||
color: var(--slate-deep);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 1.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reveal:hover:not(:disabled) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.reveal:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.verdict h2 {
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.verdict.won h2 {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.verdict p {
|
||||
margin: 0.3rem 0 0.8rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--gutter) 3rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
/* The sticky bar carries the reveal button on phones. */
|
||||
.move > .reveal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
order: 2;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hands {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user