From 4a7a5318f3e388aad9e744ec5fb3cb427beba171 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Mon, 2 Nov 2020 22:08:52 -0800 Subject: [PATCH 1/8] Can now define a custom fallback instead of just {} for setting components --- package.json | 2 +- src/entity.js | 40 +++++++++++++++++++++++++++------------- src/entity.test.js | 3 ++- src/world.test.js | 14 +++++++------- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 9dbfaef..befb150 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "picoes", - "version": "1.0.0-alpha2", + "version": "1.0.0-alpha3", "description": "Pico Entity System for JavaScript (ES6).", "main": "./index.js", "scripts": { diff --git a/src/entity.js b/src/entity.js index 20b1604..4296ac8 100644 --- a/src/entity.js +++ b/src/entity.js @@ -88,9 +88,9 @@ class Entity { * * @return {Object} Always returns either the existing component, or the newly created one. */ - access(component, ...args) { + access(component, fallback, ...args) { if (!this.has(component)) { - this.set(component, ...args) + this.setWithFallback(component, fallback, ...args) } return this.data[component] } @@ -118,18 +118,32 @@ class Entity { * @return {Object} The original entity that set() was called on, so that operations can be chained. */ set(component, ...args) { + // Use first argument as fallback + return this.setWithFallback(component, args[0], ...args) + } + + /** + * Adds a new component, or re-creates and overwrites an existing component. Has separate fallback argument. + * + * @example + * entity.setWithFallback('position', { x: 1, y: 2 }, 1, 2) + * + * @param {string} component - See entity.set() for details. + * @param {Object} fallback - The object or value to use as the component when there is no registered type. + * @param {...Object} [args] - See entity.set() for details. + * + * @return {Object} The original entity that setWithFallback() was called on, so that operations can be chained. + */ + setWithFallback(component, fallback, ...args) { if (this.valid() && component in this.world.components) { // Create component and store in entity this.data[component] = new this.world.components[component](...args) // Inject parent entity into component this.data[component].entity = this - } else if (args.length > 0) { - // Use first argument as component value - this.data[component] = args[0] } else { - // Make an empty object - this.data[component] = {} + // Use fallback argument as component value + this.data[component] = fallback } // Update the index with this new component @@ -177,11 +191,11 @@ class Entity { * * @return {Object} The original entity that update() was called on, so that operations can be chained. */ - update(component, data) { - let comp = this.access(component) + update(component, data, ...args) { + const comp = this.access(component, {}, ...args) // Shallow set keys of the component - for (let key in data) { + for (const key in data) { comp[key] = data[key] } @@ -321,9 +335,9 @@ class Entity { * @return {Object} The original entity that fromJSON() was called on, so that operations can be chained. */ fromJSON(data) { - let parsed = JSON.parse(data) - for (let name in parsed) { - let comp = this.access(name) + const parsed = JSON.parse(data) + for (const name in parsed) { + const comp = this.access(name, {}) // Either call custom method or copy all properties if (typeof comp.fromJSON === 'function') { diff --git a/src/entity.test.js b/src/entity.test.js index 456edcf..cd87435 100644 --- a/src/entity.test.js +++ b/src/entity.test.js @@ -416,7 +416,8 @@ test('entity: register and use prototypes', testIndexers(world => { assert(p.get('position').x === 5 && p.get('position').y === 10) assert(p.get('velocity').x === 15 && p.get('velocity').y === 20) assert(p.get('player') !== undefined) - assert(e.get('position').x === 0 && e.get('position').y === 0) + expect(e.get('position').x).toEqual(0) + expect(e.get('position').y).toEqual(0) assert(e.get('velocity').x === undefined && e.get('velocity').y === undefined) assert(t.get('position').x === 3.14159 && t.get('position').y === 5000) })) diff --git a/src/world.test.js b/src/world.test.js index d10fdc1..3b621d2 100644 --- a/src/world.test.js +++ b/src/world.test.js @@ -431,7 +431,7 @@ test('system: system iteration', testIndexers(world => { })) test('system: system methods', testIndexers(world => { - world.component('position') + expect(world.component('position')).toBeUndefined() let methodsCalled = 0 @@ -457,15 +457,15 @@ test('system: system methods', testIndexers(world => { world.system(class {}) world.system() - let ent = world.entity().set('position') + world.entity().set('position', {}) assert(methodsCalled == 2) world.run() assert(methodsCalled == 4) })) test('system: system edge cases', testIndexers(world => { - world.component('position') - world.component('velocity') + world.component('position', class {}) + world.component('velocity', class {}) let testEnt0 = world.entity().set('position').set('velocity') let testEnt2 = null @@ -688,9 +688,9 @@ test('system: system variadic arguments with optional components', testIndexers( })) test('system: use the each() method', testIndexers(world => { - let ent1 = world.entity().set('position').set('"velocity"') - let ent2 = world.entity().set('position') - let ent3 = world.entity().set('position:"velocity"') + let ent1 = world.entity().set('position', {}).set('"velocity"', {}) + let ent2 = world.entity().set('position', {}) + let ent3 = world.entity().set('position:"velocity"', {}) let externalVar = 5 world.each('position', ({position: pos}, ent) => { assert(pos) From 4d963c20a94ff6dad132bcf555b4d330a10f29f5 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Mon, 2 Nov 2020 22:49:56 -0800 Subject: [PATCH 2/8] Wrote initial clone() for entities --- src/entity.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/entity.js b/src/entity.js index 4296ac8..0787e4d 100644 --- a/src/entity.js +++ b/src/entity.js @@ -387,6 +387,41 @@ class Entity { this.world = undefined } } + + /** + * Creates a copy of this entity with all of the components cloned and returns it. + * + * @example + * entity.clone() + */ + clone() { + if (!this.valid()) { + throw new Error('Cannot clone detached or invalid entity.') + } + + const entity = this.world.entity() + for (const name in this.data) { + const component = this.data[name] + + // Clone component + let cloned, args = [] + if (typeof component === 'object' && typeof component.clone === 'function') { + // Custom implementation + cloned = component.clone(args) + } else { + // Fallback implementation + cloned = cloneDeepWith(component, (value, key) => { + if (key === 'entity') { + return entity + } + }) + } + + // Set cloned component and call onCreate + entity.setRaw(name, cloned) + invoke(cloned, 'onCreate', ...args) + } + } } exports.Entity = Entity From 7f12494652d53b5132d63b6fc650326fb6f81a3b Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 01:28:48 -0800 Subject: [PATCH 3/8] Implement safer clone Added documentation with examples --- src/entity.js | 99 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/src/entity.js b/src/entity.js index 0787e4d..5e94e33 100644 --- a/src/entity.js +++ b/src/entity.js @@ -166,7 +166,7 @@ class Entity { * @param {string} component - The component name to set. * @param {Object} value - Should be a previous component instance, or whatever is expected for the component name. * - * @return {Object} The original entity that set() was called on, so that operations can be chained. + * @return {Object} The original entity that setRaw() was called on, so that operations can be chained. */ setRaw(component, value) { // Directly set value @@ -390,6 +390,8 @@ class Entity { /** * Creates a copy of this entity with all of the components cloned and returns it. + * Individual components are either shallow or deep copied, depending on component + * registration status and if a clone() method is defined. See entity.cloneComponentTo(). * * @example * entity.clone() @@ -399,28 +401,87 @@ class Entity { throw new Error('Cannot clone detached or invalid entity.') } - const entity = this.world.entity() + // Clone each component in this entity, to a new entity + const newEntity = this.world.entity() for (const name in this.data) { - const component = this.data[name] + this.cloneComponentTo(newEntity, name) + } - // Clone component - let cloned, args = [] - if (typeof component === 'object' && typeof component.clone === 'function') { - // Custom implementation - cloned = component.clone(args) - } else { - // Fallback implementation - cloned = cloneDeepWith(component, (value, key) => { - if (key === 'entity') { - return entity - } - }) - } + // Return the cloned entity + return newEntity + } - // Set cloned component and call onCreate - entity.setRaw(name, cloned) - invoke(cloned, 'onCreate', ...args) + /** + * Clones a component from this entity to the target entity. + * + * @example + * const source = world.entity().set('foo', 'bar') + * const target = world.entity() + * source.cloneComponentTo(target, 'foo') + * assert(target.get('foo') === 'bar') + * + * @example + * world.component('foo', class { + * onCreate(bar, baz) { + * this.bar = bar + * this.baz = baz + * this.qux = false + * } + * setQux(qux = true) { + * this.qux = qux + * } + * cloneArgs() { + * return [this.bar, this.baz] + * } + * clone(target) { + * target.qux = this.qux + * } + * }) + * const source = world.entity() + * .set('foo', 'bar', 'baz') + * .set('qux', true) + * const target = world.entity() + * source.cloneComponentTo(target, 'foo') + * assert(source.get('foo').bar === target.get('foo').bar) + * assert(source.get('foo').baz === target.get('foo').baz) + * assert(source.get('foo').qux === target.get('foo').qux) + * + * @param {Entity} targetEntity - Must be a valid entity. Could be part of another world, but it + * is undefined behavior if the registered components are different types. + * @param {string} name - Component name of both source and target components. + * + * @return {Object} The original entity that cloneComponentTo() was called on, + * so that operations can be chained. + */ + cloneComponentTo(targetEntity, name) { + // Get component and optional arguments for cloning + const component = this.get(name) + const args = invoke(component, 'cloneArgs') || [] + + if (name in targetEntity.world.components) { + // Registered component, so create new using constructor, inject + // entity, and call optional clone + const newComponent = new targetEntity.world.components[name](...args) + newComponent.entity = targetEntity + targetEntity.data[name] = newComponent + invoke(component, 'clone', newComponent) + } else { + // Unregistered component, so just shallow clone it + targetEntity.data[name] = cloneWith(component, value => { + // Lodash doesn't copy functions, but this makes sense to allow here + if (typeof value === 'function') { + return value + } + }) } + + // Update the index with this new component + targetEntity.world.index.add(targetEntity, name) + + // Call custom onCreate to initialize component, and any additional arguments passed into set() + invoke(targetEntity.data[name], 'onCreate', ...args) + + return this } } From 24eba9038e7adfb7f488a27aaec1790a4a29d322 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 02:07:39 -0800 Subject: [PATCH 4/8] Implemented shallowClone Added passing tests --- src/entity.js | 4 ++-- src/entity.test.js | 38 ++++++++++++++++++++++++++++++++++++-- src/utilities.js | 16 ++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/entity.js b/src/entity.js index 5e94e33..e9cfcad 100644 --- a/src/entity.js +++ b/src/entity.js @@ -1,5 +1,5 @@ /** @ignore */ -const { invoke } = require('./utilities.js') +const { invoke, shallowClone } = require('./utilities.js') /** * Entity class used for storing components. @@ -467,7 +467,7 @@ class Entity { invoke(component, 'clone', newComponent) } else { // Unregistered component, so just shallow clone it - targetEntity.data[name] = cloneWith(component, value => { + targetEntity.data[name] = shallowClone(component, value => { // Lodash doesn't copy functions, but this makes sense to allow here if (typeof value === 'function') { return value diff --git a/src/entity.test.js b/src/entity.test.js index cd87435..06e1e27 100644 --- a/src/entity.test.js +++ b/src/entity.test.js @@ -1,6 +1,6 @@ const { - testIndexers, - assert + testIndexers, + assert } = require('./test_utils.js') test('entity: create an entity', testIndexers(world => { @@ -421,3 +421,37 @@ test('entity: register and use prototypes', testIndexers(world => { assert(e.get('velocity').x === undefined && e.get('velocity').y === undefined) assert(t.get('position').x === 3.14159 && t.get('position').y === 5000) })) + +test('entity: cloning basic', testIndexers(world => { + const source = world.entity().set('a', 'aaa') + const target = world.entity() + source.cloneComponentTo(target, 'a') + expect(target.get('a')).toEqual('aaa') +})) + +test('entity: cloning advanced', testIndexers(world => { + world.component('foo', class { + onCreate(bar, baz) { + this.bar = bar + this.baz = baz + this.qux = false + } + setQux(qux = true) { + this.qux = qux + } + cloneArgs() { + return [this.bar, this.baz] + } + clone(target) { + target.qux = this.qux + } + }) + const source = world.entity() + .set('foo', 'bar', 'baz') + .set('qux', true) + const target = world.entity() + source.cloneComponentTo(target, 'foo') + expect(source.get('foo').bar).toEqual(target.get('foo').bar) + expect(source.get('foo').baz).toEqual(target.get('foo').baz) + expect(source.get('foo').qux).toEqual(target.get('foo').qux) +})) diff --git a/src/utilities.js b/src/utilities.js index f3455a6..b73bf3d 100644 --- a/src/utilities.js +++ b/src/utilities.js @@ -15,6 +15,7 @@ function invoke(object, method, ...args) { } } +// TODO: Delete this /** * Determines if function. * @@ -24,5 +25,20 @@ function isFunction(obj) { return typeof obj === 'function' } +/** + * Shallow clones any type of variable. + * + * @ignore + */ +function shallowClone(val) { + if (Array.isArray(val)) { + return [...val] + } else if (typeof val === 'object') { + return {...val} + } + return val +} + exports.invoke = invoke exports.isFunction = isFunction +exports.shallowClone = shallowClone From 7d4bd9014e19ba7bd6ba13226d0604f7501f5859 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 02:08:29 -0800 Subject: [PATCH 5/8] Deleted isFunction --- src/utilities.js | 11 ----------- src/world.js | 4 ++-- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/utilities.js b/src/utilities.js index b73bf3d..a458e27 100644 --- a/src/utilities.js +++ b/src/utilities.js @@ -15,16 +15,6 @@ function invoke(object, method, ...args) { } } -// TODO: Delete this -/** - * Determines if function. - * - * @ignore - */ -function isFunction(obj) { - return typeof obj === 'function' -} - /** * Shallow clones any type of variable. * @@ -40,5 +30,4 @@ function shallowClone(val) { } exports.invoke = invoke -exports.isFunction = isFunction exports.shallowClone = shallowClone diff --git a/src/world.js b/src/world.js index ccfeedc..dfb545c 100644 --- a/src/world.js +++ b/src/world.js @@ -100,7 +100,7 @@ class World { */ component(name, componentClass) { // Only allow functions and classes to be components - if (isFunction(componentClass)) { + if (typeof componentClass === 'function') { this.components[name] = componentClass return name } @@ -220,7 +220,7 @@ class World { */ system(systemClass, ...args) { // Make sure the system is valid - if (isFunction(systemClass)) { + if (typeof systemClass === 'function') { // Create the system const newSystem = new systemClass(...args) From 473fe569ffd55956541770c86df48165642c9168 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 02:29:04 -0800 Subject: [PATCH 6/8] Added utilities tests for invoke and shallowClone --- src/utilities.test.js | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/utilities.test.js diff --git a/src/utilities.test.js b/src/utilities.test.js new file mode 100644 index 0000000..ac48385 --- /dev/null +++ b/src/utilities.test.js @@ -0,0 +1,50 @@ +const { invoke, shallowClone } = require('./utilities') + +test('utilities: invoke', () => { + const obj = { + foo1: () => 'bar', + foo2: 'bar', + foo3: (...args) => args.join(' ') + } + class C { + constructor() { + this.that = this + this.foo5 = () => { + return Boolean(this.that && this === this.that) + } + } + foo4() { + return Boolean(this.that && this === this.that) + } + } + const obj2 = new C() + expect(invoke(obj, 'foo1')).toBe('bar') + expect(invoke(obj, 'foo2')).toBe(undefined) + expect(invoke(obj, 'foo3', 'foo', 'bar')).toBe('foo bar') + expect(invoke(obj2, 'foo4')).toBe(true) + expect(obj2.foo4()).toBe(true) + expect(obj2.foo4.call(this)).toBe(false) + expect(invoke(obj2, 'foo5')).toBe(true) + expect(obj2.foo5()).toBe(true) + expect(obj2.foo5.call(this)).toBe(true) +}) + +test('utilities: shallowClone', () => { + const obj = { a: 1, b: 2 } + const arr = [ 1, 2, { c: 3 }, { d: 4 } ] + const obj2 = shallowClone(obj) + const arr2 = shallowClone(arr) + expect(obj).toEqual(obj2) + expect(arr).toEqual(arr2) + obj.a = 0 + obj2.a = -1 + arr[0] = 0 + arr[2] = 3 + expect(obj.a).toBe(0) + expect(obj2.a).toBe(-1) + expect(arr).toEqual([ 0, 2, 3, { d: 4 } ]) + expect(arr2).toEqual([ 1, 2, { c: 3 }, { d: 4 } ]) + arr[3].d = 10 + expect(arr).toEqual([ 0, 2, 3, { d: 10 } ]) + expect(arr2).toEqual([ 1, 2, { c: 3 }, { d: 10 } ]) +}) From c61b2c57426abd84d0da74194ae308b40a58c5c2 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 02:38:37 -0800 Subject: [PATCH 7/8] 100% test coverage Removed old customizer for lodash (not using lodash) --- src/entity.js | 7 +------ src/entity.test.js | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/entity.js b/src/entity.js index e9cfcad..b5ea575 100644 --- a/src/entity.js +++ b/src/entity.js @@ -467,12 +467,7 @@ class Entity { invoke(component, 'clone', newComponent) } else { // Unregistered component, so just shallow clone it - targetEntity.data[name] = shallowClone(component, value => { - // Lodash doesn't copy functions, but this makes sense to allow here - if (typeof value === 'function') { - return value - } - }) + targetEntity.data[name] = shallowClone(component) } // Update the index with this new component diff --git a/src/entity.test.js b/src/entity.test.js index 06e1e27..78474eb 100644 --- a/src/entity.test.js +++ b/src/entity.test.js @@ -454,4 +454,26 @@ test('entity: cloning advanced', testIndexers(world => { expect(source.get('foo').bar).toEqual(target.get('foo').bar) expect(source.get('foo').baz).toEqual(target.get('foo').baz) expect(source.get('foo').qux).toEqual(target.get('foo').qux) + + const target2 = source.clone() + expect(source.get('foo').bar).toEqual(target2.get('foo').bar) + expect(source.get('foo').baz).toEqual(target2.get('foo').baz) + expect(source.get('foo').qux).toEqual(target2.get('foo').qux) + + target.get('foo').bar = 'change1' + target2.get('foo').baz = 'change2' + expect(source.get('foo').bar).not.toEqual(target.get('foo').bar) + expect(source.get('foo').baz).toEqual(target.get('foo').baz) + expect(source.get('foo').qux).toEqual(target.get('foo').qux) + expect(source.get('foo').bar).toEqual(target2.get('foo').bar) + expect(source.get('foo').baz).not.toEqual(target2.get('foo').baz) + expect(source.get('foo').qux).toEqual(target2.get('foo').qux) + + const target3 = target2.clone() + expect(target3.get('foo').bar).toEqual(target2.get('foo').bar) + expect(target3.get('foo').baz).toEqual(target2.get('foo').baz) + expect(target3.get('foo').qux).toEqual(target2.get('foo').qux) + + target3.destroy() + expect(() => target3.clone()).toThrow() })) From 129defde40a8a4b043548a6e1dfb3d7e9bc62e11 Mon Sep 17 00:00:00 2001 From: Eric Hebert Date: Thu, 5 Nov 2020 02:41:27 -0800 Subject: [PATCH 8/8] Docs cleanup --- src/entity.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/entity.js b/src/entity.js index b5ea575..ac635e4 100644 --- a/src/entity.js +++ b/src/entity.js @@ -84,6 +84,7 @@ class Entity { * let position = entity.access('position', 3, 4) * * @param {string} component - The component name to create/get + * @param {Object} fallback - The value to set the component as for unregistered components * @param {...Object} [args] - The arguments to forward to create the new component, only if it doesn't exist. * * @return {Object} Always returns either the existing component, or the newly created one. @@ -158,13 +159,15 @@ class Entity { } /** - * Sets a component value directly. The onCreate method is not called, and it is expected that you pass an already initialized component. + * Sets a component value directly. The onCreate method is not called, and it is expected that you + * pass an already initialized component. * * @example * entity.set('position', position) * * @param {string} component - The component name to set. - * @param {Object} value - Should be a previous component instance, or whatever is expected for the component name. + * @param {Object} value - Should be a previous component instance, or whatever is expected for + * the component name. * * @return {Object} The original entity that setRaw() was called on, so that operations can be chained. */ @@ -188,6 +191,7 @@ class Entity { * * @param {string} component - The component name to update * @param {Object} data - The object or other component to merge into the specified component. + * @param {...Object} [args] - See entity.set() for details. * * @return {Object} The original entity that update() was called on, so that operations can be chained. */