import {Encoder, Decoder} from '@msgpack/msgpack'; import {LRUCache} from 'lru-cache'; import {withResolvers} from '@/util/promise.js'; import Script from '@/util/script.js'; import EntityFactory from './entity-factory.js'; const cache = new LRUCache({ max: 128, }); const decoder = new Decoder(); const encoder = new Encoder(); const textDecoder = new TextDecoder(); export default class Ecs { $$caret = 1; Components = {}; deferredChanges = {} $$deindexing = new Set(); $$destructionDependencies = new Map(); $$detached = new Set(); diff = {}; $$entities = {}; $$entityFactory = new EntityFactory(); $$reindexing = new Set(); Systems = {}; 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); } } addDestructionDependency(id, promise) { if (!this.$$destructionDependencies.has(id)) { this.$$destructionDependencies.set(id, {promises: new Set()}) } const {promises} = this.$$destructionDependencies.get(id); promises.add(promise); promise.then(() => { promises.delete(promise); if (!this.$$destructionDependencies.get(id)?.resolvers) { this.$$destructionDependencies.delete(id); } }); } async apply(patch) { const creating = []; const destroying = new Set(); const inserting = []; const removing = []; const updating = []; for (const entityIdString in patch) { const entityId = parseInt(entityIdString); const components = patch[entityId]; if (false === components) { destroying.add(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[entityIdString]) { const entity = this.$$entities[entityIdString]; let isInserting = false; const entityInserts = {}; let isUpdating = false; const entityUpdates = {}; for (const componentName in componentsToUpdate) { if (entity[componentName]) { entityUpdates[componentName] = componentsToUpdate[componentName]; isUpdating = true; } else { entityInserts[componentName] = componentsToUpdate[componentName]; isInserting = true; } } if (isInserting) { inserting.push([entityId, entityInserts]); } if (isUpdating) { updating.push([entityId, entityUpdates]); } } else { creating.push([entityId, componentsToUpdate]); } } const promises = []; if (inserting.length > 0) { promises.push(this.insertMany(inserting)); } if (updating.length > 0) { promises.push(this.updateMany(updating)); } if (creating.length > 0) { promises.push(this.createManySpecific(creating)); } await Promise.all(promises); if (destroying.size > 0) { this.destroyMany(destroying); } if (removing.length > 0) { this.removeMany(removing); } } applyDeferredChanges(entityId) { const changes = this.deferredChanges[entityId]; delete this.deferredChanges[entityId]; for (const components of changes) { this.markChange(entityId, components); } } attach(entityIds) { for (const entityId of entityIds) { this.$$detached.delete(entityId); this.$$reindexing.add(entityId); this.applyDeferredChanges(entityId); } } 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 createDetached(components = {}) { const [entityId] = await this.createManyDetached([components]); return entityId; } async createMany(componentsList) { const specificsList = []; for (const components of componentsList) { specificsList.push([this.$$caret, components]); this.$$caret += 1; } return this.createManySpecific(specificsList); } async createManyDetached(componentsList) { const specificsList = []; for (const components of componentsList) { specificsList.push([this.$$caret, components]); this.$$detached.add(this.$$caret); this.deferredChanges[this.$$caret] = []; this.$$caret += 1; } return this.createManySpecific(specificsList); } async createManySpecific(specificsList) { if (0 === specificsList.length) { return; } const entityIds = new Set(); const creating = {}; for (let i = 0; i < specificsList.length; i++) { const [entityId, components] = specificsList[i]; if (!this.$$detached.has(entityId)) { this.deferredChanges[entityId] = []; } const componentNames = []; for (const componentName in components) { if (this.Components[componentName]) { componentNames.push(componentName); } } entityIds.add(entityId); 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, components] = specificsList[i]; this.$$reindexing.add(entityId); this.rebuild(entityId, () => Object.keys(components)); if (this.$$detached.has(entityId)) { continue; } this.applyDeferredChanges(entityId); } return entityIds; } async createSpecific(entityId, components) { const [created] = await this.createManySpecific([[entityId, components]]); return created; } deindex(entityIds) { // Stage 4 Draft / July 6, 2024 // const attached = entityIds.difference(this.$$detached); const attached = new Set(entityIds); for (const detached of this.$$detached) { attached.delete(detached); } if (0 === attached.size) { return; } for (const systemName in this.Systems) { const System = this.Systems[systemName]; if (!System.active) { continue; } System.deindex(attached); } } 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) { if (!this.$$destructionDependencies.has(entityId)) { this.$$destructionDependencies.set(entityId, {promises: new Set()}); } const dependencies = this.$$destructionDependencies.get(entityId); if (!dependencies.resolvers) { dependencies.resolvers = withResolvers(); } return dependencies.resolvers.promise; } destroyMany(entityIds) { const destroying = {}; // this.deindex(entityIds); for (const entityId of entityIds) { this.$$deindexing.add(entityId); 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); } } for (const i in destroying) { this.Components[i].destroyMany(destroying[i]); } for (const entityId of entityIds) { delete this.$$entities[entityId]; this.diff[entityId] = false; } } detach(entityIds) { for (const entityId of entityIds) { this.$$deindexing.add(entityId); this.$$detached.add(entityId); } } get entities() { const ids = []; for (const entity of Object.values(this.$$entities)) { ids.push(entity.id); } return ids; } 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) { 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); for (const [entityId, components] of entities) { this.$$reindexing.add(entityId); this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]); } } 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]) { this.diff[entityId] = components; } // 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], ); } } } async readJson(uri) { const key = ['$$json', uri].join(':'); if (!cache.has(key)) { const {promise, resolve, reject} = withResolvers(); cache.set(key, promise); this.readAsset(uri) .then((chars) => { resolve( chars.byteLength > 0 ? JSON.parse(textDecoder.decode(chars)) : {}, ); }) .catch(reject); } return cache.get(key); } async readScript(uriOrCode, context = {}) { if (!uriOrCode) { return undefined; } let code = ''; if (!uriOrCode.startsWith('/')) { code = uriOrCode; } else { const buffer = await this.readAsset(uriOrCode); if (buffer.byteLength > 0) { code = textDecoder.decode(buffer); } } if (!code) { return undefined; } return Script.fromCode(code, context); } rebuild(entityId, componentNames) { let Class; if (componentNames) { let existing = []; if (this.$$entities[entityId]) { existing.push(...this.$$entities[entityId].constructor.componentNames); } Class = this.$$entityFactory.makeClass(componentNames(existing), this.Components); } else { Class = this.$$entities[entityId].constructor; } if (this.$$entities[entityId] && Class === this.$$entities[entityId].constructor) { // Eventually - memoizable. } return this.$$entities[entityId] = new Class(entityId); } reindex(entityIds) { // Stage 4 Draft / July 6, 2024 // const attached = entityIds.difference(this.$$detached); const attached = new Set(entityIds); for (const detached of this.$$detached) { attached.delete(detached); } if (0 === attached.size) { return; } for (const systemName in this.Systems) { const System = this.Systems[systemName]; if (!System.active) { continue; } System.reindex(attached); } } 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); } for (const componentName in removing) { this.Components[componentName].destroyMany(removing[componentName]); } for (const [entityId, components] of entities) { this.$$reindexing.add(entityId); this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type))); } } 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) { // tick systems for (const systemName in this.Systems) { const System = this.Systems[systemName]; if (!System.active) { continue; } if (!System.frequency) { System.tick(elapsed); continue; } System.elapsed += elapsed; while (System.elapsed >= System.frequency) { System.tick(System.frequency); System.elapsed -= System.frequency; } } // destroy entities const destroying = new Set(); for (const [entityId, {promises}] of this.$$destructionDependencies) { if (0 === promises.size) { destroying.add(entityId); } } if (destroying.size > 0) { this.destroyMany(destroying); for (const entityId of destroying) { this.$$destructionDependencies.get(entityId).resolvers.resolve(); this.$$destructionDependencies.delete(entityId); } } // update indices if (this.$$deindexing.size > 0) { this.deindex(this.$$deindexing); this.$$deindexing.clear(); } if (this.$$reindexing.size > 0) { this.reindex(this.$$reindexing); this.$$reindexing.clear(); } } 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, }; } async updateMany(entities) { const updating = {}; const unique = new Set(); for (const [entityId, components] of entities) { this.rebuild(entityId); for (const componentName in components) { if (!updating[componentName]) { updating[componentName] = []; } updating[componentName].push([entityId, components[componentName]]); } this.$$reindexing.add(entityId); unique.add(entityId); } const promises = []; for (const componentName in updating) { promises.push(this.Components[componentName].updateMany(updating[componentName])); } await Promise.all(promises); } }