Turning the ledger toward a wizard summed a sticky cell's offset, which moves with the scroll; it now uses viewport rectangles and the width the pinned columns cover, so repeated and switched taps land the same. The masthead's actions wrap instead of running off a phone. The phone bar names you, the wizard in focus and the count, with the roster a tap away. The hands row's first cell is pinned with the rest of the turn column. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
399 lines
11 KiB
Svelte
399 lines
11 KiB
Svelte
<script lang="ts">
|
|
import type { Duel } from '$lib/game/duel.svelte';
|
|
import { seatColor } from '$lib/game/colors';
|
|
import { glyph } from '$lib/game/glyphs';
|
|
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
|
|
import { hiddenFromViewer, type HistoryEntry, type 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; the gallery sees the table as laid. */
|
|
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [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: hiddenFromViewer(raw[index].hiddenFrom, 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;
|
|
});
|
|
|
|
const crowded = $derived(duel.others.length >= 3);
|
|
|
|
// The focused wizard's columns slide in just past the pinned ones. Sticky cells
|
|
// report where they are stuck, not where they lie, so the sums are done in
|
|
// viewport coordinates: the header's distance from the scroller's edge plus
|
|
// the current scroll, less the width the pinned columns always cover.
|
|
$effect(() => {
|
|
const who = duel.focus;
|
|
void duel.focusTick;
|
|
if (!who || !scroller) return;
|
|
const th = scroller.querySelector<HTMLElement>(`th[data-who="${who}"]`);
|
|
if (!th) return;
|
|
const pinnedWidth = ['th.turn', 'thead tr.hands th.pin-left', 'thead tr.hands th.pin-right']
|
|
.map((sel) => scroller!.querySelector<HTMLElement>(sel)?.getBoundingClientRect().width ?? 0)
|
|
.reduce((a, b) => a + b, 0);
|
|
const left = th.getBoundingClientRect().left - scroller.getBoundingClientRect().left + scroller.scrollLeft - pinnedWidth;
|
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
scroller.scrollTo({ left: Math.max(0, left), behavior: reduce ? 'auto' : 'smooth' });
|
|
});
|
|
</script>
|
|
|
|
{#if crowded}
|
|
<nav class="seats" aria-label="Wizards in the ledger">
|
|
<span class="muted">Turn the ledger toward</span>
|
|
{#each duel.others as who (who)}
|
|
{@const w = duel.state.wizards[who]}
|
|
<button type="button" class="seat" class:focused={duel.focus === who} class:gone={!w.alive || w.surrendered} onclick={() => duel.focusOn(who)}>
|
|
<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{w.name}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
{/if}
|
|
<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 data-who={who} class:you={who === duel.me} class:pin-left={who === duel.me} class:foe={who !== duel.me} class:focused={duel.focus === who} class:gone={!duel.state.wizards[who].alive || duel.state.wizards[who].surrendered} colspan="2" scope="colgroup">{#if crowded && who !== duel.me}<span class="dot" style="background: {seatColor(duel.state, who)}"></span>{/if}{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
|
{/each}
|
|
</tr>
|
|
<tr class="hands">
|
|
<th class="turn"></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 && duel.spectating}
|
|
<tr class="draft">
|
|
<td class="turn">{duel.turnNumber}</td>
|
|
{#each columns as who (who)}
|
|
<td class="cell foe"><span class="letter placeholder">?</span></td>
|
|
<td class="cell foe"><span class="letter placeholder">?</span></td>
|
|
{/each}
|
|
</tr>
|
|
{:else 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>
|
|
.seats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.3rem 0.4rem;
|
|
padding: 0.2rem 0 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.seat {
|
|
background: none;
|
|
border: 1px solid var(--rule);
|
|
border-radius: 999px;
|
|
padding: 0.1rem 0.6rem 0.1rem 0.45rem;
|
|
color: var(--bone-dim);
|
|
font-style: italic;
|
|
}
|
|
|
|
.seat:hover {
|
|
border-color: var(--bone-dim);
|
|
color: var(--bone);
|
|
}
|
|
|
|
.seat.focused {
|
|
border-color: var(--frost);
|
|
color: var(--bone);
|
|
}
|
|
|
|
.seat.gone {
|
|
text-decoration: line-through;
|
|
}
|
|
|
|
.dot {
|
|
display: inline-block;
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 50%;
|
|
margin-right: 0.3rem;
|
|
}
|
|
|
|
thead th.focused {
|
|
color: var(--frost);
|
|
}
|
|
|
|
.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>
|