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
This commit is contained in:
Eric WagonerandClaude Opus 5.5 committed 2026-10-10 18:29:49 -04:00
1 parent db682814ea
commit ee630276b3
9 files changed
+213 -42

No files matched your search

+131 -24
View File
@@ -11,7 +11,7 @@
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
import { MAGIC_GESTURES, type Gesture } from '$lib/game/spells';
let { duel }: { duel: Duel } = $props();
@@ -20,8 +20,37 @@
const won = $derived(outcome?.winner === duel.me);
/** Past three wizards the rail lists the others on a line each. */
const crowded = $derived(duel.others.length >= 3);
/** The button's word for what the player is about to do. */
const sealLabel = $derived(
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
);
/** Letters typed anywhere on the board fill the hands, left then right; Enter seals the turn. */
const KEYS: Record<string, Gesture> = { f: 'F', p: 'P', s: 'S', w: 'W', d: 'D', c: 'C', k: '>', '>': '>', '-': '-', '.': '-', ' ': '-' };
function onKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
if (outcome || duel.out || duel.moved || duel.spectating) return;
if (e.key === 'Enter') {
if (duel.ready) {
e.preventDefault();
duel.reveal();
}
return;
}
if (e.key === 'Backspace') {
e.preventDefault();
duel.untype();
return;
}
const g = KEYS[e.key.toLowerCase()];
if (g && duel.typeGesture(g)) e.preventDefault();
}
</script>
<svelte:window onkeydown={onKey} />
<main class="board">
<section class="play">
<Ledger {duel} />
@@ -54,7 +83,7 @@
<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>
<h2 id="move-heading">Your gestures for turn {duel.turnNumber}{#if duel.hasted}, twice over{/if}</h2>
{/if}
{#if duel.threats.length && !duel.timeStopped}
@@ -111,7 +140,7 @@
</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>
<p class="haste-note">You are hastened, so 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" />
@@ -191,22 +220,35 @@
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.intentText && !duel.moved}
<p class="intent">{duel.intentText}</p>
{/if}
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
{/if}
{#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.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
<div class="commit">
<div class="pair" aria-label="This turn's gestures">
{#each duel.activeSets as set (set)}
<span class="hand-pair">
<span class="which">left</span> <span class="letter" class:empty={duel.chosenGesture(set, 'left') === null}>{duel.drawn(set, 'left')}</span>
<span class="which">right</span> <span class="letter" class:empty={duel.chosenGesture(set, 'right') === null}>{duel.drawn(set, 'right')}</span>
</span>
{/each}
</div>
<div class="notes">
{#if duel.moved}
<p class="waiting">Your hands are still. Waiting for {duel.awaitingText}.</p>
{:else if duel.intentText}
<p class="intent">{duel.intentText}</p>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{:else if duel.loneClap && !duel.moved}
<p class="warning">A clap needs both hands; one hand clapping does nothing. Clap with both, or rest the other hand.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture, and a gesture can finish only one spell. Let one of them pass.</p>
{/if}
{#if !duel.moved}
<p class="keys muted">Or type: F P S W D C, K to stab, a dash to rest; Backspace undoes, Enter seals.</p>
{/if}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>{sealLabel}</button>
</div>
</section>
{/if}
{/if}
@@ -378,8 +420,66 @@
margin: 1rem 0 0.6rem;
}
.move > .reveal {
margin-top: 0.8rem;
/* Your hands, framed apart from the ledger's columns above them. */
.hands {
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 0.7rem 0.9rem 0.9rem;
}
/* The turn about to be sealed, kept in view while the hands and orders scroll. */
.commit {
position: sticky;
bottom: 0;
z-index: 4;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem 1rem;
margin-top: 0.9rem;
padding: 0.6rem 0.2rem;
background: var(--slate-deep);
border-top: 1px solid var(--rule-strong);
}
.pair {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.hand-pair {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.which {
font-size: 0.75rem;
font-style: italic;
color: var(--bone-faint);
}
.pair .letter {
font-size: 1.5rem;
min-width: 1ch;
}
.pair .letter.empty {
color: var(--bone-faint);
}
.notes p {
margin: 0;
}
.notes p + p {
margin-top: 0.2rem;
}
.keys {
font-size: 0.8rem;
}
.verdict {
@@ -407,8 +507,16 @@
grid-template-columns: minmax(0, 1fr);
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
/* On phones the bar at the foot of the screen carries the pair and the button. */
.commit {
position: static;
grid-template-columns: minmax(0, 1fr);
border-top: 0;
}
.commit .pair,
.commit .reveal,
.keys {
display: none;
}
@@ -425,7 +533,6 @@
.waiting {
color: var(--frost);
margin-top: 0.8rem;
}
.reveal.again {
+13 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { LogEntry } from '$lib/game/state';
let { log }: { log: LogEntry[] } = $props();
@@ -9,14 +10,23 @@
if (!groups.has(entry.turn)) groups.set(entry.turn, []);
groups.get(entry.turn)!.push(entry);
}
return [...groups.entries()].reverse();
return [...groups.entries()];
});
// Read top to bottom like a conversation; each new turn scrolls into view at the foot.
let box = $state<HTMLElement | null>(null);
$effect(() => {
void log.length;
void tick().then(() => {
if (box) box.scrollTop = box.scrollHeight;
});
});
</script>
<section class="chronicle">
<section class="chronicle" bind:this={box}>
<h2>Chronicle</h2>
{#if turns.length === 0}
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and reveal them.</p>
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and seal the turn.</p>
{/if}
{#each turns as [turn, entries] (turn)}
<article>
+2 -2
View File
@@ -264,7 +264,7 @@
{/each}
</tbody>
</table>
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
<p class="caption">A few turns of a duel. Each row is a turn, each letter one hand's gesture, and a spell is named under the gesture that finished it.</p>
</div>
{#if seats.length || local}
@@ -357,7 +357,7 @@
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About Waving Hands" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
<About bind:open={aboutOpen} title="About Waving Hands" tabs={[{ id: 'game', label: 'Origins', body: aboutGame }, { id: 'tally', label: 'Duels so far', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
</section>
<style>
+5 -5
View File
@@ -21,20 +21,20 @@
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
const whyForced = $derived(
duel.you.constraints.amnesia
? 'Amnesia: you must repeat last turn.'
? 'Amnesia holds this hand to last turn\'s gesture.'
: duel.you.constraints.paralysedHand === hand
? "Paralysed in last turn's position."
? "Paralysis has locked this hand 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>
<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> Charmed: {duel.foe.name} chooses this hand's gesture.</p>
<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)}
@@ -53,7 +53,7 @@
{/each}
</div>
{#if duel.you.constraints.fear && !duel.timeStopped}
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
<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}
+1 -1
View File
@@ -36,7 +36,7 @@
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Seal'}
</button>
{/if}
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
{/each}
</div>
{#if details.length}
<h3>The ledgers in detail</h3>
<h3>Every count</h3>
<dl class="ledger">
{#each details as d (d.label)}
<dt>{shown(d.n)}</dt><dd>{d.label}</dd>