feat: basic client prediction

This commit is contained in:
cha0s 2024-09-05 07:15:55 -05:00
parent b0b13c8961
commit 2733968fa4
15 changed files with 485 additions and 113 deletions

View File

@ -38,7 +38,7 @@ export default class Interpolator {
return undefined; return undefined;
} }
this.location += elapsed; this.location += elapsed;
const fraction = this.location / this.duration; const fraction = Math.min(1, this.location / this.duration);
const [from, to] = [this.penultimate.payload.ecs, this.latest.payload.ecs]; const [from, to] = [this.penultimate.payload.ecs, this.latest.payload.ecs];
const interpolated = {}; const interpolated = {};
for (const {entityId, componentName, properties} of this.tracking) { for (const {entityId, componentName, properties} of this.tracking) {

View File

@ -1,14 +1,17 @@
import Client from '@/net/client.js'; import Client from '@/net/client.js';
import {decode, encode} from '@/net/packets/index.js'; import {decode, encode} from '@/net/packets/index.js';
import {CLIENT_INTERPOLATION, CLIENT_PREDICTION} from '@/util/constants.js';
export default class LocalClient extends Client { export default class LocalClient extends Client {
server = null; server = null;
interpolator = null; interpolator = null;
predictor = null;
async connect() { async connect() {
this.server = new Worker( this.server = new Worker(
new URL('../server/worker.js', import.meta.url), new URL('../server/worker.js', import.meta.url),
{type: 'module'}, {type: 'module'},
); );
if (CLIENT_INTERPOLATION) {
this.interpolator = new Worker( this.interpolator = new Worker(
new URL('./interpolator.js', import.meta.url), new URL('./interpolator.js', import.meta.url),
{type: 'module'}, {type: 'module'},
@ -16,6 +19,33 @@ export default class LocalClient extends Client {
this.interpolator.addEventListener('message', (event) => { this.interpolator.addEventListener('message', (event) => {
this.accept(event.data); this.accept(event.data);
}); });
}
if (CLIENT_PREDICTION) {
this.predictor = new Worker(
new URL('./predictor.js', import.meta.url),
{type: 'module'},
);
this.predictor.addEventListener('message', (event) => {
const [flow, packet] = event.data;
switch (flow) {
case 0: {
const packed = encode(packet);
this.throughput.$$up += packed.byteLength;
this.server.postMessage(packed);
break;
}
case 1: {
if (CLIENT_INTERPOLATION) {
this.interpolator.postMessage(packet);
}
else {
this.accept(packet);
}
break;
}
}
});
}
this.server.addEventListener('message', (event) => { this.server.addEventListener('message', (event) => {
if (0 === event.data) { if (0 === event.data) {
this.server.terminate(); this.server.terminate();
@ -23,16 +53,32 @@ export default class LocalClient extends Client {
return; return;
} }
this.throughput.$$down += event.data.byteLength; this.throughput.$$down += event.data.byteLength;
this.interpolator.postMessage(decode(event.data)); const packet = decode(event.data);
if (CLIENT_PREDICTION) {
this.predictor.postMessage([1, packet]);
}
else if (CLIENT_INTERPOLATION) {
this.interpolator.postMessage(packet);
}
else {
this.accept(packet);
}
}); });
} }
disconnect() { disconnect() {
this.server.postMessage(0); this.server.postMessage(0);
if (CLIENT_INTERPOLATION) {
this.interpolator.terminate(); this.interpolator.terminate();
} }
}
transmit(packet) { transmit(packet) {
if (CLIENT_PREDICTION) {
this.predictor.postMessage([0, packet]);
}
else {
const packed = encode(packet); const packed = encode(packet);
this.throughput.$$up += packed.byteLength; this.throughput.$$up += packed.byteLength;
this.server.postMessage(packed); this.server.postMessage(packed);
} }
} }
}

172
app/client/predictor.js Normal file
View File

@ -0,0 +1,172 @@
import {LRUCache} from 'lru-cache';
import Components from '@/ecs/components/index.js';
import Ecs from '@/ecs/ecs.js';
import Systems from '@/ecs/systems/index.js';
import {withResolvers} from '@/util/promise.js';
const cache = new LRUCache({
max: 128,
});
class PredictionEcs extends Ecs {
async readAsset(uri) {
if (!cache.has(uri)) {
const {promise, resolve, reject} = withResolvers();
cache.set(uri, promise);
fetch(uri)
.then((response) => resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0)))
.catch(reject);
}
return cache.get(uri);
}
}
const Flow = {
UP: 0,
DOWN: 1,
};
const Stage = {
UNACK: 0,
ACK: 1,
FINISHING: 2,
FINISHED: 3,
};
const actions = new Map();
let ecs = new PredictionEcs({Components, Systems});
let mainEntityId = 0;
function applyClientActions(elapsed) {
if (actions.size > 0) {
const main = ecs.get(mainEntityId);
const {Controlled} = main;
const finished = [];
for (const [id, action] of actions) {
if (Stage.UNACK === action.stage) {
if (!Controlled.locked) {
switch (action.action.type) {
case 'moveUp':
case 'moveRight':
case 'moveDown':
case 'moveLeft': {
Controlled[action.action.type] = action.action.value;
break;
}
}
}
action.steps.push(elapsed);
}
if (Stage.FINISHING === action.stage) {
if (!Controlled.locked) {
switch (action.action.type) {
case 'moveUp':
case 'moveRight':
case 'moveDown':
case 'moveLeft': {
Controlled[action.action.type] = 0;
break;
}
}
}
action.stage = Stage.FINISHED;
}
if (Stage.FINISHED === action.stage) {
action.steps.shift();
if (0 === action.steps.length) {
finished.push(id);
continue;
}
}
let leap = 0;
for (const step of action.steps) {
leap += step;
}
if (leap > 0) {
ecs.predict(main, leap);
}
}
for (const id of finished) {
actions.delete(id);
}
}
}
let downPromise;
const pending = new Map();
onmessage = async (event) => {
const [flow, packet] = event.data;
switch (flow) {
case Flow.UP: {
switch (packet.type) {
case 'Action': {
switch (packet.payload.type) {
case 'moveUp':
case 'moveRight':
case 'moveDown':
case 'moveLeft': {
if (0 === packet.payload.value) {
const ack = pending.get(packet.payload.type);
const action = actions.get(ack);
action.stage = Stage.FINISHING;
pending.delete(packet.payload.type);
}
else {
const tx = {
action: packet.payload,
stage: Stage.UNACK,
steps: [],
};
packet.payload.ack = Math.random();
pending.set(packet.payload.type, packet.payload.ack);
actions.set(packet.payload.ack, tx);
}
}
}
break;
}
}
postMessage([0, packet]);
break;
}
case Flow.DOWN: {
downPromise = Promise.resolve(downPromise).then(async () => {
switch (packet.type) {
case 'ActionAck': {
const action = actions.get(packet.payload.ack);
action.stage = Stage.ACK;
return;
}
case 'Tick': {
for (const entityId in packet.payload.ecs) {
if (packet.payload.ecs[entityId]) {
if (packet.payload.ecs[entityId].MainEntity) {
mainEntityId = parseInt(entityId);
}
}
}
await ecs.apply(packet.payload.ecs);
if (actions.size > 0) {
const main = ecs.get(mainEntityId);
const authoritative = structuredClone(main.toNet(main));
applyClientActions(packet.payload.elapsed);
if (ecs.diff[mainEntityId]) {
packet.payload.ecs[mainEntityId] = ecs.diff[mainEntityId];
}
await ecs.apply({[mainEntityId]: authoritative});
}
ecs.setClean();
break;
}
}
postMessage([1, packet]);
});
break;
}
}
};

View File

@ -1,10 +1,17 @@
import Client from '@/net/client.js'; import Client from '@/net/client.js';
import {decode, encode} from '@/net/packets/index.js'; import {decode, encode} from '@/net/packets/index.js';
import {CLIENT_INTERPOLATION, CLIENT_PREDICTION} from '@/util/constants.js';
export default class RemoteClient extends Client { export default class RemoteClient extends Client {
socket = null; socket = null;
interpolator = null; interpolator = null;
predictor = null;
async connect(host) { async connect(host) {
this.interpolator = new Worker(
new URL('./interpolator.js', import.meta.url),
{type: 'module'},
);
if (CLIENT_INTERPOLATION) {
this.interpolator = new Worker( this.interpolator = new Worker(
new URL('./interpolator.js', import.meta.url), new URL('./interpolator.js', import.meta.url),
{type: 'module'}, {type: 'module'},
@ -12,11 +19,48 @@ export default class RemoteClient extends Client {
this.interpolator.addEventListener('message', (event) => { this.interpolator.addEventListener('message', (event) => {
this.accept(event.data); this.accept(event.data);
}); });
}
if (CLIENT_PREDICTION) {
this.predictor = new Worker(
new URL('./predictor.js', import.meta.url),
{type: 'module'},
);
this.predictor.addEventListener('message', (event) => {
const [flow, packet] = event.data;
switch (flow) {
case 0: {
const packed = encode(packet);
this.throughput.$$up += packed.byteLength;
this.socket.send(packed);
break;
}
case 1: {
if (CLIENT_INTERPOLATION) {
this.interpolator.postMessage(packet);
}
else {
this.accept(packet);
}
break;
}
}
});
}
const url = new URL(`wss://${host}/ws`) const url = new URL(`wss://${host}/ws`)
this.socket = new WebSocket(url.href); this.socket = new WebSocket(url.href);
this.socket.binaryType = 'arraybuffer'; this.socket.binaryType = 'arraybuffer';
this.socket.addEventListener('message', (event) => { this.socket.addEventListener('message', (event) => {
this.interpolator.postMessage(decode(event.data)); this.throughput.$$down += event.data.byteLength;
const packet = decode(event.data);
if (CLIENT_PREDICTION) {
this.predictor.postMessage([1, packet]);
}
else if (CLIENT_INTERPOLATION) {
this.interpolator.postMessage(packet);
}
else {
this.accept(packet);
}
}); });
this.socket.addEventListener('close', () => { this.socket.addEventListener('close', () => {
this.accept({type: 'ConnectionStatus', payload: 'aborted'}); this.accept({type: 'ConnectionStatus', payload: 'aborted'});
@ -27,11 +71,18 @@ export default class RemoteClient extends Client {
this.accept({type: 'ConnectionStatus', payload: 'connected'}); this.accept({type: 'ConnectionStatus', payload: 'connected'});
} }
disconnect() { disconnect() {
if (CLIENT_INTERPOLATION) {
this.interpolator.terminate(); this.interpolator.terminate();
} }
}
transmit(packet) { transmit(packet) {
if (CLIENT_PREDICTION) {
this.predictor.postMessage([0, packet]);
}
else {
const packed = encode(packet); const packed = encode(packet);
this.throughput.$$up += packed.byteLength; this.throughput.$$up += packed.byteLength;
this.socket.send(packed); this.socket.send(packed);
} }
} }
}

View File

@ -144,11 +144,22 @@ export default class Component {
} }
Component.ecs.markChange(this.entity, {[Component.constructor.componentName]: values}) Component.ecs.markChange(this.entity, {[Component.constructor.componentName]: values})
} }
toFullJSON() {
const {properties} = concrete;
const json = {};
for (const key in properties) {
json[key] = this[key];
}
return json;
}
toNet(recipient, data) { toNet(recipient, data) {
return data || Component.constructor.filterDefaults(this); if (data) {
return data;
}
return this.toFullJSON();
} }
toJSON() { toJSON() {
return Component.constructor.filterDefaults(this); return this.toFullJSON();
} }
async update(values) { async update(values) {
for (const key in values) { for (const key in values) {

View File

@ -408,6 +408,16 @@ export default class Ecs {
} }
} }
predict(entity, elapsed) {
for (const systemName in this.Systems) {
const System = this.Systems[systemName];
if (!System.predict) {
continue;
}
System.predict(entity, elapsed);
}
}
async readJson(uri) { async readJson(uri) {
const key = ['$$json', uri].join(':'); const key = ['$$json', uri].join(':');
if (!cache.has(key)) { if (!cache.has(key)) {

View File

@ -3,6 +3,10 @@ import {normalizeVector} from '@/util/math.js';
export default class ApplyControlMovement extends System { export default class ApplyControlMovement extends System {
predict(entity) {
this.tickSingle(entity);
}
static get priority() { static get priority() {
return { return {
before: 'IntegratePhysics', before: 'IntegratePhysics',
@ -16,7 +20,16 @@ export default class ApplyControlMovement extends System {
} }
tick() { tick() {
for (const {Controlled, Forces, Speed} of this.select('default')) { for (const entity of this.select('default')) {
this.tickSingle(entity);
}
}
tickSingle(entity) {
const {Controlled, Forces, Speed} = entity;
if (!Controlled || !Forces | !Speed) {
return;
}
if (!Controlled.locked) { if (!Controlled.locked) {
const movement = normalizeVector({ const movement = normalizeVector({
x: (Controlled.moveRight - Controlled.moveLeft), x: (Controlled.moveRight - Controlled.moveLeft),
@ -28,7 +41,6 @@ export default class ApplyControlMovement extends System {
}); });
} }
} }
}
} }

View File

@ -3,11 +3,24 @@ import {TAU} from '@/util/math.js';
export default class ControlDirection extends System { export default class ControlDirection extends System {
predict(entity) {
this.tickSingle(entity);
}
tick() { tick() {
for (const {Controlled, Direction} of this.ecs.changed(['Controlled'])) { for (const entity of this.ecs.changed(['Controlled'])) {
this.tickSingle(entity);
}
}
tickSingle(entity) {
const {Controlled, Direction} = entity;
if (!Controlled || !Direction) {
return;
}
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
if (locked) { if (locked) {
continue; return;
} }
if ( if (
0 === moveRight 0 === moveRight
@ -15,7 +28,7 @@ export default class ControlDirection extends System {
&& 0 === moveLeft && 0 === moveLeft
&& 0 === moveUp && 0 === moveUp
) { ) {
continue; return;
} }
Direction.direction = ( Direction.direction = (
TAU + Math.atan2( TAU + Math.atan2(
@ -24,6 +37,5 @@ export default class ControlDirection extends System {
) )
) % TAU; ) % TAU;
} }
}
} }

View File

@ -5,6 +5,10 @@ import {System} from '@/ecs/index.js';
export default class FollowCamera extends System { export default class FollowCamera extends System {
predict(entity, elapsed) {
this.tickSingle(entity, elapsed);
}
static get priority() { static get priority() {
return { return {
after: 'IntegratePhysics', after: 'IntegratePhysics',
@ -25,11 +29,15 @@ export default class FollowCamera extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {id} of this.select('default')) { for (const entity of this.select('default')) {
this.updateCamera(elapsed * 3, this.ecs.get(id)); this.tickSingle(entity, elapsed);
} }
} }
tickSingle(entity, elapsed) {
this.updateCamera(elapsed * 3, entity);
}
updateCamera(portion, entity) { updateCamera(portion, entity) {
const {Camera, Position} = entity; const {Camera, Position} = entity;
if (Camera && Position) { if (Camera && Position) {

View File

@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
export default class IntegratePhysics extends System { export default class IntegratePhysics extends System {
predict(entity, elapsed) {
this.tickSingle(entity, elapsed);
}
static queries() { static queries() {
return { return {
default: ['Position', 'Forces'], default: ['Position', 'Forces'],
@ -9,10 +13,18 @@ export default class IntegratePhysics extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {Position, Forces} of this.select('default')) { for (const entity of this.select('default')) {
Position.x = Position.$$x + elapsed * (Forces.$$impulseX + Forces.$$forceX); this.tickSingle(entity, elapsed);
Position.y = Position.$$y + elapsed * (Forces.$$impulseY + Forces.$$forceY);
} }
} }
tickSingle(entity, elapsed) {
const {Forces, Position} = entity;
if (!Forces || !Position) {
return;
}
Position.x = Position.$$x + elapsed * (Forces.$$impulseX + Forces.$$forceX);
Position.y = Position.$$y + elapsed * (Forces.$$impulseY + Forces.$$forceY);
}
} }

View File

@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
export default class ResetForces extends System { export default class ResetForces extends System {
predict(entity, elapsed) {
this.tickSingle(entity, elapsed);
}
static get priority() { static get priority() {
return {phase: 'post'}; return {phase: 'post'};
} }
@ -13,7 +17,16 @@ export default class ResetForces extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {Forces} of this.select('default')) { for (const entity of this.select('default')) {
this.tickSingle(entity, elapsed);
}
}
tickSingle(entity, elapsed) {
const {Forces} = entity;
if (!Forces) {
return;
}
if (0 !== Forces.forceX) { if (0 !== Forces.forceX) {
const factorX = Math.pow(1 - Forces.dampingX, elapsed); const factorX = Math.pow(1 - Forces.dampingX, elapsed);
Forces.forceX *= factorX; Forces.forceX *= factorX;
@ -31,6 +44,5 @@ export default class ResetForces extends System {
Forces.impulseX = 0; Forces.impulseX = 0;
Forces.impulseY = 0; Forces.impulseY = 0;
} }
}
} }

View File

@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
export default class RunAnimations extends System { export default class RunAnimations extends System {
predict(entity, elapsed) {
this.tickSingle(entity, elapsed);
}
static queries() { static queries() {
return { return {
default: ['Sprite'], default: ['Sprite'],
@ -9,17 +13,25 @@ export default class RunAnimations extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {Sprite} of this.select('default')) { for (const entity of this.select('default')) {
this.tickSingle(entity, elapsed);
}
}
tickSingle(entity, elapsed) {
const {Sprite} = entity;
if (!Sprite) {
return;
}
if (0 === Sprite.speed || !Sprite.isAnimating) { if (0 === Sprite.speed || !Sprite.isAnimating) {
continue; return;
} }
Sprite.elapsed += elapsed / Sprite.speed; Sprite.elapsed += elapsed / Sprite.speed;
while (Sprite.elapsed > 1) { while (Sprite.elapsed >= 1) {
Sprite.elapsed -= 1; Sprite.elapsed -= 1;
Sprite.frame += 1; Sprite.frame += 1;
} }
} }
}
} }

View File

@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
export default class SpriteDirection extends System { export default class SpriteDirection extends System {
predict(entity) {
this.tickSingle(entity);
}
static get priority() { static get priority() {
return { return {
after: 'ControlDirection', after: 'ControlDirection',
@ -15,12 +19,21 @@ export default class SpriteDirection extends System {
} }
tick() { tick() {
for (const {Controlled, Direction, Sprite} of this.select('default')) { for (const entity of this.select('default')) {
this.tickSingle(entity);
}
}
tickSingle(entity) {
const parts = []; const parts = [];
const {Controlled, Direction, Sprite} = entity;
if (!Sprite) {
return;
}
if (Controlled) { if (Controlled) {
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
if (locked) { if (locked) {
continue; return;
} }
if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) { if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) {
parts.push('moving'); parts.push('moving');
@ -46,6 +59,5 @@ export default class SpriteDirection extends System {
} }
} }
} }
}
} }

View File

@ -425,7 +425,7 @@ export default class Engine {
this.update(this.updateElapsed); this.update(this.updateElapsed);
this.setClean(); this.setClean();
this.frame += 1; this.frame += 1;
this.updateElapsed -= UPS_PER_S; this.updateElapsed = this.updateElapsed % UPS_PER_S;
} }
this.handle = setTimeout(loop, 1000 / TPS); this.handle = setTimeout(loop, 1000 / TPS);
}; };

View File

@ -2,6 +2,8 @@ export const CHUNK_SIZE = 32;
export const CLIENT_LATENCY = 0; export const CLIENT_LATENCY = 0;
export const CLIENT_INTERPOLATION = true;
export const CLIENT_PREDICTION = true; export const CLIENT_PREDICTION = true;
export const IRL_MINUTES_PER_GAME_DAY = 20; export const IRL_MINUTES_PER_GAME_DAY = 20;
@ -15,4 +17,4 @@ export const SERVER_LATENCY = 0;
export const TPS = 60; export const TPS = 60;
export const UPS = 30; export const UPS = 15;