reworking reconcilation and improved debug
This commit is contained in:
parent
cdaa7c678d
commit
f1c9af7b6b
|
@ -12,9 +12,11 @@
|
|||
<script type="module" src="/js/main.js"></script>
|
||||
<div id="controls">
|
||||
<label for="fakeping">Fake Latency (ms): </label>
|
||||
<input type="number" id="fakeping" value="0" min="0" max="1000" title="This feature is broken!" disabled>
|
||||
<input type="number" id="fakeping" value="0" min="0" max="1000">
|
||||
<label for="interpolation">Interpolation: </label>
|
||||
<input type="checkbox" id="interpolation">
|
||||
<label for="show-authority">Show Authority Positions: </label>
|
||||
<input type="checkbox" id="show-authority">
|
||||
</div>
|
||||
<div id="chatbox"></div>
|
||||
<div id="compose">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue