feat(ui): Compass component — pinwheel layout, click-to-move wiring

This commit is contained in:
claude (duplicate_chess)
2026-05-19 01:06:39 -04:00
parent 059177c635
commit be05ee5617
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import Board from './Board.svelte';
import { gameStore } from './stores/game.svelte';
import { PLAYER_BOARDS } from '../engine/boards';
import type { BoardId, Player, Square } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
// Board centre positions inside the 744x744 compass (see spec §5.1).
const POS: Record<BoardId, { left: number; top: number }> = {
NW: { left: 200, top: 200 }, NE: { left: 544, top: 200 },
SW: { left: 200, top: 544 }, SE: { left: 544, top: 544 },
};
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
let view = $derived(gameStore.view);
let active = $derived(gameStore.activeBoards);
/** Ghost squares for a given board. */
function ghostsFor(id: BoardId): Square[] {
return view.ghosts.filter((g) => g.board === id).map((g) => g.square);
}
/** Highlight payload for a given board, or null. */
function highlightFor(id: BoardId) {
const h = gameStore.highlight;
if (h === null) return null;
if (id !== h.boardA && id !== h.boardB) return null;
const local = id === h.boardA ? h.onlyA : h.onlyB;
const selectedHere = active.includes(id) ? gameStore.selected : null;
return { playable: h.playable, local, selected: selectedHere };
}
function handleSquare(id: BoardId, square: Square): void {
if (gameStore.isScrubbing) return;
if (!active.includes(id)) return; // only the player-to-move's boards are interactive
if (gameStore.selected === null) {
gameStore.select(square);
} else if (gameStore.highlight?.playable.includes(square)) {
gameStore.commitTo(square);
} else {
gameStore.select(square); // re-grab or cancel
}
}
</script>
<div class="compass" style="--glow:{COLORS[view.currentPlayer]}">
{#each BOARD_IDS as id (id)}
<div class="slot" style="left:{POS[id].left}px; top:{POS[id].top}px;">
<Board
{id}
fen={view.fen[id]}
colors={COLORS}
ghosts={ghostsFor(id)}
highlight={highlightFor(id)}
active={active.includes(id)}
onSquare={(sq) => handleSquare(id, sq)}
/>
</div>
{/each}
<div class="plabel" class:on={view.currentPlayer === 'N'}
style="left:372px; top:74px; background:{COLORS.N}">NORTH</div>
<div class="plabel" class:on={view.currentPlayer === 'S'}
style="left:372px; top:670px; background:{COLORS.S}">SOUTH</div>
<div class="plabel vert" class:on={view.currentPlayer === 'E'}
style="left:670px; top:372px; background:{COLORS.E}">EAST</div>
<div class="plabel vert" class:on={view.currentPlayer === 'W'}
style="left:74px; top:372px; background:{COLORS.W}">WEST</div>
</div>
<style>
.compass { position: relative; width: 744px; height: 744px; flex: none; }
.slot { position: absolute; transform: translate(-50%, -50%); }
.plabel {
position: absolute; transform: translate(-50%, -50%);
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 0.09em;
padding: 6px 12px; border-radius: 7px; white-space: nowrap;
}
.plabel.vert { writing-mode: vertical-rl; }
.plabel.on { box-shadow: 0 0 14px currentColor; }
</style>