From c53d716a37e63ff6c0f3bb5fde052bb4ead41082 Mon Sep 17 00:00:00 2001 From: cha0s Date: Fri, 14 Jun 2024 15:18:55 -0500 Subject: [PATCH] feat!: server persistence --- .gitignore | 1 + app/ecs-components/ecs.js | 3 + app/ecs-components/index.js | 26 ++- app/ecs-components/world.js | 3 - app/ecs-systems/index.js | 3 + app/ecs-systems/update-spatial-hash.js | 11 +- app/ecs/arbitrary.test.js | 3 +- app/ecs/base.js | 24 ++- app/ecs/component.js | 14 -- app/ecs/ecs.js | 218 +++++++------------ app/ecs/ecs.test.js | 263 ++++++++++++----------- app/ecs/query.test.js | 21 +- app/ecs/system.js | 76 +++---- app/engine/ecs.js | 2 + app/engine/engine.js | 150 ++++++++----- app/engine/engine.test.js | 35 ++- app/net/server/worker.js | 2 +- app/routes/_main-menu.play.$.$/route.jsx | 7 + app/session.server.js | 34 +++ app/websocket.js | 32 ++- package.json | 3 +- 21 files changed, 501 insertions(+), 430 deletions(-) create mode 100644 app/ecs-components/ecs.js delete mode 100644 app/ecs-components/world.js create mode 100644 app/ecs-systems/index.js delete mode 100644 app/ecs/component.js create mode 100644 app/session.server.js diff --git a/.gitignore b/.gitignore index 80ec311..0a3a6fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules +/app/data /.cache /build .env diff --git a/app/ecs-components/ecs.js b/app/ecs-components/ecs.js new file mode 100644 index 0000000..a3ae9ea --- /dev/null +++ b/app/ecs-components/ecs.js @@ -0,0 +1,3 @@ +export default { + path: {type: 'string'}, +} diff --git a/app/ecs-components/index.js b/app/ecs-components/index.js index 61c6535..3f6fb8d 100644 --- a/app/ecs-components/index.js +++ b/app/ecs-components/index.js @@ -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; diff --git a/app/ecs-components/world.js b/app/ecs-components/world.js deleted file mode 100644 index 047d03e..0000000 --- a/app/ecs-components/world.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - world: {type: 'uint16'}, -} diff --git a/app/ecs-systems/index.js b/app/ecs-systems/index.js new file mode 100644 index 0000000..61c6535 --- /dev/null +++ b/app/ecs-systems/index.js @@ -0,0 +1,3 @@ +import gather from '@/engine/gather.js'; + +export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'})); diff --git a/app/ecs-systems/update-spatial-hash.js b/app/ecs-systems/update-spatial-hash.js index 6a96a59..c9602f7 100644 --- a/app/ecs-systems/update-spatial-hash.js +++ b/app/ecs-systems/update-spatial-hash.js @@ -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)); diff --git a/app/ecs/arbitrary.test.js b/app/ecs/arbitrary.test.js index ac59d18..7814478 100644 --- a/app/ecs/arbitrary.test.js +++ b/app/ecs/arbitrary.test.js @@ -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({}); diff --git a/app/ecs/base.js b/app/ecs/base.js index 1250325..24f7d37 100644 --- a/app/ecs/base.js +++ b/app/ecs/base.js @@ -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(); - } - } diff --git a/app/ecs/component.js b/app/ecs/component.js deleted file mode 100644 index 285ed0c..0000000 --- a/app/ecs/component.js +++ /dev/null @@ -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}); - }; -} diff --git a/app/ecs/ecs.js b/app/ecs/ecs.js index 33b5cef..c7f27c0 100644 --- a/app/ecs/ecs.js +++ b/app/ecs/ecs.js @@ -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 types = Object.keys(ecs.Types); + const {entities, systems} = decoder.decode(view.buffer); + for (const system of systems) { + ecs.system(system).active = true; } - 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 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 (let i = 0; i < this.$$systems.length; i++) { - this.$$systems[i].finalize(elapsed); + for (const name in this.Systems) { + if (this.Systems[name].active) { + this.Systems[name].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, + }; + } + } diff --git a/app/ecs/ecs.test.js b/app/ecs/ecs.test.js index 231cc7e..a9fc57e 100644 --- a/app/ecs/ecs.test.js +++ b/app/ecs/ecs.test.js @@ -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,33 +133,35 @@ 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 Systems = { + Physics: class Physics extends System { + + static queries() { + return { + default: ['Position', 'Momentum'], + }; + } + + tick(elapsed) { + for (const [position, momentum] of this.select('default')) { + position.x += momentum.x * elapsed; + position.y += momentum.y * elapsed; + position.z += momentum.z * elapsed; + } + } + + }, + } static Types = {Momentum, Position}; } const ecs = new TickEcs(); - class Physics extends System { - - static queries() { - return { - default: ['Position', 'Momentum'], - }; - } - - tick(elapsed) { - for (const [position, momentum] of this.select('default')) { - position.x += momentum.x * elapsed; - position.y += momentum.y * elapsed; - position.z += momentum.z * elapsed; - } - } - - } - ecs.addSystem(Physics); + 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 { - tick() { - this.createManyEntities(Array.from({length: 5}).map(() => [])); - } + 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 { - tick() { - this.createEntity(); - } + 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 { - - finalize() { - entity = ecs.get(1); - } - - tick() { - this.destroyEntity(1); - } - + 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,46 +238,48 @@ 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 { - static queries() { - return { - default: ['Foo'], - }; - } - tick() { - this.insertComponents(1, {Foo: {}}); - } - finalize() { - addLength = Array.from(this.select('default')).length; - } + class TickingSystemEcs extends Ecs { + static Systems = { + AddComponent: class extends System { + static queries() { + return { + default: ['Foo'], + }; + } + tick() { + this.insertComponents(1, {Foo: {}}); + } + finalize() { + addLength = Array.from(this.select('default')).length; + } + }, + RemoveComponent: class extends System { + static queries() { + return { + default: ['Foo'], + }; + } + tick() { + this.removeComponents(1, ['Foo']); + } + finalize() { + removeLength = Array.from(this.select('default')).length; + } + }, + }; + static Types = {Foo: wrapSpecification('Foo', {bar: {type: 'uint8'}})}; } - class RemoveComponent extends System { - static queries() { - return { - default: ['Foo'], - }; - } - tick() { - this.removeComponents(1, ['Foo']); - } - finalize() { - removeLength = Array.from(this.select('default')).length; - } - } - 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; -}); diff --git a/app/ecs/query.test.js b/app/ecs/query.test.js index 565a793..13614f7 100644 --- a/app/ecs/query.test.js +++ b/app/ecs/query.test.js @@ -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]); diff --git a/app/ecs/system.js b/app/ecs/system.js index 55dc8f1..ff9bea2 100644 --- a/app/ecs/system.js +++ b/app/ecs/system.js @@ -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; - } - 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`); + insertComponents(entityId, components) { + this.ecs.insert(entityId, components); + } + + 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(); - } - } diff --git a/app/engine/ecs.js b/app/engine/ecs.js index 89ee425..9ec4c60 100644 --- a/app/engine/ecs.js +++ b/app/engine/ecs.js @@ -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; } diff --git a/app/engine/engine.js b/app/engine/engine.js index c2bd099..72314ce 100644 --- a/app/engine/engine.js +++ b/app/engine/engine.js @@ -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()); diff --git a/app/engine/engine.test.js b/app/engine/engine.test.js index 944b38c..350c7a2 100644 --- a/app/engine/engine.test.js +++ b/app/engine/engine.test.js @@ -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()}); }); diff --git a/app/net/server/worker.js b/app/net/server/worker.js index d953825..c9a48c9 100644 --- a/app/net/server/worker.js +++ b/app/net/server/worker.js @@ -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'})); })(); diff --git a/app/routes/_main-menu.play.$.$/route.jsx b/app/routes/_main-menu.play.$.$/route.jsx index 51ca82b..3dbf0b6 100644 --- a/app/routes/_main-menu.play.$.$/route.jsx +++ b/app/routes/_main-menu.play.$.$/route.jsx @@ -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(); diff --git a/app/session.server.js b/app/session.server.js new file mode 100644 index 0000000..6bd8086 --- /dev/null +++ b/app/session.server.js @@ -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}; +} diff --git a/app/websocket.js b/app/websocket.js index 795ade8..98012e6 100644 --- a/app/websocket.js +++ b/app/websocket.js @@ -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); } } diff --git a/package.json b/package.json index 46b52ed..7bf0748 100644 --- a/package.json +++ b/package.json @@ -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",