From d7832cd04697069862199be4f9f87afe1eda2061 Mon Sep 17 00:00:00 2001 From: Malte Hilpert Date: Tue, 10 Nov 2026 23:00:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(schach):=20Schach=20mit=20KI,=20lokalem=20?= =?UTF-8?q?Duell=20und=20Online-R=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volle FIDE-Regeln (Rochade, en passant, Umwandlung, Matt/Patt/Remis), drei KI-Stufen, lokales 2-Spieler-Duell mit automatischem Brettdreh sowie Mehrspieler-Räume mit Zuschauern, Revanche, Farbtausch und Aufgeben. --- games/schach-engine.js | 669 +++++++++++++++++++++++++++++++++++++++++ games/schach-view.js | 576 +++++++++++++++++++++++++++++++++++ games/schach.js | 374 +++++++++++++++++++++++ hub-games/schach.js | 521 ++++++++++++++++++++++++++++++++ hub.js | 1 + index.html | 1 + manifest.js | 32 ++ 7 files changed, 2174 insertions(+) create mode 100644 games/schach-engine.js create mode 100644 games/schach-view.js create mode 100644 games/schach.js create mode 100644 hub-games/schach.js 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(`
${n}.${w}${b}
`); + } + el.innerHTML = rows.length ? rows.join("") : `
Noch keine Züge
`; + el.scrollTop = el.scrollHeight; + } + + function showPromotion(container, color) { + injectStyle(); + return new Promise((resolve) => { + const el = document.createElement("div"); + el.className = "schach-overlay"; + el.innerHTML = ``; + const done = (value) => { + el.remove(); + document.removeEventListener("keydown", onKey); + resolve(value); + }; + const onKey = (e) => { if (e.key === "Escape") done(null); }; + el.addEventListener("click", (e) => { + const b = e.target.closest("[data-promo]"); + if (b) done(b.dataset.promo); + }); + document.addEventListener("keydown", onKey); + container.appendChild(el); + }); + } + + function showResult(container, { title, sub, actionLabel, onAction, onClose }) { + injectStyle(); + container.querySelectorAll(".schach-overlay").forEach((n) => n.remove()); + const el = document.createElement("div"); + el.className = "schach-overlay"; + el.innerHTML = ``; + el.addEventListener("click", (e) => { + if (e.target.closest("[data-result-action]") && onAction) onAction(); + else if (e.target.closest(".schach-box-x") && onClose) { el.remove(); onClose(); } + }); + container.appendChild(el); + return el; + } + + let audio = null; + function playSound(kind) { + try { + if (!window.AudioContext && !window.webkitAudioContext) return; + if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)(); + if (audio.state === "suspended") audio.resume(); + const t = audio.currentTime; + const notes = { + select: [[520, 0.025, 0.012]], + move: [[260, 0.05, 0.03], [190, 0.06, 0.018]], + capture: [[180, 0.05, 0.04], [120, 0.12, 0.035]], + check: [[720, 0.07, 0.032], [980, 0.12, 0.026]], + end: [[523, 0.12, 0.035], [659, 0.12, 0.03], [784, 0.22, 0.03]], + }[kind] || []; + for (const [freq, dur, gain] of notes) { + const osc = audio.createOscillator(); + const g = audio.createGain(); + osc.type = kind === "capture" ? "sawtooth" : "triangle"; + osc.frequency.setValueAtTime(freq, t); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(gain, t + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + osc.connect(g).connect(audio.destination); + osc.start(t); + osc.stop(t + dur + 0.01); + } + } catch {} + } + + function resultInfo(result) { + if (!result || !result.over) return { title: "", sub: "" }; + const winner = result.winner === "w" ? "Weiß" : result.winner === "b" ? "Schwarz" : null; + switch (result.reason) { + case "checkmate": return { title: "Matt!", sub: `${winner} gewinnt.` }; + case "stalemate": return { title: "Patt", sub: "Keine legalen Züge – Remis." }; + case "fifty": return { title: "Remis", sub: "50 Züge ohne Bauernzug oder Schlag." }; + case "repetition": return { title: "Remis", sub: "Dreifache Stellungswiederholung." }; + case "material": return { title: "Remis", sub: "Unzureichendes Material." }; + case "leave": return { title: "Partie beendet", sub: result.winner ? `${winner} gewinnt – der Gegner hat den Raum verlassen.` : "Der Gegner hat den Raum verlassen." }; + case "resign": return { title: "Aufgegeben", sub: winner ? `${winner} gewinnt.` : "" }; + default: return { title: "Partie beendet", sub: "" }; + } + } + + window.SchachView = { + PIECES, + drawPiece, + drawBoard, + drawCoords, + squareRect, + indexAt, + rowCol, + Board, + injectStyle, + renderMoves, + showPromotion, + showResult, + playSound, + resultInfo, + rgb, + }; +})(); diff --git a/games/schach.js b/games/schach.js new file mode 100644 index 0000000..4badbd9 --- /dev/null +++ b/games/schach.js @@ -0,0 +1,374 @@ +// Schach — gegen die KI (drei Stufen) oder zu zweit am selben Gerät. +// Komplette Regeln (Rochade, en passant, Umwandlung, Remis-Regeln) kommen aus +// schach-engine.js; das Brett zeichnet schach-view.js. + +const SAVED = loadSettings("schach_state"); +let mode = SAVED.mode === "local" ? "local" : "ai"; +let level = [1, 2, 3].includes(SAVED.level) ? SAVED.level : 2; +let humanColor = SAVED.side === "b" ? "b" : "w"; +let soundOn = SAVED.sound !== false; + +initGame({ + title: "Schach", + tag: "Matt · Rochade · en passant", + presets: [ + { label: "KI · Mittel · Weiß", values: [0, 2, 0] }, + { label: "KI · Locker · Weiß", values: [0, 1, 0] }, + { label: "KI · Stark · Weiß", values: [0, 3, 0] }, + { label: "KI · Mittel · Schwarz", values: [0, 2, 1] }, + { label: "Lokal · 2 Spieler", values: [1, 2, 0] }, + ], + buttons: [ + { id: "btn-new", label: "Neue Partie", primary: true, onClick: () => startNew() }, + { id: "btn-flip", label: "Brett drehen", onClick: () => toggleFlip() }, + { id: "btn-sound", label: "Sound an", onClick: () => toggleSound() }, + ], + sliders: [], + onPreset: ([m, l, s]) => { + mode = m === 1 ? "local" : "ai"; + level = [1, 2, 3].includes(l) ? l : 2; + humanColor = s === 1 ? "b" : "w"; + saveState(); + syncPreset(); + startNew(); + }, + onSlider: () => {}, + onClamp: () => {}, + getSliderValues: () => ({}), + info: { + anim: infoAnim, + title: "So wird gespielt", + text: [ + "Tippe oder klicke eine eigene Figur an und dann das Zielfeld – oder ziehe sie mit der Maus. Punkte zeigen legale Züge, ein Ring bedeutet Schlagen. Der letzte Zug und ein Schach sind farbig markiert. Rochade, en passant und Umwandlung funktionieren wie im Turnier.", + "Gegen die KI wählst du oben Stufe und Seite. „Lokal“ ist Zwei-Spieler-Betrieb am selben Gerät; das Brett dreht sich automatisch zum Spieler, der am Zug ist. Tasten: N neue Partie · F Brett drehen · Esc Auswahl aufheben.", + ], + }, +}); + +let E = null; // engine module, loaded asynchronously +let board = null; +let started = false; +let gameToken = 0; +let pos = null; +let sans = []; +let keys = []; +let lastMove = null; +let result = null; +let thinking = false; +let flipped = humanColor === "b"; +let moveBody = null; +let resultEl = null; + +const engineReady = import("./schach-engine.js") + .then((m) => { + E = m; + if (started) initBoard(); + }) + .catch((err) => { + console.error(err); + setStatus("Engine konnte nicht geladen werden", "dead"); + }); + +function saveState() { + saveSettings("schach_state", { mode, level, side: humanColor, sound: soundOn }); +} + +function syncPreset() { + const sel = document.getElementById("preset-select"); + if (!sel) return; + const key = [mode === "local" ? 1 : 0, level, humanColor === "b" ? 1 : 0].join(","); + for (let i = 0; i < sel.options.length; i++) { + if (sel.options[i].value === key) { sel.selectedIndex = i; return; } + } +} + +function setup() { + const cnv = createCanvas(480, 480); + cnv.parent("canvas-wrap"); + frameRate(60); + SchachView.injectStyle(); + buildMoveList(); + syncPreset(); + updateSoundButton(); + started = true; + if (E) initBoard(); + else setStatus("Lade Partie …", null); +} + +function initBoard() { + if (board) return; + board = new SchachView.Board(window, E, { + x: 20, y: 20, size: 440, + flipped, + canMove: canHumanMove, + onMove: onBoardMove, + play: (kind) => { if (soundOn) SchachView.playSound(kind); }, + choosePromotion: (color) => SchachView.showPromotion(document.getElementById("canvas-wrap"), color), + }); + updateFlipButton(); + startNew(); +} + +function canHumanMove() { + if (!E || !pos || result || thinking) return false; + if (mode === "local") return true; + return pos.turn === humanColor; +} + +function startNew() { + if (!E || !board) return; + gameToken++; + pos = E.parseFen(E.START_FEN); + sans = []; + keys = [E.positionKey(pos)]; + lastMove = null; + result = null; + thinking = false; + removeResult(); + flipped = mode === "local" ? false : humanColor === "b"; + board.setCanMove(canHumanMove); + board.setFlip(flipped); + board.setPosition(pos, { last: null }); + updateFlipButton(); + renderMoveList(); + updateStatus(); + maybeAiMove(); +} + +function onBoardMove(move) { + if (!E || !pos || result) return; + if (mode === "ai" && pos.turn !== humanColor) return; + applyMove(move); +} + +function applyMove(move) { + const mover = pos.turn; + const san = E.moveToSan(pos, move); + const capture = !!move.captured; + const next = E.makeMove(pos, move); + + sans.push(san); + pos = next; + keys.push(E.positionKey(pos)); + lastMove = { from: move.from, to: move.to }; + const status = E.gameStatus(pos, keys); + result = status.over ? status : null; + + if (mode === "local") flipped = pos.turn === "b"; + board.setFlip(flipped); + board.setPosition(pos, { last: lastMove, animate: { from: move.from, to: move.to } }); + + if (capture) { + const c = board.centerOf(move.to); + spawnBurst(c.x, c.y, { + count: 12, speed: [1.2, 3.4], life: [70, 150], size: [1.2, 2.8], + col: PAL.rose, drag: 0.94, decay: 7, + }); + } + + renderMoveList(); + updateStatus(); + updateFlipButton(); + + if (result) { + SchachView.playSound("end"); + showResultOverlay(); + } else if (E.inCheck(pos)) { + if (soundOn) SchachView.playSound("check"); + } else if (soundOn) { + SchachView.playSound(capture ? "capture" : "move"); + } + + maybeAiMove(mover); +} + +function maybeAiMove() { + if (mode !== "ai" || !pos || result || pos.turn === humanColor) return; + const token = ++gameToken; + thinking = true; + updateStatus(); + setTimeout(() => { + if (token !== gameToken || !pos || result) return; + const move = E.chooseMove(pos, level); + thinking = false; + if (!move) return; + applyMove(move); + }, 240); +} + +function updateStatus() { + if (!E || !pos) return; + if (result) { + const info = SchachView.resultInfo(result); + let state = null; + if (mode === "ai" && result.winner) state = result.winner === humanColor ? "solved" : "dead"; + setStatus(info.title + (info.sub ? " · " + info.sub : ""), state); + return; + } + const check = E.inCheck(pos) ? " · Schach!" : ""; + if (mode === "ai") { + setStatus(thinking || pos.turn !== humanColor ? "KI denkt …" : "Du bist am Zug" + check, null); + } else { + setStatus((pos.turn === "w" ? "Weiß" : "Schwarz") + " am Zug" + check, null); + } +} + +function showResultOverlay() { + removeResult(); + const info = SchachView.resultInfo(result); + resultEl = SchachView.showResult(document.getElementById("canvas-wrap"), { + title: info.title, + sub: info.sub, + actionLabel: "Neue Partie", + onAction: startNew, + onClose: () => { resultEl = null; }, + }); +} + +function removeResult() { + if (resultEl) { resultEl.remove(); resultEl = null; } +} + +function buildMoveList() { + if (document.getElementById("schach-moves")) return; + const side = document.querySelector(".stage-side"); + if (!side) return; + const wrap = document.createElement("div"); + wrap.className = "schach-moves"; + wrap.id = "schach-moves"; + wrap.innerHTML = `
Züge
`; + side.appendChild(wrap); + moveBody = wrap.querySelector("#schach-moves-body"); +} + +function renderMoveList() { + SchachView.renderMoves(moveBody, sans); + const c = document.getElementById("schach-move-count"); + if (c) { + const n = Math.ceil(sans.length / 2); + c.textContent = n ? (n === 1 ? "1 Zug" : n + " Züge") : ""; + } +} + +function updateSoundButton() { + const b = document.getElementById("btn-sound"); + if (!b) return; + b.textContent = soundOn ? "Sound: an" : "Sound: aus"; + setButtonActive("btn-sound", soundOn); +} + +function toggleSound() { + soundOn = !soundOn; + saveState(); + updateSoundButton(); + if (soundOn) SchachView.playSound("select"); +} + +function updateFlipButton() { + const b = document.getElementById("btn-flip"); + if (!b) return; + b.style.display = mode === "local" ? "none" : ""; +} + +function toggleFlip() { + flipped = !flipped; + if (board) board.setFlip(flipped); +} + +function draw() { + background(...PAL.board); + if (!board) { + noStroke(); + fill(...PAL.muted); + textAlign(CENTER, CENTER); + textSize(14); + text("Lade Partie …", width / 2, height / 2); + return; + } + board.draw(); + drawParticles(); +} + +// ---- input ---- + +function insideCanvas() { + return mouseX >= 0 && mouseY >= 0 && mouseX <= width && mouseY <= height; +} + +function mousePressed() { + if (!board || !insideCanvas()) return; + board.press(mouseX, mouseY); + return false; +} + +function mouseDragged() { + if (board && insideCanvas()) board.drag(mouseX, mouseY); +} + +function mouseReleased() { + if (board) board.release(mouseX, mouseY); +} + +function keyPressed() { + const tag = document.activeElement && document.activeElement.tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + if (key === "n" || key === "N") { startNew(); return false; } + if (key === "f" || key === "F") { if (mode !== "local") toggleFlip(); return false; } + if (key === "Escape") { + if (board) { board.selected = null; board.legal = []; board.dragging = false; } + return false; + } +} + +// ---- info modal animation: a knight develops with a short pulse ---- + +function infoAnim(p, w, h, frame) { + p.background(...PAL.board); + const size = 224; + const x = (w - size) / 2; + const y = (h - size) / 2; + if (!E) { + p.noStroke(); + p.fill(...PAL.muted); + p.textAlign(p.CENTER, p.CENTER); + p.textSize(13); + p.text("Nf3", w / 2, h / 2); + return; + } + + const cycle = 160; + const t = frame % cycle; + const moveT = Math.max(0, Math.min(1, (t - 36) / 26)); + const base = E.parseFen("rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 1"); + const after = E.parseFen("rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2"); + + let shown = after; + if (moveT < 1) { + shown = { ...after, board: after.board.slice() }; + shown.board[41] = null; // f3 — the travelling knight + } + const last = t < 96 ? { from: 62, to: 41 } : null; + SchachView.drawBoard(p, { + x, y, size, + pos: shown, + flipped: false, + selected: t < 36 ? 62 : null, + last, + check: null, + }); + + if (moveT < 1) { + const a = SchachView.squareRect(x, y, size, 62, false); + const b = SchachView.squareRect(x, y, size, 41, false); + const k = moveT < 0.5 ? 2 * moveT * moveT : -1 + (4 - 2 * moveT) * moveT; + const cx = a.x + a.cell / 2 + (b.x - a.x) * k; + const cy = a.y + a.cell / 2 + (b.y - a.y) * k; + SchachView.drawPiece(p, "N", cx, cy, a.cell); + } + + p.noStroke(); + p.fill(...PAL.muted); + p.textAlign(p.CENTER, p.TOP); + p.textSize(11); + p.text("Nf3", w / 2, y + size + 6); +} diff --git a/hub-games/schach.js b/hub-games/schach.js new file mode 100644 index 0000000..a718061 --- /dev/null +++ b/hub-games/schach.js @@ -0,0 +1,521 @@ +// Schach für den Mehrspieler-Hub — zwei Spieler am Brett, alle anderen schauen zu. +// +// Der Host ist Schiedsrichter: Gäste schicken nur ihren Zug (UCI, z. B. "e2e4"), +// der Host prüft ihn gegen die legalen Züge und schickt die komplette Stellung +// zurück. So kann niemand am Brett vorbei ziehen, und nach einem Reload genügt +// der letzte "state", um die Partie wiederherzustellen. Revanche tauscht die +// Farben; wer zusieht, kann danach nachrücken, wenn ein Platz frei wird. + +import { shell } from "../hub-kit.js"; +import * as E from "../games/schach-engine.js"; + +const V = window.SchachView; +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +const COLOR = ["Weiß", "Schwarz"]; +const SOUND = (() => { + try { return JSON.parse(localStorage.getItem("schach_state") || "{}").sound !== false; } catch { return true; } +})(); + +// A state that came over the wire is rebuilt from scratch before it is used. +function clean(d, ids) { + if (!d || typeof d !== "object") return null; + let pos; + try { pos = E.parseFen(typeof d.fen === "string" ? d.fen : E.START_FEN); } catch { return null; } + if (!pos || !Array.isArray(pos.board)) return null; + + const seat = (x) => (x === null || ids.has(x) ? x : null); + if (!Array.isArray(d.seats) || d.seats.length !== 2) return null; + const seats = [seat(d.seats[0]), seat(d.seats[1])]; + const square = (v) => Number.isInteger(v) && v >= 0 && v < 64 ? v : null; + const last = d.last && square(d.last.from) !== null && square(d.last.to) !== null + ? { from: d.last.from, to: d.last.to } : null; + const r = d.result && typeof d.result === "object" ? d.result : null; + const reason = r && ["checkmate", "stalemate", "fifty", "repetition", "material", "resign", "leave"].includes(r.reason) ? r.reason : null; + return { + rev: Number.isInteger(d.rev) && d.rev >= 0 ? d.rev : 0, + round: Number.isInteger(d.round) && d.round >= 1 ? d.round : 1, + seats, + fen: E.toFen(pos), + sans: Array.isArray(d.sans) ? d.sans.filter((s) => typeof s === "string").slice(0, 600).map((s) => s.slice(0, 12)) : [], + last, + phase: d.phase === "over" ? "over" : "play", + result: r && r.over !== false && (reason || r.winner === "w" || r.winner === "b") + ? { over: true, winner: r.winner === "w" || r.winner === "b" ? r.winner : null, reason, by: seat(r.by) } + : null, + check: !!d.check, + cap: !!d.cap, + gone: Array.isArray(d.gone) ? d.gone.filter((x) => ids.has(x)) : [], + }; +} + +export default { + min: 2, max: 6, + + mount(ctx) { + const kit = shell(ctx, { title: "Schach" }), root = kit.body; + V.injectStyle(); + + root.innerHTML = `
+
+ +
`; + + const $ = (s) => root.querySelector(s); + const wrap = $(".schach-boardwrap"), canvasHost = $(".schach-canvas"); + const statusEl = $(".schach-status"), statusTxt = $(".schach-status .txt"); + const playersEl = $(".schach-players"), movesBody = $(".schach-moves-body"), movesCnt = $(".schach-moves .cnt"); + const noteEl = $(".schach-note"), resultBtn = $("[data-act=result]"), resignBtn = $("[data-act=resign]"); + const names = new Map(ctx.players.map((p) => [p.id, p.name])); + const seen = new Set(ctx.players.map((p) => p.id)); + const me = () => ctx.me.id; + const nm = (id) => esc(names.get(id) || "?") + (id === me() ? " (du)" : ""); + + let st = null; // authoritative snapshot (host) / last received snapshot (guest) + let cur = null; // parsed position that belongs to st + let keys = []; // repetition history (host only; rebuilt after a reload) + let board = null, pf = null; + let animateNext = null; + let flipPref = null; + let lastSeatKey = ""; + let boardRev = -1; + let seenSans = null, seenPhase = null; + let resultKey = ""; + let restoreTimer = 0; + let resignTimer = 0; + + const mySeat = () => (st ? st.seats.indexOf(me()) : -1); + const seatId = (color) => st && st.seats[color === "w" ? 0 : 1]; + const isMyTurn = () => !!cur && st && st.phase === "play" && mySeat() === (cur.turn === "w" ? 0 : 1); + + function canMove() { + return !!cur && !!st && st.phase === "play" && mySeat() >= 0 && mySeat() === (cur.turn === "w" ? 0 : 1); + } + + function orient() { + if (flipPref !== null) return flipPref; + return mySeat() === 1; + } + + // ---- state changes (host) ---- + + function makeState() { + const ids = ctx.players.map((p) => p.id); + const host = ids.includes(ctx.hostId) ? ctx.hostId : ids[0]; + const other = ids.find((id) => id !== host) || null; + return { + rev: (st ? st.rev : 0) + 1, + round: 1, + seats: [host, other], + fen: E.START_FEN, + sans: [], + last: null, + phase: "play", + result: null, + check: false, + cap: false, + gone: [], + }; + } + + function adopt(next) { + const before = st; + const grew = !!before && next.sans.length > before.sans.length; + st = next; + cur = E.parseFen(st.fen); + keys = [E.positionKey(cur)]; + animateNext = grew && st.last ? st.last : null; + if (before && next.round !== before.round) seenSans = null; + render(); + } + + function fillSeats() { + if (!st) return; + const taken = st.seats.filter(Boolean); + const pool = ctx.players.map((p) => p.id).filter((id) => !st.gone.includes(id) && !taken.includes(id)); + for (let k = 0; k < 2; k++) { + if (!st.seats[k] && pool.length) { st.seats[k] = pool.shift(); taken.push(st.seats[k]); } + } + } + + function push() { + if (!st || !ctx.isHost) return; + if (cur) { + st.fen = E.toFen(cur); + st.check = E.inCheck(cur); + } + ctx.send("state", st); + render(); + } + + function moveOnHost(uci, from) { + if (!ctx.isHost || !st || !cur || st.phase !== "play") return; + if (seatId(cur.turn) !== from) return; + const move = E.uciToMove(cur, String(uci || "")); + if (!move) return; + const san = E.moveToSan(cur, move); + const cap = !!move.captured; + const next = E.makeMove(cur, move); + keys.push(E.positionKey(next)); + st.sans.push(san); + st.last = { from: move.from, to: move.to }; + st.cap = cap; + cur = next; + const status = E.gameStatus(cur, keys); + if (status.over) { st.phase = "over"; st.result = { over: true, winner: status.winner, reason: status.reason, by: null }; } + st.rev++; + animateNext = st.last; + push(); + } + + function newRound() { + if (!ctx.isHost || !st || st.phase !== "over") return; + fillSeats(); + if (!st.seats[0] || !st.seats[1]) { render(); return; } + st.seats = [st.seats[1], st.seats[0]]; + st.round++; + st.fen = E.START_FEN; + st.sans = []; + st.last = null; + st.phase = "play"; + st.result = null; + st.check = false; + st.cap = false; + st.rev++; + cur = E.parseFen(st.fen); + keys = [E.positionKey(cur)]; + animateNext = null; + flipPref = null; + push(); + } + + function resign(id) { + if (!ctx.isHost || !st || !cur || st.phase !== "play") return; + const k = st.seats.indexOf(id); + if (k < 0) return; + st.phase = "over"; + st.result = { over: true, winner: k === 0 ? "b" : "w", reason: "resign", by: id }; + st.rev++; + push(); + } + + // ---- rendering ---- + + function resultTitle(r) { + if (!r) return "Partie beendet"; + return V.resultInfo(r).title || "Partie beendet"; + } + + function overText(r) { + if (!r) return "Partie beendet"; + const winner = r.winner ? nm(seatId(r.winner)) : ""; + if (r.reason === "checkmate") return `${winner} gewinnt.`; + if (r.reason === "resign") return `${nm(r.by)} gibt auf – ${winner} gewinnt.`; + if (r.reason === "leave") return r.winner ? `${nm(r.by)} hat den Raum verlassen – ${winner} gewinnt.` : "Partie beendet."; + return V.resultInfo(r).sub || ""; + } + + function renderPlayers() { + if (!st) { playersEl.innerHTML = ""; return; } + playersEl.innerHTML = [0, 1].map((k) => { + const id = st.seats[k]; + const color = k === 0 ? "w" : "b"; + const active = st.phase === "play" && cur && cur.turn === color; + const gone = id && st.gone.includes(id); + return `
+ + ${id ? nm(id) : "frei"} + ${COLOR[k]} +
`; + }).join(""); + } + + function render() { + // names of players who already left stay available for the result text + for (const p of ctx.players) { names.set(p.id, p.name); seen.add(p.id); } + if (st && st.result && st.result.by && names.has(st.result.by) === false) names.set(st.result.by, "?"); + + if (!st || !cur) { + statusTxt.textContent = ctx.isHost ? "Partie wird vorbereitet …" : "Warte auf den Host …"; + statusEl.classList.remove("live", "over"); + playersEl.innerHTML = ""; + V.renderMoves(movesBody, []); + movesCnt.textContent = ""; + noteEl.textContent = ""; + resultBtn.hidden = true; + resignBtn.hidden = true; + resetResign(); + if (board) board.setInteractive(false); + return; + } + + const seatKey = st.seats.join("|"); + if (seatKey !== lastSeatKey) { lastSeatKey = seatKey; flipPref = null; } + + const seat = mySeat(); + const other = seat === 0 ? 1 : 0; + const myTurn = isMyTurn(); + const check = st.phase === "play" && st.check ? " · Schach!" : ""; + + if (st.phase === "over") { + statusTxt.textContent = resultTitle(st.result) + (overText(st.result) ? " · " + overText(st.result) : ""); + statusEl.classList.remove("live"); + statusEl.classList.add("over"); + } else if (seat < 0) { + statusTxt.textContent = `${nm(seatId(cur.turn))} (${COLOR[cur.turn === "w" ? 0 : 1]}) ist am Zug` + check; + statusEl.classList.remove("live", "over"); + } else if (myTurn) { + statusTxt.textContent = "Du bist am Zug" + check; + statusEl.classList.remove("over"); + statusEl.classList.add("live"); + } else { + statusTxt.textContent = `${nm(seatId(cur.turn))} ist am Zug` + check; + statusEl.classList.remove("live", "over"); + } + + renderPlayers(); + V.renderMoves(movesBody, st.sans); + const n = Math.ceil(st.sans.length / 2); + movesCnt.textContent = n ? (n === 1 ? "1 Zug" : n + " Züge") : ""; + + // sound and the result overlay are driven by what actually changed + const grew = seenSans !== null && st.sans.length > seenSans; + if (SOUND && grew) { + if (st.phase === "over") V.playSound("end"); + else if (st.check) V.playSound("check"); + else V.playSound(st.cap ? "capture" : "move"); + } else if (SOUND && seenPhase === "play" && st.phase === "over" && !grew) { + V.playSound("end"); + } + seenSans = st.sans.length; + seenPhase = st.phase; + + const rKey = st.phase === "over" && st.result ? `${st.round}|${st.result.reason}|${st.result.winner}|${st.result.by}` : ""; + if (!rKey) { + wrap.querySelectorAll(".schach-overlay").forEach((el) => el.remove()); + resultKey = ""; + } else if (rKey !== resultKey) { + resultKey = rKey; + openResult(); + } + resultBtn.hidden = !rKey || !!wrap.querySelector(".schach-overlay"); + resignBtn.hidden = st.phase !== "play" || seat < 0; + if (resignBtn.hidden) resetResign(); + + // notes + buttons + if (st.phase === "play" && seat < 0) noteEl.textContent = "Du schaust zu."; + else if (st.phase === "play" && !st.seats[other]) noteEl.textContent = "Warte auf einen Gegner …"; + else if (st.phase === "over" && !ctx.isHost && seat >= 0) noteEl.textContent = "Der Host kann eine Revanche starten – oder du fragst danach."; + else if (st.phase === "over" && !ctx.isHost && seat < 0) noteEl.textContent = "Warte auf die Revanche …"; + else noteEl.textContent = ""; + + if (!board) return; + board.setCanMove(canMove); + board.setInteractive(st.phase === "play" && seat >= 0 && myTurn); + board.setFlip(orient()); + if (boardRev !== st.rev) { + boardRev = st.rev; + board.setPosition(cur, { last: st.last, animate: animateNext }); + animateNext = null; + } + } + + function openResult() { + if (!st || !st.result) return; + const seat = mySeat(); + const actionLabel = ctx.isHost ? "Nochmal" : seat >= 0 ? "Revanche" : ""; + V.showResult(wrap, { + title: resultTitle(st.result), + sub: overText(st.result), + actionLabel, + onAction: () => { + wrap.querySelectorAll(".schach-overlay").forEach((el) => el.remove()); + if (!ctx.isHost && seat >= 0 && st && st.phase === "over") { + ctx.send("again", null, ctx.hostId); + noteEl.textContent = "Revanche angefragt – warte auf den Host …"; + } else if (ctx.isHost) newRound(); + }, + onClose: () => { + resultBtn.hidden = false; + resultBtn.textContent = "Ergebnis"; + }, + }); + resultBtn.hidden = true; + resultBtn.textContent = "Ergebnis"; + } + + // ---- board (p5 instance; drawn from PAL so every design works) ---- + + if (typeof window.p5 === "function") { + pf = new window.p5((p) => { + p.setup = () => { + const c = p.createCanvas(480, 480); + c.parent(canvasHost); + p.frameRate(60); + board = new V.Board(p, E, { + x: 20, y: 20, size: 440, + canMove, + onMove: (move) => { + if (!st) return; + const uci = E.moveToUci(move); + if (ctx.isHost) moveOnHost(uci, me()); + else ctx.send("move", uci, ctx.hostId); + }, + play: (kind) => { if (SOUND) V.playSound(kind); }, + choosePromotion: (color) => V.showPromotion(wrap, color), + }); + bindPointer(c.elt); + render(); + }; + p.draw = () => { + p.background(...PAL.board); + if (board) board.draw(); + }; + }, canvasHost); + } + + function bindPointer(canvas) { + let active = null; + const at = (e) => { + const r = canvas.getBoundingClientRect(); + return { x: (e.clientX - r.left) * 480 / Math.max(1, r.width), y: (e.clientY - r.top) * 480 / Math.max(1, r.height) }; + }; + canvas.style.touchAction = "none"; + canvas.addEventListener("pointerdown", (e) => { + if (!board || active !== null || (e.pointerType === "mouse" && e.button !== 0)) return; + active = e.pointerId; + try { canvas.setPointerCapture(e.pointerId); } catch {} + const q = at(e); + board.press(q.x, q.y); + e.preventDefault(); + }); + canvas.addEventListener("pointermove", (e) => { + if (!board || e.pointerId !== active) return; + const q = at(e); + board.drag(q.x, q.y); + e.preventDefault(); + }); + const end = (e) => { + if (!board || e.pointerId !== active) return; + active = null; + const q = at(e); + board.release(q.x, q.y); + }; + canvas.addEventListener("pointerup", end); + canvas.addEventListener("pointercancel", end); + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + } + + function resetResign() { + clearTimeout(resignTimer); + resignBtn.dataset.confirm = ""; + resignBtn.textContent = "Aufgeben"; + } + + // ---- input ---- + + root.addEventListener("click", (e) => { + const b = e.target.closest("[data-act]"); + if (!b) return; + if (b.dataset.act === "flip") { + flipPref = !orient(); + if (board) board.setFlip(orient()); + } else if (b.dataset.act === "resign") { + if (b.dataset.confirm === "1") { + resetResign(); + if (ctx.isHost) resign(me()); + else ctx.send("resign", null, ctx.hostId); + } else { + b.dataset.confirm = "1"; + b.textContent = "Wirklich aufgeben?"; + clearTimeout(resignTimer); + resignTimer = setTimeout(resetResign, 3200); + } + } else if (b.dataset.act === "result") { + if (st && st.result) { resultKey = ""; openResult(); resultBtn.hidden = true; } + } + }); + + // ---- network ---- + + ctx.onMessage("move", ({ data, from }) => { if (ctx.isHost) moveOnHost(data, from); }); + ctx.onMessage("again", ({ from }) => { + if (!ctx.isHost || !st || st.phase !== "over") return; + if (!st.seats.includes(from)) return; + newRound(); + }); + ctx.onMessage("resign", ({ from }) => { if (ctx.isHost) resign(from); }); + ctx.onMessage("sync", () => { if (ctx.isHost && st) push(); }); + ctx.onMessage("restore", () => { if (!ctx.isHost && st) ctx.send("state", st, ctx.hostId); }); + ctx.onMessage("state", ({ data, from }) => { + if (from === ctx.hostId) { + const n = clean(data, seen); + if (n && (!st || n.rev >= st.rev)) adopt(n); + return; + } + // host reloaded: until it has a state, it may take the snapshot of a player who stayed + if (ctx.isHost && !st) { + const n = clean(data, seen); + if (n) { adopt(n); push(); } + } + }); + + // leave events are applied by everybody the same way, so a host change cannot lose the result + ctx.onEvent("leave", ({ id }) => { + if (!st || !seen.has(id)) return; + const k = st.seats.indexOf(id); + if (!st.gone.includes(id)) st.gone.push(id); + if (k >= 0) { + st.seats[k] = null; + if (st.phase === "play" && cur) { + st.phase = "over"; + st.result = { over: true, winner: k === 0 ? "b" : "w", reason: "leave", by: id }; + st.check = false; + st.rev++; + } + } + if (ctx.isHost) { fillSeats(); push(); } else render(); + }); + ctx.onEvent("join", () => { + for (const p of ctx.players) { names.set(p.id, p.name); seen.add(p.id); } + if (ctx.isHost) { fillSeats(); push(); } else render(); + }); + ctx.onEvent("host", ({ you }) => { + if (you) { fillSeats(); push(); } + }); + + // ---- start ---- + + function go() { + if (ctx.isHost) { + if (st) { push(); return; } + ctx.send("restore"); // after a reload the seat that stayed has the snapshot + restoreTimer = setTimeout(() => { + if (st) return; + st = makeState(); + cur = E.parseFen(st.fen); + keys = [E.positionKey(cur)]; + boardRev = -1; + push(); + }, 700); + } else { + render(); + ctx.send("sync", null, ctx.hostId); + } + } + + return { + go, + destroy() { + clearTimeout(restoreTimer); + clearTimeout(resignTimer); + if (pf) { try { pf.remove(); } catch {} } + }, + }; + }, +}; diff --git a/hub.js b/hub.js index b20c3fe..93e9d03 100644 --- a/hub.js +++ b/hub.js @@ -27,6 +27,7 @@ const MP_GAMES = [ { id: "clickrace", title: "Klick-Rennen", desc: "15 Sekunden, wer klickt mehr?", min: 2, max: 6, preview: (p, w, h) => drawClickRace(p, w, h), load: () => import("./hub-games/clickrace.js") }, { id: "kritzel", title: "Kritzel-Revier", desc: "Echtzeit: kritzle das größte Revier – Flecken blockieren, Sterne geben Turbo", min: 2, max: 4, preview: (p, w, h) => drawKritzel(p, w, h), load: () => import("./hub-games/kritzel.js") }, { id: "battleship", title: "Schiffe versenken", desc: "Flotte aufstellen, Koordinaten feuern – Duell mit geheimer Flotte", min: 2, max: 2, preview: (p, w, h) => drawBattleship(p, w, h), load: () => import("./hub-games/battleship.js") }, + { id: "schach", title: "Schach", desc: "Zwei Spieler am Brett, der Rest schaut zu – mit Revanche und Farbtausch", min: 2, max: 6, preview: (p, w, h) => drawSchach(p, w, h), load: () => import("./hub-games/schach.js") }, ]; window.MP_GAMES = MP_GAMES; // multiplayer.js reads the game titles from here diff --git a/index.html b/index.html index ff7a1ce..687fce0 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,7 @@ + diff --git a/manifest.js b/manifest.js index 1519b75..e29ba56 100644 --- a/manifest.js +++ b/manifest.js @@ -25,6 +25,7 @@ const GAMES = [ { id: "kart", title: "Kart-Rennen", desc: "drift · turbo · Bestzeit", preview: drawKart }, { id: "pistenjagd", title: "Pistenjagd", desc: "Ski · Snowboard · Schanzen", preview: drawPistenjagd }, { id: "battleship", title: "Schiffe versenken", desc: "Flotte aufstellen und feuern, gegen den Computer · drei Stufen", preview: drawBattleship }, + { id: "schach", title: "Schach", desc: "Matt · Rochade · en passant", preview: drawSchach }, ]; // ---- preview renderers ---- @@ -1324,3 +1325,34 @@ function drawPistenjagd(p, w, h) { p.fill(10, 14, 30, 120); p.rect(w - 20, 70, 6, h - 140, 3); p.fill(...PAL.sky); p.circle(w - 17, 70 + (h - 140) * 0.66, 9); p.fill(...PAL.rose); p.circle(w - 17, 70 + (h - 140) * 0.6, 12); } + +// Schach: a quiet Italian middlegame, white knight selected, black's last move marked. +function drawSchach(p, w, h) { + previewBg(p, w, h); + if (!window.SchachView) return; + const fen = "r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPP1PPP/R1BQK2R w KQkq - 6 5"; + const board = new Array(64).fill(null); + let i = 0; + for (const ch of fen.split(" ")[0]) { + if (ch === "/") continue; + if (ch >= "1" && ch <= "8") i += Number(ch); + else board[i++] = ch; + } + const size = 300; + SchachView.drawBoard(p, { + x: (w - size) / 2, + y: (h - size) / 2, + size, + flipped: false, + pos: { board, turn: "w" }, + selected: 41, // Nf3 + legal: [{ to: 35 }, { to: 28 }, { to: 30 }, { to: 31 }], + last: { from: 6, to: 21 }, // …Nf6 + check: null, + }); + p.noStroke(); + p.fill(...PAL.muted); + p.textAlign(p.CENTER, p.CENTER); + p.textSize(11); + p.text("Weiß am Zug", w / 2, (h + size) / 2 + 14); +}