Files
waving-hands/src/lib/components/Ledger.svelte
T
Eric WagonerandClaude Fable 5.1 58d7db106e A table for a dozen wizards, begun by its host
Rooms take up to twelve seats and the host begins the duel with whoever
is seated; a full table begins by itself. The lobby is a roster with a
bot seat and the host's begin button. The ledger pins the turn and your
own columns and scrolls sideways to the rest; the phone bar shows every
wizard's health; a wizard who is out watches the rest; each charmed
wizard gets their own gesture; an unnamed spell goes at whoever hurt you
last, else the weakest opponent; a stab has a target from the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-09-23 09:34:21 -04:00

315 lines
8.0 KiB
Svelte

<script lang="ts">
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';
let { duel }: { duel: Duel } = $props();
let scroller: HTMLDivElement | undefined = $state();
interface Placed {
entry: HistoryEntry;
index: number;
hidden: boolean;
}
interface Row {
key: string;
label: string;
note?: string;
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 === 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(duel.me) });
}
});
return out;
}
const rows = $derived.by(() => {
const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) {
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'])) {
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 });
}
}
}
return out;
});
const drafting = $derived(!duel.state.over);
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
.filter((c) => c.index === index && c.hand === hand)
.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;
});
</script>
<div class="scroller" bind:this={scroller}>
<table class="ledger">
<thead>
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
{#each columns as who (who)}
<th class:you={who === duel.me} class:foe={who !== duel.me} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
{/each}
</tr>
<tr class="hands">
<th></th>
{#each columns as who (who)}
<th scope="col" class:pin-left={who === duel.me}>left</th>
<th scope="col" class:pin-right={who === duel.me}>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 columns as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === duel.me} class:foe={who !== duel.me} class:pin-left={who === duel.me && hand === 'left'} class:pin-right={who === duel.me && hand === 'right'}>
{#each row.cells[who] as p (p.index)}
{@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={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>
{/each}
</td>
{/each}
{/each}
</tr>
{/each}
{#if drafting}
<tr class="draft" class:stopped={duel.timeStopped}>
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell you" class:pin-left={hand === 'left'} class:pin-right={hand === 'right'}>
{#each duel.activeSets as set (set)}
{@const undecided = duel.chosenGesture(set, hand) === null || duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
<span class="letter" class:placeholder={undecided}>{duel.drawn(set, hand)}</span>
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
</span>
{/each}
</td>
{/each}
{#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>
</table>
</div>
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
/* With many wizards the table grows sideways; the turn and your own two columns stay pinned on the left. */
.ledger {
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
.ledger th,
.ledger td {
min-width: 4.5rem;
}
th.pin-left,
td.pin-left,
th.pin-right,
td.pin-right {
position: sticky;
z-index: 1;
background: var(--slate-deep);
}
th.pin-left,
td.pin-left {
left: 2.9rem;
}
th.pin-right,
td.pin-right {
left: 7.4rem;
}
thead th.pin-left,
thead th.pin-right {
z-index: 3;
}
thead th.gone {
text-decoration: line-through;
color: var(--bone-dim);
}
thead th {
position: sticky;
top: 0;
background: var(--slate-deep);
font-weight: 500;
text-align: center;
padding: 0.5rem 0 0.15rem;
font-size: 1.05rem;
z-index: 1;
}
thead th.you,
thead th.foe {
font-style: italic;
font-size: 1.15rem;
}
thead tr.hands th {
top: 2.2rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--bone-dim);
padding: 0 0 0.35rem;
border-bottom: 1px solid var(--rule-strong);
}
th.turn,
td.turn {
position: sticky;
left: 0;
z-index: 2;
background: var(--slate-deep);
width: 2.9rem;
min-width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
vertical-align: top;
padding-top: 0.6rem;
}
.rownote {
display: block;
font-size: 0.6rem;
font-style: italic;
line-height: 1.1;
color: var(--frost);
}
td.cell {
text-align: center;
vertical-align: top;
padding: 0.35rem 0.2rem 0.3rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
tr.stopped td.cell {
background: rgba(140, 200, 224, 0.06);
}
td.cell:nth-child(odd) {
border-right: 1px solid var(--rule-strong);
}
td.cell:last-child {
border-right: 0;
}
.entry {
display: block;
}
.entry.haste .letter {
font-size: clamp(1.1rem, 2.4vw, 1.4rem);
color: var(--frost);
}
.letter {
display: block;
font-size: clamp(1.6rem, 3.5vw, 2.1rem);
color: var(--bone);
}
.entry.cast .letter {
color: var(--ember);
}
.entry.hidden .letter {
color: var(--bone-faint);
}
.entry.lit {
background: rgba(140, 200, 224, 0.18);
outline: 1px solid var(--frost);
border-radius: 3px;
}
.entry.lit .letter {
color: var(--frost);
}
.placeholder {
color: var(--bone-faint);
}
.note {
display: block;
font-size: 0.72rem;
font-style: italic;
color: var(--ember);
line-height: 1.15;
margin-top: 0.15rem;
}
tr.draft td.cell {
background: var(--slate);
border-bottom: 2px solid var(--ember);
}
tr.draft td.turn {
background: var(--slate);
}
</style>