Compare commits

...

13 Commits

Author SHA1 Message Date
cha0s
3aacc77ccd feat: goold ol' escape hatch
Some checks failed
CI / build (push) Has been cancelled
CI / test (20.x) (push) Has been cancelled
CI / lint (push) Has been cancelled
release-please / release-please (push) Has been cancelled
2024-12-05 15:28:04 -06:00
cha0s
098e18ea76 perf: revert changed 2024-12-05 07:25:24 -06:00
cha0s
86063aae5a perf: primitive array fast paths 2024-12-05 07:09:00 -06:00
cha0s
f82e336849 fix: array full set 2024-12-05 03:37:18 -06:00
cha0s
e128f6f7da fix: export 2024-12-05 03:37:01 -06:00
cha0s
a8ab286133 fix: map delete change 2024-12-05 03:26:49 -06:00
cha0s
08a07ea5a1 fix: component diff 2024-12-05 03:09:50 -06:00
cha0s
428a18fbf3 fix: map merge delete 2024-12-05 03:06:36 -06:00
cha0s
00ea2f69e0 fix: map set 2024-12-05 02:58:55 -06:00
cha0s
e5ac510839 feat: sparse array 2024-12-05 02:52:27 -06:00
cha0s
aaa6ac7cc3 flow: many fixes and updates 2024-12-05 02:19:04 -06:00
cha0s
4461121a6c fix: array merge 2024-12-04 20:54:07 -06:00
cha0s
ff2a3fffb3 refactor: codec based off components 2024-12-03 18:10:18 -06:00
18 changed files with 965 additions and 475 deletions

View File

@ -14,12 +14,12 @@
"engines": {
"node": ">=20.0.0"
},
"exports": "./dist/index.js",
"files": [
"dist"
],
"license": "MIT",
"main": "./dist/index.umd.cjs",
"module": "./dist/index.js",
"name": "ecstc",
"repository": {
"type": "git",

View File

@ -1,4 +1,57 @@
function registerCodecs(Codecs) {
class SparseArray {
constructor(blueprint) {
this.$$isSparseCodec = new Codecs.bool();
this.$$arrayCodec = new Codecs.array(blueprint);
this.$$sparseCodec = new Codecs.map({key: {type: 'varuint'}, value: blueprint.element});
}
decode(view, target) {
const isSparse = this.$$isSparseCodec.decode(view, target);
if (isSparse) {
return Object.fromEntries(this.$$sparseCodec.decode(view, target));
}
else {
return this.$$arrayCodec.decode(view, target);
}
}
encode(value, view, byteOffset) {
let written = 0;
if (Array.isArray(value)) {
written += this.$$isSparseCodec.encode(false, view, byteOffset + written);
written += this.$$arrayCodec.encode(value, view, byteOffset + written);
}
else {
written += this.$$isSparseCodec.encode(true, view, byteOffset + written);
const map = [];
for (const key in value) {
map.push([parseInt(key), value[key]]);
}
written += this.$$sparseCodec.encode(map, view, byteOffset + written);
}
return written;
}
size(value) {
let size = this.$$isSparseCodec.size();
if (Array.isArray(value)) {
size += this.$$arrayCodec.size(value);
}
else {
const map = [];
for (const key in value) {
map.push([parseInt(key), value[key]]);
}
size += this.$$sparseCodec.size(map);
}
return size;
}
}
class MaybeDeletion extends Codecs.object {
constructor(blueprint) {
@ -33,14 +86,17 @@ function registerCodecs(Codecs) {
}
function convertPropertiesToOptional(blueprint) {
if ('properties' in blueprint) {
const newBlueprint = {properties: {}, type: blueprint.type}
function convertProperties(blueprint) {
if ('element' in blueprint) {
return {element: convertProperties(blueprint.element), type: 'ecstc-sparse-array'};
}
else if ('properties' in blueprint) {
const newBlueprint = {properties: {}, type: blueprint.type};
for (const key in blueprint.properties) {
newBlueprint.properties[key] = {
...convertPropertiesToOptional(blueprint.properties[key]),
...convertProperties(blueprint.properties[key]),
optional: true,
}
};
}
return newBlueprint;
}
@ -52,7 +108,32 @@ function registerCodecs(Codecs) {
class Component extends MaybeDeletion {
constructor(blueprint) {
super(convertPropertiesToOptional(blueprint));
super(convertProperties(blueprint));
}
decode(view, target) {
const isDeletion = this.$$isDeletion.decode(view, target);
if (isDeletion) {
return false;
}
return super.decode(view, target);
}
encode(value, view, byteOffset) {
let written = 0;
written += this.$$isDeletion.encode(false === value, view, byteOffset);
if (value) {
written += super.encode(value, view, byteOffset + written);
}
return written;
}
size(value) {
let size = this.$$isDeletion.size();
if (value) {
size += super.size(value);
}
return size;
}
}
@ -60,14 +141,14 @@ function registerCodecs(Codecs) {
class Entity extends MaybeDeletion {
constructor(blueprint) {
const {ecs} = blueprint;
const {Components} = blueprint;
const properties = {};
for (const componentName in ecs.Components) {
const Component = ecs.Components[componentName];
for (const componentName in Components) {
const Component = Components[componentName];
properties[componentName] = {
optional: true,
type: 'ecstc-component',
properties: Component.constructor.properties,
properties: Component.properties,
};
}
super({properties});
@ -80,7 +161,7 @@ function registerCodecs(Codecs) {
constructor(blueprint) {
super({
key: {type: 'varuint'},
value: {type: 'ecstc-entity', ecs: blueprint.ecs},
value: {type: 'ecstc-entity', Components: blueprint.Components},
});
}
@ -89,6 +170,7 @@ function registerCodecs(Codecs) {
Codecs['ecstc-ecs'] = Ecs;
Codecs['ecstc-entity'] = Entity;
Codecs['ecstc-component'] = Component;
Codecs['ecstc-sparse-array'] = SparseArray;
}
export default registerCodecs;

View File

@ -30,10 +30,16 @@ class Component {
instance() {
const Component = this;
const properties = {};
const {constructor} = this;
const Instance = class {
entityId;
constructor() {
for (const key in constructor.properties) {
const propertyBlueprint = constructor.properties[key];
const Property = Properties[propertyBlueprint.type];
Property.define(this, key, propertyBlueprint);
}
}
destroy() {
this.set(undefined);
}
@ -44,16 +50,24 @@ class Component {
}
set(entityId, values = {}) {
this.entityId = entityId;
for (const key in properties) {
for (const key in constructor.properties) {
if (key in values) {
this[key] = values[key];
continue;
}
const propertyBlueprint = constructor.properties[key];
if ('defaultValue' in propertyBlueprint) {
this[key] = propertyBlueprint.defaultValue;
continue;
}
const Property = Properties[propertyBlueprint.type];
this[key] = Property.defaultValue(values[key], propertyBlueprint);
this[key] = Property.defaultValue();
}
return this;
}
toJSON() {
const json = {};
for (const key in properties) {
for (const key in constructor.properties) {
if ('object' === typeof this[key] && this[key].toJSON) {
json[key] = this[key].toJSON();
}
@ -64,12 +78,6 @@ class Component {
return json;
}
};
for (const key in constructor.properties) {
const propertyBlueprint = constructor.properties[key];
const Property = Properties[propertyBlueprint.type];
properties[key] = Property.definitions(propertyBlueprint, key);
}
Object.defineProperties(Instance.prototype, properties);
return Instance;
}

View File

@ -22,10 +22,6 @@ test('component', () => {
change = {[entityId]: {[componentName]: change}};
switch (changeIndex++) {
case 0: {
expect(change).to.deep.equal({1: {Number: {number: 128}}});
break;
}
case 1: {
expect(change).to.deep.equal({1: {Number: {number: 129}}});
break;
}
@ -38,7 +34,7 @@ test('component', () => {
expect(instance.toJSON()).to.deep.equal({number: 128});
instance.number = 129;
expect(instance.toJSON()).to.deep.equal({number: 129});
expect(changeIndex).to.equal(2);
expect(changeIndex).to.equal(1);
});
test('empty', () => {

View File

@ -8,6 +8,7 @@ class Ecs {
changes = [];
Components = {};
entities = new Map();
globals = {};
Systems = {};
tracking = true;
@ -57,7 +58,15 @@ class Ecs {
continue;
}
for (const key in values) {
entity[componentName][key] = values[key];
if (
'object' === typeof entity[componentName][key]
&& 'merge' in entity[componentName][key]
) {
entity[componentName][key].merge(values[key]);
}
else {
entity[componentName][key] = values[key];
}
}
}
}
@ -70,14 +79,15 @@ class Ecs {
createSpecific(entityId, components) {
const entity = new Entity(entityId);
let added = false;
let addedAnyComponents = false;
for (const componentName in components) {
if (this.Components[componentName]) {
added = true;
entity.addComponent(this.Components[componentName], components[componentName]);
}
this.markChange(entityId, componentName, {});
addedAnyComponents = true;
}
if (!added) {
if (!addedAnyComponents) {
this.markChange(entityId);
}
this.entities.set(entityId, entity);
@ -163,7 +173,7 @@ class Ecs {
if (!l[key]) {
l[key] = [];
}
for (const j of value) {
for (const j in value) {
l[key][j] = value[j];
}
}

View File

@ -2,12 +2,13 @@ import {Codecs, Schema} from 'crunches';
import {expect, test} from 'vitest';
import registerCodecs from './codecs.js';
import Component from './component.js';
import Ecs from './ecs.js';
import System from './system.js';
import {fakeEnvironment} from './test-helper.js';
const {Components} = fakeEnvironment();
const {A, D} = Components;
const {A, D, E} = Components;
test('smoke', () => {
expect(() => new Ecs({Components: {}, Systems: {}})).not.toThrowError();
@ -30,7 +31,7 @@ test('components', () => {
test('diffs', () => {
const ecs = new Ecs({Components: {A}, Systems: {}});
const entity = ecs.create({A: {}});
expect(ecs.diff()).to.deep.equal(new Map([[entity.id, {A: {a: 64}}]]));
expect(ecs.diff()).to.deep.equal(new Map([[entity.id, {A: {}}]]));
// memoized
expect(ecs.diff()).to.equal(ecs.diff());
entity.A.a = 32;
@ -47,6 +48,17 @@ test('diffs', () => {
expect(ecs.diff()).to.deep.equal(new Map([[1, false]]));
});
test('nested diffs', () => {
const ecs = new Ecs({Components: {E}, Systems: {}});
const entity = ecs.create({E: {}});
expect(ecs.diff()).to.deep.equal(new Map([[entity.id, {E: {}}]]));
// memoized
expect(ecs.diff()).to.equal(ecs.diff());
entity.E.e.f = 32;
expect(ecs.changes.length).to.equal(2);
expect(ecs.diff()).to.deep.equal(new Map([[entity.id, {E: {e: {f: 32}}}]]));
});
test('defers destruction', () => {
const ecs = new Ecs({Components: {A}, Systems: {}});
const entity = ecs.create({A: {}});
@ -91,11 +103,12 @@ test('indexing', () => {
test('serialization', async () => {
registerCodecs(Codecs);
const ecs = new Ecs({Components: {D}, Systems: {}});
const Components = {D};
const ecs = new Ecs({Components, Systems: {}});
for (let i = 0; i < 10; ++i) {
ecs.create((i & 1) ? {} : {D: {d: 1 / (i + 1)}});
}
const schema = new Schema({type: 'ecstc-ecs', ecs});
const schema = new Schema({type: 'ecstc-ecs', Components});
let view;
view = schema.encode(ecs.diff());
expect(schema.decode(view)).to.deep.equal(ecs.diff());
@ -107,7 +120,7 @@ test('serialization', async () => {
});
test('apply', () => {
const ecs = new Ecs({Components: {A}, Systems: {}});
const ecs = new Ecs({Components: {A, D, E}, Systems: {}});
ecs.apply(new Map([[32, {}]]))
const entity = ecs.entities.get(32);
expect(entity).not.to.be.undefined;
@ -122,4 +135,55 @@ test('apply', () => {
ecs.apply(new Map([[32, false]]))
expect(ecs.entities.get(32)).to.be.undefined;
expect(ecs.diff()).to.deep.equal(new Map());
const d = ecs.create({D: {d: 32, e: 33}});
ecs.apply(new Map([[d.id, {D: {e: 34}}]]))
expect(d.D.d).to.equal(32);
expect(d.D.e).to.equal(34);
const e = ecs.create({E: {e: {f: 4}}});
ecs.apply(new Map([[e.id, {E: {e: {g: 5}}}]]))
expect(e.E.e.f).to.equal(4);
expect(e.E.e.g).to.equal(5);
});
test('sparse array', async () => {
registerCodecs(Codecs);
class TileLayers extends Component {
static componentName = 'TileLayers';
static properties = {
layers: {
type: 'array',
element: {
type: 'object',
properties: {
data: {
type: 'array',
element: {
type: 'uint16',
},
},
},
},
},
};
}
const data = Array(60 * 60).fill(162);
const Components = {D, TileLayers};
const ecs = new Ecs({Components, Systems: {}});
const entity = ecs.create({
TileLayers: {
layers: [
{
data,
},
],
},
});
ecs.setClean();
entity.TileLayers.layers.at(0).data.setAt(1, 2);
const schema = new Schema({type: 'ecstc-ecs', Components});
let view;
view = schema.encode(ecs.diff());
entity.TileLayers.layers.at(0).data.setAt(1, 0);
ecs.apply(schema.decode(view));
expect(entity.TileLayers.layers.at(0).data.at(1)).to.equal(2);
});

View File

@ -1,4 +1,4 @@
import {definitions, Properties} from './properties.js';
import {define, Properties} from './properties.js';
const properties = import.meta.glob(
['./properties/*.js', '!./properties/*.test.js'],
@ -21,6 +21,8 @@ for (const numberType of [
delete Properties.number;
export {definitions, Properties};
export {default as registerCodecs} from './codecs.js';
export {default as Component} from './component.js';
export {default as Ecs} from './ecs.js';
export {default as System} from './system.js';
export {define, Properties};

View File

@ -1,23 +1,28 @@
export const Properties = {};
export function definitions(blueprint, key) {
const privateKey = `$$${key ?? 'value'}`;
return {
get: function() {
return this[privateKey];
},
set: function(value) {
if (this[privateKey] !== value) {
this[privateKey] = value;
if (this.markChange) {
if (key) {
this.markChange({[key]: value});
export function define(receiver, key, blueprint) {
const privateKey = `$$${key}`;
receiver[privateKey] = blueprint.defaultValue ?? this.defaultValue();
if (receiver.markChange) {
receiver.markChange({[key]: receiver[privateKey]});
}
Object.defineProperties(
receiver,
{
[key]: {
configurable: true,
get: () => {
return receiver[privateKey];
},
set: (value) => {
if (receiver[privateKey] !== value) {
receiver[privateKey] = value;
if (receiver.markChange) {
receiver.markChange({[key]: value});
}
}
else {
this.markChange(value);
}
}
}
},
};
},
},
}
);
}

View File

@ -1,93 +1,163 @@
import {Properties} from '../properties.js';
const ArrayProperty = {
defaultValue(value = [], blueprint) {
const defaultValue = [];
const elementBlueprint = blueprint.element;
const ElementClass = Properties[elementBlueprint.type];
for (const key in value) {
defaultValue[key] = ElementClass.defaultValue(value[key] ?? blueprint.defaultValue?.[key], elementBlueprint);
}
return defaultValue;
defaultValue() {
return [];
},
definitions(blueprint, key) {
define(receiver, key, blueprint) {
let isPrimitiveType = false;
switch (blueprint.element.type) {
case 'bool':
case 'int8': case 'uint8':
case 'int16': case 'uint16':
case 'int32': case 'uint32':
case 'float32': case 'float64':
case 'string': {
isPrimitiveType = true;
break;
}
}
const privateKey = `$$${key}`;
const privateArrayKey = `$$array$$${key}`;
return {
get: function() {
return this[privateKey];
let coalescing = true;
let coalesced;
const flushCoalesced = () => {
if (coalesced) {
if (receiver.markChange) {
receiver.markChange({[key]: coalesced});
}
coalesced = undefined;
}
coalescing = false;
}
receiver[privateKey] = [];
const toJSON = () => {
return receiver[privateKey].map((element) => {
return 'object' === typeof element ? element.toJSON() : element;
});
};
if (receiver.markChange) {
receiver[privateKey].markChange = (change) => {
if (!coalescing) {
if (receiver.markChange) {
receiver.markChange({[key]: change});
}
return;
}
if (!coalesced) {
coalesced = {};
}
coalesced = {
...coalesced,
...change,
};
};
}
const api = {
[Symbol.iterator]: () => {
if (isPrimitiveType) {
return receiver[privateKey].values();
}
const protocol = toJSON().values();
return {
next: () => {
let result = protocol.next();
if (result.done) {
return {done: true};
}
return {done: false, value: result.value};
},
};
},
set: function(value) {
if (this[privateArrayKey] !== value) {
const elementBlueprint = blueprint.element;
const ElementClass = Properties[elementBlueprint.type];
this[privateArrayKey] = value;
this[privateKey] = Object.defineProperties({}, {
[Symbol.iterator]: {
value: () => {
const protocol = this[privateArrayKey].values();
return {
next: () => {
let result = protocol.next();
if (result.done) {
return {done: true};
}
return {done: false, value: result.value?.proxy};
},
};
},
},
at: {
value: (index) => this[privateArrayKey].at(index).proxy,
},
setAt: {
value: (index, value) => {
let suppress = true;
this[privateArrayKey][index] = Object.defineProperties(
{
markChange: (change) => {
if (!suppress) {
if (this.markChange) {
if (key) {
this.markChange({[key]: {[index]: change}});
}
else {
this.markChange({[index]: change});
}
}
}
},
},
{
proxy: ElementClass.definitions(
elementBlueprint,
),
},
);
this[privateArrayKey][index].proxy = value;
if (this.markChange) {
if (key) {
this.markChange({[key]: {[index]: value}});
}
else {
this.markChange({[index]: value});
}
}
suppress = false;
},
},
toJSON: {
value: () => {
return Array.from(this[privateKey]);
},
},
});
for (const key in value) {
this[privateKey].setAt(key, ElementClass.defaultValue(value?.[key] ?? blueprint.defaultValue?.[key], elementBlueprint));
at: (index) => {
return receiver[privateKey][index];
},
get length() {
return receiver[privateKey].length;
},
merge(value) {
coalescing = true;
if (isPrimitiveType) {
const change = {};
for (const elementKey in value) {
receiver[privateKey][elementKey] = value[elementKey];
change[elementKey] = value[elementKey];
}
if (receiver.markChange) {
receiver[privateKey].markChange(change);
}
}
else {
for (const elementKey in value) {
receiver[key].setAt(elementKey, value[elementKey]);
}
}
flushCoalesced();
},
setAt: (index, value) => {
const elementBlueprint = blueprint.element;
let wasCoalescing = coalescing;
coalescing = true;
if (isPrimitiveType) {
receiver[privateKey][index] = value;
if (receiver.markChange) {
receiver[privateKey].markChange({[index]: value});
}
}
else {
if (!(index in receiver[privateKey])) {
Properties[elementBlueprint.type].define(
receiver[privateKey],
index,
elementBlueprint,
);
}
receiver[privateKey][index] = value;
}
if (!wasCoalescing) {
flushCoalesced();
}
},
toJSON,
};
Object.defineProperties(
receiver,
{
[key]: {
configurable: true,
get() {
return api;
},
set(value) {
this[privateKey].length = 0;
// fast paths
if (isPrimitiveType) {
coalescing = false;
const change = {};
for (const elementKey in value) {
this[privateKey][elementKey] = value[elementKey];
change[elementKey] = value[elementKey];
}
if (receiver.markChange) {
receiver[privateKey].markChange(change);
}
return;
}
coalescing = true;
for (const elementKey in value) {
this[key].setAt(elementKey, value[elementKey]);
}
flushCoalesced();
},
},
}
);
if ('defaultValue' in blueprint) {
receiver[key] = blueprint.defaultValue;
}
else {
flushCoalesced();
}
},
};

View File

@ -1,4 +1,4 @@
import {expect, test} from 'vitest';
import {expect, test, vi} from 'vitest';
import {Properties} from '../properties.js';
import ArrayProperty from './array.js';
@ -7,54 +7,139 @@ import NumberProperty from './number.js';
Properties.array = ArrayProperty;
Properties.object = ObjectProperty;
Properties.uint16 = NumberProperty;
Properties.uint8 = NumberProperty;
test('array', () => {
let changeIndex = 0;
const receiver = Object.defineProperties(
{
markChange: (change) => {
switch (changeIndex++) {
case 0: {
expect(change).to.deep.equal({layers: {0: {data: []}}});
break;
}
case 1: {
expect(change).to.deep.equal({layers: {0: {data: {10: 1}}}});
break;
}
}
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ArrayProperty.define(receiver, 'property', {
element: {type: 'uint8'},
});
receiver.property.setAt(0, 6);
expect(markChange).toHaveBeenCalledWith({property: {0: 6}});
});
test('defaults primitive', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = [1, 2, 3];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {type: 'uint8'},
});
expect(markChange).toHaveBeenCalledWith({property: {0: 1, 1: 2, 2: 3}});
receiver.property.setAt(3, 6);
expect(markChange).toHaveBeenCalledWith({property: {3: 6}});
});
test('defaults nesting', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = [{x: 1}];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {
type: 'object',
properties: {
x: {type: 'uint8'},
},
},
{
$$: ObjectProperty.definitions(
{
properties: {
layers: {
element: {
properties: {
data: {
element: {type: 'uint16'},
type: 'array',
},
},
type: 'object',
},
type: 'array',
},
},
type: 'object',
},
),
},
);
receiver.$$ = {layers: [{data: []}]};
const object = receiver.$$;
object.layers.at(0).data.setAt(10, 1);
expect(changeIndex).to.equal(2);
const data = [];
for (const i of object.layers.at(0).data) {
data.push(i);
}
expect(data).to.deep.equal(object.layers.at(0).data.toJSON());
});
expect(markChange).toHaveBeenCalledWith({property: {0: {x: 1}}});
receiver.property.at(0).x = 123;
expect(markChange).toHaveBeenCalledWith({property: {0: {x: 123}}});
});
test('toJSON primitve', () => {
const receiver = {};
ArrayProperty.define(receiver, 'property', {
defaultValue: [1, 2, 3],
element: {type: 'uint8'},
});
expect(receiver.property.toJSON()).to.deep.equal([1, 2, 3])
});
test('toJSON nesting', () => {
const receiver = {};
const defaultValue = [{x: 1}];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {
type: 'object',
properties: {
x: {type: 'uint8'},
},
},
});
expect(receiver.property.toJSON()).to.deep.equal(defaultValue);
});
test('iterate primitive', () => {
const receiver = {markChange: () => {}};
const defaultValue = [1, 2, 3];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {type: 'uint8'},
});
const i = [];
for (const element of receiver.property) {
i.push(element);
}
expect(i).to.deep.equal(defaultValue);
});
test('iterate nesting', () => {
const receiver = {};
const defaultValue = [{x: 1}];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {
type: 'object',
properties: {
x: {type: 'uint8'},
},
},
});
const i = [];
for (const element of receiver.property) {
i.push(element);
}
expect(i).to.deep.equal(defaultValue);
});
test('merge', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = [1, 2, 3];
ArrayProperty.define(receiver, 'property', {
defaultValue,
element: {type: 'uint8'},
});
expect(markChange).toHaveBeenCalledWith({property: {0: 1, 1: 2, 2: 3}});
receiver.property.merge({1: 5, 2: 10});
expect(markChange).toHaveBeenCalledWith({property: {1: 5, 2: 10}});
expect(receiver.property.toJSON()).deep.equal([1, 5, 10]);
});
test('length', () => {
const receiver = {};
ArrayProperty.define(receiver, 'property', {
defaultValue: [1, 2, 3],
element: {type: 'uint8'},
});
expect(receiver.property.length).to.equal(3);
receiver.property.setAt(2, 3);
expect(receiver.property.length).to.equal(3);
receiver.property.setAt(3, 4);
expect(receiver.property.length).to.equal(4);
});
test('full set', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ArrayProperty.define(receiver, 'property', {
element: {type: 'uint8'},
});
receiver.property = [1, 2, 3];
expect(markChange).toHaveBeenCalledWith({property: {0: 1, 1: 2, 2: 3}});
});

View File

@ -1,10 +1,10 @@
import {definitions} from '../properties.js'
import {define} from '../properties.js'
const BoolProperty = {
defaultValue(value, blueprint) {
return value ?? blueprint.defaultValue ?? false;
defaultValue() {
return false;
},
definitions,
define,
};
export default BoolProperty;

View File

@ -1,109 +1,164 @@
import {Properties} from '../properties.js';
const MapProperty = {
defaultValue(value = [], blueprint) {
const defaultValue = new Map();
const elementValueBlueprint = blueprint.value;
const ElementValueClass = Properties[elementValueBlueprint.type];
for (const [key, mapValue] in value) {
defaultValue.set(
key,
ElementValueClass.defaultValue(
mapValue ?? blueprint.defaultValue?.[key],
elementValueBlueprint,
),
);
}
return defaultValue;
defaultValue() {
return new Map();
},
definitions(blueprint, key) {
define(receiver, key, blueprint) {
const {defaultValue} = blueprint;
const privateKey = `$$${key}`;
const privateMapKey = `$$map$$${key}`;
return {
get: function() {
return this[privateKey];
let fakeKeyCaret = 0;
const fakeKeys = new Map();
const reversedFakeKeys = new Map();
let coalescing = true;
let coalesced;
const flushCoalesced = () => {
if (coalesced) {
if (receiver.markChange) {
receiver.markChange({[key]: coalesced});
}
coalesced = undefined;
}
coalescing = false;
}
receiver[privateKey] = {};
const api = {
[Symbol.iterator]: () => {
const protocol = fakeKeys.entries();
return {
next: () => {
let result = protocol.next();
if (result.done) {
return {done: true};
}
const [key, fakeKey] = result.value;
return {done: false, value: [key, receiver[privateKey][fakeKey]]};
},
};
},
set: function(value) {
if (this[privateMapKey] !== value) {
const elementValueBlueprint = blueprint.value;
const ElementValueClass = Properties[elementValueBlueprint.type];
this[privateMapKey] = new Map(value);
this[privateKey] = Object.defineProperties({}, {
[Symbol.iterator]: {
value: () => {
const protocol = this[privateMapKey].entries();
return {
next: () => {
let result = protocol.next();
if (result.done) {
return {done: true};
}
const [key, mapValue] = result.value;
return {done: false, value: [key, mapValue?.proxy]};
},
};
},
},
get: {
value: (index) => this[privateMapKey].get(index).proxy,
},
set: {
value: (mapKey, value) => {
let suppress = true;
this[privateMapKey].set(
mapKey,
Object.defineProperties(
{
markChange: (change) => {
if (!suppress) {
if (this.markChange) {
if (key) {
this.markChange({[key]: change});
}
else {
this.markChange(change);
}
}
}
},
},
{
proxy: ElementValueClass.definitions(
elementValueBlueprint,
),
},
),
);
this[privateMapKey].get(mapKey).proxy = value;
if (this.markChange) {
if (key) {
this.markChange({[key]: {[mapKey]: value}});
}
else {
this.markChange({[mapKey]: value});
}
}
suppress = false;
},
},
toJSON: {
value: () => {
return Array.from(this[privateKey]);
},
},
});
for (const [key, mapValue] of value) {
this[privateKey].set(
key,
ElementValueClass.defaultValue(
mapValue ?? blueprint.defaultValue?.[key],
elementValueBlueprint,
),
);
}
clear() {
let wasCoalescing = coalescing;
coalescing = true;
for (const [key] of fakeKeys) {
this.delete(key);
}
if (!wasCoalescing) {
flushCoalesced();
}
},
delete(mapKey) {
const fakeKey = fakeKeys.get(mapKey);
delete receiver[privateKey][fakeKey];
if (receiver[privateKey].markChange) {
receiver[privateKey].markChange({[fakeKey]: undefined});
}
fakeKeys.delete(mapKey);
reversedFakeKeys.delete(`${fakeKey}`);
},
get(key) {
return receiver[privateKey][fakeKeys.get(key)];
},
has(key) {
return fakeKeys.has(key);
},
merge(value) {
coalescing = true;
for (const propertyKey in value) {
const parsedKey = !isNaN(propertyKey) ? parseFloat(propertyKey) : propertyKey;
if (undefined === value[propertyKey]) {
this.delete(parsedKey);
}
else {
this.set(parsedKey, value[propertyKey]);
}
}
flushCoalesced();
},
set(key, value) {
let wasCoalescing = coalescing;
coalescing = true;
if (!fakeKeys.has(key)) {
fakeKeys.set(key, fakeKeyCaret);
reversedFakeKeys.set(`${fakeKeyCaret}`, key);
fakeKeyCaret += 1;
}
const fakeKey = fakeKeys.get(key);
if (!(fakeKeys.get(key) in receiver[privateKey])) {
Properties[blueprint.value.type].define(
receiver[privateKey],
fakeKey,
blueprint.value,
);
}
receiver[privateKey][fakeKey] = value;
if (!wasCoalescing) {
flushCoalesced();
}
},
get size() {
return fakeKeys.size;
},
toJSON() {
const json = [];
for (const [fakeKey, key] of reversedFakeKeys) {
let value;
if ('object' === typeof receiver[privateKey][fakeKey]) {
value = receiver[privateKey][fakeKey].toJSON();
}
else {
value = receiver[privateKey][fakeKey];
}
json.push([key, value]);
}
return json;
},
};
if (receiver.markChange) {
receiver[privateKey].markChange = (change) => {
const translatedChange = {};
for (const key in change) {
translatedChange[reversedFakeKeys.get(key)] = change[key];
}
if (!coalescing) {
if (receiver.markChange) {
receiver.markChange({[key]: translatedChange});
}
return;
}
if (!coalesced) {
coalesced = {};
}
coalesced = {
...coalesced,
...translatedChange,
};
};
}
Object.defineProperties(
receiver,
{
[key]: {
configurable: true,
get: function() {
return api;
},
set: function(iterable) {
coalescing = true;
api.clear();
for (const [key, value] of iterable) {
api.set(key, value);
}
flushCoalesced();
},
},
}
);
if (defaultValue) {
receiver[key] = defaultValue;
}
else {
flushCoalesced();
}
},
};

View File

@ -1,46 +1,122 @@
import {expect, test} from 'vitest';
import {expect, test, vi} from 'vitest';
import {Properties} from '../properties.js';
import MapProperty from './map.js';
import NumberProperty from './number.js';
import ObjectProperty from './object.js';
Properties.map = MapProperty;
Properties.object = ObjectProperty;
Properties.uint16 = NumberProperty;
test('map', () => {
let changeIndex = 0;
const receiver = Object.defineProperties(
{
markChange: (change) => {
switch (changeIndex++) {
case 0: {
expect(change).to.deep.equal({0: 1});
break;
}
case 1: {
expect(change).to.deep.equal({1: 2});
break;
}
}
test('map api', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
MapProperty.define(receiver, 'property', {
key: {type: 'uint16'},
value: {type: 'uint16'},
});
expect(receiver.property.has(1)).to.be.false;
expect(receiver.property.size).to.equal(0);
receiver.property.set(1, 2);
expect(markChange).toHaveBeenCalledWith({property: {1: 2}});
expect(receiver.property.size).to.equal(1);
receiver.property.set(2, 3);
expect(markChange).toHaveBeenCalledWith({property: {2: 3}});
expect(receiver.property.size).to.equal(2);
receiver.property.set(3, 4);
expect(markChange).toHaveBeenCalledWith({property: {3: 4}});
expect(receiver.property.size).to.equal(3);
expect(receiver.property.has(1)).to.be.true;
receiver.property.delete(1);
expect(markChange).toHaveBeenCalledWith({property: {1: undefined}});
expect(receiver.property.size).to.equal(2);
expect(receiver.property.has(1)).to.be.false;
receiver.property.clear();
expect(markChange).toHaveBeenCalledWith({property: {2: undefined, 3: undefined}});
expect(receiver.property.size).to.equal(0);
});
test('iterate', () => {
const receiver = {};
const defaultValue = [[1, 2], [3, 4]];
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {type: 'uint16'},
});
const i = [];
for (const tuple of receiver.property) {
i.push(tuple);
}
expect(i).to.deep.equal(defaultValue);
});
test('primitive map default', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = new Map([[1, 2], [3, 4]]);
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {type: 'uint16'},
});
expect(markChange).toHaveBeenCalledWith({property: {1: 2, 3: 4}});
});
test('iterable default', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = [[1, 2], [3, 4]];
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {type: 'uint16'},
});
expect(markChange).toHaveBeenCalledWith({property: {1: 2, 3: 4}});
});
test('nested default', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
const defaultValue = new Map([[1, {x: 2}], [2, {x: 3}]])
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {
type: 'object',
properties: {
x: {type: 'uint16'},
},
},
{
$$: MapProperty.definitions(
{
key: {type: 'uint16'},
value: {type: 'uint16'},
type: 'map',
},
),
},
);
receiver.$$ = new Map([[0, 1]]);
const map = receiver.$$;
map.set(1, 2);
expect(changeIndex).to.equal(2);
const data = [];
for (const i of map) {
data.push(i);
}
expect(data).to.deep.equal(map.toJSON());
});
expect(markChange).toHaveBeenCalledWith({property: {1: {x: 2}, 2: {x: 3}}});
receiver.property.get(1).x = 123;
expect(markChange).toHaveBeenCalledWith({property: {1: {x: 123}}});
});
test('merge', () => {
const receiver = {markChange: () => {}};
const defaultValue = new Map([[1, 2], [3, 4]]);
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {type: 'uint16'},
});
receiver.property.merge({5: 6, 7: 8});
expect(receiver.property.toJSON()).to.deep.equal([[1, 2], [3, 4], [5, 6], [7, 8]])
receiver.property.merge({1: undefined, 3: undefined});
expect(receiver.property.toJSON()).to.deep.equal([[5, 6], [7, 8]])
});
test('full set', () => {
const receiver = {markChange: () => {}};
const defaultValue = new Map([[1, 2], [3, 4]]);
MapProperty.define(receiver, 'property', {
defaultValue,
key: {type: 'uint16'},
value: {type: 'uint16'},
});
receiver.property = new Map([[5, 6], [7, 8]])
expect(receiver.property.toJSON()).to.deep.equal([[5, 6], [7, 8]])
});

View File

@ -1,10 +1,10 @@
import {definitions} from '../properties.js'
import {define} from '../properties.js'
const NumberProperty = {
defaultValue(value, blueprint) {
return value ?? blueprint.defaultValue ?? 0;
defaultValue() {
return 0;
},
definitions,
define,
};
export default NumberProperty;

View File

@ -1,99 +1,101 @@
import {Properties} from '../properties.js';
const ObjectProperty = {
defaultValue(value, blueprint) {
const defaultValue = {};
for (const key in blueprint.properties) {
const propertyBlueprint = blueprint.properties[key];
const PropertyClass = Properties[propertyBlueprint.type];
defaultValue[key] = PropertyClass.defaultValue(
value?.[key] ?? blueprint.defaultValue?.[key],
defaultValue() {
return {};
},
define(receiver, key, blueprint) {
const {defaultValue} = blueprint;
const privateKey = `$$${key}`;
let coalescing = true;
let coalesced;
const flushCoalesced = () => {
if (coalesced) {
if (receiver.markChange) {
receiver.markChange({[key]: coalesced});
}
coalesced = undefined;
}
coalescing = false;
}
receiver[privateKey] = {
merge(value) {
coalescing = true;
for (const propertyKey in value) {
receiver[key][propertyKey] = value[propertyKey]
}
flushCoalesced();
},
toJSON() {
const json = {};
for (const key in blueprint.properties) {
if ('object' === typeof this[key]) {
json[key] = this[key].toJSON();
}
else {
json[key] = this[key];
}
}
return json;
},
};
if (receiver.markChange) {
receiver[privateKey].markChange = (change) => {
if (!coalescing) {
if (receiver.markChange) {
receiver.markChange({[key]: change});
}
return;
}
if (!coalesced) {
coalesced = {};
}
coalesced = {
...coalesced,
...change,
};
};
}
for (const propertyKey in blueprint.properties) {
const propertyBlueprint = blueprint.properties[propertyKey];
Properties[propertyBlueprint.type].define(
receiver[privateKey],
propertyKey,
propertyBlueprint,
);
}
return defaultValue;
},
definitions(blueprint, key) {
const privateKey = `$$${key}`;
return {
get: function() {
return this[privateKey];
},
set: function(value) {
if (this[privateKey] !== value) {
let coalescing = true;
let coalesced;
const properties = {};
for (const propertyKey in blueprint.properties) {
const propertyBlueprint = blueprint.properties[propertyKey];
properties[propertyKey] = Properties[propertyBlueprint.type].definitions(
propertyBlueprint,
propertyKey,
);
}
this[privateKey] = Object.defineProperties(
(this.markChange && {
markChange: (change) => {
if (!coalescing) {
if (this.markChange) {
if (key) {
this.markChange({[key]: change});
}
else {
this.markChange(change);
}
}
return;
}
if (!coalesced) {
coalesced = {};
}
coalesced = {
...coalesced,
...change,
}
},
}),
{
...properties,
toJSON: {
value: () => {
const json = {};
for (const key in properties) {
if ('object' === typeof this[privateKey][key]) {
json[key] = this[privateKey][key].toJSON();
}
else {
json[key] = this[privateKey][key];
}
}
return json;
},
},
},
);
for (const key in properties) {
const propertyBlueprint = blueprint.properties[key];
this[privateKey][key] = Properties[propertyBlueprint.type].defaultValue(
value?.[key] ?? blueprint.defaultValue?.[key],
propertyBlueprint,
);
}
if (coalesced) {
if (this.markChange) {
if (key) {
this.markChange({[key]: coalesced});
Object.defineProperties(
receiver,
{
[key]: {
get: () => {
return receiver[privateKey];
},
set: (value) => {
coalescing = true;
for (const propertyKey in blueprint.properties) {
const propertyBlueprint = blueprint.properties[propertyKey];
if (propertyKey in value) {
receiver[key][propertyKey] = value[propertyKey];
}
else if ('defaultValue' in propertyBlueprint) {
receiver[key][propertyKey] = propertyBlueprint.defaultValue;
}
else {
this.markChange(coalesced);
receiver[key][propertyKey] = Properties[propertyBlueprint.type].defaultValue();
}
}
coalesced = undefined;
}
coalescing = false;
}
},
};
flushCoalesced();
},
},
}
);
if (defaultValue) {
receiver[key] = defaultValue;
}
else {
flushCoalesced();
}
},
};

View File

@ -1,4 +1,4 @@
import {expect, test} from 'vitest';
import {expect, test, vi} from 'vitest';
import {Properties} from '../properties.js';
import ObjectProperty from './object.js';
@ -7,63 +7,91 @@ import StringProperty from './string.js';
Properties.object = ObjectProperty;
Properties.string = StringProperty;
test('object', () => {
const defaultValue = {x: {y: '2', z: 'asd'}};
let changeIndex = 0;
const receiver = Object.defineProperties(
{
markChange(change) {
switch (changeIndex++) {
case 0: {
expect(change).to.deep.equal({x: {y: '2', z: 'asd'}});
break;
}
case 1: {
expect(change).to.deep.equal({x: {y: '123'}});
break;
}
case 2: {
expect(change).to.deep.equal({x: {y: '', z: 'zed'}});
break;
}
case 3: {
expect(change).to.deep.equal({x: {y: '12'}});
break;
}
}
test('property defaults', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ObjectProperty.define(receiver, 'property', {
properties: {
x: {defaultValue: 'zed', type: 'string'},
y: {
properties: {
z: {defaultValue: 'sdf', type: 'string'},
},
type: 'object',
},
},
{
$$: ObjectProperty.definitions(
{
defaultValue,
properties: {
x: {
properties: {
y: {type: 'string'},
z: {defaultValue: 'zed', type: 'string'},
},
type: 'object',
},
},
type: 'object',
},
),
},
);
receiver.$$ = {};
const object = receiver.$$;
expect(object.x.y).to.equal(defaultValue.x.y);
expect(object.x.z).to.equal(defaultValue.x.z);
object.x.y = '123';
expect(object.x.y).to.equal('123');
expect(object.x.z).to.equal(defaultValue.x.z);
object.x = {};
expect(object.x.y).to.equal('');
expect(object.x.z).to.equal('zed');
object.x.y = '12';
expect(object.x.y).to.equal('12');
expect(object.x.z).to.equal('zed');
expect(changeIndex).to.equal(4);
expect(object.toJSON()).to.deep.equal({x: {y: '12', z: 'zed'}});
});
expect(markChange).toHaveBeenCalledWith({property: {x: 'zed', y: {z: 'sdf'}}});
expect(receiver.property.x).to.equal('zed');
receiver.property.y.z = 'asd';
expect(markChange).toHaveBeenCalledWith({property: {y: {z: 'asd'}}});
});
test('blueprint defaults', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ObjectProperty.define(receiver, 'property', {
defaultValue: {x: 'foo'},
properties: {
x: {defaultValue: 'zed', type: 'string'},
y: {defaultValue: 'moo', type: 'string'},
},
});
expect(markChange).toHaveBeenCalledWith({property: {x: 'foo', y: 'moo'}});
expect(receiver.property.x).to.equal('foo');
});
test('set', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ObjectProperty.define(receiver, 'property', {
properties: {
x: {defaultValue: 'zed', type: 'string'},
y: {type: 'string'},
},
});
expect(markChange).toHaveBeenCalledWith({property: {x: 'zed', y: ''}});
receiver.property.x = 'nope';
expect(markChange).toHaveBeenCalledWith({property: {x: 'nope'}});
receiver.property = {y: 'foo'};
expect(markChange).toHaveBeenCalledWith({property: {x: 'zed', y: 'foo'}});
expect(receiver.property.x).to.equal('zed');
expect(receiver.property.y).to.equal('foo');
});
test('merge', () => {
const receiver = {markChange: () => {}};
const markChange = vi.spyOn(receiver, 'markChange');
ObjectProperty.define(receiver, 'property', {
properties: {
x: {defaultValue: 'zed', type: 'string'},
y: {type: 'string'},
z: {type: 'string'},
},
});
expect(markChange).toHaveBeenCalledWith({property: {x: 'zed', y: '', z: ''}});
receiver.property.x = 'nope';
expect(markChange).toHaveBeenCalledWith({property: {x: 'nope'}});
receiver.property.merge({y: 'foo', z: 'bar'});
expect(markChange).toHaveBeenCalledWith({property: {y: 'foo', z: 'bar'}});
expect(receiver.property.x).to.equal('nope');
expect(receiver.property.y).to.equal('foo');
expect(receiver.property.z).to.equal('bar');
});
test('toJSON', () => {
const receiver = {};
ObjectProperty.define(receiver, 'property', {
properties: {
x: {
properties: {
y: {type: 'string'},
z: {defaultValue: 'zed', type: 'string'},
},
type: 'object',
},
},
});
receiver.property.x.y = 'asd';
expect(receiver.property.toJSON()).to.deep.equal({x: {y: 'asd', z: 'zed'}});
});

View File

@ -1,10 +1,10 @@
import {definitions} from '../properties.js'
import {define} from '../properties.js'
const StringProperty = {
defaultValue(value, blueprint) {
return value ?? blueprint.defaultValue ?? '';
defaultValue() {
return '';
},
definitions,
define,
};
export default StringProperty;

View File

@ -1,13 +1,18 @@
import ArrayProperty from './properties/array.js';
import NumberProperty from './properties/number.js';
import ObjectProperty from './properties/object.js';
import StringProperty from './properties/string.js';
import Entity from './entity.js';
import {Properties} from './properties.js';
import Component from './component.js';
Properties.array = ArrayProperty;
Properties.object = ObjectProperty;
Properties.float32 = NumberProperty;
Properties.float64 = NumberProperty;
Properties.uint16 = NumberProperty;
Properties.int32 = NumberProperty;
Properties.string = StringProperty;
export function wrapComponents(Components) {
return Components
@ -28,12 +33,14 @@ export function fakeEnvironment() {
['B', {b: {type: 'int32', defaultValue: 32}}],
['C', {c: {type: 'int32'}}],
['D', {d: {type: 'float64'}, e: {type: 'float64'}}],
['E', {e: {type: 'object', properties: {f: {type: 'int32'}, g: {type: 'int32'}}}}],
]);
const fakeEcs = {markChange() {}};
const A = new Components.A(fakeEcs);
const B = new Components.B(fakeEcs);
const C = new Components.C(fakeEcs);
const D = new Components.D(fakeEcs);
const E = new Components.E(fakeEcs);
const one = new Entity(1);
one.addComponent(B);
const two = new Entity(2);
@ -42,6 +49,6 @@ export function fakeEnvironment() {
two.addComponent(C);
const three = new Entity(3);
three.addComponent(A);
return {A, B, C, D, Components, one, two, three};
return {A, B, C, D, E, Components, one, two, three};
}