The masthead, the scrim, the quiet button and the colophon are styled once in app.css instead of pasted into each page and slip with drifting paddings; the style blocks of the play page and the board are indented like the rest of the files; and comments in script and CSS say what the code does or guards, in plain words, where they had been written in the game's own voice
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
1 parent
22979afd35
commit
42144c1cc9
19 files changed
+364
-481
No files matched your search
@@ -10,10 +10,10 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// The about panel: the game's story, its keeping and its company, a tab
|
||||
// at a time rather than one long page. Opened from the hall's "about this
|
||||
// game" link (and #about); closes with its button, a click outside it, or
|
||||
// Escape. The tab last read is the one it reopens on.
|
||||
// A tabbed modal for the about content. Opened from the landing page's
|
||||
// "about this game" link or the #about hash; closes on its button, a click
|
||||
// on the backdrop, or Escape. Keeps the last-read tab while mounted, so
|
||||
// reopening shows it.
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
|
||||
@@ -41,7 +41,7 @@
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if open}
|
||||
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
|
||||
<header>
|
||||
<h2 id="about-title">{title}</h2>
|
||||
@@ -71,16 +71,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own backdrop: not every game has another dialog to share one with. */
|
||||
.about-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.panel {
|
||||
width: min(100%, 44rem);
|
||||
max-height: 100%;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
||||
// may dismiss one announcement; the next one still shows.
|
||||
// The site-family announcement, shown by every game: the maintainer writes
|
||||
// /var/lib/hall/announcement.json on the host (hall-announce), and each game's
|
||||
// site serves it at /hall/announcement.json. It is fetched on load, every few
|
||||
// minutes, and whenever the tab becomes visible again. Dismissal is per
|
||||
// announcement id, so the next one still shows.
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Announcement {
|
||||
@@ -88,7 +88,7 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
||||
/* Fixed colours rather than the game's tokens, so the notice looks the same on every site that shows it. */
|
||||
.hall-announcement {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
+173
-173
@@ -18,9 +18,9 @@
|
||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||
const outcome = $derived(duel.state.over);
|
||||
const won = $derived(outcome?.winner === duel.me);
|
||||
/** Past three wizards the rail lists the others on a line each. */
|
||||
/** With three or more opponents the rail shows a one-line roster instead of a status card each. */
|
||||
const crowded = $derived(duel.others.length >= 3);
|
||||
/** The button's word for what the player is about to do. */
|
||||
/** The seal button's label for the current state. */
|
||||
const sealLabel = $derived(
|
||||
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
|
||||
);
|
||||
@@ -275,260 +275,260 @@
|
||||
|
||||
<style>
|
||||
details.next summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
details.next summary::before {
|
||||
content: '\25B8 ';
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
content: '\25B8 ';
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
details.next[open] summary::before {
|
||||
content: '\25BE ';
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
min-width: 0;
|
||||
}
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.intent {
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
font-style: italic;
|
||||
color: var(--bone);
|
||||
}
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
font-style: italic;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.move {
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.move h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.threats {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.threat:hover,
|
||||
.threat.active {
|
||||
border-color: var(--frost);
|
||||
}
|
||||
.threat.active {
|
||||
border-color: var(--frost);
|
||||
}
|
||||
|
||||
.threat.active {
|
||||
background: rgba(140, 200, 224, 0.12);
|
||||
}
|
||||
background: rgba(140, 200, 224, 0.12);
|
||||
}
|
||||
|
||||
.thand {
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tseq {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tseq strong {
|
||||
font-weight: 600;
|
||||
color: var(--ember);
|
||||
}
|
||||
font-weight: 600;
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.prefs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.haste-note {
|
||||
color: var(--frost);
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
color: var(--frost);
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
|
||||
/* Your hands, framed apart from the ledger's columns above them. */
|
||||
/* Box the hand pickers so they read apart from the ledger table above. */
|
||||
.hands {
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 0.9rem 0.9rem;
|
||||
}
|
||||
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. */
|
||||
/* Sticky at the foot so the chosen pair and the seal button stay in view while the pickers 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.hand-pair {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.which {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.pair .letter {
|
||||
font-size: 1.5rem;
|
||||
min-width: 1ch;
|
||||
}
|
||||
font-size: 1.5rem;
|
||||
min-width: 1ch;
|
||||
}
|
||||
|
||||
.pair .letter.empty {
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.notes p {
|
||||
margin: 0;
|
||||
}
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notes p + p {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.keys {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.verdict h2 {
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
color: var(--blood);
|
||||
}
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.verdict.won h2 {
|
||||
color: var(--ember);
|
||||
}
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.verdict p {
|
||||
margin: 0.3rem 0 0.8rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
margin: 0.3rem 0 0.8rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@media (max-width: 860px) {
|
||||
.board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Narrow screens: MobileBar carries the pair and the button instead. */
|
||||
.commit {
|
||||
position: static;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.commit .pair,
|
||||
.commit .reveal,
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.commit .pair,
|
||||
.commit .reveal,
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
order: 2;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
order: 2;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hands {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.waiting {
|
||||
|
||||
@@ -18,37 +18,37 @@
|
||||
$effect(() => {
|
||||
if (location.hash === '#about') aboutOpen = true;
|
||||
});
|
||||
/** The ledgers' tally, fetched when the about panel opens. */
|
||||
/** Site-wide statistics, fetched the first time the about panel opens. */
|
||||
let tally = $state<Tally | null>(null);
|
||||
$effect(() => {
|
||||
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
||||
});
|
||||
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
||||
|
||||
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
|
||||
/** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
|
||||
const told = $derived.by(() => {
|
||||
const t = tally;
|
||||
if (!t) return null;
|
||||
|
||||
|
||||
const headline = [
|
||||
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'duel fought to a finish' : 'duels fought to a finish' },
|
||||
{ n: t.byGame['duels won with every rival slain'] ?? 0, label: 'won with every rival slain' },
|
||||
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
|
||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||
];
|
||||
const told = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
||||
const onCards = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
|
||||
const details = [
|
||||
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
|
||||
{ n: t.gamesWithBot, label: `duels with the bot seated` },
|
||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
|
||||
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
|
||||
];
|
||||
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||
return { headline, details, note: `${first}The count is of names, so one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
|
||||
});
|
||||
/** The other games of Kestrel's Hall, for the about panel. */
|
||||
/** The sibling games listed in the about panel, and the family's landing page. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
let hall = $state('');
|
||||
$effect(() => {
|
||||
@@ -57,12 +57,12 @@
|
||||
hall = f.hall;
|
||||
});
|
||||
});
|
||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
||||
/** Bug reports filed from this browser's seats, with the maintainer's replies. */
|
||||
let reports = $state<Report[]>([]);
|
||||
let answers = $state<Record<string, string>>({});
|
||||
let answerError = $state('');
|
||||
|
||||
// The desk is asked once per visit for every seat this browser holds.
|
||||
// Fetch the reports for every held seat; re-runs when the held seats change.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
if (!held.length) return;
|
||||
@@ -85,13 +85,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The single-player duel saved in this browser, if one is under way. */
|
||||
/** The offline game saved in this browser, if one is unfinished. */
|
||||
const local = $derived(duel.inProgress ? duel : null);
|
||||
const ready = $derived(name.trim().length > 0);
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it
|
||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
// Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
|
||||
// the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
@@ -111,7 +110,7 @@
|
||||
let phrase = $state('');
|
||||
let claiming = $state(false);
|
||||
|
||||
/** Bring a seat from another device by the phrase it minted. */
|
||||
/** Claim a seat transferred from another device, by its transfer phrase. */
|
||||
async function claim(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
claiming = true;
|
||||
@@ -167,7 +166,7 @@
|
||||
if (c) void goto(`/join/${c}`);
|
||||
}
|
||||
|
||||
/** A duel in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
||||
/** Forgetting an unfinished game asks first: this browser's token may be the only copy of the seat. */
|
||||
let forgetting = $state<string | null>(null);
|
||||
function forget(roomId: string) {
|
||||
if (!games[roomId]?.state?.over && forgetting !== roomId) {
|
||||
@@ -195,7 +194,7 @@
|
||||
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
||||
}
|
||||
|
||||
/** Lines of table talk said since this browser last looked in. */
|
||||
/** Chat lines posted since this browser last opened the room. */
|
||||
function unread(held: { roomId: string }): number {
|
||||
const g = games[held.roomId];
|
||||
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
||||
@@ -206,7 +205,7 @@
|
||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||
}
|
||||
|
||||
/* A few turns of a duel, as the ledger writes them. */
|
||||
/** Fixed sample rows for the illustration table. */
|
||||
const sample = [
|
||||
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
|
||||
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
|
||||
@@ -332,7 +331,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
||||
<!-- The about panel's tabs. Their copy is this game's own, in its in-world voice by design. -->
|
||||
{#snippet aboutGame()}
|
||||
<div class="about-body">
|
||||
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
||||
@@ -500,18 +499,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.quiet:hover:not(:disabled) {
|
||||
border-color: var(--bone);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
}
|
||||
@@ -576,7 +563,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* the games ledger */
|
||||
/* The list of held seats, and the reports under it. */
|
||||
.ledger {
|
||||
grid-area: ledger;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
@@ -686,7 +673,7 @@
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* The question takes a line of its own under the duel on a narrow screen. */
|
||||
/* The confirmation wraps onto its own line when the row is narrow. */
|
||||
.ledger-row.asking {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -88,16 +88,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.slip {
|
||||
width: min(100%, 30rem);
|
||||
max-height: 100%;
|
||||
@@ -168,11 +158,6 @@
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
// A crowded table's rail: every other wizard on one line, with the one
|
||||
// you are looking at opened out. Your own panel stays in full above.
|
||||
// The rail for three or more opponents: each on one line, with the focused
|
||||
// one expanded. The player's own status card stays above in full.
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
// The host's hand on the other seats during a duel: a transfer phrase for a
|
||||
// wizard whose browser lost their seat, and a bot to duel in the seat of one
|
||||
// who cannot go on, until a phrase gives it back.
|
||||
// Host-only controls over the other seats during a game: a transfer phrase
|
||||
// for a player whose browser lost its seat, and handing a seat to a bot until
|
||||
// a phrase claims it back.
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import type { WizardId } from '$lib/game/state';
|
||||
|
||||
@@ -80,15 +80,9 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.slip {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
const remote = $derived(duel.remote);
|
||||
const lines = $derived(remote?.chat ?? []);
|
||||
let draft = $state('');
|
||||
/** The gallery signs its lines; the name is the one the hall remembers. */
|
||||
/** Spectators sign their lines; the default is the name saved in local storage. */
|
||||
let galleryName = $state(playerName());
|
||||
let sending = $state(false);
|
||||
let list = $state<HTMLElement | null>(null);
|
||||
|
||||
// New talk scrolls into view, as at any table.
|
||||
// Keep the list scrolled to the newest line.
|
||||
$effect(() => {
|
||||
void lines.length;
|
||||
void tick().then(() => {
|
||||
@@ -128,14 +128,6 @@
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.quiet:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
/** One count told: the number, and what it counts. */
|
||||
/** One statistic: the figure and its label. */
|
||||
export interface TallyLine {
|
||||
n: number | string;
|
||||
label: string;
|
||||
@@ -7,9 +7,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// What the ledgers add up to: a few counts that are the game's own story,
|
||||
// large, on cards; then the rest in a column of figures; then the fine
|
||||
// print. Each game chooses its counts and words.
|
||||
// A statistics panel: a few headline counts on large cards, the rest in a
|
||||
// two-column list, then an optional note. The caller supplies the counts
|
||||
// and their labels.
|
||||
let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||
$props();
|
||||
|
||||
|
||||
Reference in new issue
Block a user