Compare commits
4
Commits
13fc4c5ef3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93463a529b | ||
|
|
8c03fd624c | ||
|
|
f2be3b0055 | ||
|
|
0f4ae0dca4 |
No files matched your search
@@ -36,12 +36,15 @@ __HOST__ {
|
|||||||
# Everything else is the static site. SvelteKit writes each route as
|
# Everything else is the static site. SvelteKit writes each route as
|
||||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||||
# cached for a year; every other response is revalidated so a deploy shows
|
# cached for a year, and the glass board's figures, which weigh a few
|
||||||
# up on the next load.
|
# megabytes and seldom change, for a week; every other response is
|
||||||
|
# revalidated so a deploy shows up on the next load.
|
||||||
handle {
|
handle {
|
||||||
@immutable path /_app/immutable/*
|
@immutable path /_app/immutable/*
|
||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
@mutable not path /_app/immutable/*
|
@figures path /figures/*
|
||||||
|
header @figures Cache-Control "public, max-age=604800"
|
||||||
|
@mutable not path /_app/immutable/* /figures/*
|
||||||
header @mutable Cache-Control "no-cache"
|
header @mutable Cache-Control "no-cache"
|
||||||
try_files {path} {path}.html /index.html
|
try_files {path} {path}.html /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
+161
-25
@@ -44,29 +44,32 @@
|
|||||||
};
|
};
|
||||||
const flipped = $derived(mySide === 'scarlet');
|
const flipped = $derived(mySide === 'scarlet');
|
||||||
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
|
const inCheckNow = $derived(!g.over && inCheck(g.cells, g.toMove));
|
||||||
const checkSquare = $derived(inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
|
|
||||||
|
|
||||||
// --- Tokens: pieces with a life of their own, so a move slides, a capture fades and a Warrior stands up a Hero ---
|
// --- 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[]>([]);
|
let tokens = $state<Token[]>([]);
|
||||||
/** How many of the game's moves the tokens have played through. */
|
/** How many of the moves the tokens have played through. */
|
||||||
let shown = $state(0);
|
let shown = $state(0);
|
||||||
let animating = $state(false);
|
let animating = $state(false);
|
||||||
|
/** Bumped when the tokens are set out afresh, so a catch-up in flight lets go of them. */
|
||||||
|
let epoch = 0;
|
||||||
const calm = $derived(reducedMotion());
|
const calm = $derived(reducedMotion());
|
||||||
const showCoords = $derived(prefs.coordinates !== false);
|
const showCoords = $derived(prefs.coordinates !== false);
|
||||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms));
|
||||||
|
|
||||||
function resetTokens() {
|
function resetTokens() {
|
||||||
|
epoch += 1;
|
||||||
let id = 0;
|
let id = 0;
|
||||||
tokens = g.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
tokens = v.cells.flatMap((piece, at) => (piece === '' ? [] : [{ id: id++, piece, at, dying: false }]));
|
||||||
shown = g.moves.length;
|
shown = v.moves.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function catchUp() {
|
async function catchUp() {
|
||||||
if (animating) return;
|
if (animating) return;
|
||||||
animating = true;
|
animating = true;
|
||||||
|
const mine = epoch;
|
||||||
try {
|
try {
|
||||||
while (shown < g.moves.length) {
|
while (shown < v.moves.length) {
|
||||||
const m = g.moves[shown];
|
const m = v.moves[shown];
|
||||||
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
const mover = tokens.find((t) => t.at === m.from && !t.dying);
|
||||||
if (!mover) {
|
if (!mover) {
|
||||||
resetTokens();
|
resetTokens();
|
||||||
@@ -80,12 +83,15 @@
|
|||||||
if (victim) victim.dying = true;
|
if (victim) victim.dying = true;
|
||||||
mover.at = m.to;
|
mover.at = m.to;
|
||||||
await wait(380);
|
await wait(380);
|
||||||
|
if (mine !== epoch) return;
|
||||||
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
|
if (m.promoted) mover.piece = (mover.piece[0] + 'H') as typeof mover.piece;
|
||||||
}
|
}
|
||||||
|
if (mine !== epoch) return;
|
||||||
if (victim) tokens = tokens.filter((t) => !t.dying);
|
if (victim) tokens = tokens.filter((t) => !t.dying);
|
||||||
shown += 1;
|
shown += 1;
|
||||||
announce(storyOf(shown));
|
if (reviewing === null) announce(storyOf(shown));
|
||||||
if (shown < g.moves.length) await wait(260);
|
if (shown < v.moves.length) await wait(260);
|
||||||
|
if (mine !== epoch) return;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
animating = false;
|
animating = false;
|
||||||
@@ -93,33 +99,105 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const total = g.moves.length;
|
const total = v.moves.length;
|
||||||
if (total < shown || (tokens.length === 0 && g.cells.some((p) => p !== ''))) resetTokens();
|
if (total < shown || (tokens.length === 0 && v.cells.some((p) => p !== ''))) resetTokens();
|
||||||
else if (total > shown) void catchUp();
|
else if (total > shown) void catchUp();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- What the boards show: the last move played, or a move picked from the record ---
|
// --- What the boards show: the game as it stands, or as it stood after a move picked from the record ---
|
||||||
let reviewing = $state<number | null>(null);
|
let reviewing = $state<number | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void g.moves.length;
|
void g.moves.length;
|
||||||
reviewing = null;
|
reviewing = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
});
|
});
|
||||||
|
/** The state the boards draw: the game's own, or the position after the move under review, with that many moves played. */
|
||||||
|
const v = $derived.by((): typeof g => {
|
||||||
|
if (reviewing === null) return g;
|
||||||
|
const t = tellings[reviewing - 1];
|
||||||
|
return { ...g, cells: t.after, moves: g.moves.slice(0, reviewing), toMove: t.move.by === 'gold' ? 'scarlet' : 'gold' };
|
||||||
|
});
|
||||||
|
/** The King in check on the boards as drawn: the game's, or the position under review. */
|
||||||
|
const checkSquare = $derived(reviewing !== null ? (inCheck(v.cells, v.toMove) ? kingSquare(v.cells, v.toMove) : -1) : inCheckNow ? kingSquare(g.cells, g.toMove) : -1);
|
||||||
const shownPly = $derived(reviewing ?? shown);
|
const shownPly = $derived(reviewing ?? shown);
|
||||||
const shownLast = $derived<Move | null>(shownPly > 0 ? g.moves[shownPly - 1] : null);
|
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 shownSquares = $derived(shownLast ? (shownLast.afar ? [shownLast.from] : [shownLast.from, shownLast.to]) : []);
|
||||||
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
|
const shownCaptured = $derived(shownLast?.captured ? [shownLast.to] : []);
|
||||||
|
|
||||||
/** A move picked from the record lights its squares on the boards as they stand; the pieces stay where they are. */
|
/** A move picked from the record puts the boards back as they stood after it, brings its board into view, and says so. */
|
||||||
function review(ply: number | null) {
|
function review(ply: number | null) {
|
||||||
|
if (ply !== null && (ply < 1 || ply > g.moves.length)) return;
|
||||||
|
const step = (ply ?? g.moves.length) - shown;
|
||||||
|
const had = document.activeElement;
|
||||||
reviewing = ply;
|
reviewing = ply;
|
||||||
if (ply === null) return;
|
selected = null;
|
||||||
const m = g.moves[ply - 1];
|
inspected = null;
|
||||||
requestAnimationFrame(() => {
|
// One move on plays itself; any other leap sets the pieces out where they stood.
|
||||||
|
if (step !== 1) resetTokens();
|
||||||
|
announce(ply === null ? `Back to the game as it stands, after move ${g.moves.length}. ${liveLine}` : `The boards after move ${ply} of ${g.moves.length}. ${storyOf(ply)}`);
|
||||||
|
const m = ply === null ? null : g.moves[ply - 1];
|
||||||
|
if (m && oneBoard && !onGlass) level = levelOf(m.to);
|
||||||
|
void tick().then(() => {
|
||||||
|
// A button that went with the mode, or went grey under the keyboard, leaves it nowhere: it goes back to the boards, or stays with the review.
|
||||||
|
const lost = document.activeElement === document.body || (document.activeElement === had && had instanceof HTMLButtonElement && had.disabled);
|
||||||
|
if (lost) {
|
||||||
|
if (ply === null) focusBoards();
|
||||||
|
else backToNow?.focus();
|
||||||
|
}
|
||||||
|
if (!m) return;
|
||||||
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
|
const target = onGlass ? document.querySelector('.play canvas') : document.querySelector(`.play g.world[data-level="${levelOf(m.to)}"]`);
|
||||||
target?.scrollIntoView({ block: matchMedia('(max-width: 860px)').matches ? 'center' : 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
if (!target) return;
|
||||||
|
if (phone) scrollUnderStrip(target);
|
||||||
|
else target.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let backToNow = $state<HTMLButtonElement | null>(null);
|
||||||
|
let artwork = $state<ReturnType<typeof BoardArtwork>>();
|
||||||
|
let glassWay = $state<HTMLButtonElement | null>(null);
|
||||||
|
/** The keyboard goes to the boards: the grid's own square, or, on the glass, the way back to the flat boards. */
|
||||||
|
function focusBoards() {
|
||||||
|
if (artwork) artwork.focusGrid();
|
||||||
|
else glassWay?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phones: the status strip carries a jump to each board, and a phone on its side shows one board at a time ---
|
||||||
|
let phone = $state(false);
|
||||||
|
let oneBoard = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const narrow = matchMedia('(max-width: 860px)');
|
||||||
|
const short = matchMedia('(max-width: 1000px) and (max-height: 540px)');
|
||||||
|
const read = () => {
|
||||||
|
phone = narrow.matches;
|
||||||
|
oneBoard = short.matches;
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
narrow.addEventListener('change', read);
|
||||||
|
short.addEventListener('change', read);
|
||||||
|
return () => {
|
||||||
|
narrow.removeEventListener('change', read);
|
||||||
|
short.removeEventListener('change', read);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
/** The board shown when there is room for one at a time; a move made on another brings that one forward. */
|
||||||
|
let level = $state(2);
|
||||||
|
$effect(() => {
|
||||||
|
const m = g.moves.at(-1);
|
||||||
|
if (m) level = levelOf(m.to);
|
||||||
|
});
|
||||||
|
function goLevel(lv: number) {
|
||||||
|
if (oneBoard) {
|
||||||
|
level = lv;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const world = document.querySelector(`.play g.world[data-level="${lv}"]`);
|
||||||
|
if (world) scrollUnderStrip(world);
|
||||||
|
}
|
||||||
|
/** Scrolls a board to sit just under the status strip, which stays in view above it. */
|
||||||
|
function scrollUnderStrip(el: Element) {
|
||||||
|
const strip = document.querySelector('.play .status');
|
||||||
|
const top = el.getBoundingClientRect().top + scrollY - ((strip?.getBoundingClientRect().height ?? 0) + 14);
|
||||||
|
scrollTo({ top, behavior: calm ? 'auto' : 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
/** The game told move by move, replayed from the ledger's moves. */
|
/** The game told move by move, replayed from the ledger's moves. */
|
||||||
const tellings = $derived(narrate(g));
|
const tellings = $derived(narrate(g));
|
||||||
@@ -152,6 +230,14 @@
|
|||||||
} else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.';
|
} else if (levelOf(m.to) === 2 && frozen(g.cells, m.to)) line += ' There it is frozen by the Basilisk beneath.';
|
||||||
return line;
|
return line;
|
||||||
});
|
});
|
||||||
|
/** The game as it stands, in a few words: shown beside a position under review, and said when the review ends. */
|
||||||
|
const liveLine = $derived.by(() => {
|
||||||
|
if (g.over) return `${verdict} ${g.over.reason}`;
|
||||||
|
if (room.spectating) return `${nameOfSide(g.toMove)} to move.`;
|
||||||
|
if (yourMove) return `Your move${inCheckNow ? ', and your King is in check' : ''}.`;
|
||||||
|
if (room.view.playingOn) return 'The drakes play on.';
|
||||||
|
return `${nameOfSide(g.toMove)} is thinking.`;
|
||||||
|
});
|
||||||
|
|
||||||
// --- Choosing and moving ---
|
// --- Choosing and moving ---
|
||||||
let selected = $state<number | null>(null);
|
let selected = $state<number | null>(null);
|
||||||
@@ -209,7 +295,11 @@
|
|||||||
|
|
||||||
async function tap(i: number) {
|
async function tap(i: number) {
|
||||||
if (room.sending || animating) return;
|
if (room.sending || animating) return;
|
||||||
reviewing = null;
|
if (reviewing !== null) {
|
||||||
|
// The boards are showing an earlier position: a tap brings them back to now, and nothing more.
|
||||||
|
review(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!yourMove) {
|
if (!yourMove) {
|
||||||
// Not your move, or no seat: a tap on a piece still opens its panel.
|
// Not your move, or no seat: a tap on a piece still opens its panel.
|
||||||
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
|
inspected = g.cells[i] !== '' ? (inspected === i ? null : i) : null;
|
||||||
@@ -282,7 +372,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
|
const pieceCount = (side: Side) => v.cells.filter((p) => p !== '' && sideOf(p) === side).length;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="board">
|
<main class="board">
|
||||||
@@ -292,6 +382,11 @@
|
|||||||
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
|
<strong>Move {squareName(pending.from)} to {squareName(pending.to)}?</strong>
|
||||||
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
|
<button type="button" class="commit small" bind:this={confirmButton} onclick={() => pending && send(pending.from, pending.to)} onkeydown={cancelOnEscape}>Confirm</button>
|
||||||
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
|
<button type="button" class="quiet small" onclick={cancel} onkeydown={cancelOnEscape}>Cancel</button>
|
||||||
|
{:else if reviewing !== null}
|
||||||
|
<strong>After move {reviewing} of {g.moves.length}</strong> · {SIDE_NAMES[v.toMove]} to move{#if checkSquare >= 0} · <span class="check">in check</span>{/if} ·
|
||||||
|
<span class="marked"><button type="button" class="link" disabled={reviewing <= 1} onclick={() => review(reviewing! - 1)}>Earlier</button> ·
|
||||||
|
<button type="button" class="link" disabled={reviewing >= g.moves.length} onclick={() => review(reviewing! + 1)}>Later</button> ·
|
||||||
|
<button type="button" class="link" bind:this={backToNow} onclick={() => review(null)}>Back to now</button></span>
|
||||||
{:else if g.over}
|
{:else if g.over}
|
||||||
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
|
<strong>{verdict}</strong> <span class="muted">{g.over.reason}</span>
|
||||||
{:else if room.spectating}
|
{:else if room.spectating}
|
||||||
@@ -303,8 +398,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
<strong>{nameOfSide(g.toMove)} is thinking</strong> · you are {SIDE_NAMES[mySide!]}{#if inCheckNow} · <span class="check">{nameOfSide(g.toMove)} is in check</span>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if reviewing !== null && !pending}
|
{#if phone && !onGlass && !pending}
|
||||||
<span class="marked">Move {reviewing} is lit on the boards as they stand now; no piece has moved back. <button type="button" class="link" onclick={() => review(null)}>Clear it</button></span>
|
<span class="jump">{#each [[3, 'Air'], [2, 'Land'], [1, 'Underworld']] as [lv, label] (lv)}<button type="button" class="link plain" class:current={oneBoard && level === lv} class:lit={targets.some((t) => levelOf(t) === lv)} onclick={() => goLevel(lv as number)}>{label}</button>{/each}</span>
|
||||||
|
{/if}
|
||||||
|
{#if reviewing !== null}
|
||||||
|
<span class="now muted">The game now, after move {g.moves.length}: {liveLine}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
{#if shownStory && (shownCaptured.length || reviewing !== null || shownLast?.check || shownLast?.promoted || shownFreeze)}
|
||||||
@@ -330,13 +428,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over} · <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
|
<p class="muted small glass-way"><button type="button" class="link plain" bind:this={glassWay} disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over} · <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
|
||||||
{#if fault}<p class="warning">{fault}</p>{/if}
|
{#if fault}<p class="warning">{fault}</p>{/if}
|
||||||
{#if onGlass && Glass}
|
{#if onGlass && Glass}
|
||||||
<Glass {g} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
|
<Glass g={v} {mySide} {showCoords} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
<BoardArtwork {g} {tokens} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
<BoardArtwork bind:this={artwork} g={v} {tokens} levels={oneBoard ? [level] : [3, 2, 1]} climb={(lv) => (level = lv)} {showCoords} {flipped} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} {escape} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
<p class="visually-hidden" aria-live="polite">{liveText}</p>
|
||||||
@@ -356,14 +454,14 @@
|
|||||||
<h2>At the table</h2>
|
<h2>At the table</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{#each g.seats as id (id)}
|
{#each g.seats as id (id)}
|
||||||
<li class:to-move={g.players[id].side === g.toMove && !g.over}>
|
<li class:to-move={g.players[id].side === v.toMove && !(g.over && reviewing === null)}>
|
||||||
<span class="dot {g.players[id].side}"></span>
|
<span class="dot {g.players[id].side}"></span>
|
||||||
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
||||||
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
|
<span class="muted">{SIDE_NAMES[g.players[id].side]}, {pieceCount(g.players[id].side)} pieces</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="muted small">Move {g.moves.length + 1}.</p>
|
<p class="muted small">{reviewing !== null ? `After move ${reviewing} of ${g.moves.length}.` : `Move ${g.moves.length + 1}.`}</p>
|
||||||
</section>
|
</section>
|
||||||
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
|
<Chronicle {tellings} {mySide} {reviewing} onReview={review} />
|
||||||
<TableTalk {room} />
|
<TableTalk {room} />
|
||||||
@@ -423,11 +521,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.marked {
|
.marked {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The jumps: one a board, and a mark on any board the chosen piece can reach. */
|
||||||
|
.jump {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump .current {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump .lit::after {
|
||||||
|
content: ' ●';
|
||||||
|
font-size: 0.6em;
|
||||||
|
vertical-align: middle;
|
||||||
color: var(--frost);
|
color: var(--frost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.marked .link:disabled {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -454,6 +583,13 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A phone on its side has no height for three boards: one shows at a time, as wide as the screen allows. */
|
||||||
|
@media (max-width: 1000px) and (max-height: 540px) {
|
||||||
|
.frame {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.glass-way {
|
.glass-way {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto 0.5rem;
|
margin: 0 auto 0.5rem;
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
// underworld, each twelve by eight in the colours Gygax suggested, with
|
// underworld, each twelve by eight in the colours Gygax suggested, with
|
||||||
// the pieces as discs bearing his letters. Shared by the table, the
|
// the pieces as discs bearing his letters. Shared by the table, the
|
||||||
// lessons and the hall's still of the opening.
|
// lessons and the hall's still of the opening.
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
|
import { FILES, RANKS, PER_LEVEL, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, squareName, LETTERS, NAMES, SIDE_NAMES, type State } from '$lib/game';
|
||||||
let {
|
let {
|
||||||
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
g, tokens, levels = [3, 2, 1], showCoords = false, flipped = false, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1,
|
||||||
calm = true, decorative = false, tap = () => {}, escape = () => {}
|
calm = true, decorative = false, tap = () => {}, escape = () => {}, climb
|
||||||
}: {
|
}: {
|
||||||
g: State;
|
g: State;
|
||||||
tokens?: Token[];
|
tokens?: Token[];
|
||||||
@@ -40,9 +41,12 @@
|
|||||||
tap?: (i: number) => void | Promise<void>;
|
tap?: (i: number) => void | Promise<void>;
|
||||||
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
|
/** Escape pressed on a square: the caller lets go of whatever it was doing. */
|
||||||
escape?: () => void;
|
escape?: () => void;
|
||||||
|
/** Page Up or Page Down towards a board that is not drawn: the caller may bring it out. */
|
||||||
|
climb?: (level: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let svg: SVGSVGElement | undefined = $state();
|
let svg: SVGSVGElement | undefined = $state();
|
||||||
|
const focusSquare = (i: number) => svg?.querySelector<SVGRectElement>(`rect.square[data-i="${i}"]`)?.focus();
|
||||||
/** The one square Tab reaches: the grid is entered once, at the square last visited, and the keys walk it from there. */
|
/** The one square Tab reaches: the grid is entered once, at the square last visited, and the keys walk it from there. */
|
||||||
let visited = $state<number | null>(null);
|
let visited = $state<number | null>(null);
|
||||||
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
|
/** Arrows walk a board the way it is drawn, Page Up and Page Down climb and descend, Enter picks up and sets down. */
|
||||||
@@ -57,7 +61,13 @@
|
|||||||
const f = fileOf(i) + df;
|
const f = fileOf(i) + df;
|
||||||
const r = rankOf(i) + dr;
|
const r = rankOf(i) + dr;
|
||||||
const lv = levelOf(i) + dl;
|
const lv = levelOf(i) + dl;
|
||||||
if (f >= 0 && f < FILES && r >= 0 && r < RANKS && levels.includes(lv)) svg?.querySelector<SVGRectElement>(`rect.square[data-i="${idx(lv, f, r)}"]`)?.focus();
|
if (f < 0 || f >= FILES || r < 0 || r >= RANKS || lv < 1 || lv > 3) return;
|
||||||
|
const to = idx(lv, f, r);
|
||||||
|
if (levels.includes(lv)) focusSquare(to);
|
||||||
|
else if (climb) {
|
||||||
|
climb(lv);
|
||||||
|
void tick().then(() => focusSquare(to));
|
||||||
|
}
|
||||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void tap(i);
|
void tap(i);
|
||||||
@@ -88,7 +98,14 @@
|
|||||||
return { x: PAD_L + f * CELL, y: slot * (boardHeight + GAP) + PAD_T + r * CELL };
|
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 drawn = $derived(levels.flatMap((lv) => Array.from({ length: PER_LEVEL }, (_, n) => (lv - 1) * PER_LEVEL + n)));
|
||||||
const entry = $derived(visited ?? selected ?? (levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0]));
|
const entry = $derived.by(() => {
|
||||||
|
for (const c of [visited, selected]) if (c !== null && drawn.includes(c)) return c;
|
||||||
|
return levels.includes(2) ? idx(2, flipped ? FILES - 1 : 0, flipped ? RANKS - 1 : 0) : drawn[0];
|
||||||
|
});
|
||||||
|
/** Puts the keyboard on the grid, at the square Tab would reach. */
|
||||||
|
export function focusGrid() {
|
||||||
|
focusSquare(entry);
|
||||||
|
}
|
||||||
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
const fileLetter = (n: number) => 'abcdefghijkl'[flipped ? FILES - 1 - n : n];
|
||||||
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
const rankNumber = (n: number) => (flipped ? n + 1 : RANKS - n);
|
||||||
const describe = (i: number) => {
|
const describe = (i: number) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The game told top to bottom, oldest first, each move with what came of
|
// The game told top to bottom, oldest first, each move with what came of
|
||||||
// it, and the newest scrolled into view. A move's heading is a button that
|
// it, and the newest scrolled into view. A move's heading is a button that
|
||||||
// shows that move on the boards.
|
// puts the boards back as they stood after it.
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { Telling } from '$lib/game/chronicle';
|
import type { Telling } from '$lib/game/chronicle';
|
||||||
import { notation, type Side } from '$lib/game';
|
import { notation, type Side } from '$lib/game';
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
{#each tellings as t (t.ply)}
|
{#each tellings as t (t.ply)}
|
||||||
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
<article class:mine={mySide === t.move.by} class:reviewed={reviewing === t.ply}>
|
||||||
<h3>
|
<h3>
|
||||||
<button type="button" class="link plain" title="Light this move's squares on the boards as they stand" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
|
<button type="button" class="link plain" title="Show the boards as they stood after this move" onclick={() => onReview(reviewing === t.ply ? null : t.ply)}>
|
||||||
Move {t.ply} <span class="notation">{notation(t.move)}</span>
|
Move {t.ply} <span class="notation">{notation(t.move)}</span>
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
import { FILES, RANKS, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, type Kind, type Side, type State } from '$lib/game';
|
import { FILES, RANKS, fileOf, rankOf, levelOf, idx, isLight, kindOf, sideOf, frozen, type Kind, type Side, type State } from '$lib/game';
|
||||||
|
|
||||||
let { g, mySide = null, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, calm = true, tap = () => {} }: {
|
let { g, mySide = null, selected = null, targets = [], reach = [], shownSquares = [], shownCaptured = [], checkSquare = -1, calm = true, showCoords = true, tap = () => {} }: {
|
||||||
g: State;
|
g: State;
|
||||||
mySide?: Side | null;
|
mySide?: Side | null;
|
||||||
selected?: number | null;
|
selected?: number | null;
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
shownCaptured?: number[];
|
shownCaptured?: number[];
|
||||||
checkSquare?: number;
|
checkSquare?: number;
|
||||||
calm?: boolean;
|
calm?: boolean;
|
||||||
|
/** Whether the sheets' rims carry the files' letters and the ranks' numbers, as the flat boards do. */
|
||||||
|
showCoords?: boolean;
|
||||||
tap?: (i: number) => void | Promise<void>;
|
tap?: (i: number) => void | Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -37,12 +39,20 @@
|
|||||||
/** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */
|
/** The boards' colours, Gygax's: light blue and white, light green and amber, red and brown. */
|
||||||
const SQUARES: Record<number, [string, string]> = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] };
|
const SQUARES: Record<number, [string, string]> = { 3: ['#e4ecf2', '#9fb8cc'], 2: ['#e6d7a3', '#8fa66a'], 1: ['#c9937a', '#7a4a3a'] };
|
||||||
const LIVERY: Record<Side, string> = { gold: '#d8a63e', scarlet: '#a4302a' };
|
const LIVERY: Record<Side, string> = { gold: '#d8a63e', scarlet: '#a4302a' };
|
||||||
const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c' };
|
const MARK = { selected: '#8cc8e0', target: '#8cc8e0', reach: '#8cc8e0', last: '#f0a53a', fallen: '#e0655c', check: '#e0655c', frozen: '#b9c9d2' };
|
||||||
/** Air between the sheets, in squares: enough for a Dragon's wings. */
|
/** Air between the sheets, in squares: enough for a Dragon's wings. */
|
||||||
const GAP = 4.2;
|
const GAP = 4.2;
|
||||||
const CELL = 1;
|
const CELL = 1;
|
||||||
/** The sheet's rim beyond the outer squares, in squares. */
|
/** The sheet's rim beyond the outer squares, in squares: room for the square names. */
|
||||||
const RIM = 0.3;
|
const RIM = 0.45;
|
||||||
|
/** The ink the rim's letters are written in, dark on the pale sheets and pale on the red one. */
|
||||||
|
const INK: Record<number, string> = { 3: '#2a3b48', 2: '#33391f', 1: '#f3dfd0' };
|
||||||
|
/** Repaints the sheets when the preference for square names changes; set once the sheets exist. */
|
||||||
|
let repaint: (() => void) | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
void showCoords;
|
||||||
|
repaint?.();
|
||||||
|
});
|
||||||
|
|
||||||
const levelY = (level: number) => (level - 1) * GAP;
|
const levelY = (level: number) => (level - 1) * GAP;
|
||||||
/** Where a square's centre sits: Gold's side towards +z, file a at -x. */
|
/** Where a square's centre sits: Gold's side towards +z, file a at -x. */
|
||||||
@@ -106,12 +116,11 @@
|
|||||||
|
|
||||||
// The sheets, tinted by level, and the posts that hold them.
|
// The sheets, tinted by level, and the posts that hold them.
|
||||||
const slabs: THREE.Mesh[] = [];
|
const slabs: THREE.Mesh[] = [];
|
||||||
for (const level of [1, 2, 3]) {
|
const sheets: { level: number; c: HTMLCanvasElement; map: THREE.CanvasTexture }[] = [];
|
||||||
// The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures.
|
// The texture covers the whole sheet, rim included, so that the squares sit exactly under the figures.
|
||||||
const PX = 40;
|
// The canvas's bottom edge is Gold's, rank 1; its left edge is file a.
|
||||||
const c = document.createElement('canvas');
|
const PX = 64;
|
||||||
c.width = (FILES + 2 * RIM) * PX;
|
function paint(level: number, c: HTMLCanvasElement) {
|
||||||
c.height = (RANKS + 2 * RIM) * PX;
|
|
||||||
const ctx = c.getContext('2d')!;
|
const ctx = c.getContext('2d')!;
|
||||||
ctx.fillStyle = SQUARES[level][1];
|
ctx.fillStyle = SQUARES[level][1];
|
||||||
ctx.fillRect(0, 0, c.width, c.height);
|
ctx.fillRect(0, 0, c.width, c.height);
|
||||||
@@ -119,8 +128,47 @@
|
|||||||
ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1];
|
ctx.fillStyle = SQUARES[level][isLight(idx(level, f, r)) ? 0 : 1];
|
||||||
ctx.fillRect((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX);
|
ctx.fillRect((RIM + f) * PX, (RIM + RANKS - 1 - r) * PX, PX, PX);
|
||||||
}
|
}
|
||||||
|
if (!showCoords) return;
|
||||||
|
// The names run along every edge, the far ones turned about, so that each seat reads its own.
|
||||||
|
ctx.fillStyle = INK[level];
|
||||||
|
ctx.font = `600 ${Math.round(PX * 0.34)}px 'Iowan Old Style', Georgia, serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const inset = (RIM * PX) / 2;
|
||||||
|
const write = (text: string, x: number, y: number, turned: boolean) => {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
if (turned) ctx.rotate(Math.PI);
|
||||||
|
ctx.fillText(text, 0, 1);
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
for (let f = 0; f < FILES; f++) {
|
||||||
|
const letter = 'abcdefghijkl'[f];
|
||||||
|
const x = (RIM + f + 0.5) * PX;
|
||||||
|
write(letter, x, c.height - inset, false);
|
||||||
|
write(letter, x, inset, true);
|
||||||
|
}
|
||||||
|
for (let r = 0; r < RANKS; r++) {
|
||||||
|
const y = (RIM + RANKS - 1 - r + 0.5) * PX;
|
||||||
|
write(String(r + 1), inset, y, false);
|
||||||
|
write(String(r + 1), c.width - inset, y, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repaint = () => {
|
||||||
|
for (const sh of sheets) {
|
||||||
|
paint(sh.level, sh.c);
|
||||||
|
sh.map.needsUpdate = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const level of [1, 2, 3]) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = (FILES + 2 * RIM) * PX;
|
||||||
|
c.height = (RANKS + 2 * RIM) * PX;
|
||||||
|
paint(level, c);
|
||||||
const map = new THREE.CanvasTexture(c);
|
const map = new THREE.CanvasTexture(c);
|
||||||
map.colorSpace = THREE.SRGBColorSpace;
|
map.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
map.anisotropy = 8;
|
||||||
|
sheets.push({ level, c, map });
|
||||||
const slab = new THREE.Mesh(
|
const slab = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL),
|
new THREE.BoxGeometry((FILES + 2 * RIM) * CELL, 0.12, (RANKS + 2 * RIM) * CELL),
|
||||||
new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0, clearcoat: 1 })
|
new THREE.MeshPhysicalMaterial({ map, transparent: true, opacity: 0.62, roughness: 0.15, metalness: 0, clearcoat: 1 })
|
||||||
@@ -142,7 +190,7 @@
|
|||||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone);
|
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.19, 1.0, 12), stone);
|
||||||
tip.position.y = GAP * 2 + 1.9;
|
tip.position.y = GAP * 2 + 1.9;
|
||||||
group.add(post, tip);
|
group.add(post, tip);
|
||||||
group.position.set(x * (FILES / 2 + 0.6), 0, z * (RANKS / 2 + 0.6));
|
group.position.set(x * (FILES / 2 + 0.75), 0, z * (RANKS / 2 + 0.75));
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
posts.push({ group, stone });
|
posts.push({ group, stone });
|
||||||
}
|
}
|
||||||
@@ -447,6 +495,8 @@
|
|||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
marks.clear();
|
marks.clear();
|
||||||
|
// A figure held by a Basilisk stands in a pale ring, since its dimmed livery is easy to miss among forty.
|
||||||
|
for (let i = 0; i < g.cells.length; i++) if (g.cells[i] !== '' && frozen(g.cells, i) && i !== selected) mark(i, 'ring', MARK.frozen, 0.8);
|
||||||
for (const i of reach) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.reach, 0.85);
|
for (const i of reach) mark(i, g.cells[i] === '' ? 'dot' : 'ring', MARK.reach, 0.85);
|
||||||
for (const i of shownSquares) mark(i, 'halo', MARK.last);
|
for (const i of shownSquares) mark(i, 'halo', MARK.last);
|
||||||
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
|
for (const i of shownCaptured) mark(i, 'halo', MARK.fallen);
|
||||||
@@ -469,6 +519,7 @@
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
|
kept = { view, sheet, side: mySide, position: camera.position.toArray(), target: controls.target.toArray(), zoom: above.zoom };
|
||||||
|
repaint = null;
|
||||||
removeEventListener('resize', resize);
|
removeEventListener('resize', resize);
|
||||||
watcher.disconnect();
|
watcher.disconnect();
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
|
|||||||
@@ -24,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]))
|
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);
|
let prefsOpen = $state(false);
|
||||||
|
/** The side and the Sylphs' setup wait behind one line until asked for. */
|
||||||
|
let setupOpen = $state(false);
|
||||||
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
/** The opening as the host's choices lay it, drawn beside the buttons. */
|
||||||
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
const opening = $derived(createGame({ A: 'Gold', B: 'Scarlet' }, 0, undefined, options));
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -218,7 +220,9 @@
|
|||||||
</label>
|
</label>
|
||||||
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
|
{#if !ready}<p class="option-note muted small">A name first, and the buttons wake. The drake is the computer, playing for the house.</p>{/if}
|
||||||
{#if game.options?.length}
|
{#if game.options?.length}
|
||||||
<div class="options">
|
{@const chosenLabels = game.options.map((o) => `${o.label} ${o.choices.find((c) => c.value === options[o.key])?.label.replace(/^(?!Gold|Scarlet)./, (ch) => ch.toLowerCase()) ?? ''}.`)}
|
||||||
|
<p class="setup muted small">{chosenLabels.join(' ')} <button type="button" class="link plain" aria-expanded={setupOpen} onclick={() => (setupOpen = !setupOpen)}>{setupOpen ? 'Done' : 'Change'}</button></p>
|
||||||
|
<div class="options" hidden={!setupOpen}>
|
||||||
{#each game.options as o (o.key)}
|
{#each game.options as o (o.key)}
|
||||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||||
<div>
|
<div>
|
||||||
@@ -660,6 +664,14 @@
|
|||||||
margin: 0.2rem 0 0.6rem;
|
margin: 0.2rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.options[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup {
|
||||||
|
margin: 0.2rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface Telling {
|
|||||||
ply: number;
|
ply: number;
|
||||||
move: Move;
|
move: Move;
|
||||||
lines: Line[];
|
lines: Line[];
|
||||||
|
/** The boards as they stood once the move was made. */
|
||||||
|
after: Cell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
|
const WORLD: Record<number, string> = { 3: 'the air', 2: 'the land', 1: 'the underworld' };
|
||||||
@@ -127,7 +129,7 @@ export function narrate(state: State, start?: Cell[]): Telling[] {
|
|||||||
state.moves.forEach((move, n) => {
|
state.moves.forEach((move, n) => {
|
||||||
const { lines, after } = tellOne(cells, move, owner);
|
const { lines, after } = tellOne(cells, move, owner);
|
||||||
cells = after;
|
cells = after;
|
||||||
out.push({ ply: n + 1, move, lines });
|
out.push({ ply: n + 1, move, lines, after });
|
||||||
});
|
});
|
||||||
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
|
if (state.over && out.length) out[out.length - 1].lines.push({ kind: 'end', text: state.over.reason });
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
Reference in new issue
Block a user