The table, the hall, the rules and the guide: three boards drawn in Gygax's colours, a drake to play, the article in full, and the pages that walk a player in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
1 parent
1ef5b23712
commit
b9efe304a0
29 files changed
+1116
-333
No files matched your search
+3
-3
@@ -7,21 +7,21 @@
|
||||
<meta name="theme-color" content="#121a20" />
|
||||
<title>Wyrm</title>
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="description" content="Wyrm, a game between friends, free in the browser." />
|
||||
<meta name="description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||
<link rel="canonical" href="https://wyrm.kestrelsnest.social/" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Wyrm" />
|
||||
<meta property="og:title" content="Wyrm" />
|
||||
<meta property="og:description" content="Wyrm, a game between friends, free in the browser." />
|
||||
<meta property="og:description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||
<meta property="og:url" content="https://wyrm.kestrelsnest.social/" />
|
||||
<meta property="og:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Wyrm" />
|
||||
<meta name="twitter:description" content="Wyrm, a game between friends, free in the browser." />
|
||||
<meta name="twitter:description" content="Gary Gygax's Dragonchess, free in the browser: chess on three boards, with Dragons in the air and Basilisks below. Play a drake or a friend." />
|
||||
<meta name="twitter:image" content="https://wyrm.kestrelsnest.social/og.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
+338
-127
@@ -1,95 +1,250 @@
|
||||
<script lang="ts">
|
||||
// The demo game's board. A real game replaces this file entirely; the
|
||||
// masthead, lobby, talk, gallery and reports around it stay.
|
||||
// The table: the three boards one above the other, the pieces as discs
|
||||
// that slide and fade, and two taps or a keyboard to move. Above them a
|
||||
// strip says whose move it is; beside them the record, each move a click
|
||||
// from being shown on the boards, and the talk.
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import { HIGHEST, TARGET } from '$lib/game';
|
||||
import BoardArtwork, { type Token } from './BoardArtwork.svelte';
|
||||
import {
|
||||
describe, legalMovesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, fileOf, rankOf, idx, frozen, notation, NAMES, FILES, RANKS, LEVELS,
|
||||
type Move, type Side
|
||||
} from '$lib/game';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
import { prefs, reducedMotion } from '$lib/prefs.svelte';
|
||||
|
||||
let { room }: { room: Room } = $props();
|
||||
|
||||
const g = $derived(room.state!);
|
||||
let pick = $state(0);
|
||||
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
||||
const nameOfSide = (side: Side) => {
|
||||
const seat = g.seats.find((id) => g.players[id].side === side);
|
||||
return seat ? g.players[seat].name : side;
|
||||
};
|
||||
const sideName = (side: Side) => (side === 'gold' ? 'Gold' : 'Scarlet');
|
||||
const flipped = $derived(mySide === 'scarlet');
|
||||
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
|
||||
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
|
||||
const label = squareName;
|
||||
|
||||
async function play() {
|
||||
if (!pick) return;
|
||||
if (await room.submit({ pick })) pick = 0;
|
||||
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
|
||||
let tokens = $state<Token[]>([]);
|
||||
/** How many of the game's moves the tokens have played through. */
|
||||
let shown = $state(0);
|
||||
let animating = $state(false);
|
||||
const calm = $derived(reducedMotion());
|
||||
const showCoords = $derived(prefs.coordinates !== false);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
||||
|
||||
function resetTokens() {
|
||||
let id = 0;
|
||||
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
||||
shown = g.moves.length;
|
||||
}
|
||||
|
||||
async function catchUp() {
|
||||
if (animating) return;
|
||||
animating = true;
|
||||
try {
|
||||
while (shown < g.moves.length) {
|
||||
const m = g.moves[shown];
|
||||
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
||||
if (!mover) {
|
||||
resetTokens();
|
||||
break;
|
||||
}
|
||||
const victim = m.captured ? tokens.find((t) => t.at === m.to && !t.dying && t !== mover) : undefined;
|
||||
if (m.afar) {
|
||||
if (victim) victim.dying = true;
|
||||
await wait(360);
|
||||
} else {
|
||||
if (victim) victim.dying = true;
|
||||
mover.at = m.to;
|
||||
await wait(380);
|
||||
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
|
||||
}
|
||||
if (victim) tokens = tokens.filter((t) => !t.dying);
|
||||
shown += 1;
|
||||
announce(storyOf(shown));
|
||||
if (shown < g.moves.length) await wait(260);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const total = g.moves.length;
|
||||
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
|
||||
else if (total > shown) void catchUp();
|
||||
});
|
||||
|
||||
// --- What the boards show: the last move played, or a move picked from the record ---
|
||||
let reviewing = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
void g.moves.length;
|
||||
reviewing = null;
|
||||
selected = null;
|
||||
});
|
||||
const shownPly = $derived(reviewing ?? shown);
|
||||
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
|
||||
const shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
|
||||
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
|
||||
|
||||
function storyOf(ply: number): string {
|
||||
const m = g.moves[ply - 1];
|
||||
return m ? describe(g, m) : '';
|
||||
}
|
||||
const shownStory = $derived(shownPly > 0 ? storyOf(shownPly) : '');
|
||||
|
||||
// --- Choosing and moving ---------------------------------------------------
|
||||
let selected = $state<number | null>(null);
|
||||
const targets = $derived(selected === null ? [] : legalMovesFrom(g.cells, selected, g.setup));
|
||||
let liveText = $state('');
|
||||
function announce(text: string) {
|
||||
liveText = text;
|
||||
}
|
||||
|
||||
/** A move awaiting the player's word, when they asked to confirm. */
|
||||
let pending = $state<{ from: number; to: number } | null>(null);
|
||||
|
||||
async function send(from: number, to: number) {
|
||||
pending = null;
|
||||
selected = null;
|
||||
await room.submit({ from, to });
|
||||
}
|
||||
|
||||
const wordFor = (i: number) => `${sideOf(g.cells[i]) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(g.cells[i])]}`;
|
||||
|
||||
async function tap(i: number) {
|
||||
if (!yourMove || room.sending || animating) return;
|
||||
reviewing = null;
|
||||
if (selected !== null && targets.includes(i)) {
|
||||
if (prefs.confirmMoves === true) {
|
||||
pending = { from: selected, to: i };
|
||||
announce(`Move ${label(selected)} to ${label(i)}? Confirm or cancel below.`);
|
||||
return;
|
||||
}
|
||||
await send(selected, i);
|
||||
return;
|
||||
}
|
||||
pending = null;
|
||||
if (sideOf(g.cells[i]) === mySide) {
|
||||
selected = selected === i ? null : i;
|
||||
if (selected !== null) {
|
||||
const to = legalMovesFrom(g.cells, i, g.setup);
|
||||
const why = frozen(g.cells, i) ? ' It is frozen by the Basilisk beneath it.' : '';
|
||||
announce(`Selected ${wordFor(i)} on ${label(i)}. ${to.length} ${to.length === 1 ? 'square' : 'squares'} to move to${to.length ? ': ' + to.map(label).join(', ') : ''}.${why}`);
|
||||
} else announce('Selection cleared.');
|
||||
} else selected = null;
|
||||
}
|
||||
|
||||
function focusSquare(i: number) {
|
||||
document.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
|
||||
}
|
||||
|
||||
/** Arrows walk a board, Page Up and Page Down climb and descend, Enter picks up and sets down. */
|
||||
function key(e: KeyboardEvent, i: number) {
|
||||
const f = fileOf(i);
|
||||
const r = rankOf(i);
|
||||
const lv = levelOf(i);
|
||||
const step: Record<string, [number, number, number]> = {
|
||||
ArrowLeft: [flipped ? 1 : -1, 0, 0], ArrowRight: [flipped ? -1 : 1, 0, 0], ArrowUp: [0, flipped ? -1 : 1, 0], ArrowDown: [0, flipped ? 1 : -1, 0],
|
||||
PageUp: [0, 0, 1], PageDown: [0, 0, -1]
|
||||
};
|
||||
if (e.key in step) {
|
||||
e.preventDefault();
|
||||
const [df, dr, dl] = step[e.key];
|
||||
const nf = f + df;
|
||||
const nr = r + dr;
|
||||
const nl = lv + dl;
|
||||
if (nf >= 0 && nf < FILES && nr >= 0 && nr < RANKS && nl >= 1 && nl <= LEVELS) focusSquare(idx(nl, nf, nr));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
void tap(i);
|
||||
} else if (e.key === 'Escape') {
|
||||
selected = null;
|
||||
reviewing = null;
|
||||
pending = null;
|
||||
announce('Selection cleared.');
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = $derived.by(() => {
|
||||
if (!g.over) return '';
|
||||
if (g.over.winner === null) return 'A draw.';
|
||||
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
|
||||
});
|
||||
|
||||
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<table class="rounds">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="turn"><span class="visually-hidden">Round</span></th>
|
||||
{#each g.seats as id (id)}
|
||||
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me} (you){/if}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each g.rounds as round, i (i)}
|
||||
<tr>
|
||||
<td class="turn">{i + 1}</td>
|
||||
{#each g.seats as id (id)}
|
||||
<td class:scored={round.scorer === id}>{round.picks[id]}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{#if !g.over}
|
||||
<tr class="draft">
|
||||
<td class="turn">{g.rounds.length + 1}</td>
|
||||
{#each g.seats as id (id)}
|
||||
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="status" class:yours={yourMove}>
|
||||
{#if g.over}
|
||||
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
|
||||
{:else if room.spectating}
|
||||
<strong>{nameOfSide(g.toMove)} to move</strong> · {sideName(g.toMove)}{#if inCheckNow} · <span class="check">in check</span>{/if} · <span class="muted">move {g.moves.length + 1}</span>
|
||||
{:else if yourMove}
|
||||
<strong>Your move</strong> · {sideName(mySide!)}{#if inCheckNow} · <span class="check">your King is in check</span>{/if} · <span class="goal">Checkmate {nameOfSide(g.toMove === 'gold' ? 'scarlet' : 'gold')}’s King.</span>
|
||||
{:else if room.view.playingOn}
|
||||
<strong>The drakes play on</strong> · <span class="muted">a move every few seconds</span>{#if !room.spectating} <button type="button" class="link" onclick={() => room.hurry()}>play the rest at once</button>{/if}
|
||||
{:else}
|
||||
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {sideName(mySide!)}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted)}
|
||||
<p class="story" class:capture={shownCaptured.length > 0}>{shownStory}{#if reviewing !== null} <button type="button" class="link" onclick={() => (reviewing = null)}>back to now</button>{/if}</p>
|
||||
{/if}
|
||||
{#if pending}
|
||||
<p class="confirm">Move {label(pending.from)} to {label(pending.to)}?
|
||||
<button type="button" class="commit small" onclick={() => pending && send(pending.from, pending.to)}>Confirm</button>
|
||||
<button type="button" class="quiet" onclick={() => (pending = null)}>Cancel</button></p>
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
|
||||
<div class="frame">
|
||||
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {shownSquares} {shownCaptured} {checkSquare} {yourMove} {calm} {tap} {key} />
|
||||
</div>
|
||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||
|
||||
{#if g.over}
|
||||
<div class="verdict">
|
||||
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
|
||||
<p>{g.over.reason}</p>
|
||||
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
|
||||
<a class="commit" href="/">Back to the hall</a>
|
||||
</div>
|
||||
{:else if room.view.playingOn}
|
||||
<section class="move">
|
||||
<h2>The bots play on</h2>
|
||||
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
|
||||
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
|
||||
</section>
|
||||
{:else if room.spectating}
|
||||
<section class="move">
|
||||
<h2>You watch from the Peanut Gallery</h2>
|
||||
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="move">
|
||||
<h2>Name a number for round {g.rounds.length + 1}</h2>
|
||||
<p class="muted">Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.</p>
|
||||
<div class="picks">
|
||||
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
|
||||
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
|
||||
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
|
||||
</button>
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
</section>
|
||||
{:else if yourMove}
|
||||
<p class="muted small how">Tap a piece, then a square on any board. With a keyboard: arrows to move about a board, Page Up and Page Down to change boards, Enter to pick up and to set down, Escape to cancel.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<section class="standings">
|
||||
<h2>Standings</h2>
|
||||
<section class="sides">
|
||||
<h2>At the table</h2>
|
||||
<ul>
|
||||
{#each g.seats as id (id)}
|
||||
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li>
|
||||
<li class:to-move={g.players[id].side === g.toMove && !g.over}>
|
||||
<span class="dot {g.players[id].side}"></span>
|
||||
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
||||
<span class="muted">{sideName(g.players[id].side)}, {pieceCount(g.players[id].side)} pieces</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="muted small">Move {g.moves.length + 1}.{#if g.moves.length} Click a move to see it on the boards.{/if}</p>
|
||||
{#if g.moves.length}
|
||||
<ol class="record muted small" reversed start={g.moves.length}>
|
||||
{#each g.moves.slice(-10).reverse() as m, i (g.moves.length - i)}
|
||||
{@const ply = g.moves.length - i}
|
||||
<li class:mine={mySide === m.by} class:reviewed={reviewing === ply}>
|
||||
<button type="button" class="link plain" onclick={() => (reviewing = reviewing === ply ? null : ply)}>
|
||||
<span class="who">{nameOfSide(m.by)}</span> <span class="notation">{notation(m)}</span>{#if m.captured}, <strong>takes the {NAMES[m.captured]}</strong>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
<TableTalk {room} />
|
||||
</aside>
|
||||
@@ -98,7 +253,7 @@
|
||||
<style>
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||
padding: 0 var(--gutter);
|
||||
max-width: 1280px;
|
||||
@@ -111,99 +266,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rounds {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
.status {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 0.5rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--bone-dim);
|
||||
position: sticky;
|
||||
top: 0.4rem;
|
||||
z-index: 2;
|
||||
background: var(--slate-deep);
|
||||
}
|
||||
|
||||
.rounds th {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
.status strong {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.rounds th.you {
|
||||
color: var(--frost);
|
||||
.status.yours {
|
||||
border-color: var(--ember);
|
||||
}
|
||||
|
||||
.rounds td {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
.status.yours strong {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.rounds .turn {
|
||||
width: 2.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
.goal {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.rounds td.scored {
|
||||
.check {
|
||||
color: var(--ember);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rounds tr.draft td {
|
||||
background: var(--slate);
|
||||
.story {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.move,
|
||||
.verdict {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.move h2,
|
||||
.verdict h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.picks {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.pick {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.3rem;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
.story.capture {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.pick.chosen {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
.warning {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
|
||||
.verdict .commit {
|
||||
display: inline-block;
|
||||
margin-top: 0.6rem;
|
||||
text-decoration: none;
|
||||
.confirm {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.standings h2 {
|
||||
.frame {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.how {
|
||||
max-width: 640px;
|
||||
margin: 0.5rem auto 0;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
max-width: 640px;
|
||||
margin: 0.8rem auto 0;
|
||||
}
|
||||
|
||||
.sides h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.standings ul {
|
||||
.sides ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.standings li {
|
||||
.sides li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sides li.to-move {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.gold {
|
||||
background: #d8a63e;
|
||||
border: 1px solid #8a6218;
|
||||
}
|
||||
|
||||
.dot.scarlet {
|
||||
background: #a4302a;
|
||||
border: 1px solid #5e1612;
|
||||
}
|
||||
|
||||
.sides p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.record {
|
||||
margin: 0.3rem 0 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.record li.mine {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.record .plain {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.record .plain:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.record li.reviewed .link {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.record .notation {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record strong {
|
||||
color: var(--ember);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script module lang="ts">
|
||||
import type { Cell } from '$lib/game';
|
||||
|
||||
/** A piece with a life of its own, so a move slides and a capture fades. */
|
||||
export interface Token {
|
||||
id: number;
|
||||
piece: Cell;
|
||||
at: number;
|
||||
dying: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// The three boards, drawn one above the other: the air, the land and the
|
||||
// underworld, each twelve by eight in the colours Gygax suggested, with
|
||||
// the pieces as discs bearing his letters. Shared by the table, the hall's
|
||||
// still of the opening and the share artwork.
|
||||
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, type State } from '$lib/game';
|
||||
let {
|
||||
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
||||
yourMove = false, calm = true, decorative = false, tap = () => {}, key = () => {}
|
||||
}: {
|
||||
g: State;
|
||||
tokens?: Token[];
|
||||
/** Which boards to draw, top first. */
|
||||
levels?: number[];
|
||||
showCoords?: boolean;
|
||||
/** Scarlet's view: rank 8 nearest, file l on the left. */
|
||||
flipped?: boolean;
|
||||
selected?: number | null;
|
||||
targets?: number[];
|
||||
shownSquares?: number[];
|
||||
shownCaptured?: number[];
|
||||
checkSquare?: number;
|
||||
yourMove?: boolean;
|
||||
calm?: boolean;
|
||||
decorative?: boolean;
|
||||
tap?: (i: number) => void | Promise<void>;
|
||||
key?: (e: KeyboardEvent, i: number) => void;
|
||||
} = $props();
|
||||
|
||||
const artworkTokens = $derived(tokens ?? g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: at, piece, at, dying: false }])));
|
||||
const CELL = 40;
|
||||
const PAD_L = $derived(showCoords ? 18 : 6);
|
||||
const PAD_R = 6;
|
||||
const PAD_T = 20;
|
||||
const PAD_B = $derived(showCoords ? 16 : 6);
|
||||
const BOARD_W = FILES * CELL;
|
||||
const BOARD_H = RANKS * CELL;
|
||||
const GAP = 14;
|
||||
const width = $derived(PAD_L + BOARD_W + PAD_R);
|
||||
const boardHeight = $derived(PAD_T + BOARD_H + PAD_B);
|
||||
const height = $derived(levels.length * boardHeight + (levels.length - 1) * GAP);
|
||||
const WORLD: Record<number, string> = { 3: 'The air', 2: 'The land', 1: 'The underworld' };
|
||||
|
||||
/** Where a cell sits on the drawing: its board's slot, then file and rank from the viewer's side. */
|
||||
function place(i: number): { x: number; y: number } | null {
|
||||
const slot = levels.indexOf(levelOf(i));
|
||||
if (slot < 0) return null;
|
||||
const f = flipped ? FILES - 1 - fileOf(i) : fileOf(i);
|
||||
const r = flipped ? rankOf(i) : RANKS - 1 - rankOf(i);
|
||||
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
|
||||
}
|
||||
const drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
|
||||
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
||||
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
||||
const describe = (i: number) => {
|
||||
const piece = g.cells[i];
|
||||
let text = squareName(i);
|
||||
if (piece !== '') text += `, ${sideOf(piece) === 'gold' ? 'Gold' : 'Scarlet'} ${NAMES[kindOf(piece)]}${frozen(g.cells, i) ? ', frozen' : ''}`;
|
||||
if (targets.includes(i)) text += ', a legal move';
|
||||
if (i === checkSquare) text += ', in check';
|
||||
return text;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="artwork" class:calm viewBox="0 0 {width} {height}" role={decorative ? 'img' : 'grid'} aria-label={decorative ? 'The three boards in their opening position' : 'The three boards'}>
|
||||
{#each levels as lv, slot (lv)}
|
||||
{@const top = slot * (boardHeight + GAP)}
|
||||
<g class="world" data-level={lv}>
|
||||
<rect class="ground" x="1" y={top + 1} width={width - 2} height={boardHeight - 2} rx="5" />
|
||||
<text class="title" x={PAD_L} y={top + 14}>{WORLD[lv]}</text>
|
||||
{#if showCoords}
|
||||
{#each Array.from({ length: FILES }, (_, n) => n) as n (n)}
|
||||
<text class="coord" x={PAD_L + n * CELL + CELL / 2} y={top + boardHeight - 4} text-anchor="middle">{fileLetter(n)}</text>
|
||||
{/each}
|
||||
{#each Array.from({ length: RANKS }, (_, n) => n) as n (n)}
|
||||
<text class="coord" x={PAD_L - 4} y={top + PAD_T + n * CELL + CELL / 2 + 3} text-anchor="end">{rankNumber(n)}</text>
|
||||
{/each}
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
<g>
|
||||
{#each drawn as i (i)}
|
||||
{@const p = place(i)!}
|
||||
<!-- Interactive squares have gridcell roles; illustrations have no tab stops. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<rect
|
||||
class="square"
|
||||
class:light={isLight(i)}
|
||||
class:last={shownSquares.includes(i)}
|
||||
class:target={targets.includes(i)}
|
||||
class:fallen={shownCaptured.includes(i)}
|
||||
class:check={i === checkSquare}
|
||||
data-i={i}
|
||||
data-level={levelOf(i)}
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
role={decorative ? undefined : 'gridcell'}
|
||||
aria-label={decorative ? undefined : describe(i)}
|
||||
aria-selected={decorative ? undefined : selected === i}
|
||||
tabindex={decorative ? undefined : yourMove ? 0 : -1}
|
||||
onclick={decorative ? undefined : () => tap(i)}
|
||||
onkeydown={decorative ? undefined : (e) => key(e, i)}
|
||||
/>
|
||||
{/each}
|
||||
{#each artworkTokens as t (t.id)}
|
||||
{@const p = place(t.at)}
|
||||
{#if p}
|
||||
<g
|
||||
class="piece {sideOf(t.piece)}"
|
||||
class:selected={selected === t.at}
|
||||
class:dying={t.dying}
|
||||
class:frozen={!t.dying && g.cells[t.at] === t.piece && frozen(g.cells, t.at)}
|
||||
style="transform: translate({p.x + CELL / 2}px, {p.y + CELL / 2}px)"
|
||||
pointer-events="none"
|
||||
>
|
||||
<circle class="shadow" cx="1" cy="2" r="15" />
|
||||
<circle class="body" r="14.5" />
|
||||
<circle class="rim" r="12.2" />
|
||||
<text class="letter" class:wide={LETTERS[kindOf(t.piece)].length > 1} y="5.5" text-anchor="middle">{LETTERS[kindOf(t.piece)]}</text>
|
||||
<circle class="ice" r="16.5" />
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each targets as i (i)}
|
||||
{@const p = place(i)}
|
||||
{#if p}
|
||||
{#if g.cells[i] === ''}
|
||||
<circle class="hint" cx={p.x + CELL / 2} cy={p.y + CELL / 2} r={CELL * 0.13} pointer-events="none" />
|
||||
{:else}
|
||||
<rect class="hint-ring" x={p.x + 2} y={p.y + 2} width={CELL - 4} height={CELL - 4} rx="4" pointer-events="none" />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.artwork {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
--air-light: #e4ecf2;
|
||||
--air-dark: #9fb8cc;
|
||||
--land-light: #e6d7a3;
|
||||
--land-dark: #8fa66a;
|
||||
--under-light: #c9937a;
|
||||
--under-dark: #7a4a3a;
|
||||
--ground: #2a2522;
|
||||
--title: #c9bca5;
|
||||
--coord: #d8cdb6;
|
||||
--line: rgba(20, 14, 10, 0.35);
|
||||
--gold: #d8a63e;
|
||||
--gold-rim: #8a6218;
|
||||
--gold-ink: #2b1d05;
|
||||
--scarlet: #a4302a;
|
||||
--scarlet-rim: #5e1612;
|
||||
--scarlet-ink: #fbe9dc;
|
||||
}
|
||||
|
||||
.ground {
|
||||
fill: var(--ground);
|
||||
stroke: rgba(255, 240, 210, 0.12);
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
|
||||
.title {
|
||||
fill: var(--title);
|
||||
font-family: var(--font);
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.coord {
|
||||
fill: var(--coord);
|
||||
font-family: var(--font);
|
||||
font-size: 8.5px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.square {
|
||||
stroke: var(--line);
|
||||
stroke-width: 0.5;
|
||||
cursor: default;
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
.square[data-level='3'] {
|
||||
fill: var(--air-dark);
|
||||
}
|
||||
|
||||
.square[data-level='3'].light {
|
||||
fill: var(--air-light);
|
||||
}
|
||||
|
||||
.square[data-level='2'] {
|
||||
fill: var(--land-dark);
|
||||
}
|
||||
|
||||
.square[data-level='2'].light {
|
||||
fill: var(--land-light);
|
||||
}
|
||||
|
||||
.square[data-level='1'] {
|
||||
fill: var(--under-dark);
|
||||
}
|
||||
|
||||
.square[data-level='1'].light {
|
||||
fill: var(--under-light);
|
||||
}
|
||||
|
||||
.square.last {
|
||||
fill: #86b5d6;
|
||||
}
|
||||
|
||||
.square.last.light {
|
||||
fill: #b7d6ea;
|
||||
}
|
||||
|
||||
.square.target {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.square.fallen,
|
||||
.square.fallen.light {
|
||||
fill: #d9736a;
|
||||
}
|
||||
|
||||
.square.check,
|
||||
.square.check.light {
|
||||
fill: #e0484a;
|
||||
}
|
||||
|
||||
.square:focus-visible {
|
||||
outline: none;
|
||||
stroke: var(--frost);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
fill: var(--frost);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hint-ring {
|
||||
fill: none;
|
||||
stroke: var(--frost);
|
||||
stroke-width: 2.6;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.piece {
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.piece.dying {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.piece .shadow {
|
||||
fill: #0d0906;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.piece .body {
|
||||
fill: var(--gold);
|
||||
stroke: var(--gold-rim);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.piece .rim {
|
||||
fill: none;
|
||||
stroke: var(--gold-rim);
|
||||
stroke-width: 0.6;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.piece .letter {
|
||||
fill: var(--gold-ink);
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.piece .letter.wide {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.piece.scarlet .body {
|
||||
fill: var(--scarlet);
|
||||
stroke: var(--scarlet-rim);
|
||||
}
|
||||
|
||||
.piece.scarlet .rim {
|
||||
stroke: var(--scarlet-ink);
|
||||
}
|
||||
|
||||
.piece.scarlet .letter {
|
||||
fill: var(--scarlet-ink);
|
||||
}
|
||||
|
||||
.piece .ice {
|
||||
fill: none;
|
||||
stroke: #bfe6ff;
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 3 2.5;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.piece.frozen .ice {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.piece.frozen .body {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.piece.selected .body {
|
||||
stroke: var(--frost);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.calm .piece,
|
||||
.calm .square {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,8 @@
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
import type { State } from '$lib/game';
|
||||
import { game } from '$lib/game';
|
||||
import { createGame, game } from '$lib/game';
|
||||
import BoardArtwork from './BoardArtwork.svelte';
|
||||
import { tableSize } from '$lib/game/spec';
|
||||
|
||||
let name = $state(playerName());
|
||||
@@ -23,6 +24,8 @@
|
||||
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
|
||||
);
|
||||
let prefsOpen = $state(false);
|
||||
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
||||
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let seats = $state(allSeats());
|
||||
@@ -163,14 +166,14 @@
|
||||
const g = games[held.roomId];
|
||||
if (g === undefined) return 'looking';
|
||||
if (g === null) return 'unreachable';
|
||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
|
||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a drake stands in)` : s.bot ? `${s.name} (a drake)` : s.name));
|
||||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||||
if (g.over) {
|
||||
if (g.over.winner === held.seat) return 'you won';
|
||||
if (g.over.winner === null) return 'a draw';
|
||||
return `${game.nameOf(g.state, g.over.winner)} won`;
|
||||
}
|
||||
const round = `round ${g.turn}`;
|
||||
const round = `move ${g.turn}`;
|
||||
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
|
||||
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
|
||||
}
|
||||
@@ -203,8 +206,8 @@
|
||||
<section class="lid" class:returning={active.length > 0}>
|
||||
<div class="lid-head">
|
||||
<h1>Wyrm</h1>
|
||||
<p class="pitch">One sentence that says what the game is, in its own world.</p>
|
||||
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
|
||||
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||
<p class="tag">Gary Gygax's Dragonchess, from Dragon Magazine in 1985. Gold and Scarlet each lead 42 pieces across the air, the land and the underworld, and the game ends the way chess does, with a King that cannot escape. Play a drake here, free, or open a table and send a friend the link.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
</div>
|
||||
@@ -230,7 +233,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
||||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against a drake</button>
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
|
||||
<form class="joinform" onsubmit={join}>
|
||||
@@ -249,8 +252,8 @@
|
||||
</div>
|
||||
|
||||
<div class="lid-look" aria-hidden="true">
|
||||
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. -->
|
||||
<p class="caption">A picture of the game goes here.</p>
|
||||
<BoardArtwork g={opening} levels={[3, 2]} decorative />
|
||||
<p class="caption">The air and the land in the opening position. The underworld lies beneath.</p>
|
||||
</div>
|
||||
|
||||
{#snippet gameRow(held: (typeof seats)[number])}
|
||||
@@ -318,9 +321,9 @@
|
||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
||||
{#snippet aboutGame()}
|
||||
<div class="about-body">
|
||||
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
|
||||
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
|
||||
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</p>
|
||||
<p>Gary Gygax, who made Dungeons & Dragons, published Dragonchess in the hundredth issue of Dragon Magazine, in August 1985, as seven pages of rules and no board. He played it against himself on three sheets of plexiglass with metal miniatures for pieces, and told readers that three rectangles laid in a row on a table would do. The keeper has that issue on the shelf and built the boards it never came with.</p>
|
||||
<p>Wyrm plays the game as the article gives it, every piece as written, with the few open readings settled on the <a href="/rules">rules page</a>, where the article is transcribed in full. The house player is a drake, a search that reads a few moves ahead and values the pieces roughly as Gygax did, so it is fair company for a first game and beatable by a careful one. The name is this site's own. Dragonchess was Gygax's and TSR's, and another company sells a game called Dragon Chess that has nothing to do with this one.</p>
|
||||
<p>It is free and keeps no accounts. A game lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and nothing in it is hidden from either player or from the gallery.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet aboutTally()}
|
||||
@@ -343,7 +346,7 @@
|
||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
|
||||
</p>
|
||||
<p class="made">Wyrm is its designer's.</p>
|
||||
<p class="made">Dragonchess is Gary Gygax's, published by TSR, Inc. in 1985. Wyrm is an unofficial rebuild of it for the browser.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet aboutWord()}
|
||||
@@ -489,8 +492,10 @@
|
||||
.lid-look {
|
||||
grid-area: look;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 8rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--slate-deep);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
{#each v.seats as s (s.id)}
|
||||
<li>
|
||||
<span class="seat-name">{s.name}</span>
|
||||
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
|
||||
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'a drake' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
|
||||
<button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -113,7 +113,7 @@
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if room.seatEmpty && room.isHost}
|
||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
|
||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a drake</button>
|
||||
{/if}
|
||||
{#if room.isHost && beginAsked && v.expected.length > 0}
|
||||
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
|
||||
@@ -138,7 +138,7 @@
|
||||
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a drake, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
function phraseTitle(name: string, standIn: boolean): string {
|
||||
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
|
||||
const why = standIn ? `Brings ${name} back from the drake.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
|
||||
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
|
||||
}
|
||||
</script>
|
||||
@@ -48,22 +48,22 @@
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<div class="slip" role="dialog" aria-labelledby="seats-heading">
|
||||
<h2 id="seats-heading">The other seats</h2>
|
||||
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p>
|
||||
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a drake, which plays it at once.</p>
|
||||
{#if people.length === 0}
|
||||
<p class="muted">Nobody else at this table is a person.</p>
|
||||
{/if}
|
||||
<ul>
|
||||
{#each people as s (s.id)}
|
||||
<li>
|
||||
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span>
|
||||
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a drake plays for them</span>{/if}</span>
|
||||
<span class="ways">
|
||||
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
|
||||
{#if !s.standIn && !room.view.over}
|
||||
{#if asked === s.id}
|
||||
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
|
||||
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a drake plays for {s.name}</button>
|
||||
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
||||
{:else}
|
||||
<button type="button" class="quiet" title="A bot plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
|
||||
<button type="button" class="quiet" title="A drake plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Cell, type Kind, type Side, type Setup, NAMES, LETTERS, cellOf, hasLegalMove, inCheck, kindOf, legalMovesFrom,
|
||||
levelOf, movesFrom, opening, other, playOn, sideOf, squareName
|
||||
} from './board';
|
||||
export { FILES, RANKS, LEVELS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, kingSquare, inCheck, other, square } from './board';
|
||||
import { chooseMove } from './bot';
|
||||
|
||||
export const CURRENT_RULES = 1;
|
||||
|
||||
@@ -109,12 +109,12 @@ export class Room {
|
||||
}
|
||||
|
||||
addBot(): Promise<boolean> {
|
||||
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
|
||||
return this.act(() => api.addBot(this.roomId, this.token), 'The drake could not be seated.');
|
||||
}
|
||||
|
||||
/** Send a bot away before the game begins; only the host may. */
|
||||
unseat(seat: SeatId): Promise<boolean> {
|
||||
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
||||
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The drake could not be sent away.');
|
||||
}
|
||||
|
||||
/** Out of the game, with the bots playing on: play the rest at once. */
|
||||
@@ -168,7 +168,7 @@ export class Room {
|
||||
|
||||
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
|
||||
standIn(seat: SeatId): Promise<boolean> {
|
||||
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.');
|
||||
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a drake.');
|
||||
}
|
||||
|
||||
async submit(input: Input): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// The rules page reads docs/source.md, the transcription of Gygax's article
|
||||
// the engine follows, and shows it section by section, so the page and the
|
||||
// engine have one source. This is the small amount of Markdown that file
|
||||
// uses: headings, paragraphs, bold and italic, indented diagrams, one
|
||||
// table and one numbered list.
|
||||
import raw from '../../docs/source.md?raw';
|
||||
|
||||
export interface SourceSection {
|
||||
id: string;
|
||||
title: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** Bold, italic and the escaped backslash, inside a line. */
|
||||
function inline(text: string): string {
|
||||
return escape(text)
|
||||
.replace(/\\\*/g, '*')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/(^|[\s(])\*([^*]+?)\*/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
function block(lines: string[]): string {
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.every((l) => l.startsWith(' '))) return `<pre>${escape(lines.map((l) => l.slice(4)).join('\n'))}</pre>`;
|
||||
if (lines[0].startsWith('|')) {
|
||||
const rows = lines.filter((l) => !/^\|[-| ]+\|$/.test(l)).map((l) => l.split('|').slice(1, -1).map((c) => inline(c.trim())));
|
||||
const [head, ...body] = rows;
|
||||
return `<table><thead><tr>${head.map((c) => `<th>${c}</th>`).join('')}</tr></thead><tbody>${body.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
if (/^\d+\. /.test(lines[0])) {
|
||||
const items: string[] = [];
|
||||
for (const l of lines) {
|
||||
if (/^\d+\. /.test(l)) items.push(l.replace(/^\d+\. /, ''));
|
||||
else items[items.length - 1] += ' ' + l.trim();
|
||||
}
|
||||
return `<ol>${items.map((i) => `<li>${inline(i)}</li>`).join('')}</ol>`;
|
||||
}
|
||||
return `<p>${inline(lines.join(' '))}</p>`;
|
||||
}
|
||||
|
||||
/** The article's sections, split at its headings; the text before the first heading is the transcriber's note. */
|
||||
export function sections(): SourceSection[] {
|
||||
const out: SourceSection[] = [];
|
||||
let current: SourceSection | null = null;
|
||||
let para: string[] = [];
|
||||
const flush = () => {
|
||||
if (current && para.length) current.html += block(para);
|
||||
para = [];
|
||||
};
|
||||
for (const line of raw.split('\n')) {
|
||||
const heading = /^(#{1,3}) (.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flush();
|
||||
const title = heading[2].trim();
|
||||
current = { id: title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''), title, html: '' };
|
||||
out.push(current);
|
||||
} else if (line === '---') flush();
|
||||
else if (line.trim() === '') flush();
|
||||
else if (line.startsWith(' ') && para.length && !para[0].startsWith(' ')) {
|
||||
flush();
|
||||
para.push(line);
|
||||
} else para.push(line);
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
// How to play: a walk through the page in the game's own voice, one
|
||||
// screenshot per section, from the hall to a first game. Capture the
|
||||
// figures from the running game into static/guide/ and describe what a
|
||||
// new player is looking at, not what the code does.
|
||||
// How to play: a walk from the hall to a first game, in the game's own
|
||||
// voice, with a figure for each part of the page.
|
||||
import { page } from '$app/state';
|
||||
|
||||
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
|
||||
@@ -18,11 +16,12 @@
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
|
||||
<h1>How to play</h1>
|
||||
<p class="lede">Two sentences that set the scene: who the player is, and what they are trying to do.</p>
|
||||
<p class="lede">You command Gold or Scarlet, 42 pieces spread across the air, the land and the underworld, and you are after the other King. If you know chess you know most of it already. The rest is learning which pieces can climb and which can dig.</p>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#hall">The hall</a>
|
||||
<a href="#table">The table</a>
|
||||
<a href="#board">The board</a>
|
||||
<a href="#boards">The boards</a>
|
||||
<a href="#moving">Making a move</a>
|
||||
<a href="#gallery">The Peanut Gallery and table talk</a>
|
||||
<a href="#first">A first game</a>
|
||||
</nav>
|
||||
@@ -30,29 +29,43 @@
|
||||
|
||||
<section id="hall">
|
||||
<h2>The hall</h2>
|
||||
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the bot and begins. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
|
||||
<figure><img src="/guide/hall.png" alt="The hall: a name field, the two table choices, the button to play the drake, and the opening position drawn beside them" loading="lazy" /></figure>
|
||||
<p>Write your name and choose your company. <strong>Play now against a drake</strong> seats you across from the house player and begins at once. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send to a friend. <strong>Join one</strong> takes a code someone sent you.</p>
|
||||
<p>Two choices sit above the buttons. <em>You play</em> picks your side, and Gold moves first. <em>The Sylphs</em> picks where the six Sylphs start. As Gygax printed it, Gold's Dragon can take an undefended Sylph on the very first move, which a reader of the Chess Variant Pages took for a misprint. The shifted setup moves the Sylphs one file over, out of reach. Beneath the buttons, <em>your games</em> lists every table this browser holds a seat at and whose move it is.</p>
|
||||
</section>
|
||||
|
||||
<section id="table">
|
||||
<h2>The table</h2>
|
||||
<p>A table fills as players open the link. Whoever laid it is the host. The host may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
|
||||
<figure><img src="/guide/table.png" alt="A table laid: the room code, the link to send, two seats, and the Begin button" loading="lazy" /></figure>
|
||||
<p>A table seats two. Whoever laid it is the host. The host may seat a drake in the empty chair or send one away, and begins the game when the company is right. Once begun, the seats are closed, and anyone else who opens the link watches from the Peanut Gallery.</p>
|
||||
</section>
|
||||
|
||||
<section id="board">
|
||||
<h2>The board</h2>
|
||||
<p>What the player sees during play, section by section, with a figure for each.</p>
|
||||
<section id="boards">
|
||||
<h2>The boards</h2>
|
||||
<figure><img src="/guide/boards.png" alt="The three boards one above the other in the opening position, the air in blue and white, the land in green and amber, the underworld in red and brown" loading="lazy" /></figure>
|
||||
<p>The three boards stand one above the other, the air at the top, the land in the middle and the underworld at the bottom, in the colours Gygax suggested. Your side is nearest you on every board. Each piece is a disc bearing the initial Gygax gave it, with Dr for the Dragon so it is not mistaken for a Dwarf. The square names along the edges are his too, and you can hide them under <em>preferences</em>.</p>
|
||||
<p>The strip above the boards says whose move it is and warns when a King is in check. The record beside the boards lists the moves played in Gygax's notation. Click one to see it on the boards, and click again, or make your move, to come back to now.</p>
|
||||
</section>
|
||||
|
||||
<section id="moving">
|
||||
<h2>Making a move</h2>
|
||||
<figure><img src="/guide/move.png" alt="A Gold Dragon selected in the air, with dots on every square it can reach and a ring around the Scarlet Sylph it can take" loading="lazy" /></figure>
|
||||
<p>Tap one of your pieces and every square it can reach lights up, on its own board and on any board it can climb or drop to. A dot marks an empty square and a ring marks a piece it can take. Tap the square and the move is made. Tap the piece again, or anywhere else, to put it down. With a keyboard, the arrows walk a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p>
|
||||
<p>A move that would leave your own King in check is never offered. If you would rather be asked before a move goes, turn on <em>confirm a move before it is sent</em> in preferences.</p>
|
||||
<p>A piece under a Basilisk's gaze is drawn pale with a frost ring around it. It is frozen and can do nothing until the Basilisk moves or is taken.</p>
|
||||
</section>
|
||||
|
||||
<section id="gallery">
|
||||
<h2>The Peanut Gallery and table talk</h2>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a bot there, and the same words bring them back to it. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery and sees the whole game, since nothing in Wyrm is hidden. <em>Table talk</em> is for the two players, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. Every line is kept with the game.</p>
|
||||
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a drake there, and the same words bring them back to it. When a game ends, either player may call for a rematch, which opens a new table for the same company and holds the seats until both are back. While a table is laid, you may challenge the keeper of this site to a seat, and the note beside the button gives the hour where they live.</p>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
<h2>A first game</h2>
|
||||
<p>The advice you would give a friend across the table for their first few turns.</p>
|
||||
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
||||
<p>Play a drake first. It plays a fair game and never minds how long you take. Begin as you would at chess, with Warriors stepping forward and the knights of the land, the Unicorns and Heroes, coming out behind them. Gygax's own advice is to hold the Mage and the Paladin back until the lesser pieces are developed, and to keep your Dragon home. It starts directly above your King for a reason. A Dragon that flies off to raid leaves the King open to the other Dragon, which strikes the land from the air without ever coming down.</p>
|
||||
<p>Watch the Basilisks. Anything you leave on the land directly above an enemy Basilisk is frozen on the spot, and a frozen piece may as well be off the board. The Cleric is stronger than it looks, since it is the only piece that plays the same on all three boards, and the Dwarves, if you do not hurry them forward, make good guards late in the game.</p>
|
||||
<p>The full rules, piece by piece, are on the <a href={withRoom('/rules')}>rules page</a>, with Gygax's article beneath them. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>, or use <em>Report</em> in the masthead at the table, which pins what you saw to the move it happened on. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -68,4 +81,16 @@
|
||||
p + p {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
figure img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -169,7 +169,7 @@
|
||||
{: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>
|
||||
<p class="standfirst">Gold and Scarlet move by turns, and the boards wait for whoever is away.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<nav class="actions">
|
||||
@@ -184,7 +184,7 @@
|
||||
<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>
|
||||
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a drake" onclick={() => (seatsOpen = true)}>Seats</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
|
||||
@@ -240,7 +240,7 @@
|
||||
{/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>
|
||||
<p class="notice muted">A drake 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}
|
||||
|
||||
+170
-10
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
// The rules page: the game's own text, structured for reading mid-game.
|
||||
// Keep the original rules text verbatim in docs/ and render from it
|
||||
// where you can, so the page and the engine follow one source.
|
||||
// The rules page: the game as this table plays it, piece by piece, then
|
||||
// Gygax's article in full from docs/source.md, which the engine follows.
|
||||
import { page } from '$app/state';
|
||||
import { sections } from '$lib/source';
|
||||
|
||||
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
|
||||
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
const source = sections();
|
||||
/** The article proper runs from its title to the end of "Putting together a game"; the notes around it are the transcriber's. */
|
||||
const article = source.filter((s) => s.id !== 'the-source-text' && s.id !== 'decisions');
|
||||
const decisions = source.find((s) => s.id === 'decisions');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -17,31 +21,187 @@
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
|
||||
<h1>The rules</h1>
|
||||
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p>
|
||||
<p class="lede">
|
||||
Wyrm plays Dragonchess as Gary Gygax published it in Dragon Magazine No. 100, August 1985. The article is the only rules text there is. It is transcribed in full at the foot of this page, and the game follows it word for word, with the handful of readings the text leaves open settled under <a href="#decisions">What this table decides</a>.
|
||||
</p>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#boards">The boards</a>
|
||||
<a href="#winning">Winning</a>
|
||||
<a href="#air">The air</a>
|
||||
<a href="#land">The land</a>
|
||||
<a href="#underworld">The underworld</a>
|
||||
<a href="#between">Between the boards</a>
|
||||
<a href="#decisions">What this table decides</a>
|
||||
<a href="#source">The original text</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="turn">
|
||||
<h2>A turn</h2>
|
||||
<p>What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.</p>
|
||||
<section id="boards">
|
||||
<h2>The boards</h2>
|
||||
<p>
|
||||
Three boards lie one above another, each twelve squares wide and eight deep. The top board is the air, the middle board the land, and the bottom board the underworld. Gold and Scarlet each have 42 pieces, and Gold moves first. A square is named by its board, its file and its rank, so <strong>3g1</strong> is the air above Gold's King and <strong>1g8</strong> the underworld beneath Scarlet's. The files run a to l from Gold's left, the ranks 1 to 8 from Gold's side, and the square at Gold's near right corner, l1, is light.
|
||||
</p>
|
||||
<p>
|
||||
A move takes one piece to one square. A piece lands on an empty square or on an enemy piece, which is taken, and never on a friend. Some pieces live on one board for the whole game, and some pass between boards in ways of their own. There is no castling, no two-square first step for a Warrior, and so no capture in passing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="winning">
|
||||
<h2>Winning</h2>
|
||||
<p>How the game ends and who wins, including draws.</p>
|
||||
<p>
|
||||
A King attacked by an enemy piece is in check, and a move may not leave your own King in check. A King in check with no way out, by moving, by taking the attacker or by putting a piece in the way, is checkmated, and the game is lost. A player who is not in check but has no legal move at all is stalemated, and the game is drawn. The game is also drawn when the same position stands for the third time with the same side to move, and when fifty moves pass on each side without a capture.
|
||||
</p>
|
||||
<p>
|
||||
A Basilisk in the underworld freezes the enemy piece on the square directly above it. A frozen piece cannot move, cannot take and threatens nothing, though it still blocks the square it stands on. A frozen King cannot step out of check, so it is mated by any attack that cannot be answered by a capture or a block.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="air">
|
||||
<h2>The pieces of the air</h2>
|
||||
<p>
|
||||
<strong>Sylph</strong>, six a side. A Sylph is a pawn turned inside out. It moves one square diagonally forward and captures the square straight ahead. It can also capture the enemy piece directly beneath it on the land, and that is the only way down. On the land it cannot move except to climb back, without capturing, to the square directly above it or to any empty one of its six starting squares. A Sylph that reaches the far rank can go no further on the air board, but it can still strike down, and once it has it may climb again into a starting square.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Griffon</strong>, two a side. In the air a Griffon leaps two squares one way and three the other, over anything in between, the way a knight leaps one and two. It can drop to the land on any of the four squares diagonally next to the square beneath it, taking a piece there or not. On the land it loses its flight and moves one square diagonally, and it rises again to any of the four squares diagonally next to the square above it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Dragon</strong>, one a side. The Dragon never leaves the air. It slides any distance along a diagonal, or steps one square in any direction. It also strikes from afar. Without moving, it can take an enemy piece on the land that stands directly beneath it or on one of the four squares beside that one. The piece is removed and the Dragon stays where it is. That strike is the Dragon's whole move, and it counts as an attack for check.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="land">
|
||||
<h2>The pieces of the land</h2>
|
||||
<p>
|
||||
<strong>Warrior</strong>, twelve a side. A pawn that cannot take a double first step. It moves one square straight forward and captures one square diagonally forward. On reaching the far rank it becomes a Hero, with no choice in the matter. It never leaves the land.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Oliphant</strong>, two a side, moves like a rook. <strong>Unicorn</strong>, two a side, moves like a knight. <strong>Thief</strong>, two a side, moves like a bishop. None of the three leaves the land.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Hero</strong>, two a side. On the land a Hero moves one or two squares along a diagonal, leaping over whatever is on the square between. It can also go up to the air or down to the underworld, landing on any of the four squares diagonally next to the square above or below it, and may take a piece as it goes. Off the land, its only move is back, to any of the four squares diagonally next to the square above or below it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Cleric</strong>, one a side. On any board the Cleric moves one square in any direction, like a king. It also steps straight up or down one board. It is the one piece that plays the same on all three boards.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Mage</strong>, one a side. On the land the Mage is a queen, sliding any distance along a rank, file or diagonal. It can also move straight up or down one board or two, though to pass through the land the square there must be empty. In the air or the underworld it is reduced to one square sideways, forward or back.
|
||||
</p>
|
||||
<p>
|
||||
<strong>King</strong>, one a side. On the land the King moves one square in any direction, and it may step straight up to the air or down to the underworld. On either of those boards it can do nothing but come straight back to the land.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Paladin</strong>, one a side. On the land the Paladin has the king's move and the knight's leap together. It can also leap between boards the way a knight leaps on one, one board up or down and two squares along a rank or file, or two boards and one square, over anything in between. In the air or the underworld it keeps only the king's step.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="underworld">
|
||||
<h2>The pieces of the underworld</h2>
|
||||
<p>
|
||||
<strong>Dwarf</strong>, six a side. A Dwarf moves one square straight forward or one square sideways without capturing, and captures one square diagonally forward. From the underworld it can take the enemy piece directly above it on the land, and on the land it keeps the same moves. It comes back down only to the empty square directly beneath it. A Dwarf on the far rank can still move sideways and between the boards, and it never reaches the air.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Basilisk</strong>, two a side. A Basilisk moves one square straight or diagonally forward, taking what is there, or one square straight back onto an empty square. It never leaves the underworld. The enemy piece directly above it on the land is frozen for as long as the Basilisk stays beneath it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Elemental</strong>, one a side. In the underworld the Elemental moves one or two squares along a rank or file and captures the same way, with the nearer square empty before it can reach the further. It can also move one square diagonally, but never captures that way. It climbs to the land only to capture, moving one square along a rank or file to an empty square and then straight up onto an enemy piece. On the land it can do nothing but come down, straight down through an empty square and then one square along a rank or file, taking a piece there or not.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="between">
|
||||
<h2>Between the boards</h2>
|
||||
<p>
|
||||
Five kinds of piece pass between boards. The Griffon, the Hero and the Paladin leap, and nothing in the way stops them. The Mage and the Elemental do not leap. A Mage going from the underworld to the air needs the square on the land between to be empty, and an Elemental needs the square beside it empty on the way up and the square beneath it empty on the way down. The Sylph, the Dwarf, the Cleric and the King move straight up or down, one board at a time, onto the square itself.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="decisions">
|
||||
<h2>What this table decides</h2>
|
||||
<p class="muted">
|
||||
Gygax's text contradicts itself in two places and leaves a few cases unsaid. These are the readings the game follows, as recorded with the transcription. Two of them are choices the host makes when opening a table.
|
||||
</p>
|
||||
{#if decisions}{@html decisions.html}{/if}
|
||||
</section>
|
||||
|
||||
<section id="source">
|
||||
<h2>The original text</h2>
|
||||
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
|
||||
<p class="muted">
|
||||
"Dragonchess" by Gary Gygax, Dragon Magazine No. 100, August 1985, pages 34 to 40. Transcribed from the keeper's copy. The diagrams are described in words where they stood. Open a heading to read that part.
|
||||
</p>
|
||||
{#each article as s (s.id)}
|
||||
<details>
|
||||
<summary>{s.title}</summary>
|
||||
<div class="source">{@html s.html}</div>
|
||||
</details>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<p class="word">If this page reads a rule differently from the original text, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
details {
|
||||
border-top: 1px solid var(--rule);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source {
|
||||
padding: 0.5rem 0 0.4rem 1rem;
|
||||
border-left: 2px solid var(--rule-strong);
|
||||
color: var(--bone-dim);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.source :global(p + p),
|
||||
.source :global(pre + p),
|
||||
.source :global(p + pre),
|
||||
.source :global(table) {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.source :global(pre) {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.source :global(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.source :global(th),
|
||||
.source :global(td) {
|
||||
padding: 0.15rem 0.8rem 0.15rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#decisions :global(ol) {
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
#decisions :global(li + li) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-faint);
|
||||
color: var(--bone-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user