feat: interpolation

This commit is contained in:
cha0s 2024-08-29 15:43:50 -05:00
parent 5c25fee186
commit 48a45533f5
8 changed files with 173 additions and 115 deletions

110
app/client/interpolator.js Normal file
View File

@ -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);
}
}
};

View File

@ -1,26 +1,38 @@
import Client from '@/net/client.js'; import Client from '@/net/client.js';
import {decode, encode} from '@/net/packets/index.js';
export default class LocalClient extends Client { export default class LocalClient extends Client {
server = null;
interpolator = null;
async connect() { async connect() {
this.worker = 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'},
); );
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) { if (0 === event.data) {
this.worker.terminate(); this.server.terminate();
this.worker = undefined; this.server = null;
return; return;
} }
this.throughput.$$down += event.data.byteLength; this.throughput.$$down += event.data.byteLength;
this.accept(event.data); this.interpolator.postMessage(decode(event.data));
}); });
} }
disconnect() { 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.throughput.$$up += packed.byteLength;
this.worker.postMessage(packed); this.server.postMessage(packed);
} }
} }

View File

@ -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);
};

View File

@ -1,67 +1,37 @@
import Client from '@/net/client.js'; import Client from '@/net/client.js';
import {encode} from '@/net/packets/index.js'; import {decode, encode} from '@/net/packets/index.js';
import {CLIENT_PREDICTION} from '@/util/constants.js';
import {withResolvers} from '@/util/promise.js';
export default class RemoteClient extends Client { export default class RemoteClient extends Client {
constructor() { socket = null;
super(); interpolator = null;
if (CLIENT_PREDICTION) {
this.worker = undefined;
}
else {
this.socket = undefined;
}
}
async connect(host) { async connect(host) {
if (CLIENT_PREDICTION) { this.interpolator = new Worker(
this.worker = new Worker( new URL('./interpolator.js', import.meta.url),
new URL('./prediction.js', import.meta.url), {type: 'module'},
{type: 'module'}, );
); this.interpolator.addEventListener('message', (event) => {
this.worker.postMessage({host}); this.accept(event.data);
this.worker.onmessage = (event) => { });
this.throughput.$$down += event.data.byteLength; const url = new URL(`wss://${host}/ws`)
this.accept(event.data); this.socket = new WebSocket(url.href);
}; this.socket.binaryType = 'arraybuffer';
} this.socket.addEventListener('message', (event) => {
else { this.interpolator.postMessage(decode(event.data));
const url = new URL(`wss://${host}/ws`) });
this.socket = new WebSocket(url.href); this.socket.addEventListener('close', () => {
this.socket.binaryType = 'arraybuffer'; this.accept({type: 'ConnectionStatus', payload: 'aborted'});
const onMessage = (event) => { });
this.throughput.$$down += event.data.byteLength; this.socket.addEventListener('error', () => {
this.accept(event.data); this.accept({type: 'ConnectionStatus', payload: 'aborted'});
} });
const {promise, resolve} = withResolvers(); this.accept({type: 'ConnectionStatus', payload: 'connected'});
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'}));
}
} }
disconnect() { disconnect() {
if (CLIENT_PREDICTION) { this.interpolator.terminate();
this.worker.terminate();
}
else {
this.socket.close();
}
} }
transmit(packed) { transmit(packet) {
const packed = encode(packet);
this.throughput.$$up += packed.byteLength; this.throughput.$$up += packed.byteLength;
if (CLIENT_PREDICTION) { this.socket.send(packed);
this.worker.postMessage(packed);
}
else {
this.socket.send(packed);
}
} }
} }

View File

@ -1,8 +1,6 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Outlet, useParams} from 'react-router-dom'; import {Outlet, useParams} from 'react-router-dom';
import {decode, encode} from '@/net/packets/index.js';
import styles from './play.module.css'; import styles from './play.module.css';
export default function Play() { export default function Play() {
@ -20,15 +18,7 @@ export default function Play() {
({default: Client} = await import('@/client/remote.js')); ({default: Client} = await import('@/client/remote.js'));
break; break;
} }
class SilphiusClient extends Client { setClient(() => Client);
accept(packed) {
super.accept(decode(packed));
}
transmit(packet) {
super.transmit(encode(packet));
}
}
setClient(() => SilphiusClient);
} }
loadClient(); loadClient();
}, [type]); }, [type]);

View File

@ -37,7 +37,7 @@ export default async function createPlayer(id) {
Magnet: {strength: 24}, Magnet: {strength: 24},
Player: {}, Player: {},
Position: {x: 128, y: 448}, Position: {x: 128, y: 448},
Speed: {speed: 300}, Speed: {speed: 100},
Sound: {}, Sound: {},
Sprite: { Sprite: {
anchorX: 0.5, anchorX: 0.5,

View File

@ -6,6 +6,7 @@ import {
CHUNK_SIZE, CHUNK_SIZE,
RESOLUTION, RESOLUTION,
TPS, TPS,
UPS,
} from '@/util/constants.js'; } from '@/util/constants.js';
import {withResolvers} from '@/util/promise.js'; import {withResolvers} from '@/util/promise.js';
@ -16,6 +17,8 @@ import createHouse from './create/house.js';
import createPlayer from './create/player.js'; import createPlayer from './create/player.js';
import createTown from './create/town.js'; import createTown from './create/town.js';
const UPS_PER_S = 1 / UPS;
const cache = new LRUCache({ const cache = new LRUCache({
max: 128, max: 128,
}); });
@ -31,6 +34,7 @@ export default class Engine {
incomingActions = new Map(); incomingActions = new Map();
last; last;
server; server;
updateElapsed = 0;
constructor(Server) { constructor(Server) {
this.ecses = {}; this.ecses = {};
@ -402,12 +406,16 @@ export default class Engine {
const loop = async () => { const loop = async () => {
const now = performance.now() / 1000; const now = performance.now() / 1000;
const elapsed = now - this.last; const elapsed = now - this.last;
this.updateElapsed += elapsed;
this.last = now; this.last = now;
this.acceptActions(); this.acceptActions();
this.tick(elapsed); this.tick(elapsed);
this.update(elapsed); if (this.updateElapsed >= UPS_PER_S) {
this.setClean(); this.update(this.updateElapsed);
this.frame += 1; this.setClean();
this.frame += 1;
this.updateElapsed -= UPS_PER_S;
}
this.handle = setTimeout(loop, 1000 / TPS); this.handle = setTimeout(loop, 1000 / TPS);
}; };
loop(); loop();

View File

@ -13,4 +13,6 @@ export const RESOLUTION = {
export const SERVER_LATENCY = 0; export const SERVER_LATENCY = 0;
export const TPS = 60; export const TPS = 30;
export const UPS = 5;