Skip to content

Commit

Permalink
Merge pull request #42 from ayebear/dev
Browse files Browse the repository at this point in the history
alpha 3
  • Loading branch information
ayebear authored Nov 5, 2020
2 parents 7f9686e + 129defd commit 3bd7f1f
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 34 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
143 changes: 126 additions & 17 deletions src/entity.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @ignore */
const { invoke } = require('./utilities.js')
const { invoke, shallowClone } = require('./utilities.js')

/**
* Entity class used for storing components.
Expand Down Expand Up @@ -84,13 +84,14 @@ 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.
*/
access(component, ...args) {
access(component, fallback, ...args) {
if (!this.has(component)) {
this.set(component, ...args)
this.setWithFallback(component, fallback, ...args)
}
return this.data[component]
}
Expand Down Expand Up @@ -118,18 +119,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
Expand All @@ -144,15 +159,17 @@ 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 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
Expand All @@ -174,14 +191,15 @@ 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.
*/
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]
}

Expand Down Expand Up @@ -321,9 +339,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') {
Expand Down Expand Up @@ -373,6 +391,97 @@ class Entity {
this.world = undefined
}
}

/**
* 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()
*/
clone() {
if (!this.valid()) {
throw new Error('Cannot clone detached or invalid entity.')
}

// Clone each component in this entity, to a new entity
const newEntity = this.world.entity()
for (const name in this.data) {
this.cloneComponentTo(newEntity, name)
}

// Return the cloned entity
return newEntity
}

/**
* 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] = shallowClone(component)
}

// 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
}
}

exports.Entity = Entity
63 changes: 60 additions & 3 deletions src/entity.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const {
testIndexers,
assert
testIndexers,
assert
} = require('./test_utils.js')

test('entity: create an entity', testIndexers(world => {
Expand Down Expand Up @@ -416,7 +416,64 @@ 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)
}))

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)

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()
}))
13 changes: 9 additions & 4 deletions src/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ function invoke(object, method, ...args) {
}

/**
* Determines if function.
* Shallow clones any type of variable.
*
* @ignore
*/
function isFunction(obj) {
return typeof obj === 'function'
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
50 changes: 50 additions & 0 deletions src/utilities.test.js
Original file line number Diff line number Diff line change
@@ -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 } ])
})
4 changes: 2 additions & 2 deletions src/world.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand Down
Loading

0 comments on commit 3bd7f1f

Please sign in to comment.