421 lines
11 KiB
JavaScript
421 lines
11 KiB
JavaScript
import EntityFactory from './entity-factory.js';
|
|
|
|
import {Encoder, Decoder} from '@msgpack/msgpack';
|
|
|
|
const decoder = new Decoder();
|
|
const encoder = new Encoder();
|
|
|
|
export default class Ecs {
|
|
|
|
$$caret = 1;
|
|
|
|
Components = {};
|
|
|
|
deferredChanges = {}
|
|
|
|
diff = {};
|
|
|
|
Systems = {};
|
|
|
|
$$entities = {};
|
|
|
|
$$entityFactory = new EntityFactory();
|
|
|
|
constructor({Systems, Components} = {}) {
|
|
for (const componentName in Components) {
|
|
this.Components[componentName] = new Components[componentName](this);
|
|
}
|
|
for (const systemName in Systems) {
|
|
this.Systems[systemName] = new Systems[systemName](this);
|
|
}
|
|
}
|
|
|
|
async apply(patch) {
|
|
const creating = [];
|
|
const destroying = [];
|
|
const removing = [];
|
|
const updating = [];
|
|
for (const entityIdString in patch) {
|
|
const entityId = parseInt(entityIdString);
|
|
const components = patch[entityId];
|
|
if (false === components) {
|
|
destroying.push(entityId);
|
|
continue;
|
|
}
|
|
const componentsToRemove = [];
|
|
const componentsToUpdate = {};
|
|
for (const componentName in components) {
|
|
if (false === components[componentName]) {
|
|
componentsToRemove.push(componentName);
|
|
}
|
|
else {
|
|
componentsToUpdate[componentName] = components[componentName];
|
|
}
|
|
}
|
|
if (componentsToRemove.length > 0) {
|
|
removing.push([entityId, componentsToRemove]);
|
|
}
|
|
if (this.$$entities[entityId]) {
|
|
updating.push([entityId, componentsToUpdate]);
|
|
}
|
|
else {
|
|
creating.push([entityId, componentsToUpdate]);
|
|
}
|
|
}
|
|
if (destroying.length > 0) {
|
|
this.destroyMany(destroying);
|
|
}
|
|
if (updating.length > 0) {
|
|
await this.insertMany(updating);
|
|
}
|
|
if (removing.length > 0) {
|
|
this.removeMany(removing);
|
|
}
|
|
if (creating.length > 0) {
|
|
await this.createManySpecific(creating);
|
|
}
|
|
}
|
|
|
|
changed(criteria) {
|
|
const it = Object.entries(this.diff).values();
|
|
return {
|
|
[Symbol.iterator]() {
|
|
return this;
|
|
},
|
|
next: () => {
|
|
let result = it.next();
|
|
hasResult: while (!result.done) {
|
|
if (false === result.value[1]) {
|
|
result = it.next();
|
|
continue;
|
|
}
|
|
for (const componentName of criteria) {
|
|
if (!(componentName in result.value[1])) {
|
|
result = it.next();
|
|
continue hasResult;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (result.done) {
|
|
return {done: true, value: undefined};
|
|
}
|
|
return {done: false, value: this.get(result.value[0])};
|
|
},
|
|
};
|
|
}
|
|
|
|
async create(components = {}) {
|
|
const [entityId] = await this.createMany([components]);
|
|
return entityId;
|
|
}
|
|
|
|
async createMany(componentsList) {
|
|
const specificsList = [];
|
|
for (const components of componentsList) {
|
|
specificsList.push([this.$$caret++, components]);
|
|
}
|
|
return this.createManySpecific(specificsList);
|
|
}
|
|
|
|
async createManySpecific(specificsList) {
|
|
if (0 === specificsList.length) {
|
|
return;
|
|
}
|
|
const entityIds = [];
|
|
const creating = {};
|
|
for (let i = 0; i < specificsList.length; i++) {
|
|
const [entityId, components] = specificsList[i];
|
|
this.deferredChanges[entityId] = [];
|
|
const componentNames = [];
|
|
for (const componentName in components) {
|
|
if (this.Components[componentName]) {
|
|
componentNames.push(componentName);
|
|
}
|
|
}
|
|
entityIds.push(entityId);
|
|
this.rebuild(entityId, () => componentNames);
|
|
for (const componentName of componentNames) {
|
|
if (!creating[componentName]) {
|
|
creating[componentName] = [];
|
|
}
|
|
creating[componentName].push([entityId, components[componentName]]);
|
|
}
|
|
this.markChange(entityId, components);
|
|
}
|
|
const promises = [];
|
|
for (const i in creating) {
|
|
promises.push(this.Components[i].createMany(creating[i]));
|
|
}
|
|
await Promise.all(promises);
|
|
for (let i = 0; i < specificsList.length; i++) {
|
|
const [entityId] = specificsList[i];
|
|
const changes = this.deferredChanges[entityId];
|
|
delete this.deferredChanges[entityId];
|
|
for (const components of changes) {
|
|
this.markChange(entityId, components);
|
|
}
|
|
}
|
|
this.reindex(entityIds);
|
|
return entityIds;
|
|
}
|
|
|
|
async createSpecific(entityId, components) {
|
|
return this.createManySpecific([[entityId, components]]);
|
|
}
|
|
|
|
deindex(entityIds) {
|
|
for (const systemName in this.Systems) {
|
|
this.Systems[systemName].deindex(entityIds);
|
|
}
|
|
}
|
|
|
|
static async deserialize(ecs, view) {
|
|
const componentNames = Object.keys(ecs.Components);
|
|
const {entities, systems} = decoder.decode(view.buffer);
|
|
for (const system of systems) {
|
|
const System = ecs.system(system);
|
|
if (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(([componentName]) => componentNames.includes(componentName)),
|
|
),
|
|
]);
|
|
}
|
|
ecs.$$caret = max + 1;
|
|
await ecs.createManySpecific(specifics);
|
|
return ecs;
|
|
}
|
|
|
|
destroy(entityId) {
|
|
this.destroyMany([entityId]);
|
|
}
|
|
|
|
destroyAll() {
|
|
this.destroyMany(this.entities);
|
|
}
|
|
|
|
destroyMany(entityIds) {
|
|
const destroying = {};
|
|
this.deindex(entityIds);
|
|
for (const entityId of entityIds) {
|
|
if (!this.$$entities[entityId]) {
|
|
throw new Error(`can't destroy non-existent entity ${entityId}`);
|
|
}
|
|
for (const componentName of this.$$entities[entityId].constructor.componentNames) {
|
|
if (!destroying[componentName]) {
|
|
destroying[componentName] = [];
|
|
}
|
|
destroying[componentName].push(entityId);
|
|
}
|
|
this.$$entities[entityId] = undefined;
|
|
this.diff[entityId] = false;
|
|
}
|
|
for (const i in destroying) {
|
|
this.Components[i].destroyMany(destroying[i]);
|
|
}
|
|
}
|
|
|
|
get entities() {
|
|
const it = Object.values(this.$$entities).values();
|
|
return {
|
|
[Symbol.iterator]() {
|
|
return this;
|
|
},
|
|
next: () => {
|
|
let result = it.next();
|
|
while (!result.done && !result.value) {
|
|
result = it.next();
|
|
}
|
|
if (result.done) {
|
|
return {done: true, value: undefined};
|
|
}
|
|
return {done: false, value: result.value.id};
|
|
},
|
|
};
|
|
}
|
|
|
|
get(entityId) {
|
|
return this.$$entities[entityId];
|
|
}
|
|
|
|
async insert(entityId, components) {
|
|
return this.insertMany([[entityId, components]]);
|
|
}
|
|
|
|
async insertMany(entities) {
|
|
const inserting = {};
|
|
const unique = new Set();
|
|
for (const [entityId, components] of entities) {
|
|
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
|
|
const diff = {};
|
|
for (const componentName in components) {
|
|
if (!inserting[componentName]) {
|
|
inserting[componentName] = [];
|
|
}
|
|
diff[componentName] = {};
|
|
inserting[componentName].push([entityId, components[componentName]]);
|
|
}
|
|
unique.add(entityId);
|
|
this.markChange(entityId, diff);
|
|
}
|
|
const promises = [];
|
|
for (const componentName in inserting) {
|
|
promises.push(this.Components[componentName].insertMany(inserting[componentName]));
|
|
}
|
|
await Promise.all(promises);
|
|
this.reindex(unique.values());
|
|
}
|
|
|
|
markChange(entityId, components) {
|
|
if (this.deferredChanges[entityId]) {
|
|
this.deferredChanges[entityId].push(components);
|
|
return;
|
|
}
|
|
// Deleted?
|
|
if (false === components) {
|
|
this.diff[entityId] = false;
|
|
}
|
|
// Created?
|
|
else if (!this.diff[entityId]) {
|
|
const filtered = {};
|
|
for (const componentName in components) {
|
|
filtered[componentName] = false === components[componentName]
|
|
? false
|
|
: components[componentName];
|
|
}
|
|
this.diff[entityId] = filtered;
|
|
}
|
|
// Otherwise, merge.
|
|
else {
|
|
for (const componentName in components) {
|
|
this.diff[entityId][componentName] = false === components[componentName]
|
|
? false
|
|
: this.Components[componentName].mergeDiff(
|
|
this.diff[entityId][componentName] || {},
|
|
components[componentName],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
rebuild(entityId, componentNames) {
|
|
let existing = [];
|
|
if (this.$$entities[entityId]) {
|
|
existing.push(...this.$$entities[entityId].constructor.componentNames);
|
|
}
|
|
const Class = this.$$entityFactory.makeClass(componentNames(existing), this.Components);
|
|
if (this.$$entities[entityId] && Class === this.$$entities[entityId].constructor) {
|
|
// Eventually - memoizable.
|
|
}
|
|
this.$$entities[entityId] = new Class(entityId);
|
|
}
|
|
|
|
reindex(entityIds) {
|
|
for (const systemName in this.Systems) {
|
|
this.Systems[systemName].reindex(entityIds);
|
|
}
|
|
}
|
|
|
|
remove(entityId, components) {
|
|
this.removeMany([[entityId, components]]);
|
|
}
|
|
|
|
removeMany(entities) {
|
|
const removing = {};
|
|
const unique = new Set();
|
|
for (const [entityId, components] of entities) {
|
|
unique.add(entityId);
|
|
const diff = {};
|
|
for (const componentName of components) {
|
|
diff[componentName] = false;
|
|
if (!removing[componentName]) {
|
|
removing[componentName] = [];
|
|
}
|
|
removing[componentName].push(entityId);
|
|
}
|
|
this.markChange(entityId, diff);
|
|
this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type)));
|
|
}
|
|
for (const componentName in removing) {
|
|
this.Components[componentName].destroyMany(removing[componentName]);
|
|
}
|
|
this.reindex(unique);
|
|
}
|
|
|
|
static serialize(ecs, view) {
|
|
const buffer = encoder.encode(ecs.toJSON());
|
|
if (!view) {
|
|
view = new DataView(new ArrayBuffer(buffer.length));
|
|
}
|
|
(new Uint8Array(view.buffer)).set(buffer);
|
|
return view;
|
|
}
|
|
|
|
setClean() {
|
|
this.diff = {};
|
|
}
|
|
|
|
system(systemName) {
|
|
return this.Systems[systemName];
|
|
}
|
|
|
|
tick(elapsed) {
|
|
const destroying = new Set();
|
|
for (const systemName in this.Systems) {
|
|
const System = this.Systems[systemName];
|
|
if (System.active) {
|
|
if (System.frequency) {
|
|
System.elapsed += elapsed;
|
|
if (System.elapsed < System.frequency) {
|
|
continue;
|
|
}
|
|
}
|
|
while (!System.frequency || System.elapsed >= System.frequency) {
|
|
System.tick(System.frequency ? System.elapsed : elapsed);
|
|
for (let j = 0; j < System.destroying.length; j++) {
|
|
destroying.add(System.destroying[j]);
|
|
}
|
|
System.tickDestruction();
|
|
if (!System.frequency) {
|
|
break;
|
|
}
|
|
System.elapsed -= System.frequency;
|
|
}
|
|
}
|
|
}
|
|
if (destroying.size > 0) {
|
|
this.destroyMany(destroying.values());
|
|
}
|
|
}
|
|
|
|
toJSON() {
|
|
const entities = {};
|
|
for (const id in this.$$entities) {
|
|
if (this.$$entities[id]) {
|
|
entities[id] = this.$$entities[id].toJSON();
|
|
}
|
|
}
|
|
const systems = [];
|
|
for (const systemName in this.Systems) {
|
|
if (this.Systems[systemName].active) {
|
|
systems.push(systemName);
|
|
}
|
|
}
|
|
return {
|
|
entities,
|
|
systems,
|
|
};
|
|
}
|
|
|
|
}
|