From 083fc0463c8bb180156e088c395651da9d446ee3 Mon Sep 17 00:00:00 2001 From: Zutatensuppe Date: Tue, 22 Dec 2020 22:35:09 +0100 Subject: [PATCH] add replay functionality --- common/GameCommon.js | 47 +++-- common/Protocol.js | 4 + common/Util.js | 12 ++ game/Communication.js | 20 ++ game/Game.js | 3 + game/game.js | 339 +++++++++++++++++++++++--------- game/style.css | 10 + game/templates/game.html.twig | 1 + game/templates/replay.html.twig | 21 ++ server/Game.js | 40 +++- server/GameLog.js | 25 +++ server/Puzzle.js | 7 +- server/index.js | 48 ++++- 13 files changed, 452 insertions(+), 125 deletions(-) create mode 100644 game/templates/replay.html.twig create mode 100644 server/GameLog.js 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 @@ + diff --git a/game/templates/replay.html.twig b/game/templates/replay.html.twig new file mode 100644 index 0000000..4c92436 --- /dev/null +++ b/game/templates/replay.html.twig @@ -0,0 +1,21 @@ + + + + + 🧩 jigsaw.hyottoko.club + + + + + + + + diff --git a/server/Game.js b/server/Game.js index 4ca3994..97b10b5 100644 --- a/server/Game.js +++ b/server/Game.js @@ -1,8 +1,9 @@ import fs from 'fs' -import { createPuzzle } from './Puzzle.js' import GameCommon from './../common/GameCommon.js' import Util from './../common/Util.js' import { Rng } from '../common/Rng.js' +import GameLog from './GameLog.js' +import { createPuzzle } from './Puzzle.js' const DATA_DIR = './../data' @@ -42,24 +43,44 @@ function loadAllGames() { } const changedGames = {} -async function createGame(gameId, targetTiles, image) { - const rng = new Rng(gameId); +async function createGameObject(gameId, targetTiles, image, ts) { + const seed = Util.hash(gameId + ' ' + ts) + const rng = new Rng(seed) + return GameCommon.__createGameObject( + gameId, + { + type: 'Rng', + obj: rng, + }, + await createPuzzle(rng, targetTiles, image, ts), + {}, + [], + {} + ) +} +async function createGame(gameId, targetTiles, image, ts) { + GameLog.log(gameId, 'createGame', targetTiles, image, ts) + + const seed = Util.hash(gameId + ' ' + ts) + const rng = new Rng(seed) GameCommon.newGame({ id: gameId, rng: { type: 'Rng', obj: rng, }, - puzzle: await createPuzzle(rng, targetTiles, image), + puzzle: await createPuzzle(rng, targetTiles, image, ts), players: {}, sockets: [], evtInfos: {}, }) + changedGames[gameId] = true } -function addPlayer(gameId, playerId) { - GameCommon.addPlayer(gameId, playerId) +function addPlayer(gameId, playerId, ts) { + GameLog.log(gameId, 'addPlayer', playerId, ts) + GameCommon.addPlayer(gameId, playerId, ts) changedGames[gameId] = true } @@ -68,8 +89,10 @@ function addSocket(gameId, socket) { changedGames[gameId] = true } -function handleInput(gameId, playerId, input) { - const ret = GameCommon.handleInput(gameId, playerId, input) +function handleInput(gameId, playerId, input, ts) { + GameLog.log(gameId, 'handleInput', playerId, input, ts) + + const ret = GameCommon.handleInput(gameId, playerId, input, ts) changedGames[gameId] = true return ret } @@ -93,6 +116,7 @@ function persistChangedGames() { } export default { + createGameObject, loadAllGames, persistChangedGames, createGame, diff --git a/server/GameLog.js b/server/GameLog.js new file mode 100644 index 0000000..300fdec --- /dev/null +++ b/server/GameLog.js @@ -0,0 +1,25 @@ +import fs from 'fs' + +const DATA_DIR = './../data' + +const log = (gameId, ...args) => { + const str = JSON.stringify(args) + fs.appendFileSync(`${DATA_DIR}/log_${gameId}.log`, str + "\n") +} + +const get = (gameId) => { + const all = fs.readFileSync(`${DATA_DIR}/log_${gameId}.log`, 'utf-8') + return all.split("\n").filter(line => !!line).map((line) => { + try { + return JSON.parse(line) + } catch (e) { + console.log(line) + console.log(e) + } + }) +} + +export default { + log, + get, +} diff --git a/server/Puzzle.js b/server/Puzzle.js index ae93d5d..8d454d1 100644 --- a/server/Puzzle.js +++ b/server/Puzzle.js @@ -1,5 +1,5 @@ import sizeOf from 'image-size' -import Util from './../common/Util.js' +import Util from '../common/Util.js' import exif from 'exif' import { Rng } from '../common/Rng.js' @@ -38,7 +38,8 @@ async function getExifOrientation(imagePath) { async function createPuzzle( /** @type Rng */ rng, targetTiles, - image + image, + ts ) { const imagePath = image.file const imageUrl = image.url @@ -135,7 +136,7 @@ async function createPuzzle( // TODO: maybe calculate this each time? maxZ: 0, // max z of all pieces maxGroup: 0, // max group of all pieces - started: Util.timestamp(), // start timestamp + started: ts, // start timestamp finished: 0, // finish timestamp }, // static puzzle information. stays same for complete duration of diff --git a/server/index.js b/server/index.js index 5fc920a..1775ea5 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,7 @@ import twing from 'twing' import bodyParser from 'body-parser' import v8 from 'v8' import { Rng } from '../common/Rng.js' +import GameLog from './GameLog.js' const allImages = () => [ ...fs.readdirSync('./../data/uploads/').map(f => ({ @@ -50,6 +51,14 @@ app.use('/g/:gid', async (req, res, next) => { WS_ADDRESS: config.ws.connectstring, })) }) + +app.use('/replay/:gid', async (req, res, next) => { + res.send(await render('replay.html.twig', { + GAME_ID: req.params.gid, + WS_ADDRESS: config.ws.connectstring, + })) +}) + app.post('/upload', (req, res) => { upload(req, res, (err) => { if (err) { @@ -68,7 +77,8 @@ app.post('/newgame', bodyParser.json(), async (req, res) => { console.log(req.body.tiles, req.body.image) const gameId = Util.uniqId() if (!Game.exists(gameId)) { - await Game.createGame(gameId, req.body.tiles, req.body.image) + const ts = Util.timestamp() + await Game.createGame(gameId, req.body.tiles, req.body.image, ts) } res.send({ url: `/g/${gameId}` }) }) @@ -77,6 +87,7 @@ app.use('/common/', express.static('./../common/')) app.use('/uploads/', express.static('./../data/uploads/')) app.use('/', async (req, res, next) => { if (req.path === '/') { + const ts = Util.timestamp() const games = [ ...Game.getAllGames().map(game => ({ id: game.id, @@ -84,7 +95,7 @@ app.use('/', async (req, res, next) => { finished: Game.getFinishTs(game.id), tilesFinished: Game.getFinishedTileCount(game.id), tilesTotal: Game.getTileCount(game.id), - players: Game.getActivePlayers(game.id).length, + players: Game.getActivePlayers(game.id, ts).length, imageUrl: Game.getImageUrl(game.id), })), ] @@ -124,11 +135,36 @@ wss.on('message', async ({socket, data}) => { const msg = JSON.parse(data) const msgType = msg[0] switch (msgType) { + case Protocol.EV_CLIENT_INIT_REPLAY: { + const log = GameLog.get(gameId) + let game = await Game.createGameObject( + gameId, + log[0][1], + log[0][2], + log[0][3] + ) + notify( + [Protocol.EV_SERVER_INIT_REPLAY, { + id: game.id, + rng: { + type: game.rng.type, + obj: Rng.serialize(game.rng.obj), + }, + puzzle: game.puzzle, + players: game.players, + sockets: [], + evtInfos: game.evtInfos, + }, log], + [socket] + ) + } break; + case Protocol.EV_CLIENT_INIT: { if (!Game.exists(gameId)) { throw `[game ${gameId} does not exist... ]` } - Game.addPlayer(gameId, clientId) + const ts = Util.timestamp() + Game.addPlayer(gameId, clientId, ts) Game.addSocket(gameId, socket) const game = Game.get(gameId) notify( @@ -150,7 +186,9 @@ wss.on('message', async ({socket, data}) => { case Protocol.EV_CLIENT_EVENT: { const clientSeq = msg[1] const clientEvtData = msg[2] - Game.addPlayer(gameId, clientId) + const ts = Util.timestamp() + + Game.addPlayer(gameId, clientId, ts) Game.addSocket(gameId, socket) const game = Game.get(gameId) @@ -164,7 +202,7 @@ wss.on('message', async ({socket, data}) => { }], [socket] ) - const changes = Game.handleInput(gameId, clientId, clientEvtData) + const changes = Game.handleInput(gameId, clientId, clientEvtData, ts) notify( [Protocol.EV_SERVER_EVENT, clientId, clientSeq, changes], Game.getSockets(gameId)