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

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

View File

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

View File

@ -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,

View File

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

View File

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