Compare commits

...

8 Commits

Author SHA1 Message Date
cha0s
52dca790f2 refactor: auto component 2024-05-30 10:06:32 -05:00
cha0s
308d27546e chore: lint 2024-05-30 09:41:39 -05:00
cha0s
3e5da631e3 chore: extensions 2024-05-30 09:02:04 -05:00
cha0s
007ea66de1 flow: net + ecs 2024-05-30 08:55:03 -05:00
cha0s
3dc7cf8a5b refactor: load 2024-05-27 12:52:30 -05:00
cha0s
9643055fb8 chore: tests 2024-05-27 12:51:57 -05:00
cha0s
56722e066d feat: initial ecs transfer 2024-05-27 12:41:14 -05:00
cha0s
5757894e0d feat: disconnection 2024-05-27 09:56:01 -05:00
50 changed files with 4160 additions and 129 deletions

25
eslint.config.js Normal file
View File

@ -0,0 +1,25 @@
// eslint.config.js
import globals from 'globals';
import js from '@eslint/js';
export default [
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
process: false,
},
parserOptions: {
ecmaFeatures: {
impliedStrict: true,
jsx: true
},
},
},
rules: {
// 'no-unused-vars': 'warn',
// 'no-undef': 'warn',
}
}
];

1411
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
"@eslint/js": "^9.3.0",
"@storybook/addon-essentials": "^8.1.3",
"@storybook/addon-interactions": "^8.1.3",
"@storybook/addon-links": "^8.1.3",
@ -23,11 +24,16 @@
"@storybook/react-vite": "^8.1.3",
"@storybook/test": "^8.1.3",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^1.6.0",
"eslint": "^9.3.0",
"globals": "^15.3.0",
"prop-types": "^15.8.1",
"storybook": "^8.1.3",
"vite": "^5.2.11"
"vite": "^5.2.11",
"vitest": "^1.6.0"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5",
"react": "^18.3.1",

10
src/cell.js Normal file
View File

@ -0,0 +1,10 @@
import {Ecs} from './ecs/index.js';
export default class Cell {
constructor() {
this.ecs = new Ecs();
}
tick(elapsed) {
this.ecs.tick(elapsed);
}
}

View File

@ -1,6 +1,6 @@
import {useEffect, useRef, useState} from 'react';
import {RESOLUTION} from '../constants';
import {RESOLUTION} from '../constants.js';
import styles from './dom.module.css';
/**

View File

@ -1,4 +1,4 @@
import Slot from './slot';
import Slot from './slot.jsx';
import styles from './hotbar.module.css';

View File

@ -5,10 +5,12 @@ import {
import {useContext, useEffect, useState} from 'react';
import {RESOLUTION} from '../constants.js';
import ClientContext from '../context/client';
import Entities from './entities';
import ClientContext from '../context/client.js';
import Entities from './entities.jsx';
import styles from './pixi.module.css';
import {Ecs} from '../ecs/index.js';
export default function Pixi() {
const client = useContext(ClientContext);
const [entities, setEntities] = useState([]);
@ -21,7 +23,19 @@ export default function Pixi() {
break;
}
case 'tick': {
setEntities(payload.entities);
const {buffer, byteLength, byteOffset} = payload.entities;
const view = new DataView(buffer, byteOffset, byteLength);
const ecs = new Ecs();
ecs.decode(view);
const entities = [];
for (const entity of ecs.entities) {
const {Position, Visible} = ecs.get(entity);
entities.push({
image: Visible.image,
position: [Position.x, Position.y],
})
}
setEntities(entities);
break;
}
default:

View File

@ -1,8 +1,8 @@
import {useEffect, useState} from 'react';
import ClientContext from '../context/client.js';
import Title from './title';
import Ui from './ui';
import Title from './title.jsx';
import Ui from './ui.jsx';
export default function Silphius() {
const connectionTuple = useState();
@ -15,10 +15,10 @@ export default function Silphius() {
let Client;
switch (connectionTuple[0]) {
case 'local':
({default: Client} = await import('../client/local.js'));
({default: Client} = await import('../net/client/local.js'));
break;
case 'remote':
({default: Client} = await import('../client/remote.js'));
({default: Client} = await import('../net/client/remote.js'));
break;
}
const client = new Client();

View File

@ -1,21 +1,14 @@
import {useContext, useEffect} from 'react';
import addKeyListener from '../add-key-listener.js';
import {RESOLUTION} from '../constants';
import ClientContext from '../context/client';
import Dom from './dom';
import Pixi from './pixi';
import {ACTION_MAP, RESOLUTION} from '../constants.js';
import ClientContext from '../context/client.js';
import Dom from './dom.jsx';
import Pixi from './pixi.jsx';
import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1];
// Handle input.
const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
const KEY_MAP = {
keyDown: 1,
keyUp: 0,

View File

@ -2,3 +2,17 @@ export const RESOLUTION = [
800,
450,
];
export const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
export const MOVE_MAP = {
'moveUp': 'up',
'moveRight': 'right',
'moveDown': 'down',
'moveLeft': 'left',
};

58
src/ecs/bundle.js Normal file
View File

@ -0,0 +1,58 @@
export default class Bundle {
Components = {};
constructor(Components) {
this.Components = this.constructor.Components.reduce(
(r, Component, i) => {
/* v8 ignore next 3 */
if (!Components[i]) {
throw new TypeError(`Bundle(): no such component '${Component}'`);
}
return {...r, [Component]: Components[i]};
},
{},
);
}
configure(configuration) {
return this.constructor.configure(configuration, this.Components);
}
static configure(configuration, Components) {
const result = {};
const entries = Object.entries(Components);
for (let i = 0; i < entries.length; i++) {
const [component] = entries[i];
result[component] = configuration[component];
}
return result;
}
static maybeNormalize(BundleLike) {
const normalized = this.normalize(BundleLike);
return normalized.prototype instanceof Bundle ? normalized : undefined;
}
static normalize(BundleLike) {
if (Array.isArray(BundleLike)) {
return class AdhocBundle extends Bundle {
static Components = BundleLike;
};
}
return BundleLike;
}
static select(entity, Components) {
const bundle = {};
for (const i in Components) {
bundle[i] = Components[i].get(entity);
}
return bundle;
}
select(entity) {
return this.constructor.select(entity, this.Components);
}
}

107
src/ecs/chain.js Normal file
View File

@ -0,0 +1,107 @@
import Ecs from './ecs.js';
export default class Chain {
index = 0;
proposals = [];
constructor(init) {
this.addProposal({
ecs: new Ecs(init),
elapsed: 0,
index: this.index++,
});
}
addProposal(proposal) {
this.proposals.push({
at: Date.now(),
...proposal,
});
}
get dirty() {
const it = this.proposals[Symbol.iterator]()
return {
[Symbol.iterator]() {
return this;
},
next: () => {
let result = it.next();
while (!result.done && !result.value?.ecs.$$dirty) {
result = it.next();
}
if (result.done) {
return {done: true, value: undefined};
}
return result;
},
};
}
get latest() {
return this.proposals[this.proposals.length - 1];
}
mutate(when, mutator) {
let walk = this.proposals.length - 1;
let index = -1;
while (walk > 0) {
if (this.proposals[walk].index === when.index) {
index = walk;
break;
}
walk -= 1;
}
if (-1 === index) {
mutator(new Error('too late'));
return false;
}
let mutated = false;
while (index < this.proposals.length) {
const {ecs: previous} = this.proposals[index];
// opt: not clone
this.proposals[index].ecs = this.proposals[index - 1].ecs.clone();
const {ecs, elapsed} = this.proposals[index];
ecs.setClean();
if (mutated) {
ecs.tick(elapsed);
}
else {
ecs.tick(when.offset);
mutator(undefined, ecs);
ecs.tick(elapsed - when.offset);
mutated = true;
}
// opt: not strings
let actuallyDirty = false;
for (const {id} of ecs.dirty) {
if (JSON.stringify(ecs.get(id)) !== JSON.stringify(previous.get(id))) {
actuallyDirty = true;
break;
}
}
if (!actuallyDirty) {
ecs.setClean();
}
index += 1;
}
}
tick(elapsed) {
// opt: not clone
const ecs = this.latest.ecs.clone();
ecs.tick(elapsed);
this.addProposal({
ecs,
elapsed,
index: this.index++,
});
if (10 === this.proposals.length) {
this.proposals.shift();
}
}
}

127
src/ecs/chain.test.js Normal file
View File

@ -0,0 +1,127 @@
import {expect, test} from 'vitest';
import Chain from './chain.js';
import System from './system.js';
const Controlled = {
input: 'uint8',
};
const Position = {
x: 'float32',
y: 'float32',
};
const Velocity = {
x: 'float32',
y: 'float32',
};
class Physics extends System {
static queries() {
return {
default: ['Position', 'Velocity'],
};
}
tick(elapsed) {
for (const [position, velocity] of this.select('default')) {
position.x += velocity.x * elapsed;
position.y += velocity.y * elapsed;
}
}
}
class Controlling extends System {
static queries() {
return {
default: ['Controlled', 'Velocity'],
};
}
tick() {
for (const [controlled, velocity] of this.select('default')) {
velocity.y = controlled.input ? 10 : 0;
}
}
}
test('efficiently rewrites history', () => {
const chain = new Chain({Controlled, Position, Velocity});
const {ecs} = chain.proposals[0];
const [one, two] = ecs.createMany([
{Controlled: {input: 0}, Position: {x: 10, y: 10}, Velocity: {x: 0, y: 0}},
{Controlled: {input: 0}, Position: {x: 20, y: 10}, Velocity: {x: -5, y: 0}},
]);
const {latest: first} = chain;
chain.latest.ecs.setClean();
ecs.addSystem(Controlling);
ecs.addSystem(Physics);
chain.tick(1);
const {latest: second} = chain;
chain.latest.ecs.setClean();
expect(chain.latest.ecs.get(two).Position.x)
.to.equal(15);
chain.tick(1);
const {latest: third} = chain;
chain.latest.ecs.setClean();
expect(chain.latest.ecs.get(two).Position.x)
.to.equal(10);
expect(Array.from(chain.dirty).length)
.to.equal(0);
chain.mutate(
{index: 1, offset: 0.25},
(error, ecs) => {
ecs.get(one).Controlled.input = 1;
},
);
expect(Array.from(chain.dirty).length)
.to.equal(2);
expect(chain.latest.ecs.get(one).Position.y)
.to.equal(27.5);
expect(Array.from(first.ecs.dirty).length)
.to.equal(0);
expect(Array.from(second.ecs.dirty).length)
.to.not.equal(0);
second.ecs.setClean();
chain.mutate(
{index: 2, offset: 0.5},
(error, ecs) => {
ecs.get(one).Controlled.input = 0;
},
);
expect(Array.from(chain.dirty).length)
.to.equal(1);
expect(chain.latest.ecs.get(one).Position.y)
.to.equal(22.5);
expect(Array.from(chain.dirty).length)
.to.equal(1);
third.ecs.setClean();
third.ecs.get(one).Position.y = 22.5;
chain.mutate(
{index: 2, offset: 0.5},
(error, ecs) => {
ecs.get(one).Controlled.input = 0;
},
);
expect(Array.from(chain.dirty).length)
.to.equal(0);
});
test('fails when out of range', () => {
const chain = new Chain({});
let error;
expect(chain.mutate(
{index: 0, offset: 0.25},
(error_) => {
error = error_;
},
))
.to.be.false;
expect(error)
.to.be.instanceOf(Error);
});

17
src/ecs/component.js Normal file
View File

@ -0,0 +1,17 @@
import Arbitrary from './component/arbitrary.js';
import Flat from './component/flat.js';
import Schema from './schema.js';
function Component() {
const schema = new Schema(this.constructor.specification);
const Component = schema.size > 0
? class extends Flat { static size = schema.size + 5; } // 5 = 1 for dirty + 4 for entity
: Arbitrary;
return new Component(schema);
}
export function createComponent(specification) {
return class AdhocComponent extends Component {
static specification = specification;
};
}

15
src/ecs/component.test.js Normal file
View File

@ -0,0 +1,15 @@
import {expect, test} from 'vitest';
import {createComponent} from './component.js';
test('creates flat components', () => {
const Component = createComponent({foo: {type: 'int32'}});
expect(new Component().schema.size)
.to.equal(4);
});
test('creates arbitrarily-sized components', () => {
const Component = createComponent({foo: {type: 'string'}});
expect(new Component().schema.size)
.to.equal(0);
});

View File

@ -0,0 +1,123 @@
import Serializer from '../serializer.js';
import BaseComponent from './base.js';
export default class ArbitraryComponent extends BaseComponent {
data = [];
serializer;
Instance;
constructor(schema) {
super(schema);
this.serializer = new Serializer(schema);
}
allocateMany(count) {
if (!this.Instance) {
this.Instance = this.instanceFromSchema();
}
const results = super.allocateMany(count);
count -= results.length; while (count--) {
results.push(this.data.push(new this.Instance()) - 1);
}
return results;
}
createMany(entries) {
if (entries.length > 0) {
const allocated = this.allocateMany(entries.length);
const {defaultValues} = this.schema;
const keys = Object.keys(this.schema.specification);
for (let i = 0; i < entries.length; ++i) {
const [entity, values = {}] = entries[i];
this.map[entity] = allocated[i];
this.data[allocated[i]].entity = entity;
if (false === values) {
continue;
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
if (j in values) {
this.data[allocated[i]][j] = values[j];
}
else if ('undefined' !== typeof defaultValues[j]) {
this.data[allocated[i]][j] = defaultValues[j];
}
}
}
}
}
decode(entity, view, offset) {
this.serializer.decode(view, this.get(entity), offset);
}
encode(entity, view, offset) {
this.serializer.encode(this.get(entity), view, offset);
}
get(entity) {
return this.data[this.map[entity]];
}
instanceFromSchema() {
const Component = this;
const Instance = class {
$$dirty = 1;
$$entity = 0;
toJSON() {
const json = {};
for (const [i] of Component.schema) {
json[i] = this[i];
}
return json;
}
};
const properties = {};
properties.dirty = {
get: function get() {
return !!this.$$dirty;
},
set: function set(v) {
this.$$dirty = v;
},
};
properties.entity = {
get: function get() {
return this.$$entity;
},
set: function set(v) {
this.$$entity = v;
},
};
for (const [i] of Component.schema) {
properties[i] = {
get: function get() {
return this[`$$${i}`];
},
set: function set(v) {
this[`$$${i}`] = v;
this.$$dirty = 1;
Component.setDirty(this.entity);
},
};
}
Object.defineProperties(Instance.prototype, properties);
return Instance;
}
setClean() {
/* v8 ignore next 3 */
if (!this.dirty) {
return;
}
super.setClean();
for (let i = 0; i < this.data.length; i++) {
this.data[i].dirty = false;
}
}
}

View File

@ -0,0 +1,45 @@
import {expect, test} from 'vitest';
import Schema from '../schema.js';
import Arbitrary from './arbitrary.js';
const schema = new Schema({foo: {defaultValue: 'bar', type: 'string'}});
test('creates instances', () => {
const Component = new Arbitrary(schema);
Component.create(1)
expect(Component.get(1).entity)
.to.equal(1);
expect(JSON.stringify(Component.get(1)))
.to.equal(JSON.stringify({foo: 'bar'}));
Component.create(2, {foo: 'baz'})
expect(JSON.stringify(Component.get(2)))
.to.equal(JSON.stringify({foo: 'baz'}));
});
test('manages dirty state', () => {
const Component = new Arbitrary(new Schema({foo: {type: 'string'}}));
Component.create(1);
Component.setClean();
expect(Component.get(1).dirty)
.to.be.false;
Component.get(1).foo = 'boo';
expect(Component.get(1).dirty)
.to.be.true;
});
test('reuses instances', () => {
const Component = new Arbitrary(new Schema({foo: {type: 'string'}}));
Component.create(1);
const instance = Component.get(1);
Component.destroy(1);
expect(Component.get(1))
.to.be.undefined;
expect(() => {
Component.destroy(1);
})
.to.throw();
Component.create(1);
expect(Component.get(1))
.to.equal(instance);
});

85
src/ecs/component/base.js Normal file
View File

@ -0,0 +1,85 @@
export default class BaseComponent {
$$dirty = true;
map = [];
pool = [];
schema;
constructor(schema) {
this.schema = schema;
}
allocateMany(count) {
const results = [];
while (count-- > 0 && this.pool.length > 0) {
results.push(this.pool.pop());
}
return results;
}
create(entity, values) {
this.createMany([[entity, values]]);
}
destroy(entity) {
this.destroyMany([entity]);
}
destroyMany(entities) {
this.freeMany(
entities
.map((entity) => {
if ('undefined' !== typeof this.map[entity]) {
return this.map[entity];
}
throw new Error(`can't free for non-existent entity ${entity}`);
}),
);
for (let i = 0; i < entities.length; i++) {
this.map[entities[i]] = undefined;
}
}
get dirty() {
return this.$$dirty;
}
freeMany(indices) {
for (let i = 0; i < indices.length; ++i) {
this.pool.push(indices[i]);
}
}
insertMany(entities) {
const creating = [];
for (let i = 0; i < entities.length; i++) {
const [entity, values] = entities[i];
if (!this.get(entity)) {
creating.push([entity, values]);
}
else {
this.set(entity, values);
}
}
this.createMany(creating);
}
set(entity, values) {
const instance = this.get(entity);
for (const i in values) {
instance[i] = values[i];
}
}
setClean() {
this.$$dirty = false;
}
setDirty() {
this.$$dirty = true;
}
}

194
src/ecs/component/flat.js Normal file
View File

@ -0,0 +1,194 @@
import BaseComponent from './base.js';
import Schema from '../schema.js';
export default class FlatComponent extends BaseComponent {
chunkSize = 64;
caret = 0;
data = new ArrayBuffer(0);
Window;
window;
allocateMany(count) {
const results = super.allocateMany(count);
count -= results.length;
if (count > 0) {
const required = (this.caret + count) * this.constructor.size;
if (required > this.data.byteLength) {
const chunkSize = this.chunkSize * this.constructor.size;
const remainder = required % chunkSize;
/* v8 ignore next */
const extra = 0 === remainder ? 0 : chunkSize - remainder;
const size = required + extra;
// soon...
const data = new ArrayBuffer(size);
(new Uint8Array(data)).set(this.data);
this.data = data;
this.Window = undefined;
this.window = undefined;
}
for (let i = 0; i < count; ++i) {
results.push(this.caret++);
}
}
return results;
}
createMany(entries) {
if (entries.length > 0) {
const allocated = this.allocateMany(entries.length);
/* v8 ignore next 3 */
if (!this.Window) {
this.Window = this.makeWindowClass();
}
const window = new this.Window(this.data, this);
const {defaultValues} = this.schema;
const keys = Object.keys(this.schema.specification);
for (let i = 0; i < entries.length; ++i) {
const [entity, values = {}] = entries[i];
this.map[entity] = allocated[i];
window.cursor = allocated[i] * this.constructor.size;
window.entity = entity;
if (false === values) {
continue;
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
if (j in values) {
window[j] = values[j];
}
else if ('undefined' !== typeof defaultValues[j]) {
window[j] = defaultValues[j];
}
}
}
}
}
decode(entity, view, offset) {
/* v8 ignore next 6 */
if (!this.Window) {
this.Window = this.makeWindowClass();
}
if (!this.window) {
this.window = new this.Window(this.data, this);
}
const cursor = this.map[entity] * this.constructor.size;
for (let i = 0; i < this.schema.size; ++i) {
this.window.view.setUint8(cursor + i, view.getUint8(offset + i));
}
}
encode(entity, view, offset) {
/* v8 ignore next 6 */
if (!this.Window) {
this.Window = this.makeWindowClass();
}
if (!this.window) {
this.window = new this.Window(this.data, this);
}
const cursor = this.map[entity] * this.constructor.size;
for (let i = 0; i < this.schema.size; ++i) {
view.setUint8(offset + i, this.window.view.getUint8(cursor + i));
}
}
get(entity) {
const offset = this.map[entity];
if (undefined === offset) {
return undefined;
}
/* v8 ignore next 6 */
if (!this.Window) {
this.Window = this.makeWindowClass();
}
if (!this.window) {
this.window = new this.Window(this.data, this);
}
this.window.cursor = offset * this.constructor.size;
return this.window;
}
makeWindowClass() {
const Component = this;
class Window {
cursor = 0;
parent;
view;
constructor(data, parent) {
if (data) {
this.view = new DataView(data);
}
if (parent) {
this.parent = parent;
}
}
toJSON() {
const json = {};
for (const [i] of Component.schema) {
json[i] = this[i];
}
return json;
}
}
let offset = 0;
const properties = {};
const {size} = this.constructor;
const get = (type) => (
`return this.view.get${Schema.viewMethodFromType(type)}(this.cursor + ${offset}, true);`
);
const set = (type) => [
`if (this.view.get${Schema.viewMethodFromType(type)}(this.cursor + ${offset}, true) === v) {`,
' return;',
'}',
`this.parent.setDirty(this.view.getUint32(this.cursor + ${size - 5}, true));`,
`this.view.set${Schema.viewMethodFromType(type)}(this.cursor + ${offset}, v, true);`,
`this.view.setUint8(this.cursor + ${size - 1}, 1, true);`,
].join('\n');
properties.dirty = {
get: new Function('', `return !!this.view.getUint8(this.cursor + ${size - 1}, true);`),
set: new Function('v', `this.view.setUint8(this.cursor + ${size - 1}, v ? 1 : 0, true);`),
};
properties.entity = {
get: new Function('', `return this.view.getUint32(this.cursor + ${size - 5}, true);`),
set: new Function('v', `this.view.setUint32(this.cursor + ${size - 5}, v, 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);
}
Object.defineProperties(Window.prototype, properties);
return Window;
}
setClean() {
/* v8 ignore next 3 */
if (!this.dirty) {
return;
}
super.setClean();
/* v8 ignore next 3 */
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.constructor.size;
}
}
}

View File

@ -0,0 +1,48 @@
import {expect, test} from 'vitest';
import Schema from '../schema.js';
import Flat from './flat.js';
const schema = new Schema({foo: {defaultValue: 42, type: 'uint8'}});
class FixedSize extends Flat { static size = schema.size + 5; }
test('creates instances', () => {
const Component = new FixedSize(schema);
Flat.size = 10;
Component.create(1)
expect(Component.get(1).entity)
.to.equal(1);
expect(Component.get(1).foo)
.to.equal(42);
Component.create(2, {foo: 12})
expect(Component.get(2).foo)
.to.equal(12);
});
test('manages dirty state', () => {
const Component = new FixedSize(schema);
Component.create(1);
Component.setClean();
expect(Component.get(1).dirty)
.to.be.false;
Component.get(1).foo = 'boo';
expect(Component.get(1).dirty)
.to.be.true;
});
test('reuses instances', () => {
const Component = new FixedSize(schema);
Component.create(1);
const instance = Component.get(1);
Component.destroy(1);
expect(Component.get(1))
.to.be.undefined;
expect(() => {
Component.destroy(1);
})
.to.throw();
Component.create(1);
expect(Component.get(1))
.to.equal(instance);
});

17
src/ecs/components.js Normal file
View File

@ -0,0 +1,17 @@
export const Controlled = {
up: 'float32',
right: 'float32',
down: 'float32',
left: 'float32',
};
export const Position = {
x: 'float32',
y: 'float32',
};
export const Visible = {
image: 'string',
};
export const Wandering = {};

497
src/ecs/ecs.js Normal file
View File

@ -0,0 +1,497 @@
import Bundle from './bundle.js';
import {createComponent} from './component.js';
import EntityFactory from './entity-factory.js';
import System from './system.js';
export default class Ecs {
$$caret = 1;
$$dirty = false;
Bundles = {};
static ComponentLikesAndOrBundleLikes = {};
$$ComponentLikesAndOrBundleLikes;
Components = {};
$$entities = [];
$$entityFactory = new EntityFactory();
$$pool = [];
$$systems = [];
constructor() {
const {ComponentLikesAndOrBundleLikes} = this.constructor;
this.$$ComponentLikesAndOrBundleLikes = ComponentLikesAndOrBundleLikes;
const Bundles = [];
for (const i in ComponentLikesAndOrBundleLikes) {
const MaybeBundle = Bundle.maybeNormalize(ComponentLikesAndOrBundleLikes[i]);
if (MaybeBundle) {
Bundles.push([i, MaybeBundle]);
}
else {
this.Components[i] = this.withDirtyTracking(
createComponent(ComponentLikesAndOrBundleLikes[i]),
);
}
}
for (let i = 0; i < Bundles.length; i++) {
const [j, Bundle] = Bundles[i];
this.Bundles[j] = new Bundle(Bundle.Components.map((c) => this.Components[c]));
}
}
addSystem(source) {
const ecs = this;
class WrappedSystem extends System.normalize(source) {
constructor(Components) {
super(Components);
this.ecs = ecs;
}
createEntity(components) {
return this.ecs.create(components);
}
createManyEntities(componentsList) {
return this.ecs.createMany(componentsList);
}
get source() {
return source;
}
insertComponents(entity, components) {
this.ecs.insert(entity, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
}
removeComponents(entity, components) {
this.ecs.remove(entity, components);
}
removeManyComponents(entities) {
this.ecs.removeMany(entities);
}
}
const wrappedSystem = new WrappedSystem(this.Components);
wrappedSystem.reindex(this.entities);
this.$$systems.push(wrappedSystem);
}
clone() {
const view = new DataView(new ArrayBuffer(this.sizeOf(this.entities)));
this.encode(this.entities, view);
const ecs = new this.constructor(this.$$ComponentLikesAndOrBundleLikes);
ecs.decode(view);
for (const system of this.$$systems) {
ecs.addSystem(system.source);
}
return ecs;
}
create(components = {}) {
const [entity] = this.createMany([components]);
return entity;
}
createExact(entity, components) {
this.createManyExact([[entity, components]]);
}
createMany(componentsList) {
const entities = [];
const creating = {};
for (let i = 0; i < componentsList.length; i++) {
const components = componentsList[i];
const componentKeys = [];
for (const key of Object.keys(components)) {
if (this.Components[key]) {
componentKeys.push(key);
}
const Bundle = this.Bundles[key];
if (Bundle) {
const configured = Bundle.configure(components[key]);
for (const subkey in configured) {
components[subkey] = configured[subkey];
componentKeys.push(subkey);
}
}
}
let entity;
if (this.$$pool.length > 0) {
entity = this.$$pool.pop();
}
else {
entity = this.$$caret++;
}
entities.push(entity);
this.rebuild(entity, () => componentKeys);
for (let j = 0; j < componentKeys.length; j++) {
const component = componentKeys[j];
if (!creating[component]) {
creating[component] = [];
}
creating[component].push([entity, components[component]]);
}
}
for (const i in creating) {
this.Components[i].createMany(creating[i]);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(entities);
}
return entities;
}
createManyExact(entities) {
const creating = {};
for (const entry of entities) {
let components = {};
let entity;
if (Array.isArray(entry)) {
[entity, components = {}] = entry;
}
else {
entity = entry;
}
if (this.$$entities[entity]) {
throw new Error(`can't create existing entity ${entity}`);
}
const index = this.$$pool.indexOf(entity);
if (-1 !== index) {
this.$$pool.splice(index, 1);
}
this.rebuild(entity, () => Object.keys(components));
for (const j in components) {
if (!creating[j]) {
creating[j] = [];
}
creating[j].push([entity, components[j]]);
}
}
for (const i in creating) {
this.Components[i].createMany(creating[i]);
}
}
decode(view) {
let cursor = 0;
const count = view.getUint32(cursor, true);
if (0 === count) {
return;
}
const keys = Object.keys(this.Components);
cursor += 4;
const creating = new Map();
const updating = new Map();
const cursors = new Map();
for (let i = 0; i < count; ++i) {
const entity = view.getUint32(cursor, true);
if (!this.$$entities[entity]) {
creating.set(entity, {});
}
cursor += 4;
const componentCount = view.getUint16(cursor, true);
cursor += 2;
cursors.set(entity, {});
const addedComponents = [];
for (let j = 0; j < componentCount; ++j) {
const id = view.getUint16(cursor, true);
cursor += 2;
const component = keys[id];
if (!component) {
throw new Error(`can't decode component ${id}`);
}
if (!this.$$entities[entity]) {
creating.get(entity)[component] = false;
}
else if (!this.$$entities[entity].constructor.types.includes(component)) {
addedComponents.push(component);
if (!updating.has(component)) {
updating.set(component, []);
}
updating.get(component).push([entity, false]);
}
cursors.get(entity)[component] = cursor;
cursor += this.Components[component].schema.readSize(view, cursor);
}
if (addedComponents.length > 0 && this.$$entities[entity]) {
this.rebuild(entity, (types) => types.concat(addedComponents));
}
}
this.createManyExact(creating.entries());
for (const [component, entities] of updating) {
this.Components[component].createMany(entities);
}
for (const [entity, components] of cursors) {
for (const component in components) {
this.Components[component].decode(entity, view, components[component]);
}
}
}
destroy(entity) {
this.destroyMany([entity]);
}
destroyAll() {
this.destroyMany(this.entities);
}
destroyMany(entities) {
const destroying = {};
for (const entity of entities) {
if (!this.$$entities[entity]) {
throw new Error(`can't destroy non-existent entity ${entity}`);
}
for (const component of this.$$entities[entity].constructor.types) {
if (!destroying[component]) {
destroying[component] = [];
}
destroying[component].push(entity);
}
this.$$pool.push(entity);
this.$$entities[entity] = undefined;
}
for (const i in destroying) {
this.Components[i].destroyMany(destroying[i]);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].deindex(entities);
}
}
get dirty() {
const ecs = this;
const it = this.$$entities[Symbol.iterator]()
return {
[Symbol.iterator]() {
return this;
},
next: () => {
if (!ecs.$$dirty) {
return {done: true, value: undefined};
}
let result = it.next();
while (!result.done && !result.value?.dirty) {
result = it.next();
}
if (result.done) {
return {done: true, value: undefined};
}
return result;
},
};
}
encode(entities, view, options = {}) {
const {onlyDirty = false} = options;
let cursor = 0;
let entitiesWritten = 0;
cursor += 4;
const keys = Object.keys(this.Components);
for (const entity of entities) {
if (onlyDirty && !this.$$entities[entity].dirty) {
continue;
}
entitiesWritten += 1;
view.setUint32(cursor, entity, true);
cursor += 4;
const entityComponents = this.$$entities[entity].constructor.types;
view.setUint16(cursor, entityComponents.length, true);
const componentsWrittenIndex = cursor;
cursor += 2;
if (0 === entityComponents.length) {
continue;
}
let componentsWritten = 0;
for (const component of entityComponents) {
const instance = this.Components[component];
if (onlyDirty && !instance.dirty) {
continue;
}
componentsWritten += 1;
view.setUint16(cursor, keys.indexOf(component), true);
cursor += 2;
const source = instance.get(entity);
this.Components[component].encode(entity, view, cursor);
cursor += instance.schema.sizeOf(source);
}
view.setUint16(componentsWrittenIndex, componentsWritten, true);
}
view.setUint32(0, entitiesWritten, true);
}
get entities() {
const it = this.$$entities[Symbol.iterator]()
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(entity) {
return this.$$entities[entity];
}
insert(entity, components) {
this.insertMany([[entity, components]]);
}
insertMany(entities) {
const inserting = {};
const unique = new Set();
for (const [entity, components] of entities) {
this.rebuild(entity, (types) => [...new Set(types.concat(Object.keys(components)))]);
for (const component in components) {
if (!inserting[component]) {
inserting[component] = [];
}
inserting[component].push([entity, components[component]]);
}
unique.add(entity);
}
for (const component in inserting) {
this.Components[component].insertMany(inserting[component]);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(unique.values());
}
}
rebuild(entity, types) {
let existing = [];
if (this.$$entities[entity]) {
existing.push(...this.$$entities[entity].constructor.types);
}
const Class = this.$$entityFactory.makeClass(types(existing), this.Components);
this.$$entities[entity] = new Class(entity);
}
remove(entity, components) {
this.removeMany([[entity, components]]);
}
removeMany(entities) {
const removing = {};
const unique = new Set();
for (const [entity, components] of entities) {
unique.add(entity);
for (const component of components) {
if (!removing[component]) {
removing[component] = [];
}
removing[component].push(entity);
}
this.rebuild(entity, (types) => types.filter((type) => !components.includes(type)));
}
for (const component in removing) {
this.Components[component].destroyMany(removing[component]);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(unique.values());
}
}
removeSystem(SystemLike) {
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
if (-1 !== index) {
this.$$systems.splice(index, 1);
}
}
setClean() {
for (const i in this.Components) {
this.Components[i].setClean();
}
for (const entity in this.$$entities) {
if (this.$$entities[entity]) {
this.$$entities[entity].dirty = false;
}
}
this.$$dirty = false;
}
sizeOf(entities, options = {}) {
const {onlyDirty = false} = options;
let size = 0;
if (0 === entities.length) {
return size;
}
size += 4;
for (const entity of entities) {
if (!this.$$entities[entity]) {
throw new Error(`can't encode non-existent entity ${entity}`);
}
if (onlyDirty && !this.$$entities[entity].dirty) {
continue;
}
// Entity + # of components.
size += 4 + 2;
for (const component of this.$$entities[entity].constructor.types) {
const instance = this.Components[component];
if (onlyDirty && !instance.dirty) {
continue;
}
size += 2 + instance.schema.sizeOf(instance.get(entity));
}
}
return size;
}
tick(elapsed) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].tick(elapsed);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].finalize(elapsed);
}
this.tickDestruction();
}
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();
}
if (unique.size > 0) {
this.destroyMany(unique.values());
}
}
withDirtyTracking(Component) {
const component = new Component();
component.setDirty = (entity) => {
component.$$dirty = true;
this.$$dirty = true;
this.$$entities[entity].dirty = true;
};
return component;
}
}

426
src/ecs/ecs.test.js Normal file
View File

@ -0,0 +1,426 @@
import {expect, test} from 'vitest';
import Bundle from './bundle';
import Ecs from './ecs';
import System from './system';
const Empty = {};
const Position = {
x: {type: 'int32', defaultValue: 32},
y: 'int32',
z: 'int32',
};
test('can add and remove systems at runtime', () => {
const ecs = new Ecs();
let oneCount = 0;
let twoCount = 0;
const oneSystem = () => {
oneCount++;
};
ecs.addSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(1);
const twoSystem = () => {
twoCount++;
};
ecs.addSystem(twoSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(1);
ecs.removeSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(2);
});
test('fails trying to exact create already existing', () => {
const ecs = new Ecs();
ecs.createExact(1);
expect(() => {
ecs.createExact(1);
})
.to.throw();
});
test('reuses from pool', () => {
const ecs = new Ecs();
ecs.createExact(1);
ecs.destroy(1);
expect(ecs.$$pool.length)
.to.equal(1);
expect(ecs.create())
.to.equal(1);
expect(ecs.$$pool.length)
.to.equal(0);
});
test('cleans pool when exact creating a previously existing entity', () => {
const ecs = new Ecs();
ecs.createManyExact([1]);
ecs.destroy(1);
expect(ecs.$$pool.length)
.to.equal(1);
ecs.createExact(1);
expect(ecs.$$pool.length)
.to.equal(0);
});
test('can create entities with components', () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}, Position: {y: 420}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 420, z: 0}}));
});
test("can remove entities' components", () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}, Position: {y: 420}});
ecs.remove(entity, ['Position']);
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}}));
});
test('gets entities', () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}, Position: {y: 420}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 420, z: 0}}));
});
test('destroys entities', () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}, Position: {y: 420}});
ecs.destroyAll();
expect(ecs.get(entity))
.to.be.undefined;
expect(() => {
ecs.destroy(entity);
})
.to.throw();
});
test('can insert components into entities', () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}});
ecs.insert(entity, {Position: {y: 420}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 420, z: 0}}));
ecs.insert(entity, {Position: {y: 69}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 69, z: 0}}));
});
test('can tick systems', () => {
const Momentum = {
x: 'int32',
y: 'int32',
z: 'int32',
};
const ecs = new Ecs({Momentum, Position});
class Physics extends System {
static queries() {
return {
default: ['Position', 'Momentum'],
};
}
tick(elapsed) {
for (const [position, momentum] of this.select('default')) {
position.x += momentum.x * elapsed;
position.y += momentum.y * elapsed;
position.z += momentum.z * elapsed;
}
}
}
ecs.addSystem(Physics);
const entity = ecs.create({Momentum: {}, Position: {y: 420}});
const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
.to.deep.equal(position);
ecs.get(1).Momentum.y = 30;
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
.to.deep.equal(JSON.stringify({x: 32, y: 450, z: 0}));
});
test('can create many entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createManyEntities(Array.from({length: 5}).map(() => []));
}
}
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(5))
.to.be.undefined;
ecs.tick(1);
expect(ecs.get(5))
.to.not.be.undefined;
});
test('can create entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createEntity();
}
}
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(2))
.to.be.undefined;
ecs.tick(1);
expect(ecs.get(2))
.to.not.be.undefined;
});
test('can schedule entities to be deleted when ticking systems', () => {
const ecs = new Ecs();
let entity;
class Despawn extends System {
finalize() {
entity = ecs.get(1);
}
static queries() {
return {
default: ['Despawn'],
};
}
tick() {
this.destroyEntity(1);
}
}
ecs.addSystem(Despawn);
ecs.createExact(1);
ecs.tick(1);
expect(entity)
.to.not.be.undefined;
expect(ecs.get(1))
.to.be.undefined;
});
test('can add components to and remove components from entities when ticking systems', () => {
const Foo = {bar: 'uint8'};
const ecs = new Ecs({Foo});
let addLength, removeLength;
class AddComponent extends System {
static queries() {
return {
default: ['Foo'],
};
}
tick() {
this.insertComponents(1, {Foo: {}});
}
finalize() {
addLength = Array.from(this.select('default')).length;
}
}
class RemoveComponent extends System {
static queries() {
return {
default: ['Foo'],
};
}
tick() {
this.removeComponents(1, ['Foo']);
}
finalize() {
removeLength = Array.from(this.select('default')).length;
}
}
ecs.addSystem(AddComponent);
ecs.createExact(1);
ecs.tick(1);
expect(addLength)
.to.equal(1);
expect(ecs.get(1).Foo)
.to.not.be.undefined;
ecs.removeSystem(AddComponent);
ecs.addSystem(RemoveComponent);
ecs.tick(1);
expect(removeLength)
.to.equal(0);
expect(ecs.get(1).Foo)
.to.be.undefined;
});
test('can add many components to and remove many components from entities when ticking systems', () => {
const Foo = {bar: 'uint8'};
const ecs = new Ecs({Foo});
class AddComponent extends System {
tick() {
this.insertManyComponents([
[1, {Foo: {}}],
[2, {Foo: {bar: 42}}],
]);
}
}
class RemoveComponent extends System {
tick() {
this.removeManyComponents([
[1, ['Foo']],
[2, ['Foo']],
]);
}
}
ecs.addSystem(AddComponent);
ecs.createManyExact([1, 2]);
ecs.tick(1);
expect(ecs.get(2).Foo.bar)
.to.equal(42);
ecs.removeSystem(AddComponent);
ecs.addSystem(RemoveComponent);
ecs.tick(1);
expect(ecs.get(2).Foo)
.to.be.undefined;
});
test('can encode and decode an ecs', () => {
const ecs = new Ecs({Empty, Position});
const view = new DataView(new ArrayBuffer(1024));
expect(() => {
ecs.encode([], view);
})
.not.to.throw();
const entity = ecs.create({Empty: {}, Position: {y: 420}});
ecs.encode([entity], view, {onlyDirty: true});
const newEcs = new Ecs({Empty, Position});
newEcs.decode(view);
expect(JSON.stringify(newEcs.get(entity)))
.to.deep.equal(JSON.stringify(ecs.get(entity)));
ecs.setClean();
ecs.encode([entity], view, {onlyDirty: true});
const newEcs2 = new Ecs({Empty, Position});
newEcs2.decode(view);
expect(newEcs2.get(entity))
.to.be.undefined;
ecs.get(entity).Position.x = 42;
ecs.encode([entity], view, {onlyDirty: true});
newEcs2.decode(view);
expect(newEcs2.get(entity))
.to.not.be.undefined;
expect(newEcs2.get(entity).Empty)
.to.be.undefined;
ecs.encode([entity], view);
newEcs2.decode(view);
expect(newEcs2.get(entity))
.to.not.be.undefined;
const empty = newEcs2.get(entity).Empty;
expect(empty)
.to.not.be.undefined;
newEcs2.decode(view);
expect(empty)
.to.not.be.undefined;
});
test('catches bad encoding', () => {
const ecs = new Ecs({Empty, Position});
const entity = ecs.create({Empty: {}});
const view = new DataView(new ArrayBuffer(1024));
ecs.encode([entity], view);
view.setUint16(10, 2);
const newEcs = new Ecs({Empty, Position});
expect(() => {
newEcs.decode(view);
})
.to.throw();
});
test('manages dirtiness', () => {
const ecs = new Ecs({Position});
const entity = ecs.create({Position: {x: 42}});
expect(ecs.Components['Position'].dirty)
.to.be.true;
ecs.setClean();
expect(ecs.Components['Position'].dirty)
.to.be.false;
ecs.get(entity).Position.x = 13;
expect(ecs.Components['Position'].dirty)
.to.be.true;
});
test('handles empty size', () => {
const ecs = new Ecs({Empty, Position});
expect(ecs.sizeOf([]))
.to.equal(0);
});
test('catches non-existent size', () => {
const ecs = new Ecs({Empty, Position});
ecs.create({Empty: {}, Position: {x: 42}});
expect(() => {
ecs.sizeOf([2]);
})
.to.throw();
});
test('calculates size based on dirtiness', () => {
const ecs = new Ecs({Empty, Position});
const first = ecs.create({Empty: {}, Position: {x: 42}});
ecs.create({Empty: {}, Position: {y: 42}});
const sizeOfBoth = ecs.sizeOf([1, 2], true);
ecs.setClean();
ecs.get(first).Position.x = 13;
expect(ecs.sizeOf([1, 2], {onlyDirty: true}))
.to.be.lessThan(sizeOfBoth);
expect(ecs.sizeOf([1, 2]))
.to.equal(sizeOfBoth);
});
test('clones an ecs', () => {
const ecs = new Ecs({Position});
const entity = ecs.create({Position: {y: 420}});
const entity2 = ecs.create();
const newEcs = ecs.clone();
expect(JSON.stringify(newEcs.get(entity)))
.to.deep.equal(JSON.stringify(ecs.get(entity)));
expect(JSON.stringify(newEcs.get(entity2)))
.to.deep.equal(JSON.stringify(ecs.get(entity2)));
});
test('can add bundles', () => {
const Height = {height: 'uint32'};
const Width = {width: 'uint32'};
const Area = ['Height', 'Width'];
class ConfiguredArea extends Bundle {
static configure({height, width}) {
return {Height: {height}, Width: {width}};
}
static Components = ['Height', 'Width'];
}
const ecs = new Ecs({
Area,
ConfiguredArea,
Height,
Width,
});
const entity = ecs.get(ecs.create({Area: {Width: {width: 420}}}));
expect(entity.Height.height)
.to.equal(0);
expect(entity.Width.width)
.to.equal(420);
const configuredEntity = ecs.get(ecs.create({ConfiguredArea: {width: 420}}));
expect(configuredEntity.Height.height)
.to.equal(0);
expect(configuredEntity.Width.width)
.to.equal(420);
});

48
src/ecs/entity-factory.js Normal file
View File

@ -0,0 +1,48 @@
class Node {
children = {};
class;
}
export default class EntityFactory {
$$tries = new Node();
makeClass(types, Components) {
const sorted = types.toSorted();
let walk = this.$$tries;
let i = 0;
while (i < sorted.length) {
if (!walk.children[sorted[i]]) {
walk.children[sorted[i]] = new Node();
}
walk = walk.children[sorted[i]];
i += 1;
}
if (!walk.class) {
class Entity {
dirty = true;
static types = sorted;
constructor(id) {
this.id = id;
}
}
const properties = {};
for (const type of sorted) {
properties[type] = {};
const get = Components[type].get.bind(Components[type]);
properties[type].get = function() {
return get(this.id);
};
}
Object.defineProperties(Entity.prototype, properties);
Entity.prototype.toJSON = new Function('', `
return {
${sorted.map((type) => `${type}: this.${type}`).join(', ')}
};
`);
walk.class = Entity;
}
return walk.class;
}
}

3
src/ecs/index.js Normal file
View File

@ -0,0 +1,3 @@
/* v8 ignore start */
export {default as Ecs} from './ecs.js';
export {default as System} from './system.js';

118
src/ecs/query.js Normal file
View File

@ -0,0 +1,118 @@
import BaseComponent from './component/base.js';
export default class Query {
$$compiled = {with: [], without: []};
$$index = new Set();
constructor(parameters, ComponentsAndOrBundles) {
for (let i = 0; i < parameters.length; ++i) {
const parameter = parameters[i];
switch (parameter.charCodeAt(0)) {
case '!'.charCodeAt(0):
this.$$compiled.without.push(ComponentsAndOrBundles[parameter.slice(1)]);
break;
default:
this.$$compiled.with.push(ComponentsAndOrBundles[parameter]);
break;
}
}
}
get count() {
return this.$$index.size;
}
deindex(entities) {
for (let i = 0; i < entities.length; ++i) {
this.$$index.delete(entities[i]);
}
}
reindex(entities) {
if (0 === this.$$compiled.with.length && 0 === this.$$compiled.without.length) {
for (const entity of entities) {
this.$$index.add(entity);
}
return;
}
for (const entity of entities) {
let should = true;
withCheck: for (let j = 0; j < this.$$compiled.with.length; ++j) {
const ComponentOrBundle = this.$$compiled.with[j];
if (ComponentOrBundle instanceof BaseComponent) {
if ('undefined' === typeof ComponentOrBundle.get(entity)) {
should = false;
break;
}
}
else {
for (const k in ComponentOrBundle.Components) {
if ('undefined' === typeof ComponentOrBundle.Components[k].get(entity)) {
should = false;
break withCheck;
}
}
}
}
if (should) {
for (let j = 0; j < this.$$compiled.without.length; ++j) {
const ComponentOrBundle = this.$$compiled.without[j];
if (ComponentOrBundle instanceof BaseComponent) {
if ('undefined' !== typeof ComponentOrBundle.get(entity)) {
should = false;
break;
}
}
else {
let shouldLocal = false;
for (const k in ComponentOrBundle.Components) {
if ('undefined' === typeof ComponentOrBundle.Components[k].get(entity)) {
shouldLocal = true;
break;
}
}
if (!shouldLocal) {
should = false;
break;
}
}
}
}
if (should) {
this.$$index.add(entity);
}
else if (!should) {
this.$$index.delete(entity);
}
}
}
select() {
const it = this.$$index.values();
const value = [];
return {
[Symbol.iterator]() {
return this;
},
next: () => {
const result = it.next();
if (result.done) {
return {done: true, value: undefined};
}
for (let i = 0; i < this.$$compiled.with.length; ++i) {
if (this.$$compiled.with[i] instanceof BaseComponent) {
value[i] = this.$$compiled.with[i].get(result.value);
}
else {
value[i] = this.$$compiled.with[i].select(result.value);
}
}
value[this.$$compiled.with.length] = result.value;
return {done: false, value};
},
};
}
}

116
src/ecs/query.test.js Normal file
View File

@ -0,0 +1,116 @@
import {expect, test} from 'vitest';
import Bundle from './bundle.js';
import {createComponent} from './component.js';
import Query from './query.js';
const A = createComponent({a: {type: 'int32', defaultValue: 420}});
const B = createComponent({b: {type: 'int32', defaultValue: 69}});
const C = createComponent({c: 'int32'});
const D = Bundle.normalize(['B', 'C']);
class E extends Bundle {
static Components = ['A', 'B'];
static select(entity, Components) {
const {A: {a}, B: {b}} = super.select(entity, Components);
return {a, b};
}
}
const ComponentsAndOrBundles = {
A: new A(),
B: new B(),
C: new C(),
};
ComponentsAndOrBundles.A.createMany([[2], [3]]);
ComponentsAndOrBundles.B.createMany([[1], [2]]);
ComponentsAndOrBundles.C.createMany([[2], [4]]);
ComponentsAndOrBundles.D = new D([ComponentsAndOrBundles.B, ComponentsAndOrBundles.C]);
ComponentsAndOrBundles.E = new E([ComponentsAndOrBundles.A, ComponentsAndOrBundles.B]);
function testQuery(parameters, expected) {
const query = new Query(parameters, ComponentsAndOrBundles);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(expected.length);
for (const _ of query.select()) {
expect(_.length)
.to.equal(parameters.filter((spec) => '!'.charCodeAt(0) !== spec.charCodeAt(0)).length + 1);
expect(expected.includes(_.pop()))
.to.equal(true);
}
}
test('can query all', () => {
testQuery([], [1, 2, 3]);
});
test('can query some', () => {
testQuery(['A'], [2, 3]);
testQuery(['A', 'B'], [2]);
});
test('can query excluding', () => {
testQuery(['!A'], [1]);
testQuery(['A', '!B'], [3]);
});
test('can query bundles', () => {
testQuery(['D'], [2]);
});
test('can query excluding bundles', () => {
testQuery(['!D'], [1, 3]);
});
test('can deindex', () => {
const query = new Query(['A'], ComponentsAndOrBundles);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(2);
query.deindex([2]);
expect(query.count)
.to.equal(1);
});
test('can reindex', () => {
const Test = new (createComponent({a: {type: 'int32', defaultValue: 420}}))();
Test.createMany([[2], [3]]);
const query = new Query(['Test'], {Test});
query.reindex([2, 3]);
expect(query.count)
.to.equal(2);
Test.destroy(2);
query.reindex([2, 3]);
expect(query.count)
.to.equal(1);
});
test('can select', () => {
const query = new Query(['A'], ComponentsAndOrBundles);
query.reindex([1, 2, 3]);
const it = query.select();
const result = it.next();
expect(result.value[0].a)
.to.equal(420);
});
test('can select bundles', () => {
const query = new Query(['D'], ComponentsAndOrBundles);
query.reindex([1, 2, 3]);
const it = query.select();
const result = it.next();
expect(result.value[0].B.b)
.to.equal(69);
});
test('can select configured bundles', () => {
const query = new Query(['E'], ComponentsAndOrBundles);
query.reindex([1, 2, 3]);
const it = query.select();
const result = it.next();
expect(result.value[0])
.to.deep.equal({a: 420, b: 69});
});

153
src/ecs/schema.js Normal file
View File

@ -0,0 +1,153 @@
export default class Schema {
defaultValues = {};
$$size = 0;
specification;
constructor(specification) {
this.specification = this.constructor.normalize(specification);
// Try to calculate static size.
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
this.$$size = 0;
break;
}
this.$$size += size;
}
for (const i in this.specification) {
const {type} = this.specification[i];
this.defaultValues[i] = this.specification[i].defaultValue;
if ('undefined' === typeof this.defaultValues[i] && 'string' === type) {
this.defaultValues[i] = '';
}
}
}
[Symbol.iterator]() {
const keys = Object.keys(this.specification);
return {
next: () => {
if (0 === keys.length) {
return {done: true};
}
const key = keys.shift();
return {
done: false,
value: [key, this.specification[key]],
};
},
};
}
has(key) {
return key in this.specification;
}
static normalize(specification) {
const normalized = Object.create(null);
for (const i in specification) {
normalized[i] = 'string' === typeof specification[i]
? {type: specification[i]}
: specification[i];
if (!this.validateType(normalized[i].type)) {
throw new TypeError(`unknown schema type: ${normalized[i].type}`);
}
}
return normalized;
}
readSize(view, cursor) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string': {
const length = view.getUint32(cursor, true);
cursor += 4 + length;
fullSize += 4;
fullSize += length;
break;
}
}
}
else {
cursor += size;
fullSize += size;
}
}
return fullSize;
}
get size() {
return this.$$size;
}
sizeOf(instance) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string':
fullSize += 4;
fullSize += instance[i].length;
break;
}
}
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': case 'float32': return 4;
case 'float64': case 'int64': case 'uint64': return 8;
default: return 0;
}
}
static validateType(type) {
return [
'float32', 'float64',
'int8', 'int16', 'int32', 'int64',
'string',
'uint8', 'uint16', 'uint32', 'uint64',
]
.includes(type);
}
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;
}
}
}

22
src/ecs/schema.test.js Normal file
View File

@ -0,0 +1,22 @@
import {expect, test} from 'vitest';
import Schema from './schema.js';
test('validates a schema', () => {
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
});
test('calculates the size of an instance', () => {
expect((new Schema({foo: 'uint8', bar: 'uint32'})).sizeOf({foo: 69, bar: 420}))
.to.equal(5);
expect((new Schema({foo: 'string'})).sizeOf({foo: 'hi'}))
.to.equal(4 + 2);
});

61
src/ecs/serializer.js Normal file
View File

@ -0,0 +1,61 @@
import Schema from './schema.js';
export default class Serializer {
constructor(schema) {
this.schema = schema instanceof Schema ? schema : new Schema(schema);
}
decode(view, destination, offset = 0) {
let cursor = offset;
for (const [key, {type}] of this.schema) {
const viewMethod = Schema.viewMethodFromType(type);
let value;
if (viewMethod) {
value = view[`get${viewMethod}`](cursor, true);
cursor += Schema.sizeOfType(type);
}
else {
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;
}
}
}
destination[key] = value;
}
}
encode(source, view, offset = 0) {
let cursor = offset;
for (const [key, {type}] of this.schema) {
const viewMethod = Schema.viewMethodFromType(type);
if (viewMethod) {
view[`set${viewMethod}`](cursor, source[key], true);
cursor += Schema.sizeOfType(type);
}
else {
switch (type) {
case 'string': {
const lengthOffset = cursor;
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]);
}
view.setUint32(lengthOffset, bytes.length, true);
break;
}
}
}
}
}
}

View File

@ -0,0 +1,32 @@
import {expect, test} from 'vitest';
import Serializer from './serializer.js';
test('can encode and decode', () => {
const entries = [
['uint8', 255],
['int8', -128],
['int8', 127],
['uint16', 65535],
['int16', -32768],
['int16', 32767],
['uint32', 4294967295],
['int32', -2147483648],
['int32', 2147483647],
['uint64', 18446744073709551615n],
['int64', -9223372036854775808n],
['int64', 9223372036854775807n],
['float32', 0.5],
['float64', 1.234],
['string', 'hello world'],
];
const schema = entries.reduce((r, [type]) => ({...r, [Object.keys(r).length]: type}), {});
const data = entries.reduce((r, [, value]) => ({...r, [Object.keys(r).length]: value}), {});
const serializer = new Serializer(schema);
const view = new DataView(new ArrayBuffer(serializer.schema.sizeOf(data)));
serializer.encode(data, view);
const result = {};
serializer.decode(view, result);
expect(data)
.to.deep.equal(result);
});

69
src/ecs/system.js Normal file
View File

@ -0,0 +1,69 @@
import Query from './query.js';
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]);
}
}
finalize() {}
static normalize(SystemLike) {
if (SystemLike.prototype instanceof System) {
return SystemLike;
}
if ('function' === typeof SystemLike) {
class TickingSystem extends System {}
TickingSystem.prototype.tick = SystemLike;
return TickingSystem;
}
/* v8 ignore next */
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
}
static queries() {
return {};
}
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 = [];
}
tick() {}
}

View File

@ -3,6 +3,8 @@ import {createRoot} from 'react-dom/client';
import Silphius from './components/silphius.jsx';
await import('./isomorphinit.js');
// Setup DOM.
createRoot(document.querySelector('.silphius'))
.render(createElement(Silphius));

14
src/isomorphinit.js Normal file
View File

@ -0,0 +1,14 @@
// Gathering.
const {default: PacketClass} = await import('./net/packet/packet.js');
Object.values(await import('./net/packet/packets.js'))
.forEach((Packet) => {
PacketClass.register(Packet);
});
PacketClass.mapRegistered();
const {default: Ecs} = await import('./ecs/ecs.js');
Ecs.ComponentLikesAndOrBundleLikes = Object.fromEntries(
Object.entries(
await import('./ecs/components.js'),
),
);

View File

@ -1,10 +1,13 @@
import Packet from '../packet/packet.js';
export default class Client {
constructor() {
this.listeners = [];
}
accept(data) {
accept(packed) {
const decoded = Packet.accept(packed);
for (const i in this.listeners) {
this.listeners[i](data);
this.listeners[i](decoded);
}
}
addMessageListener(listener) {
@ -16,4 +19,7 @@ export default class Client {
this.listeners.splice(index, 1);
}
}
send(packet) {
this.transmit(Packet.transmit(packet));
}
}

View File

@ -2,12 +2,12 @@ import Client from './client.js';
export default class LocalClient extends Client {
async connect() {
this.worker = new Worker('../server/worker.js', {type: 'module'});
this.worker = new Worker('/net/server/worker.js', {type: 'module'});
this.worker.onmessage = (event) => {
this.accept(event.data);
};
}
send(message) {
this.worker.postMessage(message);
transmit(packed) {
this.worker.postMessage(packed);
}
}

View File

@ -3,15 +3,15 @@ import Client from './client.js';
export default class RemoteClient extends Client {
async connect() {
this.socket = new WebSocket(`ws://${window.location.host}/ws`);
this.socket.binaryType = 'arraybuffer';
this.socket.onmessage = (event) => {
this.accept(JSON.parse(event.data));
this.accept(event.data);
};
await new Promise((resolve) => {
this.socket.onopen = resolve;
});
}
send(message) {
this.socket.send(JSON.stringify(message));
transmit(packed) {
this.socket.send(packed);
}
}

33
src/net/packet/action.js Normal file
View File

@ -0,0 +1,33 @@
import Packet from './packet.js';
const WIRE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
Object.entries(WIRE_MAP)
.forEach(([k, v]) => {
WIRE_MAP[v] = k;
});
export default class Action extends Packet {
static type = 'action';
static pack(payload) {
return super.pack({
type: WIRE_MAP[payload.type],
value: payload.value,
});
}
static unpack(packed) {
const unpacked = super.unpack(packed);
return {
type: WIRE_MAP[unpacked.type],
value: unpacked.value,
};
}
};

View File

@ -0,0 +1,7 @@
import Packet from './packet.js';
export default class Connect extends Packet {
static type = 'connect';
};

View File

@ -0,0 +1,7 @@
import Packet from './packet.js';
export default class Connected extends Packet {
static type = 'connected';
};

68
src/net/packet/packet.js Normal file
View File

@ -0,0 +1,68 @@
import {encode, decode} from '@msgpack/msgpack';
export default class Packet {
static registered = {};
constructor(payload = {}) {
this.payload = payload;
}
static accept(packed) {
const decoded = decode(packed);
const Packet = this.registered[decoded.id];
return {
type: Packet.type,
payload: Packet.unpack(decoded),
};
}
static decode(buffer) {
try {
return this.unpack(decode(buffer));
}
catch (error) {
throw new Error(`decoding ${this.type}: ${error.message}`);
}
}
static encode(payload) {
try {
return encode(this.pack(payload));
}
catch (error) {
throw new Error(`encoding ${this.type}: ${error.message}`);
}
}
static mapRegistered() {
this.registered = Object.fromEntries([
...Object.entries(this.registered),
...Object.entries(this.registered)
.map(([, Packet], id) => {
Packet.id = id;
return [id, Packet];
}),
]);
}
static pack(payload) {
return {
id: this.id,
payload,
};
}
static register(Packet) {
this.registered[Packet.type] = Packet;
}
static transmit({type, payload}) {
return this.registered[type].encode(payload);
}
static unpack({payload}) {
return payload;
}
}

View File

@ -0,0 +1,4 @@
export {default as Action} from './action.js';
export {default as Connect} from './connect.js';
export {default as Connected} from './connected.js';
export {default as Tick} from './tick.js';

7
src/net/packet/tick.js Normal file
View File

@ -0,0 +1,7 @@
import Packet from './packet.js';
export default class Tick extends Packet {
static type = 'tick';
};

117
src/net/server/server.js Normal file
View File

@ -0,0 +1,117 @@
import {MOVE_MAP} from '../../constants.js';
import Cell from '../../cell.js';
import {System} from '../../ecs/index.js';
import Packet from "../packet/packet.js";
const SPEED = 100;
const TPS = 60;
await import ('../../isomorphinit.js');
class MovementSystem extends System {
static queries() {
return {
default: ['Position', 'Controlled'],
};
}
tick(elapsed) {
for (const [position, controlled] of this.select('default')) {
position.x += SPEED * elapsed * (controlled.right - controlled.left);
position.y += SPEED * elapsed * (controlled.down - controlled.up);
}
}
}
export default class Server {
constructor() {
this.cells = [new Cell()];
this.cells[0].ecs.addSystem(MovementSystem);
this.connections = [];
this.connectedPlayers = new Map();
this.frame = 0;
this.last = Date.now();
}
accept(connection, packed) {
const decoded = Packet.accept(packed);
const {payload, type} = decoded;
switch (type) {
case 'connect': {
this.send(
connection,
{
type: 'connected',
payload: [],
},
);
break;
}
case 'action': {
const {ecs} = this.cells[0];
const connectedPlayer = this.connectedPlayers.get(connection);
if (payload.type in MOVE_MAP) {
ecs.get(connectedPlayer).Controlled[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
connectPlayer(connection) {
this.connections.push(connection);
const {ecs} = this.cells[0];
const entity = ecs.create({
Controlled: {up: 0, right: 0, down: 0, left: 0},
Position: {x: 50, y: 50},
Visible: {image: './assets/bunny.png'},
})
this.connectedPlayers.set(connection, entity);
}
disconnectPlayer(connection) {
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
}
async load() {
}
send(connection, packet) {
this.transmit(connection, Packet.transmit(packet));
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
}, 1000 / TPS);
}
tick(elapsed) {
this.cells[0].ecs.tick(elapsed);
const {ecs} = this.cells[0];
const view = new DataView(new ArrayBuffer(ecs.sizeOf(ecs.entities)));
ecs.encode(ecs.entities, view);
for (const connection of this.connections) {
this.send(
connection,
{
type: 'tick',
payload: {
entities: view,
elapsed,
frame: this.frame,
},
},
);
}
this.frame += 1;
}
}

View File

@ -9,14 +9,18 @@ const {
const wss = new WebSocketServer({port: SILPHIUS_WEBSOCKET_PORT});
const server = new class SocketServer extends Server {
send(ws, data) { ws.send(JSON.stringify(data)); }
transmit(ws, packed) { ws.send(packed); }
}
await server.load();
server.start();
wss.on('connection', function connection(ws) {
server.connectPlayer(ws);
ws.on('message', function message(data) {
server.accept(ws, JSON.parse(data));
ws.on('close', () => {
server.disconnectPlayer(ws);
})
ws.on('message', (packed) => {
server.accept(ws, packed);
});
});

View File

@ -1,11 +1,12 @@
import Server from './server.js';
const server = new class WorkerServer extends Server {
send(connection, data) { postMessage(data); }
transmit(connection, packed) { postMessage(packed); }
}
await server.load();
server.start();
server.connectPlayer(undefined);
onmessage = ({data}) => { server.accept(undefined, data); };
onmessage = (event) => { server.accept(undefined, event.data); };

View File

@ -1,79 +0,0 @@
const SPEED = 100;
const TPS = 20;
const MOVE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
export default class Server {
constructor() {
this.connections = [];
this.connectedPlayers = new Map();
this.entities = [];
this.frame = 0;
this.last = Date.now();
}
accept(connection, {type, payload}) {
switch (type) {
case 'connect': {
this.send(connection, {type: 'connected', payload: Array.from(this.connectedPlayers.values())});
break;
}
case 'action': {
const connectedPlayer = this.connectedPlayers.get(connection);
if (payload.type in MOVE_MAP) {
connectedPlayer.movement[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
connectPlayer(connection) {
this.connections.push(connection);
const entity = {
image: './assets/bunny.png',
movement: [0, 0, 0, 0],
position: [50, 50],
};
this.entities.push(entity);
this.connectedPlayers.set(connection, entity);
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
}, 1000 / TPS);
}
tick(elapsed) {
for (const connection of this.connections) {
const {movement, position} = this.connectedPlayers.get(connection);
position[0] += SPEED * elapsed * (movement[1] - movement[3]);
position[1] += SPEED * elapsed * (movement[2] - movement[0]);
}
for (const connection of this.connections) {
this.send(
connection,
{
type: 'tick',
payload: {
entities: this.entities,
elapsed,
frame: this.frame,
},
},
);
}
this.frame += 1;
}
}

View File

@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react';
import Dom from '../components/dom';
import {RESOLUTION} from '../constants';
import Dom from '../components/dom.jsx';
import {RESOLUTION} from '../constants.js';
function Decorator({children, style}) {
const ref = useRef();

View File

@ -1,11 +1,9 @@
import {useArgs} from '@storybook/preview-api';
import {fn} from '@storybook/test';
import {createElement} from 'react';
import potion from '../assets/potion.png';
import Hotbar from '../components/hotbar';
import Dom from '../components/dom';
import DomDecorator from './dom-decorator';
import Hotbar from '../components/hotbar.jsx';
import DomDecorator from './dom-decorator.jsx';
const slots = Array(10).fill({});
slots[2] = {image: potion, qty: 24};

View File

@ -1,8 +1,6 @@
import {createElement} from 'react';
import potion from '../assets/potion.png';
import Slot from '../components/slot';
import DomDecorator from './dom-decorator';
import Slot from '../components/slot.jsx';
import DomDecorator from './dom-decorator.jsx';
export default {
title: 'Dom/Inventory/Slot',