Compare commits
3 Commits
0685243b7b
...
c53d716a37
Author | SHA1 | Date | |
---|---|---|---|
|
c53d716a37 | ||
|
ce62d873bf | ||
|
6a7aa68002 |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
node_modules
|
||||
|
||||
/app/data
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
|
3
app/ecs-components/ecs.js
Normal file
3
app/ecs-components/ecs.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
path: {type: 'string'},
|
||||
}
|
7
app/ecs-components/helpers/vector-2d.js
Normal file
7
app/ecs-components/helpers/vector-2d.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default (type) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: {type},
|
||||
y: {type},
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
world: {type: 'uint16'},
|
||||
}
|
3
app/ecs-systems/index.js
Normal file
3
app/ecs-systems/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import gather from '@/engine/gather.js';
|
||||
|
||||
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
|
|
@ -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));
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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});
|
||||
};
|
||||
}
|
216
app/ecs/ecs.js
216
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 count = view.getUint32(cursor, true);
|
||||
cursor += 4;
|
||||
const creating = new Map();
|
||||
const updating = new Map();
|
||||
const cursors = new Map();
|
||||
for (let i = 0; i < count; ++i) {
|
||||
const entityId = view.getUint32(cursor, true);
|
||||
if (!ecs.$$entities[entityId]) {
|
||||
creating.set(entityId, {});
|
||||
}
|
||||
cursor += 4;
|
||||
const componentCount = view.getUint16(cursor, true);
|
||||
cursor += 2;
|
||||
cursors.set(entityId, {});
|
||||
const addedComponents = [];
|
||||
for (let j = 0; j < componentCount; ++j) {
|
||||
const componentId = view.getUint16(cursor, true);
|
||||
cursor += 2;
|
||||
const component = keys[componentId];
|
||||
const componentSize = view.getUint32(cursor, true);
|
||||
cursor += 4;
|
||||
if (!this.Types[component]) {
|
||||
console.error(`can't deserialize nonexistent component ${component}`);
|
||||
cursor += componentSize;
|
||||
continue;
|
||||
}
|
||||
if (!ecs.$$entities[entityId]) {
|
||||
creating.get(entityId)[component] = false;
|
||||
}
|
||||
else if (!ecs.$$entities[entityId].constructor.types.includes(component)) {
|
||||
addedComponents.push(component);
|
||||
if (!updating.has(component)) {
|
||||
updating.set(component, []);
|
||||
}
|
||||
updating.get(component).push([entityId, false]);
|
||||
}
|
||||
cursors.get(entityId)[component] = cursor;
|
||||
cursor += componentSize;
|
||||
}
|
||||
if (addedComponents.length > 0 && ecs.$$entities[entityId]) {
|
||||
ecs.rebuild(entityId, (types) => types.concat(addedComponents));
|
||||
}
|
||||
}
|
||||
ecs.createManySpecific(Array.from(creating.entries()));
|
||||
for (const [component, entityIds] of updating) {
|
||||
ecs.Types[component].createMany(entityIds);
|
||||
}
|
||||
for (const [entityId, components] of cursors) {
|
||||
for (const component in components) {
|
||||
ecs.Types[component].deserialize(entityId, view, components[component]);
|
||||
const types = Object.keys(ecs.Types);
|
||||
const {entities, systems} = decoder.decode(view.buffer);
|
||||
for (const system of systems) {
|
||||
ecs.system(system).active = true;
|
||||
}
|
||||
const specifics = [];
|
||||
let max = 1;
|
||||
for (const id in entities) {
|
||||
max = Math.max(max, parseInt(id));
|
||||
specifics.push([
|
||||
parseInt(id),
|
||||
Object.fromEntries(Object.entries(entities[id]).filter(([type]) => types.includes(type))),
|
||||
]);
|
||||
}
|
||||
ecs.$$caret = max + 1;
|
||||
ecs.createManySpecific(specifics);
|
||||
return ecs;
|
||||
}
|
||||
|
||||
|
@ -272,21 +229,21 @@ export default class Ecs {
|
|||
// Created?
|
||||
else if (!this.diff[entityId]) {
|
||||
const filtered = {};
|
||||
for (const type in components) {
|
||||
filtered[type] = false === components[type]
|
||||
for (const name in components) {
|
||||
filtered[name] = false === components[name]
|
||||
? false
|
||||
: this.Types[type].constructor.filterDefaults(components[type]);
|
||||
: this.Types[name].constructor.filterDefaults(components[name]);
|
||||
}
|
||||
this.diff[entityId] = filtered;
|
||||
}
|
||||
// Otherwise, merge.
|
||||
else {
|
||||
for (const type in components) {
|
||||
this.diff[entityId][type] = false === components[type]
|
||||
for (const name in components) {
|
||||
this.diff[entityId][name] = false === components[name]
|
||||
? false
|
||||
: this.Types[type].mergeDiff(
|
||||
this.diff[entityId][type] || {},
|
||||
components[type],
|
||||
: this.Types[name].mergeDiff(
|
||||
this.diff[entityId][name] || {},
|
||||
components[name],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -302,8 +259,8 @@ export default class Ecs {
|
|||
}
|
||||
|
||||
reindex(entityIds) {
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].reindex(entityIds);
|
||||
for (const name in this.Systems) {
|
||||
this.Systems[name].reindex(entityIds);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -333,49 +290,12 @@ export default class Ecs {
|
|||
this.reindex(unique.values());
|
||||
}
|
||||
|
||||
removeSystem(SystemLike) {
|
||||
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
|
||||
if (-1 !== index) {
|
||||
this.$$systems.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
static serialize(ecs, view) {
|
||||
const buffer = encoder.encode(ecs.toJSON());
|
||||
if (!view) {
|
||||
view = new DataView(new ArrayBuffer(ecs.size()));
|
||||
view = new DataView(new ArrayBuffer(buffer.length));
|
||||
}
|
||||
let cursor = 0;
|
||||
const keys = Object.keys(ecs.Types);
|
||||
view.setUint16(cursor, keys.length, true);
|
||||
cursor += 2;
|
||||
for (const i in keys) {
|
||||
cursor += Schema.serialize(keys[i], view, cursor, {type: 'string'});
|
||||
}
|
||||
const entitiesWrittenAt = cursor;
|
||||
let entitiesWritten = 0;
|
||||
cursor += 4;
|
||||
for (const entityId of ecs.entities) {
|
||||
const entity = ecs.get(entityId);
|
||||
entitiesWritten += 1;
|
||||
view.setUint32(cursor, entityId, true);
|
||||
cursor += 4;
|
||||
const entityComponents = entity.constructor.types;
|
||||
view.setUint16(cursor, entityComponents.length, true);
|
||||
const componentsWrittenAt = cursor;
|
||||
cursor += 2;
|
||||
for (const component of entityComponents) {
|
||||
const instance = ecs.Types[component];
|
||||
view.setUint16(cursor, keys.indexOf(component), true);
|
||||
cursor += 2;
|
||||
const sizeOf = instance.sizeOf(entityId);
|
||||
view.setUint32(cursor, sizeOf, true);
|
||||
cursor += 4;
|
||||
instance.serialize(entityId, view, cursor);
|
||||
cursor += sizeOf;
|
||||
}
|
||||
view.setUint16(componentsWrittenAt, entityComponents.length, true);
|
||||
}
|
||||
view.setUint32(entitiesWrittenAt, entitiesWritten, true);
|
||||
(new Uint8Array(view.buffer)).set(buffer);
|
||||
return view;
|
||||
}
|
||||
|
||||
|
@ -398,31 +318,55 @@ export default class Ecs {
|
|||
return size;
|
||||
}
|
||||
|
||||
system(SystemLike) {
|
||||
return this.$$systems.find((system) => SystemLike === system.source)
|
||||
system(name) {
|
||||
return this.Systems[name];
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].tick(elapsed);
|
||||
for (const name in this.Systems) {
|
||||
if (this.Systems[name].active) {
|
||||
this.Systems[name].tick(elapsed);
|
||||
}
|
||||
}
|
||||
for (const name in this.Systems) {
|
||||
if (this.Systems[name].active) {
|
||||
this.Systems[name].finalize(elapsed);
|
||||
}
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].finalize(elapsed);
|
||||
}
|
||||
this.tickDestruction();
|
||||
}
|
||||
|
||||
tickDestruction() {
|
||||
const unique = new Set();
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
for (let j = 0; j < this.$$systems[i].destroying.length; j++) {
|
||||
unique.add(this.$$systems[i].destroying[j]);
|
||||
for (const name in this.Systems) {
|
||||
const System = this.Systems[name];
|
||||
if (System.active) {
|
||||
for (let j = 0; j < System.destroying.length; j++) {
|
||||
unique.add(System.destroying[j]);
|
||||
}
|
||||
System.tickDestruction();
|
||||
}
|
||||
this.$$systems[i].tickDestruction();
|
||||
}
|
||||
if (unique.size > 0) {
|
||||
this.destroyMany(unique.values());
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const entities = {};
|
||||
for (const id in this.$$entities) {
|
||||
entities[id] = this.$$entities[id].toJSON();
|
||||
}
|
||||
const systems = [];
|
||||
for (const name in this.Systems) {
|
||||
if (this.Systems[name].active) {
|
||||
systems.push(name);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entities,
|
||||
systems,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
const Name = {
|
||||
name: {type: 'string'},
|
||||
function wrapSpecification(name, specification) {
|
||||
return class Component extends Arbitrary {
|
||||
static name = name;
|
||||
static schema = new Schema({
|
||||
type: 'object',
|
||||
properties: specification,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const Position = {
|
||||
const Empty = wrapSpecification('Empty', {});
|
||||
|
||||
const Name = wrapSpecification('Name', {
|
||||
name: {type: 'string'},
|
||||
});
|
||||
|
||||
const Position = wrapSpecification('Position', {
|
||||
x: {type: 'int32', defaultValue: 32},
|
||||
y: {type: 'int32'},
|
||||
z: {type: 'int32'},
|
||||
};
|
||||
});
|
||||
|
||||
test('adds and remove systems at runtime', () => {
|
||||
const ecs = new Ecs();
|
||||
test('activates and deactivates systems at runtime', () => {
|
||||
let oneCount = 0;
|
||||
let twoCount = 0;
|
||||
const oneSystem = () => {
|
||||
oneCount++;
|
||||
};
|
||||
ecs.addSystem(oneSystem);
|
||||
class SystemToggle extends Ecs {
|
||||
static Systems = {
|
||||
OneSystem: class extends System {
|
||||
tick() {
|
||||
oneCount += 1;
|
||||
}
|
||||
},
|
||||
TwoSystem: class extends System {
|
||||
tick() {
|
||||
twoCount += 1;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
const ecs = new SystemToggle();
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(0);
|
||||
expect(twoCount)
|
||||
.to.equal(0);
|
||||
ecs.system('OneSystem').active = true;
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(1);
|
||||
const twoSystem = () => {
|
||||
twoCount++;
|
||||
};
|
||||
ecs.addSystem(twoSystem);
|
||||
ecs.system('TwoSystem').active = true;
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(2);
|
||||
expect(twoCount)
|
||||
.to.equal(1);
|
||||
ecs.removeSystem(oneSystem);
|
||||
ecs.system('OneSystem').active = false;
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(2);
|
||||
|
@ -108,16 +133,14 @@ test('inserts components into entities', () => {
|
|||
});
|
||||
|
||||
test('ticks systems', () => {
|
||||
const Momentum = {
|
||||
const Momentum = wrapSpecification('Momentum', {
|
||||
x: {type: 'int32'},
|
||||
y: {type: 'int32'},
|
||||
z: {type: 'int32'},
|
||||
};
|
||||
});
|
||||
class TickEcs extends Ecs {
|
||||
static Types = {Momentum, Position};
|
||||
}
|
||||
const ecs = new TickEcs();
|
||||
class Physics extends System {
|
||||
static Systems = {
|
||||
Physics: class Physics extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
|
@ -133,8 +156,12 @@ test('ticks systems', () => {
|
|||
}
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
ecs.addSystem(Physics);
|
||||
static Types = {Momentum, Position};
|
||||
}
|
||||
const ecs = new TickEcs();
|
||||
ecs.system('Physics').active = true;
|
||||
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
|
||||
const position = JSON.stringify(ecs.get(entity).Position);
|
||||
ecs.tick(1);
|
||||
|
@ -147,13 +174,17 @@ test('ticks systems', () => {
|
|||
});
|
||||
|
||||
test('creates many entities when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
class Spawn extends System {
|
||||
class TickingSystemEcs extends Ecs {
|
||||
static Systems = {
|
||||
Spawn: class extends System {
|
||||
tick() {
|
||||
this.createManyEntities(Array.from({length: 5}).map(() => []));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
ecs.addSystem(Spawn);
|
||||
const ecs = new TickingSystemEcs();
|
||||
ecs.system('Spawn').active = true;
|
||||
ecs.create();
|
||||
expect(ecs.get(5))
|
||||
.to.be.undefined;
|
||||
|
@ -163,13 +194,17 @@ test('creates many entities when ticking systems', () => {
|
|||
});
|
||||
|
||||
test('creates entities when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
class Spawn extends System {
|
||||
class TickingSystemEcs extends Ecs {
|
||||
static Systems = {
|
||||
Spawn: class extends System {
|
||||
tick() {
|
||||
this.createEntity();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
ecs.addSystem(Spawn);
|
||||
const ecs = new TickingSystemEcs();
|
||||
ecs.system('Spawn').active = true;
|
||||
ecs.create();
|
||||
expect(ecs.get(2))
|
||||
.to.be.undefined;
|
||||
|
@ -179,20 +214,21 @@ test('creates entities when ticking systems', () => {
|
|||
});
|
||||
|
||||
test('schedules entities to be deleted when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
let entity;
|
||||
class Despawn extends System {
|
||||
|
||||
class TickingSystemEcs extends Ecs {
|
||||
static Systems = {
|
||||
Despawn: class extends System {
|
||||
finalize() {
|
||||
entity = ecs.get(1);
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.destroyEntity(1);
|
||||
}
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
ecs.addSystem(Despawn);
|
||||
const ecs = new TickingSystemEcs();
|
||||
ecs.system('Despawn').active = true;
|
||||
ecs.create();
|
||||
ecs.tick(1);
|
||||
expect(entity)
|
||||
|
@ -202,12 +238,10 @@ test('schedules entities to be deleted when ticking systems', () => {
|
|||
});
|
||||
|
||||
test('adds components to and remove components from entities when ticking systems', () => {
|
||||
class TickingEcs extends Ecs {
|
||||
static Types = {Foo: {bar: {type: 'uint8'}}};
|
||||
}
|
||||
const ecs = new TickingEcs();
|
||||
let addLength, removeLength;
|
||||
class AddComponent extends System {
|
||||
class TickingSystemEcs extends Ecs {
|
||||
static Systems = {
|
||||
AddComponent: class extends System {
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Foo'],
|
||||
|
@ -219,8 +253,8 @@ test('adds components to and remove components from entities when ticking system
|
|||
finalize() {
|
||||
addLength = Array.from(this.select('default')).length;
|
||||
}
|
||||
}
|
||||
class RemoveComponent extends System {
|
||||
},
|
||||
RemoveComponent: class extends System {
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Foo'],
|
||||
|
@ -232,16 +266,20 @@ test('adds components to and remove components from entities when ticking system
|
|||
finalize() {
|
||||
removeLength = Array.from(this.select('default')).length;
|
||||
}
|
||||
},
|
||||
};
|
||||
static Types = {Foo: wrapSpecification('Foo', {bar: {type: 'uint8'}})};
|
||||
}
|
||||
ecs.addSystem(AddComponent);
|
||||
const ecs = new TickingSystemEcs();
|
||||
ecs.system('AddComponent').active = true;
|
||||
ecs.create();
|
||||
ecs.tick(1);
|
||||
expect(addLength)
|
||||
.to.equal(1);
|
||||
expect(ecs.get(1).Foo)
|
||||
.to.not.be.undefined;
|
||||
ecs.removeSystem(AddComponent);
|
||||
ecs.addSystem(RemoveComponent);
|
||||
ecs.system('AddComponent').active = false;
|
||||
ecs.system('RemoveComponent').active = true;
|
||||
ecs.tick(1);
|
||||
expect(removeLength)
|
||||
.to.equal(0);
|
||||
|
@ -412,35 +450,25 @@ test('serializes and deserializes', () => {
|
|||
class SerializingEcs extends Ecs {
|
||||
static Types = {Empty, Name, Position};
|
||||
}
|
||||
// # of components + strings (Empty, Name, Position)
|
||||
// 2 + 4 + 5 + 4 + 4 + 4 + 8 = 31
|
||||
const ecs = new SerializingEcs();
|
||||
// ID + # of components + Empty + Position + x + y + z
|
||||
// 4 + 2 + 2 + 4 + 2 + 4 + 4 + 4 + 4 = 30
|
||||
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
|
||||
// ID + # of components + Name + 'foobar' + Position + x + y + z
|
||||
// 4 + 2 + 2 + 4 + 4 + 6 + 2 + 4 + 4 + 4 + 4 = 40
|
||||
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
|
||||
expect(ecs.toJSON())
|
||||
.to.deep.equal({
|
||||
entities: {
|
||||
1: {Empty: {}, Position: {x: 64}},
|
||||
16: {Name: {name: 'foobar'}, Position: {x: 128}},
|
||||
},
|
||||
systems: [],
|
||||
});
|
||||
const view = SerializingEcs.serialize(ecs);
|
||||
// # of entities + Header + Entity(1) + Entity(16)
|
||||
// 4 + 31 + 30 + 40 = 105
|
||||
expect(view.byteLength)
|
||||
.to.equal(105);
|
||||
// Entity values.
|
||||
expect(view.getUint32(4 + 31 + 30 - 12, true))
|
||||
.to.equal(64);
|
||||
expect(view.getUint32(4 + 31 + 30 + 40 - 12, true))
|
||||
.to.equal(128);
|
||||
const deserialized = SerializingEcs.deserialize(view);
|
||||
// # of entities.
|
||||
expect(Array.from(deserialized.entities).length)
|
||||
.to.equal(2);
|
||||
// Composition of entities.
|
||||
expect(deserialized.get(1).constructor.types)
|
||||
.to.deep.equal(['Empty', 'Position']);
|
||||
expect(deserialized.get(16).constructor.types)
|
||||
.to.deep.equal(['Name', 'Position']);
|
||||
// Entity values.
|
||||
expect(JSON.stringify(deserialized.get(1)))
|
||||
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
|
||||
expect(JSON.stringify(deserialized.get(1).Position))
|
||||
|
@ -462,22 +490,9 @@ test('deserializes from compatible ECS', () => {
|
|||
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
|
||||
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
|
||||
const view = SerializingEcs.serialize(ecs);
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const deserialized = DeserializingEcs.deserialize(view);
|
||||
expect(consoleErrorSpy.mock.calls.length)
|
||||
.to.equal(2);
|
||||
consoleErrorSpy.mockRestore();
|
||||
expect(deserialized.get(1).toJSON())
|
||||
.to.deep.equal({Empty: {}});
|
||||
expect(deserialized.get(16).toJSON())
|
||||
.to.deep.equal({Name: {name: 'foobar'}});
|
||||
});
|
||||
|
||||
test('deserializes empty', () => {
|
||||
class SerializingEcs extends Ecs {
|
||||
static Types = {Empty, Name, Position};
|
||||
}
|
||||
const ecs = SerializingEcs.deserialize(new DataView(new Uint16Array([0, 0, 0]).buffer));
|
||||
expect(ecs)
|
||||
.to.not.be.undefined;
|
||||
});
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -3,6 +3,8 @@ import Query from './query.js';
|
|||
|
||||
export default class System {
|
||||
|
||||
active = false;
|
||||
|
||||
destroying = [];
|
||||
|
||||
ecs;
|
||||
|
@ -15,6 +17,15 @@ export default class System {
|
|||
for (const i in queries) {
|
||||
this.queries[i] = new Query(queries[i], ecs.Types);
|
||||
}
|
||||
this.reindex(ecs.entities);
|
||||
}
|
||||
|
||||
createEntity(components) {
|
||||
return this.ecs.create(components);
|
||||
}
|
||||
|
||||
createManyEntities(componentsList) {
|
||||
return this.ecs.createMany(componentsList);
|
||||
}
|
||||
|
||||
deindex(entityIds) {
|
||||
|
@ -35,17 +46,12 @@ export default class System {
|
|||
|
||||
finalize() {}
|
||||
|
||||
static normalize(SystemLike) {
|
||||
if (SystemLike.prototype instanceof System) {
|
||||
return SystemLike;
|
||||
insertComponents(entityId, components) {
|
||||
this.ecs.insert(entityId, components);
|
||||
}
|
||||
if ('function' === typeof SystemLike) {
|
||||
class TickingSystem extends System {}
|
||||
TickingSystem.prototype.tick = SystemLike;
|
||||
return TickingSystem;
|
||||
}
|
||||
/* v8 ignore next */
|
||||
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
|
||||
|
||||
insertManyComponents(components) {
|
||||
this.ecs.insertMany(components);
|
||||
}
|
||||
|
||||
static queries() {
|
||||
|
@ -58,6 +64,14 @@ export default class System {
|
|||
}
|
||||
}
|
||||
|
||||
removeComponents(entityId, components) {
|
||||
this.ecs.remove(entityId, components);
|
||||
}
|
||||
|
||||
removeManyComponents(entityIds) {
|
||||
this.ecs.removeMany(entityIds);
|
||||
}
|
||||
|
||||
select(query) {
|
||||
return this.queries[query].select();
|
||||
}
|
||||
|
@ -69,44 +83,4 @@ export default class System {
|
|||
|
||||
tick() {}
|
||||
|
||||
static wrap(source, ecs) {
|
||||
class WrappedSystem extends System.normalize(source) {
|
||||
|
||||
constructor() {
|
||||
super(ecs);
|
||||
this.reindex(ecs.entities);
|
||||
}
|
||||
|
||||
createEntity(components) {
|
||||
return this.ecs.create(components);
|
||||
}
|
||||
|
||||
createManyEntities(componentsList) {
|
||||
return this.ecs.createMany(componentsList);
|
||||
}
|
||||
|
||||
get source() {
|
||||
return source;
|
||||
}
|
||||
|
||||
insertComponents(entityId, components) {
|
||||
this.ecs.insert(entityId, components);
|
||||
}
|
||||
|
||||
insertManyComponents(components) {
|
||||
this.ecs.insertMany(components);
|
||||
}
|
||||
|
||||
removeComponents(entityId, components) {
|
||||
this.ecs.remove(entityId, components);
|
||||
}
|
||||
|
||||
removeManyComponents(entityIds) {
|
||||
this.ecs.removeMany(entityIds);
|
||||
}
|
||||
|
||||
}
|
||||
return new WrappedSystem();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,43 +1,14 @@
|
|||
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 = [];
|
||||
|
@ -53,35 +24,7 @@ export default class Engine {
|
|||
server;
|
||||
|
||||
constructor(Server) {
|
||||
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,
|
||||
};
|
||||
this.ecses = {};
|
||||
class SilphiusServer extends Server {
|
||||
accept(connection, packed) {
|
||||
super.accept(connection, decode(packed));
|
||||
|
@ -96,24 +39,95 @@ 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
disconnectPlayer(connection) {
|
||||
const {entity} = this.connectedPlayers.get(connection);
|
||||
const ecs = this.ecses[entity.World.world];
|
||||
players[0] = JSON.parse(JSON.stringify(entity.toJSON()));
|
||||
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);
|
||||
ecs.destroy(entity.id);
|
||||
this.connectedPlayers.delete(connection);
|
||||
this.connections.splice(this.connections.indexOf(connection), 1);
|
||||
|
@ -122,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() {
|
||||
|
@ -171,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());
|
||||
|
|
|
@ -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()});
|
||||
});
|
||||
|
|
15
app/hooks/use-asset.js
Normal file
15
app/hooks/use-asset.js
Normal 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;
|
||||
}
|
|
@ -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'}));
|
||||
})();
|
||||
|
||||
|
|
|
@ -43,10 +43,7 @@ export default function EcsComponent() {
|
|||
return (
|
||||
<Container>
|
||||
<TileLayer
|
||||
size={TileLayers.layers[0].size}
|
||||
tiles={TileLayers.layers[0].data}
|
||||
tileset="/assets/tileset.json"
|
||||
tileSize={{x: 16, y: 16}}
|
||||
tileLayer={TileLayers.layers[0]}
|
||||
x={-cx}
|
||||
y={-cy}
|
||||
/>
|
||||
|
|
|
@ -1,18 +1,9 @@
|
|||
import {Assets} from '@pixi/assets';
|
||||
import {Sprite as PixiSprite} from '@pixi/react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import useAsset from '@/hooks/use-asset.js';
|
||||
|
||||
export default function Sprite({entity}) {
|
||||
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]);
|
||||
const asset = useAsset(entity.Sprite.source);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -1,42 +1,33 @@
|
|||
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, {tiles: oldTiles}, props) => {
|
||||
const {asset, tiles, tileset, tileSize, size, x, y} = props;
|
||||
const extless = tileset.slice('/assets/'.length, -'.json'.length);
|
||||
applyProps: (tilemap, {tileLayer: oldTileLayer}, props) => {
|
||||
const {asset, tileLayer, x, y} = props;
|
||||
const extless = tileLayer.source.slice('/assets/'.length, -'.json'.length);
|
||||
const {textures} = asset;
|
||||
tilemap.position.x = x;
|
||||
tilemap.position.y = y;
|
||||
if (tiles === oldTiles) {
|
||||
if (tileLayer === oldTileLayer) {
|
||||
return;
|
||||
}
|
||||
tilemap.clear();
|
||||
let i = 0;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default function TileLayer(props) {
|
||||
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]);
|
||||
const {tileLayer} = props;
|
||||
const asset = useAsset(tileLayer.source);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
@ -44,7 +35,6 @@ export default function TileLayer(props) {
|
|||
<TileLayerInternal
|
||||
{...props}
|
||||
asset={asset}
|
||||
tileset={tileset}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,4 +5,7 @@ html, body {
|
|||
line-height: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
* {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
|
73
app/routes/_main-menu.play.$.$/route.jsx
Normal file
73
app/routes/_main-menu.play.$.$/route.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -1,19 +1,16 @@
|
|||
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 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 Index() {
|
||||
const [client, setClient] = useState();
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
export default function Play() {
|
||||
const [Client, setClient] = useState();
|
||||
const params = useParams();
|
||||
const [type, url] = params['*'].split('/');
|
||||
const [type] = params['*'].split('/');
|
||||
useEffect(() => {
|
||||
let Client;
|
||||
switch (type) {
|
||||
|
@ -32,55 +29,11 @@ export default function Index() {
|
|||
super.transmit(encode(packet));
|
||||
}
|
||||
}
|
||||
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]);
|
||||
setClient(() => SilphiusClient);
|
||||
}, [type]);
|
||||
return (
|
||||
<div className={styles.play}>
|
||||
<ClientContext.Provider value={client}>
|
||||
<Ui disconnected={disconnected} />
|
||||
</ClientContext.Provider>
|
||||
<Outlet context={Client} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
34
app/session.server.js
Normal file
34
app/session.server.js
Normal 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};
|
||||
}
|
|
@ -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); }
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue
Block a user