Compare commits

..

18 Commits

Author SHA1 Message Date
cha0s
29df4c3dcb fix: ecs change 2024-09-05 17:02:09 -05:00
cha0s
9df38d5952 fix: merge 2024-09-05 16:52:22 -05:00
cha0s
2733968fa4 feat: basic client prediction 2024-09-05 16:48:57 -05:00
cha0s
b0b13c8961 chore: moderate 2024-09-05 01:45:57 -05:00
cha0s
841db3de13 feat: action ack 2024-09-05 01:42:14 -05:00
cha0s
a58e1b6b89 fix: only on tick 2024-09-05 01:41:45 -05:00
cha0s
48a45533f5 feat: interpolation 2024-08-30 04:29:28 -05:00
cha0s
5c25fee186 fix: priority 2024-08-29 15:28:25 -05:00
cha0s
9b60744ae2 fix: test 2024-08-25 04:19:04 -05:00
cha0s
ac9e0b4134 refactor: component test 2024-08-14 17:46:59 -05:00
cha0s
77dcb7c5a1 refactor: timestamp precision 2024-08-12 21:48:06 -05:00
cha0s
e15e94777f fix: priority 2024-08-07 22:56:15 -05:00
cha0s
a03b5e3412 refactor: update no reindex 2024-08-07 22:55:43 -05:00
cha0s
d0644858af feat: heartbeat/rtt 2024-08-07 14:29:05 -05:00
cha0s
2dec51008d fix: revert indexing opts 2024-08-07 14:27:55 -05:00
cha0s
0898dabb62 fix: default keys 2024-08-07 14:24:17 -05:00
cha0s
ac11545937 dev: measure throughput 2024-08-06 00:40:43 -05:00
cha0s
8a3ef7842c fix: sync 2024-08-06 00:00:39 -05:00
30 changed files with 778 additions and 280 deletions

111
app/client/interpolator.js Normal file
View 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);
}
}
};

View File

@ -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) {
this.interpolator = new Worker(
new URL('./interpolator.js', import.meta.url),
{type: 'module'},
);
this.interpolator.addEventListener('message', (event) => {
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.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) { if (0 === event.data) {
this.worker.terminate(); this.server.terminate();
this.worker = undefined; this.server = null;
return; return;
} }
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);
}
}); });
} }
disconnect() { disconnect() {
this.worker.postMessage(0); this.server.postMessage(0);
if (CLIENT_INTERPOLATION) {
this.interpolator.terminate();
}
} }
transmit(packed) { transmit(packet) {
this.worker.postMessage(packed); if (CLIENT_PREDICTION) {
this.predictor.postMessage([0, packet]);
}
else {
const packed = encode(packet);
this.throughput.$$up += packed.byteLength;
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;
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
View 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;
}
}
};

View File

@ -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'},
);
if (CLIENT_INTERPOLATION) {
this.interpolator = new Worker(
new URL('./interpolator.js', import.meta.url),
{type: 'module'}, {type: 'module'},
); );
this.worker.postMessage({host}); this.interpolator.addEventListener('message', (event) => {
this.worker.onmessage = (event) => {
this.accept(event.data); this.accept(event.data);
}; });
} }
else { if (CLIENT_PREDICTION) {
const url = new URL(`wss://${host}/ws`) this.predictor = new Worker(
this.socket = new WebSocket(url.href); new URL('./predictor.js', import.meta.url),
this.socket.binaryType = 'arraybuffer'; {type: 'module'},
const onMessage = (event) => { );
this.accept(event.data); 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 {
this.accept(packet);
}
break;
}
}
});
}
const url = new URL(`wss://${host}/ws`)
this.socket = new WebSocket(url.href);
this.socket.binaryType = 'arraybuffer';
this.socket.addEventListener('message', (event) => {
this.throughput.$$down += event.data.byteLength;
const packet = decode(event.data);
if (CLIENT_PREDICTION) {
this.predictor.postMessage([1, packet]);
} }
const {promise, resolve} = withResolvers(); else if (CLIENT_INTERPOLATION) {
this.socket.addEventListener('open', resolve); this.interpolator.postMessage(packet);
this.socket.addEventListener('error', () => { }
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'})); else {
}); this.accept(packet);
await promise; }
this.socket.removeEventListener('open', resolve); });
this.socket.addEventListener('message', onMessage); this.socket.addEventListener('close', () => {
this.socket.addEventListener('close', () => { this.accept({type: 'ConnectionStatus', payload: 'aborted'});
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'})); });
}); this.socket.addEventListener('error', () => {
this.accept(encode({type: 'ConnectionStatus', payload: 'connected'})); this.accept({type: 'ConnectionStatus', payload: 'aborted'});
} });
this.accept({type: 'ConnectionStatus', payload: 'connected'});
} }
disconnect() { disconnect() {
if (CLIENT_PREDICTION) { if (CLIENT_INTERPOLATION) {
this.worker.terminate(); this.interpolator.terminate();
}
else {
this.socket.close();
} }
} }
transmit(packed) { transmit(packet) {
if (CLIENT_PREDICTION) { if (CLIENT_PREDICTION) {
this.worker.postMessage(packed); this.predictor.postMessage([0, packet]);
} }
else { else {
const packed = encode(packet);
this.throughput.$$up += packed.byteLength;
this.socket.send(packed); this.socket.send(packed);
} }
} }

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [];

View File

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

View File

@ -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) => {

View File

@ -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,17 +20,25 @@ export default class ApplyControlMovement extends System {
} }
tick() { tick() {
for (const {Controlled, Forces, Speed} of this.select('default')) { for (const entity of this.select('default')) {
if (!Controlled.locked) { this.tickSingle(entity);
const movement = normalizeVector({ }
x: (Controlled.moveRight - Controlled.moveLeft), }
y: (Controlled.moveDown - Controlled.moveUp),
}); tickSingle(entity) {
Forces.applyImpulse({ const {Controlled, Forces, Speed} = entity;
x: Speed.speed * movement.x, if (!Controlled || !Forces | !Speed) {
y: Speed.speed * movement.y, return;
}); }
} if (!Controlled.locked) {
const movement = normalizeVector({
x: (Controlled.moveRight - Controlled.moveLeft),
y: (Controlled.moveDown - Controlled.moveUp),
});
Forces.applyImpulse({
x: Speed.speed * movement.x,
y: Speed.speed * movement.y,
});
} }
} }

View File

@ -3,27 +3,39 @@ 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'])) {
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; this.tickSingle(entity);
if (locked) {
continue;
}
if (
0 === moveRight
&& 0 === moveDown
&& 0 === moveLeft
&& 0 === moveUp
) {
continue;
}
Direction.direction = (
TAU + Math.atan2(
moveDown - moveUp,
moveRight - moveLeft,
)
) % TAU;
} }
} }
tickSingle(entity) {
const {Controlled, Direction} = entity;
if (!Controlled || !Direction) {
return;
}
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
if (locked) {
return;
}
if (
0 === moveRight
&& 0 === moveDown
&& 0 === moveLeft
&& 0 === moveUp
) {
return;
}
Direction.direction = (
TAU + Math.atan2(
moveDown - moveUp,
moveRight - moveLeft,
)
) % TAU;
}
} }

View File

@ -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) {

View File

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

View File

@ -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,24 +17,32 @@ export default class ResetForces extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {Forces} of this.select('default')) { for (const entity of this.select('default')) {
if (0 !== Forces.forceX) { this.tickSingle(entity, elapsed);
const factorX = Math.pow(1 - Forces.dampingX, elapsed);
Forces.forceX *= factorX;
if (Math.abs(Forces.forceX) <= 1) {
Forces.forceX = 0;
}
}
if (0 !== Forces.forceY) {
const factorY = Math.pow(1 - Forces.dampingY, elapsed);
Forces.forceY *= factorY;
if (Math.abs(Forces.forceY) <= 1) {
Forces.forceY = 0;
}
}
Forces.impulseX = 0;
Forces.impulseY = 0;
} }
} }
tickSingle(entity, elapsed) {
const {Forces} = entity;
if (!Forces) {
return;
}
if (0 !== Forces.forceX) {
const factorX = Math.pow(1 - Forces.dampingX, elapsed);
Forces.forceX *= factorX;
if (Math.abs(Forces.forceX) <= 1) {
Forces.forceX = 0;
}
}
if (0 !== Forces.forceY) {
const factorY = Math.pow(1 - Forces.dampingY, elapsed);
Forces.forceY *= factorY;
if (Math.abs(Forces.forceY) <= 1) {
Forces.forceY = 0;
}
}
Forces.impulseX = 0;
Forces.impulseY = 0;
}
} }

View File

@ -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,15 +13,23 @@ export default class RunAnimations extends System {
} }
tick(elapsed) { tick(elapsed) {
for (const {Sprite} of this.select('default')) { for (const entity of this.select('default')) {
if (0 === Sprite.speed || !Sprite.isAnimating) { this.tickSingle(entity, elapsed);
continue; }
} }
Sprite.elapsed += elapsed / Sprite.speed;
while (Sprite.elapsed > 1) { tickSingle(entity, elapsed) {
Sprite.elapsed -= 1; const {Sprite} = entity;
Sprite.frame += 1; if (!Sprite) {
} return;
}
if (0 === Sprite.speed || !Sprite.isAnimating) {
return;
}
Sprite.elapsed += elapsed / Sprite.speed;
while (Sprite.elapsed >= 1) {
Sprite.elapsed -= 1;
Sprite.frame += 1;
} }
} }

View File

@ -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,35 +19,43 @@ export default class SpriteDirection extends System {
} }
tick() { tick() {
for (const {Controlled, Direction, Sprite} of this.select('default')) { for (const entity of this.select('default')) {
const parts = []; this.tickSingle(entity);
if (Controlled) { }
const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled; }
if (locked) {
continue; tickSingle(entity) {
} const parts = [];
if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) { const {Controlled, Direction, Sprite} = entity;
parts.push('moving'); if (!Sprite) {
} return;
else { }
parts.push('idle'); if (Controlled) {
} const {locked, moveUp, moveRight, moveDown, moveLeft} = Controlled;
if (locked) {
return;
} }
if (Direction) { if ((moveUp > 0 || moveRight > 0 || moveDown > 0 || moveLeft > 0)) {
if (!Sprite.rotates) { parts.push('moving');
const name = {
0: 'right',
1: 'down',
2: 'left',
3: 'up',
};
parts.push(name[Direction.quantize(4)]);
}
} }
if (parts.length > 0) { else {
if (Sprite.hasAnimation(parts.join(':'))) { parts.push('idle');
Sprite.animation = parts.join(':'); }
} }
if (Direction) {
if (!Sprite.rotates) {
const name = {
0: 'right',
1: 'down',
2: 'left',
3: 'up',
};
parts.push(name[Direction.quantize(4)]);
}
}
if (parts.length > 0) {
if (Sprite.hasAnimation(parts.join(':'))) {
Sprite.animation = parts.join(':');
} }
} }
} }

14
app/ecs/test-helper.js Normal file
View 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;
},
};
}, {})
}

View File

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

View File

@ -0,0 +1,3 @@
import Packet from '@/net/packet.js';
export default class ActionAck extends Packet {}

View File

@ -0,0 +1,3 @@
import Packet from '@/net/packet.js';
export default class Heartbeat extends Packet {}

View File

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

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,
}); });
@ -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 = {
distance: {last: performance.now(), rtt: 0},
entity,
id,
memory: {
chunks: new Map(),
nearby: new Set(),
},
};
this.server.send(
connection, connection,
{ {
entity, type: 'Heartbeat',
id, payload: {
memory: { rtt: 0,
chunks: new Map(),
nearby: new Set(),
}, },
}, },
); );
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.setClean(); this.update(this.updateElapsed);
this.frame += 1; this.setClean();
this.frame += 1;
this.updateElapsed = this.updateElapsed % UPS_PER_S;
}
this.handle = setTimeout(loop, 1000 / TPS); this.handle = setTimeout(loop, 1000 / TPS);
}; };
loop(); loop();

View File

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