import type { Move, Square, Color } from "../game/state"; import { type PieceSet, type PieceType } from "./pieces"; export interface BoardOptions { orientation: Color; set: PieceSet; coords: boolean; legalTargets: (from: Square) => Move[]; canMove: () => boolean; onMove: (from: Square, to: Square, promotion?: PromoPiece) => void; } type PromoPiece = "q" | "r" | "b" | "n"; const FILES = "abcdefgh"; export class Board { private root: HTMLElement; private cells = new Map(); private pieces = new Map(); private selected: Square | null = null; private lastMove: [Square, Square] | null = null; private drag: { from: Square; el: HTMLElement; moved: boolean } | null = null; constructor(root: HTMLElement, private opts: BoardOptions) { this.root = root; this.root.classList.add("board"); this.buildGrid(); this.root.addEventListener("pointerdown", this.onPointerDown); window.addEventListener("pointermove", this.onPointerMove); window.addEventListener("pointerup", this.onPointerUp); } private buildGrid() { this.root.replaceChildren(); this.cells.clear(); for (let rank = 8; rank >= 1; rank--) { for (let f = 0; f < 8; f++) { const file = this.opts.orientation === "w" ? f : 7 - f; const r = this.opts.orientation === "w" ? rank : 9 - rank; const square = (FILES[file] + r) as Square; const cell = document.createElement("div"); cell.className = "sq " + ((file + r) % 2 === 0 ? "light" : "dark"); cell.dataset.square = square; if (this.opts.coords) this.addCoords(cell, file, r); this.cells.set(square, cell); this.root.appendChild(cell); } } } private addCoords(cell: HTMLElement, file: number, rank: number) { const bottomRank = this.opts.orientation === "w" ? 1 : 8; const edgeFile = this.opts.orientation === "w" ? 0 : 7; if (rank === bottomRank) { const c = document.createElement("span"); c.className = "coord file"; c.textContent = FILES[file]; cell.appendChild(c); } if (file === edgeFile) { const c = document.createElement("span"); c.className = "coord rank"; c.textContent = String(rank); cell.appendChild(c); } } setPosition(board: ReturnType) { for (const el of this.pieces.values()) el.remove(); this.pieces.clear(); for (const row of board) { for (const cell of row) { if (!cell) continue; const el = this.makePiece(cell.type, cell.color, cell.square as Square); this.pieces.set(cell.square as Square, el); this.root.appendChild(el); } } this.clearHints(); } private makePiece(type: PieceType, color: Color, square: Square): HTMLElement { const el = document.createElement("div"); el.className = "piece " + color + (this.opts.set.traditional ? " traditional" : ""); const glyph = document.createElement("span"); glyph.className = "glyph"; glyph.textContent = this.opts.set.glyphs[type]; el.appendChild(glyph); el.dataset.type = type; this.place(el, square); return el; } private place(el: HTMLElement, square: Square) { const { x, y } = this.xy(square); el.style.transform = `translate(${x * 100}%, ${y * 100}%)`; } private xy(square: Square): { x: number; y: number } { const file = FILES.indexOf(square[0]); const rank = Number(square[1]); if (this.opts.orientation === "w") return { x: file, y: 8 - rank }; return { x: 7 - file, y: rank - 1 }; } // Animate a single applied move, including the special cases chess.js flags for us. applyMove(move: Move) { const from = move.from as Square; const to = move.to as Square; const moving = this.pieces.get(from); if (!moving) return this.reconcile(move); if (move.flags.includes("e")) { const capSquare = (to[0] + from[1]) as Square; this.capture(capSquare); } else if (move.captured) { this.capture(to); } this.pieces.delete(from); this.pieces.set(to, moving); this.animateFor(moving, move.piece); this.place(moving, to); if (move.flags.includes("p") && move.promotion) { const glyph = moving.querySelector(".glyph"); if (glyph) glyph.textContent = this.opts.set.glyphs[move.promotion as PieceType]; moving.dataset.type = move.promotion; } if (move.flags.includes("k")) this.moveRook(move.color, "h", "f"); if (move.flags.includes("q")) this.moveRook(move.color, "a", "d"); this.setLastMove(from, to); this.clearHints(); } private reconcile(move: Move) { this.setLastMove(move.from as Square, move.to as Square); } private moveRook(color: Color, fromFile: string, toFile: string) { const rank = color === "w" ? "1" : "8"; const from = (fromFile + rank) as Square; const to = (toFile + rank) as Square; const rook = this.pieces.get(from); if (!rook) return; this.pieces.delete(from); this.pieces.set(to, rook); this.place(rook, to); } private capture(square: Square) { const el = this.pieces.get(square); if (!el) return; this.pieces.delete(square); el.classList.add("captured"); setTimeout(() => el.remove(), 220); this.puff(square); } // Each piece carries a little of its own character: pawns hop, the king trudges. private animateFor(el: HTMLElement, type: string) { const ms: Record = { p: 220, n: 340, b: 300, r: 340, q: 240, k: 400 }; el.style.transitionDuration = (ms[type] ?? 280) + "ms"; if (type === "n") { el.classList.add("hop"); setTimeout(() => el.classList.remove("hop"), ms.n); } } private puff(square: Square) { const p = document.createElement("div"); p.className = "puff"; p.textContent = "💨"; this.place(p, square); this.root.appendChild(p); setTimeout(() => p.remove(), 480); } setThreats(squares: Square[]) { for (const el of this.pieces.values()) el.classList.remove("threat"); for (const sq of squares) this.pieces.get(sq)?.classList.add("threat"); } react(square: Square, text: string) { const b = document.createElement("div"); b.className = "reaction"; b.textContent = text; this.place(b, square); this.root.appendChild(b); requestAnimationFrame(() => b.classList.add("rise")); setTimeout(() => b.remove(), 900); } setLastMove(from: Square, to: Square) { if (this.lastMove) { for (const sq of this.lastMove) this.cells.get(sq)?.classList.remove("last"); } this.lastMove = [from, to]; this.cells.get(from)?.classList.add("last"); this.cells.get(to)?.classList.add("last"); } setCheck(square: Square | null) { for (const cell of this.cells.values()) cell.classList.remove("check"); if (square) this.cells.get(square)?.classList.add("check"); } private select(square: Square) { this.clearHints(); this.selected = square; this.cells.get(square)?.classList.add("sel"); for (const move of this.opts.legalTargets(square)) { const cell = this.cells.get(move.to as Square); if (!cell) continue; const dot = document.createElement("div"); dot.className = "dot" + (move.captured || move.flags.includes("e") ? " capture" : ""); cell.appendChild(dot); } } private clearHints() { if (this.selected) this.cells.get(this.selected)?.classList.remove("sel"); this.selected = null; for (const cell of this.cells.values()) { cell.querySelectorAll(".dot").forEach((d) => d.remove()); } } private tryMove(from: Square, to: Square) { const legal = this.opts.legalTargets(from).find((m) => m.to === to); if (!legal) return false; if (legal.flags.includes("p")) { this.askPromotion(legal.color, to, (choice) => this.opts.onMove(from, to, choice)); } else { this.opts.onMove(from, to); } return true; } private squareAt(clientX: number, clientY: number): Square | null { const rect = this.root.getBoundingClientRect(); const col = Math.floor(((clientX - rect.left) / rect.width) * 8); const row = Math.floor(((clientY - rect.top) / rect.height) * 8); if (col < 0 || col > 7 || row < 0 || row > 7) return null; const file = this.opts.orientation === "w" ? col : 7 - col; const rank = this.opts.orientation === "w" ? 8 - row : row + 1; return (FILES[file] + rank) as Square; } private onPointerDown = (e: PointerEvent) => { if (!this.opts.canMove()) return; const square = this.squareAt(e.clientX, e.clientY); if (!square) return; if (this.selected && this.selected !== square) { if (this.tryMove(this.selected, square)) return; } const el = this.pieces.get(square); if (el) { this.select(square); this.drag = { from: square, el, moved: false }; el.classList.add("dragging"); this.dragTo(el, e.clientX, e.clientY); } else { this.clearHints(); } }; private onPointerMove = (e: PointerEvent) => { if (!this.drag) return; this.drag.moved = true; this.dragTo(this.drag.el, e.clientX, e.clientY); }; private onPointerUp = (e: PointerEvent) => { if (!this.drag) return; const { from, el, moved } = this.drag; this.drag = null; el.classList.remove("dragging"); this.place(el, from); if (!moved) return; const to = this.squareAt(e.clientX, e.clientY); if (to && to !== from) this.tryMove(from, to); }; private dragTo(el: HTMLElement, clientX: number, clientY: number) { const rect = this.root.getBoundingClientRect(); const size = rect.width / 8; const x = clientX - rect.left - size / 2; const y = clientY - rect.top - size / 2; el.style.transform = `translate(${x}px, ${y}px)`; } private askPromotion(color: Color, at: Square, done: (choice: PromoPiece) => void) { const menu = document.createElement("div"); menu.className = "promo"; const { x, y } = this.xy(at); menu.style.left = `${x * 12.5}%`; menu.style.top = `${Math.min(y, 4) * 12.5}%`; const order: PromoPiece[] = ["q", "r", "b", "n"]; for (const type of order) { const b = document.createElement("button"); b.textContent = this.opts.set.traditional ? this.opts.set.glyphs[type] : this.opts.set.glyphs[type]; b.className = "piece-choice " + color; b.onclick = () => { menu.remove(); done(type); }; menu.appendChild(b); } this.root.appendChild(menu); } setOrientation(o: Color) { this.opts.orientation = o; this.buildGrid(); } setSet(set: PieceSet) { this.opts.set = set; } setCoords(on: boolean) { this.opts.coords = on; this.buildGrid(); } }