feat: interpolation
This commit is contained in:
parent
5c25fee186
commit
48a45533f5
110
app/client/interpolator.js
Normal file
110
app/client/interpolator.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user