From 48a45533f5759e4adba14b0ba7a0935ccdd38901 Mon Sep 17 00:00:00 2001 From: cha0s Date: Thu, 29 Aug 2024 15:43:50 -0500 Subject: [PATCH] feat: interpolation --- app/client/interpolator.js | 110 +++++++++++++++++++++++++ app/client/local.js | 28 +++++-- app/client/prediction.js | 34 -------- app/client/remote.js | 84 ++++++------------- app/routes/_main-menu.play.$/route.jsx | 12 +-- app/server/create/player.js | 2 +- app/server/engine.js | 14 +++- app/util/constants.js | 4 +- 8 files changed, 173 insertions(+), 115 deletions(-) create mode 100644 app/client/interpolator.js delete mode 100644 app/client/prediction.js diff --git a/app/client/interpolator.js b/app/client/interpolator.js new file mode 100644 index 0000000..5339c7a --- /dev/null +++ b/app/client/interpolator.js @@ -0,0 +1,110 @@ +export default class Interpolator { + duration = 0; + latest; + location = 0; + penultimate; + tracking = []; + accept(state) { + const packet = state; + if ('Tick' !== packet.type) { + return; + } + this.penultimate = this.latest; + this.latest = packet; + this.tracking = []; + if (this.penultimate) { + this.duration = this.penultimate.payload.elapsed; + const [from, to] = [this.penultimate.payload.ecs, this.latest.payload.ecs]; + for (const entityId in from) { + for (const componentName in from[entityId]) { + if ( + ['Camera', 'Position'].includes(componentName) + && to[entityId]?.[componentName] + ) { + this.tracking.push({ + entityId, + componentName, + properties: ['x', 'y'], + }); + } + } + } + } + this.location = 0; + } + interpolate(elapsed) { + if (0 === this.tracking.length) { + return undefined; + } + this.location += elapsed; + const fraction = 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) { + if (!interpolated[entityId]) { + interpolated[entityId] = {}; + } + if (!interpolated[entityId][componentName]) { + interpolated[entityId][componentName] = {}; + } + for (const property of properties) { + if ( + !(property in from[entityId][componentName]) + || !(property in to[entityId][componentName]) + ) { + continue; + } + interpolated[entityId][componentName][property] = ( + from[entityId][componentName][property] + + ( + fraction + * (to[entityId][componentName][property] - from[entityId][componentName][property]) + ) + ); + } + } + return { + type: 'Tick', + payload: { + ecs: interpolated, + elapsed, + frame: this.penultimate.payload.frame + fraction, + }, + }; + } +} + +let handle; +const interpolator = new Interpolator(); +let last; + +const interpolate = (now) => { + const elapsed = (now - last) / 1000; + last = now; + const interpolated = interpolator.interpolate(elapsed); + if (interpolated) { + handle = requestAnimationFrame(interpolate); + postMessage(interpolated); + } + else { + handle = null; + } +} + +onmessage = async (event) => { + interpolator.accept(event.data); + if (interpolator.penultimate) { + postMessage({ + type: 'Tick', + payload: { + ecs: interpolator.penultimate.payload.ecs, + elapsed: last ? (performance.now() - last) / 1000 : 0, + frame: interpolator.penultimate.payload.frame, + }, + }); + if (!handle) { + last = performance.now(); + handle = requestAnimationFrame(interpolate); + } + } +}; diff --git a/app/client/local.js b/app/client/local.js index 36405ba..4b854ee 100644 --- a/app/client/local.js +++ b/app/client/local.js @@ -1,26 +1,38 @@ import Client from '@/net/client.js'; +import {decode, encode} from '@/net/packets/index.js'; export default class LocalClient extends Client { + server = null; + interpolator = null; async connect() { - this.worker = new Worker( + this.server = new Worker( new URL('../server/worker.js', import.meta.url), {type: 'module'}, ); - this.worker.addEventListener('message', (event) => { + this.interpolator = new Worker( + new URL('./interpolator.js', import.meta.url), + {type: 'module'}, + ); + this.interpolator.addEventListener('message', (event) => { + this.accept(event.data); + }); + this.server.addEventListener('message', (event) => { if (0 === event.data) { - this.worker.terminate(); - this.worker = undefined; + this.server.terminate(); + this.server = null; return; } this.throughput.$$down += event.data.byteLength; - this.accept(event.data); + this.interpolator.postMessage(decode(event.data)); }); } disconnect() { - this.worker.postMessage(0); + this.server.postMessage(0); + this.interpolator.terminate(); } - transmit(packed) { + transmit(packet) { + const packed = encode(packet); this.throughput.$$up += packed.byteLength; - this.worker.postMessage(packed); + this.server.postMessage(packed); } } diff --git a/app/client/prediction.js b/app/client/prediction.js deleted file mode 100644 index 4fd3dc8..0000000 --- a/app/client/prediction.js +++ /dev/null @@ -1,34 +0,0 @@ -import {encode} from '@/net/packets/index.js'; -import {withResolvers} from '@/util/promise.js'; - -let connected = false; -let socket; - -const {promise, resolve} = withResolvers(); - -onmessage = async (event) => { - if (!connected) { - const url = new URL(`wss://${event.data.host}/ws`) - socket = new WebSocket(url.href); - socket.binaryType = 'arraybuffer'; - socket.addEventListener('open', resolve); - socket.addEventListener('error', () => { - postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'})); - close(); - }); - await promise; - socket.removeEventListener('open', resolve); - socket.addEventListener('message', (event) => { - postMessage(event.data); - }); - socket.addEventListener('close', () => { - postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'})); - close(); - }); - postMessage(encode({type: 'ConnectionStatus', payload: 'connected'})); - connected = true; - return; - } - await promise; - socket.send(event.data); -}; diff --git a/app/client/remote.js b/app/client/remote.js index 4747b95..0cc4117 100644 --- a/app/client/remote.js +++ b/app/client/remote.js @@ -1,67 +1,37 @@ import Client from '@/net/client.js'; -import {encode} from '@/net/packets/index.js'; -import {CLIENT_PREDICTION} from '@/util/constants.js'; -import {withResolvers} from '@/util/promise.js'; +import {decode, encode} from '@/net/packets/index.js'; export default class RemoteClient extends Client { - constructor() { - super(); - if (CLIENT_PREDICTION) { - this.worker = undefined; - } - else { - this.socket = undefined; - } - } + socket = null; + interpolator = null; async connect(host) { - if (CLIENT_PREDICTION) { - this.worker = new Worker( - new URL('./prediction.js', import.meta.url), - {type: 'module'}, - ); - this.worker.postMessage({host}); - this.worker.onmessage = (event) => { - this.throughput.$$down += event.data.byteLength; - this.accept(event.data); - }; - } - else { - const url = new URL(`wss://${host}/ws`) - this.socket = new WebSocket(url.href); - this.socket.binaryType = 'arraybuffer'; - const onMessage = (event) => { - this.throughput.$$down += event.data.byteLength; - this.accept(event.data); - } - const {promise, resolve} = withResolvers(); - this.socket.addEventListener('open', resolve); - this.socket.addEventListener('error', () => { - this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'})); - }); - await promise; - this.socket.removeEventListener('open', resolve); - this.socket.addEventListener('message', onMessage); - this.socket.addEventListener('close', () => { - this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'})); - }); - this.accept(encode({type: 'ConnectionStatus', payload: 'connected'})); - } + this.interpolator = new Worker( + new URL('./interpolator.js', import.meta.url), + {type: 'module'}, + ); + this.interpolator.addEventListener('message', (event) => { + this.accept(event.data); + }); + 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.socket.addEventListener('close', () => { + this.accept({type: 'ConnectionStatus', payload: 'aborted'}); + }); + this.socket.addEventListener('error', () => { + this.accept({type: 'ConnectionStatus', payload: 'aborted'}); + }); + this.accept({type: 'ConnectionStatus', payload: 'connected'}); } disconnect() { - if (CLIENT_PREDICTION) { - this.worker.terminate(); - } - else { - this.socket.close(); - } + this.interpolator.terminate(); } - transmit(packed) { + transmit(packet) { + const packed = encode(packet); this.throughput.$$up += packed.byteLength; - if (CLIENT_PREDICTION) { - this.worker.postMessage(packed); - } - else { - this.socket.send(packed); - } + this.socket.send(packed); } } diff --git a/app/routes/_main-menu.play.$/route.jsx b/app/routes/_main-menu.play.$/route.jsx index 53ea8a4..3ef23c4 100644 --- a/app/routes/_main-menu.play.$/route.jsx +++ b/app/routes/_main-menu.play.$/route.jsx @@ -1,8 +1,6 @@ import {useEffect, useState} from 'react'; import {Outlet, useParams} from 'react-router-dom'; -import {decode, encode} from '@/net/packets/index.js'; - import styles from './play.module.css'; export default function Play() { @@ -20,15 +18,7 @@ export default function Play() { ({default: Client} = await import('@/client/remote.js')); break; } - class SilphiusClient extends Client { - accept(packed) { - super.accept(decode(packed)); - } - transmit(packet) { - super.transmit(encode(packet)); - } - } - setClient(() => SilphiusClient); + setClient(() => Client); } loadClient(); }, [type]); diff --git a/app/server/create/player.js b/app/server/create/player.js index f4f04bd..b2a3808 100644 --- a/app/server/create/player.js +++ b/app/server/create/player.js @@ -37,7 +37,7 @@ export default async function createPlayer(id) { Magnet: {strength: 24}, Player: {}, Position: {x: 128, y: 448}, - Speed: {speed: 300}, + Speed: {speed: 100}, Sound: {}, Sprite: { anchorX: 0.5, diff --git a/app/server/engine.js b/app/server/engine.js index 1e9d947..dd6e719 100644 --- a/app/server/engine.js +++ b/app/server/engine.js @@ -6,6 +6,7 @@ import { CHUNK_SIZE, RESOLUTION, TPS, + UPS, } from '@/util/constants.js'; import {withResolvers} from '@/util/promise.js'; @@ -16,6 +17,8 @@ import createHouse from './create/house.js'; import createPlayer from './create/player.js'; import createTown from './create/town.js'; +const UPS_PER_S = 1 / UPS; + const cache = new LRUCache({ max: 128, }); @@ -31,6 +34,7 @@ export default class Engine { incomingActions = new Map(); last; server; + updateElapsed = 0; constructor(Server) { this.ecses = {}; @@ -402,12 +406,16 @@ export default class Engine { const loop = async () => { const now = performance.now() / 1000; const elapsed = now - this.last; + this.updateElapsed += elapsed; this.last = now; this.acceptActions(); this.tick(elapsed); - this.update(elapsed); - this.setClean(); - this.frame += 1; + if (this.updateElapsed >= UPS_PER_S) { + this.update(this.updateElapsed); + this.setClean(); + this.frame += 1; + this.updateElapsed -= UPS_PER_S; + } this.handle = setTimeout(loop, 1000 / TPS); }; loop(); diff --git a/app/util/constants.js b/app/util/constants.js index 2ed20fb..1648711 100644 --- a/app/util/constants.js +++ b/app/util/constants.js @@ -13,4 +13,6 @@ export const RESOLUTION = { export const SERVER_LATENCY = 0; -export const TPS = 60; +export const TPS = 30; + +export const UPS = 5;