feat!: server persistence

This commit is contained in:
cha0s 2024-06-14 15:18:55 -05:00
parent ce62d873bf
commit c53d716a37
21 changed files with 501 additions and 430 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules
/app/data
/.cache
/build
.env

View File

@ -0,0 +1,3 @@
export default {
path: {type: 'string'},
}

View File

@ -1,3 +1,27 @@
import Arbitrary from '@/ecs/arbitrary.js';
import Base from '@/ecs/base.js';
import Schema from '@/ecs/schema.js';
import gather from '@/engine/gather.js';
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
const specificationsOrClasses = gather(
import.meta.glob('./*.js', {eager: true, import: 'default'}),
);
const Components = {};
for (const name in specificationsOrClasses) {
const specificationOrClass = specificationsOrClasses[name];
if (specificationOrClass instanceof Base) {
Components[name] = specificationOrClass;
}
else {
Components[name] = class Component extends Arbitrary {
static name = name;
static schema = new Schema({
type: 'object',
properties: specificationOrClass,
});
};
}
}
export default Components;

View File

@ -1,3 +0,0 @@
export default {
world: {type: 'uint16'},
}

3
app/ecs-systems/index.js Normal file
View File

@ -0,0 +1,3 @@
import gather from '@/engine/gather.js';
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));

View File

@ -54,12 +54,6 @@ class SpatialHash {
export default class UpdateSpatialHash extends System {
constructor(ecs) {
super(ecs);
const master = ecs.get(1);
this.hash = new SpatialHash(master.AreaSize);
}
deindex(entities) {
super.deindex(entities);
for (const id of entities) {
@ -68,6 +62,11 @@ export default class UpdateSpatialHash extends System {
}
reindex(entities) {
for (const id of entities) {
if (1 === id) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
}
super.reindex(entities);
for (const id of entities) {
this.updateHash(this.ecs.get(id));

View File

@ -23,7 +23,8 @@ test('does not serialize default values', () => {
properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}},
});
}
const Component = new CreatingArbitrary();
const fakeEcs = {markChange() {}};
const Component = new CreatingArbitrary(fakeEcs);
Component.create(1)
expect(Component.get(1).toJSON())
.to.deep.equal({});

View File

@ -1,11 +1,17 @@
export default class Base {
ecs;
map = [];
pool = [];
static schema;
constructor(ecs) {
this.ecs = ecs;
}
allocateMany(count) {
const results = [];
while (count-- > 0 && this.pool.length > 0) {
@ -54,6 +60,10 @@ export default class Base {
}
}
static gathered(id, key) {
this.name = key;
}
insertMany(entities) {
const creating = [];
for (let i = 0; i < entities.length; i++) {
@ -71,8 +81,9 @@ export default class Base {
this.createMany(creating);
}
// eslint-disable-next-line no-unused-vars
markChange(entityId, components) {}
markChange(entityId, key, value) {
this.ecs.markChange(entityId, {[this.constructor.name]: {[key]: value}})
}
mergeDiff(original, update) {
return {...original, ...update};
@ -86,13 +97,4 @@ export default class Base {
return this.constructor.schema.sizeOf(this.get(entityId));
}
static wrap(name, ecs) {
class WrappedComponent extends this {
markChange(entityId, key, value) {
ecs.markChange(entityId, {[name]: {[key]: value}})
}
}
return new WrappedComponent();
}
}

View File

@ -1,14 +0,0 @@
import Arbitrary from './arbitrary.js';
import Base from './base.js';
import Schema from './schema.js';
export default function Component(specificationOrClass) {
if (specificationOrClass instanceof Base) {
return specificationOrClass;
}
// Why the rigamarole? Maybe we'll implement a flat component for direct binary storage
// eventually.
return class AdhocComponent extends Arbitrary {
static schema = new Schema({type: 'object', properties: specificationOrClass});
};
}

View File

@ -1,7 +1,10 @@
import Component from './component.js';
import EntityFactory from './entity-factory.js';
import Schema from './schema.js';
import System from './system.js';
import {Encoder, Decoder} from '@msgpack/msgpack';
const decoder = new Decoder();
const encoder = new Encoder();
export default class Ecs {
@ -9,6 +12,10 @@ export default class Ecs {
diff = {};
static Systems = {};
Systems = {};
static Types = {};
Types = {};
@ -17,19 +24,14 @@ export default class Ecs {
$$entityFactory = new EntityFactory();
$$systems = [];
constructor() {
const {Types} = this.constructor;
for (const i in Types) {
this.Types[i] = Component(Types[i]).wrap(i, this);
const {Systems, Types} = this.constructor;
for (const name in Types) {
this.Types[name] = new Types[name](this);
}
for (const name in Systems) {
this.Systems[name] = new Systems[name](this);
}
addSystem(source) {
const system = System.wrap(source, this);
this.$$systems.push(system);
system.reindex(this.entities);
}
apply(patch) {
@ -88,9 +90,9 @@ export default class Ecs {
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
const componentKeys = [];
for (const key of Object.keys(components)) {
if (this.Types[key]) {
componentKeys.push(key);
for (const name in components) {
if (this.Types[name]) {
componentKeys.push(name);
}
}
entityIds.push(entityId);
@ -115,74 +117,29 @@ export default class Ecs {
}
deindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].deindex(entityIds);
for (const name in this.Systems) {
this.Systems[name].deindex(entityIds);
}
}
static deserialize(view) {
const ecs = new this();
let cursor = 0;
const headerComponents = view.getUint16(cursor, true);
cursor += 2;
const keys = [];
for (let i = 0; i < headerComponents; ++i) {
const wrapped = {value: cursor};
keys[i] = Schema.deserialize(view, wrapped, {type: 'string'});
cursor = wrapped.value;
}
const count = view.getUint32(cursor, true);
cursor += 4;
const creating = new Map();
const updating = new Map();
const cursors = new Map();
for (let i = 0; i < count; ++i) {
const entityId = view.getUint32(cursor, true);
if (!ecs.$$entities[entityId]) {
creating.set(entityId, {});
}
cursor += 4;
const componentCount = view.getUint16(cursor, true);
cursor += 2;
cursors.set(entityId, {});
const addedComponents = [];
for (let j = 0; j < componentCount; ++j) {
const componentId = view.getUint16(cursor, true);
cursor += 2;
const component = keys[componentId];
const componentSize = view.getUint32(cursor, true);
cursor += 4;
if (!this.Types[component]) {
console.error(`can't deserialize nonexistent component ${component}`);
cursor += componentSize;
continue;
}
if (!ecs.$$entities[entityId]) {
creating.get(entityId)[component] = false;
}
else if (!ecs.$$entities[entityId].constructor.types.includes(component)) {
addedComponents.push(component);
if (!updating.has(component)) {
updating.set(component, []);
}
updating.get(component).push([entityId, false]);
}
cursors.get(entityId)[component] = cursor;
cursor += componentSize;
}
if (addedComponents.length > 0 && ecs.$$entities[entityId]) {
ecs.rebuild(entityId, (types) => types.concat(addedComponents));
}
}
ecs.createManySpecific(Array.from(creating.entries()));
for (const [component, entityIds] of updating) {
ecs.Types[component].createMany(entityIds);
}
for (const [entityId, components] of cursors) {
for (const component in components) {
ecs.Types[component].deserialize(entityId, view, components[component]);
const types = Object.keys(ecs.Types);
const {entities, systems} = decoder.decode(view.buffer);
for (const system of systems) {
ecs.system(system).active = true;
}
const specifics = [];
let max = 1;
for (const id in entities) {
max = Math.max(max, parseInt(id));
specifics.push([
parseInt(id),
Object.fromEntries(Object.entries(entities[id]).filter(([type]) => types.includes(type))),
]);
}
ecs.$$caret = max + 1;
ecs.createManySpecific(specifics);
return ecs;
}
@ -272,21 +229,21 @@ export default class Ecs {
// Created?
else if (!this.diff[entityId]) {
const filtered = {};
for (const type in components) {
filtered[type] = false === components[type]
for (const name in components) {
filtered[name] = false === components[name]
? false
: this.Types[type].constructor.filterDefaults(components[type]);
: this.Types[name].constructor.filterDefaults(components[name]);
}
this.diff[entityId] = filtered;
}
// Otherwise, merge.
else {
for (const type in components) {
this.diff[entityId][type] = false === components[type]
for (const name in components) {
this.diff[entityId][name] = false === components[name]
? false
: this.Types[type].mergeDiff(
this.diff[entityId][type] || {},
components[type],
: this.Types[name].mergeDiff(
this.diff[entityId][name] || {},
components[name],
);
}
}
@ -302,8 +259,8 @@ export default class Ecs {
}
reindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(entityIds);
for (const name in this.Systems) {
this.Systems[name].reindex(entityIds);
}
}
@ -333,49 +290,12 @@ export default class Ecs {
this.reindex(unique.values());
}
removeSystem(SystemLike) {
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
if (-1 !== index) {
this.$$systems.splice(index, 1);
}
}
static serialize(ecs, view) {
const buffer = encoder.encode(ecs.toJSON());
if (!view) {
view = new DataView(new ArrayBuffer(ecs.size()));
view = new DataView(new ArrayBuffer(buffer.length));
}
let cursor = 0;
const keys = Object.keys(ecs.Types);
view.setUint16(cursor, keys.length, true);
cursor += 2;
for (const i in keys) {
cursor += Schema.serialize(keys[i], view, cursor, {type: 'string'});
}
const entitiesWrittenAt = cursor;
let entitiesWritten = 0;
cursor += 4;
for (const entityId of ecs.entities) {
const entity = ecs.get(entityId);
entitiesWritten += 1;
view.setUint32(cursor, entityId, true);
cursor += 4;
const entityComponents = entity.constructor.types;
view.setUint16(cursor, entityComponents.length, true);
const componentsWrittenAt = cursor;
cursor += 2;
for (const component of entityComponents) {
const instance = ecs.Types[component];
view.setUint16(cursor, keys.indexOf(component), true);
cursor += 2;
const sizeOf = instance.sizeOf(entityId);
view.setUint32(cursor, sizeOf, true);
cursor += 4;
instance.serialize(entityId, view, cursor);
cursor += sizeOf;
}
view.setUint16(componentsWrittenAt, entityComponents.length, true);
}
view.setUint32(entitiesWrittenAt, entitiesWritten, true);
(new Uint8Array(view.buffer)).set(buffer);
return view;
}
@ -398,31 +318,55 @@ export default class Ecs {
return size;
}
system(SystemLike) {
return this.$$systems.find((system) => SystemLike === system.source)
system(name) {
return this.Systems[name];
}
tick(elapsed) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].tick(elapsed);
for (const name in this.Systems) {
if (this.Systems[name].active) {
this.Systems[name].tick(elapsed);
}
}
for (const name in this.Systems) {
if (this.Systems[name].active) {
this.Systems[name].finalize(elapsed);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].finalize(elapsed);
}
this.tickDestruction();
}
tickDestruction() {
const unique = new Set();
for (let i = 0; i < this.$$systems.length; i++) {
for (let j = 0; j < this.$$systems[i].destroying.length; j++) {
unique.add(this.$$systems[i].destroying[j]);
for (const name in this.Systems) {
const System = this.Systems[name];
if (System.active) {
for (let j = 0; j < System.destroying.length; j++) {
unique.add(System.destroying[j]);
}
System.tickDestruction();
}
this.$$systems[i].tickDestruction();
}
if (unique.size > 0) {
this.destroyMany(unique.values());
}
}
toJSON() {
const entities = {};
for (const id in this.$$entities) {
entities[id] = this.$$entities[id].toJSON();
}
const systems = [];
for (const name in this.Systems) {
if (this.Systems[name].active) {
systems.push(name);
}
}
return {
entities,
systems,
};
}
}

View File

@ -1,41 +1,66 @@
import {expect, test, vi} from 'vitest';
import {expect, test} from 'vitest';
import Arbitrary from './arbitrary.js';
import Ecs from './ecs.js';
import Schema from './schema.js';
import System from './system.js';
const Empty = {};
function wrapSpecification(name, specification) {
return class Component extends Arbitrary {
static name = name;
static schema = new Schema({
type: 'object',
properties: specification,
});
};
}
const Name = {
const Empty = wrapSpecification('Empty', {});
const Name = wrapSpecification('Name', {
name: {type: 'string'},
};
});
const Position = {
const Position = wrapSpecification('Position', {
x: {type: 'int32', defaultValue: 32},
y: {type: 'int32'},
z: {type: 'int32'},
};
});
test('adds and remove systems at runtime', () => {
const ecs = new Ecs();
test('activates and deactivates systems at runtime', () => {
let oneCount = 0;
let twoCount = 0;
const oneSystem = () => {
oneCount++;
};
ecs.addSystem(oneSystem);
class SystemToggle extends Ecs {
static Systems = {
OneSystem: class extends System {
tick() {
oneCount += 1;
}
},
TwoSystem: class extends System {
tick() {
twoCount += 1;
}
},
}
}
const ecs = new SystemToggle();
ecs.tick();
expect(oneCount)
.to.equal(0);
expect(twoCount)
.to.equal(0);
ecs.system('OneSystem').active = true;
ecs.tick();
expect(oneCount)
.to.equal(1);
const twoSystem = () => {
twoCount++;
};
ecs.addSystem(twoSystem);
ecs.system('TwoSystem').active = true;
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(1);
ecs.removeSystem(oneSystem);
ecs.system('OneSystem').active = false;
ecs.tick();
expect(oneCount)
.to.equal(2);
@ -108,16 +133,14 @@ test('inserts components into entities', () => {
});
test('ticks systems', () => {
const Momentum = {
const Momentum = wrapSpecification('Momentum', {
x: {type: 'int32'},
y: {type: 'int32'},
z: {type: 'int32'},
};
});
class TickEcs extends Ecs {
static Types = {Momentum, Position};
}
const ecs = new TickEcs();
class Physics extends System {
static Systems = {
Physics: class Physics extends System {
static queries() {
return {
@ -133,8 +156,12 @@ test('ticks systems', () => {
}
}
},
}
ecs.addSystem(Physics);
static Types = {Momentum, Position};
}
const ecs = new TickEcs();
ecs.system('Physics').active = true;
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1);
@ -147,13 +174,17 @@ test('ticks systems', () => {
});
test('creates many entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
class TickingSystemEcs extends Ecs {
static Systems = {
Spawn: class extends System {
tick() {
this.createManyEntities(Array.from({length: 5}).map(() => []));
}
},
};
}
ecs.addSystem(Spawn);
const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.create();
expect(ecs.get(5))
.to.be.undefined;
@ -163,13 +194,17 @@ test('creates many entities when ticking systems', () => {
});
test('creates entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
class TickingSystemEcs extends Ecs {
static Systems = {
Spawn: class extends System {
tick() {
this.createEntity();
}
},
};
}
ecs.addSystem(Spawn);
const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.create();
expect(ecs.get(2))
.to.be.undefined;
@ -179,20 +214,21 @@ test('creates entities when ticking systems', () => {
});
test('schedules entities to be deleted when ticking systems', () => {
const ecs = new Ecs();
let entity;
class Despawn extends System {
class TickingSystemEcs extends Ecs {
static Systems = {
Despawn: class extends System {
finalize() {
entity = ecs.get(1);
}
tick() {
this.destroyEntity(1);
}
},
};
}
ecs.addSystem(Despawn);
const ecs = new TickingSystemEcs();
ecs.system('Despawn').active = true;
ecs.create();
ecs.tick(1);
expect(entity)
@ -202,12 +238,10 @@ test('schedules entities to be deleted when ticking systems', () => {
});
test('adds components to and remove components from entities when ticking systems', () => {
class TickingEcs extends Ecs {
static Types = {Foo: {bar: {type: 'uint8'}}};
}
const ecs = new TickingEcs();
let addLength, removeLength;
class AddComponent extends System {
class TickingSystemEcs extends Ecs {
static Systems = {
AddComponent: class extends System {
static queries() {
return {
default: ['Foo'],
@ -219,8 +253,8 @@ test('adds components to and remove components from entities when ticking system
finalize() {
addLength = Array.from(this.select('default')).length;
}
}
class RemoveComponent extends System {
},
RemoveComponent: class extends System {
static queries() {
return {
default: ['Foo'],
@ -232,16 +266,20 @@ test('adds components to and remove components from entities when ticking system
finalize() {
removeLength = Array.from(this.select('default')).length;
}
},
};
static Types = {Foo: wrapSpecification('Foo', {bar: {type: 'uint8'}})};
}
ecs.addSystem(AddComponent);
const ecs = new TickingSystemEcs();
ecs.system('AddComponent').active = true;
ecs.create();
ecs.tick(1);
expect(addLength)
.to.equal(1);
expect(ecs.get(1).Foo)
.to.not.be.undefined;
ecs.removeSystem(AddComponent);
ecs.addSystem(RemoveComponent);
ecs.system('AddComponent').active = false;
ecs.system('RemoveComponent').active = true;
ecs.tick(1);
expect(removeLength)
.to.equal(0);
@ -412,35 +450,25 @@ test('serializes and deserializes', () => {
class SerializingEcs extends Ecs {
static Types = {Empty, Name, Position};
}
// # of components + strings (Empty, Name, Position)
// 2 + 4 + 5 + 4 + 4 + 4 + 8 = 31
const ecs = new SerializingEcs();
// ID + # of components + Empty + Position + x + y + z
// 4 + 2 + 2 + 4 + 2 + 4 + 4 + 4 + 4 = 30
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
// ID + # of components + Name + 'foobar' + Position + x + y + z
// 4 + 2 + 2 + 4 + 4 + 6 + 2 + 4 + 4 + 4 + 4 = 40
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
expect(ecs.toJSON())
.to.deep.equal({
entities: {
1: {Empty: {}, Position: {x: 64}},
16: {Name: {name: 'foobar'}, Position: {x: 128}},
},
systems: [],
});
const view = SerializingEcs.serialize(ecs);
// # of entities + Header + Entity(1) + Entity(16)
// 4 + 31 + 30 + 40 = 105
expect(view.byteLength)
.to.equal(105);
// Entity values.
expect(view.getUint32(4 + 31 + 30 - 12, true))
.to.equal(64);
expect(view.getUint32(4 + 31 + 30 + 40 - 12, true))
.to.equal(128);
const deserialized = SerializingEcs.deserialize(view);
// # of entities.
expect(Array.from(deserialized.entities).length)
.to.equal(2);
// Composition of entities.
expect(deserialized.get(1).constructor.types)
.to.deep.equal(['Empty', 'Position']);
expect(deserialized.get(16).constructor.types)
.to.deep.equal(['Name', 'Position']);
// Entity values.
expect(JSON.stringify(deserialized.get(1)))
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
expect(JSON.stringify(deserialized.get(1).Position))
@ -462,22 +490,9 @@ test('deserializes from compatible ECS', () => {
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = SerializingEcs.serialize(ecs);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const deserialized = DeserializingEcs.deserialize(view);
expect(consoleErrorSpy.mock.calls.length)
.to.equal(2);
consoleErrorSpy.mockRestore();
expect(deserialized.get(1).toJSON())
.to.deep.equal({Empty: {}});
expect(deserialized.get(16).toJSON())
.to.deep.equal({Name: {name: 'foobar'}});
});
test('deserializes empty', () => {
class SerializingEcs extends Ecs {
static Types = {Empty, Name, Position};
}
const ecs = SerializingEcs.deserialize(new DataView(new Uint16Array([0, 0, 0]).buffer));
expect(ecs)
.to.not.be.undefined;
});

View File

@ -1,11 +1,22 @@
import {expect, test} from 'vitest';
import Component from './component.js';
import Arbitrary from './arbitrary.js';
import Query from './query.js';
import Schema from './schema.js';
const A = new (Component({a: {type: 'int32', defaultValue: 420}}));
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
const C = new (Component({c: {type: 'int32'}}));
function wrapSpecification(name, specification) {
return class Component extends Arbitrary {
static name = name;
static schema = new Schema({
type: 'object',
properties: specification,
});
};
}
const A = new (wrapSpecification('A', {a: {type: 'int32', defaultValue: 420}}));
const B = new (wrapSpecification('B', {b: {type: 'int32', defaultValue: 69}}));
const C = new (wrapSpecification('C', {c: {type: 'int32'}}));
const Types = {A, B, C};
Types.A.createMany([[2], [3]]);
@ -50,7 +61,7 @@ test('can deindex', () => {
});
test('can reindex', () => {
const Test = new (Component({a: {type: 'int32', defaultValue: 420}}));
const Test = new (wrapSpecification('Test', {a: {type: 'int32', defaultValue: 420}}));
Test.createMany([[2], [3]]);
const query = new Query(['Test'], {Test});
query.reindex([2, 3]);

View File

@ -3,6 +3,8 @@ import Query from './query.js';
export default class System {
active = false;
destroying = [];
ecs;
@ -15,6 +17,15 @@ export default class System {
for (const i in queries) {
this.queries[i] = new Query(queries[i], ecs.Types);
}
this.reindex(ecs.entities);
}
createEntity(components) {
return this.ecs.create(components);
}
createManyEntities(componentsList) {
return this.ecs.createMany(componentsList);
}
deindex(entityIds) {
@ -35,17 +46,12 @@ export default class System {
finalize() {}
static normalize(SystemLike) {
if (SystemLike.prototype instanceof System) {
return SystemLike;
insertComponents(entityId, components) {
this.ecs.insert(entityId, components);
}
if ('function' === typeof SystemLike) {
class TickingSystem extends System {}
TickingSystem.prototype.tick = SystemLike;
return TickingSystem;
}
/* v8 ignore next */
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
insertManyComponents(components) {
this.ecs.insertMany(components);
}
static queries() {
@ -58,6 +64,14 @@ export default class System {
}
}
removeComponents(entityId, components) {
this.ecs.remove(entityId, components);
}
removeManyComponents(entityIds) {
this.ecs.removeMany(entityIds);
}
select(query) {
return this.queries[query].select();
}
@ -69,44 +83,4 @@ export default class System {
tick() {}
static wrap(source, ecs) {
class WrappedSystem extends System.normalize(source) {
constructor() {
super(ecs);
this.reindex(ecs.entities);
}
createEntity(components) {
return this.ecs.create(components);
}
createManyEntities(componentsList) {
return this.ecs.createMany(componentsList);
}
get source() {
return source;
}
insertComponents(entityId, components) {
this.ecs.insert(entityId, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
}
removeComponents(entityId, components) {
this.ecs.remove(entityId, components);
}
removeManyComponents(entityIds) {
this.ecs.removeMany(entityIds);
}
}
return new WrappedSystem();
}
}

View File

@ -1,7 +1,9 @@
import Ecs from '@/ecs/ecs.js';
import Systems from '@/ecs-systems/index.js';
import Types from '@/ecs-components/index.js';
class EngineEcs extends Ecs {
static Systems = Systems;
static Types = Types;
}

View File

@ -1,38 +1,12 @@
import {join} from 'node:path';
import {
MOVE_MAP,
TPS,
} from '@/constants.js';
import ControlMovement from '@/ecs-systems/control-movement.js';
import ApplyMomentum from '@/ecs-systems/apply-momentum.js';
import CalculateAabbs from '@/ecs-systems/calculate-aabbs.js';
import ClampPositions from '@/ecs-systems/clamp-positions.js';
import FollowCamera from '@/ecs-systems/follow-camera.js';
import UpdateSpatialHash from '@/ecs-systems/update-spatial-hash.js';
import RunAnimations from '@/ecs-systems/run-animations.js';
import ControlDirection from '@/ecs-systems/control-direction.js';
import SpriteDirection from '@/ecs-systems/sprite-direction.js';
import Ecs from '@/engine/ecs.js';
import {decode, encode} from '@/packets/index.js';
const players = {
0: {
Camera: {},
Controlled: {up: 0, right: 0, down: 0, left: 0},
Direction: {direction: 2},
Momentum: {},
Position: {x: 368, y: 368},
VisibleAabb: {},
World: {world: 1},
Sprite: {
animation: 'moving:down',
frame: 0,
frames: 8,
source: '/assets/dude.json',
speed: 0.115,
},
},
};
export default class Engine {
incomingActions = [];
@ -50,9 +24,7 @@ export default class Engine {
server;
constructor(Server) {
this.ecses = {
1: this.createHomestead(),
};
this.ecses = {};
class SilphiusServer extends Server {
accept(connection, packed) {
super.accept(connection, decode(packed));
@ -67,38 +39,28 @@ export default class Engine {
});
}
async connectPlayer(connection) {
this.connections.push(connection);
const entityJson = await this.loadPlayer(connection);
const ecs = this.ecses[entityJson.World.world];
async connectPlayer(connection, id) {
const entityJson = await this.loadPlayer(id);
if (!this.ecses[entityJson.Ecs.path]) {
await this.loadEcs(entityJson.Ecs.path);
}
const ecs = this.ecses[entityJson.Ecs.path];
const entity = ecs.create(entityJson);
this.connections.push(connection);
this.connectedPlayers.set(
connection,
{
entity: ecs.get(entity),
id,
memory: new Set(),
},
);
}
createEcs(master) {
createEcs() {
const ecs = new Ecs();
ecs.create(master);
ecs.addSystem(ControlMovement);
ecs.addSystem(ApplyMomentum);
ecs.addSystem(ClampPositions);
ecs.addSystem(FollowCamera);
ecs.addSystem(CalculateAabbs);
ecs.addSystem(UpdateSpatialHash);
ecs.addSystem(ControlDirection);
ecs.addSystem(SpriteDirection);
ecs.addSystem(RunAnimations);
return ecs;
}
createHomestead() {
const area = {x: 100, y: 60};
return this.createEcs({
ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
TileLayers: {
layers: [
@ -111,12 +73,61 @@ export default class Engine {
],
},
});
const defaultSystems = [
'ControlMovement',
'ApplyMomentum',
'ClampPositions',
'FollowCamera',
'CalculateAabbs',
'UpdateSpatialHash',
'ControlDirection',
'SpriteDirection',
'RunAnimations',
];
defaultSystems.forEach((defaultSystem) => {
ecs.system(defaultSystem).active = true;
});
return ecs;
}
disconnectPlayer(connection) {
const {entity} = this.connectedPlayers.get(connection);
const ecs = this.ecses[entity.World.world];
players[0] = JSON.parse(JSON.stringify(entity.toJSON()));
async createHomestead(id) {
const ecs = this.createEcs();
const view = Ecs.serialize(ecs);
await this.server.writeData(
join('homesteads', `${id}`),
view,
);
}
async createPlayer(id) {
const player = {
Camera: {},
Controlled: {up: 0, right: 0, down: 0, left: 0},
Direction: {direction: 2},
Ecs: {path: join('homesteads', `${id}`)},
Momentum: {},
Position: {x: 368, y: 368},
VisibleAabb: {},
Sprite: {
animation: 'moving:down',
frame: 0,
frames: 8,
source: '/assets/dude.json',
speed: 0.115,
},
};
const buffer = (new TextEncoder()).encode(JSON.stringify(player));
await this.server.writeData(
join('players', `${id}`),
buffer,
);
return buffer;
}
async disconnectPlayer(connection) {
const {entity, id} = this.connectedPlayers.get(connection);
const ecs = this.ecses[entity.Ecs.path];
await this.savePlayer(id, entity);
ecs.destroy(entity.id);
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
@ -125,8 +136,29 @@ export default class Engine {
async load() {
}
async loadPlayer() {
return players[0];
async loadEcs(path) {
this.ecses[path] = Ecs.deserialize(await this.server.readData(path));
}
async loadPlayer(id) {
let buffer;
try {
buffer = await this.server.readData(['players', `${id}`].join('/'))
}
catch (error) {
if ('ENOENT' !== error.code) {
throw error;
}
await this.createHomestead(id);
buffer = await this.createPlayer(id);
}
return JSON.parse((new TextDecoder()).decode(buffer));
}
async savePlayer(id, entity) {
const encoder = new TextEncoder();
const buffer = encoder.encode(JSON.stringify(entity.toJSON()));
await this.server.writeData(['players', `${id}`].join('/'), buffer);
}
start() {
@ -174,8 +206,8 @@ export default class Engine {
const update = {};
const {entity, memory} = this.connectedPlayers.get(connection);
const mainEntityId = entity.id;
const ecs = this.ecses[entity.World.world];
const nearby = ecs.system(UpdateSpatialHash).nearby(entity);
const ecs = this.ecses[entity.Ecs.path];
const nearby = ecs.system('UpdateSpatialHash').nearby(entity);
// Master entity.
nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values());

View File

@ -2,28 +2,41 @@ import {expect, test} from 'vitest';
import {RESOLUTION} from '@/constants.js'
import Server from '@/net/server/server.js';
import Engine from './engine.js';
test('visibility-based updates', async () => {
const engine = new Engine(Server);
const ecs = engine.ecses[1];
const data = {};
engine.server.readData = async (path) => {
if (path in data) {
return data[path];
}
const error = new Error();
error.code = 'ENOENT';
throw error;
};
engine.server.writeData = async (path, view) => {
data[path] = view;
};
// Connect an entity.
await engine.connectPlayer(0, 0);
const ecs = engine.ecses['homesteads/0'];
// Create an entity.
const entity = ecs.get(ecs.create({
Momentum: {x: 1, y: 0},
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
VisibleAabb: {},
}));
// Connect an entity.
await engine.connectPlayer(undefined);
// Tick and get update. Should be a full update.
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.include({2: ecs.get(2).toJSON(), 3: {MainEntity: {}, ...ecs.get(3).toJSON()}});
expect(engine.updateFor(0))
.to.deep.include({2: {MainEntity: {}, ...ecs.get(2).toJSON()}, 3: ecs.get(3).toJSON()});
// Tick and get update. Should be a partial update.
engine.tick(1);
expect(engine.updateFor(undefined))
expect(engine.updateFor(0))
.to.deep.include({
2: {
3: {
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
VisibleAabb: {
x0: 1199,
@ -33,11 +46,11 @@ test('visibility-based updates', async () => {
});
// Tick and get update. Should remove the entity.
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.include({2: false});
expect(engine.updateFor(0))
.to.deep.include({3: false});
// Aim back toward visible area and tick. Should be a full update for that entity.
entity.Momentum.x = -1;
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.include({2: ecs.get(2).toJSON()});
expect(engine.updateFor(0))
.to.deep.include({3: ecs.get(3).toJSON()});
});

View File

@ -16,7 +16,7 @@ onmessage = (event) => {
(async () => {
await engine.load();
engine.start();
await engine.connectPlayer(undefined);
await engine.connectPlayer();
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
})();

View File

@ -1,8 +1,15 @@
import {json} from "@remix-run/node";
import {useEffect, useState} from 'react';
import {useOutletContext, useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import Ui from '@/react-components/ui.jsx';
import {juggleSession} from '@/session.server';
export async function loader({request}) {
await juggleSession(request);
return json({});
}
export default function PlaySpecific() {
const Client = useOutletContext();

34
app/session.server.js Normal file
View File

@ -0,0 +1,34 @@
import {join} from 'node:path';
import {createFileSessionStorage} from "@remix-run/node";
import {redirect} from '@remix-run/react';
const {getSession, commitSession, destroySession} = createFileSessionStorage({
dir: join(import.meta.dirname, 'data', 'remote', 'sessions'),
cookie: {
secrets: ["r3m1xr0ck5"],
sameSite: true,
},
});
export {getSession, commitSession, destroySession};
export async function juggleSession(request) {
const session = await getSession(request.headers.get('Cookie'));
const url = new URL(request.url);
if (!session.get('id')) {
if (!url.searchParams.has('session')) {
const [id] = crypto.getRandomValues(new Uint32Array(1));
session.set('id', id);
throw redirect(`${url.origin}${url.pathname}?session`, {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
}
else if (url.searchParams.has('session')) {
throw redirect(`${url.origin}${url.pathname}`);
}
return session ? session : {id: 0};
}

View File

@ -1,7 +1,12 @@
import {mkdir, readFile, writeFile} from 'node:fs/promises';
import {dirname, join} from 'node:path';
import {WebSocketServer} from 'ws';
import Server from '@/net/server/server.js';
import {getSession} from '@/session.server.js';
import Engine from './engine/engine.js';
import Server from './net/server/server.js';
const wss = new WebSocketServer({
noServer: true,
@ -23,19 +28,36 @@ let engine;
let onConnect;
function createOnConnect(engine) {
onConnect = async (ws) => {
ws.on('close', () => {
engine.disconnectPlayer(ws);
onConnect = async (ws, request) => {
ws.on('close', async () => {
await engine.disconnectPlayer(ws);
})
ws.on('message', (packed) => {
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
});
await engine.connectPlayer(ws);
const session = await getSession(request.headers['cookie']);
await engine.connectPlayer(ws, session.get('id'));
};
wss.on('connection', onConnect);
}
class SocketServer extends Server {
async ensurePath(path) {
await mkdir(path, {recursive: true});
}
static qualify(path) {
return join(import.meta.dirname, 'data', 'remote', 'UNIVERSE', path);
}
async readData(path) {
const qualified = this.constructor.qualify(path);
await this.ensurePath(dirname(qualified));
return readFile(qualified);
}
async writeData(path, view) {
const qualified = this.constructor.qualify(path);
await this.ensurePath(dirname(qualified));
await writeFile(qualified, view);
}
transmit(ws, packed) { ws.send(packed); }
}

View File

@ -9,7 +9,8 @@
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node ./server.js",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
"storybook:build": "storybook build",
"test": "vitest app"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",