add replay functionality
This commit is contained in:
parent
4158aa0854
commit
083fc0463c
13 changed files with 452 additions and 125 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
339
game/game.js
339
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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
21
game/templates/replay.html.twig
Normal file
21
game/templates/replay.html.twig
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue