diff --git a/games/schach-engine.js b/games/schach-engine.js new file mode 100644 index 0000000..1faac80 --- /dev/null +++ b/games/schach-engine.js @@ -0,0 +1,669 @@ +// Schach-Engine — pure rules, no DOM and no p5. +// Shared by the solo page (games/schach.js) and the hub game (hub-games/schach.js). +// +// Board indices follow FEN order: 0 = a8, 7 = h8, 8 = a7 … 63 = h1. +// Pieces are FEN characters: uppercase = white, lowercase = black. +// +// Public API: +// START_FEN, parseFen, toFen, clonePosition, positionKey +// legalMoves, inCheck, makeMove, moveToSan, gameStatus, insufficientMaterial +// chooseMove (levels 1–3), moveToUci, uciToMove, squareName + +export const START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + +const FILES = "abcdefgh"; +const WHITE = "w"; +const BLACK = "b"; +const PROMO = ["q", "r", "b", "n"]; +const TIMEOUT = { timeout: true }; + +const KNIGHT_STEPS = [[-2, -1], [-2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2], [2, -1], [2, 1]]; +const KING_STEPS = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; +const ROOK_DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]]; +const BISHOP_DIRS = [[-1, -1], [-1, 1], [1, -1], [1, 1]]; + +// ---- board helpers ---- + +export const other = (color) => (color === WHITE ? BLACK : WHITE); +const isWhitePiece = (ch) => !!ch && ch === ch.toUpperCase(); +const colorOf = (ch) => (isWhitePiece(ch) ? WHITE : BLACK); +const typeOf = (ch) => (ch ? ch.toLowerCase() : ""); +const rankOf = (sq) => sq >> 3; +const fileOf = (sq) => sq & 7; +const squareAt = (rank, file) => rank * 8 + file; + +export function squareName(sq) { + return FILES[fileOf(sq)] + (8 - rankOf(sq)); +} + +function squareIndex(name) { + if (typeof name !== "string" || name.length < 2) return -1; + const file = FILES.indexOf(name[0]); + const rank = 8 - Number(name[1]); + if (file < 0 || rank < 0 || rank > 7) return -1; + return squareAt(rank, file); +} + +// ---- FEN ---- + +export function parseFen(fen = START_FEN) { + const parts = String(fen).trim().split(/\s+/); + const board = new Array(64).fill(null); + let i = 0; + for (const ch of parts[0] || "") { + if (ch === "/") continue; + if (ch >= "1" && ch <= "8") i += Number(ch); + else if (i < 64) board[i++] = ch; + } + const rights = parts[2] || "-"; + const epName = parts[3] || "-"; + return { + board, + turn: parts[1] === BLACK ? BLACK : WHITE, + castling: { + K: rights.includes("K"), + Q: rights.includes("Q"), + k: rights.includes("k"), + q: rights.includes("q"), + }, + ep: epName === "-" ? null : squareIndex(epName), + halfmove: Math.max(0, Number(parts[4]) || 0), + fullmove: Math.max(1, Number(parts[5]) || 1), + }; +} + +export function toFen(pos) { + let placement = ""; + for (let rank = 0; rank < 8; rank++) { + let empty = 0; + for (let file = 0; file < 8; file++) { + const ch = pos.board[squareAt(rank, file)]; + if (ch) { + if (empty) { placement += empty; empty = 0; } + placement += ch; + } else empty++; + } + if (empty) placement += empty; + if (rank < 7) placement += "/"; + } + const rights = ["K", "Q", "k", "q"].filter((k) => pos.castling[k]).join("") || "-"; + return [ + placement, + pos.turn, + rights, + pos.ep === null || pos.ep === undefined ? "-" : squareName(pos.ep), + pos.halfmove, + pos.fullmove, + ].join(" "); +} + +// Board + side + rights + ep: ignores the move counters, so positions that only +// differ in halfmove/fullmove compare equal for repetition. +export function positionKey(pos) { + return toFen(pos).split(" ").slice(0, 4).join(" "); +} + +export function clonePosition(pos) { + return { + board: pos.board.slice(), + turn: pos.turn, + castling: { ...pos.castling }, + ep: pos.ep, + halfmove: pos.halfmove, + fullmove: pos.fullmove, + }; +} + +// ---- attack detection ---- + +export function isSquareAttacked(pos, sq, byColor) { + const b = pos.board; + const rank = rankOf(sq); + const file = fileOf(sq); + + // pawns + if (byColor === WHITE) { + if (file > 0 && rank < 7 && b[sq + 7] === "P") return true; + if (file < 7 && rank < 7 && b[sq + 9] === "P") return true; + } else { + if (file > 0 && rank > 0 && b[sq - 9] === "p") return true; + if (file < 7 && rank > 0 && b[sq - 7] === "p") return true; + } + + // knights + const knight = byColor === WHITE ? "N" : "n"; + for (const [dr, df] of KNIGHT_STEPS) { + const r = rank + dr, f = file + df; + if (r >= 0 && r < 8 && f >= 0 && f < 8 && b[squareAt(r, f)] === knight) return true; + } + + // king + const king = byColor === WHITE ? "K" : "k"; + for (const [dr, df] of KING_STEPS) { + const r = rank + dr, f = file + df; + if (r >= 0 && r < 8 && f >= 0 && f < 8 && b[squareAt(r, f)] === king) return true; + } + + // sliding pieces + const rook = byColor === WHITE ? "R" : "r"; + const bishop = byColor === WHITE ? "B" : "b"; + const queen = byColor === WHITE ? "Q" : "q"; + for (const [dr, df] of ROOK_DIRS) { + let r = rank + dr, f = file + df; + while (r >= 0 && r < 8 && f >= 0 && f < 8) { + const ch = b[squareAt(r, f)]; + if (ch) { + if (ch === rook || ch === queen) return true; + break; + } + r += dr; f += df; + } + } + for (const [dr, df] of BISHOP_DIRS) { + let r = rank + dr, f = file + df; + while (r >= 0 && r < 8 && f >= 0 && f < 8) { + const ch = b[squareAt(r, f)]; + if (ch) { + if (ch === bishop || ch === queen) return true; + break; + } + r += dr; f += df; + } + } + return false; +} + +export function kingSquare(pos, color) { + const king = color === WHITE ? "K" : "k"; + for (let i = 0; i < 64; i++) if (pos.board[i] === king) return i; + return -1; +} + +export function inCheck(pos, color = pos.turn) { + const k = kingSquare(pos, color); + return k >= 0 && isSquareAttacked(pos, k, other(color)); +} + +// ---- move generation ---- + +function pushPromotions(moves, from, to, captured) { + for (const promo of PROMO) moves.push({ from, to, promo, captured: captured || null }); +} + +function pseudoMoves(pos) { + const moves = []; + const b = pos.board; + const turn = pos.turn; + const white = turn === WHITE; + + for (let from = 0; from < 64; from++) { + const piece = b[from]; + if (!piece || colorOf(piece) !== turn) continue; + const type = typeOf(piece); + const rank = rankOf(from); + const file = fileOf(from); + + if (type === "p") { + const dir = white ? -1 : 1; + const promoRank = white ? 0 : 7; + const startRank = white ? 6 : 1; + const oneRank = rank + dir; + if (oneRank >= 0 && oneRank < 8) { + const one = squareAt(oneRank, file); + if (!b[one]) { + if (oneRank === promoRank) pushPromotions(moves, from, one, null); + else { + moves.push({ from, to: one }); + if (rank === startRank) { + const two = squareAt(rank + dir * 2, file); + if (!b[two]) moves.push({ from, to: two, double: true }); + } + } + } + for (const df of [-1, 1]) { + const f = file + df; + if (f < 0 || f > 7) continue; + const to = squareAt(oneRank, f); + const target = b[to]; + if (target && colorOf(target) !== turn) { + if (oneRank === promoRank) pushPromotions(moves, from, to, target); + else moves.push({ from, to, captured: target }); + } else if (!target && pos.ep === to) { + moves.push({ from, to, ep: true, captured: white ? "p" : "P" }); + } + } + } + continue; + } + + if (type === "n" || type === "k") { + const steps = type === "n" ? KNIGHT_STEPS : KING_STEPS; + for (const [dr, df] of steps) { + const r = rank + dr, f = file + df; + if (r < 0 || r > 7 || f < 0 || f > 7) continue; + const to = squareAt(r, f); + const target = b[to]; + if (!target) moves.push({ from, to }); + else if (colorOf(target) !== turn) moves.push({ from, to, captured: target }); + } + continue; + } + + const dirs = type === "r" ? ROOK_DIRS : type === "b" ? BISHOP_DIRS : ROOK_DIRS.concat(BISHOP_DIRS); + for (const [dr, df] of dirs) { + let r = rank + dr, f = file + df; + while (r >= 0 && r < 8 && f >= 0 && f < 8) { + const to = squareAt(r, f); + const target = b[to]; + if (!target) moves.push({ from, to }); + else { + if (colorOf(target) !== turn) moves.push({ from, to, captured: target }); + break; + } + r += dr; f += df; + } + } + } + + // castling — rights, empty path, and no attack on king or transit squares + const enemy = other(turn); + if (white) { + if (pos.castling.K && b[61] === null && b[62] === null && b[63] === "R" && + b[60] === "K" && !isSquareAttacked(pos, 60, enemy) && + !isSquareAttacked(pos, 61, enemy) && !isSquareAttacked(pos, 62, enemy)) { + moves.push({ from: 60, to: 62, castle: "K" }); + } + if (pos.castling.Q && b[59] === null && b[58] === null && b[57] === null && b[56] === "R" && + b[60] === "K" && !isSquareAttacked(pos, 60, enemy) && + !isSquareAttacked(pos, 59, enemy) && !isSquareAttacked(pos, 58, enemy)) { + moves.push({ from: 60, to: 58, castle: "Q" }); + } + } else { + if (pos.castling.k && b[5] === null && b[6] === null && b[7] === "r" && + b[4] === "k" && !isSquareAttacked(pos, 4, enemy) && + !isSquareAttacked(pos, 5, enemy) && !isSquareAttacked(pos, 6, enemy)) { + moves.push({ from: 4, to: 6, castle: "K" }); + } + if (pos.castling.q && b[3] === null && b[2] === null && b[1] === null && b[0] === "r" && + b[4] === "k" && !isSquareAttacked(pos, 4, enemy) && + !isSquareAttacked(pos, 3, enemy) && !isSquareAttacked(pos, 2, enemy)) { + moves.push({ from: 4, to: 2, castle: "Q" }); + } + } + + return moves; +} + +// Applies a move without validating it. Returns a fresh position. +export function makeMove(pos, move) { + const b = pos.board.slice(); + const piece = b[move.from]; + const color = colorOf(piece); + const type = typeOf(piece); + + b[move.from] = null; + if (move.ep) b[move.to + (color === WHITE ? 8 : -8)] = null; + b[move.to] = move.promo + ? (color === WHITE ? move.promo.toUpperCase() : move.promo) + : piece; + + if (move.castle) { + if (color === WHITE) { + if (move.castle === "K") { b[63] = null; b[61] = "R"; } + else { b[56] = null; b[59] = "R"; } + } else { + if (move.castle === "K") { b[7] = null; b[5] = "r"; } + else { b[0] = null; b[3] = "r"; } + } + } + + const castling = { ...pos.castling }; + if (type === "k") { + if (color === WHITE) { castling.K = false; castling.Q = false; } + else { castling.k = false; castling.q = false; } + } + // a rook leaving or being captured on a corner kills that right + for (const sq of [move.from, move.to]) { + if (sq === 63) castling.K = false; + else if (sq === 56) castling.Q = false; + else if (sq === 7) castling.k = false; + else if (sq === 0) castling.q = false; + } + + let ep = null; + if (type === "p" && Math.abs(move.to - move.from) === 16) { + ep = (move.from + move.to) / 2; + } + + const halfmove = type === "p" || move.captured ? 0 : pos.halfmove + 1; + return { + board: b, + turn: other(color), + castling, + ep, + halfmove, + fullmove: pos.fullmove + (color === BLACK ? 1 : 0), + }; +} + +export function legalMoves(pos) { + const moves = pseudoMoves(pos); + const me = pos.turn; + const enemy = other(me); + const out = []; + for (const move of moves) { + const next = makeMove(pos, move); + const k = kingSquare(next, me); + if (k < 0 || !isSquareAttacked(next, k, enemy)) out.push(move); + } + return out; +} + +// ---- notation ---- + +export function moveToUci(move) { + return squareName(move.from) + squareName(move.to) + (move.promo || ""); +} + +export function uciToMove(pos, uci) { + if (typeof uci !== "string") return null; + const from = squareIndex(uci.slice(0, 2)); + const to = squareIndex(uci.slice(2, 4)); + const promo = uci.length > 4 ? uci[4] : null; + return legalMoves(pos).find((m) => + m.from === from && m.to === to && (m.promo || null) === (promo || null) + ) || null; +} + +export function moveToSan(pos, move) { + const piece = pos.board[move.from]; + const type = typeOf(piece); + let san; + + if (move.castle) { + san = move.castle === "K" ? "O-O" : "O-O-O"; + } else { + san = ""; + if (type !== "p") { + san += type.toUpperCase(); + const rivals = legalMoves(pos).filter((m) => + m.to === move.to && m.from !== move.from && typeOf(pos.board[m.from]) === type + ); + if (rivals.length) { + const sameFile = rivals.some((m) => fileOf(m.from) === fileOf(move.from)); + const sameRank = rivals.some((m) => rankOf(m.from) === rankOf(move.from)); + if (!sameFile) san += FILES[fileOf(move.from)]; + else if (!sameRank) san += String(8 - rankOf(move.from)); + else san += squareName(move.from); + } + } else if (move.captured) { + san += FILES[fileOf(move.from)]; + } + if (move.captured) san += "x"; + san += squareName(move.to); + if (move.promo) san += "=" + move.promo.toUpperCase(); + } + + const next = makeMove(pos, move); + if (inCheck(next)) san += legalMoves(next).length ? "+" : "#"; + return san; +} + +// ---- results ---- + +export function insufficientMaterial(pos) { + const pieces = []; + for (let i = 0; i < 64; i++) { + const ch = pos.board[i]; + if (!ch) continue; + const t = typeOf(ch); + if (t === "k") continue; + pieces.push({ t, color: colorOf(ch), light: (rankOf(i) + fileOf(i)) % 2 === 0 }); + } + if (pieces.length === 0) return true; + if (pieces.length === 1) return pieces[0].t === "b" || pieces[0].t === "n"; + if (pieces.every((p) => p.t === "b")) { + return pieces.every((p) => p.light === pieces[0].light); + } + return false; +} + +export function gameStatus(pos, keys) { + const moves = legalMoves(pos); + if (moves.length === 0) { + if (inCheck(pos)) return { over: true, winner: other(pos.turn), reason: "checkmate" }; + return { over: true, winner: null, reason: "stalemate" }; + } + if (pos.halfmove >= 100) return { over: true, winner: null, reason: "fifty" }; + if (insufficientMaterial(pos)) return { over: true, winner: null, reason: "material" }; + if (keys && keys.length) { + const key = positionKey(pos); + let n = 0; + for (const k of keys) if (k === key) n++; + if (n >= 3) return { over: true, winner: null, reason: "repetition" }; + } + return { over: false, winner: null, reason: null }; +} + +// ---- evaluation ---- + +const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 20000 }; + +// Tables are written from White's point of view in our board order (a8 first). +const PST = { + p: [ + 0, 0, 0, 0, 0, 0, 0, 0, + 50, 50, 50, 50, 50, 50, 50, 50, + 10, 10, 20, 30, 30, 20, 10, 10, + 5, 5, 10, 25, 25, 10, 5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, -5, -10, 0, 0, -10, -5, 5, + 5, 10, 10, -20, -20, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0, + ], + n: [ + -50, -40, -30, -30, -30, -30, -40, -50, + -40, -20, 0, 0, 0, 0, -20, -40, + -30, 0, 10, 15, 15, 10, 0, -30, + -30, 5, 15, 20, 20, 15, 5, -30, + -30, 0, 15, 20, 20, 15, 0, -30, + -30, 5, 10, 15, 15, 10, 5, -30, + -40, -20, 0, 5, 5, 0, -20, -40, + -50, -40, -30, -30, -30, -30, -40, -50, + ], + b: [ + -20, -10, -10, -10, -10, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 10, 10, 5, 0, -10, + -10, 5, 5, 10, 10, 5, 5, -10, + -10, 0, 10, 10, 10, 10, 0, -10, + -10, 10, 10, 10, 10, 10, 10, -10, + -10, 5, 0, 0, 0, 0, 5, -10, + -20, -10, -10, -10, -10, -10, -10, -20, + ], + r: [ + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, 10, 10, 10, 10, 5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 0, 0, 0, 5, 5, 0, 0, 0, + ], + q: [ + -20, -10, -10, -5, -5, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 5, 5, 5, 0, -10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0, -10, + -10, 0, 5, 0, 0, 0, 0, -10, + -20, -10, -10, -5, -5, -10, -10, -20, + ], + k: [ + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -20, -30, -30, -40, -40, -30, -30, -20, + -10, -20, -20, -20, -20, -20, -20, -10, + 20, 20, 0, 0, 0, 0, 20, 20, + 20, 30, 10, 0, 0, 10, 30, 20, + ], +}; + +const KING_END = [ + -50, -40, -30, -20, -20, -30, -40, -50, + -30, -20, -10, 0, 0, -10, -20, -30, + -30, -10, 20, 30, 30, 20, -10, -30, + -30, -10, 30, 40, 40, 30, -10, -30, + -30, -10, 30, 40, 40, 30, -10, -30, + -30, -10, 20, 30, 30, 20, -10, -30, + -30, -30, 0, 0, 0, 0, -30, -30, + -50, -30, -30, -30, -30, -30, -30, -50, +]; + +function evaluate(pos) { + const b = pos.board; + let score = 0; + let heavy = 0; + let minor = 0; + for (let i = 0; i < 64; i++) { + const ch = b[i]; + if (!ch) continue; + const type = typeOf(ch); + if (type === "q" || type === "r") heavy += VALUE[type]; + if (type === "b" || type === "n") minor += VALUE[type]; + } + const endgame = heavy === 0 || heavy + minor <= 1300; + + for (let i = 0; i < 64; i++) { + const ch = b[i]; + if (!ch) continue; + const type = typeOf(ch); + const white = isWhitePiece(ch); + const table = type === "k" ? (endgame ? KING_END : PST.k) : PST[type]; + const pst = table[white ? i : 63 - i]; + const v = VALUE[type] + pst; + score += white ? v : -v; + } + return score; +} + +// ---- search ---- + +const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + +const LEVELS = { + 1: { depth: 2, time: 220, noise: 90, blunder: 0.18 }, + 2: { depth: 3, time: 650, noise: 30, blunder: 0.04 }, + 3: { depth: 4, time: 1200, noise: 0, blunder: 0 }, +}; + +let nodes = 0; + +function orderMoves(pos, moves) { + for (const m of moves) { + let s = 0; + if (m.captured) s += 10 * (VALUE[typeOf(m.captured)] || 0) - (VALUE[typeOf(pos.board[m.from])] || 0); + if (m.promo) s += 800; + if (m.castle) s += 40; + m._order = s; + } + moves.sort((a, b) => b._order - a._order); + return moves; +} + +function evalForTurn(pos) { + const e = evaluate(pos); + return pos.turn === WHITE ? e : -e; +} + +function quiesce(pos, alpha, beta, qdepth, deadline, ply) { + nodes++; + if ((nodes & 2047) === 0 && now() > deadline) throw TIMEOUT; + + const checked = inCheck(pos); + const stand = evalForTurn(pos); + if (!checked) { + if (stand >= beta) return beta; + if (stand > alpha) alpha = stand; + } + if (qdepth >= 4) return checked ? stand : alpha; + + let moves = legalMoves(pos); + if (!moves.length) return checked ? -100000 + ply : 0; + if (!checked) moves = moves.filter((m) => m.captured || m.promo); + orderMoves(pos, moves); + + for (const m of moves) { + const next = makeMove(pos, m); + const score = -quiesce(next, -beta, -alpha, qdepth + 1, deadline, ply + 1); + if (score >= beta) return beta; + if (score > alpha) alpha = score; + } + return alpha; +} + +function search(pos, depth, alpha, beta, ply, deadline) { + nodes++; + if ((nodes & 2047) === 0 && now() > deadline) throw TIMEOUT; + + const moves = legalMoves(pos); + if (!moves.length) return inCheck(pos) ? -100000 + ply : 0; + if (depth <= 0) return quiesce(pos, alpha, beta, 0, deadline, ply); + + orderMoves(pos, moves); + let best = -Infinity; + for (const m of moves) { + const next = makeMove(pos, m); + const score = -search(next, depth - 1, -beta, -alpha, ply + 1, deadline); + if (score > best) best = score; + if (best > alpha) alpha = best; + if (alpha >= beta) break; + } + return best; +} + +function rootSearch(pos, depth, deadline) { + const moves = orderMoves(pos, legalMoves(pos)); + const out = []; + let alpha = -Infinity; + for (const m of moves) { + const next = makeMove(pos, m); + const score = -search(next, depth - 1, -Infinity, -alpha, 1, deadline); + out.push({ move: m, score }); + if (score > alpha) alpha = score; + } + return out; +} + +// level: 1 easy, 2 medium, 3 strong. Always returns a legal move (or null). +export function chooseMove(pos, level = 2) { + const moves = legalMoves(pos); + if (!moves.length) return null; + const cfg = LEVELS[Math.max(1, Math.min(3, Number(level) || 2))] || LEVELS[2]; + if (Math.random() < cfg.blunder) return moves[Math.floor(Math.random() * moves.length)]; + + const deadline = now() + cfg.time; + nodes = 0; + let scored = moves.map((move) => ({ move, score: 0 })); + for (let depth = 1; depth <= cfg.depth; depth++) { + try { + scored = rootSearch(pos, depth, deadline); + } catch (e) { + if (e !== TIMEOUT) throw e; + break; + } + } + + scored.sort((a, b) => b.score - a.score); + const best = scored[0]; + let pick = best.move; + if (cfg.noise > 0) { + const pool = scored.filter((s) => s.score >= best.score - cfg.noise); + pick = pool[Math.floor(Math.random() * pool.length)].move; + } + return pick; +} diff --git a/games/schach-view.js b/games/schach-view.js new file mode 100644 index 0000000..3227ab4 --- /dev/null +++ b/games/schach-view.js @@ -0,0 +1,576 @@ +// Schach — shared board renderer, input and small UI helpers. +// +// A plain classic script on purpose: the home previews (manifest.js) need it +// synchronously, the solo game uses it in p5 global mode, and the hub game +// imports it through window.SchachView. Everything is drawn from PAL, so the +// designs keep working; the DOM bits use the theme CSS variables. +// +// SchachView.drawPiece(p, piece, cx, cy, size) +// SchachView.drawBoard(p, { x, y, size, flipped, pos, selected, legal, last, check, anim }) +// new SchachView.Board(p, engine, { x, y, size, flipped, canMove, onMove, choosePromotion }) +// SchachView.showPromotion(container, color) -> Promise<"q"|"r"|"b"|"n"|null> +// SchachView.showResult(container, { title, sub, actionLabel, onAction, onClose }) +// SchachView.playSound("move" | "capture" | "check" | "end" | "select") +// SchachView.resultInfo(result) -> { title, sub } +// SchachView.renderMoves(el, sans) + +(function () { + const FILES = "abcdefgh"; + const PIECES = { + p: [ + "M50 17 C43 17 39 23 39 29 C39 33 41 36 44 38 C37 42 34 51 33 64 L67 64 C66 51 63 42 56 38 C59 36 61 33 61 29 C61 23 57 17 50 17 Z", + "M31 64 L69 64 Q73 64 73 69 L73 76 Q73 80 69 80 L31 80 Q27 80 27 76 L27 69 Q27 64 31 64 Z", + ], + r: [ + "M30 20 L39 20 L39 28 L45 28 L45 20 L55 20 L55 28 L61 28 L61 20 L70 20 L70 38 L65 44 L65 64 L35 64 L35 44 L30 38 Z", + "M29 64 L71 64 Q75 64 75 69 L75 76 Q75 80 71 80 L29 80 Q25 80 25 76 L25 69 Q25 64 29 64 Z", + ], + n: [ + "M66 64 C66 54 66 48 63 43 C61 39 57 36 56 32 L53 23 L49 30 C44 29 39 31 36 36 C34 39 34 42 36 43 C39 44 42 43 45 42 C43 46 40 49 36 51 L33 64 Z", + "M32 64 L68 64 Q72 64 72 69 L72 76 Q72 80 68 80 L32 80 Q28 80 28 76 L28 69 Q28 64 32 64 Z", + ], + b: [ + "M50 20 C41 29 37 37 37 46 C37 54 42 59 50 59 C58 59 63 54 63 46 C63 37 59 29 50 20 Z", + "M50 11 A5 5 0 1 0 50 21 A5 5 0 1 0 50 11 Z", + "M36 59 L64 59 L66 64 L34 64 Z", + "M30 64 L70 64 Q74 64 74 69 L74 76 Q74 80 70 80 L30 80 Q26 80 26 76 L26 69 Q26 64 30 64 Z", + ], + q: [ + "M26 38 L22 20 L34 34 L38 18 L46 34 L50 16 L54 34 L62 18 L66 34 L78 20 L74 38 Z", + "M50 10 A4 4 0 1 0 50 18 A4 4 0 1 0 50 10 Z", + "M22 14 A4 4 0 1 0 22 22 A4 4 0 1 0 22 14 Z", + "M38 12 A4 4 0 1 0 38 20 A4 4 0 1 0 38 12 Z", + "M62 12 A4 4 0 1 0 62 20 A4 4 0 1 0 62 12 Z", + "M78 14 A4 4 0 1 0 78 22 A4 4 0 1 0 78 14 Z", + "M32 38 L68 38 L72 62 L28 62 Z", + "M26 66 L74 66 Q78 66 78 70 L78 77 Q78 81 74 81 L26 81 Q22 81 22 77 L22 70 Q22 66 26 66 Z", + ], + k: [ + "M46 10 L54 10 L54 17 L61 17 L61 25 L54 25 L54 32 L46 32 L46 25 L39 25 L39 17 L46 17 Z", + "M30 40 L34 33 L40 41 L50 30 L60 41 L66 33 L70 40 L68 62 L32 62 Z", + "M28 62 L72 62 Q76 62 76 67 L76 77 Q76 81 72 81 L28 81 Q24 81 24 77 L24 67 Q24 62 28 62 Z", + ], + }; + + const pathCache = new Map(); + const path = (d) => { + let p = pathCache.get(d); + if (!p) { p = new Path2D(d); pathCache.set(d, p); } + return p; + }; + + const rgb = (c, a = 255) => `rgba(${c[0]},${c[1]},${c[2]},${a / 255})`; + const isWhite = (ch) => ch === ch.toUpperCase(); + const ease = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); + const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + + function indexAt(row, col, flipped) { + return flipped ? 63 - (row * 8 + col) : row * 8 + col; + } + + function rowCol(index, flipped) { + const row = flipped ? 7 - Math.floor(index / 8) : Math.floor(index / 8); + const col = flipped ? 7 - (index % 8) : index % 8; + return { row, col }; + } + + function drawPiece(p, ch, cx, cy, size, opts = {}) { + if (!ch) return; + const ctx = p.drawingContext; + const white = isWhite(ch); + const fill = white ? PAL.onDark : PAL.onLight; + const stroke = white ? PAL.onLight : PAL.onDark; + const scale = size / 100; + const kind = ch.toLowerCase(); + + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(scale, scale); + ctx.translate(-50, -50); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.lineWidth = opts.lineWidth || 4; + ctx.fillStyle = rgb(fill); + ctx.strokeStyle = rgb(stroke); + if (opts.shadow !== false) { + ctx.shadowColor = "rgba(0,0,0,0.3)"; + ctx.shadowBlur = 5; + ctx.shadowOffsetY = 2; + } + for (const d of PIECES[kind]) { + const shape = path(d); + ctx.fill(shape); + ctx.stroke(shape); + ctx.shadowColor = "transparent"; + } + // little details in the outline colour + ctx.shadowColor = "transparent"; + ctx.fillStyle = rgb(stroke); + ctx.strokeStyle = rgb(stroke); + if (kind === "n") { + ctx.beginPath(); + ctx.arc(45, 36, 2.8, 0, Math.PI * 2); + ctx.fill(); + } else if (kind === "b") { + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(50, 30); + ctx.lineTo(54, 42); + ctx.stroke(); + } + ctx.restore(); + } + + function squareRect(x, y, size, index, flipped) { + const { row, col } = rowCol(index, flipped); + const cell = size / 8; + return { x: x + col * cell, y: y + row * cell, cell }; + } + + function drawBoard(p, o) { + const { x, y, size, flipped = false, pos, selected = null, legal = [], last = null, check = null, anim = null, drag = null } = o; + const cell = size / 8; + + p.noStroke(); + p.fill(...PAL.line); + p.rect(x - 2, y - 2, size + 4, size + 4, 6); + + for (let row = 0; row < 8; row++) { + for (let col = 0; col < 8; col++) { + p.noStroke(); + p.fill(...((row + col) % 2 === 0 ? PAL.panel : PAL.well)); + p.rect(x + col * cell, y + row * cell, cell + 0.4, cell + 0.4, 0); + } + } + + if (last) { + p.noStroke(); + p.fill(...PAL.sun, 60); + for (const sq of [last.from, last.to]) { + const r = squareRect(x, y, size, sq, flipped); + p.rect(r.x, r.y, r.cell, r.cell, 0); + } + } + + if (check !== null && check !== undefined) { + const r = squareRect(x, y, size, check, flipped); + p.noStroke(); + p.fill(...PAL.rose, 75); + p.rect(r.x, r.y, r.cell, r.cell, 0); + for (let k = 3; k >= 1; k--) { + p.fill(...PAL.rose, 9); + p.circle(r.x + r.cell / 2, r.y + r.cell / 2, r.cell * (0.8 + k * 0.3)); + } + } + + if (selected !== null) { + const r = squareRect(x, y, size, selected, flipped); + p.noStroke(); + p.fill(...PAL.lilac, 90); + p.rect(r.x, r.y, r.cell, r.cell, 0); + } + + for (const m of legal) { + const r = squareRect(x, y, size, m.to, flipped); + const cx = r.x + r.cell / 2, cy = r.y + r.cell / 2; + if (pos.board[m.to]) { + p.noFill(); + p.stroke(...PAL.rose, 210); + p.strokeWeight(Math.max(2, cell * 0.07)); + p.circle(cx, cy, r.cell * 0.84); + p.noStroke(); + } else { + p.noStroke(); + p.fill(...PAL.lilac, 205); + p.circle(cx, cy, r.cell * 0.22); + } + } + + const hide = new Set(); + if (anim) hide.add(anim.to); + if (drag) hide.add(drag.from); + + for (let i = 0; i < 64; i++) { + if (hide.has(i)) continue; + const ch = pos.board[i]; + if (!ch) continue; + const r = squareRect(x, y, size, i, flipped); + drawPiece(p, ch, r.x + r.cell / 2, r.y + r.cell / 2, cell * 0.98); + } + + if (anim) { + const t = Math.min(1, (now() - anim.start) / anim.dur); + const a = squareRect(x, y, size, anim.from, flipped); + const b = squareRect(x, y, size, anim.to, flipped); + const k = ease(t); + const cx = a.x + a.cell / 2 + (b.x - a.x) * k; + const cy = a.y + a.cell / 2 + (b.y - a.y) * k; + drawPiece(p, anim.piece, cx, cy, cell * (0.98 + Math.sin(t * Math.PI) * 0.08)); + } + + if (drag) { + drawPiece(p, pos.board[drag.from], drag.x, drag.y, cell * 1.04); + } + } + + function drawCoords(p, { x, y, size, flipped = false }) { + const cell = size / 8; + p.noStroke(); + p.fill(...PAL.dim); + p.textSize(cell * 0.17); + p.textAlign(p.RIGHT, p.BOTTOM); + for (let col = 0; col < 8; col++) { + const name = flipped ? FILES[7 - col] : FILES[col]; + p.text(name, x + col * cell + cell - 4, y + size - 4); + } + p.textAlign(p.LEFT, p.TOP); + for (let row = 0; row < 8; row++) { + const name = flipped ? String(row + 1) : String(8 - row); + p.text(name, x + 4, y + row * cell + 4); + } + } + + // ---- board controller ---- + + class Board { + constructor(p, engine, opts = {}) { + this.p = p; + this.engine = engine; + this.x = opts.x ?? 0; + this.y = opts.y ?? 0; + this.size = opts.size ?? 480; + this.flipped = !!opts.flipped; + this.interactive = opts.interactive !== false; + this.canMove = opts.canMove || (() => true); + this.choosePromotion = opts.choosePromotion || null; + this.onMove = opts.onMove || null; + this.play = opts.play || playSound; + this.pos = engine.parseFen(engine.START_FEN); + this.last = null; + this.check = null; + this.selected = null; + this.legal = []; + this.anim = null; + this.down = null; + this.dragging = false; + this.pointer = { x: 0, y: 0 }; + this.busy = false; + } + + setPosition(pos, { last = undefined, animate = null } = {}) { + this.pos = pos; + if (last !== undefined) this.last = last; + this.check = this.engine.inCheck(pos) ? this.engine.kingSquare(pos, pos.turn) : null; + this.selected = null; + this.legal = []; + this.dragging = false; + if (animate) { + this.anim = { + from: animate.from, + to: animate.to, + piece: pos.board[animate.to], + start: now(), + dur: 180, + }; + } else { + this.anim = null; + } + } + + setFlip(flipped) { + this.flipped = !!flipped; + } + + setCanMove(fn) { + this.canMove = fn; + } + + setInteractive(on) { + this.interactive = !!on; + if (!on) { this.selected = null; this.legal = []; } + } + + squareAt(px, py) { + const cell = this.size / 8; + if (px < this.x || py < this.y || px >= this.x + this.size || py >= this.y + this.size) return -1; + const col = Math.floor((px - this.x) / cell); + const row = Math.floor((py - this.y) / cell); + return indexAt(row, col, this.flipped); + } + + centerOf(index) { + const r = squareRect(this.x, this.y, this.size, index, this.flipped); + return { x: r.x + r.cell / 2, y: r.y + r.cell / 2 }; + } + + trySelect(sq) { + const ch = this.pos.board[sq]; + if (!ch || (isWhite(ch) ? "w" : "b") !== this.pos.turn || !this.canMove()) { + this.selected = null; + this.legal = []; + return; + } + this.selected = sq; + this.legal = this.engine.legalMoves(this.pos).filter((m) => m.from === sq); + this.play("select"); + } + + press(px, py) { + if (!this.interactive || this.busy) return; + this.pointer = { x: px, y: py }; + const sq = this.squareAt(px, py); + this.down = { sq, x: px, y: py }; + this.dragging = false; + if (sq < 0) { + this.selected = null; + this.legal = []; + return; + } + if (this.selected !== null) { + const targets = this.legal.filter((m) => m.to === sq); + if (targets.length) { this.commit(targets); return; } + } + const ch = this.pos.board[sq]; + if (ch && (isWhite(ch) ? "w" : "b") === this.pos.turn) this.trySelect(sq); + else { this.selected = null; this.legal = []; } + } + + drag(px, py) { + if (!this.down || this.selected === null) return; + this.pointer = { x: px, y: py }; + const dx = px - this.down.x, dy = py - this.down.y; + if (dx * dx + dy * dy > 36) this.dragging = true; + } + + release(px, py) { + if (!this.down) return; + this.pointer = { x: px, y: py }; + if (this.dragging && this.interactive && !this.busy) { + const sq = this.squareAt(px, py); + const targets = sq >= 0 ? this.legal.filter((m) => m.to === sq) : []; + if (targets.length) this.commit(targets); + else if (sq !== this.selected) { this.selected = null; this.legal = []; } + } + this.dragging = false; + this.down = null; + } + + commit(targets) { + if (!this.onMove) return; + if (targets.length > 1 && targets[0].promo) { + this.busy = true; + const color = this.pos.turn; + Promise.resolve(this.choosePromotion ? this.choosePromotion(color) : "q").then((promo) => { + this.busy = false; + if (!promo) { this.selected = null; this.legal = []; return; } + const move = targets.find((m) => m.promo === promo); + if (move) this.emit(move); + else { this.selected = null; this.legal = []; } + }); + return; + } + this.emit(targets[0]); + } + + emit(move) { + this.selected = null; + this.legal = []; + this.onMove(move); + } + + draw() { + if (this.anim && now() - this.anim.start > this.anim.dur + 40) this.anim = null; + drawBoard(this.p, { + x: this.x, y: this.y, size: this.size, + flipped: this.flipped, + pos: this.pos, + selected: this.selected, + legal: this.legal, + last: this.last, + check: this.check, + anim: this.anim, + drag: this.dragging && this.selected !== null + ? { from: this.selected, x: this.pointer.x, y: this.pointer.y } + : null, + }); + drawCoords(this.p, { x: this.x, y: this.y, size: this.size, flipped: this.flipped }); + } + } + + // ---- DOM helpers (theme variables, no hard-coded colours) ---- + + function injectStyle() { + if (document.getElementById("schach-style")) return; + const st = document.createElement("style"); + st.id = "schach-style"; + st.textContent = ` +.schach-moves { margin-top: 14px; border: 1px solid var(--hairline); background: var(--panel); border-radius: var(--r-sm); overflow: hidden; } +.schach-moves-head { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); border-bottom: 1px solid var(--hairline); } +.schach-moves-body { max-height: 168px; overflow-y: auto; padding: 4px 6px; font-variant-numeric: tabular-nums; } +.schach-moves-row { display: grid; grid-template-columns: 30px 1fr 1fr; gap: 4px; padding: 2px 4px; font-size: 13px; } +.schach-moves-row:nth-child(odd) { background: color-mix(in srgb, var(--panel-2) 55%, transparent); border-radius: 4px; } +.schach-moves-row .nr { color: var(--dim); } +.schach-moves-row .san.on { color: var(--accent); font-weight: 700; } +.schach-moves-empty { padding: 8px 4px; color: var(--muted); font-size: 13px; } +.schach-overlay { position: absolute; inset: 0; z-index: 4; display: grid; place-items: center; padding: 14px; background: color-mix(in srgb, var(--board) 74%, transparent); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); border-radius: inherit; } +.schach-box { position: relative; width: min(320px, 100%); padding: 18px 16px 14px; text-align: center; background: var(--panel); border: var(--border-w) solid var(--hairline); border-radius: var(--r-md); box-shadow: var(--wrap-shadow); line-height: 1.35; } +.schach-box h3 { margin: 0 0 4px; font-family: var(--font-display); font-size: 19px; line-height: 1.2; color: var(--ink); } +.schach-box p { margin: 0 0 12px; color: var(--muted); font-size: 13px; line-height: 1.35; } +.schach-box .row button { line-height: 1.2; } +.schach-box .row { justify-content: center; margin: 0; } +.schach-box .row button { flex: 0 1 auto; min-width: 96px; } +.schach-box-x { position: absolute; top: 6px; right: 8px; flex: none; width: 26px; min-width: 0; height: 26px; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--muted); font-size: 16px; line-height: 1; } +.schach-box-x:hover { background: transparent; color: var(--ink); } +.schach-promo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.schach-promo-grid button { display: flex; align-items: center; gap: 8px; justify-content: center; line-height: 1.3; } +.schach-promo-glyph { font-size: 20px; line-height: 1; } +.schach-hub { display: grid; grid-template-columns: minmax(0, 540px) minmax(230px, 1fr); gap: 26px; align-items: start; } +.schach-boardwrap { position: relative; width: 100%; max-width: 540px; border-radius: var(--r-md); overflow: hidden; } +.schach-boardwrap canvas { display: block; width: 100% !important; height: auto !important; } +.schach-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.schach-status { padding: 10px 12px; border: 1px solid var(--hairline); border-radius: var(--r-sm); background: var(--panel); font-size: 14px; } +.schach-status b { color: var(--ink); } +.schach-status .dot { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--dim); vertical-align: 1px; } +.schach-status.live .dot { background: var(--mint); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mint) 18%, transparent); } +.schach-status.over .dot { background: var(--rose); } +.schach-player { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--r-sm); font-size: 13px; color: var(--muted); } +.schach-player.active { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--ink); } +.schach-player.gone span:not(.tag-me) { text-decoration: line-through; opacity: .6; } +.schach-player .dot { flex: none; width: 11px; height: 11px; border-radius: 50%; border: 1px solid var(--hairline); } +.schach-player .dot.w { background: var(--ink); } +.schach-player .dot.b { background: var(--muted); } +.schach-player .piece { width: 18px; text-align: center; font-size: 16px; } +.schach-player .tag-me { margin-left: auto; font-size: 11px; color: var(--accent); } +.schach-spectators { font-size: 12px; color: var(--muted); } +.schach-side .row { margin: 0; } +.schach-note { font-size: 12px; color: var(--muted); } +@media (max-width: 860px) { + .schach-hub { grid-template-columns: 1fr; gap: 16px; } + .schach-moves-body { max-height: 132px; } +} +`; + document.head.appendChild(st); + } + + function renderMoves(el, sans) { + if (!el) return; + const rows = []; + for (let i = 0; i < sans.length; i += 2) { + const n = i / 2 + 1; + const w = sans[i] || ""; + const b = sans[i + 1] || ""; + rows.push(`
Wähle die neue Figur.
+${sub}
` : ""} + ${actionLabel ? `` : ""} +