From: Benjamin Auder Date: Thu, 11 Nov 2021 18:22:59 +0000 (+0100) Subject: First commit X-Git-Url: https://git.auder.net/variants/current/doc/scripts/css/pieces/cb.svg?a=commitdiff_plain;h=41534b92f0bcfc8ef5f58d8040706a5e7ce088c6;p=xogo.git First commit --- 41534b92f0bcfc8ef5f58d8040706a5e7ce088c6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..490dce9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.swp +*~ +*.min.js +*.min.css +/node_modules/ +/assets/ +/assets.zip +/.pid +/parameters.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd404c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC license https://opensource.org/licenses/ISC + +Copyright (C) 2021-2022 Benjamin Auder + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f81c9d --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# xogo.live + +Simplified version of old vchess.club, to focus on the essential : the game. + +## Requirements (dev) + +Global npm install: nodemon, livereload.
+A static web server like "php -S localhost:8000". + +## Usage + +```wget https://xogo.live/assets.zip && unzip assets.zip```
+Rename parameters.js.dist → parameters.js, and edit file.
+```npm i``` + +```./start.sh```
+```php -S localhost:PORT``` (or other static server)
+...
+```./stop.sh``` diff --git a/TODO b/TODO new file mode 100644 index 0000000..c0761e6 --- /dev/null +++ b/TODO @@ -0,0 +1,8 @@ +Dark Racing Kings ? +Checkered-Teleport ? + +// TODO: playvisual enlightened test (pas forcément mode dark...) + +// rem ref to bordContainer --> rethink CSS design... + +// TODO: Otage, Emergo, Pacosako : fonction "buildPiece(arg1, arg2)" returns HTML element with 2 SVG or SVG + number diff --git a/app.js b/app.js new file mode 100644 index 0000000..f897062 --- /dev/null +++ b/app.js @@ -0,0 +1,424 @@ +let $ = document; //shortcut + +/////////////////// +// Initialisations + +// https://stackoverflow.com/a/27747377/12660887 +function dec2hex (dec) { return dec.toString(16).padStart(2, "0") } +function generateId (len) { + var arr = new Uint8Array((len || 40) / 2) + window.crypto.getRandomValues(arr) + return Array.from(arr, dec2hex).join('') +} + +// Populate variants dropdown list +let dropdown = $.getElementById("selectVariant"); +dropdown[0] = new Option("? ? ?", "_random", true, true); +dropdown[0].title = "Random variant"; +for (let i = 0; i < variants.length; i++) { + let newOption = new Option( + variants[i].disp || variants[i].name, variants[i].name, false, false); + newOption.title = variants[i].desc; + dropdown[dropdown.length] = newOption; +} + +// Ensure that I have a socket ID and a name +if (!localStorage.getItem("sid")) + localStorage.setItem("sid", generateId(8)); +if (!localStorage.getItem("name")) + localStorage.setItem("name", "@non" + generateId(4)); +const sid = localStorage.getItem("sid"); +$.getElementById("myName").value = localStorage.getItem("name"); + +///////// +// Utils + +function setName() { + localStorage.setItem("name", $.getElementById("myName").value); +} + +// Turn a "tab" on, and "close" all others +function toggleVisible(element) { + for (elt of document.querySelectorAll('body > div')) { + if (elt.id != element) elt.style.display = "none"; + else elt.style.display = "block"; + } +} + +let seek_vname; +function seekGame() { + seek_vname = $.getElementById("selectVariant").value; + send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")}); + toggleVisible("pendingSeek"); +} +function cancelSeek() { + send("cancelseek", {vname: seek_vname}); + toggleVisible("newGame"); +} + +function sendRematch() { + send("rematch", { gid: gid }); + toggleVisible("pendingRematch"); +} +function cancelRematch() { + send("norematch", { gid: gid }); + toggleVisible("newGame"); +} + +// Play with a friend (or not ^^) +function showNewGameForm() { + const vname = $.getElementById("selectVariant").value; + if (vname == "_random") alert("Select a variant first"); + else { + $.getElementById("gameLink").innerHTML = ""; + $.getElementById("selectColor").selectedIndex = 0; + toggleVisible("newGameForm"); + import(`/variants/${vname}/class.js`).then(module => { + const Rules = module.default; + prepareOptions(Rules); + }); + } +} +function backToNormalSeek() { toggleVisible("newGame"); } + +function toggleStyle(e, word) { + options[word] = !options[word]; + e.target.classList.toggle("highlight-word"); +} + +let options; +function prepareOptions(Rules) { + options = {}; + let optHtml = ""; + for (let select of Rules.Options.select) { + optHtml += ` + + '; + } + for (let check of Rules.Options.check) { + optHtml += ` + + = 1) optHtml += "

"; + for (let style of Rules.Options.styles) { + optHtml += ` + + ${style} + `; + } + if (Rules.Options.styles.length >= 1) optHtml += "

"; + $.getElementById("gameOptions").innerHTML = optHtml; +} + +function getGameLink() { + const vname = $.getElementById("selectVariant").value; + const color = $.getElementById("selectColor").value; + for (const select of $.querySelectorAll("#gameOptions > select")) { + let value = select.value; + if (select.attributes["data-numeric"]) value = parseInt(value, 10); + options[ select.id.split("_")[1] ] = value; + } + for (const check of $.querySelectorAll("#gameOptions > input")) + options[ check.id.split("_")[1] ] = check.checked; + send("creategame", { + vname: vname, + player: { sid: sid, name: localStorage.getItem("name"), color: color }, + options: options + }); +} + +const fillGameInfos = (gameInfos, oppIndex) => { + fetch(`/variants/${gameInfos.vname}/rules.html`) + .then(res => res.text()) + .then(txt => { + let htmlContent = ` +

+ ${gameInfos.vdisp} + vs. ${gameInfos.players[oppIndex].name} +

+
+

`; + htmlContent += + Object.entries(gameInfos.options).map(opt => { + return ( + '' + + (opt[1] === true ? opt[0] : `${opt[0]}:${opt[1]}`) + + '' + ); + }) + .join(", "); + htmlContent += ` +

+
+
+ ${txt} +
+ `; + $.getElementById("gameInfos").innerHTML = htmlContent; + }); +}; + +//////////////// +// Communication + +let socket, gid, attempt = 0; +const autoReconnectDelay = () => { + return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(attempt, 6)]; +}; + +function copyClipboard(msg) { navigator.clipboard.writeText(msg); } +function getWhatsApp(msg) { + return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`; +} + +const tryResumeGame = () => { + attempt = 0; + // If a game is found, resume it: + if (localStorage.getItem("gid")) { + gid = localStorage.getItem("gid"); + send("getgame", { gid: gid }); + } + else { + // If URL indicates "play with a friend", start game: + const hashIdx = document.URL.indexOf('#'); + if (hashIdx >= 0) { + const urlParts = $.URL.split('#'); + gid = urlParts[1]; + send("joingame", { gid: gid, name: localStorage.getItem("name") }); + localStorage.setItem("gid", gid); + history.replaceState(null, '', urlParts[0]); + } + } +}; + +const messageCenter = (msg) => { + const obj = JSON.parse(msg.data); + switch (obj.code) { + // Start new game: + case "gamestart": { + if (!$.hasFocus()) notifyMe("game"); + gid = obj.gid; + initializeGame(obj); + break; + } + // Game vs. friend just created on server: share link now + case "gamecreated": { + const link = `${Params.http_server}/#${obj.gid}`; + $.getElementById("gameLink").innerHTML = ` +

+ WhatsApp + / + ToClipboard +

+

${link}

+ `; + break; + } + // Game vs. friend joined after 1 minute (try again!) + case "jointoolate": + alert("Game no longer available"); + break; + // Get infos of a running game (already launched) + case "gameinfo": + initializeGame(obj); + break; + // Tried to resume a game which is now gone: + case "nogame": + localStorage.removeItem("gid"); + break; + // Receive opponent's move: + case "newmove": + if (!$.hasFocus()) notifyMe("move"); + vr.playReceivedMove(obj.moves, () => { + if (vr.getCurrentScore(obj.moves[obj.moves.length-1]) != "*") { + localStorage.removeItem("gid"); + setTimeout( () => toggleVisible("gameStopped"), 2000 ); + } + else toggleTurnIndicator(true); + }); + break; + // Opponent stopped game (draw, abort, resign...) + case "gameover": + toggleVisible("gameStopped"); + localStorage.removeItem("gid"); + break; + // Opponent cancelled rematch: + case "closerematch": + toggleVisible("newGame"); + break; + } +}; + +const handleError = (err) => { + if (err.code === 'ECONNREFUSED') { + removeAllListeners(); + alert("Server refused connection. Please reload page later"); + } + socket.close(); +}; + +const handleClose = () => { + setTimeout(() => { + removeAllListeners(); + connectToWSS(); + }, autoReconnectDelay()); +}; + +const removeAllListeners = () => { + socket.removeEventListener("open", tryResumeGame); + socket.removeEventListener("message", messageCenter); + socket.removeEventListener("error", handleError); + socket.removeEventListener("close", handleClose); +}; + +const connectToWSS = () => { + socket = + new WebSocket(`${Params.socket_server}${Params.socket_path}?sid=${sid}`); + socket.addEventListener("open", tryResumeGame); + socket.addEventListener("message", messageCenter); + socket.addEventListener("error", handleError); + socket.addEventListener("close", handleClose); + attempt++; +}; +connectToWSS(); + +const send = (code, data) => { + socket.send(JSON.stringify(Object.assign({code: code}, data))); +}; + +/////////// +// Playing + +function toggleTurnIndicator(myTurn) { + let indicator = $.getElementById("chessboard"); + if (myTurn) indicator.style.outline = "thick solid green"; + else indicator.style.outline = "thick solid lightgrey"; +} + +function notifyMe(code) { + const doNotify = () => { + // NOTE: empty body (TODO?) + new Notification("New " + code, { vibrate: [200, 100, 200] }); + new Audio("/assets/new_" + code + ".mp3").play(); + } + if (Notification.permission === 'granted') doNotify(); + else if (Notification.permission !== 'denied') { + Notification.requestPermission().then((permission) => { + if (permission === 'granted') doNotify(); + }); + } +} + +let curMoves = []; +const afterPlay = (move) => { //pack into one moves array, then send + curMoves.push({ + appear: move.appear, + vanish: move.vanish, + start: move.start, + end: move.end + }); + if (vr.turn != color) { + toggleTurnIndicator(false); + send("newmove", { gid: gid, moves: curMoves, fen: vr.getFen() }); + curMoves = []; + const result = vr.getCurrentScore(move); + if (result != "*") { + setTimeout( () => { + toggleVisible("gameStopped"); + send("gameover", { gid: gid }); + }, 2000); + } + } +}; + +// Avoid loading twice the same stylesheet: +const conditionalLoadCSS = (vname) => { + const allIds = [].slice.call($.styleSheets).map(s => s.id); + const newId = vname + "_css"; + if (!allIds.includes(newId)) { + $.getElementsByTagName("head")[0].insertAdjacentHTML( + "beforeend", + ``); + } +}; + +let vr, color; +function initializeGame(obj) { + const options = obj.options || {}; + import(`/variants/${obj.vname}/class.js`).then(module => { + const Rules = module.default; + conditionalLoadCSS(obj.vname); + color = (sid == obj.players[0].sid ? "w" : "b"); + // Init + remove potential extra DOM elements from a previous game: + document.getElementById("boardContainer").innerHTML = ` +
+ +
+
+ +
+
`; + vr = new Rules({ + seed: obj.seed, //may be null if FEN already exists (running game) + fen: obj.fen, + element: "chessboard", + color: color, + afterPlay: afterPlay, + options: options + }); + if (!obj.fen) { + // Game creation + if (color == "w") send("setfen", {gid: obj.gid, fen: vr.getFen()}); + localStorage.setItem("gid", obj.gid); + } + const select = $.getElementById("selectVariant"); + obj.vdisp = ""; + for (let i=0; i { + if (!localStorage.getItem("gid")) return; + if (e.keyCode == 27) confirmStopGame(); + else if (e.keyCode == 32) { + e.preventDefault(); + toggleGameInfos(); + } +}); diff --git a/base_pieces.css b/base_pieces.css new file mode 100644 index 0000000..08f8257 --- /dev/null +++ b/base_pieces.css @@ -0,0 +1,37 @@ +/** Embedded SVGs for all chess pieces */ +piece.pawn.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PHBhdGggZD0iTTIyLjUgOWMtMi4yMSAwLTQgMS43OS00IDQgMCAuODkuMjkgMS43MS43OCAyLjM4QzE3LjMzIDE2LjUgMTYgMTguNTkgMTYgMjFjMCAyLjAzLjk0IDMuODQgMi40MSA1LjAzLTMgMS4wNi03LjQxIDUuNTUtNy40MSAxMy40N2gyM2MwLTcuOTItNC40MS0xMi40MS03LjQxLTEzLjQ3IDEuNDctMS4xOSAyLjQxLTMgMi40MS01LjAzIDAtMi40MS0xLjMzLTQuNS0zLjI4LTUuNjIuNDktLjY3Ljc4LTEuNDkuNzgtMi4zOCAwLTIuMjEtMS43OS00LTQtNHoiIGZpbGw9IiNmZmYiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg=='); +} +piece.bishop.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxnIGZpbGw9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJidXR0Ij48cGF0aCBkPSJNOSAzNmMzLjM5LS45NyAxMC4xMS40MyAxMy41LTIgMy4zOSAyLjQzIDEwLjExIDEuMDMgMTMuNSAyIDAgMCAxLjY1LjU0IDMgMi0uNjguOTctMS42NS45OS0zIC41LTMuMzktLjk3LTEwLjExLjQ2LTEzLjUtMS0zLjM5IDEuNDYtMTAuMTEuMDMtMTMuNSAxLTEuMzU0LjQ5LTIuMzIzLjQ3LTMtLjUgMS4zNTQtMS45NCAzLTIgMy0yeiIvPjxwYXRoIGQ9Ik0xNSAzMmMyLjUgMi41IDEyLjUgMi41IDE1IDAgLjUtMS41IDAtMiAwLTIgMC0yLjUtMi41LTQtMi41LTQgNS41LTEuNSA2LTExLjUtNS0xNS41LTExIDQtMTAuNSAxNC01IDE1LjUgMCAwLTIuNSAxLjUtMi41IDQgMCAwLS41LjUgMCAyeiIvPjxwYXRoIGQ9Ik0yNSA4YTIuNSAyLjUgMCAxIDEtNSAwIDIuNSAyLjUgMCAxIDEgNSAweiIvPjwvZz48cGF0aCBkPSJNMTcuNSAyNmgxME0xNSAzMGgxNW0tNy41LTE0LjV2NU0yMCAxOGg1IiBzdHJva2UtbGluZWpvaW49Im1pdGVyIi8+PC9nPjwvc3ZnPg=='); +} +piece.knight.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMiAxMGMxMC41IDEgMTYuNSA4IDE2IDI5SDE1YzAtOSAxMC02LjUgOC0yMSIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yNCAxOGMuMzggMi45MS01LjU1IDcuMzctOCA5LTMgMi0yLjgyIDQuMzQtNSA0LTEuMDQyLS45NCAxLjQxLTMuMDQgMC0zLTEgMCAuMTkgMS4yMy0xIDItMSAwLTQuMDAzIDEtNC00IDAtMiA2LTEyIDYtMTJzMS44OS0xLjkgMi0zLjVjLS43My0uOTk0LS41LTItLjUtMyAxLTEgMyAyLjUgMyAyLjVoMnMuNzgtMS45OTIgMi41LTNjMSAwIDEgMyAxIDMiIGZpbGw9IiNmZmYiLz48cGF0aCBkPSJNOS41IDI1LjVhLjUuNSAwIDEgMS0xIDAgLjUuNSAwIDEgMSAxIDB6bTUuNDMzLTkuNzVhLjUgMS41IDMwIDEgMS0uODY2LS41LjUgMS41IDMwIDEgMSAuODY2LjV6IiBmaWxsPSIjMDAwIi8+PC9nPjwvc3ZnPg=='); +} +piece.rook.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDM5aDI3di0zSDl2M3ptMy0zdi00aDIxdjRIMTJ6bS0xLTIyVjloNHYyaDVWOWg1djJoNVY5aDR2NSIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiLz48cGF0aCBkPSJNMzQgMTRsLTMgM0gxNGwtMy0zIi8+PHBhdGggZD0iTTMxIDE3djEyLjVIMTRWMTciIHN0cm9rZS1saW5lY2FwPSJidXR0IiBzdHJva2UtbGluZWpvaW49Im1pdGVyIi8+PHBhdGggZD0iTTMxIDI5LjVsMS41IDIuNWgtMjBsMS41LTIuNSIvPjxwYXRoIGQ9Ik0xMSAxNGgyMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIvPjwvZz48L3N2Zz4='); +} +piece.queen.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik04IDEyYTIgMiAwIDEgMS00IDAgMiAyIDAgMSAxIDQgMHptMTYuNS00LjVhMiAyIDAgMSAxLTQgMCAyIDIgMCAxIDEgNCAwek00MSAxMmEyIDIgMCAxIDEtNCAwIDIgMiAwIDEgMSA0IDB6TTE2IDguNWEyIDIgMCAxIDEtNCAwIDIgMiAwIDEgMSA0IDB6TTMzIDlhMiAyIDAgMSAxLTQgMCAyIDIgMCAxIDEgNCAweiIvPjxwYXRoIGQ9Ik05IDI2YzguNS0xLjUgMjEtMS41IDI3IDBsMi0xMi03IDExVjExbC01LjUgMTMuNS0zLTE1LTMgMTUtNS41LTE0VjI1TDcgMTRsMiAxMnoiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PHBhdGggZD0iTTkgMjZjMCAyIDEuNSAyIDIuNSA0IDEgMS41IDEgMSAuNSAzLjUtMS41IDEtMS41IDIuNS0xLjUgMi41LTEuNSAxLjUuNSAyLjUuNSAyLjUgNi41IDEgMTYuNSAxIDIzIDAgMCAwIDEuNS0xIDAtMi41IDAgMCAuNS0xLjUtMS0yLjUtLjUtMi41LS41LTIgLjUtMy41IDEtMiAyLjUtMiAyLjUtNC04LjUtMS41LTE4LjUtMS41LTI3IDB6IiBzdHJva2UtbGluZWNhcD0iYnV0dCIvPjxwYXRoIGQ9Ik0xMS41IDMwYzMuNS0xIDE4LjUtMSAyMiAwTTEyIDMzLjVjNi0xIDE1LTEgMjEgMCIgZmlsbD0ibm9uZSIvPjwvZz48L3N2Zz4='); +} +piece.king.white { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMi41IDExLjYzVjZNMjAgOGg1IiBzdHJva2UtbGluZWpvaW49Im1pdGVyIi8+PHBhdGggZD0iTTIyLjUgMjVzNC41LTcuNSAzLTEwLjVjMCAwLTEtMi41LTMtMi41cy0zIDIuNS0zIDIuNWMtMS41IDMgMyAxMC41IDMgMTAuNSIgZmlsbD0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiLz48cGF0aCBkPSJNMTEuNSAzN2M1LjUgMy41IDE1LjUgMy41IDIxIDB2LTdzOS00LjUgNi0xMC41Yy00LTYuNS0xMy41LTMuNS0xNiA0VjI3di0zLjVjLTMuNS03LjUtMTMtMTAuNS0xNi00LTMgNiA1IDEwIDUgMTBWMzd6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTExLjUgMzBjNS41LTMgMTUuNS0zIDIxIDBtLTIxIDMuNWM1LjUtMyAxNS41LTMgMjEgMG0tMjEgMy41YzUuNS0zIDE1LjUtMyAyMSAwIi8+PC9nPjwvc3ZnPg=='); +} +piece.pawn.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PHBhdGggZD0iTTIyLjUgOWMtMi4yMSAwLTQgMS43OS00IDQgMCAuODkuMjkgMS43MS43OCAyLjM4QzE3LjMzIDE2LjUgMTYgMTguNTkgMTYgMjFjMCAyLjAzLjk0IDMuODQgMi40MSA1LjAzLTMgMS4wNi03LjQxIDUuNTUtNy40MSAxMy40N2gyM2MwLTcuOTItNC40MS0xMi40MS03LjQxLTEzLjQ3IDEuNDctMS4xOSAyLjQxLTMgMi40MS01LjAzIDAtMi40MS0xLjMzLTQuNS0zLjI4LTUuNjIuNDktLjY3Ljc4LTEuNDkuNzgtMi4zOCAwLTIuMjEtMS43OS00LTQtNHoiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg=='); +} +piece.bishop.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxnIGZpbGw9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJidXR0Ij48cGF0aCBkPSJNOSAzNmMzLjM5LS45NyAxMC4xMS40MyAxMy41LTIgMy4zOSAyLjQzIDEwLjExIDEuMDMgMTMuNSAyIDAgMCAxLjY1LjU0IDMgMi0uNjguOTctMS42NS45OS0zIC41LTMuMzktLjk3LTEwLjExLjQ2LTEzLjUtMS0zLjM5IDEuNDYtMTAuMTEuMDMtMTMuNSAxLTEuMzU0LjQ5LTIuMzIzLjQ3LTMtLjUgMS4zNTQtMS45NCAzLTIgMy0yeiIvPjxwYXRoIGQ9Ik0xNSAzMmMyLjUgMi41IDEyLjUgMi41IDE1IDAgLjUtMS41IDAtMiAwLTIgMC0yLjUtMi41LTQtMi41LTQgNS41LTEuNSA2LTExLjUtNS0xNS41LTExIDQtMTAuNSAxNC01IDE1LjUgMCAwLTIuNSAxLjUtMi41IDQgMCAwLS41LjUgMCAyeiIvPjxwYXRoIGQ9Ik0yNSA4YTIuNSAyLjUgMCAxIDEtNSAwIDIuNSAyLjUgMCAxIDEgNSAweiIvPjwvZz48cGF0aCBkPSJNMTcuNSAyNmgxME0xNSAzMGgxNW0tNy41LTE0LjV2NU0yMCAxOGg1IiBzdHJva2U9IiNlY2VjZWMiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiLz48L2c+PC9zdmc+'); +} +piece.knight.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMiAxMGMxMC41IDEgMTYuNSA4IDE2IDI5SDE1YzAtOSAxMC02LjUgOC0yMSIgZmlsbD0iIzAwMCIvPjxwYXRoIGQ9Ik0yNCAxOGMuMzggMi45MS01LjU1IDcuMzctOCA5LTMgMi0yLjgyIDQuMzQtNSA0LTEuMDQyLS45NCAxLjQxLTMuMDQgMC0zLTEgMCAuMTkgMS4yMy0xIDItMSAwLTQuMDAzIDEtNC00IDAtMiA2LTEyIDYtMTJzMS44OS0xLjkgMi0zLjVjLS43My0uOTk0LS41LTItLjUtMyAxLTEgMyAyLjUgMyAyLjVoMnMuNzgtMS45OTIgMi41LTNjMSAwIDEgMyAxIDMiIGZpbGw9IiMwMDAiLz48cGF0aCBkPSJNOS41IDI1LjVhLjUuNSAwIDEgMS0xIDAgLjUuNSAwIDEgMSAxIDB6bTUuNDMzLTkuNzVhLjUgMS41IDMwIDEgMS0uODY2LS41LjUgMS41IDMwIDEgMSAuODY2LjV6IiBmaWxsPSIjZWNlY2VjIiBzdHJva2U9IiNlY2VjZWMiLz48cGF0aCBkPSJNMjQuNTUgMTAuNGwtLjQ1IDEuNDUuNS4xNWMzLjE1IDEgNS42NSAyLjQ5IDcuOSA2Ljc1UzM1Ljc1IDI5LjA2IDM1LjI1IDM5bC0uMDUuNWgyLjI1bC4wNS0uNWMuNS0xMC4wNi0uODgtMTYuODUtMy4yNS0yMS4zNC0yLjM3LTQuNDktNS43OS02LjY0LTkuMTktNy4xNmwtLjUxLS4xeiIgZmlsbD0iI2VjZWNlYyIgc3Ryb2tlPSJub25lIi8+PC9nPjwvc3ZnPg=='); +} +piece.rook.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05IDM5aDI3di0zSDl2M3ptMy41LTdsMS41LTIuNWgxN2wxLjUgMi41aC0yMHptLS41IDR2LTRoMjF2NEgxMnoiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PHBhdGggZD0iTTE0IDI5LjV2LTEzaDE3djEzSDE0eiIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiLz48cGF0aCBkPSJNMTQgMTYuNUwxMSAxNGgyM2wtMyAyLjVIMTR6TTExIDE0VjloNHYyaDVWOWg1djJoNVY5aDR2NUgxMXoiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PHBhdGggZD0iTTEyIDM1LjVoMjFtLTIwLTRoMTltLTE4LTJoMTdtLTE3LTEzaDE3TTExIDE0aDIzIiBmaWxsPSJub25lIiBzdHJva2U9IiNlY2VjZWMiIHN0cm9rZS13aWR0aD0iMSIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIvPjwvZz48L3N2Zz4='); +} +piece.queen.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxnIHN0cm9rZT0ibm9uZSI+PGNpcmNsZSBjeD0iNiIgY3k9IjEyIiByPSIyLjc1Ii8+PGNpcmNsZSBjeD0iMTQiIGN5PSI5IiByPSIyLjc1Ii8+PGNpcmNsZSBjeD0iMjIuNSIgY3k9IjgiIHI9IjIuNzUiLz48Y2lyY2xlIGN4PSIzMSIgY3k9IjkiIHI9IjIuNzUiLz48Y2lyY2xlIGN4PSIzOSIgY3k9IjEyIiByPSIyLjc1Ii8+PC9nPjxwYXRoIGQ9Ik05IDI2YzguNS0xLjUgMjEtMS41IDI3IDBsMi41LTEyLjVMMzEgMjVsLS4zLTE0LjEtNS4yIDEzLjYtMy0xNC41LTMgMTQuNS01LjItMTMuNkwxNCAyNSA2LjUgMTMuNSA5IDI2eiIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiLz48cGF0aCBkPSJNOSAyNmMwIDIgMS41IDIgMi41IDQgMSAxLjUgMSAxIC41IDMuNS0xLjUgMS0xLjUgMi41LTEuNSAyLjUtMS41IDEuNS41IDIuNS41IDIuNSA2LjUgMSAxNi41IDEgMjMgMCAwIDAgMS41LTEgMC0yLjUgMCAwIC41LTEuNS0xLTIuNS0uNS0yLjUtLjUtMiAuNS0zLjUgMS0yIDIuNS0yIDIuNS00LTguNS0xLjUtMTguNS0xLjUtMjcgMHoiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PHBhdGggZD0iTTExIDM4LjVhMzUgMzUgMSAwIDAgMjMgMCIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9ImJ1dHQiLz48cGF0aCBkPSJNMTEgMjlhMzUgMzUgMSAwIDEgMjMgMG0tMjEuNSAyLjVoMjBtLTIxIDNhMzUgMzUgMSAwIDAgMjIgMG0tMjMgM2EzNSAzNSAxIDAgMCAyNCAwIiBmaWxsPSJub25lIiBzdHJva2U9IiNlY2VjZWMiLz48L2c+PC9zdmc+'); +} +piece.king.black { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NSIgaGVpZ2h0PSI0NSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMi41IDExLjYzVjYiIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiLz48cGF0aCBkPSJNMjIuNSAyNXM0LjUtNy41IDMtMTAuNWMwIDAtMS0yLjUtMy0yLjVzLTMgMi41LTMgMi41Yy0xLjUgMyAzIDEwLjUgMyAxMC41IiBmaWxsPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciIvPjxwYXRoIGQ9Ik0xMS41IDM3YzUuNSAzLjUgMTUuNSAzLjUgMjEgMHYtN3M5LTQuNSA2LTEwLjVjLTQtNi41LTEzLjUtMy41LTE2IDRWMjd2LTMuNWMtMy41LTcuNS0xMy0xMC41LTE2LTQtMyA2IDUgMTAgNSAxMFYzN3oiIGZpbGw9IiMwMDAiLz48cGF0aCBkPSJNMjAgOGg1IiBzdHJva2UtbGluZWpvaW49Im1pdGVyIi8+PHBhdGggZD0iTTMyIDI5LjVzOC41LTQgNi4wMy05LjY1QzM0LjE1IDE0IDI1IDE4IDIyLjUgMjQuNWwuMDEgMi4xLS4wMS0yLjFDMjAgMTggOS45MDYgMTQgNi45OTcgMTkuODVjLTIuNDk3IDUuNjUgNC44NTMgOSA0Ljg1MyA5IiBzdHJva2U9IiNlY2VjZWMiLz48cGF0aCBkPSJNMTEuNSAzMGM1LjUtMyAxNS41LTMgMjEgMG0tMjEgMy41YzUuNS0zIDE1LjUtMyAyMSAwbS0yMSAzLjVjNS41LTMgMTUuNS0zIDIxIDAiIHN0cm9rZT0iI2VjZWNlYyIvPjwvZz48L3N2Zz4='); +} diff --git a/base_rules.js b/base_rules.js new file mode 100644 index 0000000..ffb9fd6 --- /dev/null +++ b/base_rules.js @@ -0,0 +1,2083 @@ +import { Random } from "/utils/alea.js"; +import { ArrayFun } from "/utils/array.js"; +import PiPo from "/utils/PiPo.js"; +import Move from "/utils/Move.js"; + +// NOTE: x coords: top to bottom (white perspective); y: left to right +export default class ChessRules { + + ///////////////////////// + // VARIANT SPECIFICATIONS + + // Some variants have specific options, like the number of pawns in Monster, + // or the board size for Pandemonium. + // Users can generally select a randomness level from 0 to 2. + static get Options() { + return { + // NOTE: some options are required for FEN generation, some aren't. + select: [{ + label: "Randomness", + variable: "randomness", + defaut: 0, + options: [ + { label: "Deterministic", value: 0 }, + { label: "Symmetric random", value: 1 }, + { label: "Asymmetric random", value: 2 } + ] + }], + check: [{ + label: "Capture king?", + defaut: false, + variable: "taking" + }], + // Game modifiers (using "elementary variants"). Default: false + styles: [ + "atomic", + "balance", //takes precedence over doublemove & progressive + "cannibal", + "capture", + "crazyhouse", + "cylinder", //ok with all + "dark", + "doublemove", + "madrasi", + "progressive", //(natural) priority over doublemove + "recycle", + "rifle", + "teleport", + "zen" + ] + }; + } + + // Pawns specifications + get pawnSpecs() { + return { + directions: { 'w': -1, 'b': 1 }, + initShift: { w: 1, b: 1 }, + twoSquares: true, + threeSquares: false, + canCapture: true, + captureBackward: false, + bidirectional: false, + promotions: ['r', 'n', 'b', 'q'] + }; + } + + // Some variants don't have flags: + get hasFlags() { + return true; + } + // Or castle + get hasCastle() { + return this.hasFlags; + } + + // En-passant captures allowed? + get hasEnpassant() { + return true; + } + + get hasReserve() { + return ( + !!this.options["crazyhouse"] || + (!!this.options["recycle"] && !this.options["teleport"]) + ); + } + + get noAnimate() { + return !!this.options["dark"]; + } + + // Some variants use click infos: + doClick([x, y]) { + if (typeof x != "number") return null; //click on reserves + if ( + this.options["teleport"] && this.subTurn == 2 && + this.board[x][y] == "" + ) { + return new Move({ + start: {x: this.captured.x, y: this.captured.y}, + appear: [ + new PiPo({ + x: x, + y: y, + c: this.captured.c, //this.turn, + p: this.captured.p + }) + ], + vanish: [] + }); + } + return null; + } + + //////////////////// + // COORDINATES UTILS + + // 3 --> d (column number to letter) + static CoordToColumn(colnum) { + return String.fromCharCode(97 + colnum); + } + + // d --> 3 (column letter to number) + static ColumnToCoord(columnStr) { + return columnStr.charCodeAt(0) - 97; + } + + // 7 (numeric) --> 1 (str) [from black viewpoint]. + static CoordToRow(rownum) { + return rownum; + } + + // NOTE: wrong row index (1 should be 7 ...etc). But OK for the usage. + static RowToCoord(rownumStr) { + // NOTE: 30 is way more than enough (allow up to 29 rows on one character) + return parseInt(rownumStr, 30); + } + + // a2 --> {x:2,y:0} (this is in fact a6) + static SquareToCoords(sq) { + return { + x: ChessRules.RowToCoord(sq[1]), + // NOTE: column is always one char => max 26 columns + y: ChessRules.ColumnToCoord(sq[0]) + }; + } + + // {x:0,y:4} --> e0 (should be e8) + static CoordsToSquare(coords) { + return ( + ChessRules.CoordToColumn(coords.y) + ChessRules.CoordToRow(coords.x) + ); + } + + coordsToId([x, y]) { + if (typeof x == "number") + return `${this.containerId}|sq-${x.toString(30)}-${y.toString(30)}`; + // Reserve : + return `${this.containerId}|rsq-${x}-${y}`; + } + + idToCoords(targetId) { + if (!targetId) return null; //outside page, maybe... + const idParts = targetId.split('|'); //prefix|sq-2-3 (start at 0 => 3,4) + if ( + idParts.length < 2 || + idParts[0] != this.containerId || + !idParts[1].match(/sq-[0-9a-zA-Z]-[0-9a-zA-Z]/) + ) { + return null; + } + const squares = idParts[1].split('-'); + if (squares[0] == "sq") + return [ parseInt(squares[1], 30), parseInt(squares[2], 30) ]; + // squares[0] == "rsq" : reserve, 'c' + 'p' (letters) + return [squares[1], squares[2]]; + } + + ///////////// + // FEN UTILS + + // Turn "wb" into "B" (for FEN) + board2fen(b) { + return b[0] == "w" ? b[1].toUpperCase() : b[1]; + } + + // Turn "p" into "bp" (for board) + fen2board(f) { + return f.charCodeAt(0) <= 90 ? "w" + f.toLowerCase() : "b" + f; + } + + // Setup the initial random-or-not (asymmetric-or-not) position + genRandInitFen(seed) { + Random.setSeed(seed); + + let fen, flags = "0707"; + if (this.options.randomness == 0 || !this.options.randomness) + // Deterministic: + fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w 0"; + + else { + // Randomize + let pieces = { w: new Array(8), b: new Array(8) }; + flags = ""; + // Shuffle pieces on first (and last rank if randomness == 2) + for (let c of ["w", "b"]) { + if (c == 'b' && this.options.randomness == 1) { + pieces['b'] = pieces['w']; + flags += flags; + break; + } + + let positions = ArrayFun.range(8); + + // Get random squares for bishops + let randIndex = 2 * Random.randInt(4); + const bishop1Pos = positions[randIndex]; + // The second bishop must be on a square of different color + let randIndex_tmp = 2 * Random.randInt(4) + 1; + const bishop2Pos = positions[randIndex_tmp]; + // Remove chosen squares + positions.splice(Math.max(randIndex, randIndex_tmp), 1); + positions.splice(Math.min(randIndex, randIndex_tmp), 1); + + // Get random squares for knights + randIndex = Random.randInt(6); + const knight1Pos = positions[randIndex]; + positions.splice(randIndex, 1); + randIndex = Random.randInt(5); + const knight2Pos = positions[randIndex]; + positions.splice(randIndex, 1); + + // Get random square for queen + randIndex = Random.randInt(4); + const queenPos = positions[randIndex]; + positions.splice(randIndex, 1); + + // Rooks and king positions are now fixed, + // because of the ordering rook-king-rook + const rook1Pos = positions[0]; + const kingPos = positions[1]; + const rook2Pos = positions[2]; + + // Finally put the shuffled pieces in the board array + pieces[c][rook1Pos] = "r"; + pieces[c][knight1Pos] = "n"; + pieces[c][bishop1Pos] = "b"; + pieces[c][queenPos] = "q"; + pieces[c][kingPos] = "k"; + pieces[c][bishop2Pos] = "b"; + pieces[c][knight2Pos] = "n"; + pieces[c][rook2Pos] = "r"; + flags += rook1Pos.toString() + rook2Pos.toString(); + } + fen = ( + pieces["b"].join("") + + "/pppppppp/8/8/8/8/PPPPPPPP/" + + pieces["w"].join("").toUpperCase() + + " w 0" + ); + } + // Add turn + flags + enpassant (+ reserve) + let parts = []; + if (this.hasFlags) parts.push(`"flags":"${flags}"`); + if (this.hasEnpassant) parts.push('"enpassant":"-"'); + if (this.hasReserve) parts.push('"reserve":"000000000000"'); + if (this.options["crazyhouse"]) parts.push('"ispawn":"-"'); + if (parts.length >= 1) fen += " {" + parts.join(",") + "}"; + return fen; + } + + // "Parse" FEN: just return untransformed string data + parseFen(fen) { + const fenParts = fen.split(" "); + let res = { + position: fenParts[0], + turn: fenParts[1], + movesCount: fenParts[2] + }; + if (fenParts.length > 3) res = Object.assign(res, JSON.parse(fenParts[3])); + return res; + } + + // Return current fen (game state) + getFen() { + let fen = ( + this.getBaseFen() + " " + + this.getTurnFen() + " " + + this.movesCount + ); + let parts = []; + if (this.hasFlags) parts.push(`"flags":"${this.getFlagsFen()}"`); + if (this.hasEnpassant) + parts.push(`"enpassant":"${this.getEnpassantFen()}"`); + if (this.hasReserve) parts.push(`"reserve":"${this.getReserveFen()}"`); + if (this.options["crazyhouse"]) + parts.push(`"ispawn":"${this.getIspawnFen()}"`); + if (parts.length >= 1) fen += " {" + parts.join(",") + "}"; + return fen; + } + + // Position part of the FEN string + getBaseFen() { + const format = (count) => { + // if more than 9 consecutive free spaces, break the integer, + // otherwise FEN parsing will fail. + if (count <= 9) return count; + // Most boards of size < 18: + if (count <= 18) return "9" + (count - 9); + // Except Gomoku: + return "99" + (count - 18); + }; + let position = ""; + for (let i = 0; i < this.size.y; i++) { + let emptyCount = 0; + for (let j = 0; j < this.size.x; j++) { + if (this.board[i][j] == "") emptyCount++; + else { + if (emptyCount > 0) { + // Add empty squares in-between + position += format(emptyCount); + emptyCount = 0; + } + position += this.board2fen(this.board[i][j]); + } + } + if (emptyCount > 0) + // "Flush remainder" + position += format(emptyCount); + if (i < this.size.y - 1) position += "/"; //separate rows + } + return position; + } + + getTurnFen() { + return this.turn; + } + + // Flags part of the FEN string + getFlagsFen() { + return ["w", "b"].map(c => { + return this.castleFlags[c].map(x => x.toString(30)).join(""); + }).join(""); + } + + // Enpassant part of the FEN string + getEnpassantFen() { + if (!this.epSquare) return "-"; //no en-passant + return ChessRules.CoordsToSquare(this.epSquare); + } + + getReserveFen() { + return ( + ["w","b"].map(c => Object.values(this.reserve[c]).join("")).join("") + ); + } + + getIspawnFen() { + const coords = Object.keys(this.ispawn); + if (coords.length == 0) return "-"; + return coords.map(ChessRules.CoordsToSquare).join(","); + } + + // Set flags from fen (castle: white a,h then black a,h) + setFlags(fenflags) { + this.castleFlags = { + w: [0, 1].map(i => parseInt(fenflags.charAt(i), 30)), + b: [2, 3].map(i => parseInt(fenflags.charAt(i), 30)) + }; + } + + ////////////////// + // INITIALIZATION + + // Fen string fully describes the game state + constructor(o) { + this.options = o.options; + this.playerColor = o.color; + this.afterPlay = o.afterPlay; + + // FEN-related: + if (!o.fen) o.fen = this.genRandInitFen(o.seed); + const fenParsed = this.parseFen(o.fen); + this.board = this.getBoard(fenParsed.position); + this.turn = fenParsed.turn; + this.movesCount = parseInt(fenParsed.movesCount, 10); + this.setOtherVariables(fenParsed); + + // Graphical (can use variables defined above) + this.containerId = o.element; + this.graphicalInit(); + } + + // Turn position fen into double array ["wb","wp","bk",...] + getBoard(position) { + const rows = position.split("/"); + let board = ArrayFun.init(this.size.x, this.size.y, ""); + for (let i = 0; i < rows.length; i++) { + let j = 0; + for (let indexInRow = 0; indexInRow < rows[i].length; indexInRow++) { + const character = rows[i][indexInRow]; + const num = parseInt(character, 10); + // If num is a number, just shift j: + if (!isNaN(num)) j += num; + // Else: something at position i,j + else board[i][j++] = this.fen2board(character); + } + } + return board; + } + + // Some additional variables from FEN (variant dependant) + setOtherVariables(fenParsed) { + // Set flags and enpassant: + if (this.hasFlags) this.setFlags(fenParsed.flags); + if (this.hasEnpassant) + this.epSquare = this.getEpSquare(fenParsed.enpassant); + if (this.hasReserve) this.initReserves(fenParsed.reserve); + if (this.options["crazyhouse"]) this.initIspawn(fenParsed.ispawn); + this.subTurn = 1; //may be unused + if (this.options["teleport"]) this.captured = null; + if (this.options["dark"]) { + this.enlightened = ArrayFun.init(this.size.x, this.size.y); + // Setup enlightened: squares reachable by player side + this.updateEnlightened(false); + } + } + + updateEnlightened(withGraphics) { + let newEnlightened = ArrayFun.init(this.size.x, this.size.y, false); + const pawnShift = { w: -1, b: 1 }; + // Add pieces positions + all squares reachable by moves (includes Zen): + // (watch out special pawns case) + for (let x=0; x { + const [i, j] = [x + step[0], this.computeY(y + step[1])]; + if (this.onBoard(i, j) && this.board[i][j] == "") + newEnlightened[i][j] = true; + }); + } + this.getPotentialMovesFrom([x, y]).forEach(m => { + newEnlightened[m.end.x][m.end.y] = true; + }); + } + } + } + if (this.epSquare) this.enlightEnpassant(newEnlightened); + if (withGraphics) this.graphUpdateEnlightened(newEnlightened); + this.enlightened = newEnlightened; + } + + // Include en-passant capturing square if any: + enlightEnpassant(newEnlightened) { + const steps = this.pieces(this.playerColor)[ChessRules.PAWN].attack; + for (let step of steps) { + const x = this.epSquare.x - step[0], + y = this.computeY(this.epSquare.y - step[1]); + if ( + this.onBoard(x, y) && + this.getColor(x, y) == this.playerColor && + this.getPiece(x, y) == ChessRules.PAWN + ) { + newEnlightened[x][this.epSquare.y] = true; + break; + } + } + } + + // Apply diff this.enlightened --> newEnlightened on board + graphUpdateEnlightened(newEnlightened) { + let container = document.getElementById(this.containerId); + const r = container.getBoundingClientRect(); + const pieceWidth = this.getPieceWidth(r.width); + for (let x=0; x this.g_pieces[x][y].classList.add(cl)); + this.g_pieces[x][y].style.width = pieceWidth + "px"; + this.g_pieces[x][y].style.height = pieceWidth + "px"; + const [ip, jp] = this.getPixelPosition(x, y, r); + this.g_pieces[x][y].style.transform = + `translate(${ip}px,${jp}px)`; + container.appendChild(this.g_pieces[x][y]); + } + } + } + } + } + + // ordering p,r,n,b,q,k (most general + count in base 30 if needed) + initReserves(reserveStr) { + const counts = reserveStr.split("").map(c => parseInt(c, 30)); + this.reserve = { w: {}, b: {} }; + const pieceName = Object.keys(this.pieces()); + for (let i of ArrayFun.range(12)) { + if (i < 6) this.reserve['w'][pieceName[i]] = counts[i]; + else this.reserve['b'][pieceName[i-6]] = counts[i]; + } + } + + initIspawn(ispawnStr) { + if (ispawnStr != "-") { + this.ispawn = ispawnStr.split(",").map(ChessRules.SquareToCoords) + .reduce((o, key) => ({ ...o, [key]: true}), {}); + } + else this.ispawn = {}; + } + + getNbReservePieces(color) { + return ( + Object.values(this.reserve[color]).reduce( + (oldV,newV) => oldV + (newV > 0 ? 1 : 0), 0) + ); + } + + ////////////// + // VISUAL PART + + getPieceWidth(rwidth) { + return (rwidth / this.size.y); + } + + getSquareWidth(rwidth) { + return this.getPieceWidth(rwidth); + } + + getReserveSquareSize(rwidth, nbR) { + const sqSize = this.getSquareWidth(rwidth); + return Math.min(sqSize, rwidth / nbR); + } + + getReserveNumId(color, piece) { + return `${this.containerId}|rnum-${color}${piece}`; + } + + graphicalInit() { + // NOTE: not window.onresize = this.re_drawBoardElts because scope (this) + window.onresize = () => this.re_drawBoardElements(); + this.re_drawBoardElements(); + this.initMouseEvents(); + const container = document.getElementById(this.containerId); + new ResizeObserver(this.rescale).observe(container); + } + + re_drawBoardElements() { + const board = this.getSvgChessboard(); + const oppCol = ChessRules.GetOppCol(this.playerColor); + let container = document.getElementById(this.containerId); + container.innerHTML = ""; + container.insertAdjacentHTML('beforeend', board); + let cb = container.querySelector("#" + this.containerId + "_SVG"); + const aspectRatio = this.size.y / this.size.x; + // Compare window ratio width / height to aspectRatio: + const windowRatio = window.innerWidth / window.innerHeight; + let cbWidth, cbHeight; + if (windowRatio <= aspectRatio) { + // Limiting dimension is width: + cbWidth = Math.min(window.innerWidth, 767); + cbHeight = cbWidth / aspectRatio; + } + else { + // Limiting dimension is height: + cbHeight = Math.min(window.innerHeight, 767); + cbWidth = cbHeight * aspectRatio; + } + if (this.reserve) { + const sqSize = cbWidth / this.size.y; + // NOTE: allocate space for reserves (up/down) even if they are empty + if ((window.innerHeight - cbHeight) / 2 < sqSize + 5) { + cbHeight = window.innerHeight - 2 * (sqSize + 5); + cbWidth = cbHeight * aspectRatio; + } + } + container.style.width = cbWidth + "px"; + container.style.height = cbHeight + "px"; + // Center chessboard: + const spaceLeft = (window.innerWidth - cbWidth) / 2, + spaceTop = (window.innerHeight - cbHeight) / 2; + container.style.left = spaceLeft + "px"; + container.style.top = spaceTop + "px"; + // Give sizes instead of recomputing them, + // because chessboard might not be drawn yet. + this.setupPieces({ + width: cbWidth, + height: cbHeight, + x: spaceLeft, + y: spaceTop + }); + } + + // Get SVG board (background, no pieces) + getSvgChessboard() { + const [sizeX, sizeY] = [this.size.x, this.size.y]; + const flipped = (this.playerColor == 'b'); + let board = ` + + `; + for (let i=0; i < sizeX; i++) { + for (let j=0; j < sizeY; j++) { + const ii = (flipped ? this.size.x - 1 - i : i); + const jj = (flipped ? this.size.y - 1 - j : j); + let fillOpacity = '1'; + if (this.options["dark"] && !this.enlightened[ii][jj]) + fillOpacity = '0.5'; + // NOTE: x / y reversed because coordinates system is reversed. + // TODO: CSS "wood" style, rect --> style --> background-image ? + board += ``; + } + } + board += ""; + return board; + } + + // Generally light square bottom-right; TODO: user-defined colors at least + getSquareColor(i, j) { + return ((i+j) % 2 == 0 ? "f0d9b5": "b58863"); + } + + setupPieces(r) { + if (this.g_pieces) { + // Refreshing: delete old pieces first + for (let i=0; i { + if (this.r_pieces[c][p]) { + this.r_pieces[c][p].remove(); + delete this.r_pieces[c][p]; + const numId = this.getReserveNumId(c, p); + document.getElementById(numId).remove(); + } + }); + let reservesDiv = document.getElementById("reserves_" + c); + if (reservesDiv) reservesDiv.remove(); + } + } + else this.r_pieces = { 'w': {}, 'b': {} }; + if (!r) { + const container = document.getElementById(this.containerId); + r = container.getBoundingClientRect(); + } + const epsilon = 1e-4; //fix display bug on Firefox at least + for (let c of colors) { + if (!this.reserve[c]) continue; + const nbR = this.getNbReservePieces(c); + if (nbR == 0) continue; + const sqResSize = this.getReserveSquareSize(r.width, nbR); + let ridx = 0; + const vShift = (c == this.playerColor ? r.height + 5 : -sqResSize - 5); + const [i0, j0] = [r.x, r.y + vShift]; + let rcontainer = document.createElement("div"); + rcontainer.id = "reserves_" + c; + rcontainer.classList.add("reserves"); + rcontainer.style.left = i0 + "px"; + rcontainer.style.top = j0 + "px"; + rcontainer.style.width = (nbR * sqResSize) + "px"; + rcontainer.style.height = sqResSize + "px"; + document.getElementById("boardContainer").appendChild(rcontainer); + for (let p of Object.keys(this.reserve[c])) { + if (this.reserve[c][p] == 0) continue; + let r_cell = document.createElement("div"); + r_cell.id = this.coordsToId([c, p]); + r_cell.classList.add("reserve-cell"); + r_cell.style.width = (sqResSize - epsilon) + "px"; + r_cell.style.height = (sqResSize - epsilon) + "px"; + rcontainer.appendChild(r_cell); + let piece = document.createElement("piece"); + const pieceSpec = this.pieces(c)[p]; + piece.classList.add(pieceSpec["class"]); + piece.classList.add(c == 'w' ? "white" : "black"); + piece.style.width = "100%"; + piece.style.height = "100%"; + this.r_pieces[c][p] = piece; + r_cell.appendChild(piece); + let number = document.createElement("div"); + number.textContent = this.reserve[c][p]; + number.classList.add("reserve-num"); + number.id = this.getReserveNumId(c, p); + const fontSize = "1.3em"; + number.style.fontSize = fontSize; + number.style.fontSize = fontSize; + r_cell.appendChild(number); + ridx++; + } + } + } + + updateReserve(color, piece, count) { + const oldCount = this.reserve[color][piece]; + this.reserve[color][piece] = count; + // Redrawing is much easier if count==0 + if ([oldCount, count].includes(0)) this.re_drawReserve([color]); + else { + const numId = this.getReserveNumId(color, piece); + document.getElementById(numId).textContent = count; + } + } + + // After resize event: no need to destroy/recreate pieces + rescale() { + let container = document.getElementById(this.containerId); + if (!container) return; //useful at initial loading + const r = container.getBoundingClientRect(); + const newRatio = r.width / r.height; + const aspectRatio = this.size.y / this.size.x; + let newWidth = r.width, + newHeight = r.height; + if (newRatio > aspectRatio) { + newWidth = r.height * aspectRatio; + container.style.width = newWidth + "px"; + } + else if (newRatio < aspectRatio) { + newHeight = r.width / aspectRatio; + container.style.height = newHeight + "px"; + } + const newX = (window.innerWidth - newWidth) / 2; + container.style.left = newX + "px"; + const newY = (window.innerHeight - newHeight) / 2; + container.style.top = newY + "px"; + const newR = { x: newX, y: newY, width: newWidth, height: newHeight }; + const pieceWidth = this.getPieceWidth(newWidth); + for (let i=0; i < this.size.x; i++) { + for (let j=0; j < this.size.y; j++) { + if (this.board[i][j] != "") { + // NOTE: could also use CSS transform "scale" + this.g_pieces[i][j].style.width = pieceWidth + "px"; + this.g_pieces[i][j].style.height = pieceWidth + "px"; + const [ip, jp] = this.getPixelPosition(i, j, newR); + this.g_pieces[i][j].style.transform = `translate(${ip}px,${jp}px)`; + } + } + } + if (this.reserve) this.rescaleReserve(newR); + } + + rescaleReserve(r) { + const epsilon = 1e-4; + for (let c of ['w','b']) { + if (!this.reserve[c]) continue; + const nbR = this.getNbReservePieces(c); + if (nbR == 0) continue; + // Resize container first + const sqResSize = this.getReserveSquareSize(r.width, nbR); + const vShift = (c == this.playerColor ? r.height + 5 : -sqResSize - 5); + const [i0, j0] = [r.x, r.y + vShift]; + let rcontainer = document.getElementById("reserves_" + c); + rcontainer.style.left = i0 + "px"; + rcontainer.style.top = j0 + "px"; + rcontainer.style.width = (nbR * sqResSize) + "px"; + rcontainer.style.height = sqResSize + "px"; + // And then reserve cells: + const rpieceWidth = this.getReserveSquareSize(r.width, nbR); + Object.keys(this.reserve[c]).forEach(p => { + if (this.reserve[c][p] == 0) return; + let r_cell = document.getElementById(this.coordsToId([c, p])); + r_cell.style.width = (sqResSize - epsilon) + "px"; + r_cell.style.height = (sqResSize - epsilon) + "px"; + }); + } + } + + // Return the absolute pixel coordinates given current position. + // Our coordinate system differs from CSS one (x <--> y). + // We return here the CSS coordinates (more useful). + getPixelPosition(i, j, r) { + const sqSize = this.getSquareWidth(r.width); + if (i < 0 || j < 0) return [0, 0]; //piece vanishes + const flipped = (this.playerColor == 'b'); + const x = (flipped ? this.size.y - 1 - j : j) * sqSize; + const y = (flipped ? this.size.x - 1 - i : i) * sqSize; + return [x, y]; + } + + initMouseEvents() { + let container = document.getElementById(this.containerId); + + const getOffset = e => { + if (e.clientX) return {x: e.clientX, y: e.clientY}; //Mouse + let touchLocation = null; + if (e.targetTouches && e.targetTouches.length >= 1) + // Touch screen, dragstart + touchLocation = e.targetTouches[0]; + else if (e.changedTouches && e.changedTouches.length >= 1) + // Touch screen, dragend + touchLocation = e.changedTouches[0]; + if (touchLocation) + return {x: touchLocation.pageX, y: touchLocation.pageY}; + return [0, 0]; //Big trouble here =) + } + + const centerOnCursor = (piece, e) => { + const centerShift = sqSize / 2; + const offset = getOffset(e); + piece.style.left = (offset.x - centerShift) + "px"; + piece.style.top = (offset.y - centerShift) + "px"; + } + + let start = null, + r = null, + startPiece, curPiece = null, + sqSize; + const mousedown = (e) => { + r = container.getBoundingClientRect(); + sqSize = this.getSquareWidth(r.width); + const square = this.idToCoords(e.target.id); + if (square) { + const [i, j] = square; + const move = this.doClick([i, j]); + if (move) this.playPlusVisual(move); + else { + if (typeof i != "number") startPiece = this.r_pieces[i][j]; + else if (this.g_pieces[i][j]) startPiece = this.g_pieces[i][j]; + if (startPiece && this.canIplay(i, j)) { + e.preventDefault(); + start = { x: i, y: j }; + curPiece = startPiece.cloneNode(); + curPiece.style.transform = "none"; + curPiece.style.zIndex = 5; + curPiece.style.width = sqSize + "px"; + curPiece.style.height = sqSize + "px"; + centerOnCursor(curPiece, e); + document.getElementById("boardContainer").appendChild(curPiece); + startPiece.style.opacity = "0.4"; + container.style.cursor = "none"; + } + } + } + }; + + const mousemove = (e) => { + if (start) { + e.preventDefault(); + centerOnCursor(curPiece, e); + } + }; + + const mouseup = (e) => { + const newR = container.getBoundingClientRect(); + if (newR.width != r.width || newR.height != r.height) { + this.rescale(); + return; + } + if (!start) return; + const [x, y] = [start.x, start.y]; + start = null; + e.preventDefault(); + container.style.cursor = "pointer"; + startPiece.style.opacity = "1"; + const offset = getOffset(e); + const landingElt = document.elementFromPoint(offset.x, offset.y); + const sq = this.idToCoords(landingElt.id); + if (sq) { + const [i, j] = sq; + // NOTE: clearly suboptimal, but much easier, and not a big deal. + const potentialMoves = this.getPotentialMovesFrom([x, y]) + .filter(m => m.end.x == i && m.end.y == j); + const moves = this.filterValid(potentialMoves); + if (moves.length >= 2) this.showChoices(moves, r); + else if (moves.length == 1) this.playPlusVisual(moves[0], r); + } + curPiece.remove(); + }; + + if ('onmousedown' in window) { + document.addEventListener("mousedown", mousedown); + document.addEventListener("mousemove", mousemove); + document.addEventListener("mouseup", mouseup); + } + if ('ontouchstart' in window) { + document.addEventListener("touchstart", mousedown); + document.addEventListener("touchmove", mousemove); + document.addEventListener("touchend", mouseup); + } + } + + showChoices(moves, r) { + let container = document.getElementById(this.containerId); + let choices = document.createElement("div"); + choices.id = "choices"; + choices.style.width = r.width + "px"; + choices.style.height = r.height + "px"; + choices.style.left = r.x + "px"; + choices.style.top = r.y + "px"; + container.style.opacity = "0.5"; + let boardContainer = document.getElementById("boardContainer"); + boardContainer.appendChild(choices); + const squareWidth = this.getSquareWidth(r.width); + const firstUpLeft = (r.width - (moves.length * squareWidth)) / 2; + const firstUpTop = (r.height - squareWidth) / 2; + const color = moves[0].appear[0].c; + const callback = (m) => { + container.style.opacity = "1"; + boardContainer.removeChild(choices); + this.playPlusVisual(m, r); + } + for (let i=0; i < moves.length; i++) { + let choice = document.createElement("div"); + choice.classList.add("choice"); + choice.style.width = squareWidth + "px"; + choice.style.height = squareWidth + "px"; + choice.style.left = (firstUpLeft + i * squareWidth) + "px"; + choice.style.top = firstUpTop + "px"; + choice.style.backgroundColor = "lightyellow"; + choice.onclick = () => callback(moves[i]); + const piece = document.createElement("piece"); + const pieceSpec = this.pieces(color)[moves[i].appear[0].p]; + piece.classList.add(pieceSpec["class"]); + piece.classList.add(color == 'w' ? "white" : "black"); + piece.style.width = "100%"; + piece.style.height = "100%"; + choice.appendChild(piece); + choices.appendChild(choice); + } + } + + ////////////// + // BASIC UTILS + + get size() { + return { "x": 8, "y": 8 }; + } + + // Color of thing on square (i,j). 'undefined' if square is empty + getColor(i, j) { + return this.board[i][j].charAt(0); + } + + // Piece type on square (i,j). 'undefined' if square is empty + getPiece(i, j) { + return this.board[i][j].charAt(1); + } + + // Get opponent color + static GetOppCol(color) { + return (color == "w" ? "b" : "w"); + } + + // Can thing on square1 take thing on square2 + canTake([x1, y1], [x2, y2]) { + return ( + ( + (this.options["recycle"] || this.options["teleport"]) && + this.getPiece(x2, y2) != ChessRules.KING + ) || + (this.getColor(x1, y1) !== this.getColor(x2, y2)) + ); + } + + // Is (x,y) on the chessboard? + onBoard(x, y) { + return x >= 0 && x < this.size.x && y >= 0 && y < this.size.y; + } + + // Used in interface: 'side' arg == player color + canIplay(x, y) { + return ( + this.playerColor == this.turn && + ( + (typeof x == "number" && this.getColor(x, y) == this.turn) || + (typeof x == "string" && x == this.turn) //reserve + ) + ); + } + + //////////////////////// + // PIECES SPECIFICATIONS + + pieces(color) { + const pawnShift = (color == "w" ? -1 : 1); + return { + 'p': { + "class": "pawn", + steps: [[pawnShift, 0]], + range: 1, + attack: [[pawnShift, 1], [pawnShift, -1]] + }, + // rook + 'r': { + "class": "rook", + steps: [[0, 1], [0, -1], [1, 0], [-1, 0]] + }, + // knight + 'n': { + "class": "knight", + steps: [ + [1, 2], [1, -2], [-1, 2], [-1, -2], + [2, 1], [-2, 1], [2, -1], [-2, -1] + ], + range: 1 + }, + // bishop + 'b': { + "class": "bishop", + steps: [[1, 1], [1, -1], [-1, 1], [-1, -1]] + }, + // queen + 'q': { + "class": "queen", + steps: [ + [0, 1], [0, -1], [1, 0], [-1, 0], + [1, 1], [1, -1], [-1, 1], [-1, -1] + ] + }, + // king + 'k': { + "class": "king", + steps: [ + [0, 1], [0, -1], [1, 0], [-1, 0], + [1, 1], [1, -1], [-1, 1], [-1, -1] + ], + range: 1 + } + }; + } + + // Some pieces codes (for a clearer code) + static get PAWN() { + return "p"; + } + static get QUEEN() { + return "q"; + } + static get KING() { + return "k"; + } + + //////////////////// + // MOVES GENERATION + + // For Cylinder: get Y coordinate + computeY(y) { + if (!this.options["cylinder"]) return y; + let res = y % this.size.y; + if (res < 0) res += this.size.y; + return res; + } + + // Stop at the first capture found + atLeastOneCapture(color) { + color = color || this.turn; + const oppCol = ChessRules.GetOppCol(color); + for (let i = 0; i < this.size.x; i++) { + for (let j = 0; j < this.size.y; j++) { + if (this.board[i][j] != "" && this.getColor(i, j) == color) { + const specs = this.pieces(color)[this.getPiece(i, j)]; + const steps = specs.attack || specs.steps; + outerLoop: for (let step of steps) { + let [ii, jj] = [i + step[0], this.computeY(j + step[1])]; + let stepCounter = 1; + while (this.onBoard(ii, jj) && this.board[ii][jj] == "") { + if (specs.range <= stepCounter++) continue outerLoop; + ii += step[0]; + jj = this.computeY(jj + step[1]); + } + if ( + this.onBoard(ii, jj) && + this.getColor(ii, jj) == oppCol && + this.filterValid( + [this.getBasicMove([i, j], [ii, jj])] + ).length >= 1 + ) { + return true; + } + } + } + } + } + return false; + } + + getDropMovesFrom([c, p]) { + // NOTE: by design, this.reserve[c][p] >= 1 on user click + // (but not necessarily otherwise) + if (!this.reserve[c][p] || this.reserve[c][p] == 0) return []; + let moves = []; + for (let i=0; i 0) + ) + ) { + moves.push( + new Move({ + start: {x: c, y: p}, + end: {x: i, y: j}, + appear: [new PiPo({x: i, y: j, c: c, p: p})], + vanish: [] + }) + ); + } + } + } + return moves; + } + + // All possible moves from selected square + getPotentialMovesFrom(sq) { + if (typeof sq[0] == "string") return this.getDropMovesFrom(sq); + if (this.options["madrasi"] && this.isImmobilized(sq)) return []; + const piece = this.getPiece(sq[0], sq[1]); + let moves; + if (piece == ChessRules.PAWN) moves = this.getPotentialPawnMoves(sq); + else moves = this.getPotentialMovesOf(piece, sq); + if ( + piece == ChessRules.KING && + this.hasCastle && + this.castleFlags[color || this.turn].some(v => v < this.size.y) + ) { + Array.prototype.push.apply(moves, this.getCastleMoves(sq)); + } + return this.postProcessPotentialMoves(moves); + } + + postProcessPotentialMoves(moves) { + if (moves.length == 0) return []; + const color = this.getColor(moves[0].start.x, moves[0].start.y); + const oppCol = ChessRules.GetOppCol(color); + + if (this.options["capture"] && this.atLeastOneCapture()) { + // Filter out non-capturing moves (not using m.vanish because of + // self captures of Recycle and Teleport). + moves = moves.filter(m => { + return ( + this.board[m.end.x][m.end.y] != "" && + this.getColor(m.end.x, m.end.y) == oppCol + ); + }); + } + + if (this.options["atomic"]) { + moves.forEach(m => { + if ( + this.board[m.end.x][m.end.y] != "" && + this.getColor(m.end.x, m.end.y) == oppCol + ) { + // Explosion! + let steps = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1] + ]; + for (let step of steps) { + let x = m.end.x + step[0]; + let y = this.computeY(m.end.y + step[1]); + if ( + this.onBoard(x, y) && + this.board[x][y] != "" && + this.getPiece(x, y) != ChessRules.PAWN + ) { + m.vanish.push( + new PiPo({ + p: this.getPiece(x, y), + c: this.getColor(x, y), + x: x, + y: y + }) + ); + } + } + if (!this.options["rifle"]) m.appear.pop(); //nothin appears + } + }); + } + return moves; + } + + // For Madrasi: + // (redefined in Baroque etc, where Madrasi condition doesn't make sense) + isImmobilized([x, y]) { + const color = this.getColor(x, y); + const oppCol = ChessRules.GetOppCol(color); + const piece = this.getPiece(x, y); + const stepSpec = this.pieces(color)[piece]; + let [steps, range] = [stepSpec.attack || stepSpec.steps, stepSpec.range]; + outerLoop: for (let step of steps) { + let [i, j] = [x + step[0], y + step[1]]; + let stepCounter = 1; + while (this.onBoard(i, j) && this.board[i][j] == "") { + if (range <= stepCounter++) continue outerLoop; + i += step[0]; + j = this.computeY(j + step[1]); + } + if ( + this.onBoard(i, j) && + this.getColor(i, j) == oppCol && + this.getPiece(i, j) == piece + ) { + return true; + } + } + return false; + } + + // Generic method to find possible moves of "sliding or jumping" pieces + getPotentialMovesOf(piece, [x, y]) { + const color = this.getColor(x, y); + const stepSpec = this.pieces(color)[piece]; + let [steps, range] = [stepSpec.steps, stepSpec.range]; + let moves = []; + let explored = {}; //for Cylinder mode + outerLoop: for (let step of steps) { + let [i, j] = [x + step[0], this.computeY(y + step[1])]; + let stepCounter = 1; + while ( + this.onBoard(i, j) && + this.board[i][j] == "" && + !explored[i + "." + j] + ) { + explored[i + "." + j] = true; + moves.push(this.getBasicMove([x, y], [i, j])); + if (range <= stepCounter++) continue outerLoop; + i += step[0]; + j = this.computeY(j + step[1]); + } + if ( + this.onBoard(i, j) && + ( + !this.options["zen"] || + this.getPiece(i, j) == ChessRules.KING || + this.getColor(i, j) == color //OK for Recycle and Teleport + ) && + this.canTake([x, y], [i, j]) && + !explored[i + "." + j] + ) { + explored[i + "." + j] = true; + moves.push(this.getBasicMove([x, y], [i, j])); + } + } + if (this.options["zen"]) + Array.prototype.push.apply(moves, this.getZenCaptures(x, y)); + return moves; + } + + getZenCaptures(x, y) { + let moves = []; + // Find reverse captures (opponent takes) + const color = this.getColor(x, y); + const oppCol = ChessRules.GetOppCol(color); + const pieces = this.pieces(oppCol); + Object.keys(pieces).forEach(p => { + if (p == ChessRules.KING) return; //king isn't captured this way + const steps = pieces[p].attack || pieces[p].steps; + const range = pieces[p].range; + steps.forEach(s => { + // From x,y: revert step + let [i, j] = [x - s[0], this.computeY(y - s[1])]; + let stepCounter = 1; + while (this.onBoard(i, j) && this.board[i][j] == "") { + if (range <= stepCounter++) return; + i -= s[0]; + j = this.computeY(j - s[1]); + } + if ( + this.onBoard(i, j) && + this.getPiece(i, j) == p && + this.getColor(i, j) == oppCol && //condition for Recycle & Teleport + this.canTake([i, j], [x, y]) + ) { + if (this.getPiece(x, y) != ChessRules.PAWN) + moves.push(this.getBasicMove([x, y], [i, j])); + else this.addPawnMoves([x, y], [i, j], moves); + } + }); + }); + return moves; + } + + // Build a regular move from its initial and destination squares. + // tr: transformation + getBasicMove([sx, sy], [ex, ey], tr) { + const initColor = this.getColor(sx, sy); + const initPiece = this.board[sx][sy].charAt(1); + const destColor = (this.board[ex][ey] != "" ? this.getColor(ex, ey) : ""); + let mv = new Move({ + appear: [], + vanish: [], + start: {x:sx, y:sy}, + end: {x:ex, y:ey} + }); + if ( + !this.options["rifle"] || + this.board[ex][ey] == "" || + destColor == initColor //Recycle, Teleport + ) { + mv.appear = [ + new PiPo({ + x: ex, + y: ey, + c: !!tr ? tr.c : initColor, + p: !!tr ? tr.p : initPiece + }) + ]; + mv.vanish = [ + new PiPo({ + x: sx, + y: sy, + c: initColor, + p: initPiece + }) + ]; + } + if (this.board[ex][ey] != "") { + mv.vanish.push( + new PiPo({ + x: ex, + y: ey, + c: this.getColor(ex, ey), + p: this.board[ex][ey].charAt(1) + }) + ); + if (this.options["rifle"]) + // Rifle captures are tricky in combination with Atomic etc, + // so it's useful to mark the move : + mv.capture = true; + if (this.options["cannibal"] && destColor != initColor) { + const lastIdx = mv.vanish.length - 1; + if (mv.appear.length >= 1) mv.appear[0].p = mv.vanish[lastIdx].p; + else if (this.options["rifle"]) { + mv.appear.unshift( + new PiPo({ + x: sx, + y: sy, + c: initColor, + p: mv.vanish[lastIdx].p + }) + ); + mv.vanish.unshift( + new PiPo({ + x: sx, + y: sy, + c: initColor, + p: initPiece + }) + ); + } + } + } + return mv; + } + + // En-passant square, if any + getEpSquare(moveOrSquare) { + if (typeof moveOrSquare === "string") { + const square = moveOrSquare; + if (square == "-") return undefined; + return ChessRules.SquareToCoords(square); + } + // Argument is a move: + const move = moveOrSquare; + const s = move.start, + e = move.end; + if ( + s.y == e.y && + Math.abs(s.x - e.x) == 2 && + // Next conditions for variants like Atomic or Rifle, Recycle... + (move.appear.length > 0 && move.appear[0].p == ChessRules.PAWN) && + (move.vanish.length > 0 && move.vanish[0].p == ChessRules.PAWN) + ) { + return { + x: (s.x + e.x) / 2, + y: s.y + }; + } + return undefined; //default + } + + // Special case of en-passant captures: treated separately + getEnpassantCaptures([x, y], shiftX) { + const color = this.getColor(x, y); + const oppCol = ChessRules.GetOppCol(color); + let enpassantMove = null; + if ( + !!this.epSquare && + this.epSquare.x == x + shiftX && + Math.abs(this.computeY(this.epSquare.y - y)) == 1 && + this.getColor(x, this.epSquare.y) == oppCol //Doublemove guard... + ) { + const [epx, epy] = [this.epSquare.x, this.epSquare.y]; + this.board[epx][epy] = oppCol + "p"; + enpassantMove = this.getBasicMove([x, y], [epx, epy]); + this.board[epx][epy] = ""; + const lastIdx = enpassantMove.vanish.length - 1; //think Rifle + enpassantMove.vanish[lastIdx].x = x; + } + return !!enpassantMove ? [enpassantMove] : []; + } + + // Consider all potential promotions: + addPawnMoves([x1, y1], [x2, y2], moves, promotions) { + let finalPieces = [ChessRules.PAWN]; + const color = this.getColor(x1, y1); + const lastRank = (color == "w" ? 0 : this.size.x - 1); + if (x2 == lastRank && (!this.options["rifle"] || this.board[x2][y2] == "")) + { + // promotions arg: special override for Hiddenqueen variant + if (promotions) finalPieces = promotions; + else if (this.pawnSpecs.promotions) + finalPieces = this.pawnSpecs.promotions; + } + for (let piece of finalPieces) { + const tr = (piece != ChessRules.PAWN ? { c: color, p: piece } : null); + moves.push(this.getBasicMove([x1, y1], [x2, y2], tr)); + } + } + + // What are the pawn moves from square x,y ? + getPotentialPawnMoves([x, y], promotions) { + const color = this.getColor(x, y); //this.turn doesn't work for Dark mode + const [sizeX, sizeY] = [this.size.x, this.size.y]; + const pawnShiftX = this.pawnSpecs.directions[color]; + const firstRank = (color == "w" ? sizeX - 1 : 0); + const forward = (color == 'w' ? -1 : 1); + + // Pawn movements in shiftX direction: + const getPawnMoves = (shiftX) => { + let moves = []; + // NOTE: next condition is generally true (no pawn on last rank) + if (x + shiftX >= 0 && x + shiftX < sizeX) { + if (this.board[x + shiftX][y] == "") { + // One square forward (or backward) + this.addPawnMoves([x, y], [x + shiftX, y], moves, promotions); + // Next condition because pawns on 1st rank can generally jump + if ( + this.pawnSpecs.twoSquares && + ( + ( + color == 'w' && + x >= this.size.x - 1 - this.pawnSpecs.initShift['w'] + ) + || + (color == 'b' && x <= this.pawnSpecs.initShift['b']) + ) + ) { + if ( + shiftX == forward && + this.board[x + 2 * shiftX][y] == "" + ) { + // Two squares jump + moves.push(this.getBasicMove([x, y], [x + 2 * shiftX, y])); + if ( + this.pawnSpecs.threeSquares && + this.board[x + 3 * shiftX, y] == "" + ) { + // Three squares jump + moves.push(this.getBasicMove([x, y], [x + 3 * shiftX, y])); + } + } + } + } + // Captures + if (this.pawnSpecs.canCapture) { + for (let shiftY of [-1, 1]) { + const yCoord = this.computeY(y + shiftY); + if (yCoord >= 0 && yCoord < sizeY) { + if ( + this.board[x + shiftX][yCoord] != "" && + this.canTake([x, y], [x + shiftX, yCoord]) && + ( + !this.options["zen"] || + this.getPiece(x + shiftX, yCoord) == ChessRules.KING + ) + ) { + this.addPawnMoves( + [x, y], [x + shiftX, yCoord], + moves, promotions + ); + } + if ( + this.pawnSpecs.captureBackward && shiftX == forward && + x - shiftX >= 0 && x - shiftX < this.size.x && + this.board[x - shiftX][yCoord] != "" && + this.canTake([x, y], [x - shiftX, yCoord]) && + ( + !this.options["zen"] || + this.getPiece(x + shiftX, yCoord) == ChessRules.KING + ) + ) { + this.addPawnMoves( + [x, y], [x - shiftX, yCoord], + moves, promotions + ); + } + } + } + } + } + return moves; + } + + let pMoves = getPawnMoves(pawnShiftX); + if (this.pawnSpecs.bidirectional) + pMoves = pMoves.concat(getPawnMoves(-pawnShiftX)); + + if (this.hasEnpassant) { + // NOTE: backward en-passant captures are not considered + // because no rules define them (for now). + Array.prototype.push.apply( + pMoves, + this.getEnpassantCaptures([x, y], pawnShiftX) + ); + } + + if (this.options["zen"]) + Array.prototype.push.apply(pMoves, this.getZenCaptures(x, y)); + + return pMoves; + } + + // "castleInCheck" arg to let some variants castle under check + getCastleMoves([x, y], finalSquares, castleInCheck, castleWith) { + const c = this.getColor(x, y); + + // Castling ? + const oppCol = ChessRules.GetOppCol(c); + let moves = []; + // King, then rook: + finalSquares = + finalSquares || [ [2, 3], [this.size.y - 2, this.size.y - 3] ]; + const castlingKing = this.board[x][y].charAt(1); + castlingCheck: for ( + let castleSide = 0; + castleSide < 2; + castleSide++ //large, then small + ) { + if (this.castleFlags[c][castleSide] >= this.size.y) continue; + // If this code is reached, rook and king are on initial position + + // NOTE: in some variants this is not a rook + const rookPos = this.castleFlags[c][castleSide]; + const castlingPiece = this.board[x][rookPos].charAt(1); + if ( + this.board[x][rookPos] == "" || + this.getColor(x, rookPos) != c || + (!!castleWith && !castleWith.includes(castlingPiece)) + ) { + // Rook is not here, or changed color (see Benedict) + continue; + } + // Nothing on the path of the king ? (and no checks) + const finDist = finalSquares[castleSide][0] - y; + let step = finDist / Math.max(1, Math.abs(finDist)); + let i = y; + do { + if ( + (!castleInCheck && this.underCheck([x, i], oppCol)) || + ( + this.board[x][i] != "" && + // NOTE: next check is enough, because of chessboard constraints + (this.getColor(x, i) != c || ![rookPos, y].includes(i)) + ) + ) { + continue castlingCheck; + } + i += step; + } while (i != finalSquares[castleSide][0]); + // Nothing on the path to the rook? + step = (castleSide == 0 ? -1 : 1); + for (i = y + step; i != rookPos; i += step) { + if (this.board[x][i] != "") continue castlingCheck; + } + + // Nothing on final squares, except maybe king and castling rook? + for (i = 0; i < 2; i++) { + if ( + finalSquares[castleSide][i] != rookPos && + this.board[x][finalSquares[castleSide][i]] != "" && + ( + finalSquares[castleSide][i] != y || + this.getColor(x, finalSquares[castleSide][i]) != c + ) + ) { + continue castlingCheck; + } + } + + // If this code is reached, castle is valid + moves.push( + new Move({ + appear: [ + new PiPo({ + x: x, + y: finalSquares[castleSide][0], + p: castlingKing, + c: c + }), + new PiPo({ + x: x, + y: finalSquares[castleSide][1], + p: castlingPiece, + c: c + }) + ], + vanish: [ + // King might be initially disguised (Titan...) + new PiPo({ x: x, y: y, p: castlingKing, c: c }), + new PiPo({ x: x, y: rookPos, p: castlingPiece, c: c }) + ], + end: + Math.abs(y - rookPos) <= 2 + ? { x: x, y: rookPos } + : { x: x, y: y + 2 * (castleSide == 0 ? -1 : 1) } + }) + ); + } + + return moves; + } + + //////////////////// + // MOVES VALIDATION + + // Is (king at) given position under check by "color" ? + underCheck([x, y], color) { + if (this.taking || this.options["dark"]) return false; + color = color || ChessRules.GetOppCol(this.getColor(x, y)); + const pieces = this.pieces(color); + return Object.keys(pieces).some(p => { + return this.isAttackedBy([x, y], p, color, pieces[p]); + }); + } + + isAttackedBy([x, y], piece, color, stepSpec) { + const steps = stepSpec.attack || stepSpec.steps; + const range = stepSpec.range; + let explored = {}; //for Cylinder mode + outerLoop: for (let step of steps) { + let rx = x - step[0], + ry = this.computeY(y - step[1]); + let stepCounter = 1; + while ( + this.onBoard(rx, ry) && + this.board[rx][ry] == "" && + !explored[rx + "." + ry] + ) { + explored[rx + "." + ry] = true; + if (range <= stepCounter++) continue outerLoop; + rx -= step[0]; + ry = this.computeY(ry - step[1]); + } + if ( + this.onBoard(rx, ry) && + this.board[rx][ry] != "" && + this.getPiece(rx, ry) == piece && + this.getColor(rx, ry) == color && + (!this.options["madrasi"] || !this.isImmobilized([rx, ry])) + ) { + return true; + } + } + return false; + } + + // Stop at first king found (TODO: multi-kings) + searchKingPos(color) { + for (let i=0; i < this.size.x; i++) { + for (let j=0; j < this.size.y; j++) { + if (this.board[i][j] == color + 'k') return [i, j]; + } + } + return [-1, -1]; //king not found + } + + filterValid(moves) { + if (moves.length == 0) return []; + const color = this.turn; + const oppCol = ChessRules.GetOppCol(color); + if (this.options["balance"] && [1, 3].includes(this.movesCount)) { + // Forbid moves either giving check or exploding opponent's king: + const oppKingPos = this.searchKingPos(oppCol); + moves = moves.filter(m => { + if ( + m.vanish.some(v => v.c == oppCol && v.p == ChessRules.KING) && + m.appear.every(a => a.c != oppCol || a.p != ChessRUles.KING) + ) + return false; + this.playOnBoard(m); + const res = !this.underCheck(oppKingPos, color); + this.undoOnBoard(m); + return res; + }); + } + if (this.taking || this.options["dark"]) return moves; + const kingPos = this.searchKingPos(color); + let filtered = {}; //avoid re-checking similar moves (promotions...) + return moves.filter(m => { + const key = m.start.x + m.start.y + '.' + m.end.x + m.end.y; + if (!filtered[key]) { + this.playOnBoard(m); + let square = kingPos, + res = true; //a priori valid + if (m.vanish.some(v => v.p == ChessRules.KING && v.c == color)) { + // Search king in appear array: + const newKingIdx = + m.appear.findIndex(a => a.p == ChessRules.KING && a.c == color); + if (newKingIdx >= 0) + square = [m.appear[newKingIdx].x, m.appear[newKingIdx].y]; + else res = false; + } + res &&= !this.underCheck(square, oppCol); + this.undoOnBoard(m); + filtered[key] = res; + return res; + } + return filtered[key]; + }); + } + + ///////////////// + // MOVES PLAYING + + // Aggregate flags into one object + aggregateFlags() { + return this.castleFlags; + } + + // Reverse operation + disaggregateFlags(flags) { + this.castleFlags = flags; + } + + // Apply a move on board + playOnBoard(move) { + for (let psq of move.vanish) this.board[psq.x][psq.y] = ""; + for (let psq of move.appear) this.board[psq.x][psq.y] = psq.c + psq.p; + } + // Un-apply the played move + undoOnBoard(move) { + for (let psq of move.appear) this.board[psq.x][psq.y] = ""; + for (let psq of move.vanish) this.board[psq.x][psq.y] = psq.c + psq.p; + } + + updateCastleFlags(move) { + // Update castling flags if start or arrive from/at rook/king locations + move.appear.concat(move.vanish).forEach(psq => { + if ( + this.board[psq.x][psq.y] != "" && + this.getPiece(psq.x, psq.y) == ChessRules.KING + ) { + this.castleFlags[psq.c] = [this.size.y, this.size.y]; + } + // NOTE: not "else if" because king can capture enemy rook... + let c = ''; + if (psq.x == 0) c = 'b'; + else if (psq.x == this.size.x - 1) c = 'w'; + if (c != '') { + const fidx = this.castleFlags[c].findIndex(f => f == psq.y); + if (fidx >= 0) this.castleFlags[c][fidx] = this.size.y; + } + }); + } + + prePlay(move) { + if ( + typeof move.start.x == "number" && + (!this.options["teleport"] || this.subTurn == 1) + ) { + // OK, not a drop move + if ( + this.hasCastle && + // If flags already off, no need to re-check: + Object.keys(this.castleFlags).some(c => { + return this.castleFlags[c].some(val => val < this.size.y)}) + ) { + this.updateCastleFlags(move); + } + const initSquare = ChessRules.CoordsToSquare(move.start); + if ( + this.options["crazyhouse"] && + (!this.options["rifle"] || !move.capture) + ) { + if (this.ispawn[initSquare]) { + delete this.ispawn[initSquare]; + this.ispawn[ChessRules.CoordsToSquare(move.end)] = true; + } + else if ( + move.vanish[0].p == ChessRules.PAWN && + move.appear[0].p != ChessRules.PAWN + ) { + this.ispawn[ChessRules.CoordsToSquare(move.end)] = true; + } + } + } + const minSize = Math.min(move.appear.length, move.vanish.length); + if (this.hasReserve) { + const color = this.turn; + for (let i=minSize; i move.appear.length && + move.vanish[move.vanish.length - 1].c == color + ) { + const v = move.vanish[move.vanish.length - 1]; + this.captured = {x: v.x, y: v.y, c: v.c, p: v.p}; + this.subTurn = 2; + return; + } + this.captured = null; + } + if (this.options["balance"]) { + if (![1, 3].includes(this.movesCount)) this.turn = oppCol; + } + else { + if ( + ( + this.options["doublemove"] && + this.movesCount >= 1 && + this.subTurn == 1 + ) || + (this.options["progressive"] && this.subTurn <= this.movesCount) + ) { + const oppKingPos = this.searchKingPos(oppCol); + if (oppKingPos[0] >= 0 && !this.underCheck(oppKingPos, color)) { + this.subTurn++; + return; + } + } + this.turn = oppCol; + } + this.movesCount++; + this.subTurn = 1; + } + + // "Stop at the first move found" + atLeastOneMove(color) { + color = color || this.turn; + for (let i = 0; i < this.size.x; i++) { + for (let j = 0; j < this.size.y; j++) { + if (this.board[i][j] != "" && this.getColor(i, j) == color) { + // TODO?: do not search all possible moves here + const moves = this.getPotentialMovesFrom([i, j]); + if (moves.some(m => this.filterValid([m]).length >= 1)) return true; + } + } + } + if (this.hasReserve && this.reserve[color]) { + for (let p of Object.keys(this.reserve[color])) { + const moves = this.getDropMovesFrom([color, p]); + if (moves.some(m => this.filterValid([m]).length >= 1)) return true; + } + } + return false; + } + + // What is the score ? (Interesting if game is over) + getCurrentScore(move) { + const color = this.turn; + const oppCol = ChessRules.GetOppCol(color); + const kingPos = [this.searchKingPos(color), this.searchKingPos(oppCol)]; + if (kingPos[0][0] < 0 && kingPos[1][0] < 0) return "1/2"; + if (kingPos[0][0] < 0) return (color == "w" ? "0-1" : "1-0"); + if (kingPos[1][0] < 0) return (color == "w" ? "1-0" : "0-1"); + if (this.atLeastOneMove()) return "*"; + // No valid move: stalemate or checkmate? + if (!this.underCheck(kingPos, color)) return "1/2"; + // OK, checkmate + return (color == "w" ? "0-1" : "1-0"); + } + + // NOTE: quite suboptimal for eg. Benedict (TODO?) + playVisual(move, r) { + move.vanish.forEach(v => { + if (!this.enlightened || this.enlightened[v.x][v.y]) { + this.g_pieces[v.x][v.y].remove(); + this.g_pieces[v.x][v.y] = null; + } + }); + let container = document.getElementById(this.containerId); + if (!r) r = container.getBoundingClientRect(); + const pieceWidth = this.getPieceWidth(r.width); + move.appear.forEach(a => { + if (this.enlightened && !this.enlightened[a.x][a.y]) return; + this.g_pieces[a.x][a.y] = document.createElement("piece"); + this.g_pieces[a.x][a.y].classList.add(this.pieces()[a.p]["class"]); + this.g_pieces[a.x][a.y].classList.add(a.c == "w" ? "white" : "black"); + this.g_pieces[a.x][a.y].style.width = pieceWidth + "px"; + this.g_pieces[a.x][a.y].style.height = pieceWidth + "px"; + const [ip, jp] = this.getPixelPosition(a.x, a.y, r); + this.g_pieces[a.x][a.y].style.transform = `translate(${ip}px,${jp}px)`; + container.appendChild(this.g_pieces[a.x][a.y]); + }); + } + + playPlusVisual(move, r) { + this.playVisual(move, r); + this.play(move); + this.afterPlay(move); //user method + } + + // Assumes reserve on top (usage case otherwise? TODO?) + getReserveShift(c, p, r) { + let nbR = 0, + ridx = 0; + for (let pi of Object.keys(this.reserve[c])) { + if (this.reserve[c][pi] == 0) continue; + if (pi == p) ridx = nbR; + nbR++; + } + const rsqSize = this.getReserveSquareSize(r.width, nbR); + return [ridx * rsqSize, rsqSize]; //slightly inaccurate... TODO? + } + + animate(move, callback) { + if (this.noAnimate) { + callback(); + return; + } + const [i1, j1] = [move.start.x, move.start.y]; + const dropMove = (typeof i1 == "string"); + const startArray = (dropMove ? this.r_pieces : this.g_pieces); + let startPiece = startArray[i1][j1]; + let container = document.getElementById(this.containerId); + const clonePiece = ( + !dropMove && + this.options["rifle"] || + (this.options["teleport"] && this.subTurn == 2) + ); + if (clonePiece) { + startPiece = startPiece.cloneNode(); + if (this.options["rifle"]) startArray[i1][j1].style.opacity = "0"; + if (this.options["teleport"] && this.subTurn == 2) { + const pieces = this.pieces(); + const startCode = (dropMove ? j1 : this.getPiece(i1, j1)); + startPiece.classList.remove(pieces[startCode]["class"]); + startPiece.classList.add(pieces[this.captured.p]["class"]); + // Color: OK + } + container.appendChild(startPiece); + } + const [i2, j2] = [move.end.x, move.end.y]; + let startCoords; + if (dropMove) { + startCoords = [ + i1 == this.playerColor ? this.size.x : 0, + this.size.y / 2 //not trying to be accurate here... (TODO?) + ]; + } + else startCoords = [i1, j1]; + const r = container.getBoundingClientRect(); + const arrival = this.getPixelPosition(i2, j2, r); //TODO: arrival on drop? + let rs = [0, 0]; + if (dropMove) rs = this.getReserveShift(i1, j1, r); + const distance = + Math.sqrt((startCoords[0] - i2) ** 2 + (startCoords[1] - j2) ** 2); + const maxDist = Math.sqrt((this.size.x - 1)** 2 + (this.size.y - 1) ** 2); + const multFact = (distance - 1) / (maxDist - 1); //1 == minDist + const duration = 0.2 + multFact * 0.3; + startPiece.style.transform = + `translate(${arrival[0] + rs[0]}px, ${arrival[1] + rs[1]}px)`; + startPiece.style.transitionDuration = duration + "s"; + setTimeout( + () => { + if (clonePiece) { + if (this.options["rifle"]) startArray[i1][j1].style.opacity = "1"; + startPiece.remove(); + } + callback(); + }, + duration * 1000 + ); + } + + playReceivedMove(moves, callback) { + const r = + document.getElementById(this.containerId).getBoundingClientRect(); + const animateRec = i => { + this.animate(moves[i], () => { + this.playVisual(moves[i], r); + this.play(moves[i]); + if (i < moves.length - 1) setTimeout(() => animateRec(i+1), 300); + else callback(); + }); + }; + animateRec(0); + } + +}; diff --git a/common.css b/common.css new file mode 100644 index 0000000..c0dba37 --- /dev/null +++ b/common.css @@ -0,0 +1,225 @@ +body { + margin: 0; + text-align: center; + background-color: #f8f8f8; + font-family: Arial, Verdana, Tahoma, sans-serif; +} + +#gameInfos, #boardContainer, #gameStopped, #pendingSeek, #pendingRematch, #newGameForm { + display: none; +} + +#gameStopped > h1 { + margin-bottom: 10px; +} + +/* Sticky footer */ +footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50px; +} + +button { + background-color: green; + border: none; + color: white; + padding: 10px 15px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 1em; + cursor: pointer; + border-radius: 20%; + margin: 15px 0; +} + +button:hover { + background-color: darkblue; +} + +button.block-btn { + display: block; + margin: 30px auto 20px auto; + font-size: 1.5em; + padding: 15px 32px; +} + +#upLeftInfos { + position: absolute; + left: 0; + top: 0; +} + +#upRightStop { + position: absolute; + left: calc(100% - 25px); + top: 0; +} + +#upLeftInfos > img, #upRightStop > img { + width: 25px; + cursor: pointer; +} + +#ng-select { + margin-bottom: 20px; +} + +#ng-name { + /* TODO */ +} + +/* Options when starting custom game */ +.word { + cursor: pointer; + padding: 3px; +} +.highlight-word { + background-color: lightblue; +} + +/* Game link div + custom game "button" */ +#gameLink span, #gameLink a, #playCustom { + text-decoration: underline; + color: blue; + cursor: pointer; +} + +#selectVariant { + margin-right: 15px; +} + +/* Board container (without reserves) */ +#chessboard { + position: absolute; + cursor: pointer; +} + +/* Board container can be resized */ +.resizeable { + resize: both; + overflow: hidden; + min-width: 200px; + min-height: 200px; +} + +piece { + position: absolute; + top: 0; + left: 0; + background-size: cover; + z-index: 2; + will-change: transform; + pointer-events: none; +} + +/* Drawing of the board */ +#chessboard_SVG { + width: 100%; + height: 100%; +} + +.reserves { + position: absolute; + display: block; + cursor: pointer; +} + +.reserve-cell { + position: relative; + display: block; + float: left; +} + +/* Pieces' counter for reserves */ +.reserve-num { + color: red; + position: absolute; + display: block; + font-weight: bold; + /*z-index: 10;*/ +} + +/* Choices div after a promotion (TODO: do not hide board) */ +#choices, .choice { + position: absolute; + cursor: pointer; +} + +/* https://theanam.github.io/css-only-loaders/ ("hour-glass") */ +:root{ + --loader-width: 70px; + --loader-height: 70px; + --loader-color-primary: #141D58; + --loader-color-secondary: #EEE; + --line-width: 3px; + --animation-duration: 3s; + --loader-initial-scale: 0.1; +} +.loader,.loader:before,.loader:after{ + box-sizing: border-box; + flex-grow: 0; + flex-shrink: 0; +} + +@keyframes slide { + 0% { + transform: translateY(0%); + } + 25% { + transform: translateY(100%); + } + 50% { + transform: translateY(100%); + } + 75% { + transform: translateY(0%); + } + 100% { + transform: translateY(0%); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(0deg); + } + 50% { + transform: rotate(180deg); + } + 75% { + transform: rotate(180deg); + } + 100% { + transform: rotate(360deg); + } +} + +.loader.hour-glass { + position: relative; + width: var(--loader-width, 100px); + height: var(--loader-height, 100px); + background-color: var(--loader-color-primary, #00f); + -webkit-clip-path: polygon(0% 0%, 100% 0%, 50% 50%, 100% 100%, 0% 100%, 50% 50%); + clip-path: polygon(0% 0%, 100% 0%, 50% 50%, 100% 100%, 0% 100%, 50% 50%); + overflow: hidden; + animation: spin var(--animation-duration, 4s) infinite ease-in-out; + margin: 20px auto; +} + +.hour-glass:before { + content: ""; + position: absolute; + top: 0px; + left: 0px; + width: var(--loader-width, 100px); + height: 50%; + background-color: var(--loader-color-secondary, #eee); + animation: slide var(--animation-duration, 4s) infinite ease-in-out; +} diff --git a/favicon.ico b/favicon.ico new file mode 120000 index 0000000..f6ff788 --- /dev/null +++ b/favicon.ico @@ -0,0 +1 @@ +assets/favicon.ico \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..12253ed --- /dev/null +++ b/index.html @@ -0,0 +1,85 @@ + + + + + + xogo + + + + + +
+
+
+

Game over

+ + +
+
+
+ +
+
+ +
+ + + Customize + +
+
+ + +
+ +
+
+
+ +
+
+
+ + +
+
+ + + +
+ + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7f52ea0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "xogo", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "ws": "^7.5.3" + } + }, + "node_modules/ws": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", + "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + }, + "dependencies": { + "ws": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", + "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "requires": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b02fc5e --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "ws": "^7.5.3" + } +} diff --git a/parameters.js.dist b/parameters.js.dist new file mode 100644 index 0000000..c1e3eb5 --- /dev/null +++ b/parameters.js.dist @@ -0,0 +1,16 @@ +const Params = { + + // Usage on client: + http_server: "http://localhost:8000", //https://xogo.live + socket_server: "ws://localhost:8080", //wss://xogo.live + + // Usage on (socket) server: + socket_port: 8080, //... + + // Usage on both: + socket_path: "/ws", + +}; + +// Next line for usage on server (Node.js) +if (typeof window === 'undefined') module.exports = Params; diff --git a/server.js b/server.js new file mode 100644 index 0000000..c21d8bf --- /dev/null +++ b/server.js @@ -0,0 +1,214 @@ +const params = require("./parameters.js"); +const WebSocket = require('ws'); +const wss = new WebSocket.Server( + {port: params.socket_port, path: params.socket_path}); + +let challenges = {}; //variantName --> socketId, name +let games = {}; //gameId --> gameInfo (vname, fen, players, options) +let sockets = {}; //socketId --> socket +const variants = require("./variants.js"); + +const send = (sid, code, data) => { + const socket = sockets[sid]; + // If a player delete local infos and then try to resume a game, + // sockets[oppSid] will probably not exist anymore: + if (socket) socket.send(JSON.stringify(Object.assign({ code: code }, data))); +} + +const Crypto = require('crypto') +function randomString(size = 8) { + return Crypto.randomBytes(size).toString('hex').slice(0, size); +} + +wss.on('connection', function connection(socket, req) { + const sid = req.url.split("=")[1]; //...?sid=... + sockets[sid] = socket; + socket.isAlive = true; + socket.on('pong', () => socket.isAlive = true); + + function launchGame(vname, players, options) { + const gid = randomString(8); + games[gid] = { + vname: vname, + players: players.map(p => { + return (!p ? null : {sid: p.sid, name: p.name}); + }), + options: options + }; + if (players.every(p => p)) { + const gameInfo = Object.assign( + // Provide seed so that both players initialize with same FEN + {seed: Math.floor(Math.random() * 1984), gid: gid}, + games[gid]); + for (let i of [0, 1]) { + send(players[i].sid, "gamestart", + Object.assign({randvar: players[i].randvar}, gameInfo)); + } + } + else { + // Incomplete players array: do not start game yet + send(sid, "gamecreated", {gid: gid}); + // If nobody joins within a minute, delete game + setTimeout( + () => { + if (games[gid] && games[gid].players.some(p => !p)) + delete games[gid]; + }, + 60000 + ); + } + } + + socket.on('message', (msg) => { + const obj = JSON.parse(msg); + switch (obj.code) { + // Send challenge (may trigger game creation) + case "seekgame": { + // Only one challenge per player: + if (Object.keys(challenges).some(k => challenges[k].sid == sid)) + return; + let opponent = undefined, + choice = undefined; + const vname = obj.vname, + randvar = (obj.vname == "_random"); + if (vname == "_random") { + // Pick any current challenge if any + const currentChalls = Object.keys(challenges); + if (currentChalls.length >= 1) { + choice = + currentChalls[Math.floor(Math.random() * currentChalls.length)]; + opponent = challenges[choice]; + } + } + else if (challenges[vname]) { + opponent = challenges[vname]; + choice = vname; + } + if (opponent) { + delete challenges[choice]; + if (choice == "_random") { + // Pick a variant at random in the list + const index = Math.floor(Math.random() * variants.length); + choice = variants[index].name; + } + // Launch game + let players = [ + {sid: sid, name: obj.name, randvar: randvar}, + opponent + ]; + if (Math.random() < 0.5) players = players.reverse(); + launchGame(choice, players, {}); //empty options => default + } + else + // Place challenge and wait. 'randvar' indicate if we play anything + challenges[vname] = {sid: sid, name: obj.name, randvar: randvar}; + break; + } + // Set FEN after game was created + case "setfen": + games[obj.gid].fen = obj.fen; + break; + // Send back game informations + case "getgame": { + if (!games[obj.gid]) send(sid, "nogame"); + else send(sid, "gameinfo", games[obj.gid]); + break; + } + // Cancel challenge + case "cancelseek": + delete challenges[obj.vname]; + break; + // Receive rematch + case "rematch": { + if (!games[obj.gid]) send(sid, "closerematch"); + else { + const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1); + if (!games[obj.gid].rematch) games[obj.gid].rematch = [false, false]; + games[obj.gid].rematch[myIndex] = true; + if (games[obj.gid].rematch[1-myIndex]) { + // Launch new game, colors reversed + launchGame(games[obj.gid].vname, + games[obj.gid].players.reverse(), + games[obj.gid].options); + } + } + break; + } + // Rematch cancellation + case "norematch": { + if (games[obj.gid]) { + const myIndex = (games[obj.gid].players[0].sid == sid ? 0 : 1); + send(games[obj.gid].players[1-myIndex].sid, "closerematch"); + } + break; + } + // Create game vs. friend + case "creategame": + let players = [ + { sid: obj.player.sid, name: obj.player.name }, + undefined + ]; + if ( + obj.player.color == 'b' || + (obj.player.color == '' && Math.random() < 0.5) + ) { + players = players.reverse(); + } + launchGame(obj.vname, players, obj.options); + break; + // Join game vs. friend + case "joingame": { + if (!games[obj.gid]) send(sid, "jointoolate"); + else { + // Join a game (started by some other player) + const emptySlot = games[obj.gid].players.findIndex(p => !p); + if (emptySlot < 0) send(sid, "jointoolate"); + games[obj.gid].players[emptySlot] = {sid: sid, name: obj.name}; + const gameInfo = Object.assign( + // Provide seed so that both players initialize with same FEN + {seed: Math.floor(Math.random()*1984), gid: obj.gid}, + games[obj.gid]); + for (let i of [0, 1]) + send(games[obj.gid].players[i].sid, "gamestart", gameInfo); + } + break; + } + // Relay a move + update games object + case "newmove": { + // TODO?: "pingback" strategy to ensure that move was transmitted + games[obj.gid].fen = obj.fen; + const playingWhite = (games[obj.gid].players[0].sid == sid); + const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid; + send(oppSid, "newmove", { moves: obj.moves }); + break; + } + // Relay "game ends" message + case "gameover": { + const playingWhite = (games[obj.gid].players[0].sid == sid); + const oppSid = games[obj.gid].players[playingWhite ? 1 : 0].sid; + if (obj.relay) send(oppSid, "gameover", { gid: obj.gid }); + games[obj.gid].over = true; + setTimeout( () => delete games[obj.gid], 60000 ); + break; + } + } + }); + socket.on("close", () => { + delete sockets[sid]; + for (const [key, value] of Object.entries(challenges)) { + if (value.sid == sid) { + delete challenges[key]; + break; //only one challenge per player + } + } + }); +}); + +const interval = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); +}, 30000); +wss.on('close', () => clearInterval(interval)); diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..4fe6790 --- /dev/null +++ b/start.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +nodemon -i index.js -i base_rules.js ./server.js & +echo "NODE_PID=$!" > .pid + +# NOTE: require browser plugin + start it +livereload -e 'html,js,css,png,jpg,jpeg,gif,svg' -w 1000 -d . & + +# I use 8080 for socket and 8000 for http server (arbitrary...) +php -S localhost:8000 & diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..8d54dcc --- /dev/null +++ b/stop.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# https://stackoverflow.com/questions/13939038/how-do-you-run-a-command-for-each-line-of-a-file +source ./.pid +kill -9 -`ps p $NODE_PID -o pgid | grep "[0-9]\+" | tr -d " "` diff --git a/utils/Move.js b/utils/Move.js new file mode 100644 index 0000000..62eddea --- /dev/null +++ b/utils/Move.js @@ -0,0 +1,12 @@ +// class "Move": generic ready-to-play verbose move, standardised format. +export default class Move { + // o: {appear, vanish, [start,] [end,]} + // appear,vanish = arrays of PiPo + // start,end = coordinates to apply to trigger move visually (think castle) + constructor(o) { + this.appear = o.appear; + this.vanish = o.vanish; + this.start = o.start || { x: o.vanish[0].x, y: o.vanish[0].y }; + this.end = o.end || { x: o.appear[0].x, y: o.appear[0].y }; + } +}; diff --git a/utils/PiPo.js b/utils/PiPo.js new file mode 100644 index 0000000..8d55134 --- /dev/null +++ b/utils/PiPo.js @@ -0,0 +1,10 @@ +// class "PiPo": Piece + Position +export default class PiPo { + // o: {piece[p], color[c], posX[x], posY[y]} + constructor(o) { + this.p = o.p; + this.c = o.c; + this.x = o.x; + this.y = o.y; + } +}; diff --git a/utils/alea.js b/utils/alea.js new file mode 100644 index 0000000..8b5a4ea --- /dev/null +++ b/utils/alea.js @@ -0,0 +1,44 @@ +// https://stackoverflow.com/a/47593316/12660887 +function mulberry32(a) { + return function() { + let t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } +} + +export const Random = { + + rand: null, + + setSeed: function(a) { + Random.rand = mulberry32(a); + }, + + randInt: function(min, max) { + if (!max) { + max = min; + min = 0; + } + if (!Random.rand) + Random.setSeed(Math.floor(Math.random() * 1984)); + return Math.floor(Random.rand() * (max - min)) + min; + }, + + // Inspired by https://github.com/jashkenas/underscore + sample: function(arr, n) { + n = n || 1; + let cpArr = arr.map(e => e); + for (let index = 0; index < n; index++) { + const rand = randInt(index, arr.length); + [ cpArr[index], cpArr[rand] ] = [ cpArr[rand], cpArr[index] ]; + } + return cpArr.slice(0, n); + }, + + shuffle: function(arr) { + return sample(arr, arr.length); + } + +}; diff --git a/utils/array.js b/utils/array.js new file mode 100644 index 0000000..8f2ebfe --- /dev/null +++ b/utils/array.js @@ -0,0 +1,12 @@ +export const ArrayFun = { + + // Double array intialization + init: function(size1, size2, initElem) { + return [...Array(size1)].map(() => Array(size2).fill(initElem)); + }, + + range: function(max) { + return [...Array(max).keys()]; + } + +}; diff --git a/variants.js b/variants.js new file mode 100644 index 0000000..fb77f1d --- /dev/null +++ b/variants.js @@ -0,0 +1,163 @@ +const variants = [ +/* { name: 'Absorption', desc: 'Absorb powers' }, + { name: 'Alapo', desc: 'Geometric Chess' }, + { name: 'Alice', desc: 'Both sides of the mirror' }, + { name: 'Align4', desc: 'Align four pawns' }, + { name: 'Allmate', desc: 'Mate any piece' }, + { name: 'Ambiguous', desc: "Play opponent's pieces" }, + { name: 'Antiking1', desc: 'Keep antiking in check', disp: 'Anti-King' }, + { name: 'Antimatter', desc: 'Dangerous collisions', disp: 'Antimatter' }, + { name: 'Apocalypse', desc: 'The end of the world' }, + { name: 'Arena', desc: 'Middle battle' }, + { name: 'Atarigo', desc: 'First capture wins', disp: 'Atari-Go' }, + { name: 'Atomic', desc: 'Explosive captures' }, + { name: 'Avalam', desc: 'Build towers' }, + { name: 'Avalanche', desc: 'Pawnfalls' }, + { name: 'Ball', desc: 'Score a goal' }, + { name: 'Balaklava', desc: 'Meet the Mammoth' }, + { name: 'Bario', desc: 'A quantum story' }, + { name: 'Baroque', desc: 'Exotic captures' },*/ + { name: "Benedict", desc: "Change colors" }, +/* { name: 'Berolina', desc: 'Pawns move diagonally' }, + { name: 'Bicolour', desc: 'Harassed kings' }, + { name: 'Bishopawns', desc: 'Bishop versus pawns', disp: 'Bishop-Pawns' }, + { name: 'Brotherhood', desc: 'Friendly pieces' }, + { name: 'Cannibal', desc: 'Capture powers' }, + { name: 'Capablanca', desc: 'Capablanca Chess', disp: 'Capablanca Chess' }, + { name: 'Capture', desc: 'Mandatory captures' }, + { name: 'Castle', desc: 'Win by castling long' }, + { name: 'Chakart', desc: 'Capture the princess' }, + { name: 'Checkered', desc: 'Shared pieces' }, + { name: 'Checkless', desc: 'No-check mode' }, +*/ { name: 'Chess960', disp: "Chess 960", desc: "Standard rules" }, +/* { name: 'Circular', desc: 'Run forward' }, + { name: 'Clorange', desc: 'A Clockwork Orange', disp: 'Clockwork Orange' }, + { name: 'Convert', desc: 'Convert enemy pieces' }, + { name: 'Copycat', desc: 'Borrow powers' }, + { name: 'Coregal', desc: 'Two royal pieces' }, + { name: 'Coronation', desc: 'Long live the Queen' }, + { name: 'Crazyhouse', desc: 'Captures reborn' }, + { name: 'Crossing', desc: 'Cross the river' }, + { name: 'Cylinder', desc: 'Neverending rows' }, + { name: 'Cwda', desc: 'New teams', disp: 'Different armies' }, + { name: 'Dark', desc: 'In the shadow' }, + { name: 'Diamond', desc: 'Rotating board' }, + { name: 'Dice', desc: 'Roll the dice' }, + { name: 'Discoduel', desc: 'Enter the disco', disp: 'Disco Duel' }, + { name: 'Dobutsu', desc: "Let's catch the Lion!" }, + { name: 'Doublearmy', desc: '64 pieces on the board', disp: 'Double Army' }, + { name: 'Doublemove', desc: 'Double moves' }, + { name: 'Dynamo', desc: 'Push and pull' }, + { name: 'Eightpieces', desc: 'Each piece is unique', disp: '8-Pieces' }, + { name: 'Emergo', desc: 'Stacking Checkers variant' }, + { name: 'Empire', desc: 'Empire versus Kingdom' }, + { name: 'Enpassant', desc: 'Capture en passant', disp: 'En-passant' }, + { name: 'Evolution', desc: 'Faster development' }, + { name: 'Extinction', desc: 'Capture all of a kind' }, + { name: 'Fanorona', desc: 'Malagasy Draughts' }, + { name: 'Football', desc: 'Score a goal' }, + { name: 'Forward', desc: 'Moving forward' }, + { name: 'Freecapture', desc: 'Capture both colors', disp: 'Free Capture' }, + { name: 'Fugue', desc: 'Baroque Music' }, + { name: 'Fullcavalry', desc: 'Lancers everywhere', disp: 'Full Cavalry' }, + { name: 'Fusion', desc: 'Fusion pieces (v1)' }, + { name: 'Gomoku', desc: 'Align five stones' }, + { name: 'Grand', desc: 'Big board' }, + { name: 'Grasshopper', desc: 'Long jumps over pieces' }, + { name: 'Gridolina', desc: 'Jump the borders' }, + { name: 'Hamilton', desc: 'Walk on a graph' }, + { name: 'Hidden', desc: 'Unidentified pieces', disp: 'Strate-Go' }, + { name: 'Hiddenqueen', desc: 'Queen disguised as a pawn', disp: 'Hidden Queen' }, + { name: 'Hoppelpoppel', desc: 'Knibis and Bisknis', disp: 'Hoppel-Poppel' }, + { name: 'Horde', desc: 'A pawns cloud' }, + { name: 'Hypnotic', desc: 'Mind control (v1)' }, + { name: 'Iceage', desc: 'Ice Age is coming!', disp: 'Ice Age' }, + { name: 'Interweave', desc: 'Interweaved colorbound teams' }, + { name: 'Isardam', desc: 'No paralyzed pieces' }, + { name: 'Janggi', desc: 'Korean Chess' }, + { name: 'Joker', desc: 'Replace pieces' }, + { name: 'Karouk', desc: 'Thai Chess (v3)', disp: 'Kar-Ouk' }, + { name: 'Kinglet', desc: 'Protect your pawns' }, + { name: 'Kingsmaker', desc: 'Promote into kings' }, + { name: 'Knightmate', desc: 'Mate the knight' }, + { name: 'Knightpawns', desc: 'Knight versus pawns', disp: 'Knight-Pawns' }, + { name: 'Knightrelay', desc: 'Move like a knight' }, + { name: 'Konane', desc: 'Hawaiian Checkers' }, + { name: 'Koopa', desc: 'Stun & kick pieces' }, + { name: 'Koth', desc: 'King of the Hill', disp:'King of the Hill' }, + { name: 'Losers', desc: 'Get strong at self-mate' }, + { name: 'Madhouse', desc: 'Rearrange enemy pieces' }, + { name: 'Madrasi', desc: 'Paralyzed pieces' }, + { name: 'Magnetic', desc: 'Laws of attraction' }, + { name: 'Maharajah', desc: 'Augmented Queens' }, + { name: 'Makpong', desc: 'Thai Chess (v2)' }, + { name: 'Makruk', desc: 'Thai Chess (v1)' }, + { name: 'Maxima', desc: 'Occupy the enemy palace' }, + { name: 'Mesmer', desc: 'Mind control (v2)' }, + { name: 'Minishogi', desc: 'Shogi 5 x 5' }, + { name: 'Minixiangqi', desc: 'Xiangqi 7 x 7' }, + { name: 'Monocolor', desc: 'All of the same color' }, + { name: 'Monster', desc: 'White move twice' }, + { name: 'Musketeer', desc: 'New fairy pieces' }, + { name: 'Newzealand', desc: 'Kniros and Rosknis', disp: 'New-Zealand' }, + { name: 'Omega', desc: 'A wizard in the corner' }, + { name: 'Orda', desc: 'Mongolian Horde (v1)' }, + { name: 'Ordamirror', desc: 'Mongolian Horde (v2)', disp: 'Orda Mirror' }, + { name: 'Otage', desc: 'Capture and release hostages' }, + { name: 'Pacifist', desc: 'Convert & support' }, + { name: 'Pacosako', desc: 'Dance with the King', disp: 'Paco-Sako' }, + { name: 'Pandemonium', desc: 'Noise and confusion' }, + { name: 'Parachute', desc: 'Landing on the board' }, + { name: 'Pawnmassacre', desc: 'Pieces upside down', disp: 'Pawn Massacre' }, + { name: 'Pawns', desc: 'Reach the last rank (v1)' }, + { name: 'Pawnsking', desc: 'Reach the last rank (v2)', disp: 'Pawns & King' }, + { name: 'Perfect', desc: 'Powerful pieces' }, + { name: 'Pocketknight', desc: 'Knight in pocket', disp: 'Pocket Knight' }, + { name: 'Progressive', desc: 'Play more and more moves' }, + { name: 'Queenpawns', desc: 'Queen versus pawns', disp: 'Queen-Pawns' }, + { name: 'Racingkings', desc: 'Kings cross the 8x8 board', disp: 'Racing Kings' }, + { name: 'Rampage', desc: 'Move under cover' }, + { name: 'Relayup', desc: 'Upgrade pieces', disp: 'Relay-up' }, + { name: 'Rifle', desc: 'Shoot pieces' }, + { name: 'Recycle', desc: 'Reuse pieces' }, + { name: 'Refusal', desc: 'Do not play that!' }, + { name: 'Rollerball', desc: 'As in the movie' }, + { name: 'Rococo', desc: 'Capture on the edge' }, + { name: 'Rookpawns', desc: 'Rook versus pawns', disp: 'Rook-Pawns' }, + { name: 'Royalrace', desc: 'Kings cross the 11x11 board', disp: 'Royal Race' }, + { name: 'Rugby', desc: 'Transform an essay' }, + { name: 'Schess', desc: 'Seirawan-Harper Chess', disp: 'Seirawan-Harper Chess' }, + { name: 'Screen', desc: 'Free initial setup' }, + { name: 'Selfabsorb', desc: 'Fusion pieces (v2)', disp: 'Self-Absorption' }, + { name: 'Shako', desc: 'Non-conformism and utopia' }, + { name: 'Shatranj', desc: 'Ancient rules' }, + { name: 'Shinobi', desc: 'A story of invasion' }, + { name: 'Shogi', desc: 'Japanese Chess' }, + { name: 'Shogun', desc: "General's Chess" }, + { name: 'Sittuyin', desc: 'Burmese Chess' }, + { name: 'Spartan', desc: 'Spartan versus Persians' }, + { name: 'Squatter', desc: 'Squat last rank' }, + { name: 'Stealthbomb', desc: 'Beware the bomb', disp: 'Stealth Bomb' }, + { name: 'Suicide', desc: 'Lose all pieces' }, + { name: 'Suction', desc: 'Attract opposite king' }, + { name: 'Swap', desc: 'Dangerous captures' }, + { name: 'Switching', desc: "Exchange pieces' positions" }, + { name: 'Synchrone', desc: 'Play at the same time' }, + { name: 'Synochess', desc: 'Dynasty versus Kingdom' }, + { name: 'Takenmake', desc: 'Prolongated captures', disp: 'Take and make' }, + { name: 'Teleport', desc: 'Reposition pieces' }, + { name: 'Tencubed', desc: 'Four new pieces' }, + { name: 'Threechecks', desc: 'Give three checks', disp: 'Three Checks' }, + { name: 'Titan', desc: 'Extra bishops and knights' }, + { name: 'Twokings', desc: 'Two kings', disp: 'Two Kings' }, + { name: 'Upsidedown', desc: 'Board upside down', disp: 'Upside-down' }, + { name: 'Vchess', desc: 'Pawns capture backward', disp: 'Victor Chess' }, + { name: 'Wildebeest', desc: 'Balanced sliders & leapers' }, + { name: 'Wormhole', desc: 'Squares disappear' }, + { name: 'Xiangqi', desc: 'Chinese Chess' }, + { name: 'Yote', desc: 'African Draughts' },*/ + { name: "Zen", disp: "Zen Chess", desc: "Reverse captures" } +]; + +// Next line for usage on server (Node.js) +if (typeof window === 'undefined') module.exports = variants; diff --git a/variants/Benedict/class.js b/variants/Benedict/class.js new file mode 100644 index 0000000..c0fffcc --- /dev/null +++ b/variants/Benedict/class.js @@ -0,0 +1,98 @@ +import ChessRules from "/base_rules.js"; +import PiPo from "/utils/PiPo.js"; + +export default class BenedictRules extends ChessRules { + + static get Options() { + return { + select: ChessRules.Options.select, + check: [], + styles: ( + ChessRules.Options.styles.filter(s => { + return ( + ["balance", "cylinder", "dark", "doublemove", "progressive", "zen"] + .includes(s) + ); + }) + ) + }; + } + + get hasEnpassant() { + return false; + } + + get pawnSpecs() { + return Object.assign( + {}, + super.pawnSpecs, + { canCapture: false } + ); + } + + canTake() { + return false; + } + + // Find potential captures from a square + // follow steps from x,y until something is met. + findAttacks([x, y]) { + const [color, piece] = [this.getColor(x, y), this.getPiece(x, y)]; + const oppCol = ChessRules.GetOppCol(color); + let squares = {}; + const specs = this.pieces(color)[piece]; + const steps = specs.attack || specs.steps; + outerLoop: for (let step of steps) { + let [i, j] = [x + step[0], this.computeY(y + step[1])]; + let nbSteps = 1; + while (this.onBoard(i, j) && this.board[i][j] == "") { + if (specs.range <= nbSteps++) continue outerLoop; + i += step[0]; + j = this.computeY(j + step[1]); + } + if (this.onBoard(i, j) && this.getColor(i, j) == oppCol) + squares[ChessRules.CoordsToSquare({x: i, y: j})] = true; + } + return Object.keys(squares); + } + + postProcessPotentialMoves(moves) { + if (moves.length == 0) return moves; + const [x, y] = [moves[0].end.x, moves[0].end.y]; + const color = this.getColor(moves[0].start.x, moves[0].start.y); + const oppCol = ChessRules.GetOppCol(color); + moves = super.postProcessPotentialMoves(moves); + moves.forEach(m => { + this.playOnBoard(m); + let attacks; + if (this.options["zen"]) { + let endSquares = {}; + super.getZenCaptures(x, y).forEach(c => { + endSquares[ChessRules.CoordsToSquare(c.end)] = true; + }); + attacks = Object.keys(endSquares); + } + else attacks = this.findAttacks([m.end.x, m.end.y]) + this.undoOnBoard(m); + attacks.map(ChessRules.SquareToCoords).forEach(a => { + const p = this.getPiece(a.x, a.y); + m.appear.push(new PiPo({x: a.x, y: a.y, c: color, p: p})); + m.vanish.push(new PiPo({x: a.x, y: a.y, c: oppCol, p: p})); + }); + }); + return moves; + } + + // Moves cannot flip our king's color, so (almost) all are valid + filterValid(moves) { + if (this.options["balance"] && [1, 3].includes(this.movesCount)) + return moves.filter(m => m.vanish.every(v => v.p != ChessRules.KING)); + return moves; + } + + // Since it's used just for the king, and there are no captures: + underCheck(square, color) { + return false; + } + +}; diff --git a/variants/Benedict/rules.html b/variants/Benedict/rules.html new file mode 100644 index 0000000..0211f6b --- /dev/null +++ b/variants/Benedict/rules.html @@ -0,0 +1 @@ +Benedict rules. diff --git a/variants/Benedict/style.css b/variants/Benedict/style.css new file mode 100644 index 0000000..9378e29 --- /dev/null +++ b/variants/Benedict/style.css @@ -0,0 +1 @@ +@import "../../base_pieces.css" diff --git a/variants/Chess960/class.js b/variants/Chess960/class.js new file mode 100644 index 0000000..abafb56 --- /dev/null +++ b/variants/Chess960/class.js @@ -0,0 +1,3 @@ +import ChessRules from "/base_rules.js"; + +export default class Chess960Rules extends ChessRules {}; diff --git a/variants/Chess960/rules.html b/variants/Chess960/rules.html new file mode 100644 index 0000000..46751e1 --- /dev/null +++ b/variants/Chess960/rules.html @@ -0,0 +1 @@ +Orthodox chess rules. diff --git a/variants/Chess960/style.css b/variants/Chess960/style.css new file mode 100644 index 0000000..9378e29 --- /dev/null +++ b/variants/Chess960/style.css @@ -0,0 +1 @@ +@import "../../base_pieces.css" diff --git a/variants/Zen/class.js b/variants/Zen/class.js new file mode 100644 index 0000000..f06dd1f --- /dev/null +++ b/variants/Zen/class.js @@ -0,0 +1,18 @@ +import ChessRules from "/base_rules.js"; + +export default class ZenRules extends ChessRules { + + static get Options() { + return { + select: ChessRules.Options.select, + check: ChessRules.Options.check, + styles: ChessRules.Options.styles.filter(s => s != "zen") + }; + } + + constructor(o) { + super(o); + o.options.zen = true; + } + +}; diff --git a/variants/Zen/rules.html b/variants/Zen/rules.html new file mode 100644 index 0000000..569f213 --- /dev/null +++ b/variants/Zen/rules.html @@ -0,0 +1,3 @@ +

Pieces capture enemy units which threaten them (normal captures are disabled).

+ +

Exception: the king is attacked as usual.

diff --git a/variants/Zen/style.css b/variants/Zen/style.css new file mode 100644 index 0000000..9378e29 --- /dev/null +++ b/variants/Zen/style.css @@ -0,0 +1 @@ +@import "../../base_pieces.css"