'use strict'; import PS from './pacosako.js'; import IO from './pacosako_io.js'; import deepEqual from 'deep-equal'; import 'webpack-jquery-ui/draggable'; import 'webpack-jquery-ui/droppable'; import 'webpack-jquery-ui/selectmenu'; import 'webpack-jquery-ui/fold-effect'; import 'webpack-jquery-ui/css'; import 'jquery-ui-touch-punch'; import escape from 'lodash/escape'; import jBox from 'jbox'; import 'jbox/dist/jBox.all.css'; import {Workbox, messageSW} from 'workbox-window'; /* "Waterdrop" by Porphyr (freesound.org/people/Porphyr) / CC BY 3.0 (creativecommons.org/licenses/by/3.0) */ import Waterdrop from '../wav/191678__porphyr__waterdrop.wav'; $(function (){ function debug() { if (window.logDebug) { console.log.apply(console, arguments); } } window.logDebug = false; let currentGame = new PS.Game(); let visibleGame = new PS.Game(currentGame); let cancelGameCallback = function() {}; function pieceTypeClass(type) { if (type === PS.KING) { return 'cb-king'; } else if (type === PS.QUEEN) { return 'cb-queen'; } else if (type === PS.ROOK) { return 'cb-rook'; } else if (type === PS.KNIGHT) { return 'cb-knight'; } else if (type === PS.BISHOP) { return 'cb-bishop'; } else if (type === PS.PAWN) { return 'cb-pawn'; } else { throw new Error(`unknown piece type: ${type}`); } } function pieceSideClass(side) { if (side === PS.LIGHT) { return 'cb-lt-piece'; } else if (side === PS.DARK) { return 'cb-dk-piece'; } else { throw new Error(`unknown side: ${side}`); } } function cbSquare(where) { if (where === PS.PHANTOM) { let square = $('#cb_phantom'); if (square.length < 1) { square = $(`
`); square.hide().appendTo('body'); } return square; } else if (where.match(/^[a-h][1-8]$/)) { return $('#cb_' + where).first() } else { return null; } } function cbPiece(where, side) { const square = cbSquare(where); if (!square) { return null; } else if (side === true) { return square.find('.cb-piece'); } else { return square.find('.' + pieceSideClass(side)).first(); } } function cbSquareLocation(square) { const id = $(square).attr('id'); if (id === "cb_phantom") { return PS.PHANTOM; } const found = id.match(/^cb_([a-h][1-8])$/); if (found) { return found[1]; } return null; } function pieceStartMove(piece, event) { const side = piece.data('side'); const type = piece.data('type'); const from = piece.data('location'); const legals = currentGame.legalMoves(side, from); for (const there of legals) { if (currentGame.isDeadEnd(from, there)) { continue; } const square = cbSquare(there); square.addClass('cb-legal') if (event === 'drag') { square.droppable('enable'); } else if (event === 'click') { square.on('click.destination', squareClickDestination); } } if (event === 'drag') { cbSquare(from).droppable('enable'); } } function pieceEndMove(piece, to, animate) { let from = piece.data('location'); piece.appendTo('#cb_hidden'); try { const meta = { timestamp: +new Date() }; currentGame.move(from, to, meta); } catch (err) { debug('unable to move', err); animate = false; } $('#cb_board').data('last_state', currentGame.moves); setCurrentGame(currentGame, animate); putState(); } function squareClickDestination(ev, ui) { let selected = $('#cb_board .cb-selected'); if (selected.length !== 1) { renderBoard(); return; } pieceEndMove(selected, cbSquareLocation(this), true); } function squareDropDestination(ev, ui) { const droppedAt = cbSquareLocation(this); if ($(ui.draggable).data('location') == droppedAt) { squareClickSelect.call(cbSquare(droppedAt)); } else { pieceEndMove(ui.draggable, droppedAt); } } function squareClickUnselect(ev, ui) { renderBoard(); } function squareClickSelect(ev, ui) { renderBoard(); const clicked = $(this).children('.cb-piece.ui-draggable').not('.ui-draggable-disabled'); clicked.addClass('cb-selected'); $('#cb_board .cb-square').off('click.select'); clicked.parent().on('click.unselect', squareClickUnselect); pieceStartMove(clicked, 'click'); if ($('#cb_board .cb-legal').length < 1) { /* cancel the selection since there is nowhere to move the piece to */ renderBoard(); } } function pieceStartDrag(ev, ui) { const dragged = $(this); $('#cb_board .cb-selected').removeClass('cb-selected'); $('#cb_board .cb-legal').removeClass('cb-legal'); dragged.data('saved-style', dragged.attr('style')); $('#cb_board').data('dragging_from', dragged.data('location')); pieceStartMove(dragged, 'drag'); } function pieceStopDrag(ev, ui) { const dragged = $(this); dragged.attr('style', dragged.data('saved-style')); dragged.removeData('saved-style'); dragged.css('z-index', ''); if ($('#cb_board').data('dragging_from') === dragged.data('location')) { if ($('#cb_board .cb-selected').length < 1) { renderBoard(); } } } function placePiece(where, side, type) { const piece = $(``); piece.addClass(pieceSideClass(side)); piece.addClass(pieceTypeClass(type)); piece.data({ side: side, type: type, location: where }); piece.appendTo(cbSquare(where)); piece.draggable({ disabled: true, containment: '#cb_inner', revert: true, zIndex: 100, start: pieceStartDrag, stop: pieceStopDrag, }); return piece; } function renderBoard(animate) { $('#cb_board').removeData('dragging_from'); $('#cb_board .cb-piece').remove(); $('#cb_board .cb-square').off('click.select'); $('#cb_board .cb-square').off('click.unselect'); $('#cb_board .cb-square').off('click.destination'); $('#cb_board .cb-start').removeClass('cb-start'); $('#cb_board .cb-end').removeClass('cb-end'); $('#cb_board .cb-legal').removeClass('cb-legal'); $('#cb_explain_check').text(''); $('#cb_phantom').remove(); const game = visibleGame; const board = game.board; for (const side of [PS.LIGHT, PS.DARK]) { for (const row of PS.ROWS) { for (const column of PS.COLUMNS) { const here = column + row; const type = board.getPiece(side, here); if (type !== PS.EMPTY) { placePiece(here, side, type); } } } } const lastMove = game.lastMove; if (lastMove) { if (lastMove.from) { cbSquare(lastMove.from).addClass('cb-start'); } if (lastMove.to) { cbSquare(lastMove.to).addClass('cb-end'); } if (lastMove.from && lastMove.to && animate) { const sideMoved = lastMove.alongside ? true : lastMove.side; const pieces = cbPiece(lastMove.to, sideMoved); if (pieces.length > 0) { const fromRect = cbSquare(lastMove.from)[0].getBoundingClientRect(); const toRect = cbSquare(lastMove.to)[0].getBoundingClientRect(); const movedDown = toRect.top - fromRect.top; const movedRight = toRect.left - fromRect.left; for (const domPiece of pieces) { const piece = $(domPiece); const originalTop = parseFloat(piece.css('top')); const originalLeft = parseFloat(piece.css('left')); const originalStyle = piece.attr('style') || null; piece.css({ 'z-index': 100, 'top': originalTop - movedDown, 'left': originalLeft - movedRight, }).animate({ 'top': originalTop, 'left': originalLeft, }, { always: function() { piece.attr('style', originalStyle); }, }); } } } } if (game.player === PS.LIGHT) { $('#cb_board').removeClass('cb-dk-turn').addClass('cb-lt-turn'); } else { $('#cb_board').removeClass('cb-lt-turn').addClass('cb-dk-turn'); } const liveView = !game.canRedo; const playing = game.status === PS.PLAYING; const clss = pieceSideClass(game.player); const phantom = board.phantom; if (phantom) { const piece = placePiece(PS.PHANTOM, phantom.side, phantom.type); cbSquare(PS.PHANTOM).appendTo(cbSquare(phantom.from)).show(); if (liveView && playing) { piece.draggable('enable'); piece.addClass('cb-selected'); pieceStartMove(piece, 'click'); } } else if (liveView && playing) { const pieces = $('#cb_board .' + clss) pieces.parent().on('click.select', squareClickSelect); pieces.draggable('enable'); } const check = game.isInCheck(); const king = $('#cb_board .cb-king.' + clss); if (check) { king.addClass('cb-in-check'); } if (typeof check === 'string') { $('#cb_explain_check').text(`(Check: ${check})`); } let msg = ''; let winner = game.winner; if (!liveView) { msg += '(Move ' + String(game.moves.length) + ' of ' + String(currentGame.moves.length) + ') '; } if (winner) { if (game.status === PS.CHECKMATE) { msg += 'Checkmate! '; } else if (lastMove && lastMove.resign) { msg += (lastMove.side === PS.LIGHT ? 'Light' : 'Dark') + ' player resigned. '; } msg += (winner === PS.LIGHT ? 'Light' : 'Dark') + ' player won!'; } else if (playing) { if (game.isInCheck()) { msg += 'Check! '; } msg += (game.player === PS.LIGHT ? 'Light' : 'Dark') + ' player\'s turn.'; } else { msg += 'Game ended in a draw.'; } $('#cb_message').text(msg); const viewHistory = game.history || ''; const fullHistory = currentGame.history || ''; const futureHistory = fullHistory.slice(viewHistory.length); $('#cb_history_past').html(escape(viewHistory).replace(/\d+\./g, '$&')); $('#cb_history_future').html(escape(futureHistory).replace(/\d+\./g, '$&')); $('#cb_nav_first').attr('disabled', !game.canUndo); $('#cb_nav_prev_turn').attr('disabled', !game.canUndo); $('#cb_nav_prev_state').attr('disabled', !game.canUndo); $('#cb_nav_next_state').attr('disabled', !game.canRedo); $('#cb_nav_next_turn').attr('disabled', !game.canRedo); $('#cb_nav_last').attr('disabled', !game.canRedo); $('#cb_undo').attr('disabled', !liveView || !currentGame.canUndo); $('#cb_redo').attr('disabled', !liveView || !currentGame.canRedo); $('#cb_resign').attr('disabled', !liveView || !playing); if (liveView) { $('#cb_board').addClass('cb-live').removeClass('cb-archive'); } else { $('#cb_board').removeClass('cb-live').addClass('cb-archive'); } } function applyTheme(theme) { let cbBoard = $('#cb_board').first(); for (const klass of cbBoard.prop('classList')) { if (klass.match(/^cb-theme-/)) { cbBoard.removeClass(klass); } } cbBoard.addClass('cb-theme-' + theme); } function setVisibleGame(game, animate) { /* navigation should not include the redo stack */ visibleGame = new PS.Game(game); visibleGame.clearRedo(); renderBoard(animate); } function setCurrentGame(game, animate) { closeNotifications(); currentGame = game; setVisibleGame(game, animate); const moves = game.moves; const cb_board = $('#cb_board').first(); if (!deepEqual(moves, cb_board.data('last_state'))) { /* ignore partial moves */ if (!game.board.phantom) { if ($('#cb_notify')[0].checked) { const gameString = cb_board.data('lightName') + ' vs. ' + cb_board.data('darkName'); notify(gameString + '\n' + $('#cb_message').text()); } if ($('#cb_sound')[0].checked) { playNotifySound(); } } } cb_board.data('last_state', moves); } function randomId(){ let res = ''; for (let i = 0; i < 4; ++i) { const part = Math.floor(Math.random() * 65536).toString(16); res = res + ('0000'.substring(part.length, 4) + part); } return res; } function shortenName(name) { name = name.replace(/^\s+/, ''); name = name.replace(/\s+$/, ''); name = name.replace(/\s+/, ' '); let found = name.match(/^(.{0,20})(\s|$)/); if (found) { return found[1]; } return name.slice(0, 20) + '…'; } let noticeBox = null; function openNoticeBox(content) { if (noticeBox) { noticeBox.setContent(content); noticeBox.open(); } else { noticeBox = new jBox('Notice', { autoClose: false, closeOnEsc: false, closeOnClick: false, content: content, delayClose: 2000, delayOpen: 100, onClose() { if (noticeBox === this) { noticeBox = null; } }, onCloseComplete() { this.destroy(); }, }); } } const updateQueue = { head: 0, /* next item to be sent */ tail: 0, /* ID to assign to the next update added to the queue */ sending: false, add(gameId, data, modified) { const wasEmpty = this.isEmpty(); data = Object.assign({}, data, { modified }); this[this.tail] = { gameId, data }; this.tail += 1; if (!this.sending) { openNoticeBox('Saving...'); this.sending = true; this.sendNext(); } }, isEmpty() { return this.head === this.tail; }, peek() { if (!this.isEmpty()) { return this[this.head]; } else { return undefined; } }, remove() { if (!this.isEmpty()) { const first = this[this.head]; delete this[this.head]; this.head += 1; return first; } else { return undefined; } }, sendNext() { const queue = this; const update = queue.remove(); if (update === undefined) { return; } /* merge updates for the same game with the same baseline into a single request */ let peek; while ((peek = queue.peek()) !== undefined && peek.gameId === update.gameId && peek.data.modified === update.data.modified) { Object.assign(update.data, peek.data); queue.remove(); } IO.sendUpdate(update.gameId, update.data).then((response) => { if (response && response.data && 'modified' in response.data) { /* * If we queued two or more updates with the same .modified (thus * based on the same server data), send the later update(s) with the * new .modified assigned by the server as a result of this update. */ for (let i = queue.head; i !== queue.tail; i += 1) { if (queue[i].gameId === update.gameId && queue[i].data.modified === update.data.modified) { queue[i].data.modified = response.data.modified; } } /* * Send future updates with the new modified time, and prevent loading * older data from the server in case the connection is lagging. */ const cbBoard = $('#cb_board'); if (cbBoard.data('gameId') === update.gameId && cbBoard.data('modified') === update.data.modified) { cbBoard.data('modified', response.data.modified); } } if (queue.isEmpty()) { queue.sending = false; /* close the Saving... notice*/ noticeBox.close({ ignoreDelay: true }); } else { queue.sendNext(); } }).catch((err) => { openNoticeBox('Failed to send update to server.'); noticeBox.close(); debug('update error', err); /* additional updates are unlikely to succeed, so empty the queue */ while (!queue.isEmpty()) { queue.remove(); } queue.sending = false; /* force a reset back to the latest server data */ if (update.gameId === $('#cb_board').data('gameId')) { switchGameId(update.gameId, true); } }); }, }; function putState() { const boardElem = $('#cb_board'); const gameId = boardElem.data('gameId'); boardElem.data('last_state', currentGame.moves); putMeta({ board: JSON.parse(currentGame.toJSON()) }); } function putMeta(extra) { function player(side) { return (side === PS.LIGHT ? 'light' : 'dark'); } const gameId = $('#cb_board').data('gameId'); const lightName = $('#cb_light_name').val(); const darkName = $('#cb_dark_name').val(); const turns = currentGame.turns; const winner = currentGame.winner; const lastMove = currentGame.lastMove || {}; const lastMeta = lastMove.meta || {}; const status = (currentGame.status === PS.PLAYING) ? `${player(currentGame.player)}'s turn` : (currentGame.status === PS.CHECKMATE) ? `checkmate — ${player(winner)} won` : (currentGame.status === PS.RESIGNED) ? `${player(lastMove.side)} resigned` : 'game ended'; const meta = { lightName, darkName, moves: turns, timestamp: lastMeta.timestamp || +new Date(), status, }; if (extra) { Object.assign(meta, extra); } updateQueue.add(gameId, meta, $('#cb_board').data('modified')); } function switchGameId(newId, force) { const boardElem = $('#cb_board'); const gameId = boardElem.data('gameId'); debug('switching from ' + gameId + ' to ' + newId); if (newId === gameId && !force) { return; } /* cancel to reload data, but keep polling if game ID will be the same */ cancelGameCallback(newId === gameId); boardElem.data('gameId', newId); boardElem.data('modified', 0); history.replaceState(null, document.title, '#/' + newId); const notifyAfter = +new Date() + 2000; $(window).data('notifyAfter', +new Date() + 2000); window.setTimeout(function() { /* Delete the notification block in case the system time is changed backward */ if ($(window).data('notifyAfter') === notifyAfter) { $(window).removeData('notifyAfter'); } }, 2000); /* this will be the starting state if no data is received from peers */ setCurrentGame(new PS.Game()); boardElem.data('last_state', currentGame.moves); boardElem.data('lightName', 'Light'); boardElem.data('darkName', 'Dark'); $('#cb_light_name').val(''); $('#cb_dark_name').val(''); cancelGameCallback = IO.onGameUpdate(newId, function(data, gameId) { if (data.modified > $('#cb_board').data('modified')) { try { const newGame = new PS.Game(JSON.stringify(data.board)); const newState = JSON.parse(newGame.toJSON()); const oldState = JSON.parse(currentGame.toJSON()); if (!deepEqual(newState, oldState)) { debug('got board', newGame.moves); setCurrentGame(newGame, newGame.moves.length > currentGame.moves.length); } } catch (err) { debug('Error parsing board data', err); } const d = data || {}; $('#cb_board').data('lightName', shortenName(String(d.lightName || 'Light'))); $('#cb_board').data('darkName', shortenName(String(d.darkName || 'Dark'))); $('#cb_light_name').val(String(d.lightName || '')); $('#cb_dark_name').val(String(d.darkName || '')); $('#cb_board').data('modified', data.modified); } }); const notifyList = $('#cb_notify').data('gameList'); const doNotify = notifyList.includes('*') || notifyList.includes(newId); $('#cb_notify').prop('checked', doNotify); if (doNotify) { requestNotify(); } const reverseList = $('#cb_reverse').data('gameList'); const doReverse = reverseList.includes('*') || reverseList.includes(newId); $('#cb_reverse').prop('checked', doReverse); arrangeBoard(doReverse); $('#jitsi_link').attr('href', 'https://meet.jit.si/PacoSaco_' + newId); $('#game_link').attr('href', location.href); /* Ensure that the selected game is in the list (for new games). */ if ($('#game_tile_' + newId).length < 1) { updateSelectGameMeta({}, newId); } /* This is in case the old tile no longer qualifies to be in the list. */ const oldGameTile = $('#game_tile_' + gameId); if (oldGameTile.length >= 1) { updateSelectGameMeta(oldGameTile.data('gameMeta'), gameId); } $('.game-tile-selected').removeClass('game-tile-selected'); $('#game_tile_' + newId).addClass('game-tile-selected'); } function disableNotify(){ $('#cb_notify')[0].checked = false; $('#cb_notify').attr('disabled', true); } function requestNotify(){ try { Notification.requestPermission(function (permission){ if (permission === 'denied') { disableNotify(); } }); } catch (err) { disableNotify(); } } const notifyAudio = new Audio(Waterdrop); function playNotifySound(){ const now = +new Date(); const then = $(window).data('notifyAfter'); if (!then || now >= then) { try { notifyAudio.play(); } catch (err) {} } } function notify(body) { const now = +new Date(); const then = $(window).data('notifyAfter'); if (!then || now >= then) { try { Notification.requestPermission(function(permission){ if (permission === 'granted') { navigator.serviceWorker.ready.then(function(registration){ registration.showNotification('Paco Ŝako', { body: body, tag: 'notice', }); }); } else if (permission === 'denied') { disableNotify(); } }); } catch (err) { disableNotify(); } } } function closeNotifications(){ try { navigator.serviceWorker.ready.then(function(registration){ try { if ('getNotifications' in registration) { registration.getNotifications({tag: 'notice'}).then(function(notifications){ for (const notification of notifications) { notification.close(); } }); } } catch (err) {} }); } catch (err) {} } function arrangeBoard(reversed) { let rows = '9876543210'.split(''); let columns = 'LabcdefghR'.split(''); const boardElem = $('#cb_board'); if (reversed) { rows.reverse(); columns.reverse(); boardElem.addClass('cb-reversed'); } else { boardElem.removeClass('cb-reversed'); } for (const row of rows) { const rowElem = $('#cb_row' + row); rowElem.appendTo(boardElem); for (const column of columns) { $('#cb_' + column + row).appendTo(rowElem); } } } if ('serviceWorker' in navigator) { let confirmBox = null; Promise.resolve().then(async () => { const wb = new Workbox('sw.js'); function showSkipWaitingPrompt(event) { if (confirmBox) { confirmBox.close({ ignoreDelay: true }); } confirmBox = new jBox('Confirm', { attach: null, content: "A new version is available. Update the page?", confirmButton: ``, cancelButton: 'Not now', closeOnConfirm: false, confirm() { /* The SW should signal us to reload, but do it after 20s regardless. */ setTimeout(() => { window.location.reload(); }, 20000); messageSW(event.sw, {type: 'SKIP_WAITING'}); $('.update-confirm-button').text('Updating…'); }, onCloseComplete() { this.destroy(); }, }); confirmBox.open(); } wb.addEventListener('installed', (event) => { try { if (Notification.permission === 'denied') { disableNotify(); } } catch (err) { disableNotify(); } }); wb.addEventListener('controlling', (event) => { if (event.isUpdate) { window.location.reload(); } }); wb.addEventListener('waiting', showSkipWaitingPrompt); wb.addEventListener('externalwaiting', showSkipWaitingPrompt); const registration = await wb.register(); if ('update' in registration) { /* Check for updates every 4h without reloading the page. */ setInterval(() => { registration.update(); }, 4*3600*1000); } else { console.log('service worker update method not supported, disabling update checks'); } }).catch((err) => { console.error('failed to register the service worker', err); disableNotify(); }); } const LS_KEY_NOTIFY = 'pacosako/notify'; const LS_KEY_SOUND = 'pacosako/sound'; const LS_KEY_REVERSE = 'pacosako/reverse'; const LS_KEY_THEME = 'pacosako/theme'; if ('localStorage' in window) { const fromStorage = function fromStorage(key, value) { function updatePerGameFlag(key, value, selector, onchange) { let gameList = undefined; const gameId = $('#cb_board').data('gameId'); if (value === 'on') { gameList = ['*'] } else if (value === 'off') { gameList = []; } else { try { gameList = JSON.parse(value); if (!Array.isArray(gameList)) { throw new TypeError(`expected an array for ${key}`); } for (const item of gameList) { if (typeof item !== 'string') { throw new TypeError(`expected an array of strings for ${key}`); } } } catch (err) { debug(`error parsing game list for ${key}`, err); gameList = []; } } const enabled = gameList.includes('*') || gameList.includes(gameId); const checkbox = $(selector).first(); const wasChecked = checkbox.prop('checked'); checkbox.data('gameList', gameList); checkbox.prop('checked', enabled); if (enabled !== wasChecked) { onchange(enabled); } } debug('from localStorage', { key, value }); if (key === LS_KEY_NOTIFY) { updatePerGameFlag(key, value, '#cb_notify', (enabled) => { if (enabled) { requestNotify(); } }); } else if (key === LS_KEY_SOUND) { const doSound = value === 'on'; const cb_sound = $('#cb_sound')[0]; cb_sound.checked = doSound; } else if (key === LS_KEY_REVERSE) { updatePerGameFlag(key, value, '#cb_reverse', (enabled) => { arrangeBoard(enabled); }); } else if (key === LS_KEY_THEME) { const cb_theme = $('#cb_select_theme'); if (value !== cb_theme.val()) { cb_theme.val(value); if (!cb_theme.val()) { value = 'traditional'; cb_theme.val(value); } applyTheme(value); } } } $(window).on('storage', function(event){ fromStorage(event.originalEvent.key, event.originalEvent.newValue); }); for (const key of [LS_KEY_NOTIFY, LS_KEY_SOUND, LS_KEY_REVERSE, LS_KEY_THEME]) { const value = window.localStorage.getItem(key); if (value !== null) { fromStorage(key, value); } } } $('.cb-square, .cb-phantom').droppable({ accept: '.cb-piece', disabled: true, deactivate: function(ev, ui){ $(this).droppable('disable'); }, drop: squareDropDestination, }); /* Maximum length of gameList for per-game flags like notify and reverse */ const GAMES_TO_REMEMBER = 50; function perGameFlagChanged(key, selector) { if ('localStorage' in window) { const checkbox = $(selector); const checked = checkbox.prop('checked'); const gameId = $('#cb_board').data('gameId'); let gameList = checkbox.data('gameList') || []; if (gameList.includes('*')) { gameList = Object.keys(IO.getCachedMeta()); } gameList = gameList.filter((x) => x !== gameId); if (checked) { /* Ensure the new gameId is at the front of the list */ gameList.unshift(gameId); } gameList = gameList.slice(0, GAMES_TO_REMEMBER); checkbox.data('gameList', gameList); window.localStorage.setItem(key, JSON.stringify(gameList)); } } $('#cb_notify').on('change', function(){ perGameFlagChanged(LS_KEY_NOTIFY, this); if (this.checked) { requestNotify(); } }); $('#cb_sound').on('change', function(){ if ('localStorage' in window) { window.localStorage.setItem(LS_KEY_SOUND, this.checked ? 'on' : 'off'); } }); $('#cb_reverse').on('change', function(){ perGameFlagChanged(LS_KEY_REVERSE, this); arrangeBoard(this.checked); }); $('#cb_undo').on('click', function(){ if (currentGame.canUndo) { currentGame.undo(); $('#cb_board').data('last_state', currentGame.moves); setCurrentGame(currentGame); putState(); } }); $('#cb_redo').on('click', function(){ if (currentGame.canRedo) { currentGame.redo(); $('#cb_board').data('last_state', currentGame.moves); setCurrentGame(currentGame, true); putState(); } }); $('#cb_resign').on('click', function(){ try { const meta = { timestamp: +new Date() }; currentGame.resign(meta); } catch (err) { debug('unable to resign', err); } $('#cb_board').data('last_state', currentGame.moves); setCurrentGame(currentGame); putState(); }); $('#cb_nav_first').on('click', function(){ while (visibleGame.canUndo) { visibleGame.undo(); } renderBoard(); }); $('#cb_nav_prev_turn').on('click', function(){ if (visibleGame.canUndo) { visibleGame.undo(); const player = visibleGame.player; while (visibleGame.canUndo) { visibleGame.undo(); if (visibleGame.player !== player) { visibleGame.redo(); break; } } } renderBoard(); }); $('#cb_nav_prev_state').on('click', function(){ if (visibleGame.canUndo) { visibleGame.undo(); } renderBoard(); }); $('#cb_nav_next_state').on('click', function(){ if (visibleGame.canRedo) { visibleGame.redo(); } renderBoard(true); }); $('#cb_nav_next_turn').on('click', function(){ const player = visibleGame.player; while (visibleGame.canRedo) { visibleGame.redo(); if (visibleGame.player !== player) { break; } } renderBoard(true); }); $('#cb_nav_last').on('click', function(){ while (visibleGame.canRedo) { visibleGame.redo(); } renderBoard(true); }); $('#cb_select_theme').on('change', function(){ const theme = $('#cb_select_theme').val(); debug('cb_select_theme changed to ' + theme); if ('localStorage' in window) { window.localStorage.setItem(LS_KEY_THEME, theme); } applyTheme(theme); }); $('#cb_choose_game').on('click', function() { if (selectBox) { selectBox.open(); } }); $('#cb_light_name, #cb_dark_name').on('input', function() { putMeta(); }); let gameSelectContent = $(``).appendTo('body'); let gameTiles = $(`