feat: ecs

This commit is contained in:
cha0s 2022-09-14 02:42:55 -05:00
parent 358509bcaa
commit 94addfb22a
9 changed files with 1051 additions and 0 deletions

116
packages/ecs/.gitignore vendored Normal file
View File

@ -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.*

24
packages/ecs/package.json Normal file
View File

@ -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"
}
}

View File

@ -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);
}

168
packages/ecs/src/ecs.js Normal file
View File

@ -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);
}
}
}

104
packages/ecs/src/index.js Normal file
View File

@ -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);

85
packages/ecs/src/query.js Normal file
View File

@ -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};
},
};
}
}

108
packages/ecs/src/schema.js Normal file
View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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 = [];
}
}