Duels between people: rooms, the hall, and a games ledger
The store plays any seat, locally against the bot or remotely through the server. A room has a four-letter code that is also its invite link; the hall opens or joins one and lists the seats this browser holds with whose move it is. The ledger and summary show every seat, threats name their wizard, and the board is one component shared by the hall and the room page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8366c9d7ff
commit
e1bda87987
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import Chronicle from './Chronicle.svelte';
|
||||
import HandPicker from './HandPicker.svelte';
|
||||
import Ledger from './Ledger.svelte';
|
||||
import MobileBar from './MobileBar.svelte';
|
||||
import SpellSheet from './SpellSheet.svelte';
|
||||
import TurnSummary from './TurnSummary.svelte';
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import { castKey, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
|
||||
const outcome = $derived(duel.state.over);
|
||||
const won = $derived(outcome?.winner === duel.me);
|
||||
const charmedName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].name : '');
|
||||
const charmedHandName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].constraints.charmed?.hand : '');
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
<section class="play">
|
||||
<Ledger {duel} />
|
||||
<TurnSummary {duel} />
|
||||
|
||||
{#if outcome}
|
||||
<div class="verdict" id="verdict" class:won>
|
||||
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
|
||||
<p>{outcome.reason}</p>
|
||||
{#if duel.remote}
|
||||
<a class="reveal again" href="/">Back to the hall</a>
|
||||
{:else}
|
||||
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<section class="move" aria-labelledby="move-heading">
|
||||
{#if duel.timeStopped}
|
||||
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
|
||||
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
|
||||
{:else}
|
||||
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
|
||||
{/if}
|
||||
|
||||
{#if duel.threats.length && !duel.timeStopped}
|
||||
<div class="threats">
|
||||
{#if duel.threatsNow.length}
|
||||
<p>Their hands could finish this turn:</p>
|
||||
<ul>
|
||||
{#each duel.threatsNow as t (t.hand + t.spell.id)}
|
||||
{@const active = duel.isHighlighted(t)}
|
||||
<li>
|
||||
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if duel.threatsNext.length}
|
||||
<details class="next" open={!duel.compact}>
|
||||
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
|
||||
<ul>
|
||||
{#each duel.threatsNext as t (t.hand + t.spell.id)}
|
||||
{@const active = duel.isHighlighted(t)}
|
||||
<li>
|
||||
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
|
||||
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !duel.timeStopped}
|
||||
<p class="threats muted">Nothing hostile is one gesture away.</p>
|
||||
{/if}
|
||||
|
||||
<div class="hands">
|
||||
<HandPicker {duel} hand="left" />
|
||||
<HandPicker {duel} hand="right" />
|
||||
</div>
|
||||
<div class="prefs">
|
||||
<label class="pref">
|
||||
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
|
||||
Show spells under way beneath each hand
|
||||
</label>
|
||||
<label class="pref">
|
||||
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
|
||||
Explain rules as they come up
|
||||
</label>
|
||||
</div>
|
||||
{#if duel.hasted}
|
||||
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
|
||||
<div class="hands">
|
||||
<HandPicker {duel} hand="left" set="haste" />
|
||||
<HandPicker {duel} hand="right" set="haste" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="orders">
|
||||
{#if duel.stabbing}
|
||||
<label class="field">
|
||||
<span>Stab</span>
|
||||
<select bind:value={duel.stabTarget}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as m (m.id)}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#each duel.yourMonsters as m (m.id)}
|
||||
<label class="field">
|
||||
<span>{m.name} attacks</span>
|
||||
<select bind:value={duel.monsterOrders[m.id]}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as fm (fm.id)}
|
||||
<option value={fm.id}>{fm.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/each}
|
||||
{#if duel.bankedSpell}
|
||||
<label class="field">
|
||||
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
|
||||
<select bind:value={duel.release}>
|
||||
<option value="">not yet</option>
|
||||
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
|
||||
<option value={t.id}>at {t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.bankCandidates.length > 1}
|
||||
<label class="field">
|
||||
<span>Bank</span>
|
||||
<select bind:value={duel.bankPick}>
|
||||
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
|
||||
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.permanentCandidates.length > 1}
|
||||
<label class="field">
|
||||
<span>Make permanent</span>
|
||||
<select bind:value={duel.permanentPick}>
|
||||
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
|
||||
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if duel.youCharmedFoe}
|
||||
<label class="field">
|
||||
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
|
||||
<select bind:value={duel.charmGesture}>
|
||||
{#each MAGIC_GESTURES as g (g)}
|
||||
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if duel.remote?.error}
|
||||
<p class="warning">{duel.remote.error}</p>
|
||||
{/if}
|
||||
{#if duel.moved}
|
||||
<p class="waiting">Your move is in. Waiting for {duel.awaiting.join(' and ')}.</p>
|
||||
{/if}
|
||||
{#if duel.surrendering}
|
||||
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
|
||||
{/if}
|
||||
{#if duel.conflict}
|
||||
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
{#each duel.others as id (id)}
|
||||
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
{/each}
|
||||
<SpellSheet {duel} />
|
||||
<Chronicle log={duel.state.log} />
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<MobileBar {duel} />
|
||||
|
||||
<style>
|
||||
details.next summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
details.next summary::before {
|
||||
content: '\25B8 ';
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
details.next[open] summary::before {
|
||||
content: '\25BE ';
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||
padding: 0 var(--gutter);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.play {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.move {
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.move h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.threats {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.threats ul {
|
||||
list-style: none;
|
||||
margin: 0.15rem 0 0.4rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.threat {
|
||||
background: var(--slate);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.threat.soon {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.threat:hover,
|
||||
.threat.active {
|
||||
border-color: var(--frost);
|
||||
}
|
||||
|
||||
.threat.active {
|
||||
background: rgba(140, 200, 224, 0.12);
|
||||
}
|
||||
|
||||
.thand {
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tseq {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tseq strong {
|
||||
font-weight: 600;
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.prefs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.pref {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.hands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.orders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.haste-note {
|
||||
color: var(--frost);
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.move > .reveal {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.verdict {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.verdict h2 {
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.verdict.won h2 {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.verdict p {
|
||||
margin: 0.3rem 0 0.8rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
/* Must match COMPACT_QUERY in duel.svelte.ts. */
|
||||
@media (max-width: 860px) {
|
||||
.board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* The sticky bar carries the reveal button on phones. */
|
||||
.move > .reveal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
order: 2;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hands {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.waiting {
|
||||
color: var(--frost);
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.reveal.again {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { allSeats, api, forgetSeat } from '$lib/game/client';
|
||||
import { Duel } from '$lib/game/duel.svelte';
|
||||
import type { RoomView } from '$lib/game/view';
|
||||
|
||||
let { name }: { name: string } = $props();
|
||||
|
||||
let code = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let seats = $state(allSeats());
|
||||
let games = $state<Record<string, RoomView | null>>({});
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it is.
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
games[held.roomId] = null;
|
||||
api.view(held.roomId, held.token)
|
||||
.then((v) => {
|
||||
games[held.roomId] = v;
|
||||
})
|
||||
.catch(() => {
|
||||
games[held.roomId] = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function create() {
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const remote = await Duel.createRoom(name, 2);
|
||||
await goto(`/join/${remote.roomId}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function join(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const c = code.trim().toUpperCase();
|
||||
if (c) void goto(`/join/${c}`);
|
||||
}
|
||||
|
||||
function forget(roomId: string) {
|
||||
forgetSeat(roomId);
|
||||
seats = allSeats();
|
||||
}
|
||||
|
||||
function status(held: { roomId: string; seat: string }): string {
|
||||
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.name);
|
||||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||||
if (g.state.over) {
|
||||
if (g.state.over.winner === held.seat) return 'you won';
|
||||
if (g.state.over.winner === null) return 'a draw';
|
||||
return `${g.state.wizards[g.state.over.winner].name} won`;
|
||||
}
|
||||
const turn = `turn ${g.state.turn + 1}`;
|
||||
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
|
||||
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
|
||||
}
|
||||
|
||||
function yourMove(held: { roomId: string; seat: string }): boolean {
|
||||
const g = games[held.roomId];
|
||||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="hall" id="hall">
|
||||
<h2>Duel a friend</h2>
|
||||
<p class="pitch">A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.</p>
|
||||
<div class="ways">
|
||||
<button type="button" class="reveal small" disabled={busy} onclick={create}>Create a duel as {name}</button>
|
||||
<form class="joinform" onsubmit={join}>
|
||||
<span class="or">or join one</span>
|
||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||
<button type="submit" class="quiet" disabled={code.trim().length < 4}>Join</button>
|
||||
</form>
|
||||
</div>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
|
||||
{#if seats.length}
|
||||
<div class="ledger">
|
||||
<div class="ledger-head">your duels</div>
|
||||
{#each seats as held (held.roomId)}
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}</span>
|
||||
</a>
|
||||
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<details class="about">
|
||||
<summary>about this game — a labor of love</summary>
|
||||
<div class="about-body">
|
||||
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
|
||||
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
|
||||
<p>It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hall {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--gutter) 0.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.pitch {
|
||||
max-width: 38em;
|
||||
color: var(--bone-dim);
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem 1.25rem;
|
||||
}
|
||||
|
||||
.reveal.small {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.joinform {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.or {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code {
|
||||
width: 6.5em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
background: none;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.quiet:hover:not(:disabled) {
|
||||
border-color: var(--bone);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* the games ledger */
|
||||
.ledger {
|
||||
margin-top: 1.4rem;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
padding-top: 0.5rem;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
.ledger-head {
|
||||
font-style: italic;
|
||||
color: var(--bone-dim);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.ledger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
|
||||
.ledger-row.your-move {
|
||||
background: rgba(240, 165, 58, 0.12);
|
||||
}
|
||||
|
||||
.ledger-resume {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.7rem;
|
||||
padding: 0.3rem 0.2rem;
|
||||
color: var(--bone);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ledger-resume:hover .ledger-code {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ledger-code {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.ledger-info {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.ledger-row.your-move .ledger-info {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.ledger-forget {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.05rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.ledger-forget:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.about {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.about summary {
|
||||
cursor: pointer;
|
||||
font-style: italic;
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.about-body {
|
||||
max-width: 40em;
|
||||
color: var(--bone-dim);
|
||||
padding: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.about-body p + p {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.about-body em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.about-body a {
|
||||
color: var(--frost);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { glyph } from '$lib/game/glyphs';
|
||||
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
|
||||
import type { HistoryEntry, WizardId } from '$lib/game/state';
|
||||
@@ -21,13 +21,16 @@
|
||||
cells: Record<WizardId, Placed[]>;
|
||||
}
|
||||
|
||||
/** You first, then the others in table order. */
|
||||
const columns = $derived<WizardId[]>([duel.me, ...duel.others]);
|
||||
|
||||
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||
const history = who === YOU ? duel.you.history : duel.foeVisible;
|
||||
const history = who === duel.me ? duel.you.history : duel.visible(who);
|
||||
const raw = duel.state.wizards[who].history;
|
||||
const out: Placed[] = [];
|
||||
history.forEach((entry, index) => {
|
||||
if (entry.turn === turn && phases.includes(entry.phase)) {
|
||||
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(YOU) });
|
||||
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
@@ -36,10 +39,14 @@
|
||||
const rows = $derived.by(() => {
|
||||
const out: Row[] = [];
|
||||
for (let t = 0; t < duel.state.turn; t++) {
|
||||
out.push({ key: `t${t}`, label: String(t + 1), cells: { A: placed(YOU, t, ['main', 'haste']), B: placed(FOE, t, ['main', 'haste']) } });
|
||||
for (const who of [YOU, FOE] as WizardId[]) {
|
||||
const cells: Record<WizardId, Placed[]> = {};
|
||||
for (const who of columns) cells[who] = placed(who, t, ['main', 'haste']);
|
||||
out.push({ key: `t${t}`, label: String(t + 1), cells });
|
||||
for (const who of columns) {
|
||||
for (const p of placed(who, t, ['timestop'])) {
|
||||
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: { A: who === YOU ? [p] : [], B: who === FOE ? [p] : [] } });
|
||||
const only: Record<WizardId, Placed[]> = {};
|
||||
for (const c of columns) only[c] = c === who ? [p] : [];
|
||||
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: only });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +54,7 @@
|
||||
});
|
||||
|
||||
const drafting = $derived(!duel.state.over);
|
||||
const turns = $derived(duel.you.history.length + duel.foe.history.length);
|
||||
const turns = $derived(columns.reduce((n, id) => n + duel.state.wizards[id].history.length, 0));
|
||||
|
||||
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
|
||||
return duel.state.wizards[who].casts
|
||||
@@ -55,6 +62,11 @@
|
||||
.map((c) => SPELL_BY_ID[c.spellId].name);
|
||||
}
|
||||
|
||||
function lit(who: WizardId, hand: Hand, index: number): boolean {
|
||||
const h = duel.highlight;
|
||||
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void turns;
|
||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||
@@ -66,29 +78,29 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
|
||||
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
|
||||
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
|
||||
{#each columns as who (who)}
|
||||
<th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr class="hands">
|
||||
<th></th>
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
{#each columns as who (who)}
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.key)}
|
||||
<tr class:stopped={!!row.note}>
|
||||
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
|
||||
{#each [YOU, FOE] as const as who (who)}
|
||||
{#each columns as who (who)}
|
||||
{#each ['left', 'right'] as const as hand (hand)}
|
||||
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
|
||||
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
|
||||
{#each row.cells[who] as p (p.index)}
|
||||
{@const lit = who === FOE && duel.highlight?.hand === hand && p.index >= duel.highlight.from && p.index <= duel.highlight.to}
|
||||
{@const notes = notesFor(who, p.index, hand)}
|
||||
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
|
||||
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit>
|
||||
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit={lit(who, hand, p.index)}>
|
||||
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
|
||||
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
|
||||
</span>
|
||||
@@ -113,8 +125,10 @@
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
|
||||
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
|
||||
{#each duel.others as who (who)}
|
||||
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
|
||||
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
@@ -193,10 +207,14 @@
|
||||
background: rgba(140, 200, 224, 0.06);
|
||||
}
|
||||
|
||||
td.cell.you:nth-child(3) {
|
||||
td.cell:nth-child(odd) {
|
||||
border-right: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
td.cell:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<section class="result" id="turn-result" aria-label="What the last reveal did">
|
||||
<h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
|
||||
<dl>
|
||||
{#each [YOU, FOE] as const as who (who)}
|
||||
{#each duel.state.seats as who (who)}
|
||||
{@const w = summary.wizards[who]}
|
||||
<div>
|
||||
<dt>{who === YOU ? 'You' : duel.foe.name}</dt>
|
||||
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
|
||||
<dd>
|
||||
{#if w.effects.length === 0}
|
||||
<span class="muted">untouched</span>
|
||||
|
||||
Reference in New Issue
Block a user