feat(ui): Panel component — turn, move log, legend, controls
This commit is contained in:
@@ -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>
|
||||||
Reference in New Issue
Block a user