import {Encoder, Decoder} from '@msgpack/msgpack'; import {LRUCache} from 'lru-cache'; 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 = {} destroying = new Set(); 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.destroying.add(entityId); } destroyImmediately(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], ); } } } async readJson(uri) { const key = ['$$json', uri].join(':'); if (!cache.has(key)) { let promise, resolve, reject; promise = new Promise((res, rej) => { resolve = res; reject = rej; }); 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) { 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) { 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; } } if (this.destroying.size > 0) { this.destroyMany(this.destroying); this.destroying.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, }; } }