feat(ui): Board component — rotated grid, coloured pieces, highlights, ghosts
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BoardId, Player, Square } from '../engine/types';
|
||||||
|
import type { SelectionHighlight } from '../engine/legality';
|
||||||
|
import { BOARD_ROTATION, BOARD_PLAYERS } from '../engine/boards';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: BoardId;
|
||||||
|
fen: string;
|
||||||
|
/** Player colours, e.g. { N:'#4a90d9', ... }. */
|
||||||
|
colors: Record<Player, string>;
|
||||||
|
ghosts: Square[];
|
||||||
|
/** Highlight for this board, or null if no piece is grabbed / not active. */
|
||||||
|
highlight: { playable: Square[]; local: Square[]; selected: Square | null } | null;
|
||||||
|
active: boolean;
|
||||||
|
onSquare: (square: Square) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, fen, colors, ghosts, highlight, active, onSquare }: Props = $props();
|
||||||
|
|
||||||
|
const FILES = 'abcdefgh';
|
||||||
|
const GLYPH: Record<string, string> = {
|
||||||
|
k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Cell { square: Square; piece: { glyph: string; color: string } | null; }
|
||||||
|
|
||||||
|
let cells = $derived.by<Cell[]>(() => {
|
||||||
|
const placement = fen.split(' ')[0];
|
||||||
|
const white = colors[BOARD_PLAYERS[id].w];
|
||||||
|
const black = colors[BOARD_PLAYERS[id].b];
|
||||||
|
const map: Record<string, { glyph: string; color: string }> = {};
|
||||||
|
placement.split('/').forEach((row, ri) => {
|
||||||
|
const rank = 8 - ri;
|
||||||
|
let file = 0;
|
||||||
|
for (const ch of row) {
|
||||||
|
if (/\d/.test(ch)) { file += Number(ch); continue; }
|
||||||
|
const isWhite = ch === ch.toUpperCase();
|
||||||
|
map[`${FILES[file]}${rank}`] = {
|
||||||
|
glyph: GLYPH[ch.toLowerCase()],
|
||||||
|
color: isWhite ? white : black,
|
||||||
|
};
|
||||||
|
file += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const out: Cell[] = [];
|
||||||
|
for (let rank = 8; rank >= 1; rank--) {
|
||||||
|
for (let f = 0; f < 8; f++) {
|
||||||
|
const square = `${FILES[f]}${rank}`;
|
||||||
|
out.push({ square, piece: map[square] ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function classes(cell: Cell, index: number): string {
|
||||||
|
const dark = (index + Math.floor(index / 8)) % 2 === 1;
|
||||||
|
const hl = highlight;
|
||||||
|
const list = ['sq', dark ? 'dark' : 'light'];
|
||||||
|
if (ghosts.includes(cell.square)) list.push('ghost-sq');
|
||||||
|
if (hl?.playable.includes(cell.square)) list.push(cell.piece ? 'play occ' : 'play');
|
||||||
|
if (hl?.local.includes(cell.square)) list.push('local');
|
||||||
|
if (hl?.selected === cell.square) list.push('selected');
|
||||||
|
return list.join(' ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="board" class:active style="--rot:{BOARD_ROTATION[id]}deg">
|
||||||
|
{#each cells as cell, i (cell.square)}
|
||||||
|
<button class={classes(cell, i)} onclick={() => onSquare(cell.square)} aria-label={cell.square}>
|
||||||
|
{#if cell.piece}
|
||||||
|
<span class="pc" class:ghost={ghosts.includes(cell.square)}
|
||||||
|
style="color:{cell.piece.color}">{cell.piece.glyph}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, var(--sq, 34px));
|
||||||
|
grid-template-rows: repeat(8, var(--sq, 34px));
|
||||||
|
transform: rotate(var(--rot));
|
||||||
|
border: 1px solid #20232b;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.board.active { box-shadow: 0 0 0 3px var(--glow, #4a90d9), 0 0 20px 2px var(--glow, #4a90d9); }
|
||||||
|
.sq {
|
||||||
|
position: relative; padding: 0; border: 0; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.sq.light { background: #cabf9f; }
|
||||||
|
.sq.dark { background: #7d6f55; }
|
||||||
|
.pc {
|
||||||
|
font-size: calc(var(--sq, 34px) * 0.76); line-height: 1;
|
||||||
|
text-shadow: -1px -1px 0 #15171c, 1px -1px 0 #15171c,
|
||||||
|
-1px 1px 0 #15171c, 1px 1px 0 #15171c;
|
||||||
|
}
|
||||||
|
.pc.ghost { opacity: 0.42; }
|
||||||
|
.ghost-sq { outline: 2px dashed #888; outline-offset: -2px; }
|
||||||
|
.sq.play::after, .sq.local::after {
|
||||||
|
content: ''; position: absolute; border-radius: 50%;
|
||||||
|
width: 32%; height: 32%;
|
||||||
|
}
|
||||||
|
.sq.play::after { background: #46c24f; box-shadow: 0 0 7px #46c24f; }
|
||||||
|
.sq.play.occ::after {
|
||||||
|
width: 84%; height: 84%; background: transparent;
|
||||||
|
border: 3px solid #46c24f; box-shadow: 0 0 7px #46c24f;
|
||||||
|
}
|
||||||
|
.sq.local::after { background: transparent; border: 2px dashed #9aa0aa; }
|
||||||
|
.sq.selected { outline: 3px solid #3fd9d9; outline-offset: -3px; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user