diff --git a/common/GameCommon.js b/common/GameCommon.js index 98957e2..c2ece7c 100644 --- a/common/GameCommon.js +++ b/common/GameCommon.js @@ -7,7 +7,7 @@ function exists(gameId) { return (!!GAMES[gameId]) || false } -function createGame(id, rng, puzzle, players, sockets, evtInfos) { +function __createGameObject(id, rng, puzzle, players, sockets, evtInfos) { return { id: id, rng: rng, @@ -18,7 +18,7 @@ function createGame(id, rng, puzzle, players, sockets, evtInfos) { } } -function createPlayer(id, ts) { +function __createPlayerObject(id, ts) { return { id: id, x: 0, @@ -33,7 +33,7 @@ function createPlayer(id, ts) { } function newGame({id, rng, puzzle, players, sockets, evtInfos}) { - const game = createGame(id, rng, puzzle, players, sockets, evtInfos) + const game = __createGameObject(id, rng, puzzle, players, sockets, evtInfos) setGame(id, game) return game } @@ -62,26 +62,23 @@ function playerExists(gameId, playerId) { return !!GAMES[gameId].players[playerId] } -function getRelevantPlayers(gameId) { - const ts = Util.timestamp() +function getRelevantPlayers(gameId, ts) { const minTs = ts - 30000 return getAllPlayers(gameId).filter(player => { return player.ts >= minTs || player.points > 0 }) } -function getActivePlayers(gameId) { - const ts = Util.timestamp() +function getActivePlayers(gameId, ts) { const minTs = ts - 30000 return getAllPlayers(gameId).filter(player => { return player.ts >= minTs }) } -function addPlayer(gameId, playerId) { - const ts = Util.timestamp() +function addPlayer(gameId, playerId, ts) { if (!GAMES[gameId].players[playerId]) { - setPlayer(gameId, playerId, createPlayer(playerId, ts)) + setPlayer(gameId, playerId, __createPlayerObject(playerId, ts)) } else { changePlayer(gameId, playerId, { ts }) } @@ -368,19 +365,23 @@ const freeTileIdxByPos = (gameId, pos) => { } const getPlayerBgColor = (gameId, playerId) => { - return getPlayer(gameId, playerId).bgcolor + const p = getPlayer(gameId, playerId) + return p ? p.bgcolor : null } const getPlayerColor = (gameId, playerId) => { - return getPlayer(gameId, playerId).color + const p = getPlayer(gameId, playerId) + return p ? p.color : null } const getPlayerName = (gameId, playerId) => { - return getPlayer(gameId, playerId).name + const p = getPlayer(gameId, playerId) + return p ? p.name : null } const getPlayerPoints = (gameId, playerId) => { - return getPlayer(gameId, playerId).points + const p = getPlayer(gameId, playerId) + return p ? p.points : null } // determine if two tiles are grouped together @@ -398,6 +399,14 @@ const getTableHeight = (gameId) => { return GAMES[gameId].puzzle.info.table.height } +const getPuzzle = (gameId) => { + return GAMES[gameId].puzzle +} + +const getRng = (gameId) => { + return GAMES[gameId].rng.obj +} + const getPuzzleWidth = (gameId) => { return GAMES[gameId].puzzle.info.width } @@ -406,7 +415,7 @@ const getPuzzleHeight = (gameId) => { return GAMES[gameId].puzzle.info.height } -function handleInput(gameId, playerId, input) { +function handleInput(gameId, playerId, input, ts) { const puzzle = GAMES[gameId].puzzle let evtInfo = GAMES[gameId].evtInfos[playerId] @@ -472,8 +481,6 @@ function handleInput(gameId, playerId, input) { } } - const ts = Util.timestamp() - const type = input[0] if (type === 'bg_color') { const bgcolor = input[1] @@ -559,7 +566,7 @@ function handleInput(gameId, playerId, input) { _tileChanges(tileIdxs) // check if the puzzle is finished if (getFinishedTileCount(gameId) === getTileCount(gameId)) { - changeData(gameId, { finished: Util.timestamp() }) + changeData(gameId, { finished: ts }) _dataChange() } } else { @@ -614,6 +621,8 @@ function handleInput(gameId, playerId, input) { } export default { + __createGameObject, + __createPlayerObject, newGame, exists, playerExists, @@ -638,6 +647,8 @@ export default { setPuzzleData, getTableWidth, getTableHeight, + getPuzzle, + getRng, getPuzzleWidth, getPuzzleHeight, getTilesSortedByZIndex, diff --git a/common/Protocol.js b/common/Protocol.js index 07f4ccb..cd9132f 100644 --- a/common/Protocol.js +++ b/common/Protocol.js @@ -40,12 +40,16 @@ EV_SERVER_INIT: event sent to one client after that client */ const EV_SERVER_EVENT = 1 const EV_SERVER_INIT = 4 +const EV_SERVER_INIT_REPLAY = 5 const EV_CLIENT_EVENT = 2 const EV_CLIENT_INIT = 3 +const EV_CLIENT_INIT_REPLAY = 6 export default { EV_SERVER_EVENT, EV_SERVER_INIT, + EV_SERVER_INIT_REPLAY, EV_CLIENT_EVENT, EV_CLIENT_INIT, + EV_CLIENT_INIT_REPLAY, } diff --git a/common/Util.js b/common/Util.js index ba2f49b..f6b79b3 100644 --- a/common/Util.js +++ b/common/Util.js @@ -152,7 +152,19 @@ function coordByTileIdx(info, tileIdx) { } } +const hash = (str) => { + let hash = 0 + + for (let i = 0; i < str.length; i++) { + let chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + export default { + hash, uniqId, randomInt, choice, diff --git a/game/Communication.js b/game/Communication.js index 27c885a..7277168 100644 --- a/game/Communication.js +++ b/game/Communication.js @@ -42,6 +42,25 @@ function connect(gameId, clientId) { }) } +function connectReplay(gameId, clientId) { + clientSeq = 0 + events = {} + conn = new WsClient(WS_ADDRESS, clientId + '|' + gameId) + return new Promise(r => { + conn.connect() + send([Protocol.EV_CLIENT_INIT_REPLAY]) + conn.onSocket('message', async ({ data }) => { + const msg = JSON.parse(data) + const msgType = msg[0] + if (msgType === Protocol.EV_SERVER_INIT_REPLAY) { + const game = msg[1] + const log = msg[2] + r({game, log}) + } + }) + }) +} + function sendClientEvent(mouse) { // when sending event, increase number of sent events // and add the event locally @@ -52,6 +71,7 @@ function sendClientEvent(mouse) { export default { connect, + connectReplay, onServerChange, sendClientEvent, } diff --git a/game/Game.js b/game/Game.js index 8f7222d..f0b21c5 100644 --- a/game/Game.js +++ b/game/Game.js @@ -4,6 +4,7 @@ export default { newGame: GameCommon.newGame, getRelevantPlayers: GameCommon.getRelevantPlayers, getActivePlayers: GameCommon.getActivePlayers, + addPlayer: GameCommon.addPlayer, handleInput: GameCommon.handleInput, getPlayerBgColor: GameCommon.getPlayerBgColor, getPlayerColor: GameCommon.getPlayerColor, @@ -15,6 +16,8 @@ export default { setPuzzleData: GameCommon.setPuzzleData, getTableWidth: GameCommon.getTableWidth, getTableHeight: GameCommon.getTableHeight, + getPuzzle: GameCommon.getPuzzle, + getRng: GameCommon.getRng, getPuzzleWidth: GameCommon.getPuzzleWidth, getPuzzleHeight: GameCommon.getPuzzleHeight, getTilesSortedByZIndex: GameCommon.getTilesSortedByZIndex, diff --git a/game/game.js b/game/game.js index 900634d..ddceb1a 100644 --- a/game/game.js +++ b/game/game.js @@ -12,11 +12,14 @@ import { Rng } from '../common/Rng.js' if (typeof GAME_ID === 'undefined') throw '[ GAME_ID not set ]' if (typeof WS_ADDRESS === 'undefined') throw '[ WS_ADDRESS not set ]' +if (typeof MODE === 'undefined') throw '[ MODE not set ]' if (typeof DEBUG === 'undefined') window.DEBUG = false let RERENDER = true +let TIME = () => Util.timestamp() + function addCanvasToDom(canvas) { canvas.width = window.innerWidth canvas.height = window.innerHeight @@ -41,6 +44,13 @@ function addMenuToDom(gameId) { return row } + function btn(txt) { + const btn = document.createElement('button') + btn.classList.add('btn') + btn.innerText = txt + return btn + } + function colorinput() { const input = document.createElement('input') input.type = 'color' @@ -143,10 +153,10 @@ function addMenuToDom(gameId) { const scoresListEl = document.createElement('table') const updateScores = () => { - const ts = Util.timestamp() + const ts = TIME() const minTs = ts - 30000 - const players = Game.getRelevantPlayers(gameId) + const players = Game.getRelevantPlayers(gameId, ts) const actives = players.filter(player => player.ts >= minTs) const nonActives = players.filter(player => player.ts < minTs) @@ -181,7 +191,7 @@ function addMenuToDom(gameId) { const icon = ended ? '🏁' : '⏳' const from = started; - const to = ended || Util.timestamp() + const to = ended || TIME() const MS = 1 const SEC = MS * 1000 @@ -208,12 +218,27 @@ function addMenuToDom(gameId) { timerCountdownEl.innerText = timerStr() setInterval(() => { timerCountdownEl.innerText = timerStr() - }, 1000) + }, 50) // needs to be small, so that it updates quick enough in replay const timerEl = document.createElement('div') timerEl.classList.add('timer') timerEl.appendChild(timerCountdownEl) + let replayControl = null + if (MODE === 'replay') { + const replayControlEl = document.createElement('div') + const speedUp = btn('⏫') + const speedDown = btn('⏬') + const pause = btn('⏸️') + const speed = document.createElement('div') + replayControlEl.appendChild(speed) + replayControlEl.appendChild(speedUp) + replayControlEl.appendChild(speedDown) + replayControlEl.appendChild(pause) + timerEl.appendChild(replayControlEl) + replayControl = { speedUp, speedDown, pause, speed } + } + const scoresEl = document.createElement('div') scoresEl.classList.add('scores') scoresEl.appendChild(scoresTitleEl) @@ -230,6 +255,7 @@ function addMenuToDom(gameId) { playerColorPickerEl, nameChangeEl, updateScores, + replayControl, } } @@ -324,23 +350,46 @@ async function main() { return cursors[key] } - const game = await Communication.connect(gameId, CLIENT_ID) - game.rng.obj = Rng.unserialize(game.rng.obj) - Game.newGame(game) - - const bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(game.puzzle) - - const {bgColorPickerEl, playerColorPickerEl, nameChangeEl, updateScores} = addMenuToDom(gameId) - updateScores() - - // Create a dom and attach adapters to it so we can work with it + // Create a canvas and attach adapters to it so we can work with it const canvas = addCanvasToDom(Graphics.createCanvas()) + + // stuff only available in replay mode... + // TODO: refactor + let GAME_LOG + let GAME_LOG_IDX = 0 + let REPLAY_SPEEDS = [0.5, 1, 2, 5, 10, 20, 50] + let REPLAY_SPEED_IDX = 1 + let REPLAY_PAUSED = false + let lastRealTime = null + let lastGameTime = null + + if (MODE === 'play') { + const game = await Communication.connect(gameId, CLIENT_ID) + game.rng.obj = Rng.unserialize(game.rng.obj) + Game.newGame(game) + } else if (MODE === 'replay') { + const {game, log} = await Communication.connectReplay(gameId, CLIENT_ID) + game.rng.obj = Rng.unserialize(game.rng.obj) + Game.newGame(game) + GAME_LOG = log + lastRealTime = Util.timestamp() + lastGameTime = GAME_LOG[0][GAME_LOG[0].length - 1] + TIME = () => lastGameTime + } else { + throw '[ 2020-12-22 MODE invalid, must be play|replay ]' + } + + const bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(Game.getPuzzle(gameId)) + + const {bgColorPickerEl, playerColorPickerEl, nameChangeEl, updateScores, replayControl} = addMenuToDom(gameId) + updateScores() + const longFinished = Game.getFinishTs(gameId) let finished = longFinished ? true : false const justFinished = () => !!(finished && !longFinished) - const fireworks = new fireworksController(canvas, game.rng.obj) + const fireworks = new fireworksController(canvas, Game.getRng(gameId)) fireworks.init(canvas) const ctx = canvas.getContext('2d') @@ -371,97 +420,194 @@ async function main() { } const evts = new EventAdapter(canvas, viewport) - bgColorPickerEl.value = playerBgColor() - evts.addEvent(['bg_color', bgColorPickerEl.value]) - bgColorPickerEl.addEventListener('change', () => { - localStorage.setItem('bg_color', bgColorPickerEl.value) + if (MODE === 'play') { + bgColorPickerEl.value = playerBgColor() evts.addEvent(['bg_color', bgColorPickerEl.value]) - }) - playerColorPickerEl.value = playerColor() - evts.addEvent(['player_color', playerColorPickerEl.value]) - playerColorPickerEl.addEventListener('change', () => { - localStorage.setItem('player_color', playerColorPickerEl.value) + bgColorPickerEl.addEventListener('change', () => { + localStorage.setItem('bg_color', bgColorPickerEl.value) + evts.addEvent(['bg_color', bgColorPickerEl.value]) + }) + playerColorPickerEl.value = playerColor() evts.addEvent(['player_color', playerColorPickerEl.value]) - }) - nameChangeEl.value = playerName() - evts.addEvent(['player_name', nameChangeEl.value]) - nameChangeEl.addEventListener('change', () => { - localStorage.setItem('player_name', nameChangeEl.value) + playerColorPickerEl.addEventListener('change', () => { + localStorage.setItem('player_color', playerColorPickerEl.value) + evts.addEvent(['player_color', playerColorPickerEl.value]) + }) + nameChangeEl.value = playerName() evts.addEvent(['player_name', nameChangeEl.value]) - }) - - Communication.onServerChange((msg) => { - const msgType = msg[0] - const evClientId = msg[1] - const evClientSeq = msg[2] - const evChanges = msg[3] - for(let [changeType, changeData] of evChanges) { - switch (changeType) { - case 'player': { - const p = Util.decodePlayer(changeData) - if (p.id !== CLIENT_ID) { - Game.setPlayer(gameId, p.id, p) - RERENDER = true - } - } break; - case 'tile': { - const t = Util.decodeTile(changeData) - Game.setTile(gameId, t.idx, t) - RERENDER = true - } break; - case 'data': { - Game.setPuzzleData(gameId, changeData) - RERENDER = true - } break; - } + nameChangeEl.addEventListener('change', () => { + localStorage.setItem('player_name', nameChangeEl.value) + evts.addEvent(['player_name', nameChangeEl.value]) + }) + } else if (MODE === 'replay') { + let setSpeedStatus = () => { + replayControl.speed.innerText = 'Replay-Speed: ' + (REPLAY_SPEEDS[REPLAY_SPEED_IDX] + 'x') + (REPLAY_PAUSED ? ' Paused' : '') } - finished = Game.getFinishTs(gameId) - }) + setSpeedStatus() + replayControl.speedUp.addEventListener('click', () => { + if (REPLAY_SPEED_IDX + 1 < REPLAY_SPEEDS.length) { + REPLAY_SPEED_IDX++ + setSpeedStatus() + } + }) + replayControl.speedDown.addEventListener('click', () => { + if (REPLAY_SPEED_IDX >= 1) { + REPLAY_SPEED_IDX-- + setSpeedStatus() + } + }) + replayControl.pause.addEventListener('click', () => { + REPLAY_PAUSED = !REPLAY_PAUSED + setSpeedStatus() + }) + } + + if (MODE === 'play') { + Communication.onServerChange((msg) => { + const msgType = msg[0] + const evClientId = msg[1] + const evClientSeq = msg[2] + const evChanges = msg[3] + for(let [changeType, changeData] of evChanges) { + switch (changeType) { + case 'player': { + const p = Util.decodePlayer(changeData) + if (p.id !== CLIENT_ID) { + Game.setPlayer(gameId, p.id, p) + RERENDER = true + } + } break; + case 'tile': { + const t = Util.decodeTile(changeData) + Game.setTile(gameId, t.idx, t) + RERENDER = true + } break; + case 'data': { + Game.setPuzzleData(gameId, changeData) + RERENDER = true + } break; + } + } + finished = Game.getFinishTs(gameId) + }) + } else if (MODE === 'replay') { + // no external communication for replay mode, + // only the GAME_LOG is relevant + let inter = setInterval(() => { + let realTime = Util.timestamp() + if (REPLAY_PAUSED) { + lastRealTime = realTime + return + } + let timePassedReal = realTime - lastRealTime + + let timePassedGame = timePassedReal * REPLAY_SPEEDS[REPLAY_SPEED_IDX] + let maxGameTs = lastGameTime + timePassedGame + do { + if (REPLAY_PAUSED) { + break + } + let nextIdx = GAME_LOG_IDX + 1 + if (nextIdx >= GAME_LOG.length) { + clearInterval(inter) + break + } + + let logEntry = GAME_LOG[nextIdx] + let nextTs = logEntry[logEntry.length - 1] + if (nextTs > maxGameTs) { + break + } + + if (logEntry[0] === 'addPlayer') { + Game.addPlayer(gameId, ...logEntry.slice(1)) + RERENDER = true + } else if (logEntry[0] === 'handleInput') { + Game.handleInput(gameId, ...logEntry.slice(1)) + RERENDER = true + } + GAME_LOG_IDX = nextIdx + } while (true) + lastRealTime = realTime + lastGameTime = maxGameTs + }, 50) + } let _last_mouse_down = null const onUpdate = () => { for (let evt of evts.consumeAll()) { + if (MODE === 'play') { + // LOCAL ONLY CHANGES + // ------------------------------------------------------------- + const type = evt[0] + if (type === 'move') { + if (_last_mouse_down && !Game.getFirstOwnedTile(gameId, CLIENT_ID)) { + // move the cam + const pos = { x: evt[1], y: evt[2] } + const mouse = viewport.worldToViewport(pos) + const diffX = Math.round(mouse.x - _last_mouse_down.x) + const diffY = Math.round(mouse.y - _last_mouse_down.y) + RERENDER = true + viewport.move(diffX, diffY) - // LOCAL ONLY CHANGES - // ------------------------------------------------------------- - const type = evt[0] - if (type === 'move') { - if (_last_mouse_down && !Game.getFirstOwnedTile(gameId, CLIENT_ID)) { - // move the cam + _last_mouse_down = mouse + } + } else if (type === 'down') { const pos = { x: evt[1], y: evt[2] } - const mouse = viewport.worldToViewport(pos) - const diffX = Math.round(mouse.x - _last_mouse_down.x) - const diffY = Math.round(mouse.y - _last_mouse_down.y) - viewport.move(diffX, diffY) + _last_mouse_down = viewport.worldToViewport(pos) + } else if (type === 'up') { + _last_mouse_down = null + } else if (type === 'zoomin') { + if (viewport.zoomIn()) { + const pos = { x: evt[1], y: evt[2] } + RERENDER = true + Game.changePlayer(gameId, CLIENT_ID, pos) + } + } else if (type === 'zoomout') { + if (viewport.zoomOut()) { + const pos = { x: evt[1], y: evt[2] } + RERENDER = true + Game.changePlayer(gameId, CLIENT_ID, pos) + } + } - _last_mouse_down = mouse - } - } else if (type === 'down') { - const pos = { x: evt[1], y: evt[2] } - _last_mouse_down = viewport.worldToViewport(pos) - } else if (type === 'up') { - _last_mouse_down = null - } else if (type === 'zoomin') { - if (viewport.zoomIn()) { - const pos = { x: evt[1], y: evt[2] } + // LOCAL + SERVER CHANGES + // ------------------------------------------------------------- + const ts = TIME() + const changes = Game.handleInput(GAME_ID, CLIENT_ID, evt, ts) + if (changes.length > 0) { RERENDER = true - Game.changePlayer(gameId, CLIENT_ID, pos) } - } else if (type === 'zoomout') { - if (viewport.zoomOut()) { + Communication.sendClientEvent(evt) + } else if (MODE === 'replay') { + // LOCAL ONLY CHANGES + // ------------------------------------------------------------- + const type = evt[0] + if (type === 'move') { + if (_last_mouse_down) { + // move the cam + const pos = { x: evt[1], y: evt[2] } + const mouse = viewport.worldToViewport(pos) + const diffX = Math.round(mouse.x - _last_mouse_down.x) + const diffY = Math.round(mouse.y - _last_mouse_down.y) + RERENDER = true + viewport.move(diffX, diffY) + + _last_mouse_down = mouse + } + } else if (type === 'down') { const pos = { x: evt[1], y: evt[2] } + _last_mouse_down = viewport.worldToViewport(pos) + } else if (type === 'up') { + _last_mouse_down = null + } else if (type === 'zoomin') { + viewport.zoomIn() + RERENDER = true + } else if (type === 'zoomout') { + viewport.zoomOut() RERENDER = true - Game.changePlayer(gameId, CLIENT_ID, pos) } } - - // LOCAL + SERVER CHANGES - // ------------------------------------------------------------- - const changes = Game.handleInput(GAME_ID, CLIENT_ID, evt) - if (changes.length > 0) { - RERENDER = true - } - Communication.sendClientEvent(evt) } finished = Game.getFinishTs(gameId) @@ -528,14 +674,25 @@ async function main() { // DRAW PLAYERS // --------------------------------------------------------------- - for (let player of Game.getActivePlayers(gameId)) { + const ts = TIME() + for (let player of Game.getActivePlayers(gameId, ts)) { const cursor = await getPlayerCursor(player) const pos = viewport.worldToViewport(player) ctx.drawImage(cursor, Math.round(pos.x - cursor.width/2), Math.round(pos.y - cursor.height/2) ) - if (player.id !== CLIENT_ID) { + if (MODE === 'play') { + if (player.id !== CLIENT_ID) { + ctx.fillStyle = 'white' + ctx.font = '10px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(player.name + ' (' + player.points + ')', + Math.round(pos.x), + Math.round(pos.y) + cursor.height + ) + } + } else if (MODE === 'replay') { ctx.fillStyle = 'white' ctx.font = '10px sans-serif' ctx.textAlign = 'center' diff --git a/game/style.css b/game/style.css index 7a02eaf..436480e 100644 --- a/game/style.css +++ b/game/style.css @@ -22,6 +22,7 @@ a:hover { color: var(--link-hover-color); } .scores { position: absolute; right: 0; + top: 0; background: var(--bg-color); padding: 5px; @@ -32,6 +33,7 @@ a:hover { color: var(--link-hover-color); } .timer { position: absolute; left: 0; + top: 0; background: var(--bg-color); padding: 5px; @@ -41,6 +43,8 @@ a:hover { color: var(--link-hover-color); } .menu { position: absolute; + top: 0; + left: 50%; transform: translateX(-50%); background: var(--bg-color); @@ -192,3 +196,9 @@ input:focus { background: var(--bg-color); padding: 5px; } + +.game-replay { + position: absolute; + top: 0; + right: 0; +} diff --git a/game/templates/game.html.twig b/game/templates/game.html.twig index ee2d0da..fad127d 100644 --- a/game/templates/game.html.twig +++ b/game/templates/game.html.twig @@ -12,6 +12,7 @@
+