Duels between people: rooms, the hall, and a games ledger
The store plays any seat, locally against the bot or remotely through the server. A room has a four-letter code that is also its invite link; the hall opens or joins one and lists the seats this browser holds with whose move it is. The ledger and summary show every seat, threats name their wizard, and the board is one component shared by the hall and the room page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
8366c9d7ff
commit
e1bda87987
10 files changed
+1430
-524
No files matched your search
+79
-427
@@ -1,14 +1,8 @@
|
||||
<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 { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
import { goto } from '$app/navigation';
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import Hall from '$lib/components/Hall.svelte';
|
||||
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
|
||||
|
||||
let confirmingNew = $state(false);
|
||||
let renaming = $state(false);
|
||||
@@ -46,9 +40,6 @@
|
||||
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>
|
||||
@@ -78,6 +69,7 @@
|
||||
<nav class="actions">
|
||||
<a class="quiet" href="/rules">Rules</a>
|
||||
<button type="button" class="quiet" onclick={startNew}>New duel</button>
|
||||
<a class="quiet" href="#hall">Duel a friend</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -91,176 +83,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<Ledger {duel} />
|
||||
<TurnSummary {duel} />
|
||||
<Board {duel} />
|
||||
|
||||
{#if outcome}
|
||||
<div class="verdict" id="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.threats.length && !duel.timeStopped}
|
||||
<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 if !duel.timeStopped}
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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 class="field">
|
||||
<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} />
|
||||
<Hall name={duel.you.name} />
|
||||
|
||||
<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>
|
||||
@@ -268,285 +93,112 @@
|
||||
|
||||
<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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
max-width: 38em;
|
||||
color: var(--bone-dim);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.standfirst em {
|
||||
color: var(--bone);
|
||||
}
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.standfirst .link {
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.rename input {
|
||||
width: 14em;
|
||||
}
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
border-color: var(--bone);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0.75rem;
|
||||
padding: 0.75rem var(--gutter);
|
||||
}
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0.75rem;
|
||||
padding: 0.75rem var(--gutter);
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
color: var(--blood);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
color: var(--blood);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.choices {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.haste-note {
|
||||
color: var(--frost);
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.move > .reveal {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--gutter) 3rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--gutter) 3rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
color: var(--frost);
|
||||
}
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@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;
|
||||
}
|
||||
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
flex-direction: column;
|
||||
}
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import { seatFor } from '$lib/game/client';
|
||||
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
|
||||
|
||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||
|
||||
let room = $state<Duel | null>(null);
|
||||
let name = $state('');
|
||||
let joining = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
|
||||
const lobby = $derived(room !== null && !room.remote?.started);
|
||||
|
||||
// Return to a held seat, or offer one.
|
||||
$effect(() => {
|
||||
const id = roomId;
|
||||
room = null;
|
||||
error = '';
|
||||
const held = seatFor(id);
|
||||
if (!held) {
|
||||
name = playerName();
|
||||
joining = true;
|
||||
return;
|
||||
}
|
||||
joining = false;
|
||||
Duel.resumeRoom(id, held)
|
||||
.then((remote) => (room = new Duel(remote)))
|
||||
.catch((e: Error) => (error = e.message));
|
||||
});
|
||||
|
||||
// Keep the view fresh for as long as the room is on screen.
|
||||
$effect(() => {
|
||||
const r = room;
|
||||
if (!r) return;
|
||||
const stop = r.connect();
|
||||
return stop;
|
||||
});
|
||||
|
||||
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
|
||||
$effect(() => {
|
||||
const r = room;
|
||||
if (!r) return;
|
||||
const query = window.matchMedia(COMPACT_QUERY);
|
||||
const apply = () => (r.compact = query.matches);
|
||||
apply();
|
||||
query.addEventListener('change', apply);
|
||||
return () => query.removeEventListener('change', apply);
|
||||
});
|
||||
|
||||
async function takeSeat(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
room = new Duel(await Duel.joinRoom(roomId, name));
|
||||
joining = false;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The seat could not be taken.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1600);
|
||||
} catch {
|
||||
// The link is on screen to copy by hand.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Waving Hands: a duel</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<h1>Waving Hands</h1>
|
||||
{#if room?.remote?.started}
|
||||
<p class="standfirst">
|
||||
You are <em>{room.you.name}</em>, duelling
|
||||
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
|
||||
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="standfirst">A duel between people, played by turns whenever each of you has a moment.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<nav class="actions">
|
||||
{#if room}
|
||||
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
|
||||
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
||||
</button>
|
||||
{/if}
|
||||
<a class="quiet" href="/rules">Rules</a>
|
||||
<a class="quiet" href="/">The hall</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="notice">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if joining}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
|
||||
<form class="rename" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
|
||||
</label>
|
||||
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
|
||||
</form>
|
||||
</section>
|
||||
{:else if room && lobby}
|
||||
<section class="lobby">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
|
||||
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</p>
|
||||
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
||||
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
|
||||
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
|
||||
</section>
|
||||
{:else if room}
|
||||
<Board duel={room} />
|
||||
{:else if !error}
|
||||
<p class="notice muted">Finding your seat.</p>
|
||||
{/if}
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
.quiet.room b {
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--bone-faint);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter) 0.5rem;
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.notice.muted {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.lobby {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem var(--gutter) 2rem;
|
||||
}
|
||||
|
||||
.lobby h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.lobby p + p {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.rename {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.rename input {
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font);
|
||||
background: var(--slate);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--gutter) 3rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@media (max-width: 860px) {
|
||||
.masthead {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user