A crowded table: keyed threats, a compact roster, a ledger selector, host-only starts
Threats are keyed by seat as well as hand and spell, so two opponents building the same spell no longer crash the board (regression test). Past three wizards the rail lists the others on a line each with health, condition and whether they have moved; a row of names turns the ledger toward any wizard, as do pressing a warning or choosing a target; each seat keeps one colour across the ledger, the warnings and the rail. The result strip folds untouched wizards into one line. A full table no longer begins by itself: the host begins at every size and may send a bot away. Target lists carry health and condition, and an intent sentence says what the reveal will do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c24fccaa35
commit
5c79a65319
@@ -3,11 +3,13 @@
|
||||
import HandPicker from './HandPicker.svelte';
|
||||
import Ledger from './Ledger.svelte';
|
||||
import MobileBar from './MobileBar.svelte';
|
||||
import Roster from './Roster.svelte';
|
||||
import SpellSheet from './SpellSheet.svelte';
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import TurnSummary from './TurnSummary.svelte';
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { MAGIC_GESTURES } from '$lib/game/spells';
|
||||
|
||||
@@ -16,6 +18,8 @@
|
||||
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);
|
||||
/** Past three wizards the rail lists the others on a line each. */
|
||||
const crowded = $derived(duel.others.length >= 3);
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
@@ -58,11 +62,11 @@
|
||||
{#if duel.threatsNow.length}
|
||||
<p>Their hands could finish this turn:</p>
|
||||
<ul>
|
||||
{#each duel.threatsNow as t (t.hand + t.spell.id)}
|
||||
{#each duel.threatsNow as t (duel.threatKey(t))}
|
||||
{@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="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
@@ -74,11 +78,11 @@
|
||||
<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)}
|
||||
{#each duel.threatsNext as t (duel.threatKey(t))}
|
||||
{@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="thand">{#if duel.others.length > 1}<span class="dot" style="background: {seatColor(duel.state, t.who)}"></span>{`${duel.state.wizards[t.who].name}, `}{/if}{t.hand}</span>
|
||||
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
|
||||
<span class="tname">{t.spell.name}</span>
|
||||
</button>
|
||||
@@ -118,9 +122,9 @@
|
||||
{#if duel.stabbing}
|
||||
<label class="field">
|
||||
<span>Stab</span>
|
||||
<select bind:value={duel.stabTarget}>
|
||||
<select bind:value={duel.stabTarget} onchange={() => duel.focusOn(duel.stabTarget)}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
<option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as m (m.id)}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
@@ -131,9 +135,9 @@
|
||||
{#each duel.yourMonsters as m (m.id)}
|
||||
<label class="field">
|
||||
<span>{m.name} attacks</span>
|
||||
<select bind:value={duel.monsterOrders[m.id]}>
|
||||
<select bind:value={duel.monsterOrders[m.id]} onchange={() => duel.focusOn(duel.monsterOrders[m.id])}>
|
||||
{#each duel.others as id (id)}
|
||||
<option value={id}>{duel.state.wizards[id].name}</option>
|
||||
<option value={id}>{crowded ? duel.targetLabel(id) : duel.state.wizards[id].name}</option>
|
||||
{/each}
|
||||
{#each foeMonsters as fm (fm.id)}
|
||||
<option value={fm.id}>{fm.name}</option>
|
||||
@@ -187,6 +191,9 @@
|
||||
{#if duel.remote?.error}
|
||||
<p class="warning">{duel.remote.error}</p>
|
||||
{/if}
|
||||
{#if duel.intentText && !duel.moved}
|
||||
<p class="intent">{duel.intentText}</p>
|
||||
{/if}
|
||||
{#if duel.moved}
|
||||
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
|
||||
{/if}
|
||||
@@ -209,9 +216,13 @@
|
||||
{#if !duel.spectating}
|
||||
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||
{/if}
|
||||
{#each duel.others as id (id)}
|
||||
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
{/each}
|
||||
{#if crowded}
|
||||
<Roster {duel} />
|
||||
{:else}
|
||||
{#each duel.others as id (id)}
|
||||
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
{/each}
|
||||
{/if}
|
||||
<TableTalk {duel} />
|
||||
<SpellSheet {duel} />
|
||||
<Chronicle log={duel.state.log} />
|
||||
@@ -252,6 +263,21 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.intent {
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
font-style: italic;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.move {
|
||||
padding: 0.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
{#if spell}
|
||||
<label class="field">
|
||||
<span>at</span>
|
||||
<select bind:value={draft.target}>
|
||||
<select bind:value={draft.target} onchange={() => duel.focusOn(draft.target)}>
|
||||
{#each duel.targetsFor(spell) as t (t.id)}
|
||||
<option value={t.id}>{t.label}</option>
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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';
|
||||
@@ -71,15 +72,41 @@
|
||||
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.
|
||||
$effect(() => {
|
||||
const who = duel.focus;
|
||||
void duel.focusTick;
|
||||
if (!who || !scroller) return;
|
||||
const th = scroller.querySelector<HTMLElement>(`th[data-who="${who}"]`);
|
||||
const pinned = scroller.querySelector<HTMLElement>('thead th.pin-right');
|
||||
if (!th) return;
|
||||
const left = pinned ? pinned.offsetLeft + pinned.offsetWidth : 0;
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
scroller.scrollTo({ left: th.offsetLeft - 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 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} (you){/if}</th>
|
||||
<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">
|
||||
@@ -144,6 +171,50 @@
|
||||
</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;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
// A crowded table's rail: every other wizard on one line, with the one
|
||||
// you are looking at opened out. Your own panel stays in full above.
|
||||
import WizardStatus from './WizardStatus.svelte';
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { seatColor } from '$lib/game/colors';
|
||||
import { MAX_HP, type WizardId } from '$lib/game/state';
|
||||
import { statusWords } from '$lib/game/status';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const waitingOn = $derived(duel.remote?.waitingOn ?? []);
|
||||
|
||||
function toggle(id: WizardId) {
|
||||
if (duel.focus === id) duel.focus = null;
|
||||
else duel.focusOn(id);
|
||||
}
|
||||
|
||||
function stateOf(id: WizardId): string {
|
||||
if (!duel.remote?.started || duel.state.over) return '';
|
||||
const w = duel.state.wizards[id];
|
||||
if (!w.alive || w.surrendered) return 'out';
|
||||
const bot = duel.remote.seats.find((s) => s.id === id)?.bot;
|
||||
if (bot) return '';
|
||||
return waitingOn.includes(id) ? 'writing' : 'moved';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="roster" aria-label="The other wizards">
|
||||
<h2>At the table</h2>
|
||||
<ul>
|
||||
{#each duel.others as id (id)}
|
||||
{@const w = duel.state.wizards[id]}
|
||||
{@const open = duel.focus === id}
|
||||
{@const words = statusWords(w)}
|
||||
<li class:open class:gone={!w.alive || w.surrendered}>
|
||||
<button type="button" class="row" aria-expanded={open} onclick={() => toggle(id)}>
|
||||
<span class="dot" style="background: {seatColor(duel.state, id)}"></span>
|
||||
<span class="name">{w.name}</span>
|
||||
<span class="hp">{w.hp}/{MAX_HP}</span>
|
||||
<span class="words">{words.join(', ')}</span>
|
||||
<span class="turn muted">{stateOf(id)}</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="detail">
|
||||
<WizardStatus wizard={w} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.roster {
|
||||
padding: 0.75rem 0 0.4rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
li.gone .name {
|
||||
text-decoration: line-through;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1.4fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.2rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone);
|
||||
text-align: left;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
li.open .row {
|
||||
background: var(--slate);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hp {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.words {
|
||||
color: var(--frost);
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turn {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 0 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.detail :global(.wizard) {
|
||||
border-bottom: 0;
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
.detail :global(h2) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Duel } from '$lib/game/duel.svelte';
|
||||
import { listText, type Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const summary = $derived(duel.summary);
|
||||
const touched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length > 0) : []);
|
||||
const untouched = $derived(summary ? duel.state.seats.filter((who) => summary.wizards[who].effects.length === 0) : []);
|
||||
const nameOf = (who: string) => (who === duel.me ? 'You' : duel.state.wizards[who].name);
|
||||
|
||||
function signed(n: number): string {
|
||||
return n > 0 ? `+${n}` : String(n);
|
||||
@@ -14,22 +17,24 @@
|
||||
<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 duel.state.seats as who (who)}
|
||||
{#each touched as who (who)}
|
||||
{@const w = summary.wizards[who]}
|
||||
<div>
|
||||
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
|
||||
<dt>{nameOf(who)}</dt>
|
||||
<dd>
|
||||
{#if w.effects.length === 0}
|
||||
<span class="muted">untouched</span>
|
||||
{:else}
|
||||
{#each w.effects as e, i (i)}
|
||||
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
{#each w.effects as e, i (i)}
|
||||
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
|
||||
{/each}
|
||||
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
{#if untouched.length}
|
||||
<div>
|
||||
<dt class="muted">{touched.length === 0 ? 'Nobody' : untouched.length > 2 ? `${untouched.length} others` : listText(untouched.map(nameOf))}</dt>
|
||||
<dd><span class="muted">{touched.length === 0 ? 'No health changes.' : 'untouched'}</span></dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if summary.events.length}
|
||||
<ul class="events">
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { SPELL_BY_ID } from '$lib/game/spells';
|
||||
import { effectsOf } from '$lib/game/status';
|
||||
import { MAX_HP, type Monster, type WizardState } from '$lib/game/state';
|
||||
|
||||
let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props();
|
||||
|
||||
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
|
||||
|
||||
const effects = $derived.by(() => {
|
||||
const out: string[] = [];
|
||||
if (wizard.resistHeat) out.push('resists heat');
|
||||
if (wizard.resistCold) out.push('resists cold');
|
||||
if (wizard.protectionTurns > 0) out.push(`protected from evil, ${more(wizard.protectionTurns)}`);
|
||||
if (wizard.blindTurns > 0) out.push(`blind, ${more(wizard.blindTurns)}`);
|
||||
if (wizard.invisibleTurns > 0) out.push(`invisible, ${more(wizard.invisibleTurns)}`);
|
||||
if (wizard.hasteTurns > 0) out.push(`hastened, ${more(wizard.hasteTurns)}`);
|
||||
if (wizard.banked) out.push(`holding a banked ${SPELL_BY_ID[wizard.banked.spellId].name.toLowerCase()}`);
|
||||
else if (wizard.delayedTurns > 0) out.push(`next spell will be banked, ${more(wizard.delayedTurns)}`);
|
||||
if (wizard.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(wizard.permanencyTurns)}`);
|
||||
if (wizard.diseaseTurns !== null) out.push(`diseased, dies in ${wizard.diseaseTurns}`);
|
||||
if (wizard.poisonTurns !== null) out.push(`poisoned, dies in ${wizard.poisonTurns}`);
|
||||
const c = wizard.constraints;
|
||||
if (c.amnesia) out.push(wizard.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
|
||||
if (c.confusion) out.push(wizard.permanent.confusion ? 'confused for good' : 'confused this turn');
|
||||
if (c.fear) out.push(wizard.permanent.fear ? 'afraid for good' : 'afraid this turn');
|
||||
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${wizard.permanent.paralysedHand ? ' for good' : ''}`);
|
||||
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${wizard.permanent.charmed ? ' for good' : ''}`);
|
||||
if (wizard.usedQuickLightning) out.push('quick lightning spent');
|
||||
return out;
|
||||
});
|
||||
const effects = $derived(effectsOf(wizard));
|
||||
</script>
|
||||
|
||||
<section class="wizard" class:you class:dead={!wizard.alive}>
|
||||
|
||||
Reference in New Issue
Block a user