A room's link without a seat is now a place in the gallery: a tokenless view that withholds any gesture hidden from anyone at the table, counted for the table and the lobby. Seated wizards can talk; each line is a ledger line, shown in the rail and in the lobby, and the hall counts what was said while you were away. The hall's pitch no longer says two wizards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
385 lines
9.6 KiB
Svelte
385 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import Board from '$lib/components/Board.svelte';
|
|
import TableTalk from '$lib/components/TableTalk.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);
|
|
const watching = $derived(room?.spectating ?? false);
|
|
/** A watcher may still sit while the table is laid and a seat is free. */
|
|
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
|
|
|
|
// Return to a held seat, or take a place in the gallery and offer one.
|
|
$effect(() => {
|
|
const id = roomId;
|
|
room = null;
|
|
error = '';
|
|
const held = seatFor(id);
|
|
if (!held) {
|
|
name = playerName();
|
|
joining = true;
|
|
Duel.watchRoom(id)
|
|
.then((remote) => (room = new Duel(remote)))
|
|
.catch((e: Error) => (error = e.message));
|
|
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 && watching}
|
|
<p class="standfirst">
|
|
From the Peanut Gallery you watch
|
|
{#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} duel.
|
|
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
|
|
</p>
|
|
{:else 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">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
|
|
</button>
|
|
{#if room.remote && room.remote.audience > 0}
|
|
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
|
|
{/if}
|
|
{/if}
|
|
<a class="quiet" href="/guide">How to play</a>
|
|
<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 && seatFree}
|
|
<section class="lobby">
|
|
<p class="eyebrow">room {roomId}</p>
|
|
<h2>Take a seat</h2>
|
|
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</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>
|
|
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if room && lobby}
|
|
{@const r = room.remote!}
|
|
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
|
|
<section class="lobby">
|
|
{#if watching}
|
|
<h2>At the table</h2>
|
|
{#if seatFree}
|
|
<p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
|
|
{:else}
|
|
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
|
|
{/if}
|
|
{:else}
|
|
<p class="eyebrow">room {roomId}</p>
|
|
<h2>The table is laid</h2>
|
|
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen. Anyone who arrives once the duel has begun watches from the Peanut Gallery.</p>
|
|
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
|
|
{/if}
|
|
<ul class="roster">
|
|
{#each r.seats as s (s.id)}
|
|
<li>
|
|
<span class="seat-name">{s.name}</span>
|
|
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
|
|
</li>
|
|
{/each}
|
|
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{/if}</li>
|
|
</ul>
|
|
{#if !watching}
|
|
<div class="ways">
|
|
{#if r.seats.length < r.size}
|
|
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
|
|
{/if}
|
|
{#if room.isHost}
|
|
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
|
|
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
|
|
{:else}
|
|
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#if r.error}<p class="notice">{r.error}</p>{/if}
|
|
<div class="lobby-talk"><TableTalk duel={room} /></div>
|
|
</section>
|
|
{:else if room}
|
|
<Board duel={room} />
|
|
{:else if !error}
|
|
<p class="notice muted">{joining ? 'Finding the table.' : '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;
|
|
}
|
|
|
|
.audience {
|
|
align-self: center;
|
|
font-size: 0.85rem;
|
|
font-style: italic;
|
|
color: var(--bone-faint);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.lobby-talk {
|
|
max-width: 28em;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.roster {
|
|
list-style: none;
|
|
margin: 0.9rem 0 0.6rem;
|
|
padding: 0;
|
|
max-width: 28em;
|
|
}
|
|
|
|
.roster li {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: 0.3rem 0;
|
|
border-bottom: 1px solid var(--rule);
|
|
}
|
|
|
|
.roster li.empty {
|
|
border-bottom: 0;
|
|
justify-content: flex-start;
|
|
font-style: italic;
|
|
}
|
|
|
|
.seat-name {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ways {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem 0.9rem;
|
|
margin-top: 0.4rem;
|
|
}
|
|
|
|
.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>
|