Compare commits

...

3 Commits

Author SHA1 Message Date
cha0s
c53d716a37 feat!: server persistence 2024-06-15 16:31:04 -05:00
cha0s
ce62d873bf refactor: play specific 2024-06-14 12:27:07 -05:00
cha0s
6a7aa68002 refactor: engine 2024-06-14 12:05:02 -05:00
29 changed files with 636 additions and 535 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

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

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 { export default class UpdateSpatialHash extends System {
constructor(ecs) {
super(ecs);
const master = ecs.get(1);
this.hash = new SpatialHash(master.AreaSize);
}
deindex(entities) { deindex(entities) {
super.deindex(entities); super.deindex(entities);
for (const id of entities) { for (const id of entities) {
@ -68,6 +62,11 @@ export default class UpdateSpatialHash extends System {
} }
reindex(entities) { reindex(entities) {
for (const id of entities) {
if (1 === id) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
}
super.reindex(entities); super.reindex(entities);
for (const id of entities) { for (const id of entities) {
this.updateHash(this.ecs.get(id)); 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'}}, properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}},
}); });
} }
const Component = new CreatingArbitrary(); const fakeEcs = {markChange() {}};
const Component = new CreatingArbitrary(fakeEcs);
Component.create(1) Component.create(1)
expect(Component.get(1).toJSON()) expect(Component.get(1).toJSON())
.to.deep.equal({}); .to.deep.equal({});

View File

@ -1,11 +1,17 @@
export default class Base { export default class Base {
ecs;
map = []; map = [];
pool = []; pool = [];
static schema; static schema;
constructor(ecs) {
this.ecs = ecs;
}
allocateMany(count) { allocateMany(count) {
const results = []; const results = [];
while (count-- > 0 && this.pool.length > 0) { while (count-- > 0 && this.pool.length > 0) {
@ -54,6 +60,10 @@ export default class Base {
} }
} }
static gathered(id, key) {
this.name = key;
}
insertMany(entities) { insertMany(entities) {
const creating = []; const creating = [];
for (let i = 0; i < entities.length; i++) { for (let i = 0; i < entities.length; i++) {
@ -71,8 +81,9 @@ export default class Base {
this.createMany(creating); this.createMany(creating);
} }
// eslint-disable-next-line no-unused-vars markChange(entityId, key, value) {
markChange(entityId, components) {} this.ecs.markChange(entityId, {[this.constructor.name]: {[key]: value}})
}
mergeDiff(original, update) { mergeDiff(original, update) {
return {...original, ...update}; return {...original, ...update};
@ -86,13 +97,4 @@ export default class Base {
return this.constructor.schema.sizeOf(this.get(entityId)); 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 EntityFactory from './entity-factory.js';
import Schema from './schema.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 { export default class Ecs {
@ -9,6 +12,10 @@ export default class Ecs {
diff = {}; diff = {};
static Systems = {};
Systems = {};
static Types = {}; static Types = {};
Types = {}; Types = {};
@ -17,19 +24,14 @@ export default class Ecs {
$$entityFactory = new EntityFactory(); $$entityFactory = new EntityFactory();
$$systems = [];
constructor() { constructor() {
const {Types} = this.constructor; const {Systems, Types} = this.constructor;
for (const i in Types) { for (const name in Types) {
this.Types[i] = Component(Types[i]).wrap(i, this); 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) { apply(patch) {
@ -88,9 +90,9 @@ export default class Ecs {
for (let i = 0; i < specificsList.length; i++) { for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i]; const [entityId, components] = specificsList[i];
const componentKeys = []; const componentKeys = [];
for (const key of Object.keys(components)) { for (const name in components) {
if (this.Types[key]) { if (this.Types[name]) {
componentKeys.push(key); componentKeys.push(name);
} }
} }
entityIds.push(entityId); entityIds.push(entityId);
@ -115,74 +117,29 @@ export default class Ecs {
} }
deindex(entityIds) { deindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) { for (const name in this.Systems) {
this.$$systems[i].deindex(entityIds); this.Systems[name].deindex(entityIds);
} }
} }
static deserialize(view) { static deserialize(view) {
const ecs = new this(); const ecs = new this();
let cursor = 0; const types = Object.keys(ecs.Types);
const headerComponents = view.getUint16(cursor, true); const {entities, systems} = decoder.decode(view.buffer);
cursor += 2; for (const system of systems) {
const keys = []; ecs.system(system).active = true;
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); const specifics = [];
cursor += 4; let max = 1;
const creating = new Map(); for (const id in entities) {
const updating = new Map(); max = Math.max(max, parseInt(id));
const cursors = new Map(); specifics.push([
for (let i = 0; i < count; ++i) { parseInt(id),
const entityId = view.getUint32(cursor, true); Object.fromEntries(Object.entries(entities[id]).filter(([type]) => types.includes(type))),
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; return ecs;
} }
@ -272,21 +229,21 @@ export default class Ecs {
// Created? // Created?
else if (!this.diff[entityId]) { else if (!this.diff[entityId]) {
const filtered = {}; const filtered = {};
for (const type in components) { for (const name in components) {
filtered[type] = false === components[type] filtered[name] = false === components[name]
? false ? false
: this.Types[type].constructor.filterDefaults(components[type]); : this.Types[name].constructor.filterDefaults(components[name]);
} }
this.diff[entityId] = filtered; this.diff[entityId] = filtered;
} }
// Otherwise, merge. // Otherwise, merge.
else { else {
for (const type in components) { for (const name in components) {
this.diff[entityId][type] = false === components[type] this.diff[entityId][name] = false === components[name]
? false ? false
: this.Types[type].mergeDiff( : this.Types[name].mergeDiff(
this.diff[entityId][type] || {}, this.diff[entityId][name] || {},
components[type], components[name],
); );
} }
} }
@ -302,8 +259,8 @@ export default class Ecs {
} }
reindex(entityIds) { reindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) { for (const name in this.Systems) {
this.$$systems[i].reindex(entityIds); this.Systems[name].reindex(entityIds);
} }
} }
@ -333,49 +290,12 @@ export default class Ecs {
this.reindex(unique.values()); 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) { static serialize(ecs, view) {
const buffer = encoder.encode(ecs.toJSON());
if (!view) { if (!view) {
view = new DataView(new ArrayBuffer(ecs.size())); view = new DataView(new ArrayBuffer(buffer.length));
} }
let cursor = 0; (new Uint8Array(view.buffer)).set(buffer);
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; return view;
} }
@ -398,31 +318,55 @@ export default class Ecs {
return size; return size;
} }
system(SystemLike) { system(name) {
return this.$$systems.find((system) => SystemLike === system.source) return this.Systems[name];
} }
tick(elapsed) { tick(elapsed) {
for (let i = 0; i < this.$$systems.length; i++) { for (const name in this.Systems) {
this.$$systems[i].tick(elapsed); if (this.Systems[name].active) {
this.Systems[name].tick(elapsed);
}
} }
for (let i = 0; i < this.$$systems.length; i++) { for (const name in this.Systems) {
this.$$systems[i].finalize(elapsed); if (this.Systems[name].active) {
this.Systems[name].finalize(elapsed);
}
} }
this.tickDestruction(); this.tickDestruction();
} }
tickDestruction() { tickDestruction() {
const unique = new Set(); const unique = new Set();
for (let i = 0; i < this.$$systems.length; i++) { for (const name in this.Systems) {
for (let j = 0; j < this.$$systems[i].destroying.length; j++) { const System = this.Systems[name];
unique.add(this.$$systems[i].destroying[j]); 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) { if (unique.size > 0) {
this.destroyMany(unique.values()); 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 Ecs from './ecs.js';
import Schema from './schema.js';
import System from './system.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'}, name: {type: 'string'},
}; });
const Position = { const Position = wrapSpecification('Position', {
x: {type: 'int32', defaultValue: 32}, x: {type: 'int32', defaultValue: 32},
y: {type: 'int32'}, y: {type: 'int32'},
z: {type: 'int32'}, z: {type: 'int32'},
}; });
test('adds and remove systems at runtime', () => { test('activates and deactivates systems at runtime', () => {
const ecs = new Ecs();
let oneCount = 0; let oneCount = 0;
let twoCount = 0; let twoCount = 0;
const oneSystem = () => { class SystemToggle extends Ecs {
oneCount++; static Systems = {
}; OneSystem: class extends System {
ecs.addSystem(oneSystem); 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(); ecs.tick();
expect(oneCount) expect(oneCount)
.to.equal(1); .to.equal(1);
const twoSystem = () => { ecs.system('TwoSystem').active = true;
twoCount++;
};
ecs.addSystem(twoSystem);
ecs.tick(); ecs.tick();
expect(oneCount) expect(oneCount)
.to.equal(2); .to.equal(2);
expect(twoCount) expect(twoCount)
.to.equal(1); .to.equal(1);
ecs.removeSystem(oneSystem); ecs.system('OneSystem').active = false;
ecs.tick(); ecs.tick();
expect(oneCount) expect(oneCount)
.to.equal(2); .to.equal(2);
@ -108,33 +133,35 @@ test('inserts components into entities', () => {
}); });
test('ticks systems', () => { test('ticks systems', () => {
const Momentum = { const Momentum = wrapSpecification('Momentum', {
x: {type: 'int32'}, x: {type: 'int32'},
y: {type: 'int32'}, y: {type: 'int32'},
z: {type: 'int32'}, z: {type: 'int32'},
}; });
class TickEcs extends Ecs { 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}; static Types = {Momentum, Position};
} }
const ecs = new TickEcs(); const ecs = new TickEcs();
class Physics extends System { ecs.system('Physics').active = true;
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 entity = ecs.create({Momentum: {}, Position: {y: 128}});
const position = JSON.stringify(ecs.get(entity).Position); const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1); ecs.tick(1);
@ -147,13 +174,17 @@ test('ticks systems', () => {
}); });
test('creates many entities when ticking systems', () => { test('creates many entities when ticking systems', () => {
const ecs = new Ecs(); class TickingSystemEcs extends Ecs {
class Spawn extends System { static Systems = {
tick() { Spawn: class extends System {
this.createManyEntities(Array.from({length: 5}).map(() => [])); tick() {
} this.createManyEntities(Array.from({length: 5}).map(() => []));
}
},
};
} }
ecs.addSystem(Spawn); const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.create(); ecs.create();
expect(ecs.get(5)) expect(ecs.get(5))
.to.be.undefined; .to.be.undefined;
@ -163,13 +194,17 @@ test('creates many entities when ticking systems', () => {
}); });
test('creates entities when ticking systems', () => { test('creates entities when ticking systems', () => {
const ecs = new Ecs(); class TickingSystemEcs extends Ecs {
class Spawn extends System { static Systems = {
tick() { Spawn: class extends System {
this.createEntity(); tick() {
} this.createEntity();
}
},
};
} }
ecs.addSystem(Spawn); const ecs = new TickingSystemEcs();
ecs.system('Spawn').active = true;
ecs.create(); ecs.create();
expect(ecs.get(2)) expect(ecs.get(2))
.to.be.undefined; .to.be.undefined;
@ -179,20 +214,21 @@ test('creates entities when ticking systems', () => {
}); });
test('schedules entities to be deleted when ticking systems', () => { test('schedules entities to be deleted when ticking systems', () => {
const ecs = new Ecs();
let entity; let entity;
class Despawn extends System { class TickingSystemEcs extends Ecs {
static Systems = {
finalize() { Despawn: class extends System {
entity = ecs.get(1); finalize() {
} entity = ecs.get(1);
}
tick() { tick() {
this.destroyEntity(1); this.destroyEntity(1);
} }
},
};
} }
ecs.addSystem(Despawn); const ecs = new TickingSystemEcs();
ecs.system('Despawn').active = true;
ecs.create(); ecs.create();
ecs.tick(1); ecs.tick(1);
expect(entity) 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', () => { 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; let addLength, removeLength;
class AddComponent extends System { class TickingSystemEcs extends Ecs {
static queries() { static Systems = {
return { AddComponent: class extends System {
default: ['Foo'], static queries() {
}; return {
} default: ['Foo'],
tick() { };
this.insertComponents(1, {Foo: {}}); }
} tick() {
finalize() { this.insertComponents(1, {Foo: {}});
addLength = Array.from(this.select('default')).length; }
} 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 { const ecs = new TickingSystemEcs();
static queries() { ecs.system('AddComponent').active = true;
return {
default: ['Foo'],
};
}
tick() {
this.removeComponents(1, ['Foo']);
}
finalize() {
removeLength = Array.from(this.select('default')).length;
}
}
ecs.addSystem(AddComponent);
ecs.create(); ecs.create();
ecs.tick(1); ecs.tick(1);
expect(addLength) expect(addLength)
.to.equal(1); .to.equal(1);
expect(ecs.get(1).Foo) expect(ecs.get(1).Foo)
.to.not.be.undefined; .to.not.be.undefined;
ecs.removeSystem(AddComponent); ecs.system('AddComponent').active = false;
ecs.addSystem(RemoveComponent); ecs.system('RemoveComponent').active = true;
ecs.tick(1); ecs.tick(1);
expect(removeLength) expect(removeLength)
.to.equal(0); .to.equal(0);
@ -412,35 +450,25 @@ test('serializes and deserializes', () => {
class SerializingEcs extends Ecs { class SerializingEcs extends Ecs {
static Types = {Empty, Name, Position}; static Types = {Empty, Name, Position};
} }
// # of components + strings (Empty, Name, Position)
// 2 + 4 + 5 + 4 + 4 + 4 + 8 = 31
const ecs = new SerializingEcs(); 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}}); 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}}); 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); 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); const deserialized = SerializingEcs.deserialize(view);
// # of entities.
expect(Array.from(deserialized.entities).length) expect(Array.from(deserialized.entities).length)
.to.equal(2); .to.equal(2);
// Composition of entities.
expect(deserialized.get(1).constructor.types) expect(deserialized.get(1).constructor.types)
.to.deep.equal(['Empty', 'Position']); .to.deep.equal(['Empty', 'Position']);
expect(deserialized.get(16).constructor.types) expect(deserialized.get(16).constructor.types)
.to.deep.equal(['Name', 'Position']); .to.deep.equal(['Name', 'Position']);
// Entity values.
expect(JSON.stringify(deserialized.get(1))) expect(JSON.stringify(deserialized.get(1)))
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}})) .to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
expect(JSON.stringify(deserialized.get(1).Position)) 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(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}}); ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = SerializingEcs.serialize(ecs); const view = SerializingEcs.serialize(ecs);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const deserialized = DeserializingEcs.deserialize(view); const deserialized = DeserializingEcs.deserialize(view);
expect(consoleErrorSpy.mock.calls.length)
.to.equal(2);
consoleErrorSpy.mockRestore();
expect(deserialized.get(1).toJSON()) expect(deserialized.get(1).toJSON())
.to.deep.equal({Empty: {}}); .to.deep.equal({Empty: {}});
expect(deserialized.get(16).toJSON()) expect(deserialized.get(16).toJSON())
.to.deep.equal({Name: {name: 'foobar'}}); .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 {expect, test} from 'vitest';
import Component from './component.js'; import Arbitrary from './arbitrary.js';
import Query from './query.js'; import Query from './query.js';
import Schema from './schema.js';
const A = new (Component({a: {type: 'int32', defaultValue: 420}})); function wrapSpecification(name, specification) {
const B = new (Component({b: {type: 'int32', defaultValue: 69}})); return class Component extends Arbitrary {
const C = new (Component({c: {type: 'int32'}})); 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}; const Types = {A, B, C};
Types.A.createMany([[2], [3]]); Types.A.createMany([[2], [3]]);
@ -50,7 +61,7 @@ test('can deindex', () => {
}); });
test('can reindex', () => { 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]]); Test.createMany([[2], [3]]);
const query = new Query(['Test'], {Test}); const query = new Query(['Test'], {Test});
query.reindex([2, 3]); query.reindex([2, 3]);

View File

@ -3,6 +3,8 @@ import Query from './query.js';
export default class System { export default class System {
active = false;
destroying = []; destroying = [];
ecs; ecs;
@ -15,6 +17,15 @@ export default class System {
for (const i in queries) { for (const i in queries) {
this.queries[i] = new Query(queries[i], ecs.Types); 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) { deindex(entityIds) {
@ -35,17 +46,12 @@ export default class System {
finalize() {} finalize() {}
static normalize(SystemLike) { insertComponents(entityId, components) {
if (SystemLike.prototype instanceof System) { this.ecs.insert(entityId, components);
return SystemLike; }
}
if ('function' === typeof SystemLike) { insertManyComponents(components) {
class TickingSystem extends System {} this.ecs.insertMany(components);
TickingSystem.prototype.tick = SystemLike;
return TickingSystem;
}
/* v8 ignore next */
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
} }
static queries() { 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) { select(query) {
return this.queries[query].select(); return this.queries[query].select();
} }
@ -69,44 +83,4 @@ export default class System {
tick() {} 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 Ecs from '@/ecs/ecs.js';
import Systems from '@/ecs-systems/index.js';
import Types from '@/ecs-components/index.js'; import Types from '@/ecs-components/index.js';
class EngineEcs extends Ecs { class EngineEcs extends Ecs {
static Systems = Systems;
static Types = Types; static Types = Types;
} }

View File

@ -1,43 +1,14 @@
import {join} from 'node:path';
import { import {
MOVE_MAP, MOVE_MAP,
RESOLUTION,
TPS, TPS,
} from '@/constants.js'; } 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 Ecs from '@/engine/ecs.js';
import {decode, encode} from '@/packets/index.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 { export default class Engine {
static Ecs = Ecs;
incomingActions = []; incomingActions = [];
connections = []; connections = [];
@ -53,35 +24,7 @@ export default class Engine {
server; server;
constructor(Server) { constructor(Server) {
const ecs = new this.constructor.Ecs(); this.ecses = {};
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 { class SilphiusServer extends Server {
accept(connection, packed) { accept(connection, packed) {
super.accept(connection, decode(packed)); super.accept(connection, decode(packed));
@ -96,24 +39,95 @@ export default class Engine {
}); });
} }
async connectPlayer(connection) { async connectPlayer(connection, id) {
this.connections.push(connection); const entityJson = await this.loadPlayer(id);
const entityJson = await this.loadPlayer(connection); if (!this.ecses[entityJson.Ecs.path]) {
const ecs = this.ecses[entityJson.World.world]; await this.loadEcs(entityJson.Ecs.path);
}
const ecs = this.ecses[entityJson.Ecs.path];
const entity = ecs.create(entityJson); const entity = ecs.create(entityJson);
this.connections.push(connection);
this.connectedPlayers.set( this.connectedPlayers.set(
connection, connection,
{ {
entity: ecs.get(entity), entity: ecs.get(entity),
id,
memory: new Set(), memory: new Set(),
}, },
); );
} }
disconnectPlayer(connection) { createEcs() {
const {entity} = this.connectedPlayers.get(connection); const ecs = new Ecs();
const ecs = this.ecses[entity.World.world]; const area = {x: 100, y: 60};
players[0] = JSON.parse(JSON.stringify(entity.toJSON())); 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);
ecs.destroy(entity.id); ecs.destroy(entity.id);
this.connectedPlayers.delete(connection); this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1); this.connections.splice(this.connections.indexOf(connection), 1);
@ -122,8 +136,29 @@ export default class Engine {
async load() { async load() {
} }
async loadPlayer() { async loadEcs(path) {
return players[0]; 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() { start() {
@ -171,8 +206,8 @@ export default class Engine {
const update = {}; const update = {};
const {entity, memory} = this.connectedPlayers.get(connection); const {entity, memory} = this.connectedPlayers.get(connection);
const mainEntityId = entity.id; const mainEntityId = entity.id;
const ecs = this.ecses[entity.World.world]; const ecs = this.ecses[entity.Ecs.path];
const nearby = ecs.system(UpdateSpatialHash).nearby(entity); const nearby = ecs.system('UpdateSpatialHash').nearby(entity);
// Master entity. // Master entity.
nearby.add(ecs.get(1)); nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values()); const lastMemory = new Set(memory.values());

View File

@ -2,28 +2,41 @@ import {expect, test} from 'vitest';
import {RESOLUTION} from '@/constants.js' import {RESOLUTION} from '@/constants.js'
import Server from '@/net/server/server.js'; import Server from '@/net/server/server.js';
import Engine from './engine.js'; import Engine from './engine.js';
test('visibility-based updates', async () => { test('visibility-based updates', async () => {
const engine = new Engine(Server); 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. // Create an entity.
const entity = ecs.get(ecs.create({ const entity = ecs.get(ecs.create({
Momentum: {x: 1, y: 0}, Momentum: {x: 1, y: 0},
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20}, Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
VisibleAabb: {}, VisibleAabb: {},
})); }));
// Connect an entity.
await engine.connectPlayer(undefined);
// Tick and get update. Should be a full update. // Tick and get update. Should be a full update.
engine.tick(1); engine.tick(1);
expect(engine.updateFor(undefined)) expect(engine.updateFor(0))
.to.deep.include({2: ecs.get(2).toJSON(), 3: {MainEntity: {}, ...ecs.get(3).toJSON()}}); .to.deep.include({2: {MainEntity: {}, ...ecs.get(2).toJSON()}, 3: ecs.get(3).toJSON()});
// Tick and get update. Should be a partial update. // Tick and get update. Should be a partial update.
engine.tick(1); engine.tick(1);
expect(engine.updateFor(undefined)) expect(engine.updateFor(0))
.to.deep.include({ .to.deep.include({
2: { 3: {
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1}, Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
VisibleAabb: { VisibleAabb: {
x0: 1199, x0: 1199,
@ -33,11 +46,11 @@ test('visibility-based updates', async () => {
}); });
// Tick and get update. Should remove the entity. // Tick and get update. Should remove the entity.
engine.tick(1); engine.tick(1);
expect(engine.updateFor(undefined)) expect(engine.updateFor(0))
.to.deep.include({2: false}); .to.deep.include({3: false});
// Aim back toward visible area and tick. Should be a full update for that entity. // Aim back toward visible area and tick. Should be a full update for that entity.
entity.Momentum.x = -1; entity.Momentum.x = -1;
engine.tick(1); engine.tick(1);
expect(engine.updateFor(undefined)) expect(engine.updateFor(0))
.to.deep.include({2: ecs.get(2).toJSON()}); .to.deep.include({3: ecs.get(3).toJSON()});
}); });

15
app/hooks/use-asset.js Normal file
View File

@ -0,0 +1,15 @@
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 () => { (async () => {
await engine.load(); await engine.load();
engine.start(); engine.start();
await engine.connectPlayer(undefined); await engine.connectPlayer();
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'})); postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
})(); })();

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,73 @@
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,19 +1,16 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useParams} from 'react-router-dom'; import {Outlet, useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import LocalClient from '@/net/client/local.js'; import LocalClient from '@/net/client/local.js';
import RemoteClient from '@/net/client/remote.js'; import RemoteClient from '@/net/client/remote.js';
import {decode, encode} from '@/packets/index.js'; import {decode, encode} from '@/packets/index.js';
import Ui from '@/react-components/ui.jsx';
import styles from './play.module.css'; import styles from './play.module.css';
export default function Index() { export default function Play() {
const [client, setClient] = useState(); const [Client, setClient] = useState();
const [disconnected, setDisconnected] = useState(false);
const params = useParams(); const params = useParams();
const [type, url] = params['*'].split('/'); const [type] = params['*'].split('/');
useEffect(() => { useEffect(() => {
let Client; let Client;
switch (type) { switch (type) {
@ -32,55 +29,11 @@ export default function Index() {
super.transmit(encode(packet)); super.transmit(encode(packet));
} }
} }
const client = new SilphiusClient(); setClient(() => SilphiusClient);
async function connect() { }, [type]);
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 ( return (
<div className={styles.play}> <div className={styles.play}>
<ClientContext.Provider value={client}> <Outlet context={Client} />
<Ui disconnected={disconnected} />
</ClientContext.Provider>
</div> </div>
); );
} }

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 {WebSocketServer} from 'ws';
import Server from '@/net/server/server.js';
import {getSession} from '@/session.server.js';
import Engine from './engine/engine.js'; import Engine from './engine/engine.js';
import Server from './net/server/server.js';
const wss = new WebSocketServer({ const wss = new WebSocketServer({
noServer: true, noServer: true,
@ -23,19 +28,36 @@ let engine;
let onConnect; let onConnect;
function createOnConnect(engine) { function createOnConnect(engine) {
onConnect = async (ws) => { onConnect = async (ws, request) => {
ws.on('close', () => { ws.on('close', async () => {
engine.disconnectPlayer(ws); await engine.disconnectPlayer(ws);
}) })
ws.on('message', (packed) => { ws.on('message', (packed) => {
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length)); 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); wss.on('connection', onConnect);
} }
class SocketServer extends Server { 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); } transmit(ws, packed) { ws.send(packed); }
} }

View File

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