diff --git a/app/client/interpolator.js b/app/client/interpolator.js index 7a248e3..d13dabc 100644 --- a/app/client/interpolator.js +++ b/app/client/interpolator.js @@ -38,7 +38,7 @@ export default class Interpolator { return undefined; } 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 interpolated = {}; for (const {entityId, componentName, properties} of this.tracking) { diff --git a/app/client/local.js b/app/client/local.js index 4b854ee..af033fb 100644 --- a/app/client/local.js +++ b/app/client/local.js @@ -1,21 +1,51 @@ import Client from '@/net/client.js'; import {decode, encode} from '@/net/packets/index.js'; +import {CLIENT_INTERPOLATION, CLIENT_PREDICTION} from '@/util/constants.js'; export default class LocalClient extends Client { server = null; interpolator = null; + predictor = null; async connect() { this.server = new Worker( new URL('../server/worker.js', import.meta.url), {type: 'module'}, ); - this.interpolator = new Worker( - new URL('./interpolator.js', import.meta.url), - {type: 'module'}, - ); - this.interpolator.addEventListener('message', (event) => { - this.accept(event.data); - }); + if (CLIENT_INTERPOLATION) { + this.interpolator = new Worker( + new URL('./interpolator.js', import.meta.url), + {type: 'module'}, + ); + this.interpolator.addEventListener('message', (event) => { + 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) => { if (0 === event.data) { this.server.terminate(); @@ -23,16 +53,32 @@ export default class LocalClient extends Client { return; } 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() { this.server.postMessage(0); - this.interpolator.terminate(); + if (CLIENT_INTERPOLATION) { + this.interpolator.terminate(); + } } transmit(packet) { - const packed = encode(packet); - this.throughput.$$up += packed.byteLength; - this.server.postMessage(packed); + if (CLIENT_PREDICTION) { + this.predictor.postMessage([0, packet]); + } + else { + const packed = encode(packet); + this.throughput.$$up += packed.byteLength; + this.server.postMessage(packed); + } } } diff --git a/app/client/predictor.js b/app/client/predictor.js new file mode 100644 index 0000000..7a05e81 --- /dev/null +++ b/app/client/predictor.js @@ -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; + } + } +}; diff --git a/app/client/remote.js b/app/client/remote.js index 0cc4117..19a909a 100644 --- a/app/client/remote.js +++ b/app/client/remote.js @@ -1,22 +1,66 @@ import Client from '@/net/client.js'; import {decode, encode} from '@/net/packets/index.js'; +import {CLIENT_INTERPOLATION, CLIENT_PREDICTION} from '@/util/constants.js'; export default class RemoteClient extends Client { socket = null; interpolator = null; + predictor = null; async connect(host) { this.interpolator = new Worker( new URL('./interpolator.js', import.meta.url), {type: 'module'}, ); - this.interpolator.addEventListener('message', (event) => { - this.accept(event.data); - }); + if (CLIENT_INTERPOLATION) { + this.interpolator = new Worker( + new URL('./interpolator.js', import.meta.url), + {type: 'module'}, + ); + this.interpolator.addEventListener('message', (event) => { + 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`) this.socket = new WebSocket(url.href); this.socket.binaryType = 'arraybuffer'; 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.accept({type: 'ConnectionStatus', payload: 'aborted'}); @@ -27,11 +71,18 @@ export default class RemoteClient extends Client { this.accept({type: 'ConnectionStatus', payload: 'connected'}); } disconnect() { - this.interpolator.terminate(); + if (CLIENT_INTERPOLATION) { + this.interpolator.terminate(); + } } transmit(packet) { - const packed = encode(packet); - this.throughput.$$up += packed.byteLength; - this.socket.send(packed); + if (CLIENT_PREDICTION) { + this.predictor.postMessage([0, packet]); + } + else { + const packed = encode(packet); + this.throughput.$$up += packed.byteLength; + this.socket.send(packed); + } } } diff --git a/app/ecs/component.js b/app/ecs/component.js index 9a2475e..fc0941e 100644 --- a/app/ecs/component.js +++ b/app/ecs/component.js @@ -144,11 +144,22 @@ export default class Component { } 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) { - return data || Component.constructor.filterDefaults(this); + if (data) { + return data; + } + return this.toFullJSON(); } toJSON() { - return Component.constructor.filterDefaults(this); + return this.toFullJSON(); } async update(values) { for (const key in values) { diff --git a/app/ecs/ecs.js b/app/ecs/ecs.js index 79feeb7..ea1d690 100644 --- a/app/ecs/ecs.js +++ b/app/ecs/ecs.js @@ -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) { const key = ['$$json', uri].join(':'); if (!cache.has(key)) { diff --git a/app/ecs/systems/apply-control-movement.js b/app/ecs/systems/apply-control-movement.js index 5e27a07..5ed2eb2 100644 --- a/app/ecs/systems/apply-control-movement.js +++ b/app/ecs/systems/apply-control-movement.js @@ -3,6 +3,10 @@ import {normalizeVector} from '@/util/math.js'; export default class ApplyControlMovement extends System { + predict(entity) { + this.tickSingle(entity); + } + static get priority() { return { before: 'IntegratePhysics', @@ -16,17 +20,25 @@ export default class ApplyControlMovement extends System { } tick() { - for (const {Controlled, Forces, Speed} of this.select('default')) { - if (!Controlled.locked) { - const movement = normalizeVector({ - x: (Controlled.moveRight - Controlled.moveLeft), - y: (Controlled.moveDown - Controlled.moveUp), - }); - Forces.applyImpulse({ - x: Speed.speed * movement.x, - y: Speed.speed * movement.y, - }); - } + 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) { + const movement = normalizeVector({ + x: (Controlled.moveRight - Controlled.moveLeft), + y: (Controlled.moveDown - Controlled.moveUp), + }); + Forces.applyImpulse({ + x: Speed.speed * movement.x, + y: Speed.speed * movement.y, + }); } } diff --git a/app/ecs/systems/control-direction.js b/app/ecs/systems/control-direction.js index da12018..df5e651 100644 --- a/app/ecs/systems/control-direction.js +++ b/app/ecs/systems/control-direction.js @@ -3,27 +3,39 @@ import {TAU} from '@/util/math.js'; export default class ControlDirection extends System { + predict(entity) { + this.tickSingle(entity); + } + tick() { - for (const {Controlled, Direction} of this.ecs.changed(['Controlled'])) { - const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; - if (locked) { - continue; - } - if ( - 0 === moveRight - && 0 === moveDown - && 0 === moveLeft - && 0 === moveUp - ) { - continue; - } - Direction.direction = ( - TAU + Math.atan2( - moveDown - moveUp, - moveRight - moveLeft, - ) - ) % TAU; + 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; + if (locked) { + return; + } + if ( + 0 === moveRight + && 0 === moveDown + && 0 === moveLeft + && 0 === moveUp + ) { + return; + } + Direction.direction = ( + TAU + Math.atan2( + moveDown - moveUp, + moveRight - moveLeft, + ) + ) % TAU; + } + } diff --git a/app/ecs/systems/follow-camera.js b/app/ecs/systems/follow-camera.js index daab66f..e04548b 100644 --- a/app/ecs/systems/follow-camera.js +++ b/app/ecs/systems/follow-camera.js @@ -5,6 +5,10 @@ import {System} from '@/ecs/index.js'; export default class FollowCamera extends System { + predict(entity, elapsed) { + this.tickSingle(entity, elapsed); + } + static get priority() { return { after: 'IntegratePhysics', @@ -25,11 +29,15 @@ export default class FollowCamera extends System { } tick(elapsed) { - for (const {id} of this.select('default')) { - this.updateCamera(elapsed * 3, this.ecs.get(id)); + for (const entity of this.select('default')) { + this.tickSingle(entity, elapsed); } } + tickSingle(entity, elapsed) { + this.updateCamera(elapsed * 3, entity); + } + updateCamera(portion, entity) { const {Camera, Position} = entity; if (Camera && Position) { diff --git a/app/ecs/systems/integrate-physics.js b/app/ecs/systems/integrate-physics.js index 837ef8b..86b4e31 100644 --- a/app/ecs/systems/integrate-physics.js +++ b/app/ecs/systems/integrate-physics.js @@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js'; export default class IntegratePhysics extends System { + predict(entity, elapsed) { + this.tickSingle(entity, elapsed); + } + static queries() { return { default: ['Position', 'Forces'], @@ -9,10 +13,18 @@ export default class IntegratePhysics extends System { } tick(elapsed) { - for (const {Position, Forces} of this.select('default')) { - Position.x = Position.$$x + elapsed * (Forces.$$impulseX + Forces.$$forceX); - Position.y = Position.$$y + elapsed * (Forces.$$impulseY + Forces.$$forceY); + for (const entity of this.select('default')) { + this.tickSingle(entity, elapsed); } } + 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); + } + } diff --git a/app/ecs/systems/reset-forces.js b/app/ecs/systems/reset-forces.js index c6a6976..67b5a51 100644 --- a/app/ecs/systems/reset-forces.js +++ b/app/ecs/systems/reset-forces.js @@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js'; export default class ResetForces extends System { + predict(entity, elapsed) { + this.tickSingle(entity, elapsed); + } + static get priority() { return {phase: 'post'}; } @@ -13,24 +17,32 @@ export default class ResetForces extends System { } tick(elapsed) { - for (const {Forces} of this.select('default')) { - if (0 !== Forces.forceX) { - const factorX = Math.pow(1 - Forces.dampingX, elapsed); - Forces.forceX *= factorX; - if (Math.abs(Forces.forceX) <= 1) { - Forces.forceX = 0; - } - } - if (0 !== Forces.forceY) { - const factorY = Math.pow(1 - Forces.dampingY, elapsed); - Forces.forceY *= factorY; - if (Math.abs(Forces.forceY) <= 1) { - Forces.forceY = 0; - } - } - Forces.impulseX = 0; - Forces.impulseY = 0; + for (const entity of this.select('default')) { + this.tickSingle(entity, elapsed); } } + tickSingle(entity, elapsed) { + const {Forces} = entity; + if (!Forces) { + return; + } + if (0 !== Forces.forceX) { + const factorX = Math.pow(1 - Forces.dampingX, elapsed); + Forces.forceX *= factorX; + if (Math.abs(Forces.forceX) <= 1) { + Forces.forceX = 0; + } + } + if (0 !== Forces.forceY) { + const factorY = Math.pow(1 - Forces.dampingY, elapsed); + Forces.forceY *= factorY; + if (Math.abs(Forces.forceY) <= 1) { + Forces.forceY = 0; + } + } + Forces.impulseX = 0; + Forces.impulseY = 0; + } + } diff --git a/app/ecs/systems/run-animations.js b/app/ecs/systems/run-animations.js index 4ccdefe..4176745 100644 --- a/app/ecs/systems/run-animations.js +++ b/app/ecs/systems/run-animations.js @@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js'; export default class RunAnimations extends System { + predict(entity, elapsed) { + this.tickSingle(entity, elapsed); + } + static queries() { return { default: ['Sprite'], @@ -9,15 +13,23 @@ export default class RunAnimations extends System { } tick(elapsed) { - for (const {Sprite} of this.select('default')) { - if (0 === Sprite.speed || !Sprite.isAnimating) { - continue; - } - Sprite.elapsed += elapsed / Sprite.speed; - while (Sprite.elapsed > 1) { - Sprite.elapsed -= 1; - Sprite.frame += 1; - } + 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) { + return; + } + Sprite.elapsed += elapsed / Sprite.speed; + while (Sprite.elapsed >= 1) { + Sprite.elapsed -= 1; + Sprite.frame += 1; } } diff --git a/app/ecs/systems/sprite-direction.js b/app/ecs/systems/sprite-direction.js index cb02550..ff73e12 100644 --- a/app/ecs/systems/sprite-direction.js +++ b/app/ecs/systems/sprite-direction.js @@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js'; export default class SpriteDirection extends System { + predict(entity) { + this.tickSingle(entity); + } + static get priority() { return { after: 'ControlDirection', @@ -15,35 +19,43 @@ export default class SpriteDirection extends System { } tick() { - for (const {Controlled, Direction, Sprite} of this.select('default')) { - const parts = []; - if (Controlled) { - const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; - if (locked) { - continue; - } - if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) { - parts.push('moving'); - } - else { - parts.push('idle'); - } + for (const entity of this.select('default')) { + this.tickSingle(entity); + } + } + + tickSingle(entity) { + const parts = []; + const {Controlled, Direction, Sprite} = entity; + if (!Sprite) { + return; + } + if (Controlled) { + const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; + if (locked) { + return; } - if (Direction) { - if (!Sprite.rotates) { - const name = { - 0: 'right', - 1: 'down', - 2: 'left', - 3: 'up', - }; - parts.push(name[Direction.quantize(4)]); - } + if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) { + parts.push('moving'); } - if (parts.length > 0) { - if (Sprite.hasAnimation(parts.join(':'))) { - Sprite.animation = parts.join(':'); - } + else { + parts.push('idle'); + } + } + if (Direction) { + if (!Sprite.rotates) { + const name = { + 0: 'right', + 1: 'down', + 2: 'left', + 3: 'up', + }; + parts.push(name[Direction.quantize(4)]); + } + } + if (parts.length > 0) { + if (Sprite.hasAnimation(parts.join(':'))) { + Sprite.animation = parts.join(':'); } } } diff --git a/app/server/engine.js b/app/server/engine.js index 56ec18f..20d37a6 100644 --- a/app/server/engine.js +++ b/app/server/engine.js @@ -425,7 +425,7 @@ export default class Engine { this.update(this.updateElapsed); this.setClean(); this.frame += 1; - this.updateElapsed -= UPS_PER_S; + this.updateElapsed = this.updateElapsed % UPS_PER_S; } this.handle = setTimeout(loop, 1000 / TPS); }; diff --git a/app/util/constants.js b/app/util/constants.js index 7d38985..94d1280 100644 --- a/app/util/constants.js +++ b/app/util/constants.js @@ -2,6 +2,8 @@ export const CHUNK_SIZE = 32; export const CLIENT_LATENCY = 0; +export const CLIENT_INTERPOLATION = true; + export const CLIENT_PREDICTION = true; export const IRL_MINUTES_PER_GAME_DAY = 20; @@ -15,4 +17,4 @@ export const SERVER_LATENCY = 0; export const TPS = 60; -export const UPS = 30; +export const UPS = 15;