diff --git a/app/ecs-components/animation.js b/app/ecs-components/animation.js new file mode 100644 index 0000000..8655aec --- /dev/null +++ b/app/ecs-components/animation.js @@ -0,0 +1,3 @@ +export default { + frame: {type: 'uint16'}, +}; diff --git a/app/ecs-components/area-size.js b/app/ecs-components/area-size.js index d216b4d..6eedd1d 100644 --- a/app/ecs-components/area-size.js +++ b/app/ecs-components/area-size.js @@ -1,4 +1,4 @@ export default { - x: 'uint16', - y: 'uint16', + x: {type: 'uint16'}, + y: {type: 'uint16'}, } diff --git a/app/ecs-components/camera.js b/app/ecs-components/camera.js index d216b4d..6eedd1d 100644 --- a/app/ecs-components/camera.js +++ b/app/ecs-components/camera.js @@ -1,4 +1,4 @@ export default { - x: 'uint16', - y: 'uint16', + x: {type: 'uint16'}, + y: {type: 'uint16'}, } diff --git a/app/ecs-components/controlled.js b/app/ecs-components/controlled.js index 1f669ae..a663d5c 100644 --- a/app/ecs-components/controlled.js +++ b/app/ecs-components/controlled.js @@ -1,6 +1,6 @@ export default { - up: 'float32', - right: 'float32', - down: 'float32', - left: 'float32', + up: {type: 'float32'}, + right: {type: 'float32'}, + down: {type: 'float32'}, + left: {type: 'float32'}, }; diff --git a/app/ecs-components/momentum.js b/app/ecs-components/momentum.js index 7843fcc..54fac76 100644 --- a/app/ecs-components/momentum.js +++ b/app/ecs-components/momentum.js @@ -1,4 +1,4 @@ export default { - x: 'float32', - y: 'float32', + x: {type: 'float32'}, + y: {type: 'float32'}, } diff --git a/app/ecs-components/position.js b/app/ecs-components/position.js index 22b1ebf..a0e637a 100644 --- a/app/ecs-components/position.js +++ b/app/ecs-components/position.js @@ -1,4 +1,4 @@ export default { - x: 'float32', - y: 'float32', + x: {type: 'float32'}, + y: {type: 'float32'}, }; diff --git a/app/ecs-components/sprite.js b/app/ecs-components/sprite.js index b74d8fa..61677a7 100644 --- a/app/ecs-components/sprite.js +++ b/app/ecs-components/sprite.js @@ -1,3 +1,3 @@ export default { - image: 'string', + image: {type: 'string'}, }; diff --git a/app/ecs-components/visible-aabb.js b/app/ecs-components/visible-aabb.js index 0fca1a8..bfceaf1 100644 --- a/app/ecs-components/visible-aabb.js +++ b/app/ecs-components/visible-aabb.js @@ -1,6 +1,6 @@ export default { - x0: 'float32', - x1: 'float32', - y0: 'float32', - y1: 'float32', + x0: {type: 'float32'}, + x1: {type: 'float32'}, + y0: {type: 'float32'}, + y1: {type: 'float32'}, } diff --git a/app/ecs-components/world.js b/app/ecs-components/world.js index 1647bc0..047d03e 100644 --- a/app/ecs-components/world.js +++ b/app/ecs-components/world.js @@ -1,3 +1,3 @@ export default { - world: 'uint16', + world: {type: 'uint16'}, } diff --git a/app/ecs/arbitrary.js b/app/ecs/arbitrary.js index 06ec07c..2d0ec0b 100644 --- a/app/ecs/arbitrary.js +++ b/app/ecs/arbitrary.js @@ -22,7 +22,7 @@ export default class Arbitrary extends Base { createMany(entries) { if (entries.length > 0) { const allocated = this.allocateMany(entries.length); - const keys = Object.keys(this.constructor.schema.specification); + const keys = Object.keys(this.constructor.properties); for (let i = 0; i < entries.length; ++i) { const [entityId, values = {}] = entries[i]; this.map[entityId] = allocated[i]; @@ -32,7 +32,7 @@ export default class Arbitrary extends Base { } for (let k = 0; k < keys.length; ++k) { const j = keys[k]; - const {defaultValue} = this.constructor.schema.specification[j]; + const {defaultValue} = this.constructor.properties[j]; if (j in values) { this.data[allocated[i]][j] = values[j]; } @@ -45,7 +45,12 @@ export default class Arbitrary extends Base { } deserialize(entityId, view, offset) { - this.constructor.schema.deserialize(view, this.get(entityId), offset); + const {properties} = this.constructor; + const instance = this.get(entityId); + const deserialized = this.constructor.schema.deserialize(view, offset); + for (const key in properties) { + instance[key] = deserialized[key]; + } } serialize(entityId, view, offset) { @@ -64,8 +69,10 @@ export default class Arbitrary extends Base { this.$$reset(); } $$reset() { - for (const [i, {defaultValue}] of Component.constructor.schema) { - this[`$$${i}`] = defaultValue; + const {properties} = Component.constructor; + for (const key in properties) { + const {defaultValue} = properties[key]; + this[`$$${key}`] = defaultValue; } } toJSON() { @@ -82,15 +89,15 @@ export default class Arbitrary extends Base { this.$$reset(); }, }; - for (const [i] of Component.constructor.schema) { - properties[i] = { + for (const key in Component.constructor.properties) { + properties[key] = { get: function get() { - return this[`$$${i}`]; + return this[`$$${key}`]; }, - set: function set(v) { - if (this[`$$${i}`] !== v) { - this[`$$${i}`] = v; - Component.markChange(this.entity, i, v); + set: function set(value) { + if (this[`$$${key}`] !== value) { + this[`$$${key}`] = value; + Component.markChange(this.entity, key, value); } }, }; diff --git a/app/ecs/arbitrary.test.js b/app/ecs/arbitrary.test.js index 16ced67..ac59d18 100644 --- a/app/ecs/arbitrary.test.js +++ b/app/ecs/arbitrary.test.js @@ -5,17 +5,23 @@ import Arbitrary from './arbitrary.js'; test('creates instances', () => { class CreatingArbitrary extends Arbitrary { - static schema = new Schema({foo: {defaultValue: 'bar', type: 'string'}}); + static schema = new Schema({ + type: 'object', + properties: {foo: {defaultValue: 'bar', type: 'string'}}, + }); } const Component = new CreatingArbitrary(); - Component.create(1) + Component.create(1); expect(Component.get(1).entity) .to.equal(1); }); test('does not serialize default values', () => { class CreatingArbitrary extends Arbitrary { - static schema = new Schema({foo: {defaultValue: 'bar', type: 'string'}, bar: 'uint8'}); + static schema = new Schema({ + type: 'object', + properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}}, + }); } const Component = new CreatingArbitrary(); Component.create(1) @@ -28,7 +34,10 @@ test('does not serialize default values', () => { test('reuses instances', () => { class ReusingArbitrary extends Arbitrary { - static schema = new Schema({foo: {type: 'string'}}); + static schema = new Schema({ + type: 'object', + properties: {foo: {type: 'string'}}, + }); } const Component = new ReusingArbitrary(); Component.create(1); diff --git a/app/ecs/base.js b/app/ecs/base.js index 070fbe6..1250325 100644 --- a/app/ecs/base.js +++ b/app/ecs/base.js @@ -39,9 +39,10 @@ export default class Base { static filterDefaults(instance) { const json = {}; - for (const [i, {defaultValue}] of this.schema) { - if (i in instance && instance[i] !== defaultValue) { - json[i] = instance[i]; + for (const key in this.properties) { + const {defaultValue} = this.properties[key]; + if (key in instance && instance[key] !== defaultValue) { + json[key] = instance[key]; } } return json; @@ -77,6 +78,10 @@ export default class Base { return {...original, ...update}; } + static get properties() { + return this.schema.specification.properties; + } + sizeOf(entityId) { return this.constructor.schema.sizeOf(this.get(entityId)); } diff --git a/app/ecs/component.js b/app/ecs/component.js index bf60a77..285ed0c 100644 --- a/app/ecs/component.js +++ b/app/ecs/component.js @@ -9,6 +9,6 @@ export default function Component(specificationOrClass) { // Why the rigamarole? Maybe we'll implement a flat component for direct binary storage // eventually. return class AdhocComponent extends Arbitrary { - static schema = new Schema(specificationOrClass); + static schema = new Schema({type: 'object', properties: specificationOrClass}); }; } diff --git a/app/ecs/ecs.test.js b/app/ecs/ecs.test.js index a78c0f2..c90f1a6 100644 --- a/app/ecs/ecs.test.js +++ b/app/ecs/ecs.test.js @@ -6,13 +6,13 @@ import System from './system.js'; const Empty = {}; const Name = { - name: 'string', + name: {type: 'string'}, }; const Position = { x: {type: 'int32', defaultValue: 32}, - y: 'int32', - z: 'int32', + y: {type: 'int32'}, + z: {type: 'int32'}, }; test('adds and remove systems at runtime', () => { @@ -109,9 +109,9 @@ test('inserts components into entities', () => { test('ticks systems', () => { const Momentum = { - x: 'int32', - y: 'int32', - z: 'int32', + x: {type: 'int32'}, + y: {type: 'int32'}, + z: {type: 'int32'}, }; class TickEcs extends Ecs { static Types = {Momentum, Position}; @@ -203,7 +203,7 @@ test('schedules entities to be deleted when ticking systems', () => { test('adds components to and remove components from entities when ticking systems', () => { class TickingEcs extends Ecs { - static Types = {Foo: {bar: 'uint8'}}; + static Types = {Foo: {bar: {type: 'uint8'}}}; } const ecs = new TickingEcs(); let addLength, removeLength; diff --git a/app/ecs/query.test.js b/app/ecs/query.test.js index 8582c9e..565a793 100644 --- a/app/ecs/query.test.js +++ b/app/ecs/query.test.js @@ -5,7 +5,7 @@ import Query from './query.js'; const A = new (Component({a: {type: 'int32', defaultValue: 420}})); const B = new (Component({b: {type: 'int32', defaultValue: 69}})); -const C = new (Component({c: 'int32'})); +const C = new (Component({c: {type: 'int32'}})); const Types = {A, B, C}; Types.A.createMany([[2], [3]]); diff --git a/app/ecs/schema.js b/app/ecs/schema.js index f39d516..beb6fbf 100644 --- a/app/ecs/schema.js +++ b/app/ecs/schema.js @@ -34,51 +34,54 @@ export default class Schema { 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; + } + + static deserialize(view, offset, specification) { + const viewGetMethod = this.viewGetMethods[specification.type]; + if (viewGetMethod) { + const value = view[viewGetMethod](offset.value, true); + offset.value += this.size(specification); + return value; } - } - - [Symbol.iterator]() { - return Object.entries(this.specification).values(); - } - - deserialize(destination, view, offset = 0) { - let cursor = offset; - for (const key in this.specification) { - const {type} = this.specification[key]; - const viewGetMethod = Schema.viewGetMethods[type]; - let value; - if (viewGetMethod) { - value = view[viewGetMethod](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; - } + switch (specification.type) { + case 'array': { + const length = view.getUint32(offset.value, true); + offset.value += 4; + const value = []; + for (let i = 0; i < length; ++i) { + value.push(this.deserialize(view, offset, specification.subtype)); } + return value; + } + case 'object': { + const value = {}; + for (const key in specification.properties) { + value[key] = this.deserialize(view, offset, specification.properties[key]); + } + return value; + } + case 'string': { + const length = view.getUint32(offset.value, true); + offset.value += 4; + const {buffer, byteOffset} = view; + const decoder = new TextDecoder(); + const value = decoder.decode(new DataView(buffer, byteOffset + offset.value, length)); + offset.value += length; + return value; } - destination[key] = value; } } - static defaultValueForType(type) { - switch (type) { + deserialize(view, offset = 0) { + const wrapped = {value: offset}; + return this.constructor.deserialize(view, wrapped, this.specification); + } + + static defaultValue(specification) { + if (specification.defaultValue) { + return specification.defaultValue; + } + switch (specification.type) { case 'uint8': case 'int8': case 'uint16': case 'int16': case 'uint32': case 'int32': @@ -86,107 +89,194 @@ export default class Schema { case 'float32': case 'float64': { return 0; } + case 'array': { + return []; + } + case 'object': { + const object = {}; + for (const key in specification.properties) { + object[key] = this.defaultValue(specification.properties[key]); + } + return object; + } case 'string': { return ''; } } } + defaultValue() { + return this.constructor.defaultValue(this.specification); + } + 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}`); + let normalized = specification; + switch (specification.type) { + case 'array': { + normalized.subtype = this.normalize(specification.subtype); + break; } - normalized[i].defaultValue = normalized[i].defaultValue || this.defaultValueForType(normalized[i].type); + case 'object': { + for (const key in specification.properties) { + normalized.properties[key] = this.normalize(specification.properties[key]) + } + break; + } + case 'uint8': + case 'int8': + case 'uint16': + case 'int16': + case 'uint32': + case 'int32': + case 'uint64': + case 'int64': + case 'float32': + case 'float64': + case 'string': { + break; + } + default: + throw new TypeError(`invalid specification: ${JSON.stringify(specification)}`); } - return normalized; + return {...normalized, defaultValue: this.defaultValue(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; - } + static readSize(view, offset, specification) { + const size = this.size(specification); + if (size > 0) { + return size; + } + switch (specification.type) { + case 'array': { + const length = view.getUint32(offset.value, true); + offset.value += 4; + let arraySize = 0; + for (let i = 0; i < length; ++i) { + arraySize += this.readSize(view, offset, specification.subtype); } + return 4 + arraySize; } - else { - cursor += size; - fullSize += size; + case 'object': { + let objectSize = 0; + for (const key in specification.properties) { + objectSize += this.readSize(view, offset, specification.properties[key]); + } + return objectSize; + } + case 'string': { + const length = view.getUint32(offset.value, true); + offset.value += 4 + length; + return 4 + length; + } + } + } + + readSize(view, offset) { + const wrapped = {value: offset}; + return this.constructor.readSize(view, wrapped, this.specification); + } + + static serialize(source, view, offset, specification) { + const viewSetMethod = this.viewSetMethods[specification.type]; + if (viewSetMethod) { + view[viewSetMethod](offset, source, true); + return this.size(specification); + } + switch (specification.type) { + case 'array': { + view.setUint32(offset, source.length, true); + offset += 4; + let arraySize = 0; + for (const element of source) { + arraySize += this.serialize( + element, + view, + offset + arraySize, + specification.subtype, + ); + } + return 4 + arraySize; + } + case 'object': { + let objectSize = 0; + for (const key in specification.properties) { + objectSize += this.serialize( + source[key], + view, + offset + objectSize, + specification.properties[key], + ); + } + return objectSize; + } + case 'string': { + const encoder = new TextEncoder(); + const bytes = encoder.encode(source); + view.setUint32(offset, bytes.length, true); + offset += 4; + for (let i = 0; i < bytes.length; ++i) { + view.setUint8(offset++, bytes[i]); + } + return 4 + bytes.length; } } - return fullSize; } serialize(source, view, offset = 0) { - let cursor = offset; - for (const key in this.specification) { - const {type} = this.specification[key]; - const viewSetMethod = Schema.viewSetMethods[type]; - if (viewSetMethod) { - view[viewSetMethod](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; - } - } - } + this.constructor.serialize(source, view, offset, this.specification); + } + + static sizeOf(concrete, specification) { + const size = this.size(specification); + if (size > 0) { + return size; } - } - - get size() { - return this.$$size; - } - - sizeOf(concrete) { 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 += (encoder.encode(concrete[i])).length; - break; + const {type} = specification; + switch (type) { + case 'array': { + fullSize += 4; + for (const element of concrete) { + fullSize += this.sizeOf(element, specification.subtype); } + break; } - else { - fullSize += size; + case 'object': { + for (const key in specification.properties) { + fullSize += this.sizeOf(concrete[key], specification.properties[key]); + } + break; } + case 'string': + fullSize += 4; + fullSize += (encoder.encode(concrete)).length; + break; } return fullSize; } - static sizeOfType(type) { - switch (type) { + sizeOf(concrete) { + return this.constructor.sizeOf(concrete, this.specification); + } + + static size(specification) { + switch (specification.type) { + case 'array': return 0; + case 'object': { + let size = 0; + for (const key in specification.properties) { + const propertySize = this.size(specification.properties[key]); + if (0 === propertySize) { + return 0; + } + size += propertySize; + } + return size; + } case 'uint8': case 'int8': { return 1; } @@ -203,16 +293,8 @@ export default class Schema { } } - static validateType(type) { - return [ - 'uint8', 'int8', - 'uint16', 'int16', - 'uint32', 'int32', - 'uint64', 'int64', - 'float32', 'float64', - 'string', - ] - .includes(type); + size() { + return this.constructor.size(this.specification); } } diff --git a/app/ecs/schema.test.js b/app/ecs/schema.test.js index 6e114c1..3ee5eb4 100644 --- a/app/ecs/schema.test.js +++ b/app/ecs/schema.test.js @@ -2,57 +2,135 @@ 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('defaults values', () => { + [ + 'uint8', + 'int8', + 'uint16', + 'int16', + 'uint32', + 'int32', + 'uint64', + 'int64', + 'float32', + 'float64', + ].forEach((type) => { + expect(Schema.defaultValue({type})) + .to.equal(0); + expect(new Schema({type}).specification.defaultValue) + .to.equal(0); + }); + expect(Schema.defaultValue({type: 'string'})) + .to.equal(''); + expect(new Schema({type: 'string'}).specification.defaultValue) + .to.equal(''); + expect(Schema.defaultValue({type: 'array', subtype: {type: 'string'}})) + .to.deep.equal([]); + expect(new Schema({type: 'array', subtype: {type: 'string'}}).specification.defaultValue) + .to.deep.equal([]); + expect(Schema.defaultValue({ + type: 'object', + properties: { + foo: {type: 'uint8'}, + bar: {type: 'string'}, + baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}}, + }, + })) + .to.deep.equal({foo: 0, bar: '', baz: {blah: []}}); + expect(new Schema({ + type: 'object', + properties: { + foo: {type: 'uint8'}, + bar: {type: 'string'}, + baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}}, + }, + }).specification.defaultValue) + .to.deep.equal({foo: 0, bar: '', baz: {blah: []}}); }); -test('calculates the size of an instance', () => { +test('validates a schema', () => { + [ + 'uint8', + 'int8', + 'uint16', + 'int16', + 'uint32', + 'int32', + 'uint64', + 'int64', + 'float32', + 'float64', + 'string', + ].forEach((type) => { + expect(() => { + new Schema({type}); + new Schema({type: 'array', subtype: {type}}); + new Schema({type: 'object', properties: {foo: {type}}}); + }) + .to.not.throw(); + }); +}); + +test('calculates the size of concrete instances', () => { expect( - (new Schema({foo: 'uint8', bar: 'uint32'})) - .sizeOf({foo: 69, bar: 420}) - ) - .to.equal(5); - expect( - (new Schema({foo: 'string'})) - .sizeOf({foo: 'hi'}) + (new Schema({type: 'string'})) + .sizeOf('hi') ) .to.equal(4 + (new TextEncoder().encode('hi')).length); + + expect( + (new Schema( + {type: 'object', properties: { + foo: {type: 'uint8'}, + bar: {type: 'uint32'}, + baz: {type: 'string'}, + }} + )) + .sizeOf({foo: 69, bar: 420, baz: 'aα'}) + ) + .to.equal( + 1 + + 4 + + 4 + (new TextEncoder().encode('aα')).length + ); + expect( + (new Schema({type: 'array', subtype: {type: 'string'}})) + .sizeOf(['hallo', 'hαllo']) + ) + .to.equal( + 4 + + 4 + (new TextEncoder().encode('hallo')).length + + 4 + (new TextEncoder().encode('hαllo')).length + ); }); -test('can encode and decode', () => { +test('encodes and decodes', () => { 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'], - ['string', 'α'], + [{type: 'uint8'}, 255], + [{type: 'int8'}, -128], + [{type: 'int8'}, 127], + [{type: 'uint16'}, 65535], + [{type: 'int16'}, -32768], + [{type: 'int16'}, 32767], + [{type: 'uint32'}, 4294967295], + [{type: 'int32'}, -2147483648], + [{type: 'int32'}, 2147483647], + [{type: 'uint64'}, 18446744073709551615n], + [{type: 'int64'}, -9223372036854775808n], + [{type: 'int64'}, 9223372036854775807n], + [{type: 'float32'}, 0.5], + [{type: 'float64'}, 1.234], + [{type: 'string'}, 'hello world'], + [{type: 'string'}, 'α'], + [{type: 'array', subtype: {type: 'uint8'}}, [1, 2, 3, 4]], + [{type: 'array', subtype: {type: 'string'}}, ['one', 'two', 'three', 'four']], + [{type: 'object', properties: {foo: {type: 'uint8'}, bar: {type: 'string'}}}, {foo: 64, bar: 'baz'}], ]; - const specification = entries.reduce((r, [type]) => ({...r, [Object.keys(r).length]: type}), {}); - const data = entries.reduce((r, [, value]) => ({...r, [Object.keys(r).length]: value}), {}); - const schema = new Schema(specification); - const view = new DataView(new ArrayBuffer(schema.sizeOf(data))); - schema.serialize(data, view); - const result = {}; - schema.deserialize(result, view); - expect(data) - .to.deep.equal(result); + entries.forEach(([specification, concrete]) => { + const schema = new Schema(specification); + const view = new DataView(new ArrayBuffer(schema.sizeOf(concrete))); + schema.serialize(concrete, view); + expect(concrete) + .to.deep.equal(schema.deserialize(view)); + }); });