feat(ui): Board component — rotated grid, coloured pieces, highlights, ghosts

This commit is contained in:
claude (duplicate_chess)
2026-05-19 01:04:39 -04:00
parent 3931805b6f
commit 059177c635
+112
View File
@@ -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>