diff --git a/packages/ecs/.gitignore b/packages/ecs/.gitignore new file mode 100644 index 0000000..1f22b9c --- /dev/null +++ b/packages/ecs/.gitignore @@ -0,0 +1,116 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/packages/ecs/package.json b/packages/ecs/package.json new file mode 100644 index 0000000..984d529 --- /dev/null +++ b/packages/ecs/package.json @@ -0,0 +1,24 @@ +{ + "name": "@avocado/ecs", + "version": "1.0.0", + "scripts": { + "build": "flecks build", + "clean": "flecks clean", + "lint": "flecks lint", + "postversion": "cp package.json dist", + "test": "flecks test" + }, + "files": [ + "build", + "index.js", + "index.js.map", + "src", + "test" + ], + "dependencies": { + "@flecks/core": "^1.0.0" + }, + "devDependencies": { + "@flecks/fleck": "^1.0.0" + } +} diff --git a/packages/ecs/src/component.js b/packages/ecs/src/component.js new file mode 100644 index 0000000..2b5ff02 --- /dev/null +++ b/packages/ecs/src/component.js @@ -0,0 +1,331 @@ +/* eslint-disable guard-for-in, max-classes-per-file, no-restricted-syntax */ +import Schema from './schema'; + +class BaseComponent { + + $$dirty = true; + + map = []; + + pool = []; + + schema; + + serializer; + + constructor(schema) { + this.schema = schema; + } + + allocate() { + const [index] = this.allocateMany(1); + return index; + } + + allocateMany(count) { + const results = []; + // eslint-disable-next-line no-param-reassign + while (count-- > 0 && this.pool.length > 0) { + results.push(this.pool.pop()); + } + return results; + } + + create(entity, values) { + const [result] = this.createMany([entity, values]); + return result; + } + + destroy(entity) { + this.destroyMany([entity]); + } + + destroyMany(entities) { + this.freeMany(entities.map((entity) => this.map[entity]).filter((index) => !!index)); + for (let i = 0; i < entities.length; i++) { + this.map[entities[i]] = undefined; + } + } + + get dirty() { + return this.$$dirty; + } + + set dirty(dirty) { + this.$$dirty = dirty; + } + + free(index) { + this.freeMany([index]); + } + + freeMany(indices) { + for (let i = 0; i < indices.length; ++i) { + this.pool.push(indices[i]); + } + } + +} + +class FlatComponent extends BaseComponent { + + chunkSize = 64; + + caret = 0; + + data = new ArrayBuffer(0); + + Window; + + window; + + allocateMany(count) { + const results = super.allocateMany(count); + // eslint-disable-next-line no-param-reassign + count -= results.length; + if (count > 0) { + const required = (this.caret + count) * this.schema.width; + if (required > this.data.byteLength) { + const chunkWidth = this.chunkSize * this.schema.width; + const remainder = required % chunkWidth; + const extra = 0 === remainder ? 0 : chunkWidth - remainder; + const size = required + extra; + const data = new ArrayBuffer(size); + (new Uint8Array(data)).set(this.data); + this.data = data; + } + for (let i = 0; i < count; ++i) { + results.push(this.caret++); + } + } + return results; + } + + clean() { + if (!this.dirty) { + return; + } + if (!this.Window) { + this.Window = this.makeWindowClass(); + } + const window = new this.Window(this.data, this); + for (let i = 0; i < this.caret; ++i) { + window.dirty = false; + window.cursor += this.schema.width; + } + this.dirty = false; + } + + createMany(entries) { + if (entries.length > 0) { + const allocated = this.allocateMany(entries.length); + if (!this.Window) { + this.Window = this.makeWindowClass(); + } + const window = new this.Window(this.data, this); + const {defaultValues} = this.schema; + for (let i = 0; i < entries.length; ++i) { + let entity; + let values = {}; + if (Array.isArray(entries[i])) { + [entity, values] = entries[i]; + } + else { + entity = entries[i]; + } + this.map[entity] = allocated[i]; + window.cursor = allocated[i] * this.schema.width; + for (const [i] of this.schema) { + if (i in values) { + window[i] = values[i]; + } + else if (i in defaultValues) { + window[i] = defaultValues[i]; + } + } + } + } + } + + get(entity) { + if ('undefined' === typeof this.map[entity]) { + return undefined; + } + if (!this.Window) { + this.Window = this.makeWindowClass(); + } + const window = new this.Window(this.data, this); + window.cursor = this.map[entity] * this.schema.width; + return window; + } + + getUnsafe(entity) { + if ('undefined' === typeof this.map[entity]) { + return undefined; + } + if (!this.Window) { + this.Window = this.makeWindowClass(); + } + if (!this.window) { + this.window = new this.Window(this.data, this); + } + this.window.cursor = this.map[entity] * this.schema.width; + return this.window; + } + + makeWindowClass() { + class Window { + + cursor = 0; + + parent; + + view; + + constructor(data, parent) { + if (data) { + this.view = new DataView(data); + } + if (parent) { + this.parent = parent; + } + } + + } + let offset = 0; + const properties = {}; + const get = (type) => ( + `return this.view.get${Schema.viewMethodFromType(type)}(this.cursor + ${offset}, true);` + ); + const set = (type) => [ + 'this.parent.dirty = true;', + `this.view.set${Schema.viewMethodFromType(type)}(this.cursor + ${offset}, v, true);`, + `this.view.setUint8(this.cursor + ${this.schema.width - 1}, 1, true);`, + ].join(''); + /* eslint-disable no-new-func */ + properties.dirty = { + get: new Function('', `return !!this.view.getUint8(this.cursor + ${this.schema.width - 1}, true);`), + set: new Function('v', `this.view.setUint8(this.cursor + ${this.schema.width - 1}, v ? 1 : 0, true);`), + }; + for (const [i, spec] of this.schema) { + const {type} = spec; + properties[i] = {}; + properties[i].get = new Function('', get(type)); + properties[i].set = new Function('v', set(type)); + offset += Schema.sizeOfType(type); + } + /* eslint-enable no-new-func */ + Object.defineProperties(Window.prototype, properties); + return Window; + } + +} + +class ArbitraryComponent extends BaseComponent { + + data = []; + + Instance; + + allocateMany(count) { + if (!this.Instance) { + this.Instance = this.constructor.instanceFromSchema(this.schema); + } + const results = super.allocateMany(count); + // eslint-disable-next-line no-param-reassign + count -= results.length; + // eslint-disable-next-line no-param-reassign + while (count--) { + results.push(this.data.push(new this.Instance()) - 1); + } + return results; + } + + clean() { + if (!this.dirty) { + return; + } + for (const i in this.map) { + this.data[this.map[i]].dirty = false; + } + this.dirty = false; + } + + createMany(entries) { + if (entries.length > 0) { + const allocated = this.allocateMany(entries.length); + for (let i = 0; i < entries.length; ++i) { + let entity; + let values = {}; + if (Array.isArray(entries[i])) { + [entity, values] = entries[i]; + } + else { + entity = entries[i]; + } + this.map[entity] = allocated[i]; + for (const j in values) { + if (this.schema.has(j)) { + this.data[allocated[i]][j] = values[j]; + } + } + } + } + } + + get(entity) { + return this.data[this.map[entity]]; + } + + getUnsafe(entity) { + return this.get(entity); + } + + static instanceFromSchema(schema) { + const Instance = class { + + constructor() { + this.$$dirty = 1; + for (const [i, {defaultValue}] of schema) { + this[i] = defaultValue; + } + } + + }; + const properties = {}; + properties.dirty = { + get: function get() { + return !!this.$$dirty; + }, + set: function set(v) { + this.$$dirty = v; + }, + }; + for (const [i] of schema) { + properties[i] = { + get: function get() { + return this[`$$${i}`]; + }, + set: function set(v) { + this[`$$${i}`] = v; + this.$$dirty = 1; + }, + }; + } + Object.defineProperties(Instance.prototype, properties); + return Instance; + } + +} + +export default function ComponentRouter() { + const schema = new Schema(this.constructor.schema); + let RealClass; + if (schema.width > 0) { + RealClass = FlatComponent; + } + else { + RealClass = ArbitraryComponent; + } + return new RealClass(schema); +} diff --git a/packages/ecs/src/ecs.js b/packages/ecs/src/ecs.js new file mode 100644 index 0000000..20cf239 --- /dev/null +++ b/packages/ecs/src/ecs.js @@ -0,0 +1,168 @@ +/* eslint-disable guard-for-in, max-classes-per-file, no-restricted-syntax */ + +export default class Ecs { + + $$actions = []; + + $$caret = 1; + + $$cleanEntities = new Set(); + + Components = {}; + + dirty = new Set(); + + $$entities = []; + + $$pool = []; + + $$systems = []; + + constructor(Components) { + for (const i in Components) { + class Component extends Components[i] { + + set dirty(dirty) { + super.dirty = dirty; + const it = this.$$cleanEntities.values(); + let result = it.next(); + while (!result.done) { + result = it.next(); + } + for (let i = 0; i < this.$$entities.length; i++) { + if (this.get(this.$$entities[i])) { + this.dirty.add(this.$$entities[i]); + } + } + } + + } + this.Components[i] = new Component(); + } + } + + addSystem(System) { + const system = new System(this.Components); + this.$$systems.push(system); + return system; + } + + create(components) { + const [entity] = this.createMany(1, components); + return entity; + } + + createMany(count, components) { + const componentKeys = Object.keys(components); + const entities = []; + // eslint-disable-next-line no-param-reassign + while (this.$$pool.length > 0 && count--) { + const entity = this.$$pool.pop(); + entities.push(entity); + this.$$entities[entity] = componentKeys.slice(0); + } + // eslint-disable-next-line no-param-reassign + while (count--) { + const entity = this.$$caret++; + entities.push(entity); + this.$$entities[entity] = componentKeys.slice(0); + } + for (const i in components) { + this.Components[i].createMany(entities.map((entity) => [entity, components[i](entity)])); + } + for (let i = 0; i < this.$$systems.length; i++) { + this.$$systems[i].reindex(entities); + } + return entities; + } + + destroyMany(entities) { + const map = {}; + for (let i = 0; i < entities.length; i++) { + for (let j = 0; j < this.$$entities[entities[i]].length; j++) { + const key = this.$$entities[entities[i]][j]; + if (!map[key]) { + map[key] = []; + } + map[key].push(entities[i]); + } + this.$$pool.push(entities[i]); + } + for (const i in map) { + this.Components[i].destroyMany(map[i]); + } + for (let i = 0; i < this.$$systems.length; i++) { + this.$$systems[i].deindex(entities); + } + } + + get(entity, Components = Object.keys(this.Components)) { + const result = {}; + for (let i = 0; i < Components.length; i++) { + const component = this.Components[Components[i]].get(entity); + if ('undefined' !== typeof component) { + result[Components[i]] = component; + } + } + return result; + } + + // eslint-disable-next-line class-methods-use-this + insertMany(components) { + const unique = new Set(); + for (const i in components) { + const entities = components[i]; + for (let j = 0; j < entities.length; ++j) { + let entity; + if (Array.isArray(entities[j])) { + [entity] = entities[j]; + } + else { + entity = entities[j]; + } + if (!this.$$entities[entity].includes(i)) { + this.$$entities[entity].push(i); + } + unique.add(entity); + } + this.Components[i].createMany(entities); + } + const uniqueArray = Array.from(unique.values()); + for (let i = 0; i < this.$$systems.length; i++) { + this.$$systems[i].reindex(uniqueArray); + } + } + + tick(elapsed) { + for (let i = 0; i < this.$$systems.length; i++) { + this.$$systems[i].tick(elapsed); + } + } + + tickClean() { + for (const i in this.Components) { + this.Components[i].clean(); + } + this.dirty = []; + } + + tickFinalize() { + this.tickDestruction(); + this.tickClean(); + } + + 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]); + } + this.$$systems[i].tickDestruction(); + } + const destroying = Array.from(unique.values()); + if (destroying.length > 0) { + this.destroyMany(destroying); + } + } + +} diff --git a/packages/ecs/src/index.js b/packages/ecs/src/index.js new file mode 100644 index 0000000..769082a --- /dev/null +++ b/packages/ecs/src/index.js @@ -0,0 +1,104 @@ +/* eslint-disable */ + +import Component from './component'; +import Ecs from './ecs'; +import Schema from './schema'; +import Serializer from './serializer'; +import System from './system'; + +const N = 100; +const warm = 500; + +class Position extends Component { + + static schema = { + x: {type: 'int32', defaultValue: 32}, + y: 'int32', + z: 'int32', + }; + +} + +class Direction extends Component { + + static schema = { + direction: 'uint8', + }; + +} + +class ZSystem extends System { + + static queries() { + return { + default: ['Position', 'Direction'], + }; + } + + tick() { + for (let [position, {direction}, entity] of this.select('default')) { + position.z = entity * direction * 2; + } + } + +} + +const ecs = new Ecs({Direction, Position}); +ecs.addSystem(ZSystem); + +const createMany = () => { + const entities = ecs.createMany(N, {Position: (entity) => ({y: entity})}); + ecs.insertMany({Direction: entities.map((entity) => [entity, {direction: 1 + entity % 4}])}); + return entities; +}; + +for (let i = 0; i < warm; ++i) { + ecs.destroyMany(createMany()); +} +performance.mark('create0'); +createMany(); +performance.mark('create1'); + +for (let i = 0; i < warm; ++i) { + ecs.tick(0.01); + ecs.tickFinalize(); +} +performance.mark('tick0'); +ecs.tick(0.01); +ecs.tickFinalize(); +performance.mark('tick1'); +console.log(ecs.$$systems[0].queries.default.count); + +performance.measure(`create ${N}`, 'create0', 'create1'); +performance.measure(`tick ${N}`, 'tick0', 'tick1'); + +const {Position: p} = ecs.get(1); +console.log({ + x: p.x, + y: p.y, + z: p.z, + dirty: p.dirty, +}); + +console.log(performance.getEntriesByType('measure').map(({duration, name}) => ({duration, name}))); + +// const schema = new Schema({ +// foo: 'uint8', +// bar: 'string', +// }); + +// const serializer = new Serializer(schema); +// // const source = {foo: 8, bar: 'hello'}; +// // const size = schema.sizeOf(source); + +// const buffer = new ArrayBuffer(10 + 8); +// const view = new DataView(buffer); +// serializer.encode({foo: 8, bar: 'hello'}, view); +// serializer.encode({foo: 8, bar: 'sup'}, view, 10); +// console.log(buffer); + +// const thing = {}; +// serializer.decode(view, thing); +// console.log(thing); +// serializer.decode(view, thing, 10); +// console.log(thing); diff --git a/packages/ecs/src/query.js b/packages/ecs/src/query.js new file mode 100644 index 0000000..4472000 --- /dev/null +++ b/packages/ecs/src/query.js @@ -0,0 +1,85 @@ +export default class Query { + + $$compiled = {with: [], without: []}; + + $$index = []; + + constructor(parameters, Components) { + for (let i = 0; i < parameters.length; ++i) { + const parameter = parameters[i]; + switch (parameter.charCodeAt(0)) { + case '!'.charCodeAt(0): + this.$$compiled.without.push(Components[parameter.slice(1)]); + break; + default: + this.$$compiled.with.push(Components[parameter]); + break; + } + } + } + + get count() { + return this.$$index.length; + } + + deindex(entities) { + for (let i = 0; i < entities.length; ++i) { + const index = this.$$index.indexOf(entities[i]); + if (-1 !== index) { + this.$$index.splice(index, 1); + } + } + } + + reindex(entities) { + for (let i = 0; i < entities.length; ++i) { + const entity = entities[i]; + const index = this.$$index.indexOf(entity); + let should = true; + for (let j = 0; j < this.$$compiled.with.length; ++j) { + const C = this.$$compiled.with[j]; + if ('undefined' === typeof C.getUnsafe(entity)) { + should = false; + break; + } + } + if (should) { + for (let j = 0; j < this.$$compiled.without.length; ++j) { + const C = this.$$compiled.without[j]; + if ('undefined' !== typeof C.getUnsafe(entity)) { + should = false; + break; + } + } + } + if (should && -1 === index) { + this.$$index.push(entity); + } + else if (!should && -1 !== index) { + this.$$index.splice(index, 1); + } + } + } + + select() { + const entities = this.$$index.slice(0); + return { + [Symbol.iterator]() { + return this; + }, + next: () => { + if (0 === entities.length) { + return {done: true}; + } + const entity = entities.pop(); + const value = []; + for (let i = 0; i < this.$$compiled.with.length; ++i) { + value.push(this.$$compiled.with[i].getUnsafe(entity)); + } + value.push(entity); + return {done: false, value}; + }, + }; + } + +} diff --git a/packages/ecs/src/schema.js b/packages/ecs/src/schema.js new file mode 100644 index 0000000..17883e2 --- /dev/null +++ b/packages/ecs/src/schema.js @@ -0,0 +1,108 @@ +/* eslint-disable guard-for-in, no-restricted-syntax */ + +export default class Schema { + + defaultValues = {}; + + width = 1; + + spec; + + constructor(spec) { + this.spec = this.constructor.normalize(spec); + for (const i in this.spec) { + const {type} = spec[i]; + const size = this.constructor.sizeOfType(type); + if (0 === size) { + this.width = 0; + break; + } + this.width += size; + } + for (const i in this.spec) { + this.defaultValues[i] = spec[i].defaultValue; + } + } + + [Symbol.iterator]() { + const keys = Object.keys(this.spec); + return { + next: () => { + if (0 === keys.length) { + return {done: true}; + } + const key = keys.shift(); + return { + done: false, + value: [key, this.spec[key]], + }; + }, + }; + } + + has(key) { + return key in this.spec; + } + + static viewMethodFromType(type) { + const capitalizedType = `${type.slice(0, 1).toUpperCase()}${type.slice(1)}`; + switch (type) { + case 'uint8': + case 'int8': + case 'uint16': + case 'int16': + case 'uint32': + case 'int32': + case 'float32': + case 'float64': { + return capitalizedType; + } + case 'int64': + case 'uint64': { + return `Big${capitalizedType}`; + } + default: return undefined; + } + } + + static normalize(spec) { + const normalized = {}; + for (const i in spec) { + normalized[i] = 'string' === typeof spec[i] ? {type: spec[i]} : spec[i]; + } + return normalized; + } + + sizeOf(instance) { + let fullSize = 0; + for (const i in this.spec) { + const {type} = this.spec[i]; + const size = this.constructor.sizeOfType(type); + if (0 === size) { + switch (type) { + case 'string': + fullSize += 4; + fullSize += instance[i].length; + break; + default: throw new TypeError(`can't measure size of ${type}`); + } + } + else { + fullSize += size; + } + } + return fullSize; + } + + static sizeOfType(type) { + switch (type) { + case 'uint8': case 'int8': return 1; + case 'uint16': case 'int16': return 2; + case 'uint32': case 'int32': return 4; + case 'float32': return 4; + case 'float64': case 'int64': case 'uint64': return 8; + default: return 0; + } + } + +} diff --git a/packages/ecs/src/serializer.js b/packages/ecs/src/serializer.js new file mode 100644 index 0000000..f5df177 --- /dev/null +++ b/packages/ecs/src/serializer.js @@ -0,0 +1,66 @@ +/* eslint-disable guard-for-in, no-restricted-syntax */ +import Schema from './schema'; + +export default class Serializer { + + constructor(schema) { + this.schema = schema; + } + + decode(view, destination, offset = 0) { + let cursor = offset; + for (const [key, def] of this.schema) { + const {type} = def; + const size = Schema.sizeOfType(type); + const viewMethod = Schema.viewMethodFromType(type); + let value; + if (viewMethod) { + // eslint-disable-next-line no-param-reassign + value = view[`get${viewMethod}`](cursor, true); + cursor += size; + } + switch (type) { + case 'string': { + const length = view.getUint32(cursor, true); + cursor += 4; + const {buffer, byteOffset} = view; + const decoder = new TextDecoder(); + value = decoder.decode(new DataView(buffer, byteOffset + cursor, length)); + cursor += length; + break; + } + default: break; + } + // eslint-disable-next-line no-param-reassign + destination[key] = value; + } + } + + encode(source, view, offset = 0) { + let cursor = offset; + for (const [key, def] of this.schema) { + const {type} = def; + const size = Schema.sizeOfType(type); + const viewMethod = Schema.viewMethodFromType(type); + if (viewMethod) { + view[`set${viewMethod}`](cursor, source[key], true); + cursor += size; + } + switch (type) { + case 'string': { + const {length} = source[key]; + view.setUint32(cursor, length, true); + cursor += 4; + const encoder = new TextEncoder(); + const bytes = encoder.encode(source[key]); + for (let i = 0; i < bytes.length; ++i) { + view.setUint8(cursor++, bytes[i]); + } + break; + } + default: break; + } + } + } + +} diff --git a/packages/ecs/src/system.js b/packages/ecs/src/system.js new file mode 100644 index 0000000..580caa4 --- /dev/null +++ b/packages/ecs/src/system.js @@ -0,0 +1,49 @@ +/* eslint-disable guard-for-in, no-restricted-syntax */ + +import Query from './query'; + +export default class System { + + destroying = []; + + queries = {}; + + constructor(Components) { + const queries = this.constructor.queries(); + for (const i in queries) { + this.queries[i] = new Query(queries[i], Components); + } + } + + deindex(entities) { + for (const i in this.queries) { + this.queries[i].deindex(entities); + } + } + + destroyEntity(entity) { + this.destroyManyEntities([entity]); + } + + destroyManyEntities(entities) { + for (let i = 0; i < entities.length; i++) { + this.destroying.push(entities[i]); + } + } + + reindex(entities) { + for (const i in this.queries) { + this.queries[i].reindex(entities); + } + } + + select(query) { + return this.queries[query].select(); + } + + tickDestruction() { + this.deindex(this.destroying); + this.destroying = []; + } + +}