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