add replay functionality

This commit is contained in:
Zutatensuppe 2020-12-22 22:35:09 +01:00
parent 4158aa0854
commit 083fc0463c
13 changed files with 452 additions and 125 deletions

View file

@ -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,
}

View file

@ -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,

View file

@ -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'

View file

@ -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;
}

View file

@ -12,6 +12,7 @@
<body>
<script>window.GAME_ID = '{{GAME_ID}}'</script>
<script>window.WS_ADDRESS = '{{WS_ADDRESS}}'</script>
<script>window.MODE = 'play'</script>
<script src="/game.js" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<html>
<head>
<link rel="stylesheet" href="/style.css" />
<style type="text/css">
html,
body {
overflow: hidden;
}
canvas {
cursor: grab;
}
</style>
<title>🧩 jigsaw.hyottoko.club</title>
</head>
<body>
<script>window.GAME_ID = '{{GAME_ID}}'</script>
<script>window.WS_ADDRESS = '{{WS_ADDRESS}}'</script>
<script>window.MODE = 'replay'</script>
<script src="/game.js" type="module"></script>
</body>
</html>