Begin Wyrm from the game kit
This commit is contained in:
commit
d91b95cfab
73 files changed
+10509
No files matched your search
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
|
||||
import Preferences from '$lib/components/Preferences.svelte';
|
||||
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
|
||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||
|
||||
let room = $state<Room | null>(null);
|
||||
let name = $state('');
|
||||
let joining = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
let reporting = $state(false);
|
||||
let prefsOpen = $state(false);
|
||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
let rematching = $state(false);
|
||||
let seatsOpen = $state(false);
|
||||
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
||||
let seatLost = $state('');
|
||||
let claimPhrase = $state('');
|
||||
let claiming = $state(false);
|
||||
|
||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||
const watching = $derived(room?.spectating ?? false);
|
||||
|
||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
||||
// still opens, so a link someone else sends cannot take a working seat away.
|
||||
$effect(() => {
|
||||
const id = roomId;
|
||||
room = null;
|
||||
error = '';
|
||||
const sent = seatInLink(location.hash);
|
||||
// The browser's own call: this runs as the page first loads, before the router could take one.
|
||||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||
const held = seatFor(id);
|
||||
const take = (s: HeldSeat) =>
|
||||
Room.resume(id, s).then((r) => {
|
||||
rememberSeat(id, s);
|
||||
trustSeat(id);
|
||||
return r;
|
||||
});
|
||||
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
||||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
|
||||
const watchRefused = (e: Error) => {
|
||||
seatLost = e.message;
|
||||
return Room.watch(id);
|
||||
};
|
||||
seatLost = '';
|
||||
const arrive = held
|
||||
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
|
||||
: sent
|
||||
? take(sent).catch(() => Room.watch(id))
|
||||
: Room.watch(id);
|
||||
joining = !held && !sent;
|
||||
if (!held) name = playerName();
|
||||
arrive
|
||||
.then((r) => {
|
||||
room = r;
|
||||
if (r.spectating) joining = true;
|
||||
})
|
||||
.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;
|
||||
return r.connect();
|
||||
});
|
||||
|
||||
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery.
|
||||
$effect(() => {
|
||||
const r = room;
|
||||
if (!r?.lost) return;
|
||||
seatLost = r.lost;
|
||||
Room.watch(r.roomId)
|
||||
.then((w) => (room = w))
|
||||
.catch((e: Error) => (error = e.message));
|
||||
});
|
||||
|
||||
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
|
||||
async function claim(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
claiming = true;
|
||||
error = '';
|
||||
try {
|
||||
const got = await api.claim(claimPhrase.trim());
|
||||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||
trustSeat(got.roomId);
|
||||
claimPhrase = '';
|
||||
seatLost = '';
|
||||
if (got.roomId === roomId) room = await Room.resume(roomId, { seat: got.seat, token: got.token });
|
||||
else await goto(`/join/${got.roomId}`);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function takeSeat(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
rememberName(name);
|
||||
room = await Room.join(roomId, name.trim());
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
async function transfer() {
|
||||
if (!room) return;
|
||||
try {
|
||||
phrase = await room.transfer();
|
||||
setTimeout(() => (phrase = null), Math.max(0, phrase.expiresAt - Date.now()));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The seat could not be offered.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Call for a rematch, or take the seat held at one already called. */
|
||||
async function rematch() {
|
||||
if (!room) return;
|
||||
rematching = true;
|
||||
try {
|
||||
await goto(`/join/${await room.rematch()}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The rematch could not be called.';
|
||||
} finally {
|
||||
rematching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wyrm: a game</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<h1>Wyrm</h1>
|
||||
{#if room?.started && watching}
|
||||
<p class="standfirst">From the Peanut Gallery you watch {room.others.map((s) => s.name).join(', ')} play.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p>
|
||||
{:else if room?.started}
|
||||
<p class="standfirst">You are <em>{room.nameOf(room.me)}</em>, playing {room.others.map((s) => s.name).join(', ')}.{#if !room.connected} <span class="muted">Reconnecting.</span>{/if}</p>
|
||||
{:else}
|
||||
<p class="standfirst">A game 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.view.audience > 0}
|
||||
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
|
||||
{/if}
|
||||
{#if !watching}
|
||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||
{/if}
|
||||
{#if room.isHost && room.started}
|
||||
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
|
||||
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
|
||||
<a class="quiet" href={room ? `/rules?room=${roomId}` : '/rules'}>Rules</a>
|
||||
{#if room}
|
||||
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
|
||||
{/if}
|
||||
<a class="quiet" href="/">The hall</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
{#if room}
|
||||
<ReportSlip {room} bind:open={reporting} />
|
||||
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="notice">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if phrase}
|
||||
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
|
||||
{/if}
|
||||
|
||||
{#if room?.view.over && !watching}
|
||||
<p class="notice muted">
|
||||
{#if room.view.rematch}
|
||||
{room.nameOf(room.view.rematch.by)} called for a rematch at table {room.view.rematch.to}.
|
||||
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
|
||||
{:else}
|
||||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
|
||||
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if joining && room?.seatEmpty}
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>A player has opened this table and is gathering company. Choose a name and sit down. The game begins when the host says.</p>
|
||||
<form class="sit" 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="commit 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?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
|
||||
<p class="notice muted">A bot plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
|
||||
{/if}
|
||||
|
||||
{#if watching && room?.started && !room.view.over}
|
||||
<div class="notice muted">
|
||||
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||||
{:else}{#if room.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||||
<p>Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
|
||||
<form class="sit" onsubmit={claim}>
|
||||
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if room && !room.started}
|
||||
<Lobby {room} {link} {copied} {copyLink} />
|
||||
{:else if room}
|
||||
<Board {room} />
|
||||
{:else if !error}
|
||||
<p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
|
||||
{/if}
|
||||
|
||||
<footer class="colophon">
|
||||
<p>Wyrm. <a href={room ? `/rules?room=${roomId}` : '/rules'}>Read the full rules</a>. A rule read wrong or a bug found: <a href="mailto:eric@ericwagoner.com">send word</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;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.standfirst {
|
||||
max-width: 38em;
|
||||
color: var(--bone-dim);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.standfirst em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.notice {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter) 0.5rem;
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.notice.muted {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.phrase {
|
||||
letter-spacing: 0.06em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.seat {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem var(--gutter) 0.5rem;
|
||||
}
|
||||
|
||||
.seat h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.sit {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sit input {
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.masthead {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user