Compare commits

..

No commits in common. "c53d716a37e63ff6c0f3bb5fde052bb4ead41082" and "0685243b7b2abbf05060b84a4f8778e7278ee67f" have entirely different histories.

29 changed files with 538 additions and 639 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,7 +0,0 @@
export default (type) => ({
type: 'object',
properties: {
x: {type},
y: {type},
},
});

View File

@ -1,27 +1,3 @@
import Arbitrary from '@/ecs/arbitrary.js';
import Base from '@/ecs/base.js';
import Schema from '@/ecs/schema.js';
import gather from '@/engine/gather.js';
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;
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));

View File

@ -1,20 +1,15 @@
import vector2d from './helpers/vector-2d';
export default {
layers: {
type: 'array',
subtype: {
type: 'object',
properties: {
area: vector2d('float32'),
data: {
type: 'array',
subtype: {
type: 'uint16',
},
},
source: {type: 'string'},
tileSize: vector2d('float32'),
},
},
},

View File

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

View File

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

View File

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

View File

@ -1,17 +1,11 @@
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) {
@ -60,10 +54,6 @@ export default class Base {
}
}
static gathered(id, key) {
this.name = key;
}
insertMany(entities) {
const creating = [];
for (let i = 0; i < entities.length; i++) {
@ -81,9 +71,8 @@ export default class Base {
this.createMany(creating);
}
markChange(entityId, key, value) {
this.ecs.markChange(entityId, {[this.constructor.name]: {[key]: value}})
}
// eslint-disable-next-line no-unused-vars
markChange(entityId, components) {}
mergeDiff(original, update) {
return {...original, ...update};
@ -97,4 +86,13 @@ 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();
}
}

14
app/ecs/component.js Normal file
View File

@ -0,0 +1,14 @@
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,10 +1,7 @@
import Component from './component.js';
import EntityFactory from './entity-factory.js';
import Schema from './schema.js';
import {Encoder, Decoder} from '@msgpack/msgpack';
const decoder = new Decoder();
const encoder = new Encoder();
import System from './system.js';
export default class Ecs {
@ -12,10 +9,6 @@ export default class Ecs {
diff = {};
static Systems = {};
Systems = {};
static Types = {};
Types = {};
@ -24,16 +17,21 @@ export default class Ecs {
$$entityFactory = new EntityFactory();
$$systems = [];
constructor() {
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);
const {Types} = this.constructor;
for (const i in Types) {
this.Types[i] = Component(Types[i]).wrap(i, this);
}
}
addSystem(source) {
const system = System.wrap(source, this);
this.$$systems.push(system);
system.reindex(this.entities);
}
apply(patch) {
const creating = [];
const destroying = [];
@ -90,9 +88,9 @@ export default class Ecs {
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
const componentKeys = [];
for (const name in components) {
if (this.Types[name]) {
componentKeys.push(name);
for (const key of Object.keys(components)) {
if (this.Types[key]) {
componentKeys.push(key);
}
}
entityIds.push(entityId);
@ -117,29 +115,74 @@ export default class Ecs {
}
deindex(entityIds) {
for (const name in this.Systems) {
this.Systems[name].deindex(entityIds);
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].deindex(entityIds);
}
}
static deserialize(view) {
const ecs = new this();
const types = Object.keys(ecs.Types);
const {entities, systems} = decoder.decode(view.buffer);
for (const system of systems) {
ecs.system(system).active = true;
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 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))),
]);
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]);
}
}
ecs.$$caret = max + 1;
ecs.createManySpecific(specifics);
return ecs;
}
@ -229,21 +272,21 @@ export default class Ecs {
// Created?
else if (!this.diff[entityId]) {
const filtered = {};
for (const name in components) {
filtered[name] = false === components[name]
for (const type in components) {
filtered[type] = false === components[type]
? false
: this.Types[name].constructor.filterDefaults(components[name]);
: this.Types[type].constructor.filterDefaults(components[type]);
}
this.diff[entityId] = filtered;
}
// Otherwise, merge.
else {
for (const name in components) {
this.diff[entityId][name] = false === components[name]
for (const type in components) {
this.diff[entityId][type] = false === components[type]
? false
: this.Types[name].mergeDiff(
this.diff[entityId][name] || {},
components[name],
: this.Types[type].mergeDiff(
this.diff[entityId][type] || {},
components[type],
);
}
}
@ -259,8 +302,8 @@ export default class Ecs {
}
reindex(entityIds) {
for (const name in this.Systems) {
this.Systems[name].reindex(entityIds);
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(entityIds);
}
}
@ -290,12 +333,49 @@ export default class Ecs {
this.reindex(unique.values());
}
static serialize(ecs, view) {
const buffer = encoder.encode(ecs.toJSON());
if (!view) {
view = new DataView(new ArrayBuffer(buffer.length));
removeSystem(SystemLike) {
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
if (-1 !== index) {
this.$$systems.splice(index, 1);
}
(new Uint8Array(view.buffer)).set(buffer);
}
static serialize(ecs, view) {
if (!view) {
view = new DataView(new ArrayBuffer(ecs.size()));
}
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);
return view;
}
@ -318,55 +398,31 @@ export default class Ecs {
return size;
}
system(name) {
return this.Systems[name];
system(SystemLike) {
return this.$$systems.find((system) => SystemLike === system.source)
}
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].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 (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();
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]);
}
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,66 +1,41 @@
import {expect, test} from 'vitest';
import {expect, test, vi} from 'vitest';
import Arbitrary from './arbitrary.js';
import Ecs from './ecs.js';
import Schema from './schema.js';
import System from './system.js';
function wrapSpecification(name, specification) {
return class Component extends Arbitrary {
static name = name;
static schema = new Schema({
type: 'object',
properties: specification,
});
};
}
const Empty = {};
const Empty = wrapSpecification('Empty', {});
const Name = wrapSpecification('Name', {
const Name = {
name: {type: 'string'},
});
};
const Position = wrapSpecification('Position', {
const Position = {
x: {type: 'int32', defaultValue: 32},
y: {type: 'int32'},
z: {type: 'int32'},
});
};
test('activates and deactivates systems at runtime', () => {
test('adds and remove systems at runtime', () => {
const ecs = new Ecs();
let oneCount = 0;
let twoCount = 0;
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;
const oneSystem = () => {
oneCount++;
};
ecs.addSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(1);
ecs.system('TwoSystem').active = true;
const twoSystem = () => {
twoCount++;
};
ecs.addSystem(twoSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(1);
ecs.system('OneSystem').active = false;
ecs.removeSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
@ -133,35 +108,33 @@ test('inserts components into entities', () => {
});
test('ticks systems', () => {
const Momentum = wrapSpecification('Momentum', {
const 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();
ecs.system('Physics').active = true;
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);
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1);
@ -174,17 +147,13 @@ test('ticks systems', () => {
});
test('creates many entities when ticking systems', () => {
class TickingSystemEcs extends Ecs {
static Systems = {
Spawn: class extends System {
tick() {
this.createManyEntities(Array.from({length: 5}).map(() => []));
}
},
};
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createManyEntities(Array.from({length: 5}).map(() => []));
}
}
const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(5))
.to.be.undefined;
@ -194,17 +163,13 @@ test('creates many entities when ticking systems', () => {
});
test('creates entities when ticking systems', () => {
class TickingSystemEcs extends Ecs {
static Systems = {
Spawn: class extends System {
tick() {
this.createEntity();
}
},
};
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createEntity();
}
}
const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(2))
.to.be.undefined;
@ -214,21 +179,20 @@ test('creates entities when ticking systems', () => {
});
test('schedules entities to be deleted when ticking systems', () => {
const ecs = new Ecs();
let entity;
class TickingSystemEcs extends Ecs {
static Systems = {
Despawn: class extends System {
finalize() {
entity = ecs.get(1);
}
tick() {
this.destroyEntity(1);
}
},
};
class Despawn extends System {
finalize() {
entity = ecs.get(1);
}
tick() {
this.destroyEntity(1);
}
}
const ecs = new TickingSystemEcs();
ecs.system('Despawn').active = true;
ecs.addSystem(Despawn);
ecs.create();
ecs.tick(1);
expect(entity)
@ -238,48 +202,46 @@ test('schedules entities to be deleted when ticking systems', () => {
});
test('adds components to and remove components from entities when ticking systems', () => {
let addLength, removeLength;
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 TickingEcs extends Ecs {
static Types = {Foo: {bar: {type: 'uint8'}}};
}
const ecs = new TickingSystemEcs();
ecs.system('AddComponent').active = true;
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 RemoveComponent extends System {
static queries() {
return {
default: ['Foo'],
};
}
tick() {
this.removeComponents(1, ['Foo']);
}
finalize() {
removeLength = Array.from(this.select('default')).length;
}
}
ecs.addSystem(AddComponent);
ecs.create();
ecs.tick(1);
expect(addLength)
.to.equal(1);
expect(ecs.get(1).Foo)
.to.not.be.undefined;
ecs.system('AddComponent').active = false;
ecs.system('RemoveComponent').active = true;
ecs.removeSystem(AddComponent);
ecs.addSystem(RemoveComponent);
ecs.tick(1);
expect(removeLength)
.to.equal(0);
@ -450,25 +412,35 @@ 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))
@ -490,9 +462,22 @@ 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,22 +1,11 @@
import {expect, test} from 'vitest';
import Arbitrary from './arbitrary.js';
import Component from './component.js';
import Query from './query.js';
import Schema from './schema.js';
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 A = new (Component({a: {type: 'int32', defaultValue: 420}}));
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
const C = new (Component({c: {type: 'int32'}}));
const Types = {A, B, C};
Types.A.createMany([[2], [3]]);
@ -61,7 +50,7 @@ test('can deindex', () => {
});
test('can reindex', () => {
const Test = new (wrapSpecification('Test', {a: {type: 'int32', defaultValue: 420}}));
const Test = new (Component({a: {type: 'int32', defaultValue: 420}}));
Test.createMany([[2], [3]]);
const query = new Query(['Test'], {Test});
query.reindex([2, 3]);

View File

@ -3,8 +3,6 @@ import Query from './query.js';
export default class System {
active = false;
destroying = [];
ecs;
@ -17,15 +15,6 @@ 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) {
@ -46,12 +35,17 @@ export default class System {
finalize() {}
insertComponents(entityId, components) {
this.ecs.insert(entityId, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
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`);
}
static queries() {
@ -64,14 +58,6 @@ 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();
}
@ -83,4 +69,44 @@ 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,9 +1,7 @@
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,14 +1,43 @@
import {join} from 'node:path';
import {
MOVE_MAP,
RESOLUTION,
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 {
static Ecs = Ecs;
incomingActions = [];
connections = [];
@ -24,7 +53,35 @@ export default class Engine {
server;
constructor(Server) {
this.ecses = {};
const ecs = new this.constructor.Ecs();
const layerSize = {x: Math.ceil(RESOLUTION.x / 4), y: Math.ceil(RESOLUTION.y / 4)};
ecs.create({
AreaSize: {x: RESOLUTION.x * 4, y: RESOLUTION.y * 4},
TileLayers: {
layers: [
{
data: (
Array(layerSize.x * layerSize.y)
.fill(0)
.map(() => 1 + Math.floor(Math.random() * 4))
),
size: layerSize,
}
],
},
});
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);
this.ecses = {
1: ecs,
};
class SilphiusServer extends Server {
accept(connection, packed) {
super.accept(connection, decode(packed));
@ -39,95 +96,24 @@ export default class Engine {
});
}
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);
async connectPlayer(connection) {
this.connections.push(connection);
const entityJson = await this.loadPlayer(connection);
const ecs = this.ecses[entityJson.World.world];
const entity = ecs.create(entityJson);
this.connectedPlayers.set(
connection,
{
entity: ecs.get(entity),
id,
memory: new Set(),
},
);
}
createEcs() {
const ecs = new Ecs();
const area = {x: 100, y: 60};
ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
TileLayers: {
layers: [
{
area,
data: Array(area.x * area.y).fill(0).map(() => 1 + Math.floor(Math.random() * 4)),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
}
],
},
});
const defaultSystems = [
'ControlMovement',
'ApplyMomentum',
'ClampPositions',
'FollowCamera',
'CalculateAabbs',
'UpdateSpatialHash',
'ControlDirection',
'SpriteDirection',
'RunAnimations',
];
defaultSystems.forEach((defaultSystem) => {
ecs.system(defaultSystem).active = true;
});
return ecs;
}
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);
disconnectPlayer(connection) {
const {entity} = this.connectedPlayers.get(connection);
const ecs = this.ecses[entity.World.world];
players[0] = JSON.parse(JSON.stringify(entity.toJSON()));
ecs.destroy(entity.id);
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
@ -136,29 +122,8 @@ export default class Engine {
async load() {
}
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);
async loadPlayer() {
return players[0];
}
start() {
@ -206,8 +171,8 @@ export default class Engine {
const update = {};
const {entity, memory} = this.connectedPlayers.get(connection);
const mainEntityId = entity.id;
const ecs = this.ecses[entity.Ecs.path];
const nearby = ecs.system('UpdateSpatialHash').nearby(entity);
const ecs = this.ecses[entity.World.world];
const nearby = ecs.system(UpdateSpatialHash).nearby(entity);
// Master entity.
nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values());

View File

@ -2,41 +2,28 @@ 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 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'];
const ecs = engine.ecses[1];
// 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(0))
.to.deep.include({2: {MainEntity: {}, ...ecs.get(2).toJSON()}, 3: ecs.get(3).toJSON()});
expect(engine.updateFor(undefined))
.to.deep.include({2: ecs.get(2).toJSON(), 3: {MainEntity: {}, ...ecs.get(3).toJSON()}});
// Tick and get update. Should be a partial update.
engine.tick(1);
expect(engine.updateFor(0))
expect(engine.updateFor(undefined))
.to.deep.include({
3: {
2: {
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
VisibleAabb: {
x0: 1199,
@ -46,11 +33,11 @@ test('visibility-based updates', async () => {
});
// Tick and get update. Should remove the entity.
engine.tick(1);
expect(engine.updateFor(0))
.to.deep.include({3: false});
expect(engine.updateFor(undefined))
.to.deep.include({2: 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(0))
.to.deep.include({3: ecs.get(3).toJSON()});
expect(engine.updateFor(undefined))
.to.deep.include({2: ecs.get(2).toJSON()});
});

View File

@ -1,15 +0,0 @@
import {Assets} from '@pixi/assets';
import {useEffect, useState} from 'react';
export default function useAsset(source) {
const [asset, setAsset] = useState();
useEffect(() => {
if (Assets.cache.has(source)) {
setAsset(Assets.get(source));
}
else {
Assets.load(source).then(setAsset);
}
}, [setAsset, source]);
return asset;
}

View File

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

View File

@ -43,7 +43,10 @@ export default function EcsComponent() {
return (
<Container>
<TileLayer
tileLayer={TileLayers.layers[0]}
size={TileLayers.layers[0].size}
tiles={TileLayers.layers[0].data}
tileset="/assets/tileset.json"
tileSize={{x: 16, y: 16}}
x={-cx}
y={-cy}
/>

View File

@ -1,9 +1,18 @@
import {Assets} from '@pixi/assets';
import {Sprite as PixiSprite} from '@pixi/react';
import useAsset from '@/hooks/use-asset.js';
import {useEffect, useState} from 'react';
export default function Sprite({entity}) {
const asset = useAsset(entity.Sprite.source);
const [asset, setAsset] = useState();
useEffect(() => {
const asset = Assets.get(entity.Sprite.source);
if (asset) {
setAsset(asset);
}
else {
Assets.load(entity.Sprite.source).then(setAsset);
}
}, [setAsset, entity.Sprite.source]);
if (!asset) {
return false;
}

View File

@ -1,33 +1,42 @@
import {useEffect, useState} from 'react';
import {Assets} from '@pixi/assets';
import {PixiComponent} from '@pixi/react';
import '@pixi/spritesheet'; // NECESSARY!
import {CompositeTilemap} from '@pixi/tilemap';
import useAsset from '@/hooks/use-asset.js';
const TileLayerInternal = PixiComponent('TileLayer', {
create: () => new CompositeTilemap(),
applyProps: (tilemap, {tileLayer: oldTileLayer}, props) => {
const {asset, tileLayer, x, y} = props;
const extless = tileLayer.source.slice('/assets/'.length, -'.json'.length);
applyProps: (tilemap, {tiles: oldTiles}, props) => {
const {asset, tiles, tileset, tileSize, size, x, y} = props;
const extless = tileset.slice('/assets/'.length, -'.json'.length);
const {textures} = asset;
tilemap.position.x = x;
tilemap.position.y = y;
if (tileLayer === oldTileLayer) {
if (tiles === oldTiles) {
return;
}
tilemap.clear();
let i = 0;
for (let y = 0; y < tileLayer.area.y; ++y) {
for (let x = 0; x < tileLayer.area.x; ++x) {
tilemap.tile(textures[`${extless}/${tileLayer.data[i++]}`], tileLayer.tileSize.x * x, tileLayer.tileSize.y * y);
for (let y = 0; y < size.y; ++y) {
for (let x = 0; x < size.x; ++x) {
tilemap.tile(textures[`${extless}/${tiles[i++]}`], tileSize.x * x, tileSize.y * y);
}
}
},
})
export default function TileLayer(props) {
const {tileLayer} = props;
const asset = useAsset(tileLayer.source);
const {tileset} = props;
const [asset, setAsset] = useState();
useEffect(() => {
const asset = Assets.get(tileset);
if (asset) {
setAsset(asset);
}
else {
Assets.load(tileset).then(setAsset);
}
}, [setAsset, tileset]);
if (!asset) {
return false;
}
@ -35,6 +44,7 @@ export default function TileLayer(props) {
<TileLayerInternal
{...props}
asset={asset}
tileset={tileset}
/>
);
}
};

View File

@ -5,7 +5,4 @@ html, body {
line-height: 0;
margin: 0;
width: 100%;
* {
line-height: 1;
}
}

View File

@ -1,73 +0,0 @@
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();
const [client, setClient] = useState();
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
const [, url] = params['*'].split('/');
useEffect(() => {
if (!Client) {
return;
}
const client = new Client();
async function connect() {
await client.connect(url);
setClient(client);
}
connect();
return () => {
client.disconnect();
};
}, [Client, url]);
useEffect(() => {
if (!client) {
return;
}
function onConnectionStatus(status) {
switch (status) {
case 'aborted': {
setDisconnected(true);
break;
}
case 'connected': {
setDisconnected(false);
break;
}
}
}
client.addPacketListener('ConnectionStatus', onConnectionStatus);
return () => {
client.removePacketListener('ConnectionStatus', onConnectionStatus);
};
}, [client]);
useEffect(() => {
if (!disconnected) {
return;
}
async function reconnect() {
await client.connect(url);
}
reconnect();
const handle = setInterval(reconnect, 1000);
return () => {
clearInterval(handle);
};
}, [client, disconnected, url]);
return (
<ClientContext.Provider value={client}>
<Ui disconnected={disconnected} />
</ClientContext.Provider>
);
}

View File

@ -1,16 +1,19 @@
import {useEffect, useState} from 'react';
import {Outlet, useParams} from 'react-router-dom';
import {useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import LocalClient from '@/net/client/local.js';
import RemoteClient from '@/net/client/remote.js';
import {decode, encode} from '@/packets/index.js';
import Ui from '@/react-components/ui.jsx';
import styles from './play.module.css';
export default function Play() {
const [Client, setClient] = useState();
export default function Index() {
const [client, setClient] = useState();
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
const [type] = params['*'].split('/');
const [type, url] = params['*'].split('/');
useEffect(() => {
let Client;
switch (type) {
@ -29,11 +32,55 @@ export default function Play() {
super.transmit(encode(packet));
}
}
setClient(() => SilphiusClient);
}, [type]);
const client = new SilphiusClient();
async function connect() {
await client.connect(url);
setClient(client);
}
connect();
return () => {
client.disconnect();
};
}, [type, url]);
useEffect(() => {
if (!client) {
return;
}
function onConnectionStatus(status) {
switch (status) {
case 'aborted': {
setDisconnected(true);
break;
}
case 'connected': {
setDisconnected(false);
break;
}
}
}
client.addPacketListener('ConnectionStatus', onConnectionStatus);
return () => {
client.removePacketListener('ConnectionStatus', onConnectionStatus);
};
}, [client]);
useEffect(() => {
if (!disconnected) {
return;
}
async function reconnect() {
await client.connect(url);
}
reconnect();
const handle = setInterval(reconnect, 1000);
return () => {
clearInterval(handle);
};
}, [client, disconnected, url]);
return (
<div className={styles.play}>
<Outlet context={Client} />
<ClientContext.Provider value={client}>
<Ui disconnected={disconnected} />
</ClientContext.Provider>
</div>
);
}

View File

@ -1,34 +0,0 @@
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,12 +1,7 @@
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,
@ -28,36 +23,19 @@ let engine;
let onConnect;
function createOnConnect(engine) {
onConnect = async (ws, request) => {
ws.on('close', async () => {
await engine.disconnectPlayer(ws);
onConnect = async (ws) => {
ws.on('close', () => {
engine.disconnectPlayer(ws);
})
ws.on('message', (packed) => {
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
});
const session = await getSession(request.headers['cookie']);
await engine.connectPlayer(ws, session.get('id'));
await engine.connectPlayer(ws);
};
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,8 +9,7 @@
"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",
"test": "vitest app"
"storybook:build": "storybook build"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",