let $ = document; //shortcut /////////////////// // Initialisations // https://stackoverflow.com/a/27747377/12660887 function generateId (len) { const dec2hex = (dec) => dec.toString(16).padStart(2, "0"); let arr = new Uint8Array(len / 2); //len/2 because 2 chars per hex value window.crypto.getRandomValues(arr); //fill with random integers 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"); // "Material" input field name let inputName = document.getElementById("myName"); let formField = document.getElementById("ng-name"); const setActive = (active) => { if (active) formField.classList.add("form-field--is-active"); else { formField.classList.remove("form-field--is-active"); inputName.value == '' ? formField.classList.remove("form-field--is-filled") : formField.classList.add("form-field--is-filled"); } }; setActive(true); inputName.onblur = () => setActive(false); inputName.onfocus = () => setActive(true); ///////// // Utils function setName() { // 'onChange' event on name input text field [HTML] localStorage.setItem("name", $.getElementById("myName").value); } // Turn a "tab" on, and "close" all others function toggleVisible(element) { for (elt of document.querySelectorAll("main > div")) { if (elt.id != element) elt.style.display = "none"; else elt.style.display = "block"; } if (element == "boardContainer") { // Avoid smartphone scrolling effects (TODO?) document.querySelector("html").style.overflow = "hidden"; document.body.style.overflow = "hidden"; } else { document.querySelector("html").style.overflow = "visible"; document.body.style.overflow = "visible"; // Workaround "superposed texts" effect: if (element == "newGame") setActive(false); } } let seek_vname; function seekGame() { seek_vname = $.getElementById("selectVariant").value; if (send("seekgame", {vname: seek_vname, name: localStorage.getItem("name")}) ) { toggleVisible("pendingSeek"); } } function cancelSeek() { if (send("cancelseek", {vname: seek_vname})) toggleVisible("newGame"); } function sendRematch(random) { if (send("rematch", {gid: gid, random: !!random})) toggleVisible("pendingRematch"); } function cancelRematch() { if (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 => { window.V = module.default; for (const [k, v] of Object.entries(V.Aliases)) window[k] = v; prepareOptions(); }); } } function backToNormalSeek() { toggleVisible("newGame"); } function toggleStyle(event, obj) { const word = obj.innerHTML; options[word] = !options[word]; event.target.classList.toggle("highlight-word"); } let options; function prepareOptions() { options = {}; let optHtml = ""; if (V.Options.select) { optHtml += V.Options.select.map(select => { return `
`; }).join(""); } if (V.Options.input) { optHtml += V.Options.input.map(input => { return `
`; }).join(""); } if (V.Options.styles) { optHtml += '
'; let i = 0; const stylesLength = V.Options.styles.length; while (i < stylesLength) { optHtml += '
'; for (let j=i; j${style}`; } optHtml += "
"; i += 4; } 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 = parseInt(select.value, 10); if (isNaN(value)) //not an integer value = select.value; options[ select.id.split("_")[1] ] = value; } for (const input of $.querySelectorAll("#gameOptions input")) { const variable = input.id.split("_")[1]; if (input.type == "number") options[variable] = parseInt(input.value, 10); //TODO: real numbers? else if (input.type == "checkbox") options[variable] = input.checked; } send("creategame", { vname: vname, player: {sid: sid, name: localStorage.getItem("name"), color: color}, options: options }); } function fillGameInfos(gameInfos, oppIndex) { fetch(`/variants/${gameInfos.vname}/rules.html`) .then(res => res.text()) .then(txt => { let htmlContent = `

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

`; const options = Object.entries(gameInfos.options); if (options.length > 0) { htmlContent += '
'; let i = 0; while (i < options.length) { htmlContent += '
'; for (let j=i; j"; } htmlContent += "
"; i += 4; } htmlContent += "
"; } htmlContent += `
${txt}
`; $.getElementById("gameInfos").innerHTML = htmlContent; }); } //////////////// // Communication let socket, gid, recoAttempt = 0; const autoReconnectDelay = () => { return [100, 200, 500, 1000, 3000, 10000, 30000][Math.min(recoAttempt, 6)]; }; function send(code, data, opts) { opts = opts || {}; const trySend = () => { if (socket.readyState == 1) { socket.send(JSON.stringify(Object.assign({code: code}, data))); if (opts.success) opts.success(); return true; } return false; }; const firstTry = trySend(); if (!firstTry) { if (opts.retry) { // Retry for a few seconds (sending move) let sendAttempt = 1; const retryLoop = setInterval( () => { if (trySend() || ++sendAttempt >= 3) clearInterval(retryLoop); if (sendAttempt >= 3 && opts.error) opts.error(); }, 1000 ); } else if (opts.error) opts.error(); } return firstTry; } function copyClipboard(msg) { navigator.clipboard.writeText(msg); } function getWhatsApp(msg) { return `https://api.whatsapp.com/send?text=${encodeURIComponent(msg)}`; } const tryResumeGame = () => { recoAttempt = 0; // If a game is found, resume it: if (localStorage.getItem("gid")) { gid = localStorage.getItem("gid"); send("getgame", {gid: gid}, { retry: true, error: () => alert("Cannot load game: no connection") }); } 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]; localStorage.setItem("gid", gid); history.replaceState(null, '', urlParts[0]); //hide game ID send("joingame", {gid: gid, name: localStorage.getItem("name")}, { retry: true, error: () => alert("Cannot load game: no connection") }); } } }; const messageCenter = (msg) => { const obj = JSON.parse(msg.data); switch (obj.code) { // Start new game: case "gamestart": { if (document.hidden) 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": // Basic check: was it really opponent's turn? if (vr.turn == playerColor) break; if (document.hidden) notifyMe("move"); vr.playReceivedMove(obj.moves, () => { if (vr.getCurrentScore(obj.moves) != "*") { 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; case "filechange": // TODO?: could be more subtle setTimeout(() => location.reload(), 100); 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()); }; function removeAllListeners() { socket.removeEventListener("open", tryResumeGame); socket.removeEventListener("message", messageCenter); socket.removeEventListener("error", handleError); socket.removeEventListener("close", handleClose); } function 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); recoAttempt++; } connectToWSS(); /////////// // Playing function toggleTurnIndicator(myTurn) { let indicator = $.getElementById("boardContainer").querySelector(".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 = [], lastFen; const afterPlay = (move_s, newTurn, ops) => { if (ops.send) { // Pack into one moves array, then send (if turn changed) if (Array.isArray(move_s)) // Array of simple moves (e.g. Chakart) Array.prototype.push.apply(curMoves, move_s); else // Usual case curMoves.push(move_s); if (newTurn != playerColor) { send("newmove", {gid: gid, moves: curMoves, fen: vr.getFen()}, { retry: true, error: () => alert("Move not sent: reload page") } ); } } if (ops.res && newTurn != playerColor) { toggleTurnIndicator(false); //now all moves are sent and animated const result = vr.getCurrentScore(curMoves); curMoves = []; if (result != "*") { setTimeout(() => { toggleVisible("gameStopped"); send("gameover", {gid: gid}); }, 2000); } } }; let vr = null, playerColor, lastVname = undefined; function initializeGame(obj) { const options = obj.options || {}; import(`/variants/${obj.vname}/class.js`).then(module => { window.V = module.default; for (const [k, v] of Object.entries(V.Aliases)) window[k] = v; if (lastVname != obj.vname) { // Load CSS + unload potential previous one. if (lastVname) document.getElementById(lastVname + "_css").remove(); $.getElementsByTagName("head")[0].insertAdjacentHTML( "beforeend", ``); lastVname = obj.vname; } playerColor = (sid == obj.players[0].sid ? "w" : "b"); // Init + remove potential extra DOM elements from a previous game: document.getElementById("boardContainer").innerHTML = `
`; if (vr) // Avoid interferences: vr.removeListeners(); vr = new V({ seed: obj.seed, //may be null if FEN already exists (running game) fen: obj.fen, element: "boardContainer", color: playerColor, afterPlay: afterPlay, options: options }); const gameCreation = !obj.fen; if (gameCreation) { // Both players set FEN, in case of one is offline 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(); } });