const FILES = "abcdefgh"; export class Board { constructor(root, opts) { this.opts = opts; this.cells = new Map(); this.pieces = new Map(); this.selected = null; this.lastMove = null; this.drag = null; this.onPointerDown = (e) => { 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(); } }; this.onPointerMove = (e) => { if (!this.drag) return; this.drag.moved = true; this.dragTo(this.drag.el, e.clientX, e.clientY); }; this.onPointerUp = (e) => { 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); }; 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); } 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); 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); } } } addCoords(cell, file, rank) { 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) { 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); this.pieces.set(cell.square, el); this.root.appendChild(el); } } this.clearHints(); } makePiece(type, color, square) { 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; } place(el, square) { const { x, y } = this.xy(square); el.style.transform = `translate(${x * 100}%, ${y * 100}%)`; } xy(square) { 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) { const from = move.from; const to = move.to; const moving = this.pieces.get(from); if (!moving) return this.reconcile(move); if (move.flags.includes("e")) { const capSquare = (to[0] + from[1]); 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]; 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(); } reconcile(move) { this.setLastMove(move.from, move.to); } moveRook(color, fromFile, toFile) { const rank = color === "w" ? "1" : "8"; const from = (fromFile + rank); const to = (toFile + rank); const rook = this.pieces.get(from); if (!rook) return; this.pieces.delete(from); this.pieces.set(to, rook); this.place(rook, to); } capture(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. animateFor(el, type) { const ms = { 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); } } puff(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) { 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, text) { 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, to) { 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) { for (const cell of this.cells.values()) cell.classList.remove("check"); if (square) this.cells.get(square)?.classList.add("check"); } select(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); if (!cell) continue; const dot = document.createElement("div"); dot.className = "dot" + (move.captured || move.flags.includes("e") ? " capture" : ""); cell.appendChild(dot); } } 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()); } } tryMove(from, to) { 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; } squareAt(clientX, clientY) { 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); } dragTo(el, clientX, clientY) { 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)`; } askPromotion(color, at, done) { 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 = ["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) { this.opts.orientation = o; this.buildGrid(); } setSet(set) { this.opts.set = set; } setCoords(on) { this.opts.coords = on; this.buildGrid(); } }