feat(ui): Panel component — turn, move log, legend, controls

This commit is contained in:
claude (duplicate_chess)
2026-05-19 01:08:41 -04:00
parent be05ee5617
commit 51615debd0
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import { toCoordinate } from '../engine/notation';
import { PLAYERS } from '../engine/boards';
import type { Player } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
const NAME: Record<Player, string> = { N: 'North', S: 'South', E: 'East', W: 'West' };
let view = $derived(gameStore.view);
/** Move log grouped into rounds of four (N,S,E,W). */
let rounds = $derived.by(() => {
const out: string[][] = [];
view.history.forEach((entry, i) => {
const r = Math.floor(i / 4);
(out[r] ??= [])[i % 4] = toCoordinate(entry);
});
return out;
});
let statusText = $derived.by(() => {
const s = view.status;
if (s.state === 'playing') return `${NAME[view.currentPlayer]} to move`;
if (s.state === 'checkmate') {
const winners = PLAYERS.filter((p) => s.result?.[p] === 'win').map((p) => NAME[p]);
return `Checkmate — ${NAME[view.currentPlayer]} loses; ${winners.join(' & ')} win`;
}
if (s.state === 'stalemate') return 'Stalemate — all draw';
return `Draw (${s.reason})`;
});
let fileInput: HTMLInputElement;
function onFile(e: Event): void {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) gameStore.load(file).catch((err) => alert(String(err)));
}
</script>
<aside class="panel">
<section class="card">
<div class="turn">
<span class="dot" style="background:{COLORS[view.currentPlayer]}"></span>
{statusText}
</div>
<div class="sub">Ghosts on board: {view.ghosts.length} · Checks: {view.status.checks.length}</div>
</section>
<section class="card">
<h2>Move log</h2>
<table>
<thead><tr>
{#each PLAYERS as p}<th style="color:{COLORS[p]}">{NAME[p]}</th>{/each}
</tr></thead>
<tbody>
{#each rounds as round, r (r)}
<tr>
{#each [0, 1, 2, 3] as c}<td>{round[c] ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card">
<h2>Legend</h2>
<div class="legend">
<div><span class="ring play"></span> Playable — legal on both boards</div>
<div><span class="ring local"></span> Legal on that board only</div>
<div><span class="ring ghost"></span> Ghost — twin captured, frozen</div>
</div>
</section>
<section class="card controls">
<button onclick={() => gameStore.newGame()}>New game</button>
<button onclick={() => gameStore.undo()} disabled={view.ply === 0}>Undo</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) - 1)}
disabled={view.ply === 0}>◀ Prev</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) + 1)}
disabled={!gameStore.isScrubbing}>Next ▶</button>
<button onclick={() => gameStore.scrubTo(null)} disabled={!gameStore.isScrubbing}>● Live</button>
<button onclick={() => gameStore.declareDraw()}>Declare draw</button>
<button onclick={() => gameStore.save()}>Save</button>
<button onclick={() => fileInput.click()}>Load</button>
<input type="file" accept="application/json" bind:this={fileInput}
onchange={onFile} style="display:none" />
</section>
</aside>
<style>
.panel { width: 290px; display: flex; flex-direction: column; gap: 13px; }
.card {
background: #1d2027; border: 1px solid #333845;
border-radius: 9px; padding: 13px 15px;
}
.card h2 {
margin: 0 0 8px; font-size: 11px; letter-spacing: 0.07em;
text-transform: uppercase; color: #9aa0aa;
}
.turn { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.dot { width: 13px; height: 13px; border-radius: 50%; }
.sub { font-size: 12px; color: #9aa0aa; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 3px 5px; text-align: left; }
th { font-size: 10px; text-transform: uppercase; }
td { font-family: ui-monospace, Menlo, monospace; color: #cdd2da; }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
.legend div { display: flex; align-items: center; gap: 8px; }
.ring { width: 14px; height: 14px; border-radius: 50%; flex: none; }
.ring.play { background: #46c24f; }
.ring.local { border: 2px dashed #9aa0aa; }
.ring.ghost { border: 2px dashed #888; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; }
.controls button {
background: #262b34; color: #e6e8ec; border: 1px solid #333845;
border-radius: 5px; padding: 5px 9px; font-size: 12px; cursor: pointer;
}
.controls button:disabled { opacity: 0.4; cursor: default; }
</style>