Waving Hands: a browser duel after Bartle's 1977 game
SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { LogEntry } from '$lib/game/state';
|
||||
|
||||
let { log }: { log: LogEntry[] } = $props();
|
||||
|
||||
const turns = $derived.by(() => {
|
||||
const groups = new Map<number, LogEntry[]>();
|
||||
for (const entry of log) {
|
||||
if (!groups.has(entry.turn)) groups.set(entry.turn, []);
|
||||
groups.get(entry.turn)!.push(entry);
|
||||
}
|
||||
return [...groups.entries()].reverse();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="chronicle">
|
||||
<h2>Chronicle</h2>
|
||||
{#if turns.length === 0}
|
||||
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and reveal them.</p>
|
||||
{/if}
|
||||
{#each turns as [turn, entries] (turn)}
|
||||
<article>
|
||||
<h3>Turn {turn + 1}</h3>
|
||||
<ul>
|
||||
{#each entries as e, i (i)}
|
||||
<li class={e.kind}>{e.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chronicle {
|
||||
padding: 0.75rem 0;
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
margin: 0.6rem 0 0.2rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
li.gesture {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
li.spell {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
li.damage {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
li.death {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
li.fizzle {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { Gesture, Hand } from '$lib/game/spells';
|
||||
|
||||
let { gesture, hand = 'left' }: { gesture: Gesture; hand?: Hand } = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
|
||||
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
|
||||
The bitmap borders were removed and the contours converted to SVG with lightly
|
||||
rounded corners. The original poses and left/right mirroring are retained.
|
||||
|
||||
Original notice (Spellcast README):
|
||||
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
|
||||
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
|
||||
distributed, and modified, as long as this copyright notice is retained.
|
||||
The source code and any derivative works may not be sold for profit without
|
||||
the permission of Andrew Plotkin and Richard Bartle.
|
||||
-->
|
||||
<svg
|
||||
class="icon"
|
||||
viewBox="0 0 48 48"
|
||||
width="36"
|
||||
height="36"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g transform={hand === 'right' ? 'translate(48 0) scale(-1 1)' : undefined}>
|
||||
{#if gesture === 'F'}
|
||||
<path d="M4 46.4L4 45.6Q4 45 4.47 44.63L8.53 41.37Q9 41 9 40.4L9 39.6Q9 39 8.46 38.73L7.6 38.3Q7 38 7 37.33L7 33.3Q7 33 6.7 33L6.3 33Q6 33 6 32.7L6 23.2Q6 22 6.7 21.02L10.65 15.49Q11 15 11.6 15L12.4 15Q13 15 13 15.6L13 17.1Q13 18 12.36 18.64L10.85 20.15Q10 21 10 22.2L10 24.7Q10 25 10.3 25L10.7 25Q11 25 11.09 24.72L12.79 19.64Q13 19 13.6 18.7L14.4 18.3Q15 18 15.16 17.35L15.79 14.82Q16 14 16.6 13.4L17.58 12.42Q18 12 18.6 12L19.4 12Q20 12 20.33 11.5L21.47 9.79Q22 9 22.9 9.3L24.1 9.7Q25 10 24.38 10.72L19.78 16.09Q19 17 18.8 18.18L18.05 22.7Q18 23 17.7 23L17.3 23Q17 23 17 23.3L17 23.7Q17 24 17.3 24L18.4 24Q19 24 19.21 23.44L21.58 17.12Q22 16 22.85 15.15L28.15 9.85Q29 9 30.2 9L32.4 9Q33 9 33 9.6L33 10.4Q33 11 32.58 11.42L26.85 17.15Q26 18 26 19.2L26 22.33Q26 23 26.6 23.3L27.4 23.7Q28 24 28.47 23.53L29.4 22.6Q30 22 30.21 21.18L30.71 19.16Q31 18 31.85 17.15L35.15 13.85Q36 13 37.18 12.8L41.41 12.1Q42 12 42 12.6L42 13.4Q42 14 41.58 14.42L40.42 15.58Q40 16 39.4 16L38.6 16Q38 16 38 16.6L38 17.4Q38 18 37.46 18.27L36.54 18.73Q36 19 36 19.6L36 20.4Q36 21 35.58 21.42L34.6 22.4Q34 23 34 23.85L34 26.7Q34 27 33.7 27L33.3 27Q33 27 33 27.3L33 32.4Q33 33 33.6 33L34.4 33Q35 33 35.42 32.58L38.58 29.42Q39 29 39 28.4L39 27.6Q39 27 39.6 27L41.1 27Q42 27 42 27.9L42 30.8Q42 32 41.22 32.91L36.39 38.54Q36 39 35.4 39L34.6 39Q34 39 33.67 39.5L32.6 41.1Q32 42 31.01 42.43L25.55 44.76Q25 45 25 45.6L25 46.4Q25 47 24.4 47L4.6 47Q4 47 4 46.4Z" />
|
||||
{:else if gesture === 'P'}
|
||||
<path d="M10 35.7L10 21.3Q10 21 10.3 21L10.7 21Q11 21 11 20.7L11 17.67Q11 17 11.6 16.7L12.62 16.19Q13 16 13.3 16.3L13.7 16.7Q14 17 14 17.42L14 25.7Q14 26 14.3 26L14.7 26Q15 26 15.02 25.7L15.95 8.95Q16 8 16.9 7.7L18.43 7.19Q19 7 19 7.6L19 8.7Q19 9 19.3 9L19.7 9Q20 9 20 9.3L20 22.33Q20 23 20.6 22.7L21.4 22.3Q22 22 22 21.33L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 3.7L23 2.6Q23 2 23.6 2L24.58 2Q25 2 25.3 2.3L25.7 2.7Q26 3 26 3.42L26 20.33Q26 21 26.6 21.3L27.4 21.7Q28 22 28 21.33L28 6.3Q28 6 28.3 6L28.7 6Q29 6 29 5.7L29 4.6Q29 4 29.57 4.19L31.1 4.7Q32 5 32.04 5.95L32.95 29.8Q33 31 33.96 30.28L36.04 28.72Q37 28 37.47 26.9L39.76 21.55Q40 21 40.6 21L41.4 21Q42 21 42 21.6L42 27.7Q42 28 41.7 28L41.3 28Q41 28 40.95 28.3L40.2 32.82Q40 34 39.15 34.85L36.85 37.15Q36 38 35.53 39.1L33.24 44.45Q33 45 32.4 45L31.6 45Q31 45 31 45.6L31 46.4Q31 47 30.4 47L15.85 47Q15 47 14.4 46.4L13.6 45.6Q13 45 12.83 44.17L12.06 40.29Q12 40 11.7 40L11.3 40Q11 40 11 39.7L11 36.3Q11 36 10.7 36L10.3 36Q10 36 10 35.7Z" />
|
||||
{:else if gesture === 'S'}
|
||||
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45ZM20.7 2.7L20.3 2.3Q20 2 20.3 2.3L20.7 2.7Q21 3 20.7 2.7ZM21.7 3.7L21.3 3.3Q21 3 21.3 3.3L21.7 3.7Q22 4 21.7 3.7ZM22 5.7L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 4.3L23 5.7Q23 6 22.7 6L22.3 6Q22 6 22 5.7ZM23.7 6.7L23.3 6.3Q23 6 23.3 6.3L23.7 6.7Q24 7 23.7 6.7ZM32 6.7L32 5.6Q32 5 32.33 4.5L33.4 2.9Q34 2 33.79 3.06L33.06 6.71Q33 7 32.7 7L32.3 7Q32 7 32 6.7ZM36.79 10.79L36.3 10.3Q36 10 36.4 9.87L41.2 8.27Q42 8 41.4 8.6L40.6 9.4Q40 10 39.15 10L37.3 10Q37 10 37 10.3L37 10.7Q37 11 36.79 10.79ZM37.7 22.7L37.3 22.3Q37 22 37.3 22.3L37.7 22.7Q38 23 37.7 22.7ZM38.7 23.7L38.3 23.3Q38 23 38.3 23.3L38.7 23.7Q39 24 38.7 23.7ZM39 16.7L39 16.3Q39 16 39.3 16L45.7 16Q46 16 46 16.3L46 16.7Q46 17 45.7 17L39.3 17Q39 17 39 16.7ZM39.7 24.7L39.3 24.3Q39 24 39.3 24.3L39.7 24.7Q40 25 39.7 24.7ZM40.7 25.7L40.3 25.3Q40 25 40.3 25.3L40.7 25.7Q41 26 40.7 25.7Z" />
|
||||
{:else if gesture === 'W'}
|
||||
<path d="M9 28.1L9 26.9Q9 26 9.64 25.36L15.58 19.42Q16 19 16 18.4L16 17.6Q16 17 16.42 16.58L17.4 15.6Q18 15 18.8 14.73L22.86 13.38Q24 13 24.85 12.15L27.15 9.85Q28 9 29.2 9L31.05 9Q32 9 32.3 9.9L32.81 11.43Q33 12 32.4 12L31.6 12Q31 12 30.67 12.5L29.33 14.5Q29 15 28.4 15L27.6 15Q27 15 26.58 15.42L22.85 19.15Q22 20 21.62 21.14L20.38 24.86Q20 26 20.75 26.94L23.63 30.53Q24 31 24.6 31L25.4 31Q26 31 26.37 31.47L29.63 35.53Q30 36 30.6 36L31.4 36Q32 36 32.42 36.42L34.15 38.15Q35 39 35.2 40.18L35.86 44.16Q36 45 35.4 45.6L34.6 46.4Q34 47 33.15 47L18.6 47Q18 47 18 46.4L18 45.6Q18 45 18.54 44.73L19.4 44.3Q20 44 20 43.33L20 40.2Q20 39 19.11 38.19L9.67 29.61Q9 29 9 28.1Z" />
|
||||
{:else if gesture === 'D'}
|
||||
<path d="M9 37.8L9 28.67Q9 28 9.6 27.7L10.46 27.27Q11 27 11 26.4L11 25.6Q11 25 11.6 25L13.1 25Q14 25 14.28 24.15L14.72 22.85Q15 22 15.9 22L17.4 22Q18 22 18 21.4L18 20.6Q18 20 18.6 20L21.15 20Q22 20 22.6 19.4L23.4 18.6Q24 18 24.32 17.21L29.78 3.56Q30 3 30.6 3L31.4 3Q32 3 32.1 3.59L32.82 7.93Q33 9 32.4 9.9L31.6 11.1Q31 12 31 13.08L31 15.7Q31 16 30.7 16L30.3 16Q30 16 30 16.3L30 22.8Q30 24 31.18 23.76L34.41 23.12Q35 23 35 23.6L35 24.4Q35 25 34.67 25.5L33.6 27.1Q33 28 33 29.08L33 33.7Q33 34 32.7 34L32.3 34Q32 34 31.91 34.28L30.34 38.97Q30 40 29.1 40.6L27.5 41.67Q27 42 27 42.6L27 43.4Q27 44 26.43 44.19L24.57 44.81Q24 45 24 45.6L24 46.4Q24 47 23.4 47L10.85 47Q10 47 10.6 46.4L11.4 45.6Q12 45 12 44.15L12 42.9Q12 42 11.36 41.36L9.85 39.85Q9 39 9 37.8Z" />
|
||||
{:else if gesture === 'C'}
|
||||
<path d="M19 46.7L19 46.3Q19 46 19.28 45.91L21.1 45.3Q22 45 22.3 44.1L22.79 42.64Q23 42 23.6 41.7L24.4 41.3Q25 41 25 40.33L25 38.3Q25 38 25.3 38L25.7 38Q26 38 26 37.7L26 27.3Q26 27 25.7 27L25.3 27Q25 27 25.03 26.7L25.97 17.3Q26 17 26.3 17L26.7 17Q27 17 27.06 16.71L27.94 12.29Q28 12 28.3 12L28.7 12Q29 12 29 11.7L29 9.67Q29 9 29.6 8.7L30.4 8.3Q31 8 31.13 7.34L31.88 3.59Q32 3 32.6 3L33.4 3Q34 3 34.12 3.59L34.79 6.94Q35 8 34.4 8.9L33.6 10.1Q33 11 33 12.08L33 14.7Q33 15 32.7 15L32.3 15Q32 15 32 15.3L32 26.7Q32 27 32.3 27L32.7 27Q33 27 33 27.3L33 31.15Q33 32 33.6 32.6L34.4 33.4Q35 34 35 34.85L35 36.7Q35 37 35.3 37L35.7 37Q36 37 35.95 37.3L35.2 41.82Q35 43 34.28 43.96L32.72 46.04Q32 47 30.8 47L19.3 47Q19 47 19 46.7Z" />
|
||||
{:else if gesture === '>'}
|
||||
<path d="M10 46.7L10 46.3Q10 46 10.17 45.75L13.33 41Q14 40 14.09 38.8L14.98 27.3Q15 27 15.3 27L15.7 27Q16 27 16 26.7L16 22.3Q16 22 16.3 22L16.7 22Q17 22 17 21.7L17 17.3Q17 17 16.7 17L16.3 17Q16 17 15.94 16.71L13.2 2.06Q13 1 13.6 1.9L14.4 3.1Q15 4 15 5.08L15 8.7Q15 9 15.3 9L15.7 9Q16 9 16.05 9.3L17.79 18.82Q18 20 18.85 19.15L20.58 17.42Q21 17 21.6 17L22.4 17Q23 17 23 16.4L23 15.6Q23 15 22.46 14.73L21.54 14.27Q21 14 21 13.4L21 12.6Q21 12 20.64 11.52L18.54 8.72Q18 8 18 7.1L18 5.85Q18 5 17.4 4.4L16.42 3.42Q16 3 16 2.4L16 1.3Q16 1 16.3 1L16.7 1Q17 1 17.13 1.27L23.49 14.91Q24 16 25.16 16.29L27.18 16.79Q28 17 28.6 17.6L29.4 18.4Q30 19 30 19.85L30 22.4Q30 23 30.6 23L31.4 23Q32 23 32.42 23.42L33.4 24.4Q34 25 33.79 25.82L33.16 28.35Q33 29 33.6 29.3L34.4 29.7Q35 30 34.87 30.66L34.13 34.34Q34 35 34.6 35.3L35.4 35.7Q36 36 36 36.67L36 38.8Q36 40 35.3 40.98L31.7 46.02Q31 47 29.8 47L10.3 47Q10 47 10 46.7Z" />
|
||||
{:else}
|
||||
<!-- Spellcast left this tile blank; the letter row already shows a dash. -->
|
||||
{/if}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
|
||||
import GestureIcon from './GestureIcon.svelte';
|
||||
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
|
||||
|
||||
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
|
||||
|
||||
const draft = $derived(duel.sets[set][hand]);
|
||||
const forced = $derived(duel.forcedFor(set, hand));
|
||||
const charmed = $derived(duel.charmedHand === hand);
|
||||
const options = $derived(duel.completions[set][hand]);
|
||||
const chosen = $derived(duel.chosen[set][hand]);
|
||||
const spell = $derived(chosen?.spell);
|
||||
const other = $derived<Hand>(hand === 'left' ? 'right' : 'left');
|
||||
const key = $derived(castKey(set, hand));
|
||||
const banking = $derived(duel.willBank && castKey(duel.willBank.set, duel.willBank.hand) === key);
|
||||
const extending = $derived(duel.willExtend && castKey(duel.willExtend.set, duel.willExtend.hand) === key);
|
||||
let morePlans = $state(false);
|
||||
const plans = $derived(morePlans ? duel.plans[hand] : duel.plans[hand].slice(0, duel.planLimit(hand)));
|
||||
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
|
||||
const whyForced = $derived(
|
||||
duel.you.constraints.amnesia
|
||||
? 'Amnesia: you must repeat last turn.'
|
||||
: duel.you.constraints.paralysedHand === hand
|
||||
? 'Paralysed in last turn’s position.'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<fieldset class="hand">
|
||||
<legend>{hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
|
||||
|
||||
{#if forced}
|
||||
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
|
||||
{:else if charmed}
|
||||
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand’s gesture.</p>
|
||||
{:else}
|
||||
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
|
||||
{#each GESTURES as g (g)}
|
||||
<button
|
||||
type="button"
|
||||
class="gesture"
|
||||
class:on={draft.gesture === g}
|
||||
disabled={!duel.allowed[hand].includes(g)}
|
||||
aria-pressed={draft.gesture === g}
|
||||
onclick={() => duel.choose(set, hand, g)}
|
||||
>
|
||||
<GestureIcon gesture={g} {hand} />
|
||||
<span class="letter">{glyph(g)}</span>
|
||||
<span class="name">{GESTURE_SHORT[g]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if duel.you.constraints.fear && !duel.timeStopped}
|
||||
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
|
||||
{/if}
|
||||
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
|
||||
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
|
||||
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
|
||||
<ul class="plans" aria-label="{hand} hand, spells under way">
|
||||
{#each plans as plan (plan.spell.id)}
|
||||
<li class:pinned={plan.pinned}>
|
||||
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
|
||||
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
|
||||
<span class="pname">{plan.spell.name}</span>
|
||||
</button>
|
||||
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if hiddenPlans > 0 || morePlans}
|
||||
<li class="more">
|
||||
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if options.length > 0}
|
||||
<div class="cast">
|
||||
<label>
|
||||
<span>Finishes</span>
|
||||
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
|
||||
{#each options as c (c.spell.id + c.seqIndex)}
|
||||
<option value={c.spell.id}>{c.spell.name} ({c.spell.sequences[c.seqIndex]})</option>
|
||||
{/each}
|
||||
<option value="">nothing, let it pass</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if spell}
|
||||
<label>
|
||||
<span>at</span>
|
||||
<select bind:value={draft.target}>
|
||||
{#each duel.targetsFor(spell) as t (t.id)}
|
||||
<option value={t.id}>{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if spell.id === 'summon_elemental'}
|
||||
<label>
|
||||
<span>of</span>
|
||||
<select bind:value={draft.elemental}>
|
||||
<option value="fire">fire</option>
|
||||
<option value="ice">ice</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
|
||||
<label>
|
||||
<span>their</span>
|
||||
<select bind:value={draft.chosenHand}>
|
||||
<option value="left">left hand</option>
|
||||
<option value="right">right hand</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<p class="summary">
|
||||
{spell.summary}
|
||||
{#if banking}<strong>This spell will be banked by your delayed effect, not cast.</strong>{/if}
|
||||
{#if extending}<strong>Your permanency will make this enchantment last the whole duel.</strong>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
.hand {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-style: italic;
|
||||
font-size: 1.1rem;
|
||||
padding: 0;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.again {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.gestures {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.gesture {
|
||||
background: var(--slate-raised);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.2rem 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
color: var(--bone);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.gesture:hover:not(:disabled) {
|
||||
border-color: var(--rule-strong);
|
||||
}
|
||||
|
||||
.gesture.on {
|
||||
background: var(--ember);
|
||||
color: var(--slate-deep);
|
||||
}
|
||||
|
||||
.gesture:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gesture .letter {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
.gesture .name {
|
||||
font-size: 0.65rem;
|
||||
margin-top: 0.2rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.locked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--bone-dim);
|
||||
background: var(--slate-raised);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 1.8rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.cast {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.75rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.cast label {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.cast label span {
|
||||
color: var(--bone-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.plans {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plans li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.plan {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.1rem 0.2rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.plan:hover {
|
||||
background: var(--slate-raised);
|
||||
}
|
||||
|
||||
.seq {
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.seq .done {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.seq .arrow {
|
||||
color: var(--bone-faint);
|
||||
margin: 0 0.35em;
|
||||
}
|
||||
|
||||
.seq .next {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.pname {
|
||||
color: var(--bone-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
li.pinned .pname {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
li.more .plan {
|
||||
color: var(--frost);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pin {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
padding: 0 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
li.pinned .pin {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.summary,
|
||||
.hint {
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-dim);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--frost);
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--frost);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, 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[]>;
|
||||
}
|
||||
|
||||
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||
const history = who === YOU ? duel.you.history : duel.foeVisible;
|
||||
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) });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
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[]) {
|
||||
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] : [] } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const drafting = $derived(!duel.state.over);
|
||||
const turns = $derived(duel.you.history.length + duel.foe.history.length);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$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>
|
||||
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
|
||||
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
|
||||
</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>
|
||||
</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 ['left', 'right'] as const as hand (hand)}
|
||||
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
|
||||
{#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="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">
|
||||
{#each duel.activeSets as set (set)}
|
||||
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
|
||||
{@const charmed = duel.charmedHand === hand}
|
||||
{@const cast = duel.chosen[set][hand]}
|
||||
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
|
||||
<span class="letter" class:placeholder={chosen === null || charmed}>
|
||||
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
|
||||
</span>
|
||||
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
|
||||
</span>
|
||||
{/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>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroller {
|
||||
max-height: clamp(170px, 30vh, 400px);
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
.ledger {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
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 {
|
||||
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.you:nth-child(3) {
|
||||
border-right: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||
import { glyph } from '$lib/game/glyphs';
|
||||
import { MAX_HP } from '$lib/game/state';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const left = $derived(duel.forcedFor('main', 'left') ?? duel.sets.main.left.gesture);
|
||||
const right = $derived(duel.forcedFor('main', 'right') ?? duel.sets.main.right.gesture);
|
||||
</script>
|
||||
|
||||
{#if !duel.state.over}
|
||||
<div class="bar">
|
||||
<div class="health">
|
||||
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
|
||||
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
||||
</div>
|
||||
<div class="gestures" aria-label="Chosen gestures">
|
||||
<span class="letter" class:empty={left === null}>{duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')}</span>
|
||||
<span class="letter" class:empty={right === null}>{duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')}</span>
|
||||
</div>
|
||||
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 16px calc(0.5rem + env(safe-area-inset-bottom));
|
||||
background: var(--slate);
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.health em {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.gestures {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.letter {
|
||||
font-size: 1.6rem;
|
||||
color: var(--ember);
|
||||
min-width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.letter.empty {
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.reveal {
|
||||
background: var(--bone);
|
||||
color: var(--slate-deep);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reveal:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||
import { HANDS, SPELLS, type SpellCategory } from '$lib/game/spells';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
let open = $state<string | null>(null);
|
||||
|
||||
const groups: { category: SpellCategory; title: string }[] = [
|
||||
{ category: 'protection', title: 'Protection' },
|
||||
{ category: 'summons', title: 'Summons' },
|
||||
{ category: 'damage', title: 'Damage' },
|
||||
{ category: 'enchantment', title: 'Enchantments' }
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
|
||||
<summary>Spell sheet</summary>
|
||||
<p class="muted intro">Gestures you have already made light up; select a spell to read what it does. A bracketed lower-case letter, such as (w, is that gesture made with both hands at once; C, the clap, is always two-handed. Surrender is both palms at once: never a spell. <a href="/rules">Full rules</a>.</p>
|
||||
{#each groups as g (g.category)}
|
||||
<h3>{g.title}</h3>
|
||||
<ul>
|
||||
{#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)}
|
||||
{@const k = Math.max(duel.progress[spell.id].left, duel.progress[spell.id].right)}
|
||||
{@const done = duel.completedNow(spell.id)}
|
||||
<li class:done class:open={open === spell.id}>
|
||||
<button type="button" class="row" aria-expanded={open === spell.id} onclick={() => (open = open === spell.id ? null : spell.id)}>
|
||||
<span class="seq">{#each spell.sequences as seq, si (seq)}{#if si > 0}<span class="or"> or </span>{/if}{#each seq.split('-') as part, i (i)}{#if i > 0}<span class="dash">-</span>{/if}<span class="tok" class:lit={done || i < k}>{part}</span>{/each}{/each}</span>
|
||||
<span class="name">{spell.name}</span>
|
||||
</button>
|
||||
{#if open === spell.id}
|
||||
<p class="about">{spell.summary}</p>
|
||||
{#if !duel.state.over}
|
||||
<div class="planwith">
|
||||
{#each HANDS as hand (hand)}
|
||||
{@const plan = duel.planFor(hand, spell)}
|
||||
<button
|
||||
type="button"
|
||||
class="planbtn"
|
||||
disabled={!duel.handFree(hand)}
|
||||
title={duel.handFree(hand) ? `Pin ${spell.name} to the ${hand} hand and make its next gesture` : `The ${hand} hand is not yours to move this turn`}
|
||||
onclick={() => duel.follow(hand, plan)}
|
||||
>
|
||||
<span class="which">Plan with {hand} hand</span>
|
||||
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 0.85rem;
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
margin: 0.7rem 0 0.2rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9.5rem, auto) 1fr;
|
||||
gap: 0.75rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.12rem 0.2rem;
|
||||
text-align: left;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--slate-raised);
|
||||
}
|
||||
|
||||
li.open .row {
|
||||
background: var(--slate);
|
||||
}
|
||||
|
||||
.about {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-dim);
|
||||
padding: 0.2rem 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.planwith {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.planbtn {
|
||||
background: var(--slate-raised);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem 0.6rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.planbtn:hover:not(:disabled) {
|
||||
border-color: var(--frost);
|
||||
}
|
||||
|
||||
.planbtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.which {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.rest {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rest .done {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--bone-dim);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.seq {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash,
|
||||
.or {
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.tok {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.tok.lit {
|
||||
color: var(--ember);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
li.done .name {
|
||||
color: var(--ember);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||
|
||||
let { duel }: { duel: Duel } = $props();
|
||||
|
||||
const summary = $derived(duel.summary);
|
||||
|
||||
function signed(n: number): string {
|
||||
return n > 0 ? `+${n}` : String(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if summary}
|
||||
<section class="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)}
|
||||
{@const w = summary.wizards[who]}
|
||||
<div>
|
||||
<dt>{who === YOU ? 'You' : duel.foe.name}</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}
|
||||
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if summary.events?.length}
|
||||
<ul class="events">
|
||||
{#each summary.events as e, i (i)}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if duel.showLessons && summary.lessons?.length}
|
||||
<ul class="lessons">
|
||||
{#each summary.lessons as l, i (i)}
|
||||
<li>{l}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.result {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--slate);
|
||||
border-left: 2px solid var(--frost);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
dl > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem 0.6rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-style: italic;
|
||||
min-width: 5.5em;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem 0.75rem;
|
||||
}
|
||||
|
||||
.effect.hurt {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.effect.healed {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.hp {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.hp.changed {
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.lessons {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0.4rem 0 0.1rem 0.7rem;
|
||||
border-left: 2px solid var(--ember);
|
||||
color: var(--bone);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.lessons li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.events {
|
||||
list-style: none;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.35rem 0 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--bone-dim);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { SPELL_BY_ID } from '$lib/game/spells';
|
||||
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;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="wizard" class:you class:dead={!wizard.alive}>
|
||||
<h2>{wizard.name}{#if you}<span class="tag"> (you)</span>{/if}</h2>
|
||||
<div class="health">
|
||||
<div class="pips" role="img" aria-label="{wizard.hp} of {MAX_HP} hit points">
|
||||
{#each { length: MAX_HP } as _, i (i)}
|
||||
<span class="pip" class:lost={i >= wizard.hp}></span>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="hp">{wizard.hp} / {MAX_HP}</span>
|
||||
</div>
|
||||
{#if effects.length}
|
||||
<p class="effects">{effects.join('; ')}.</p>
|
||||
{:else}
|
||||
<p class="effects muted">Unenchanted.</p>
|
||||
{/if}
|
||||
{#if monsters.length}
|
||||
<ul class="monsters">
|
||||
{#each monsters as m (m.id)}
|
||||
<li>
|
||||
<span class="mname">{m.name}</span>
|
||||
<span class="mhp">{m.hp}/{m.maxHp}, hits for {m.attack}</span>
|
||||
{#if m.constraints.skipAttack}<span class="muted">paralysed</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.wizard {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.wizard.dead h2 {
|
||||
text-decoration: line-through;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 0.35em;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bone-dim);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.4rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.pips {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hp {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--bone-dim);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 100%;
|
||||
max-width: 16px;
|
||||
height: 10px;
|
||||
background: var(--bone);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.pip.lost {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--bone-faint);
|
||||
}
|
||||
|
||||
.effects {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monsters {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.monsters li {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.mname {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.mhp {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user