Compare commits
18 Commits
219ee71c2c
...
29df4c3dcb
Author | SHA1 | Date | |
---|---|---|---|
|
29df4c3dcb | ||
|
9df38d5952 | ||
|
2733968fa4 | ||
|
b0b13c8961 | ||
|
841db3de13 | ||
|
a58e1b6b89 | ||
|
48a45533f5 | ||
|
5c25fee186 | ||
|
9b60744ae2 | ||
|
ac9e0b4134 | ||
|
77dcb7c5a1 | ||
|
e15e94777f | ||
|
a03b5e3412 | ||
|
d0644858af | ||
|
2dec51008d | ||
|
0898dabb62 | ||
|
ac11545937 | ||
|
8a3ef7842c |
111
app/client/interpolator.js
Normal file
111
app/client/interpolator.js
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
export default class Interpolator {
|
||||||
|
duration = 0;
|
||||||
|
latest;
|
||||||
|
location = 0;
|
||||||
|
penultimate;
|
||||||
|
tracking = [];
|
||||||
|
accept(state) {
|
||||||
|
const packet = state;
|
||||||
|
if ('Tick' !== packet.type) {
|
||||||
|
postMessage(packet);
|
||||||
|
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 = 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) {
|
||||||
|
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 && 'Tick' === event.data.type) {
|
||||||
|
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,24 +1,84 @@
|
||||||
import Client from '@/net/client.js';
|
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 {
|
export default class LocalClient extends Client {
|
||||||
|
server = null;
|
||||||
|
interpolator = null;
|
||||||
|
predictor = 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) => {
|
if (CLIENT_INTERPOLATION) {
|
||||||
if (0 === event.data) {
|
this.interpolator = new Worker(
|
||||||
this.worker.terminate();
|
new URL('./interpolator.js', import.meta.url),
|
||||||
this.worker = undefined;
|
{type: 'module'},
|
||||||
return;
|
);
|
||||||
}
|
this.interpolator.addEventListener('message', (event) => {
|
||||||
this.accept(event.data);
|
this.accept(event.data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
disconnect() {
|
if (CLIENT_PREDICTION) {
|
||||||
this.worker.postMessage(0);
|
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();
|
||||||
|
this.server = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.server.postMessage(0);
|
||||||
|
if (CLIENT_INTERPOLATION) {
|
||||||
|
this.interpolator.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transmit(packet) {
|
||||||
|
if (CLIENT_PREDICTION) {
|
||||||
|
this.predictor.postMessage([0, packet]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const packed = encode(packet);
|
||||||
|
this.throughput.$$up += packed.byteLength;
|
||||||
|
this.server.postMessage(packed);
|
||||||
}
|
}
|
||||||
transmit(packed) {
|
|
||||||
this.worker.postMessage(packed);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
import {encode} from '@/net/packets/index.js';
|
|
||||||
import {withResolvers} from '@/util/promise.js';
|
|
||||||
|
|
||||||
let connected = false;
|
|
||||||
let socket;
|
|
||||||
|
|
||||||
function onMessage(event) {
|
|
||||||
postMessage(event.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
onmessage = async (event) => {
|
|
||||||
if (!connected) {
|
|
||||||
const url = new URL(`wss://${event.data.host}/ws`)
|
|
||||||
socket = new WebSocket(url.href);
|
|
||||||
socket.binaryType = 'arraybuffer';
|
|
||||||
const {promise, resolve} = withResolvers();
|
|
||||||
socket.addEventListener('open', resolve);
|
|
||||||
socket.addEventListener('error', () => {
|
|
||||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
await promise;
|
|
||||||
socket.removeEventListener('open', resolve);
|
|
||||||
socket.addEventListener('message', onMessage);
|
|
||||||
socket.addEventListener('close', () => {
|
|
||||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
|
|
||||||
connected = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.send(event.data);
|
|
||||||
};
|
|
176
app/client/predictor.js
Normal file
176
app/client/predictor.js
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
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 'EcsChange': {
|
||||||
|
ecs = new PredictionEcs({Components, Systems});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,63 +1,87 @@
|
||||||
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 {CLIENT_INTERPOLATION, 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) {
|
predictor = null;
|
||||||
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.worker.postMessage({host});
|
if (CLIENT_INTERPOLATION) {
|
||||||
this.worker.onmessage = (event) => {
|
this.interpolator = new Worker(
|
||||||
|
new URL('./interpolator.js', import.meta.url),
|
||||||
|
{type: 'module'},
|
||||||
|
);
|
||||||
|
this.interpolator.addEventListener('message', (event) => {
|
||||||
this.accept(event.data);
|
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 {
|
else {
|
||||||
|
this.accept(packet);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
const url = new URL(`wss://${host}/ws`)
|
const url = new URL(`wss://${host}/ws`)
|
||||||
this.socket = new WebSocket(url.href);
|
this.socket = new WebSocket(url.href);
|
||||||
this.socket.binaryType = 'arraybuffer';
|
this.socket.binaryType = 'arraybuffer';
|
||||||
const onMessage = (event) => {
|
this.socket.addEventListener('message', (event) => {
|
||||||
this.accept(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);
|
||||||
}
|
}
|
||||||
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.socket.addEventListener('close', () => {
|
||||||
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
this.accept({type: 'ConnectionStatus', payload: 'aborted'});
|
||||||
});
|
});
|
||||||
this.accept(encode({type: 'ConnectionStatus', payload: 'connected'}));
|
this.socket.addEventListener('error', () => {
|
||||||
}
|
this.accept({type: 'ConnectionStatus', payload: 'aborted'});
|
||||||
|
});
|
||||||
|
this.accept({type: 'ConnectionStatus', payload: 'connected'});
|
||||||
}
|
}
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
if (CLIENT_INTERPOLATION) {
|
||||||
|
this.interpolator.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transmit(packet) {
|
||||||
if (CLIENT_PREDICTION) {
|
if (CLIENT_PREDICTION) {
|
||||||
this.worker.terminate();
|
this.predictor.postMessage([0, packet]);
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.socket.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transmit(packed) {
|
|
||||||
if (CLIENT_PREDICTION) {
|
|
||||||
this.worker.postMessage(packed);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
const packed = encode(packet);
|
||||||
|
this.throughput.$$up += packed.byteLength;
|
||||||
this.socket.send(packed);
|
this.socket.send(packed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,13 +61,13 @@ export default class Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const keys = new Set(Object.keys(defaults));
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (let i = 0; i < entries.length; ++i) {
|
for (let i = 0; i < entries.length; ++i) {
|
||||||
const [entityId, values] = entries[i];
|
const [entityId, values] = entries[i];
|
||||||
const instance = allocated[i];
|
const instance = allocated[i];
|
||||||
instance.entity = entityId;
|
instance.entity = entityId;
|
||||||
this.instances[entityId] = instance;
|
this.instances[entityId] = instance;
|
||||||
|
const keys = new Set(Object.keys(defaults));
|
||||||
for (const key in values) {
|
for (const key in values) {
|
||||||
keys.delete(key);
|
keys.delete(key);
|
||||||
}
|
}
|
||||||
|
@ -144,11 +144,22 @@ export default class Component {
|
||||||
}
|
}
|
||||||
Component.ecs.markChange(this.entity, {[Component.constructor.componentName]: values})
|
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) {
|
toNet(recipient, data) {
|
||||||
return data || Component.constructor.filterDefaults(this);
|
if (data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return this.toFullJSON();
|
||||||
}
|
}
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return Component.constructor.filterDefaults(this);
|
return this.toFullJSON();
|
||||||
}
|
}
|
||||||
async update(values) {
|
async update(values) {
|
||||||
for (const key in values) {
|
for (const key in values) {
|
||||||
|
|
|
@ -2,27 +2,28 @@ import {expect, test} from 'vitest';
|
||||||
|
|
||||||
import Component from './component.js';
|
import Component from './component.js';
|
||||||
|
|
||||||
test('creates instances', () => {
|
const fakeEcs = {markChange() {}};
|
||||||
|
|
||||||
|
test('creates instances', async () => {
|
||||||
class CreatingComponent extends Component {
|
class CreatingComponent extends Component {
|
||||||
static properties = {
|
static properties = {
|
||||||
foo: {defaultValue: 'bar', type: 'string'},
|
foo: {defaultValue: 'bar', type: 'string'},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const ComponentInstance = new CreatingComponent();
|
const ComponentInstance = new CreatingComponent(fakeEcs);
|
||||||
ComponentInstance.create(1);
|
await ComponentInstance.create(1);
|
||||||
expect(ComponentInstance.get(1).entity)
|
expect(ComponentInstance.get(1).entity)
|
||||||
.to.equal(1);
|
.to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not serialize default values', () => {
|
test('does not serialize default values', async () => {
|
||||||
class CreatingComponent extends Component {
|
class CreatingComponent extends Component {
|
||||||
static properties = {
|
static properties = {
|
||||||
foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'},
|
foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const fakeEcs = {markChange() {}};
|
|
||||||
const ComponentInstance = new CreatingComponent(fakeEcs);
|
const ComponentInstance = new CreatingComponent(fakeEcs);
|
||||||
ComponentInstance.create(1)
|
await ComponentInstance.create(1)
|
||||||
expect(ComponentInstance.get(1).toJSON())
|
expect(ComponentInstance.get(1).toJSON())
|
||||||
.to.deep.equal({});
|
.to.deep.equal({});
|
||||||
ComponentInstance.get(1).bar = 1;
|
ComponentInstance.get(1).bar = 1;
|
||||||
|
@ -30,14 +31,14 @@ test('does not serialize default values', () => {
|
||||||
.to.deep.equal({bar: 1});
|
.to.deep.equal({bar: 1});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuses instances', () => {
|
test('reuses instances', async () => {
|
||||||
class ReusingComponent extends Component {
|
class ReusingComponent extends Component {
|
||||||
static properties = {
|
static properties = {
|
||||||
foo: {type: 'string'},
|
foo: {type: 'string'},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const ComponentInstance = new ReusingComponent();
|
const ComponentInstance = new ReusingComponent(fakeEcs);
|
||||||
ComponentInstance.create(1);
|
await ComponentInstance.create(1);
|
||||||
const instance = ComponentInstance.get(1);
|
const instance = ComponentInstance.get(1);
|
||||||
ComponentInstance.destroy(1);
|
ComponentInstance.destroy(1);
|
||||||
expect(ComponentInstance.get(1))
|
expect(ComponentInstance.get(1))
|
||||||
|
@ -46,7 +47,7 @@ test('reuses instances', () => {
|
||||||
ComponentInstance.destroy(1);
|
ComponentInstance.destroy(1);
|
||||||
})
|
})
|
||||||
.to.throw();
|
.to.throw();
|
||||||
ComponentInstance.create(1);
|
await ComponentInstance.create(1);
|
||||||
expect(ComponentInstance.get(1))
|
expect(ComponentInstance.get(1))
|
||||||
.to.equal(instance);
|
.to.equal(instance);
|
||||||
});
|
});
|
||||||
|
|
|
@ -38,7 +38,7 @@ export default class EmitterComponent extends Component {
|
||||||
}
|
}
|
||||||
mergeDiff(original, update) {
|
mergeDiff(original, update) {
|
||||||
const merged = {};
|
const merged = {};
|
||||||
if (update.emit) {
|
if (original.emit || update.emit) {
|
||||||
merged.emit = {
|
merged.emit = {
|
||||||
...original.emit,
|
...original.emit,
|
||||||
...update.emit,
|
...update.emit,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Component from '@/ecs/component.js';
|
||||||
export default class Interlocutor extends Component {
|
export default class Interlocutor extends Component {
|
||||||
mergeDiff(original, update) {
|
mergeDiff(original, update) {
|
||||||
const merged = {};
|
const merged = {};
|
||||||
if (update.dialogue) {
|
if (original.dialogue || update.dialogue) {
|
||||||
merged.dialogue = {
|
merged.dialogue = {
|
||||||
...original.dialogue,
|
...original.dialogue,
|
||||||
...update.dialogue,
|
...update.dialogue,
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Component from '@/ecs/component.js';
|
||||||
export default class Sound extends Component {
|
export default class Sound extends Component {
|
||||||
mergeDiff(original, update) {
|
mergeDiff(original, update) {
|
||||||
const merged = {};
|
const merged = {};
|
||||||
if (update.play) {
|
if (original.play || update.play) {
|
||||||
merged.play = [
|
merged.play = [
|
||||||
...(original.play ?? []),
|
...(original.play ?? []),
|
||||||
...update.play,
|
...update.play,
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const DamageTypes = {
|
||||||
export default class Vulnerable extends Component {
|
export default class Vulnerable extends Component {
|
||||||
mergeDiff(original, update) {
|
mergeDiff(original, update) {
|
||||||
const merged = {};
|
const merged = {};
|
||||||
if (update.damage) {
|
if (original.damage || update.damage) {
|
||||||
merged.damage = {
|
merged.damage = {
|
||||||
...original.damage,
|
...original.damage,
|
||||||
...update.damage,
|
...update.damage,
|
||||||
|
|
|
@ -23,8 +23,6 @@ export default class Ecs {
|
||||||
|
|
||||||
deferredChanges = {}
|
deferredChanges = {}
|
||||||
|
|
||||||
$$deindexing = new Set();
|
|
||||||
|
|
||||||
$$destructionDependencies = new Map();
|
$$destructionDependencies = new Map();
|
||||||
|
|
||||||
$$detached = new Set();
|
$$detached = new Set();
|
||||||
|
@ -35,8 +33,6 @@ export default class Ecs {
|
||||||
|
|
||||||
$$entityFactory = new EntityFactory();
|
$$entityFactory = new EntityFactory();
|
||||||
|
|
||||||
$$reindexing = new Set();
|
|
||||||
|
|
||||||
Systems = {};
|
Systems = {};
|
||||||
|
|
||||||
constructor({Systems, Components} = {}) {
|
constructor({Systems, Components} = {}) {
|
||||||
|
@ -145,9 +141,9 @@ export default class Ecs {
|
||||||
attach(entityIds) {
|
attach(entityIds) {
|
||||||
for (const entityId of entityIds) {
|
for (const entityId of entityIds) {
|
||||||
this.$$detached.delete(entityId);
|
this.$$detached.delete(entityId);
|
||||||
this.$$reindexing.add(entityId);
|
|
||||||
this.applyDeferredChanges(entityId);
|
this.applyDeferredChanges(entityId);
|
||||||
}
|
}
|
||||||
|
this.reindex(entityIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
changed(criteria) {
|
changed(criteria) {
|
||||||
|
@ -242,13 +238,13 @@ export default class Ecs {
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
for (let i = 0; i < specificsList.length; i++) {
|
for (let i = 0; i < specificsList.length; i++) {
|
||||||
const [entityId, components] = specificsList[i];
|
const [entityId, components] = specificsList[i];
|
||||||
this.$$reindexing.add(entityId);
|
|
||||||
this.rebuild(entityId, () => Object.keys(components));
|
this.rebuild(entityId, () => Object.keys(components));
|
||||||
if (this.$$detached.has(entityId)) {
|
if (this.$$detached.has(entityId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.applyDeferredChanges(entityId);
|
this.applyDeferredChanges(entityId);
|
||||||
}
|
}
|
||||||
|
this.reindex(entityIds);
|
||||||
return entityIds;
|
return entityIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -315,9 +311,8 @@ export default class Ecs {
|
||||||
|
|
||||||
destroyMany(entityIds) {
|
destroyMany(entityIds) {
|
||||||
const destroying = {};
|
const destroying = {};
|
||||||
// this.deindex(entityIds);
|
this.deindex(entityIds);
|
||||||
for (const entityId of entityIds) {
|
for (const entityId of entityIds) {
|
||||||
this.$$deindexing.add(entityId);
|
|
||||||
if (!this.$$entities[entityId]) {
|
if (!this.$$entities[entityId]) {
|
||||||
throw new Error(`can't destroy non-existent entity ${entityId}`);
|
throw new Error(`can't destroy non-existent entity ${entityId}`);
|
||||||
}
|
}
|
||||||
|
@ -333,15 +328,16 @@ export default class Ecs {
|
||||||
}
|
}
|
||||||
for (const entityId of entityIds) {
|
for (const entityId of entityIds) {
|
||||||
delete this.$$entities[entityId];
|
delete this.$$entities[entityId];
|
||||||
|
delete this.deferredChanges[entityId];
|
||||||
this.diff[entityId] = false;
|
this.diff[entityId] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detach(entityIds) {
|
detach(entityIds) {
|
||||||
for (const entityId of entityIds) {
|
for (const entityId of entityIds) {
|
||||||
this.$$deindexing.add(entityId);
|
|
||||||
this.$$detached.add(entityId);
|
this.$$detached.add(entityId);
|
||||||
}
|
}
|
||||||
|
this.deindex(entityIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
get entities() {
|
get entities() {
|
||||||
|
@ -381,9 +377,9 @@ export default class Ecs {
|
||||||
}
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
for (const [entityId, components] of entities) {
|
for (const [entityId, components] of entities) {
|
||||||
this.$$reindexing.add(entityId);
|
|
||||||
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
|
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
|
||||||
}
|
}
|
||||||
|
this.reindex(unique);
|
||||||
}
|
}
|
||||||
|
|
||||||
markChange(entityId, components) {
|
markChange(entityId, components) {
|
||||||
|
@ -412,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) {
|
async readJson(uri) {
|
||||||
const key = ['$$json', uri].join(':');
|
const key = ['$$json', uri].join(':');
|
||||||
if (!cache.has(key)) {
|
if (!cache.has(key)) {
|
||||||
|
@ -510,9 +516,9 @@ export default class Ecs {
|
||||||
this.Components[componentName].destroyMany(removing[componentName]);
|
this.Components[componentName].destroyMany(removing[componentName]);
|
||||||
}
|
}
|
||||||
for (const [entityId, components] of entities) {
|
for (const [entityId, components] of entities) {
|
||||||
this.$$reindexing.add(entityId);
|
|
||||||
this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type)));
|
this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type)));
|
||||||
}
|
}
|
||||||
|
this.reindex(unique);
|
||||||
}
|
}
|
||||||
|
|
||||||
static serialize(ecs, view) {
|
static serialize(ecs, view) {
|
||||||
|
@ -563,15 +569,6 @@ export default class Ecs {
|
||||||
this.$$destructionDependencies.delete(entityId);
|
this.$$destructionDependencies.delete(entityId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// update indices
|
|
||||||
if (this.$$deindexing.size > 0) {
|
|
||||||
this.deindex(this.$$deindexing);
|
|
||||||
this.$$deindexing.clear();
|
|
||||||
}
|
|
||||||
if (this.$$reindexing.size > 0) {
|
|
||||||
this.reindex(this.$$reindexing);
|
|
||||||
this.$$reindexing.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
|
@ -597,14 +594,12 @@ export default class Ecs {
|
||||||
const updating = {};
|
const updating = {};
|
||||||
const unique = new Set();
|
const unique = new Set();
|
||||||
for (const [entityId, components] of entities) {
|
for (const [entityId, components] of entities) {
|
||||||
this.rebuild(entityId);
|
|
||||||
for (const componentName in components) {
|
for (const componentName in components) {
|
||||||
if (!updating[componentName]) {
|
if (!updating[componentName]) {
|
||||||
updating[componentName] = [];
|
updating[componentName] = [];
|
||||||
}
|
}
|
||||||
updating[componentName].push([entityId, components[componentName]]);
|
updating[componentName].push([entityId, components[componentName]]);
|
||||||
}
|
}
|
||||||
this.$$reindexing.add(entityId);
|
|
||||||
unique.add(entityId);
|
unique.add(entityId);
|
||||||
}
|
}
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
|
@ -3,25 +3,21 @@ import {expect, test} from 'vitest';
|
||||||
import Component from './component.js';
|
import Component from './component.js';
|
||||||
import Ecs from './ecs.js';
|
import Ecs from './ecs.js';
|
||||||
import System from './system.js';
|
import System from './system.js';
|
||||||
|
import {wrapComponents} from './test-helper.js';
|
||||||
|
|
||||||
function wrapProperties(name, properties) {
|
const Components = wrapComponents([
|
||||||
return class WrappedComponent extends Component {
|
['Empty', {}],
|
||||||
static componentName = name;
|
['Momentum', {x: {type: 'int32'}, y: {type: 'int32'}, z: {type: 'int32'}}],
|
||||||
static properties = properties;
|
['Name', {name: {type: 'string'}}],
|
||||||
};
|
['Position', {x: {type: 'int32', defaultValue: 32}, y: {type: 'int32'}, z: {type: 'int32'}}],
|
||||||
}
|
]);
|
||||||
|
|
||||||
const Empty = wrapProperties('Empty', {});
|
const {
|
||||||
|
Empty,
|
||||||
const Name = wrapProperties('Name', {
|
Momentum,
|
||||||
name: {type: 'string'},
|
Position,
|
||||||
});
|
Name,
|
||||||
|
} = Components;
|
||||||
const Position = wrapProperties('Position', {
|
|
||||||
x: {type: 'int32', defaultValue: 32},
|
|
||||||
y: {type: 'int32'},
|
|
||||||
z: {type: 'int32'},
|
|
||||||
});
|
|
||||||
|
|
||||||
function asyncTimesTwo(x) {
|
function asyncTimesTwo(x) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
@ -131,11 +127,6 @@ test('inserts components into entities', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ticks systems', async () => {
|
test('ticks systems', async () => {
|
||||||
const Momentum = wrapProperties('Momentum', {
|
|
||||||
x: {type: 'int32'},
|
|
||||||
y: {type: 'int32'},
|
|
||||||
z: {type: 'int32'},
|
|
||||||
});
|
|
||||||
const ecs = new Ecs({
|
const ecs = new Ecs({
|
||||||
Components: {Momentum, Position},
|
Components: {Momentum, Position},
|
||||||
Systems: {
|
Systems: {
|
||||||
|
@ -350,10 +341,10 @@ test('applies update patches', async () => {
|
||||||
.to.equal(128);
|
.to.equal(128);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applies entity deletion patches', () => {
|
test('applies entity deletion patches', async () => {
|
||||||
const ecs = new Ecs({Components: {Position}});
|
const ecs = new Ecs({Components: {Position}});
|
||||||
ecs.createSpecific(16, {Position: {x: 64}});
|
await ecs.createSpecific(16, {Position: {x: 64}});
|
||||||
ecs.apply({16: false});
|
await ecs.apply({16: false});
|
||||||
expect(Array.from(ecs.entities).length)
|
expect(Array.from(ecs.entities).length)
|
||||||
.to.equal(0);
|
.to.equal(0);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,23 +1,14 @@
|
||||||
import {expect, test} from 'vitest';
|
import {expect, test} from 'vitest';
|
||||||
|
|
||||||
import Ecs from './ecs';
|
import Ecs from './ecs.js';
|
||||||
import Component from './component.js';
|
import {wrapComponents} from './test-helper.js';
|
||||||
import Query from './query.js';
|
import Query from './query.js';
|
||||||
|
|
||||||
const Components = [
|
const Components = wrapComponents([
|
||||||
['A', {a: {type: 'int32', defaultValue: 64}}],
|
['A', {a: {type: 'int32', defaultValue: 64}}],
|
||||||
['B', {b: {type: 'int32', defaultValue: 32}}],
|
['B', {b: {type: 'int32', defaultValue: 32}}],
|
||||||
['C', {c: {type: 'int32'}}],
|
['C', {c: {type: 'int32'}}],
|
||||||
]
|
]);
|
||||||
.reduce((Components, [componentName, properties]) => {
|
|
||||||
return {
|
|
||||||
...Components,
|
|
||||||
[componentName]: class extends Component {
|
|
||||||
static componentName = componentName;
|
|
||||||
static properties = properties;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
const ecsTest = test.extend({
|
const ecsTest = test.extend({
|
||||||
ecs: async ({}, use) => {
|
ecs: async ({}, use) => {
|
||||||
|
|
|
@ -3,6 +3,10 @@ import {normalizeVector} from '@/util/math.js';
|
||||||
|
|
||||||
export default class ApplyControlMovement extends System {
|
export default class ApplyControlMovement extends System {
|
||||||
|
|
||||||
|
predict(entity) {
|
||||||
|
this.tickSingle(entity);
|
||||||
|
}
|
||||||
|
|
||||||
static get priority() {
|
static get priority() {
|
||||||
return {
|
return {
|
||||||
before: 'IntegratePhysics',
|
before: 'IntegratePhysics',
|
||||||
|
@ -16,7 +20,16 @@ export default class ApplyControlMovement extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
for (const {Controlled, Forces, Speed} of this.select('default')) {
|
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) {
|
if (!Controlled.locked) {
|
||||||
const movement = normalizeVector({
|
const movement = normalizeVector({
|
||||||
x: (Controlled.moveRight - Controlled.moveLeft),
|
x: (Controlled.moveRight - Controlled.moveLeft),
|
||||||
|
@ -28,7 +41,6 @@ export default class ApplyControlMovement extends System {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,11 +3,24 @@ import {TAU} from '@/util/math.js';
|
||||||
|
|
||||||
export default class ControlDirection extends System {
|
export default class ControlDirection extends System {
|
||||||
|
|
||||||
|
predict(entity) {
|
||||||
|
this.tickSingle(entity);
|
||||||
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
for (const {Controlled, Direction} of this.ecs.changed(['Controlled'])) {
|
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;
|
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
|
||||||
if (locked) {
|
if (locked) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
0 === moveRight
|
0 === moveRight
|
||||||
|
@ -15,7 +28,7 @@ export default class ControlDirection extends System {
|
||||||
&& 0 === moveLeft
|
&& 0 === moveLeft
|
||||||
&& 0 === moveUp
|
&& 0 === moveUp
|
||||||
) {
|
) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
Direction.direction = (
|
Direction.direction = (
|
||||||
TAU + Math.atan2(
|
TAU + Math.atan2(
|
||||||
|
@ -24,6 +37,5 @@ export default class ControlDirection extends System {
|
||||||
)
|
)
|
||||||
) % TAU;
|
) % TAU;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,16 @@ import {System} from '@/ecs/index.js';
|
||||||
|
|
||||||
export default class FollowCamera extends System {
|
export default class FollowCamera extends System {
|
||||||
|
|
||||||
|
predict(entity, elapsed) {
|
||||||
|
this.tickSingle(entity, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get priority() {
|
||||||
|
return {
|
||||||
|
after: 'IntegratePhysics',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static queries() {
|
static queries() {
|
||||||
return {
|
return {
|
||||||
default: ['Camera', 'Position'],
|
default: ['Camera', 'Position'],
|
||||||
|
@ -19,11 +29,15 @@ export default class FollowCamera extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(elapsed) {
|
tick(elapsed) {
|
||||||
for (const {id} of this.select('default')) {
|
for (const entity of this.select('default')) {
|
||||||
this.updateCamera(elapsed * 3, this.ecs.get(id));
|
this.tickSingle(entity, elapsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tickSingle(entity, elapsed) {
|
||||||
|
this.updateCamera(elapsed * 3, entity);
|
||||||
|
}
|
||||||
|
|
||||||
updateCamera(portion, entity) {
|
updateCamera(portion, entity) {
|
||||||
const {Camera, Position} = entity;
|
const {Camera, Position} = entity;
|
||||||
if (Camera && Position) {
|
if (Camera && Position) {
|
||||||
|
|
|
@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
|
||||||
|
|
||||||
export default class IntegratePhysics extends System {
|
export default class IntegratePhysics extends System {
|
||||||
|
|
||||||
|
predict(entity, elapsed) {
|
||||||
|
this.tickSingle(entity, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
static queries() {
|
static queries() {
|
||||||
return {
|
return {
|
||||||
default: ['Position', 'Forces'],
|
default: ['Position', 'Forces'],
|
||||||
|
@ -9,10 +13,18 @@ export default class IntegratePhysics extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(elapsed) {
|
tick(elapsed) {
|
||||||
for (const {Position, Forces} of this.select('default')) {
|
for (const entity of this.select('default')) {
|
||||||
Position.x = Position.$$x + elapsed * (Forces.$$impulseX + Forces.$$forceX);
|
this.tickSingle(entity, elapsed);
|
||||||
Position.y = Position.$$y + elapsed * (Forces.$$impulseY + Forces.$$forceY);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
|
||||||
|
|
||||||
export default class ResetForces extends System {
|
export default class ResetForces extends System {
|
||||||
|
|
||||||
|
predict(entity, elapsed) {
|
||||||
|
this.tickSingle(entity, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
static get priority() {
|
static get priority() {
|
||||||
return {phase: 'post'};
|
return {phase: 'post'};
|
||||||
}
|
}
|
||||||
|
@ -13,7 +17,16 @@ export default class ResetForces extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(elapsed) {
|
tick(elapsed) {
|
||||||
for (const {Forces} of this.select('default')) {
|
for (const entity of this.select('default')) {
|
||||||
|
this.tickSingle(entity, elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tickSingle(entity, elapsed) {
|
||||||
|
const {Forces} = entity;
|
||||||
|
if (!Forces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (0 !== Forces.forceX) {
|
if (0 !== Forces.forceX) {
|
||||||
const factorX = Math.pow(1 - Forces.dampingX, elapsed);
|
const factorX = Math.pow(1 - Forces.dampingX, elapsed);
|
||||||
Forces.forceX *= factorX;
|
Forces.forceX *= factorX;
|
||||||
|
@ -31,6 +44,5 @@ export default class ResetForces extends System {
|
||||||
Forces.impulseX = 0;
|
Forces.impulseX = 0;
|
||||||
Forces.impulseY = 0;
|
Forces.impulseY = 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,10 @@ import {System} from '@/ecs/index.js';
|
||||||
|
|
||||||
export default class RunAnimations extends System {
|
export default class RunAnimations extends System {
|
||||||
|
|
||||||
|
predict(entity, elapsed) {
|
||||||
|
this.tickSingle(entity, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
static queries() {
|
static queries() {
|
||||||
return {
|
return {
|
||||||
default: ['Sprite'],
|
default: ['Sprite'],
|
||||||
|
@ -9,17 +13,25 @@ export default class RunAnimations extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(elapsed) {
|
tick(elapsed) {
|
||||||
for (const {Sprite} of this.select('default')) {
|
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) {
|
if (0 === Sprite.speed || !Sprite.isAnimating) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
Sprite.elapsed += elapsed / Sprite.speed;
|
Sprite.elapsed += elapsed / Sprite.speed;
|
||||||
while (Sprite.elapsed > 1) {
|
while (Sprite.elapsed >= 1) {
|
||||||
Sprite.elapsed -= 1;
|
Sprite.elapsed -= 1;
|
||||||
Sprite.frame += 1;
|
Sprite.frame += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,16 @@ import {System} from '@/ecs/index.js';
|
||||||
|
|
||||||
export default class SpriteDirection extends System {
|
export default class SpriteDirection extends System {
|
||||||
|
|
||||||
|
predict(entity) {
|
||||||
|
this.tickSingle(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get priority() {
|
||||||
|
return {
|
||||||
|
after: 'ControlDirection',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static queries() {
|
static queries() {
|
||||||
return {
|
return {
|
||||||
default: ['Sprite'],
|
default: ['Sprite'],
|
||||||
|
@ -9,12 +19,21 @@ export default class SpriteDirection extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick() {
|
tick() {
|
||||||
for (const {Controlled, Direction, Sprite} of this.select('default')) {
|
for (const entity of this.select('default')) {
|
||||||
|
this.tickSingle(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tickSingle(entity) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const {Controlled, Direction, Sprite} = entity;
|
||||||
|
if (!Sprite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Controlled) {
|
if (Controlled) {
|
||||||
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
|
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
|
||||||
if (locked) {
|
if (locked) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) {
|
if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) {
|
||||||
parts.push('moving');
|
parts.push('moving');
|
||||||
|
@ -40,6 +59,5 @@ export default class SpriteDirection extends System {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
14
app/ecs/test-helper.js
Normal file
14
app/ecs/test-helper.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import Component from './component.js';
|
||||||
|
|
||||||
|
export function wrapComponents(Components) {
|
||||||
|
return Components
|
||||||
|
.reduce((Components, [componentName, properties]) => {
|
||||||
|
return {
|
||||||
|
...Components,
|
||||||
|
[componentName]: class extends Component {
|
||||||
|
static componentName = componentName;
|
||||||
|
static properties = properties;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {})
|
||||||
|
}
|
|
@ -1,11 +1,24 @@
|
||||||
import {CLIENT_LATENCY} from '@/util/constants.js';
|
import {CLIENT_LATENCY, CLIENT_PREDICTION} from '@/util/constants.js';
|
||||||
import EventEmitter from '@/util/event-emitter.js';
|
import EventEmitter from '@/util/event-emitter.js';
|
||||||
|
|
||||||
export default class Client {
|
export default class Client {
|
||||||
|
emitter = new EventEmitter();
|
||||||
|
rtt = 0;
|
||||||
|
throughput = {$$down: 0, down: 0, $$up: 0, up: 0};
|
||||||
constructor() {
|
constructor() {
|
||||||
this.emitter = new EventEmitter();
|
setInterval(() => {
|
||||||
|
const {throughput} = this;
|
||||||
|
throughput.down = throughput.$$down * 4;
|
||||||
|
throughput.up = throughput.$$up * 4;
|
||||||
|
throughput.$$down = throughput.$$up = 0;
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
accept(packet) {
|
accept(packet) {
|
||||||
|
if ('Heartbeat' === packet.type) {
|
||||||
|
this.rtt = packet.payload.rtt;
|
||||||
|
this.send(packet);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.emitter.invoke(packet.type, packet.payload);
|
this.emitter.invoke(packet.type, packet.payload);
|
||||||
}
|
}
|
||||||
addPacketListener(type, listener) {
|
addPacketListener(type, listener) {
|
||||||
|
@ -15,7 +28,7 @@ export default class Client {
|
||||||
this.emitter.removeListener(type, listener);
|
this.emitter.removeListener(type, listener);
|
||||||
}
|
}
|
||||||
send(packet) {
|
send(packet) {
|
||||||
if (CLIENT_LATENCY > 0) {
|
if (CLIENT_LATENCY > 0 && !CLIENT_PREDICTION) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.transmit(packet);
|
this.transmit(packet);
|
||||||
}, CLIENT_LATENCY);
|
}, CLIENT_LATENCY);
|
||||||
|
|
3
app/net/packets/action-ack.js
Normal file
3
app/net/packets/action-ack.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Packet from '@/net/packet.js';
|
||||||
|
|
||||||
|
export default class ActionAck extends Packet {}
|
3
app/net/packets/heartbeat.js
Normal file
3
app/net/packets/heartbeat.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Packet from '@/net/packet.js';
|
||||||
|
|
||||||
|
export default class Heartbeat extends Packet {}
|
|
@ -2,6 +2,7 @@ import {useCallback, useState} from 'react';
|
||||||
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
|
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
|
||||||
import 'react-tabs/style/react-tabs.css';
|
import 'react-tabs/style/react-tabs.css';
|
||||||
|
|
||||||
|
import {useClient} from '@/react/context/client.js';
|
||||||
import {useEcsTick} from '@/react/context/ecs.js';
|
import {useEcsTick} from '@/react/context/ecs.js';
|
||||||
import {useMainEntity} from '@/react/context/main-entity.js';
|
import {useMainEntity} from '@/react/context/main-entity.js';
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ import Tiles from './devtools/tiles.jsx';
|
||||||
export default function Devtools({
|
export default function Devtools({
|
||||||
eventsChannel,
|
eventsChannel,
|
||||||
}) {
|
}) {
|
||||||
|
const client = useClient();
|
||||||
const [mainEntity] = useMainEntity();
|
const [mainEntity] = useMainEntity();
|
||||||
const [mainEntityJson, setMainEntityJson] = useState('');
|
const [mainEntityJson, setMainEntityJson] = useState('');
|
||||||
const onEcsTick = useCallback((payload, ecs) => {
|
const onEcsTick = useCallback((payload, ecs) => {
|
||||||
|
@ -32,6 +34,9 @@ export default function Devtools({
|
||||||
<div className={styles.dashboard}>
|
<div className={styles.dashboard}>
|
||||||
<form>
|
<form>
|
||||||
<div className={styles.engineBar}>
|
<div className={styles.engineBar}>
|
||||||
|
<div>{Math.round(client.rtt * 100) / 100}rtt</div>
|
||||||
|
<div>{Math.round(((client.throughput.down * 8) / 1024) * 10) / 10}kb/s down</div>
|
||||||
|
<div>{Math.round(((client.throughput.up * 8) / 1024) * 10) / 10}kb/s up</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<pre><code><small>{mainEntityJson}</small></code></pre>
|
<pre><code><small>{mainEntityJson}</small></code></pre>
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -29,8 +32,9 @@ export default class Engine {
|
||||||
frame = 0;
|
frame = 0;
|
||||||
handle;
|
handle;
|
||||||
incomingActions = new Map();
|
incomingActions = new Map();
|
||||||
last = Date.now();
|
last;
|
||||||
server;
|
server;
|
||||||
|
updateElapsed = 0;
|
||||||
|
|
||||||
constructor(Server) {
|
constructor(Server) {
|
||||||
this.ecses = {};
|
this.ecses = {};
|
||||||
|
@ -117,6 +121,24 @@ export default class Engine {
|
||||||
}
|
}
|
||||||
this.incomingActions.get(connection).push(payload);
|
this.incomingActions.get(connection).push(payload);
|
||||||
});
|
});
|
||||||
|
this.server.addPacketListener('Heartbeat', (connection) => {
|
||||||
|
const playerData = this.connectedPlayers.get(connection);
|
||||||
|
const {distance} = playerData;
|
||||||
|
const now = performance.now();
|
||||||
|
distance.rtt = (now - distance.last) / 1000;
|
||||||
|
playerData.heartbeat = setTimeout(() => {
|
||||||
|
distance.last = performance.now();
|
||||||
|
this.server.send(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
type: 'Heartbeat',
|
||||||
|
payload: {
|
||||||
|
rtt: distance.rtt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptActions() {
|
acceptActions() {
|
||||||
|
@ -227,6 +249,17 @@ export default class Engine {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (payload.ack) {
|
||||||
|
this.server.send(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
type: 'ActionAck',
|
||||||
|
payload: {
|
||||||
|
ack: payload.ack,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.incomingActions.set(connection, []);
|
this.incomingActions.set(connection, []);
|
||||||
}
|
}
|
||||||
|
@ -239,18 +272,26 @@ export default class Engine {
|
||||||
}
|
}
|
||||||
const ecs = this.ecses[entityJson.Ecs.path];
|
const ecs = this.ecses[entityJson.Ecs.path];
|
||||||
const entity = ecs.get(await ecs.create(entityJson));
|
const entity = ecs.get(await ecs.create(entityJson));
|
||||||
entity.Player.id = id
|
entity.Player.id = id;
|
||||||
this.connectedPlayers.set(
|
const playerData = {
|
||||||
connection,
|
distance: {last: performance.now(), rtt: 0},
|
||||||
{
|
|
||||||
entity,
|
entity,
|
||||||
id,
|
id,
|
||||||
memory: {
|
memory: {
|
||||||
chunks: new Map(),
|
chunks: new Map(),
|
||||||
nearby: new Set(),
|
nearby: new Set(),
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
this.server.send(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
type: 'Heartbeat',
|
||||||
|
payload: {
|
||||||
|
rtt: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
this.connectedPlayers.set(connection, playerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
createEcs() {
|
createEcs() {
|
||||||
|
@ -264,7 +305,8 @@ export default class Engine {
|
||||||
}
|
}
|
||||||
this.connectedPlayers.delete(connection);
|
this.connectedPlayers.delete(connection);
|
||||||
this.incomingActions.delete(connection);
|
this.incomingActions.delete(connection);
|
||||||
const {entity, id} = connectedPlayer;
|
const {entity, heartbeat, id} = connectedPlayer;
|
||||||
|
clearTimeout(heartbeat);
|
||||||
const json = entity.toJSON();
|
const json = entity.toJSON();
|
||||||
const ecs = this.ecses[entity.Ecs.path];
|
const ecs = this.ecses[entity.Ecs.path];
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
@ -371,14 +413,20 @@ export default class Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
this.last = performance.now() / 1000;
|
||||||
const loop = async () => {
|
const loop = async () => {
|
||||||
|
const now = performance.now() / 1000;
|
||||||
|
const elapsed = now - this.last;
|
||||||
|
this.updateElapsed += elapsed;
|
||||||
|
this.last = now;
|
||||||
this.acceptActions();
|
this.acceptActions();
|
||||||
const elapsed = (Date.now() - this.last) / 1000;
|
|
||||||
this.last = Date.now();
|
|
||||||
this.tick(elapsed);
|
this.tick(elapsed);
|
||||||
this.update(elapsed);
|
if (this.updateElapsed >= UPS_PER_S) {
|
||||||
|
this.update(this.updateElapsed);
|
||||||
this.setClean();
|
this.setClean();
|
||||||
this.frame += 1;
|
this.frame += 1;
|
||||||
|
this.updateElapsed = this.updateElapsed % UPS_PER_S;
|
||||||
|
}
|
||||||
this.handle = setTimeout(loop, 1000 / TPS);
|
this.handle = setTimeout(loop, 1000 / TPS);
|
||||||
};
|
};
|
||||||
loop();
|
loop();
|
||||||
|
|
|
@ -2,6 +2,8 @@ export const CHUNK_SIZE = 32;
|
||||||
|
|
||||||
export const CLIENT_LATENCY = 0;
|
export const CLIENT_LATENCY = 0;
|
||||||
|
|
||||||
|
export const CLIENT_INTERPOLATION = true;
|
||||||
|
|
||||||
export const CLIENT_PREDICTION = true;
|
export const CLIENT_PREDICTION = true;
|
||||||
|
|
||||||
export const IRL_MINUTES_PER_GAME_DAY = 20;
|
export const IRL_MINUTES_PER_GAME_DAY = 20;
|
||||||
|
@ -14,3 +16,5 @@ export const RESOLUTION = {
|
||||||
export const SERVER_LATENCY = 0;
|
export const SERVER_LATENCY = 0;
|
||||||
|
|
||||||
export const TPS = 60;
|
export const TPS = 60;
|
||||||
|
|
||||||
|
export const UPS = 15;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user