feat(ui): Compass component — pinwheel layout, click-to-move wiring
This commit is contained in:
@@ -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>
|
||||||
Reference in New Issue
Block a user