From f1c9af7b6be758ed054ffe30ba5740e372ff123c Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 30 Aug 2024 15:18:07 +0100 Subject: [PATCH] reworking reconcilation and improved debug --- client/index.html | 4 +- client/js/main.js | 374 +++++++++++++++++++++++++++++----------------- common/player.js | 7 +- server/ws.js | 8 +- 4 files changed, 251 insertions(+), 142 deletions(-) diff --git a/client/index.html b/client/index.html index 9d771f0..42a6755 100644 --- a/client/index.html +++ b/client/index.html @@ -12,9 +12,11 @@
- + + +
diff --git a/client/js/main.js b/client/js/main.js index 526a0a7..ca8c0dd 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -22,32 +22,47 @@ var players = {}; var props = {}; var client_id; var delta = 0.0; -var last_update = 0.0; +var lastUpdate = 0.0; var frames = 0; var ticks = 0; -var server_tick = 0; -var server_ping = 0; +var serverTick = 0; +var serverPing = 0; +var reconciliations = 0; +var lastServerState = 0; var ws; -var predictions = {}; -const interpolationToggle = document.getElementById("interpolation"); -var enable_interpolation = new Stateful(localStorage.getItem("interpolation") || true); -interpolationToggle.checked = enable_interpolation.get(); -enable_interpolation.onUpdate(val => { - localStorage.setItem("interpolation", val); -}); -interpolationToggle.addEventListener("change", () => { - enable_interpolation.set(interpolationToggle.checked); -}); +const BUFFER_SIZE = TICK_RATE * 5; // 5 seconds of buffer +var stateBuffer = new Array(BUFFER_SIZE); +var inputBuffer = new Array(BUFFER_SIZE); const fakePingInput = document.getElementById("fakeping"); -var fake_ping = new Stateful(localStorage.getItem("fakeping") || 0); -fakePingInput.value = fake_ping.get(); -fake_ping.onUpdate(val => { +var fakePing = new Stateful(localStorage.getItem("fakeping") || 0); +fakePingInput.value = fakePing.get(); +fakePing.onUpdate(val => { localStorage.setItem("fakeping", val); }); fakePingInput.addEventListener("change", () => { - fake_ping.set(fakePingInput.value); + fakePing.set(fakePingInput.value); +}); + +const interpolationToggle = document.getElementById("interpolation"); +var interpolationEnabled = new Stateful(localStorage.getItem("interpolation") || true); +interpolationToggle.checked = interpolationEnabled.get(); +interpolationEnabled.onUpdate(val => { + localStorage.setItem("interpolation", val); +}); +interpolationToggle.addEventListener("change", () => { + interpolationEnabled.set(interpolationToggle.checked); +}); + +const authorityPositionToggle = document.getElementById("show-authority"); +var showAuthorityPositions = new Stateful(localStorage.getItem("show-authority") || false); +authorityPositionToggle.checked = showAuthorityPositions.get(); +showAuthorityPositions.onUpdate(val => { + localStorage.setItem("show-authority", val); +}); +authorityPositionToggle.addEventListener("change", () => { + showAuthorityPositions.set(authorityPositionToggle.checked); }); var input = { @@ -72,7 +87,6 @@ function start() { }); ws.addEventListener("message", packet => { - setTimeout(() => { var data = JSON.parse(packet.data); switch (data.type) { @@ -107,73 +121,9 @@ function start() { chatbox.scrollTop = chatbox.scrollHeight; players[data.id] = new Player(data.name, data.x, data.y, data.col); break; - case "update": { - server_tick = data.tick; - var prediction = predictions[data.tick]; - if (prediction) { - Object.keys(predictions).forEach(tick => { - if (tick < data.tick) delete predictions[tick]; - }); - server_ping = new Date() - prediction.time; - } - Object.keys(data.players).forEach(id => { - const player = players[id]; - const update = data.players[id]; - if (id == client_id) { - if (!prediction) return; - const predicted = prediction.player; - // clear all predictions prior to this tick - if (predicted != update) { - var diff_x = predicted.x - update.x; - var diff_y = predicted.y - update.y; - // apply difference to all predictions - Object.values(predictions).forEach(p => { - p.x -= diff_x; - p.y -= diff_y; - }); - // update client state to reflect - player.x -= diff_x; - player.y -= diff_y; - } - delete predictions[data.tick]; - } else { - player.x = update.x; - player.y = update.y; - } - }); - Object.keys(data.props).forEach(id => { - const prop = props[id]; - const update = data.props[id]; - if (!prediction) { - prop.x = update.x; - prop.y = update.y; - prop.xv = update.xv; - prop.yv = update.yv; - return; - } - - const predicted = prediction.props[id]; - if (predicted != update) { - var diff_x = predicted.x - update.x; - var diff_y = predicted.y - update.y; - var diff_xv = predicted.xv - update.xv; - var diff_yv = predicted.yv - update.yv; - // apply difference to all predictions - Object.values(predictions).forEach(p => { - p.x -= diff_x; - p.y -= diff_y; - p.xv -= diff_xv; - p.yv -= diff_yv; - }); - // update client state to reflect - prop.x -= diff_x; - prop.y -= diff_y; - prop.xv -= diff_xv; - prop.yv -= diff_yv; - } - }); + case "update": + processServerTick(data); break; - } case "chat": { const player = players[data.player]; @@ -214,7 +164,6 @@ function start() { console.warn("Unknown message received from the server."); console.warn(msg); } - }, fake_ping.get() / 2); }); ws.addEventListener("error", error => { @@ -242,14 +191,135 @@ function start() { }); } +function processServerTick(data) { + lastServerState = data; + serverTick = data.tick; + var localState = stateBuffer[data.tick % BUFFER_SIZE] + if (localState) serverPing = new Date() - localState.time; + + // update players + Object.keys(data.players).forEach(id => { + const player = players[id]; + const serverPlayerState = data.players[id]; + if (!localState) { + player.x = serverPlayerState.x; + player.y = serverPlayerState.y; + player.in_x = serverPlayerState.in_x; + player.in_y = serverPlayerState.in_y; + return; + } + const localPlayerState = localState.players[id]; + + // calculate position difference from authority + var dist = Math.sqrt( + Math.pow(localPlayerState.x - serverPlayerState.x, 2) + + Math.pow(localPlayerState.y - serverPlayerState.y, 2)); + + if (dist > 10.0) { + return; + reconciliations++; + + console.warn("Player#" + id + ": Reconciling " + dist + " units of error."); + console.warn("\tclient said (" + localPlayerState.x.toPrecision(4) + "," + localPlayerState.y.toPrecision(4) + ")"); + console.warn("\tserver said (" + serverPlayerState.x.toPrecision(4) + "," + serverPlayerState.y.toPrecision(4) + ")"); + + // resync to authority and roll-forward + localState.players[id] = serverPlayerState; + for (var tick = serverTick + 1; tick < ticks; tick++) { + const state = stateBuffer[(tick - 1) % BUFFER_SIZE].players[id]; + const input = id == client_id ? inputBuffer[tick % BUFFER_SIZE] : { + x: state.in_x, + y: state.in_y, + }; + + player.x = state.x; + player.y = state.y; + player.in_x = input.x; + player.in_y = input.y; + + player.update(1000 / TICK_RATE / 1000); + stateBuffer[tick % BUFFER_SIZE].player = { + x: player.x, + y: player.y, + } + } + + var lastState = stateBuffer[ticks % BUFFER_SIZE]; + if (lastState) { + player.x = lastState.players[id].x; + player.y = lastState.players[id].y; + } + } + }); + + // update props + Object.keys(data.props).forEach(id => { + const serverPropState = data.props[id]; + const prop = props[id]; + + prop.x = serverPropState.x; + prop.y = serverPropState.y; + prop.xv = serverPropState.xv; + prop.yv = serverPropState.yv; + + return; // TODO: reimplement this once player reconciliation is stable + if (!localState) { + prop.x = serverPropState.x; + prop.y = serverPropState.y; + prop.xv = serverPropState.xv; + prop.yv = serverPropState.yv; + return; + } + const localPropState = localState.props[id]; + + // calculate position difference from authority + var dist = Math.sqrt( + Math.pow(localPropState.x - serverPropState.x, 2) + + Math.pow(localPropState.y - serverPropState.y, 2)); + + if (dist > 10.0) { + reconciliations++; + + console.warn("Prop#" + id + ": Reconciling " + dist + " units of error."); + + // resync to authority and roll-forward + localState.props[id] = serverPropState; + for (var tick = serverTick; tick < ticks; tick++) { + const state = stateBuffer[(tick - 1) % BUFFER_SIZE]; + const propState = state.props[id]; + prop.x = propState.x; + prop.y = propState.y; + prop.xv = propState.xv; + prop.yv = propState.yv; + prop.update(1000 / TICK_RATE / 1000, Object.values(state.players)); + stateBuffer[tick % BUFFER_SIZE].props[id] = { + x: prop.x, + y: prop.y, + xv: prop.xv, + yv: prop.yv, + } + } + + var lastState = stateBuffer[ticks % BUFFER_SIZE]; + if (lastState) { + prop.x = lastState.props[id].x; + prop.y = lastState.props[id].y; + prop.xv = lastState.props[id].xv; + prop.yv = lastState.props[id].yv; + } + } + }); +} + canvas.addEventListener("keypress", event => { switch (event.key.toLowerCase()) { case 'i': - enable_interpolation.update(val => !val); - interpolationToggle.checked = enable_interpolation.get(); + interpolationEnabled.update(val => !val); + interpolationToggle.checked = interpolationEnabled.get(); break; case 'p': - console.log(predictions); + showAuthorityPositions.update(val => !val); + authorityPositionToggle.checked = showAuthorityPositions.get(); break; case 'enter': composeBox.focus(); @@ -322,45 +392,38 @@ function sendChat(msg) { type: "chat", msg: msg, })); - }, fake_ping.get() / 2); + }, fakePing.get()); } function update(delta) { - const prediction = { - time: new Date(), - player: {}, - props: {}, - }; - const clientPlayer = players[client_id]; if (!clientPlayer) return; clientPlayer.in_x = input.move_right - input.move_left; clientPlayer.in_y = input.move_down - input.move_up; - clientPlayer.update(delta); - - // insert prediction for the next server tick - prediction.player = { - x: clientPlayer.x, - y: clientPlayer.y, + inputBuffer[ticks % BUFFER_SIZE] = { + x: clientPlayer.in_x, + y: clientPlayer.in_y, }; - var t = ticks; - setTimeout(() => { - if (!ws) return; - ws.send(JSON.stringify({ - type: "update", - tick: t, - x: input.move_right - input.move_left, - y: input.move_down - input.move_up, - })); - }, fake_ping.get() / 2); + var playerState = {}; + Object.keys(players).forEach(id => { + const player = players[id]; + player.update(delta); + playerState[id] = { + x: player.x, + y: player.y, + in_x: player.in_x, + in_y: player.in_y, + }; + }); + var propState = {}; Object.keys(props).forEach(id => { const prop = props[id]; prop.update(delta, Object.values(players)); - prediction.props[id] = { + propState[id] = { x: prop.x, y: prop.y, xv: prop.xv, @@ -368,15 +431,30 @@ function update(delta) { }; }); - predictions[ticks] = prediction; + stateBuffer[ticks % BUFFER_SIZE] = { + tick: ticks, + time: new Date(), + players: playerState, + props: propState, + }; + + setTimeout(ticks => { + if (!ws) return; + ws.send(JSON.stringify({ + type: "update", + tick: ticks, + x: input.move_right - input.move_left, + y: input.move_down - input.move_up, + })); + }, fakePing.get() / 2, ticks); ticks++; } function draw() { - delta = performance.now() - last_update; - if (performance.now() - last_update >= 1000 / TICK_RATE) { - last_update = performance.now(); + delta = performance.now() - lastUpdate; + if (performance.now() - lastUpdate >= 1000 / TICK_RATE) { + lastUpdate = performance.now(); update(delta / 1000); } @@ -385,34 +463,56 @@ function draw() { ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); + // DEBUG: show authoritative position for client players and props + var authorityDistance = 0.0; + if (lastServerState && showAuthorityPositions.get()) { + const player = players[client_id]; + const lastPlayerState = lastServerState.players[client_id]; + authorityDistance = Math.sqrt( + Math.pow(players[client_id].x - lastPlayerState.x, 2) + + Math.pow(players[client_id].y - lastPlayerState.y, 2)); + ctx.strokeStyle = player.colour; + ctx.beginPath(); + ctx.rect( + lastPlayerState.x - Player.SIZE / 2, + lastPlayerState.y - Player.SIZE / 2, + Player.SIZE, Player.SIZE); + ctx.stroke(); + + Object.keys(props).forEach(id => { + const prop = props[id]; + const lastPropState = lastServerState.props[id]; + ctx.strokeStyle = prop.colour; + ctx.beginPath(); + ctx.rect( + lastPropState.x - Prop.SIZE / 2, + lastPropState.y - Prop.SIZE / 2, + Prop.SIZE, Prop.SIZE); + ctx.stroke(); + }); + } + drawPlayers(); drawProps(); - // DEBUG: draw last known authoritative state - if (Object.values(predictions).length > 10) { - const server_state = Object.values(predictions)[0]; - ctx.fillStyle = "#208020"; - ctx.beginPath(); - ctx.rect(server_state.player.x - Player.SIZE / 2, - server_state.player.y - Player.SIZE / 2, - Player.SIZE, Player.SIZE); - ctx.stroke(); + var debug = { + "ping": serverPing + "ms", + "fake ping": + fakePing.get() + "ms", + "ticks behind": ticks - serverTick, + "reconciliations": reconciliations, + } + if (showAuthorityPositions.get()) { + debug["authority distance"] = authorityDistance; } - var debug = "ping: " + server_ping + "ms\n" + - "fake ping: " + fake_ping.get() + "ms\n" + - "buffer length: " + Object.keys(predictions).length + "\n" + - "delta: " + delta + "\n" + - "ticks behind: " + (ticks - server_tick); ctx.fillStyle = "#101010"; ctx.font = "16px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; - var debug_lines = debug.split('\n'); - var debug_y = WORLD_SIZE - 8 - (debug_lines.length - 1) * 16; - for (var i = 0; i < debug_lines.length; i++) { - ctx.fillText(debug_lines[i], 8, debug_y + 16 * i); - } + var debug_y = WORLD_SIZE - 8 - (Object.keys(debug).length - 1) * 16; + Object.keys(debug).forEach((key, i) => { + ctx.fillText(key + ": " + debug[key], 8, debug_y + 16 * i); + }); frames++; requestAnimationFrame(draw); @@ -422,7 +522,7 @@ function drawPlayers() { Object.keys(players).forEach((id, index) => { const player = players[id]; - if (enable_interpolation.get()) { + if (interpolationEnabled.get()) { player.draw_x = player.draw_x + 0.1 * (player.x - player.draw_x); player.draw_y = player.draw_y + 0.1 * (player.y - player.draw_y); } else { @@ -459,7 +559,7 @@ function drawProps() { Object.keys(props).forEach(id => { const prop = props[id]; - if (enable_interpolation.get()) { + if (interpolationEnabled.get()) { prop.draw_x = prop.draw_x + 0.1 * (prop.x - prop.draw_x); prop.draw_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y); } else { diff --git a/common/player.js b/common/player.js index d624fb0..83a89bd 100644 --- a/common/player.js +++ b/common/player.js @@ -1,5 +1,7 @@ +import { WORLD_SIZE } from "./world.js"; + export default class Player { - static SPEED = 400.0; + static SPEED = 200.0; static SIZE = 50.0; constructor(name, x, y, colour) { @@ -16,5 +18,8 @@ export default class Player { update(delta) { if (this.in_x != 0) this.x += this.in_x * Player.SPEED * delta; if (this.in_y != 0) this.y += this.in_y * Player.SPEED * delta; + + this.x = Math.min(Math.max(this.x, 0.0), WORLD_SIZE); + this.y = Math.min(Math.max(this.y, 0.0), WORLD_SIZE); } } diff --git a/server/ws.js b/server/ws.js index 5439792..f9691e4 100644 --- a/server/ws.js +++ b/server/ws.js @@ -77,6 +77,8 @@ export function init(http_server) { name: client.player.name, x: client.player.x, y: client.player.y, + xv: client.player.xv, + yv: client.player.yv, col: client.player.colour, }; }); @@ -168,17 +170,18 @@ export function init(http_server) { function update() { var delta = (performance.now() - last_update) / 1000; + last_update = performance.now(); // update players var frame_players = {}; clients.forEach(client => { if (!client.player) return; client.player.update(delta); - client.player.x = Math.max(Math.min(client.player.x, WORLD_SIZE), 0); - client.player.y = Math.max(Math.min(client.player.y, WORLD_SIZE), 0); frame_players[client.id] = { x: client.player.x, y: client.player.y, + xv: client.player.xv, + yv: client.player.yv, }; }); @@ -211,7 +214,6 @@ function update() { })); }) - last_update = performance.now(); ticks++; setTimeout(update, 1000 / TICK_RATE); }