From 22fe6261b8b43efec226caecc8208e60566407bb Mon Sep 17 00:00:00 2001 From: cha0s Date: Sun, 17 Mar 2019 23:45:48 -0500 Subject: [PATCH] chore: initial --- .gitignore | 2 + TODO.md | 3 + package.json | 15 + packages/behavior/context/globals.js | 115 +++++++ packages/behavior/context/index.js | 99 ++++++ packages/behavior/context/types/index.js | 143 ++++++++ packages/behavior/context/types/registry.js | 26 ++ packages/behavior/index.js | 11 + packages/behavior/item/action.js | 14 + packages/behavior/item/actions.js | 68 ++++ packages/behavior/item/actions.spec.js | 0 packages/behavior/item/collection.js | 35 ++ packages/behavior/item/condition.js | 97 ++++++ packages/behavior/item/condition.spec.js | 109 ++++++ packages/behavior/item/conditions.js | 14 + packages/behavior/item/initialize.js | 23 ++ packages/behavior/item/literal.js | 30 ++ packages/behavior/item/literal.spec.js | 22 ++ packages/behavior/item/registry.js | 17 + packages/behavior/item/routine.js | 29 ++ packages/behavior/item/routines.js | 34 ++ packages/behavior/item/traversal-and-set.js | 34 ++ packages/behavior/item/traversal.js | 54 +++ packages/behavior/item/traversals.js | 36 ++ packages/behavior/package.json | 10 + packages/client/index.js | 1 + packages/client/index.socket.js | 40 +++ packages/client/package.json | 10 + packages/core/index.js | 45 +++ packages/core/package.json | 7 + packages/entity/index.js | 143 ++++++++ packages/entity/list.js | 87 +++++ packages/entity/package.json | 17 + packages/entity/trait.js | 98 ++++++ packages/entity/traits/directional.js | 37 +++ packages/entity/traits/existent.js | 57 ++++ packages/entity/traits/index.js | 256 ++++++++++++++ packages/entity/traits/mobile.js | 59 ++++ packages/entity/traits/positioned.js | 32 ++ packages/entity/traits/registry.js | 29 ++ packages/graphics/image.js | 0 packages/graphics/index.js | 0 packages/graphics/package.json | 14 + packages/input/index.js | 123 +++++++ packages/input/package.json | 13 + packages/math/index.js | 4 + packages/math/matrix.js | 31 ++ packages/math/matrix.spec.coffee | 29 ++ packages/math/package.json | 7 + packages/math/rectangle/index.coffee | 167 ++++++++++ packages/math/rectangle/index.spec.coffee | 70 ++++ packages/math/rectangle/mixin.coffee | 40 +++ packages/math/vector/index.js | 350 ++++++++++++++++++++ packages/math/vector/index.spec.coffee | 85 +++++ packages/math/vector/mixin.coffee | 32 ++ packages/math/vector/mixin.spec.coffee | 36 ++ packages/math/vertice.coffee | 18 + packages/mixins/event-emitter.js | 243 ++++++++++++++ packages/mixins/index.js | 4 + packages/mixins/lfo/index.js | 9 + packages/mixins/lfo/modulated-property.js | 142 ++++++++ packages/mixins/lfo/result.js | 66 ++++ packages/mixins/package.json | 7 + packages/mixins/property.js | 77 +++++ packages/mixins/transition/easing.js | 170 ++++++++++ packages/mixins/transition/index.js | 38 +++ packages/mixins/transition/result.js | 120 +++++++ packages/react/animation.coffee | 51 +++ packages/react/container.coffee | 79 +++++ packages/react/image.coffee | 21 ++ packages/react/index.js | 8 + packages/react/layer.coffee | 24 ++ packages/react/package.json | 13 + packages/react/primitives.coffee | 32 ++ packages/react/room.coffee | 29 ++ packages/react/sprite.coffee | 38 +++ packages/react/vector.js | 46 +++ packages/react/vector.scss | 40 +++ packages/resource/index.js | 57 ++++ packages/resource/package.json | 11 + packages/server/index.js | 0 packages/server/package.json | 10 + packages/server/socket.js | 21 ++ packages/state/index.js | 71 ++++ packages/state/package.json | 7 + packages/timing/animation-frame.coffee | 54 +++ packages/timing/animation-view.coffee | 82 +++++ packages/timing/animation.coffee | 114 +++++++ packages/timing/cps.coffee | 36 ++ packages/timing/index.coffee | 13 + packages/timing/package.json | 7 + packages/timing/ticker.coffee | 76 +++++ packages/timing/timed-index.coffee | 48 +++ webpack.test.config.js | 3 + 94 files changed, 4844 insertions(+) create mode 100644 .gitignore create mode 100644 TODO.md create mode 100644 package.json create mode 100644 packages/behavior/context/globals.js create mode 100644 packages/behavior/context/index.js create mode 100644 packages/behavior/context/types/index.js create mode 100644 packages/behavior/context/types/registry.js create mode 100644 packages/behavior/index.js create mode 100644 packages/behavior/item/action.js create mode 100644 packages/behavior/item/actions.js create mode 100644 packages/behavior/item/actions.spec.js create mode 100644 packages/behavior/item/collection.js create mode 100644 packages/behavior/item/condition.js create mode 100644 packages/behavior/item/condition.spec.js create mode 100644 packages/behavior/item/conditions.js create mode 100644 packages/behavior/item/initialize.js create mode 100644 packages/behavior/item/literal.js create mode 100644 packages/behavior/item/literal.spec.js create mode 100644 packages/behavior/item/registry.js create mode 100644 packages/behavior/item/routine.js create mode 100644 packages/behavior/item/routines.js create mode 100644 packages/behavior/item/traversal-and-set.js create mode 100644 packages/behavior/item/traversal.js create mode 100644 packages/behavior/item/traversals.js create mode 100644 packages/behavior/package.json create mode 100644 packages/client/index.js create mode 100644 packages/client/index.socket.js create mode 100644 packages/client/package.json create mode 100644 packages/core/index.js create mode 100644 packages/core/package.json create mode 100644 packages/entity/index.js create mode 100644 packages/entity/list.js create mode 100644 packages/entity/package.json create mode 100644 packages/entity/trait.js create mode 100644 packages/entity/traits/directional.js create mode 100644 packages/entity/traits/existent.js create mode 100644 packages/entity/traits/index.js create mode 100644 packages/entity/traits/mobile.js create mode 100644 packages/entity/traits/positioned.js create mode 100644 packages/entity/traits/registry.js create mode 100644 packages/graphics/image.js create mode 100644 packages/graphics/index.js create mode 100644 packages/graphics/package.json create mode 100644 packages/input/index.js create mode 100644 packages/input/package.json create mode 100644 packages/math/index.js create mode 100644 packages/math/matrix.js create mode 100644 packages/math/matrix.spec.coffee create mode 100644 packages/math/package.json create mode 100644 packages/math/rectangle/index.coffee create mode 100644 packages/math/rectangle/index.spec.coffee create mode 100644 packages/math/rectangle/mixin.coffee create mode 100644 packages/math/vector/index.js create mode 100644 packages/math/vector/index.spec.coffee create mode 100644 packages/math/vector/mixin.coffee create mode 100644 packages/math/vector/mixin.spec.coffee create mode 100644 packages/math/vertice.coffee create mode 100644 packages/mixins/event-emitter.js create mode 100644 packages/mixins/index.js create mode 100644 packages/mixins/lfo/index.js create mode 100644 packages/mixins/lfo/modulated-property.js create mode 100644 packages/mixins/lfo/result.js create mode 100644 packages/mixins/package.json create mode 100644 packages/mixins/property.js create mode 100644 packages/mixins/transition/easing.js create mode 100644 packages/mixins/transition/index.js create mode 100644 packages/mixins/transition/result.js create mode 100644 packages/react/animation.coffee create mode 100644 packages/react/container.coffee create mode 100644 packages/react/image.coffee create mode 100644 packages/react/index.js create mode 100644 packages/react/layer.coffee create mode 100644 packages/react/package.json create mode 100644 packages/react/primitives.coffee create mode 100644 packages/react/room.coffee create mode 100644 packages/react/sprite.coffee create mode 100644 packages/react/vector.js create mode 100644 packages/react/vector.scss create mode 100644 packages/resource/index.js create mode 100644 packages/resource/package.json create mode 100644 packages/server/index.js create mode 100644 packages/server/package.json create mode 100644 packages/server/socket.js create mode 100644 packages/state/index.js create mode 100644 packages/state/package.json create mode 100644 packages/timing/animation-frame.coffee create mode 100644 packages/timing/animation-view.coffee create mode 100644 packages/timing/animation.coffee create mode 100644 packages/timing/cps.coffee create mode 100644 packages/timing/index.coffee create mode 100644 packages/timing/package.json create mode 100644 packages/timing/ticker.coffee create mode 100644 packages/timing/timed-index.coffee create mode 100644 webpack.test.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a56a7ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3e08aeb --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# TODO + +- ❌ remove dependency on decorators diff --git a/package.json b/package.json new file mode 100644 index 0000000..f5418de --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@avocado/monorepo", + "version": "1.0.0", + "author": "cha0s", + "license": "MIT", + "devDependencies": { + "chai": "^4.2.0", + "mocha": "6.0.2", + "mochapack": "1.1.0", + "webpack": "4.29.6" + }, + "scripts": { + "test": "mochapack --watch --webpack-config webpack.test.config.js 'packages/**/*.spec.js'" + } +} diff --git a/packages/behavior/context/globals.js b/packages/behavior/context/globals.js new file mode 100644 index 0000000..206163f --- /dev/null +++ b/packages/behavior/context/globals.js @@ -0,0 +1,115 @@ +import {TickingPromise} from '@avocado/core'; + +import {register as registerType} from './types/registry'; +import {Traversal} from '../item/traversal'; + +export class Globals { + + log(any) { + console.log(any); + } + + randomNumber(min, max, floor = true) { + let mag = Math.random() * (max - min); + return min + (floor ? Math.floor(mag) : mag); + } + + testing() { + return null; + } + + waitMs(ms, state) { + let waited = 0; + let resolve; + const promise = new TickingPromise(_resolve => resolve = _resolve); + promise.ticker = (elapsed) => { + if ((waited += elapsed) >= ms) { + resolve(); + } + } + return promise; + } + +} + +// Globals. +registerType('globals', { + label: 'Globals', + children: { + log: { + type: { + key: 'function', + args: [ + { + type: 'any', + label: 'Thing to log', + }, + ], + return: { + type: 'void', + }, + }, + label: 'Log to console', + }, + randomNumber: { + type: { + key: 'function', + args: [ + { + type: { + key: 'number', + }, + label: 'Minimum value', + advanced: true, + }, + { + type: 'number', + label: 'Maximum value', + }, + { + type: 'boolean', + label: 'Convert to an integer?', + default: true, + }, + ], + return: { + type: 'number', + }, + }, + label: 'Random number', + }, + test: { + type: { + key: 'function', + args: [ + { + type: 'number', + label: 'Milliseconds to wait', + }, + ], + return: { + type: { + key: 'entity', + traits: ['mobile'], + }, + }, + }, + label: 'Entityyyyy', + }, + waitMs: { + type: { + key: 'function', + args: [ + { + type: 'number', + label: 'Milliseconds to wait', + }, + ], + return: { + type: 'void', + }, + }, + label: 'Wait for a specified time', + }, + }, +}); diff --git a/packages/behavior/context/index.js b/packages/behavior/context/index.js new file mode 100644 index 0000000..8988f66 --- /dev/null +++ b/packages/behavior/context/index.js @@ -0,0 +1,99 @@ +import {Globals} from './globals'; +import {TypeMap} from './types'; + +class Context extends Map { + + add(key, value) { + this.set(key, value); + } + + renderStepsUntilNow(steps, step) { + const stepsUntilNow = steps.slice(0, steps.indexOf(step)); + return TypeMap.renderSteps(stepsUntilNow); + } + + traverse(steps) { + return this.traverseAndDo(steps, (node, step) => { + return this.traverseOneStep(steps, node, step); + }); + } + + traverseAndDo(steps, fn) { + const [first, ...rest] = steps; + if ('key' !== first.type) { + throw new TypeError(`First step in a traversal must be type "key"`); + } + return rest.reduce((walk, step, index) => { + if (walk instanceof Promise) { + return walk.then((walk) => { + fn(walk, step, index); + }); + } + else { + return fn(walk, step, index); + } + }, this.get(first.key)); + } + + traverseAndSet(steps, value) { + return this.traverseAndDo(steps, (node, step, index) => { + const isLastStep = index === steps.length - 2; + if (!isLastStep) { + return this.traverseOneStep(steps, node, step); + } + switch (step.type) { + case 'key': + return node[step.key] = value.get(this); + case 'invoke': + const rendered = this.renderStepsUntilNow(steps, step); + throw new ReferenceError(`invalid assignment to function "${rendered}"`); + } + }); + } + + traverseOneStep(steps, node, step) { + if ('undefined' === typeof node) { + const rendered = this.renderStepsUntilNow(steps, step); + throw TypeError(`"${rendered}" is traversed through but undefined`); + } + switch (step.type) { + case 'key': + return node[step.key]; + case 'invoke': + return node(...step.args.map((arg) => arg.get(this))); + } + } +} + +class TypedContext extends Context { + + constructor(iterator) { + super(iterator); + + this.types = {}; + } + + add(key, value, type) { + super.add(key, value); + if (!type) { + return; + } + this.types = { + ...this.types, + [key]: type, + }; + } + +} + +export function createContext() { + const context = new Context(); + context.add('global', new Globals()); + return context; +} + +export function createTypedContext() { + const context = new TypedContext(); + context.add('global', new Globals(), 'globals'); + return context; +} diff --git a/packages/behavior/context/types/index.js b/packages/behavior/context/types/index.js new file mode 100644 index 0000000..ce97872 --- /dev/null +++ b/packages/behavior/context/types/index.js @@ -0,0 +1,143 @@ +import {get, register} from './registry'; + +export class TypeMap { + + static normalizeSpec(type) { + if ('string' === typeof type) { + type = {key: type}; + } + return {...get(type.key), type}; + } + + static renderSteps(steps) { + return steps.reduce((rendered, step) => { + switch (step.type) { + case 'key': + if (rendered) { + rendered += '.'; + } + rendered += step.key; + break; + case 'invoke': + rendered += `(${step.args.length > 0 ? '...' : ''})`; + break; + } + return rendered; + }, ''); + } + + constructor(types) { + this.types = types; + } + + get(matchType, options = {}) { + options = { + doIncludeAdvanced: false, + onlyWritable: false, + ...options + }; + const result = {}; + const typeMap = {}; + for (const key in this.types) { + const spec = TypeMap.normalizeSpec(this.types[key]) + this.pushSpec(typeMap, options, spec, key, []); + } + for (const type in typeMap) { + const spec = TypeMap.normalizeSpec(type); + const doesMatch = ( + 'any' === matchType + || type === matchType + || spec.matcher(matchType) + ); + if (doesMatch) { + result[type] = typeMap[type]; + } + } + return result; + } + + pushSpec(typeMap, options, spec, key, steps) { + steps = [...steps, {type: 'key', key}]; + this.pushTypeSteps(typeMap, options, spec, steps); + if (spec.children) { + this.pushSpecChildren(typeMap, options, spec.children, steps, spec); + } + spec.extraSteps(spec).forEach(({spec, steps: extraSteps}) => { + extraSteps = [...steps, ...extraSteps]; + this.pushTypeSteps(typeMap, options, spec, extraSteps); + if (spec.children) { + this.pushSpecChildren(typeMap, options, spec.children, extraSteps, spec); + } + }); + } + + pushSpecChildren(typeMap, options, children, steps, spec) { + children = 'function' === typeof children ? children(spec) : children; + for (const childKey in children) { + const child = children[childKey]; + const spec = TypeMap.normalizeSpec(child.type); + const compoundChild = {...spec, ...child}; + if (compoundChild.advanced && !options.doIncludeAdvanced) { + continue; + } + this.pushSpec(typeMap, options, compoundChild, childKey, steps); + } + } + + pushTypeSteps(typeMap, options, spec, steps) { + const {isWritable} = spec; + if (options.onlyWritable && !isWritable) { + return; + } + const {type: {key}} = spec; + typeMap[key] = typeMap[key] || []; + typeMap[key].push(steps); + } + +} + +// Defaults... +register('boolean', { + label: 'A boolean value, either true or false', +}); +register('function', { + label: 'A function', + children: { + name: { + advanced: true, + type: 'string', + label: 'Function name', + }, + }, + valueMap: { + invoke: (value, step) => { + return value(...step.args); + }, + }, + extraSteps: (spec) => { + const returnSpec = TypeMap.normalizeSpec(spec.type.return.type); + return [{ + spec: { + ...returnSpec, + ...spec.type.return, + type: returnSpec.type, + }, + steps: [{ + type: 'invoke', + args: spec.type.args, + }], + }]; + } +}); +register('number', { + label: 'A numeric value', +}); +register('object', { + label: 'An object', +}); +register('string', { + label: 'A string of text', +}); +register('void', { + label: 'An undefined or unspecified value', +}); diff --git a/packages/behavior/context/types/registry.js b/packages/behavior/context/types/registry.js new file mode 100644 index 0000000..d8a0dd4 --- /dev/null +++ b/packages/behavior/context/types/registry.js @@ -0,0 +1,26 @@ +const types_PRIVATE = new Map(); + +export function get(type) { + return types_PRIVATE.get(type); +} + +export function register(type, info) { + info = { + advanced: false, + children: [], + extraSteps: (spec) => { + return []; + }, + isWritable: false, + matcher: (matchType) => { + return false; + }, + type, + ...info, + }; + info.valueMap = { + identity: (value, step) => { return value }, + ...info.valueMap, + } + types_PRIVATE.set(type, info); +} diff --git a/packages/behavior/index.js b/packages/behavior/index.js new file mode 100644 index 0000000..936fd92 --- /dev/null +++ b/packages/behavior/index.js @@ -0,0 +1,11 @@ +export {createContext, createTypedContext} from './context'; +export {TypeMap} from './context/types'; +export { + get as getType, + register as registerType +} from './context/types/registry'; + +export { + fromJSON as behaviorItemFromJSON, + register as registerBehaviorItem +} from './item/registry'; diff --git a/packages/behavior/item/action.js b/packages/behavior/item/action.js new file mode 100644 index 0000000..85bdebe --- /dev/null +++ b/packages/behavior/item/action.js @@ -0,0 +1,14 @@ +import {Traversal} from './traversal'; + +export class Action extends Traversal { + static type() { + return 'action'; + } + + toJSON() { + return { + ...super.toJSON(), + type: 'action', + }; + } +} diff --git a/packages/behavior/item/actions.js b/packages/behavior/item/actions.js new file mode 100644 index 0000000..24a827b --- /dev/null +++ b/packages/behavior/item/actions.js @@ -0,0 +1,68 @@ +import {TickingPromise} from '@avocado/core'; +import {EventEmitter} from '@avocado/mixins'; + +import {Traversal} from './traversal'; +import {Traversals} from './traversals'; + +@EventEmitter +export class Actions extends Traversals { + + static type() { + return 'actions'; + } + + constructor() { + super(); + this.index_PRIVATE = 0; + this.traversals = []; + this.pending = null; + } + + get index() { + return this.index_PRIVATE; + } + + set index(index) { + this.index_PRIVATE = index; + } + + tick(context, elapsed) { + if (this.traversals.length === 0) { + return; + } + if ( + this.pending + && this.pending instanceof TickingPromise + && 'function' === typeof this.pending.ticker + ) { + this.pending.ticker(elapsed); + return; + } + + // Actions execute immediately until a promise is made, or they're all + // executed. + while (true) { + const result = this.traversals[this.index].traverse(context); + if (result instanceof Promise) { + result.then(() => this.prologue()); + result.catch((error) => { + throw error; + }); + this.pending = result; + break; + } + this.prologue(); + if (0 === this.index) { + break; + } + } + } + + prologue() { + this.pending = null; + if (0 === (this.index = (this.index + 1) % this.invocations.length)) { + this.emit('actionsFinished'); + } + } + +} diff --git a/packages/behavior/item/actions.spec.js b/packages/behavior/item/actions.spec.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/behavior/item/collection.js b/packages/behavior/item/collection.js new file mode 100644 index 0000000..1af08e0 --- /dev/null +++ b/packages/behavior/item/collection.js @@ -0,0 +1,35 @@ +import {fromJSON as behaviorItemFromJSON} from './registry'; + +export function Collection(type) { + + return class Collection { + + static type() { + return `${type}s`; + } + + constructor() { + this[`${type}s`] = []; + } + + count() { + return this[`${type}s`].length; + } + + fromJSON(json) { + this[`${type}s`] = []; + for (const i in json.items) { + const item = json.items[i]; + this[`${type}s`].push(behaviorItemFromJSON(item.type, item)); + } + return this; + } + + toJSON() { + return { + type, + items: this[`${type}s`].map((item) => item.toJSON()), + }; + } + }; +} diff --git a/packages/behavior/item/condition.js b/packages/behavior/item/condition.js new file mode 100644 index 0000000..e526770 --- /dev/null +++ b/packages/behavior/item/condition.js @@ -0,0 +1,97 @@ +import {fromJSON as behaviorItemFromJSON} from './registry'; + +export class Condition { + + static type() { + return 'condition'; + } + + constructor() { + this.operator = ''; + this.operands = []; + } + + check(...args) { + return this.get(...args); + } + + fromJSON(json) { + this.operator = json.operator; + this.operands = json.operands.map((operand) => { + return behaviorItemFromJSON(operand); + }); + return this; + } + + get(context) { + switch (this.operator) { + case 'is': + return this.operands[0].get(context) === this.operands[1].get(context); + case 'isnt': + return this.operands[0].get(context) !== this.operands[1].get(context); + case '>': + return this.operands[0].get(context) > this.operands[1].get(context); + case '>=': + return this.operands[0].get(context) >= this.operands[1].get(context); + case '<': + return this.operands[0].get(context) < this.operands[1].get(context); + case '<=': + return this.operands[0].get(context) <= this.operands[1].get(context); + + case 'or': + if (0 === this.operands.length) { + return true; + } + for (const operand of this.operands) { + if (!!operand.get(context)) { + return true; + } + } + return false; + + case 'and': + if (0 === this.operands.length) { + return true; + } + for (const operand of this.operands) { + if (!operand.get(context)) { + return false; + } + } + return true; + + } + } + + operandCount() { + return this.operands.length; + } + + operand(index) { + return this.operands[index]; + } + + operands() { + return this.operands; + } + + operator() { + return this.operator; + } + + setOperand(index, operand) { + this.operands[index] = operand; + } + + setOperator(operator) { + this.operator = operator; + } + + toJSON() { + return { + type: 'condition', + operator: this.operator, + operands: this.operands.map((operand) => operand.toJSON()), + }; + } +} diff --git a/packages/behavior/item/condition.spec.js b/packages/behavior/item/condition.spec.js new file mode 100644 index 0000000..2f901aa --- /dev/null +++ b/packages/behavior/item/condition.spec.js @@ -0,0 +1,109 @@ +import {expect} from 'chai'; + +import {Condition} from './condition'; +import {Literal} from './literal'; +import {deregister, register} from './registry'; + +describe('behavior', () => { + describe('condition', () => { + it('can do binary compares', () => { + const condition = new Condition(); + condition.setOperand(0, new Literal().fromJSON({ + type: 'literal', + value: 500, + })); + condition.setOperand(1, new Literal().fromJSON({ + type: 'literal', + value: 420, + })); + // Greater than. + condition.setOperator('>'); + expect(condition.check()).to.be.true; + // Less than. + condition.setOperator('<'); + expect(condition.check()).to.be.false; + // Greater than or equal. + condition.setOperator('>='); + expect(condition.check()).to.be.true; + // Less than or equal. + condition.setOperator('<='); + expect(condition.check()).to.be.false; + // Is. + condition.setOperator('is'); + expect(condition.check()).to.be.false; + // Is not. + condition.setOperator('isnt'); + expect(condition.check()).to.be.true; + }); + it('can do varnary compares', () => { + const condition = new Condition(); + condition.setOperand(0, new Literal().fromJSON({ + type: 'literal', + value: true, + })); + condition.setOperand(1, new Literal().fromJSON({ + type: 'literal', + value: true, + })); + condition.setOperand(2, new Literal().fromJSON({ + type: 'literal', + value: false, + })); + // AND, mixed. + condition.setOperator('and'); + expect(condition.check()).to.be.false; + // OR, mixed. + condition.setOperator('or'); + expect(condition.check()).to.be.true; + // OR, all true. + condition.setOperand(2, new Literal().fromJSON({ + type: 'literal', + value: true, + })); + expect(condition.check()).to.be.true; + // AND, all true. + condition.setOperator('and'); + expect(condition.check()).to.be.true; + // AND, all false. + for (const i of [0, 1, 2]) { + condition.setOperand(i, new Literal().fromJSON({ + type: 'literal', + value: false, + })); + } + expect(condition.check()).to.be.false; + // OR, all false. + condition.setOperator('or'); + expect(condition.check()).to.be.false; + }); + describe('JSON', () => { + beforeEach(() => { + register(Literal); + }); + afterEach(() => { + deregister(Literal); + }); + it('can instantiate operands', () => { + const condition = new Condition(); + // Greater than. + condition.fromJSON({ + operator: '>', + operands: [ + { + type: 'literal', + value: 500, + }, + { + type: 'literal', + value: 420, + }, + ], + }); + expect(condition.check()).to.be.true; + // Less than. + condition.setOperator('<'); + expect(condition.check()).to.be.false; + }); + }); + }); +}); diff --git a/packages/behavior/item/conditions.js b/packages/behavior/item/conditions.js new file mode 100644 index 0000000..16437cb --- /dev/null +++ b/packages/behavior/item/conditions.js @@ -0,0 +1,14 @@ +import {Collection} from './collection'; + +export class Conditions extends Collection('condition') { + + check(context) { + for (condition of this.conditions) { + if (!this.conditions[index].check(context)) { + return false; + } + } + return true; + } + +} \ No newline at end of file diff --git a/packages/behavior/item/initialize.js b/packages/behavior/item/initialize.js new file mode 100644 index 0000000..180f018 --- /dev/null +++ b/packages/behavior/item/initialize.js @@ -0,0 +1,23 @@ +import {register} from './registry'; + +import {Action} from './action'; +import {Actions} from './actions'; +import {Condition} from './condition'; +import {Conditions} from './conditions'; +import {Literal} from './literal'; +import {Routine} from './routine'; +import {Routines} from './routines'; +import {Traversal} from './traversal'; +import {Traversals} from './traversals'; +import {TraversalAndSet} from './traversal-and-set'; + +register(Action); +register(Actions); +register(Condition); +register(Conditions); +register(Literal); +register(Routine); +register(Routines); +register(Traversal); +register(Traversals); +register(TraversalAndSet); diff --git a/packages/behavior/item/literal.js b/packages/behavior/item/literal.js new file mode 100644 index 0000000..7124f2d --- /dev/null +++ b/packages/behavior/item/literal.js @@ -0,0 +1,30 @@ +export class Literal { + + static type() { + return 'literal'; + } + + constructor() { + this.value = null; + } + + fromJSON(json) { + this.value = json.value; + return this; + } + + get(context) { + return this.value; + } + + set(value) { + this.value = value; + } + + toJSON() { + return { + type: 'literal', + value: this.value, + } + } +} diff --git a/packages/behavior/item/literal.spec.js b/packages/behavior/item/literal.spec.js new file mode 100644 index 0000000..b65b2be --- /dev/null +++ b/packages/behavior/item/literal.spec.js @@ -0,0 +1,22 @@ +import {expect} from 'chai'; + +import {Literal} from './literal'; + +describe('behavior', () => { + describe('literal', () => { + let literal = undefined; + beforeEach(() => { + literal = new Literal(); + literal.fromJSON({ + value: 69, + }); + }); + it('can access literals', () => { + expect(literal.get()).to.equal(69); + }); + it('can modify literals', () => { + literal.set(420); + expect(literal.get()).to.equal(420); + }); + }); +}); diff --git a/packages/behavior/item/registry.js b/packages/behavior/item/registry.js new file mode 100644 index 0000000..08378e8 --- /dev/null +++ b/packages/behavior/item/registry.js @@ -0,0 +1,17 @@ +const behaviorItemRegistry = new Map(); + +export function fromJSON({type, ...json}) { + const Class = behaviorItemRegistry.get(type); + if (!Class) { + throw new TypeError(`There is no class for the behavior item "${type}"`); + } + return (new Class()).fromJSON(json); +} + +export function deregister(BehaviorItem) { + behaviorItemRegistry.delete(BehaviorItem.type()); +} + +export function register(BehaviorItem) { + behaviorItemRegistry.set(BehaviorItem.type(), BehaviorItem); +} diff --git a/packages/behavior/item/routine.js b/packages/behavior/item/routine.js new file mode 100644 index 0000000..3cbe91d --- /dev/null +++ b/packages/behavior/item/routine.js @@ -0,0 +1,29 @@ +import {Actions} from './actions'; + +export class Routine { + + static type() { + return 'routine'; + } + + constructor() { + this.actions = new Actions(); + } + + fromJSON(json) { + this.actions.fromObject(json.actions); + return this; + } + + tick(context, elapsed) { + this.actions.tick(context, elapsed); + } + + toJSON() { + return { + type: 'routine', + actions: this.actions.toJSON(), + } + } + +} diff --git a/packages/behavior/item/routines.js b/packages/behavior/item/routines.js new file mode 100644 index 0000000..236f283 --- /dev/null +++ b/packages/behavior/item/routines.js @@ -0,0 +1,34 @@ +import {Routine} from './routine'; + +export class Routines { + + static type() { + return 'routines'; + } + + constructor() { + this.routines = {}; + } + + fromJSON(json) { + for (const i in json.routines) { + this.routines[i] = (new Routine()).fromJSON(json.routines[i]); + } + return this; + } + + routine(index) { + return this.routines[index]; + } + + toJSON() { + const routines = {}; + for (const i in this.routines) { + routines[i] = this.routines[i].toJSON(); + } + return { + type: 'routines', + routines, + }; + } +} diff --git a/packages/behavior/item/traversal-and-set.js b/packages/behavior/item/traversal-and-set.js new file mode 100644 index 0000000..3b7c006 --- /dev/null +++ b/packages/behavior/item/traversal-and-set.js @@ -0,0 +1,34 @@ +import {fromJSON as behaviorItemFromJSON} from './registry'; +import {Traversal} from './traversal'; + +export class TraversalAndSet extends Traversal { + + static type() { + return 'traversal-and-set'; + } + + constructor() { + super(); + this.value = undefined; + } + + fromJSON(json) { + super.fromJSON(json); + this.value = behaviorItemFromJSON(json.value); + return this; + } + + traverse(context) { + if (context) { + return context.traverseAndSet(this.steps, this.value); + } + } + + toJSON() { + return { + ...super.toJSON(), + type: 'traversal-and-set', + value: this.value.toJSON(), + }; + } +} diff --git a/packages/behavior/item/traversal.js b/packages/behavior/item/traversal.js new file mode 100644 index 0000000..a29c5ad --- /dev/null +++ b/packages/behavior/item/traversal.js @@ -0,0 +1,54 @@ +import {fromJSON as behaviorItemFromJSON} from './registry'; + +export class Traversal { + + static type() { + return 'traversal'; + } + + constructor() { + this.steps = []; + } + + fromJSON(json) { + this.steps = json.steps.map((step) => { + switch (step.type) { + case 'key': + return step; + case 'invoke': + return { + type: 'invoke', + args: step.args.map((arg) => behaviorItemFromJSON(arg)), + }; + } + }); + return this; + } + + get(context) { + return this.traverse(context); + } + + traverse(context) { + if (context) { + return context.traverse(this.steps); + } + } + + toJSON() { + return { + type: 'traversal', + steps: this.steps.map((step) => { + switch (step.type) { + case 'key': + return step; + case 'invoke': + return { + type: 'invoke', + args: step.args.map((arg) => arg.toJSON()), + }; + } + }), + }; + } +} diff --git a/packages/behavior/item/traversals.js b/packages/behavior/item/traversals.js new file mode 100644 index 0000000..1699eba --- /dev/null +++ b/packages/behavior/item/traversals.js @@ -0,0 +1,36 @@ +import {TickingPromise} from '@avocado/core'; + +import {Collection} from './collection'; +import {Traversal} from './traversal'; + +export class Traversals extends Collection('traversal') { + + parallel(context) { + const results = this.traversals.map((traversal) => { + return traversal.traverse(context); + }); + // Early out if no promises. + if (!results.reduce((has, result) => { + return has || result instanceof Promise; + }, false)) { + return results; + } + // Otherwise, wrap in a promise. + const tpromise = new TickingPromise((resolve, reject) => { + return Promise.all(results); + }); + // Proxy all ticks. + const tickableResults = results.filter((result) => { + if (!(result instanceof TickingPromise)) return false; + if ('function' !== typeof result.ticker) return false; + return true; + }); + if (tickableResults.length > 0) { + tpromise.ticker = (elapsed) => { + tickableResults.forEach((result) => result.ticker(elapsed)); + }; + } + return tpromise; + } + +} diff --git a/packages/behavior/package.json b/packages/behavior/package.json new file mode 100644 index 0000000..786dc58 --- /dev/null +++ b/packages/behavior/package.json @@ -0,0 +1,10 @@ +{ + "name": "@avocado/behavior", + "version": "1.0.4", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "debug": "3.1.0" + } +} diff --git a/packages/client/index.js b/packages/client/index.js new file mode 100644 index 0000000..4ea1343 --- /dev/null +++ b/packages/client/index.js @@ -0,0 +1 @@ +export * from './index.socket'; diff --git a/packages/client/index.socket.js b/packages/client/index.socket.js new file mode 100644 index 0000000..8c26d1a --- /dev/null +++ b/packages/client/index.socket.js @@ -0,0 +1,40 @@ +import {EventEmitter} from 'events'; +import io from 'socket.io-client'; + +const exceptions = [ + 'connect', +]; + +class SocketClient extends EventEmitter { + + constructor(address) { + super(); + this.socket = io(address, { + path: '/avocado', + }); + this.socket.on('connect', () => { + this.emit('connect'); + }); + this.socket.on('message', (...args) => { + this.emit('message', ...args); + }); + } + + on(name, fn) { + if (-1 === exceptions.indexOf(name)) { + super.on(name, fn); + } + else { + this.socket.on(name, fn); + } + } + + send(...args) { + this.socket.send(...args); + } + +} + +export function create(address) { + return new SocketClient(address); +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..b286dce --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,10 @@ +{ + "name": "@avocado/client", + "version": "1.0.1", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "socket.io-client": "2.2.0" + } +} diff --git a/packages/core/index.js b/packages/core/index.js new file mode 100644 index 0000000..3efc295 --- /dev/null +++ b/packages/core/index.js @@ -0,0 +1,45 @@ +/** + * Composes single-argument functions from right to left. The rightmost + * function can take multiple arguments as it provides the signature for + * the resulting composite function. + * + * @param {...Function} funcs The functions to compose. + * @returns {Function} A function obtained by composing the argument functions + * from right to left. For example, compose(f, g, h) is identical to doing + * (...args) => f(g(h(...args))). + */ + +export function compose(...funcs) { + if (funcs.length === 0) { + return arg => arg + } + + if (funcs.length === 1) { + return funcs[0] + } + + return funcs.reduce((a, b) => (...args) => a(b(...args))) +} + +export function virtualize(fields) { + return (Superclass) => { + class Virtualized extends Superclass {} + fields.forEach((field) => { + Virtualized.prototype[field] = function() { + const prototype = Object.getPrototypeOf(this); + const className = prototype.constructor.name; + throw new ReferenceError( + `"${className}" has undefined pure virtual method "${field}"` + ); + } + }); + return Virtualized; + } +} + +export class TickingPromise extends Promise { + constructor(resolve, reject) { + super(resolve, reject); + this.ticker = null; + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..4ac8491 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,7 @@ +{ + "name": "@avocado/core", + "version": "1.0.3", + "main": "index.js", + "author": "cha0s", + "license": "MIT" +} diff --git a/packages/entity/index.js b/packages/entity/index.js new file mode 100644 index 0000000..850ee24 --- /dev/null +++ b/packages/entity/index.js @@ -0,0 +1,143 @@ +import * as I from 'immutable'; + +import {compose} from '@avocado/core'; +import {EventEmitter} from '@avocado/mixins'; +import {Resource} from '@avocado/resource'; + +import {Traits} from './traits'; + +class TraitProxy { + + has(entity, property, receiver) { + if (property in entity) { + return Reflect.has(entity, property, receiver); + } + else { + return entity.traits_PRIVATE.hasProperty(property); + } + } + + get(entity, property, receiver) { + if (property in entity) { + return Reflect.get(entity, property, receiver); + } + else { + return entity.traits_PRIVATE.getProperty(property); + } + } + + set(entity, property, value, receiver) { + if (property in entity) { + return Reflect.set(entity, property, value, receiver); + } + else { + if (!entity.traits_PRIVATE.setProperty(property, value, receiver)) { + return Reflect.set(entity, property, value, receiver); + } + else { + return true; + } + } + } +} + +const decorate = compose( + EventEmitter, +); + +class Entity extends decorate(Resource) { + + constructor() { + super(); + this.isTicking_PRIVATE = true; + this.traits_PRIVATE = new Traits(createProxy(this)); + } + + acceptStateChange(change) { + this.traits_PRIVATE.acceptStateChange(change); + } + + addTrait(type, trait) { + this.traits_PRIVATE.addTrait(type, trait); + } + + addTraits(traits) { + for (const type in traits) { + this.traits_PRIVATE.addTrait(type, traits[type]); + } + } + + allTraitInstances() { + return this.traits_PRIVATE.allInstances(); + } + + allTraitTypes() { + return this.traits_PRIVATE.allTypes(); + } + + destroy() { + this.isTicking = false; + this.emit('destroyed'); + } + + fromJSON(json) { + super.fromJSON(json); + this.traits_PRIVATE.fromJSON(json.traits); + return this; + } + + hydrate() { + return this.traits_PRIVATE.hydrate(); + } + + invokeHook(hook, ...args) { + return this.traits_PRIVATE.invokeHook(hook, ...args); + } + + invokeHookFlat(hook, ...args) { + return this.traits_PRIVATE.invokeHookFlat(hook, ...args); + } + + removeAllTraits() { + const types = this.traits_PRIVATE.allTypes(); + this.removeTraits(types); + } + + removeTrait(type) { + this.traits_PRIVATE.removeTrait(type); + } + + removeTraits(types) { + types.forEach((type) => this.removeTrait(type)); + } + + state() { + return this.traits_PRIVATE.state(); + } + + toJSON() { + return { + ...super.toJSON(), + traits: this.traits_PRIVATE.toJSON(), + } + } + +} + +export function create() { + return createProxy(new Entity()); +} + +export function createProxy(entity) { + return new Proxy(entity, new TraitProxy()); +} + +export {EntityList} from './list'; + +export { + hasTrait, + lookupTrait, + registerTrait, +} from './traits/registry'; + +export {simpleState, Trait} from './trait'; diff --git a/packages/entity/list.js b/packages/entity/list.js new file mode 100644 index 0000000..c77cc66 --- /dev/null +++ b/packages/entity/list.js @@ -0,0 +1,87 @@ +import * as I from 'immutable'; +import mapValues from 'lodash.mapvalues'; + +import {create} from './index'; + +export class EntityList { + + constructor() { + this.entities_PRIVATE = {}; + this.state_PRIVATE = I.Map(); + this.uuidMap_PRIVATE = {}; + } + + *[Symbol.iterator]() { + for (const uuid in this.entities_PRIVATE) { + const entity = this.entities_PRIVATE[uuid]; + yield entity; + } + } + + acceptStateChange(change) { + for (const uuid in change) { + const localUuid = this.uuidMap_PRIVATE[uuid]; + const entity = this.entities_PRIVATE[localUuid]; + if (entity) { + if (false === change[uuid]) { + // Entity removed. + this.removeEntity(entity); + } + else { + entity.acceptStateChange(change[uuid]); + this.state_PRIVATE = this.state_PRIVATE.set(localUuid, entity.state()); + } + } + else { + // New entity. Create with change as traits' state. + const traits = mapValues(change[uuid], (changeTraits) => ({ + state: changeTraits, + })); + const newEntity = create().fromJSON({traits}); + this.addEntity(newEntity); + this.uuidMap_PRIVATE[uuid] = newEntity.instanceUuid; + } + } + } + + addEntity(entity) { + const uuid = entity.instanceUuid; + this.entities_PRIVATE[uuid] = entity; + this.state_PRIVATE = this.state_PRIVATE.set(uuid, entity.state()); + entity.on('destroyed', () => { + this.removeEntity(entity); + }); + } + + entity(uuid) { + return this.entities_PRIVATE[uuid]; + } + + recomputeState() { + for (const uuid in this.entities_PRIVATE) { + const entity = this.entities_PRIVATE[uuid]; + this.state_PRIVATE = this.state_PRIVATE.set(uuid, entity.state()); + } + } + + removeEntity(entity) { + const uuid = entity.instanceUuid; + delete this.entities_PRIVATE[uuid]; + this.state_PRIVATE = this.state_PRIVATE.delete(uuid); + } + + state() { + return this.state_PRIVATE; + } + + tick(elapsed) { + for (const uuid in this.entities_PRIVATE) { + const entity = this.entities_PRIVATE[uuid]; + if ('tick' in entity) { + entity.tick(elapsed); + } + } + this.recomputeState(); + } + +} diff --git a/packages/entity/package.json b/packages/entity/package.json new file mode 100644 index 0000000..017ebad --- /dev/null +++ b/packages/entity/package.json @@ -0,0 +1,17 @@ +{ + "name": "@avocado/entity", + "version": "1.0.6", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "@avocado/core": "1.x", + "@avocado/math": "1.x", + "@avocado/mixins": "1.x", + "@avocado/resource": "1.x", + "debug": "^3.1.0", + "immutable": "4.0.0-rc.12", + "lodash.mapvalues": "4.6.0", + "lodash.without": "4.4.0" + } +} diff --git a/packages/entity/trait.js b/packages/entity/trait.js new file mode 100644 index 0000000..8fbcf9c --- /dev/null +++ b/packages/entity/trait.js @@ -0,0 +1,98 @@ +import * as I from 'immutable'; + +import {Resource} from '@avocado/resource'; + +export class Trait { + + constructor(entity) { + this.entity = entity; + this.params = I.fromJS(this.constructor.defaultParams()); + this.state = I.fromJS(this.constructor.defaultState()); + // Attach listeners. + const listeners = this.listeners(); + const traitType = this.constructor.type(); + for (const type in this.listeners()) { + entity.on(`${type}.trait-${traitType}`, listeners[type]); + } + } + + acceptStateChange(change) { + this.state = this.state.merge(change); + } + + actions() { + return {}; + } + + destroy() { + this.entity.off(`.trait-${this.constructor.type()}`); + } + + fromJSON({params = {}, state = {}}) { + this.params = I.fromJS(this.constructor.defaultParams()).merge(params); + this.state = I.fromJS(this.constructor.defaultState()).merge(state); + return this; + } + + hooks() { + return {}; + } + + hydrate() { + return Promise.resolve(); + } + + label() { + return this.constructor.name; + } + + listeners() { + return {}; + } + + toJSON() { + return { + params: this.params.toJS(), + state: this.state.toJS(), + }; + } + + static contextType() { + return {}; + } + + static defaultParams() { + return {}; + } + + static defaultState() { + return {}; + } + + static dependencies() { + return []; + } + + static type() { + return this.name.toLowerCase(); + } + +} + +export function simpleState(name) { + return (Superclass) => { + class SimpleState extends Superclass {} + // Add simple state handler. + Object.defineProperty(SimpleState.prototype, name, { + get() { + return this.state.get(name); + }, + set(value) { + this.state = this.state.set(name, value); + }, + enumerable: true, + configurable: true, + }); + return SimpleState; + } +} diff --git a/packages/entity/traits/directional.js b/packages/entity/traits/directional.js new file mode 100644 index 0000000..8d68f95 --- /dev/null +++ b/packages/entity/traits/directional.js @@ -0,0 +1,37 @@ +import {compose} from '@avocado/core'; +import {Vector} from '@avocado/math'; + +import {simpleState, Trait} from '../trait'; + +const decorate = compose( + simpleState('direction'), +); + +class DirectionalBase extends Trait { + + static defaultParams() { + return { + directionCount: 1, + }; + } + + static defaultState() { + return { + direction: 0, + }; + } + + listeners() { + return { + movementRequest: (vector) => { + this.entity.direction = Vector.toDirection( + vector, + this.params.get('directionCount') + ); + }, + } + } + +} + +export class Directional extends decorate(DirectionalBase) {} diff --git a/packages/entity/traits/existent.js b/packages/entity/traits/existent.js new file mode 100644 index 0000000..d5ce9b8 --- /dev/null +++ b/packages/entity/traits/existent.js @@ -0,0 +1,57 @@ +import {compose} from '@avocado/core'; + +import {simpleState, Trait} from '../trait'; + +const decorate = compose( + simpleState('name'), +); + +class ExistentBase extends Trait { + + constructor(...args) { + super(...args); + this._isTicking = this.params.get('isTicking'); + } + + static defaultParams() { + return { + isTicking: true, + }; + } + + static defaultState() { + return { + name: 'Untitled entity', + }; + } + + get isTicking() { + return this._isTicking; + } + + set isTicking(isTicking) { + this._isTicking = isTicking; + } + + actions() { + return { + + destroy: () => { + this.isTicking = false; + this.entity.emit('destroy'); + this.entity.emit('destroyed'); + }, + + tick: (elapsed) => { + if (!this.isTicking) { + return; + } + this.entity.emit('tick', elapsed); + }, + + }; + } + +} + +export class Existent extends decorate(ExistentBase) {} diff --git a/packages/entity/traits/index.js b/packages/entity/traits/index.js new file mode 100644 index 0000000..a491fe0 --- /dev/null +++ b/packages/entity/traits/index.js @@ -0,0 +1,256 @@ +const debug = require('debug')('@avocado/entity:traits'); +import without from 'lodash.without'; +import * as I from 'immutable'; + +import {Resource} from '@avocado/resource'; + +import {hasTrait, lookupTrait, registerTrait} from './registry'; + +function enumerateProperties(prototype) { + const result = {}; + do { + Object.getOwnPropertyNames(prototype).forEach((property) => { + const descriptor = Object.getOwnPropertyDescriptor(prototype, property); + if (typeof descriptor.get === 'function') { + result[property] = result[property] || {}; + result[property].get = true; + } + if (typeof descriptor.set === 'function') { + result[property] = result[property] || {}; + result[property].set = true; + } + }); + } while (Object.prototype !== (prototype = Object.getPrototypeOf(prototype))); + return result; +} + +export class Traits { + + constructor(entity) { + this.actions_PRIVATE = {}; + this.entity_PRIVATE = entity; + this.hooks_PRIVATE = {}; + this.properties_PRIVATE = {}; + this.state_PRIVATE = I.Map(); + this.traits_PRIVATE = {}; + } + + acceptStateChange(change) { + for (const type in change) { + let instance = this.traits_PRIVATE[type]; + // New trait requested? + if (!this.traits_PRIVATE[type]) { + // Doesn't exist? + if (!hasTrait(type)) { + continue; + } + this.addTrait(type, change[type]); + instance = this.traits_PRIVATE[type]; + } + // Accept state. + instance.acceptStateChange(change[type]); + this.state_PRIVATE = this.state_PRIVATE.set(type, instance.state); + } + } + + allInstances() { + return this.traits_PRIVATE; + } + + allTypes() { + return Object.keys(this.traits_PRIVATE); + } + + addTrait(type, json) { + if (this.hasTrait(type)) { + debug(`Tried to add trait "${type}" when it already exists!`); + return; + } + if (!hasTrait(type)) { + debug(`Tried to add trait "${type}" which isn't registered!`); + return; + } + const Trait = lookupTrait(type); + // Ensure dependencies. + const dependencies = Trait.dependencies(); + const allTypes = this.allTypes(); + const lacking = without(dependencies, ...allTypes); + if (lacking.length > 0) { + debug( + `Tried to add trait "${type}" but lack one or more dependents: "${ + lacking.join('", "') + }"!` + ); + return; + } + // Instantiate. + const instance = (new Trait(this.entity_PRIVATE)).fromJSON(json); + // Proxy actions. + const actions = instance.actions(); + for (const key in actions) { + this.actions_PRIVATE[key] = actions[key]; + } + // Register hook listeners. + const hooks = instance.hooks(); + for (const key in hooks) { + this.hooks_PRIVATE[key] = this.hooks_PRIVATE[key] || []; + this.hooks_PRIVATE[key].push({ + fn: hooks[key], + type: Trait.type(), + }); + } + // Proxy properties. + const properties = enumerateProperties(Trait.prototype); + for (const key in properties) { + properties[key].instance = instance; + this.properties_PRIVATE[key] = properties[key]; + } + // Add state. + this.state_PRIVATE = this.state_PRIVATE.set(type, instance.state); + // Track trait. + this.traits_PRIVATE[type] = instance; + } + + addTraits(traits) { + const allTypes = this.allTypes(); + for (const type in traits) { + this.addTrait(type, traits[type]); + } + } + + fromJSON(json) { + this.addTraits(json); + return this; + } + + getProperty(property) { + if (property in this.actions_PRIVATE) { + return this.actions_PRIVATE[property]; + } + if (property in this.properties_PRIVATE) { + const instance = this.properties_PRIVATE[property].instance; + if (!this.properties_PRIVATE[property].get) { + const type = instance.constructor.type(); + throw new ReferenceError( + `Property '${property}' from Trait '${type}' has no getter` + ); + } + return instance[property]; + } + } + + hasProperty(property) { + if (property in this.actions_PRIVATE) { + return true; + } + if (property in this.properties_PRIVATE) { + return true; + } + return false; + } + + hasTrait(type) { + return type in this.traits_PRIVATE; + } + + hydrate() { + const promises = []; + for (const type in this.traits_PRIVATE) { + const instance = this.traits_PRIVATE[type]; + promises.push(instance.hydrate()); + } + return Promise.all(promises); + } + + instance(type) { + return this.traits_PRIVATE[type]; + } + + invokeHook(hook, ...args) { + const results = {}; + if (!(hook in this.hooks_PRIVATE)) { + return results; + } + for (const {fn, type} of this.hooks_PRIVATE[hook]) { + results[type] = fn(...args); + } + return results; + } + + invokeHookFlat(hook, ...args) { + const invokeResults = this.invokeHook(hook, ...args); + const results = []; + for (const type in invokeResults) { + results.push(invokeResults[type]); + } + return results; + } + + removeAllTraits() { + const types = this.allTypes(); + this.removeTraits(types); + } + + removeTrait(type) { + if (!this.hasTrait(type)) { + debug(`Tried to remove trait "${type}" when it doesn't exist!`); + return; + } + + const instance = this.traits_PRIVATE[type]; + + const actions = instance.actions(); + for (const key in actions) { + delete this.actions_PRIVATE[key]; + } + + const hooks = instance.hooks(); + for (const key in hooks) { + delete this.hooks_PRIVATE[key]; + } + + const Trait = lookupTrait(type); + const properties = enumerateProperties(Trait.prototype); + for (const key in properties) { + delete this.properties_PRIVATE[key]; + } + + instance.destroy(); + + this.state_PRIVATE = this.state_PRIVATE.delete(type); + delete this.traits_PRIVATE[type]; + } + + removeTraits(types) { + types.forEach((type) => this.removeTrait(type)); + } + + setProperty(property, value, receiver) { + if (property in this.properties_PRIVATE) { + const instance = this.properties_PRIVATE[property].instance; + const type = instance.constructor.type(); + if (!this.properties_PRIVATE[property].set) { + throw new ReferenceError( + `Property '${property}' from Trait '${type}' has no setter` + ); + } + instance[property] = value; + this.state_PRIVATE = this.state_PRIVATE.set(type, instance.state); + return true; + } + return false; + } + + state() { + return this.state_PRIVATE; + } + + toJSON() { + const json = {}; + for (const type in this.traits_PRIVATE) { + json[type] = this.traits_PRIVATE[type].toJSON(); + } + return json; + } + +} diff --git a/packages/entity/traits/mobile.js b/packages/entity/traits/mobile.js new file mode 100644 index 0000000..c4137dc --- /dev/null +++ b/packages/entity/traits/mobile.js @@ -0,0 +1,59 @@ +import {compose} from '@avocado/core'; +import {Vector} from '@avocado/math'; + +import {simpleState, Trait} from '../trait'; + +const decorate = compose( + simpleState('isMobile'), + simpleState('speed'), +) + +class MobileBase extends Trait { + + static defaultState() { + return { + isMobile: true, + speed: 0, + }; + } + + constructor(entity) { + super(entity); + this.requestedMovement = [0, 0]; + } + + actions() { + return { + requestMovement: (vector) => { + if (!this.isMobile) { + return; + } + this.requestedMovement = Vector.scale( + Vector.hypotenuse(vector), + this.speed + ); + this.entity.emit('movementRequest', this.requestedMovement); + }, + } + } + + listeners() { + return { + tick: (elapsed) => { + if (Vector.isZero(this.requestedMovement)) { + return; + } + const requestedMovement = Vector.scale( + this.requestedMovement, + elapsed + ); + this.requestedMovement = [0, 0]; + this.entity.x += requestedMovement[0]; + this.entity.y += requestedMovement[1]; + }, + } + } + +} + +export class Mobile extends decorate(MobileBase) {} diff --git a/packages/entity/traits/positioned.js b/packages/entity/traits/positioned.js new file mode 100644 index 0000000..9c9adca --- /dev/null +++ b/packages/entity/traits/positioned.js @@ -0,0 +1,32 @@ +import {compose} from '@avocado/core'; + +import {simpleState, Trait} from '../trait'; + +const decorate = compose( + simpleState('x'), + simpleState('y'), +); + +class PositionedBase extends Trait { + + static defaultState() { + return { + x: 0, + y: 0, + }; + } + + get position() { + return [ + this.state.get('x'), + this.state.get('y'), + ]; + } + + set position([x, y]) { + this.state = this.state.merge({x, y}); + } + +} + +export class Positioned extends decorate(PositionedBase) {} diff --git a/packages/entity/traits/registry.js b/packages/entity/traits/registry.js new file mode 100644 index 0000000..2393600 --- /dev/null +++ b/packages/entity/traits/registry.js @@ -0,0 +1,29 @@ +// import {registerType} from '@avocado/behavior'; + +const traitRegistry = new Map(); + +export function registerTrait(Trait) { + traitRegistry.set(Trait.type(), Trait); + // registerType(`entity:trait:${Trait.type()}`, Trait.contextType()); +} + +export function hasTrait(type) { + return traitRegistry.has(type); +} + +export function lookupTrait(type) { + return traitRegistry.get(type); +} + +// Register core traits. +import {Directional} from './directional'; +registerTrait(Directional); + +import {Existent} from './existent'; +registerTrait(Existent); + +import {Mobile} from './mobile'; +registerTrait(Mobile); + +import {Positioned} from './positioned'; +registerTrait(Positioned); diff --git a/packages/graphics/image.js b/packages/graphics/image.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/graphics/index.js b/packages/graphics/index.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/graphics/package.json b/packages/graphics/package.json new file mode 100644 index 0000000..f918968 --- /dev/null +++ b/packages/graphics/package.json @@ -0,0 +1,14 @@ +{ + "name": "@avocado/graphics", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "@avocado/core": "1.x", + "@avocado/math": "1.x", + "@avocado/mixins": "1.x", + "debug": "^3.1.0", + "immutable": "4.0.0-rc.12" + } +} diff --git a/packages/input/index.js b/packages/input/index.js new file mode 100644 index 0000000..bec8af7 --- /dev/null +++ b/packages/input/index.js @@ -0,0 +1,123 @@ +import * as I from 'immutable'; + +import {compose} from '@avocado/core'; +import {EventEmitter} from '@avocado/mixins'; + +const decorate = compose( + EventEmitter, +); + +class ActionRegistryBase { + + static normalizeKey(key) { + return key.toLowerCase(); + } + + constructor() { + // Track events. + this.target = undefined; + this.mapActionToKey = {}; + this.mapKeyToAction = {}; + // Track actions. + this._state = I.Set(); + // Handle lame OS input event behavior. See: https://mzl.la/2Ob0WQE + this.keysDown = {}; + this.keyUpDelays = {}; + // Bind event handlers. + this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + } + + actionForKey(key) { + return this.mapKeyToAction[key]; + } + + listen(target = window.document) { + // Only listen once. + if (this.target) { + return; + } + this.target = target; + this.target.addEventListener('blur', this.onBlur); + this.target.addEventListener('keydown', this.onKeyDown); + this.target.addEventListener('keyup', this.onKeyUp); + } + + keyForAction(action) { + return this.mapActionToKey[action]; + } + + mapKeysToActions(map) { + for (const key in map) { + const action = map[key]; + this.mapKeyToAction[key] = action; + this.mapActionToKey[action] = key; + } + } + + onBlur(event) { + event = event || window.event; + this.setAllKeysUp(); + } + + onKeyDown(event) { + event = event || window.event; + const key = this.constructor.normalizeKey(event.key); + if (this.keysDown[key]) { + if (this.keyUpDelays[key]) { + clearTimeout(this.keyUpDelays[key]); + delete this.keyUpDelays[key]; + } + return; + } + this.keysDown[key] = true; + if (this.mapKeyToAction[key]) { + const action = this.mapKeyToAction[key]; + this._state = this._state.add(action); + this.emit('actionStart', action); + } + } + + onKeyUp(event) { + event = event || window.event; + const key = this.constructor.normalizeKey(event.key); + this.keyUpDelays[key] = setTimeout(() => { + delete this.keyUpDelays[key]; + delete this.keysDown[key]; + if (this.mapKeyToAction[key]) { + const action = this.mapKeyToAction[key]; + this._state = this._state.delete(action); + this.emit('actionStop', action); + } + }, 20); + } + + setAllKeysUp() { + this.keysDown = {}; + for (const key in this.keyUpDelays) { + const handle = this.keyUpDelays[key]; + clearTimeout(handle); + } + this.keyUpDelays = {}; + this._state = I.Set(); + } + + state() { + return this._state; + } + + stopListening() { + this.setAllKeysUp(); + if (!this.target) { + return; + } + this.target.removeEventListener('blur', this.onBlur); + this.target.removeEventListener('keydown', this.onKeyDown); + this.target.removeEventListener('keyup', this.onKeyUp); + this.target = undefined; + } + +} + +export class ActionRegistry extends decorate(ActionRegistryBase) {}; diff --git a/packages/input/package.json b/packages/input/package.json new file mode 100644 index 0000000..7313326 --- /dev/null +++ b/packages/input/package.json @@ -0,0 +1,13 @@ +{ + "name": "@avocado/input", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "@avocado/core": "1.x", + "@avocado/mixins": "1.x", + "debug": "^3.1.0", + "immutable": "4.0.0-rc.12" + } +} diff --git a/packages/math/index.js b/packages/math/index.js new file mode 100644 index 0000000..9d74d14 --- /dev/null +++ b/packages/math/index.js @@ -0,0 +1,4 @@ +// export * as Matrix from './matrix'; +// export * as Rectangle from './rectangle'; +import * as Vector from './vector'; +export {Vector}; diff --git a/packages/math/matrix.js b/packages/math/matrix.js new file mode 100644 index 0000000..b0489bf --- /dev/null +++ b/packages/math/matrix.js @@ -0,0 +1,31 @@ +// Matrix operations. + +// **Matrix** is a utility class to help with matrix operations. A +// matrix is implemented as an n-element array. Data is stored in row-major +// order. + +export copy = (matrix) => matrix.map((row) => [...row]) + +export equals = (l, r) -> + + return false unless l.length is r.length + return true if l.length is 0 + return false unless l[0].length is r[0].length + + for lrow, y in l + rrow = r[y] + for lindex, x in lrow + unless lindex is rrow[x] + return false + + return true + +export size = (matrix) -> + + return 0 if 0 is matrix.length + return matrix.length * matrix[0].length + +export sizeVector = (matrix) -> + + return [0, 0] if 0 is matrix.length + return [matrix[0].length, matrix.length] diff --git a/packages/math/matrix.spec.coffee b/packages/math/matrix.spec.coffee new file mode 100644 index 0000000..fc62834 --- /dev/null +++ b/packages/math/matrix.spec.coffee @@ -0,0 +1,29 @@ + +import * as Matrix from './matrix' + +describe 'Matrix', -> + + it 'can inspect size', -> + + matrix = [[0, 0], [0, 0], [0, 0], [0, 0]] + + expect(Matrix.size matrix).toBe 8 + expect(Matrix.sizeVector matrix).toEqual [2, 4] + + it 'can test equality', -> + + l = [[0, 0], [0, 0], [0, 0], [0, 0]] + r = [[0, 0], [0, 0], [0, 0], [0, 0]] + + expect(Matrix.equals l, r).toBe true + + it 'can make deep copies', -> + + matrix = [[1], [2], [3]] + matrix2 = Matrix.copy matrix + + expect(matrix).toEqual matrix2 + + matrix[0][0] = 4 + + expect(matrix).not.toEqual matrix2 diff --git a/packages/math/package.json b/packages/math/package.json new file mode 100644 index 0000000..1e6408c --- /dev/null +++ b/packages/math/package.json @@ -0,0 +1,7 @@ +{ + "name": "@avocado/math", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT" +} diff --git a/packages/math/rectangle/index.coffee b/packages/math/rectangle/index.coffee new file mode 100644 index 0000000..936139f --- /dev/null +++ b/packages/math/rectangle/index.coffee @@ -0,0 +1,167 @@ +# Rectangle operations. + +# **Rectangle** is a utility class to help with rectangle operations. A +# rectangle is implemented as a 4-element array. Element 0 is *x*, element +# 1 is *y*, element 2 is *width* and element 3 is *height*. + +import * as Vector from '../vector' + +# Check if a rectangle intersects with another rectangle. +# +# avocado> Rectangle.intersects [0, 0, 16, 16], [8, 8, 24, 24] +# true +# +# avocado> Rectangle.intersects [0, 0, 16, 16], [16, 16, 32, 32] +# false +export intersects = (l, r) -> + + return false if l[0] >= r[0] + r[2] + return false if r[0] >= l[0] + l[2] + return false if l[1] >= r[1] + r[3] + return false if r[1] >= l[1] + l[3] + + return true + +# Check if a rectangle is touching a vector. +# +# avocado> Rectangle.isTouching [0, 0, 16, 16], [0, 0] +# true +# +# avocado> Rectangle.intersects [0, 0, 16, 16], [16, 16] +# false +export isTouching = (r, v) -> + + return false if v[0] < r[0] + return false if v[1] < r[1] + return false if v[0] >= r[0] + r[2] + return false if v[1] >= r[1] + r[3] + + return true + +# Compose a rectangle from a position vector and a size vector. +# +# avocado> Rectangle.compose [0, 0], [16, 16] +# [0, 0, 16, 16] +export compose = (l, r) -> [l[0], l[1], r[0], r[1]] + +# Make a deep copy of the rectangle. +# +# avocado> rectangle = [0, 0, 16, 16] +# avocado> rectangle is Rectangle.copy rectangle +# false +export copy = (r) -> [r[0], r[1], r[2], r[3]] + +# Convert a rectangle to an object. If you *useShortKeys*, The width and +# height keys will be named w and h, respectively. +# +# avocado> Rectangle.toObject [3, 4, 5, 6] +# {x: 3, y: 4, width: 5, height: 6} +# +# avocado> Rectangle.toObject [3, 4, 5, 6], true +# {x: 3, y: 4, w: 5, h: 6} +export toObject = (r, useShortKeys = false) -> + + whKeys = if useShortKeys then ['w', 'h'] else ['width', 'height'] + + O = x: r[0], y: r[1] + O[whKeys[0]] = r[2] + O[whKeys[1]] = r[3] + return O + +export fromObject = (O) -> [O.x, O.y, O.width, O.height] + +# Returns the position of a rectangle. +# +# avocado> Rectangle.position [8, 8, 16, 16] +# [8, 8] +export position = (r) -> [r[0], r[1]] + +# Returns the size of a rectangle. +# +# avocado> Rectangle.size [8, 8, 16, 16] +# [16, 16] +export size = (r) -> [r[2], r[3]] + +# Compute the intersection rectangle of two rectangles. +# +# avocado> Rectangle.intersection [0, 0, 16, 16], [8, 8, 24, 24] +# [8, 8, 8, 8] +export intersection = (l, r) -> + + return [0, 0, 0, 0] unless intersects l, r + + x = Math.max l[0], r[0] + y = Math.max l[1], r[1] + + lx2 = l[0] + l[2] + rx2 = r[0] + r[2] + ly2 = l[1] + l[3] + ry2 = r[1] + r[3] + + w = (if lx2 <= rx2 then lx2 else rx2) - x + h = (if ly2 <= ry2 then ly2 else ry2) - y + + return [x, y, w, h] + +# Returns a rectangle translated along the [*x*, *y*] axis of a vector. +# +# avocado> Rectangle.translated [0, 0, 16, 16], [8, 8] +# [8, 8, 16, 16] +export translated = (r, v) -> compose( + Vector.add v, position r + size r +) + +# Checks if a rectangle is null. A null rectangle is defined by having any +# 0-length axis. +# +# avocado> Rectangle.isNull [0, 0, 1, 1] +# false +# +# avocado> Rectangle.isNull [0, 0, 1, 0] +# true +export isNull = (r) -> + + return true unless r? + return true unless r.length is 4 + + return Vector.isNull size r + +# Check whether a rectangle equals another rectangle. +# +# avocado> Rectangle.equals [0, 0, 0, 0], [0, 0, 0, 1] +# false +# +# avocado> Rectangle.equals [0, 0, 0, 0], [0, 0, 0, 0] +# true +export equals = (l, r) -> + + return l[0] is r[0] and l[1] is r[1] and l[2] is r[2] and l[3] is r[3] + +# Returns a rectangle that is the united area of two rectangles. +# +# avocado> Rectangle.united [0, 0, 4, 4], [4, 4, 8, 8] +# [0, 0, 12, 12] +export united = (l, r) -> + + return r if isNull l + return l if isNull r + + x = Math.min l[0], r[0] + y = Math.min l[1], r[1] + x2 = Math.max l[0] + l[2], r[0] + r[2] + y2 = Math.max l[1] + l[3], r[1] + r[3] + + return [x, y, x2 - x, y2 - y] + +# Round the position and size of a rectangle. +# +# avocado> Rectangle.round [3.14, 4.70, 5.32, 1.8] +# [3, 5, 5, 2] +export round = (r) -> r.map Math.round + +# Floor the position and size of a rectangle. +# +# avocado> Rectangle.floor [3.14, 4.70, 5.32, 1.8] +# [3, 4, 5, 1] +export floor = (r) -> r.map Math.floor diff --git a/packages/math/rectangle/index.spec.coffee b/packages/math/rectangle/index.spec.coffee new file mode 100644 index 0000000..ea59dfb --- /dev/null +++ b/packages/math/rectangle/index.spec.coffee @@ -0,0 +1,70 @@ + +import * as Rectangle from './rectangle' + +describe 'Rectangle', -> + + it 'can calculate intersections', -> + + expect(Rectangle.intersects [0, 0, 16, 16], [8, 8, 24, 24]).toBe true + expect(Rectangle.intersects [0, 0, 16, 16], [16, 16, 32, 32]).toBe false + + expect(Rectangle.isTouching [0, 0, 16, 16], [0, 0]).toBe true + expect(Rectangle.isTouching [0, 0, 16, 16], [16, 16]).toBe false + + expect(Rectangle.intersection( + [0, 0, 16, 16], [8, 8, 24, 24] + )).toEqual [8, 8, 8, 8] + + expect(Rectangle.united [0, 0, 4, 4], [4, 4, 8, 8]).toEqual [0, 0, 12, 12] + + it 'can compose and decompose', -> + + rectangle = Rectangle.compose [0, 0], [16, 16] + + expect(Rectangle.equals rectangle, [0, 0, 16, 16]).toBe true + + expect(Rectangle.position rectangle).toEqual [0, 0] + expect(Rectangle.size rectangle).toEqual [16, 16] + + it 'can make a deep copy', -> + + rectangle = [0, 0, 16, 16] + rectangle2 = Rectangle.copy rectangle + + expect(Rectangle.equals rectangle, rectangle2).toBe true + + rectangle[0] = 6 + + expect(Rectangle.equals rectangle, rectangle2).toBe false + + it 'can convert to an object', -> + + rectangle = [0, 0, 16, 16] + + expect(Rectangle.toObject rectangle).toEqual( + x: 0, y: 0, width: 16, height: 16 + ) + + expect(Rectangle.toObject rectangle, true).toEqual( + x: 0, y: 0, w: 16, h: 16 + ) + + it 'can translate by vector', -> + + expect(Rectangle.translated [0, 0, 16, 16], [8, 8]).toEqual [8, 8, 16, 16] + + it 'can check for null', -> + + expect(Rectangle.isNull null).toBe true + expect(Rectangle.isNull 3).toBe true + expect(Rectangle.isNull [1]).toBe true + expect(Rectangle.isNull [1, 1]).toBe true + expect(Rectangle.isNull [1, 1, 1]).toBe true + expect(Rectangle.isNull [1, 1, 1, 1, 1]).toBe true + expect(Rectangle.isNull [0, 0, 1, 1]).toBe false + expect(Rectangle.isNull [0, 0, 1, 0]).toBe true + + it 'can do mathematical operations', -> + + expect(Rectangle.round [3.14, 4.70, 5.32, 1.8]).toEqual [3, 5, 5, 2] + expect(Rectangle.floor [3.14, 4.70, 5.32, 1.8]).toEqual [3, 4, 5, 1] diff --git a/packages/math/rectangle/mixin.coffee b/packages/math/rectangle/mixin.coffee new file mode 100644 index 0000000..23985ab --- /dev/null +++ b/packages/math/rectangle/mixin.coffee @@ -0,0 +1,40 @@ +import {Mixin, Property} from '@avocado/composition' +import {setterName} from '@avocado/string' + +import {Vector} from '../vector' +import {Rectangle_} from './index' + +export RectangleMixin = ( + rectangle = 'rectangle' + x = 'x' + y = 'y' + width = 'width' + height = 'height' + position = 'position' + size = 'size' + meta = {} +) -> (Superclass) -> + + setPosition = setterName position + setSize = setterName size + + class Rectangle extends Mixin(Superclass).with( + + position, x, y, meta[position] + size, width, height, meta[size] + + Property rectangle, Object.assign { + + get: -> Rectangle.compose this[position], this[size] + + set: (rectangle) -> + + this[setPosition] Rectangle.position rectangle + this[setSize] Rectangle.size rectangle + + return + + eq: (l, r) -> Rectangle_.equals l, r + + }, meta + ) diff --git a/packages/math/vector/index.js b/packages/math/vector/index.js new file mode 100644 index 0000000..80fbe95 --- /dev/null +++ b/packages/math/vector/index.js @@ -0,0 +1,350 @@ + +export const SQRT_2_2 = Math.sqrt(2) / 2; + +export const EIGHTH_PI = Math.PI * 0.125; +export const QUARTER_PI = EIGHTH_PI * 2; +export const HALF_PI = QUARTER_PI * 2; +export const TWO_PI = Math.PI * 2; + +// export function {VectorMixin as Mixin} from './mixin' + +// Scale a vector. This multiplies *x* and *y* by **k**. +// +// avocado> Vector.scale [.5, 1.5], 2 +// [1, 3] +export function scale(v, k) { + return [v[0] * k, v[1] * k]; +} + +// Add two vectors. +// +// avocado> Vector.add [1, 2], [1, 1] +// [2, 3] +export function add(l, r) { + return [l[0] + r[0], l[1] + r[1]]; +} + +// Subtract two vectors. +// +// avocado> Vector.sub [9, 5], [5, 2] +// [4, 3] +export function sub(l, r) { + return [l[0] - r[0], l[1] - r[1]]; +} + +// Multiply two vectors. +// +// avocado> Vector.mul [3, 5], [5, 5] +// [15, 25] +export function mul(l, r) { + [l[0] * r[0], l[1] * r[1]]; +} + +// Divide two vectors. +// +// avocado> Vector.div [15, 5], [5, 5] +// [3, 1] +export function div(l, r) { + return [l[0] / r[0], l[1] / r[1]]; +} + +// Modulo divide two vectors. +// +// avocado> Vector.mod [13, 6], [5, 5] +// [3, 1] +export function mod(l, r) { + return [l[0] % r[0], l[1] % r[1]]; +} + +// Get the cartesian distance between two point vectors. +// +// avocado> Vector.cartesianDistance [0, 0], [1, 1] +// 1.4142135623730951 +export function cartesianDistance (l, r) { + const xd = l[0] - r[0]; + const yd = l[1] - r[1]; + return Math.sqrt(xd * xd + yd * yd); +} + +// Get the minimum values from two vectors. +// +// avocado> Vector.min [-10, 10], [0, 0] +// [-10, 0] +export function min(l, r) { + return [Math.min(l[0], r[0]), Math.min(l[1], r[1])]; +} + +// Get the maximum values from two vectors. +// +// avocado> Vector.max [-10, 10], [0, 0] +// [0, 10] +export function max(l, r) { + return [Math.max(l[0], r[0]), Math.max(l[1], r[1])]; +} + +// Clamp a vector's axes using a min vector and a max vector. +// +// avocado> Vector.clamp [-10, 10], [0, 0], [5, 5] +// [0, 5] +export function clamp(v, min_, max_) { + return min(max_, max(min_, v)); +} + +// Returns a deep copy of the vector. +// +// avocado> vector = [0, 0] +// avocado> otherVectory Vector.copy vector +// avocado> vector is otherVector +// false +export function copy(v) { + return [v[0], v[1]]; +} + +// Check whether a vector equals another vector. +// +// avocado> Vector.equals [4, 4], [5, 4] +// false +// +// avocado> Vector.equals [4, 4], [4, 4] +// true +export function equals(l, r) { + return l[0] === r[0] && l[1] === r[1]; +} + +// Checks whether a vector is [0, 0]. +// +// avocado> Vector.zero [0, 0] +// true +// +// avocado> Vector.zero [0, 1] +// false +export function isZero(v) { + return v[0] === 0 && v[1] === 0; +} + +// Round both axes of a vector. +// +// avocado> Vector.round [3.14, 4.70] +// [3, 5] +export function round(v) { + return [Math.round(v[0]), Math.round(v[1])]; +} + +// Get the dot product of two vectors. +// +// avocado> Vector.dot [2, 3], [4, 5] +// 23 +export function dot(l, r) { + return l[0] * r[0] + l[1] * r[1]; +} + +// Get a hypotenuse unit vector. If an origin vector is passed in, the +// hypotenuse is derived from the distance to the origin. +// +// avocado> Vector.hypotenuse [5, 5], [6, 7] +// [-0.4472135954999579, -0.8944271909999159] +// +// avocado> Vector.hypotenuse [.5, .7] +// [0.5812381937190965, 0.813733471206735] +export function hypotenuse(unitOrDestination, origin) { + let distanceOrUnit = origin ? + sub(unitOrDestination, origin) + : + unitOrDestination; + const dp = dot(distanceOrUnit, distanceOrUnit); + if (0 === dp) { + return [0, 0]; + } + const hypotenuse = scale(distanceOrUnit, 1 / Math.sqrt(dp)); + // Don't let NaN poison our equations. + return [ + NaN === hypotenuse[0] ? 0 : hypotenuse[0], + NaN === hypotenuse[1] ? 0 : hypotenuse[1], + ]; +} + +// Get the absolute values of the axes of a vector. +// +// avocado> Vector.abs [23, -5.20] +// [23, 5.20] +export function abs(v) { + return [Math.abs(v[0]), Math.abs(v[1])]; +} + +// Floor both axes of a vector. +// +// avocado> Vector.floor [3.14, 4.70] +// [3, 4] +export function floor(v) { + return [Math.floor(v[0]), Math.floor(v[1])]; +} + +// Ceiling both axes of a vector. +// +// avocado> Vector.floor [3.14, 4.70] +// [3, 4] +export function ceil(v) { + return [Math.ceil(v[0]), Math.ceil(v[1])]; +} + +// Get the area a vector. +// +// avocado> Vector.area [3, 6] +// 18 +export function area(v) { + return v[0] * v[1]; +} + +// Checks whether a vector is null. A vector is null if either axis is 0. +// The algorithm prefers horizontal directions to vertical; if you move +// up-right or down-right you'll face right. +// +// avocado> Vector.isNull [1, 0] +// true +// +// avocado> Vector.isNull [1, 1] +// false +export function isNull(v) { + if (!v) { + return true; + } + if (2 !== v.length) { + return true; + } + return 0 === v[0] || 0 === v[1]; +} + +export function overshot(position, hypotenuse, destination) { + const overshot = [false, false]; + for (const i = 0; i < 2; ++i) { + if (hypotenuse[i] < 0) { + if (position[i] < destination[i]) { + overshot[i] = true; + } + } + else if (hypotenuse[i] > 0) { + if (position[i] > destination[i]) { + overshot[i] = true; + } + } + } + return overshot; +} + +// Convert a vector to a 4-direction. A 4-direction is: +// +// * 0: Up +// * 1: Right +// * 2: Down +// * 3: Left +// +// avocado> Vector.toDirection4 [0, 1] +// 2 +// +// avocado> Vector.toDirection4 [1, 0] +// 1 +export function toDirection4(vector) { + vector = hypotenuse(vector); + const x = Math.abs(vector[0]) - SQRT_2_2; + if (x > 0 && x < SQRT_2_2) { + return vector[0] > 0 ? 1 : 3; + } + else { + return vector[1] > 0 ? 2 : 0; + } +} + +// Convert a vector to an 8-direction. An 8-direction is: +// +// * 0: Up +// * 1: Right +// * 2: Down +// * 3: Left +// * 4: Up-Right +// * 5: Down-Right +// * 6: Down-Left +// * 7: Up-Left +// +// avocado> Vector.toDirection8 [1, 1] +// 5 +// +// avocado> Vector.toDirection8 [1, 0] +// 1 +export function toDirection8(v) { + v = hypotenuse(v); + // Orient radians. + let rad = (TWO_PI + Math.atan2(v[1], v[0])) % TWO_PI; + rad = (rad + HALF_PI) % TWO_PI; + // Truncate. + rad = Math.floor(rad * 100000) / 100000; + const stepStart = TWO_PI - EIGHTH_PI; + // Clockwise direction map. + const directions = [0, 4, 1, 5, 2, 6, 3, 7]; + for (const direction of directions) { + let stepEnd = (stepStart + QUARTER_PI) % TWO_PI + stepEnd = Math.floor(stepEnd * 100000) / 100000; + if (rad >= stepStart && rad < stepEnd) { + return direction; + } + stepStart = stepEnd; + } + return 0; +} + +// Convert a vector to a *directionCount*-direction. +// +// avocado> Vector.toDirection [0, 1], 4 +// 2 +export function toDirection(vector, directionCount) { + switch (directionCount) { + case 1: return 0; + case 4: return toDirection4(vector); + case 8: return toDirection8(vector); + default: + throw new Error `Unsupported conversion of vector to ${ + directionCount + }-direction.`; + } +} + +export function fromDirection(direction) { + switch (direction) { + case 0: return [0, -1]; + case 1: return [1, 0]; + case 2: return [0, 1]; + case 3: return [-1, 0]; + case 4: return hypotenuse([1, -1]); + case 5: return hypotenuse([1, 1]); + case 6: return hypotenuse([-1, 1]); + case 7: return hypotenuse([-1, -1]); + } +} + +export function interpolate(ultimate, actual, easing = 0) { + if (0 === easing) { + return ultimate; + } + const distance = cartesianDistance(ultimate, actual); + if (0 === distance) { + return ultimate; + } + return add( + actual, + scale( + hypotenuse(ultimate, actual), + distance / easing + ) + ); +} + +export function fromObject(object) { + return [object.x, object.y]; +} + +// Convert a vector to an object. +// +// avocado> Vector.toObject [3, 4] +// {x: 3, y: 4} +export function toObject(v) { + return {x: v[0], y: v[1]}; +} diff --git a/packages/math/vector/index.spec.coffee b/packages/math/vector/index.spec.coffee new file mode 100644 index 0000000..d226b03 --- /dev/null +++ b/packages/math/vector/index.spec.coffee @@ -0,0 +1,85 @@ + +import {Vector} from '@avocado/math' + +describe 'Vector', -> + + it 'can do mathematical operations', -> + + expect(Vector.scale [.5, 1.5], 2).toEqual [1, 3] + + expect(Vector.add [1, 2], [1, 1]).toEqual [2, 3] + + expect(Vector.sub [9, 5], [5, 2]).toEqual [4, 3] + + expect(Vector.mul [3, 5], [5, 5]).toEqual [15, 25] + + expect(Vector.div [15, 5], [5, 5]).toEqual [3, 1] + + expect(Vector.mod [13, 6], [5, 5]).toEqual [3, 1] + + expect(Vector.cartesianDistance [0, 0], [1, 1]).toBe Math.sqrt 2 + + expect(Vector.min [-10, 10], [0, 0]).toEqual [-10, 0] + + expect(Vector.max [-10, 10], [0, 0]).toEqual [0, 10] + + expect(Vector.clamp [-10, 10], [0, 0], [5, 5]).toEqual [0, 5] + + expect(Vector.round [3.14, 4.70]).toEqual [3, 5] + + expect(Vector.dot [2, 3], [4, 5]).toEqual 23 + + expect(Vector.hypotenuse [5, 5], [6, 7]).toEqual [ + -0.4472135954999579, -0.8944271909999159 + ] + + expect(Vector.hypotenuse [.5, .7]).toEqual [ + 0.5812381937190965, 0.813733471206735 + ] + + expect(Vector.abs [23, -5.20]).toEqual [23, 5.20] + + expect(Vector.floor [3.14, 4.70]).toEqual [3, 4] + + expect(Vector.area [3, 6]).toBe 18 + + it 'can deep copy', -> + + vector = [0, 0] + vector2 = Vector.copy vector + + expect(Vector.equals vector, vector2).toBe true + + vector[0] = 1 + + expect(Vector.equals vector, vector2).toBe false + + it 'can test for 0 or NULL', -> + + expect(Vector.isZero [0, 0]).toBe true + expect(Vector.isZero [1, 0]).toBe false + + expect(Vector.isNull [0, 1]).toBe true + expect(Vector.isNull [1, 1]).toBe false + + expect(Vector.isNull null).toBe true + expect(Vector.isNull [1]).toBe true + expect(Vector.isNull [1, 1, 1]).toBe true + + it 'can convert to/from directions', -> + + expect(Vector.toDirection4 [0, 1]).toBe 2 + expect(Vector.toDirection4 [1, 0]).toBe 1 + + expect(Vector.toDirection8 [1, 1]).toBe 5 + expect(Vector.toDirection8 [1, 0]).toBe 1 + + expect(Vector.toDirection [0, 1], 4).toBe 2 + + for i in [0...8] + vector = Vector.fromDirection i + expect(i).toBe Vector.toDirection vector, 8 + + it 'can convert to object', -> + + expect(Vector.toObject [0, 16]).toEqual x: 0, y: 16 diff --git a/packages/math/vector/mixin.coffee b/packages/math/vector/mixin.coffee new file mode 100644 index 0000000..36d039d --- /dev/null +++ b/packages/math/vector/mixin.coffee @@ -0,0 +1,32 @@ +import {Mixin, Property} from '@avocado/composition' +import {Vector as Vector_} from '@avocado/math' +import {setterName} from '@avocado/string' + +export VectorMixin = ( + vector = 'vector', x = 'x', y = 'y', meta = {} +) -> (Superclass) -> + + setX = setterName x + setY = setterName y + + Base = Mixin(Superclass).with( + + Property x, meta[x] ? {} + Property y, meta[y] ? {} + + Property vector, Object.assign { + + set: (vector) -> + + this[setX] vector[0] + this[setY] vector[1] + + return + + get: -> [@[x](), @[y]()] + + eq: (l, r) -> Vector_.equals l, r + + }, meta + ) + class Vector extends Base diff --git a/packages/math/vector/mixin.spec.coffee b/packages/math/vector/mixin.spec.coffee new file mode 100644 index 0000000..fb4897f --- /dev/null +++ b/packages/math/vector/mixin.spec.coffee @@ -0,0 +1,36 @@ +import {EventEmitter, MixinOf} from '@avocado/composition' + +import {Vector} from '@avocado/math' + +describe 'Vector.Mixin', -> + + O = null + spy = null + + beforeEach -> + + O = new class extends MixinOf( + EventEmitter + Vector.Mixin() + ) + spy = jasmine.createSpy 'listener' + + it 'can detect changes', -> + + O.on 'xChanged', spy + O.on 'yChanged', spy + O.on 'vectorChanged', spy + + O.setVector [20, 20] + + expect(spy.callCount).toEqual 3 + + it 'can detect changes in the correct order', -> + + accum = 300 + O.on 'xChanged yChanged', -> accum /= 10 + O.on 'vectorChanged', -> accum += 200 + + O.setVector [20, 20] + + expect(accum).toEqual 203 diff --git a/packages/math/vertice.coffee b/packages/math/vertice.coffee new file mode 100644 index 0000000..6a9027d --- /dev/null +++ b/packages/math/vertice.coffee @@ -0,0 +1,18 @@ +# Vertice operations. + +# **Vertice** is a utility class to help with vertice operations. A vertice +# is implemented as a 2-element array. Element 0 is *x* and element 1 is *y*. + +# Translate a vertice from an origin point using rotation and scale. +export translate = (v, origin, rotation = 0, scale = 1) -> + + difference = [v[0] - origin[0], v[1] - origin[1]] + magnitude = scale * Math.sqrt( + difference[0] * difference[0] + difference[1] * difference[1] + ) + rotation += Math.atan2 difference[1], difference[0] + + return [ + origin[0] + Math.cos(rotation) * magnitude + origin[1] + Math.sin(rotation) * magnitude + ] diff --git a/packages/mixins/event-emitter.js b/packages/mixins/event-emitter.js new file mode 100644 index 0000000..2e68bfa --- /dev/null +++ b/packages/mixins/event-emitter.js @@ -0,0 +1,243 @@ +function createListener(fn, that, type, namespace, once) { + return { + fn, that, type, namespace, once, + bound: that ? fn.bind(that) : fn, + } +} + +export function EventEmitterMixin(Superclass) { + + return class EventEmitter extends Superclass { + + constructor() { + super(); + this.events = {}; + this.namespaces = {}; + } + + addListener(...args) { return this.on(...args); } + + // Notify ALL the listeners! + emit(...args) { + const type = args[0]; + const listeners = this.lookupEmitListeners(type); + if (0 === listeners.length) { + return; + } + + for (const {once, type, namespace, fn, bound, that} of listeners) { + const offset = type !== '*' ? 1 : 0; + + if (once) { + this.removeListener(`${type}.${namespace}`, fn); + } + + // Fast path... + if (args.length === offset) { + bound() + } + + else if (args.length === offset + 1) { + bound( + args[offset + 0] + ); + } + + else if (args.length === offset + 2) { + bound( + args[offset + 0], + args[offset + 1] + ) + } + + else if (args.length === offset + 3) { + bound( + args[offset + 0], + args[offset + 1], + args[offset + 2] + ) + } + + else if (args.length === offset + 4) { + bound( + args[offset + 0], + args[offset + 1], + args[offset + 2], + args[offset + 3] + ) + } + + else if (args.length === offset + 5) { + bound( + args[offset + 0], + args[offset + 1], + args[offset + 2], + args[offset + 3], + args[offset + 4] + ) + } + + // Slow path... + else { + fn.apply(that, args.slice(offset)); + } + } + } + + lookupEmitListeners(type) { + return ['*', type].reduce((r, type) => { + if (type in this.events) { + r.push(...this.events[type]); + } + return r; + }, []); + } + + off (typesOrType, fn) { + parseTypes(typesOrType).forEach((typeOrCompositeType) => { + this.offSingleEvent(typeOrCompositeType, fn); + }); + return this; + } + + offSingleEvent(typeOrCompositeType, fn) { + const [type, namespace] = decomposeType(typeOrCompositeType); + + // Function. + if ('function' === typeof fn) { + const lists = []; + + if ((type in this.events)) { + lists.push(this.events); + } + + if ( + (namespace in this.namespaces) + && (type in this.namespaces[namespace]) + ) { + lists.push(this.namespaces[namespace]); + } + + lists.forEach((listeners) => { + listeners[type] = listeners[type].filter((listener) => { + return listener.fn !== fn; + }); + }); + + return; + } + + // Only type. + if (0 === namespace.length) { + + if (type in this.events) { + delete this.events[type]; + } + + for (const namespace in this.namespaces) { + const namespaceEvents = this.namespaces[namespace]; + if (type in namespaceEvents) { + delete namespaceEvents[type]; + } + } + + return; + } + + // Only namespace. + if (!(namespace in this.namespaces)) { + return; + } + if (0 === type.length) { + for (const type in this.namespaces[namespace]) { + this.removeEventListenersFor(type, namespace); + } + delete this.namespaces[namespace]; + } + + // Type & namespace. + else if (type in this.namespaces[namespace]) { + this.removeEventListenersFor(type, namespace); + delete this.namespaces[namespace][type]; + } + } + + on(types, fn, that = undefined) { + this.on_PRIVATE(types, fn, that, false); + return this; + } + + on_PRIVATE(typesOrType, fn, that, once) { + parseTypes(typesOrType).forEach((typeOrCompositeType) => { + this.onSingleEvent(typeOrCompositeType, fn, that, once); + }); + } + + once(types, fn, that = undefined) { + this.on_PRIVATE(types, fn, that, true); + return this; + } + + onSingleEvent(typeOrCompositeType, fn, that, once) { + const [type, namespace] = decomposeType(typeOrCompositeType); + const listener = createListener(fn, that, type, namespace, once); + + if (!(type in this.events)) { + this.events[type] = []; + } + this.events[type].push(listener); + + if (!(namespace in this.namespaces)) { + this.namespaces[namespace] = {}; + } + if (!(type in this.namespaces[namespace])) { + this.namespaces[namespace][type] = []; + } + this.namespaces[namespace][type].push(listener); + } + + removeEventListenersFor(type, namespace) { + + for (const {fn} of this.namespaces[namespace][type]) { + this.events[type] = this.events[type].filter((listener) => { + return listener.fn !== fn; + }); + } + + if (0 === this.events[type].length) { + delete this.events[type]; + } + } + + removeListener(...args) { return this.off(...args); } + + } +} + +export function decomposeType(typeOrCompositeType) { + const index = typeOrCompositeType.indexOf('.'); + const isCompositeType = -1 !== index; + if (isCompositeType) { + return [ + typeOrCompositeType.substr(0, index), + typeOrCompositeType.substr(index + 1), + ]; + } + return [typeOrCompositeType, '']; +} + +// Split, flatten, and trim. +export function parseTypes(typesOrType) { + const types = Array.isArray(typesOrType) ? typesOrType : [typesOrType]; + return types.map((type) => { + return type.split(' '); + + }).reduce((r, types) => { + r.push(...types); + return r; + + }, []).map((type) => { + return type.trim(); + + }); +} diff --git a/packages/mixins/index.js b/packages/mixins/index.js new file mode 100644 index 0000000..dc3c8c1 --- /dev/null +++ b/packages/mixins/index.js @@ -0,0 +1,4 @@ +export {EventEmitterMixin as EventEmitter} from './event-emitter'; +export {LfoMixin as Lfo} from './lfo'; +export {PropertyMixin as Property} from './property'; +export {TransitionMixin as Transition} from './transition'; diff --git a/packages/mixins/lfo/index.js b/packages/mixins/lfo/index.js new file mode 100644 index 0000000..e48fbe9 --- /dev/null +++ b/packages/mixins/lfo/index.js @@ -0,0 +1,9 @@ +import LfoResult from './result'; + +export function LfoMixin (Superclass) { + return class Lfo extends Superclass { + lfo(properties, duration) { + return new LfoResult(this, properties, duration); + } + }; +} diff --git a/packages/mixins/lfo/modulated-property.js b/packages/mixins/lfo/modulated-property.js new file mode 100644 index 0000000..8387b6a --- /dev/null +++ b/packages/mixins/lfo/modulated-property.js @@ -0,0 +1,142 @@ +import {compose} from '@avocado/core'; + +import {EventEmitterMixin as EventEmitter} from '../event-emitter'; +import {PropertyMixin as Property} from '../property'; +import {TransitionMixin as Transition} from '../transition'; + +const Modulator = { + + Flat() { + return (location) => { + return .5; + } + }, + + Linear() { + return (location) => { + return location; + } + }, + + Random({variance = .4} = {}) { + return (location) => { + return Math.abs((Math.random() * (variance + variance) - variance)) % 1; + } + }, + + Sine() { + return (location) => .5 * (1 + Math.sin(location * Math.PI * 2)) + }, + +}; + +const decorate = compose( + Transition, + Property('frequency', { + default: 0, + }), + Property('location', { + default: 0, + }), + Property('magnitude', { + default: 0, + }), + EventEmitter, +); + +class ModulatedProperty { + + constructor( + object, key, + {frequency, location, magnitude, median, modulators} + ) { + + this.object = object; + this.key = key; + + if (!modulators) { + modulators = [Modulator.Linear]; + } + if (!Array.isArray(modulators)) { + modulators = [modulators]; + } + + this.median = median; + + this.on('magnitudeChanged', () => { + this.magnitude2 = this.magnitude() * 2; + }); + + this.setFrequency(frequency); + this.setLocation(location || 0); + this.setMagnitude(magnitude); + + if (this.median) { + this.min = this.median - magnitude; + } + + const modulatorFunction = (modulator) => { + if ('string' === typeof modulator) { + return Modulator[modulator] ? Modulator[modulator] : Modulator.Linear; + } + else if ('function' === typeof modulator) { + return modulator; + } + else { + return Modulator.Linear; + } + }; + + this.modulators = modulators.map((modulator) => { + if ('object' !== typeof modulator) { + return modulatorFunction(modulator)(modulator); + } + if (modulator.f) { + return modulatorFunction(modulator.f)(modulator); + } + else { + [key] = Object.keys(modulator) + return modulatorFunction(key)(modulator[key]); + } + }); + + this.transitions = []; + } + + tick(elapsed) { + + this.transitions.forEach((transition) => { + transition.tick(elapsed); + }); + + const frequency = this.frequency(); + let location = this.location(); + + location += elapsed; + if (location > frequency) { + location -= frequency; + } + + this.setLocation(location); + + const min = this.median ? this.min : this.object[this.key]; + + const value = this.modulators.reduce( + (value, m) => value + m(location / frequency), + 0 + ) / this.modulators.length; + + this.object[this.key] = min + value * this.magnitude2; + } + + transition(...args) { + const transition = Transition.prototype.transition.apply(this, args); + this.transitions.push(transition); + transition.promise.then(() => { + this.transitions.splice(this.transitions.indexOf(transition), 1); + }); + return transition; + } +} + +export default decorate(ModulatedProperty); diff --git a/packages/mixins/lfo/result.js b/packages/mixins/lfo/result.js new file mode 100644 index 0000000..2916b5b --- /dev/null +++ b/packages/mixins/lfo/result.js @@ -0,0 +1,66 @@ +import ModulatedProperty from './modulated-property'; + +export default class LfoResult { + + constructor(object, properties, duration = 0) { + + this.duration = duration; + + this.elapsed = 0; + this.isRunning = false; + this.object = {}; + this.properties = {}; + + this.start(); + + this.deferred = {}; + this.promise = this.deferred.promise = new Promise((resolve, reject) => { + this.deferred.resolve = resolve; + this.deferred.reject = reject; + }); + + for (const key in properties) { + this.properties[key] = new ModulatedProperty(object, key, { + frequency: this.duration, + ...properties[key] + }); + } + } + + property(key) { + return this.properties[key]; + } + + start() { + this.elapsed = 0; + this.isRunning = true; + } + + stop() { + this.isRunning = false; + } + + tick(elapsed) { + if (!this.isRunning) { + return; + } + + let finished = false; + + if (this.duration > 0 && this.duration <= (this.elapsed += elapsed)) { + finished = true; + elapsed = this.elapsed - this.duration; + this.elapsed = this.duration; + } + + for (const key in properties) { + properties[key].tick(elapsed); + } + + if (finished) { + this.deferred.resolve(); + this.stop(); + } + } + +} diff --git a/packages/mixins/package.json b/packages/mixins/package.json new file mode 100644 index 0000000..6b08a3d --- /dev/null +++ b/packages/mixins/package.json @@ -0,0 +1,7 @@ +{ + "name": "@avocado/mixins", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT" +} diff --git a/packages/mixins/property.js b/packages/mixins/property.js new file mode 100644 index 0000000..1f7c677 --- /dev/null +++ b/packages/mixins/property.js @@ -0,0 +1,77 @@ +export function PropertyMixin(key, meta = {}) { + + if (!meta || 'object' !== typeof meta) { + throw new TypeError( + `Expected meta for Property(${ + key + }) to be object. ${ + JSON.stringify(meta) + } given.` + ); + } + + return (Superclass) => { + + if (Superclass.prototype[key]) { + throw new TypeError(`can't redefine Avocado property "${key}"`); + } + + meta.getContext = meta.getContext || function() { return this; }; + if ('identity' === meta.transformProperty) { + meta.transformProperty = (key) => key; + } + else if (!meta.transformProperty) { + meta.transformProperty = (key) => `${key}_PRIVATE_PROPERTY`; + } + + meta.eq = meta.eq || function (l, r) { + return l === r; + } + + meta.get = meta.get || function(value) { + return this[meta.transformProperty(key)]; + } + + meta.set = meta.set || function(value) { + this[meta.transformProperty(key)] = value; + } + + let metaDefault; + if (null === meta.default) { + metaDefault = null; + } + else if (undefined === meta.default) { + metaDefault = undefined; + } + else { + metaDefault = JSON.parse(JSON.stringify(meta.default)); + } + + class Property extends Superclass { + + constructor() { + super(); + if (undefined !== metaDefault) { + meta.set.call(this, metaDefault); + } + } + + } + + Object.defineProperty(Property.prototype, key, { + enumerable: true, + get: function() { + return meta.get.call(this); + }, + set: function (value) { + const old = meta.get.call(this); + meta.set.call(this, value); + if (this.emit && !meta.eq.call(this, old, value)) { + this.emit(`${key}Changed`, old, value) + } + }, + }); + + return Property; + } +} diff --git a/packages/mixins/transition/easing.js b/packages/mixins/transition/easing.js new file mode 100644 index 0000000..8aa5f42 --- /dev/null +++ b/packages/mixins/transition/easing.js @@ -0,0 +1,170 @@ +/* + * jQuery Easing v1.3 - http://gsgd.co.uk/sandbox/jquery/easing/ + * + * Uses the built in easing capabilities added In jQuery 1.1 + * to offer multiple easing options + * + * TERMS OF USE - jQuery Easing + * + * Open source under the BSD License. + * + * Copyright © 2008 George McGinley Smith + * All rights reserved. + * + * Modified by Ruben Rodriguez + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * Neither the name of the author nor the names of contributors may be used to + * endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +export default { + linear: function (t, b, c, d) { + return b + c * t/d + }, + easeInQuad: function (t, b, c, d) { + return c*(t/=d)*t + b; + }, + easeOutQuad: function (t, b, c, d) { + return -c *(t/=d)*(t-2) + b; + }, + easeInOutQuad: function (t, b, c, d) { + if ((t/=d/2) < 1) return c/2*t*t + b; + return -c/2 * ((--t)*(t-2) - 1) + b; + }, + easeInCubic: function (t, b, c, d) { + return c*(t/=d)*t*t + b; + }, + easeOutCubic: function (t, b, c, d) { + return c*((t=t/d-1)*t*t + 1) + b; + }, + easeInOutCubic: function (t, b, c, d) { + if ((t/=d/2) < 1) return c/2*t*t*t + b; + return c/2*((t-=2)*t*t + 2) + b; + }, + easeInQuart: function (t, b, c, d) { + return c*(t/=d)*t*t*t + b; + }, + easeOutQuart: function (t, b, c, d) { + return -c * ((t=t/d-1)*t*t*t - 1) + b; + }, + easeInOutQuart: function (t, b, c, d) { + if ((t/=d/2) < 1) return c/2*t*t*t*t + b; + return -c/2 * ((t-=2)*t*t*t - 2) + b; + }, + easeInQuint: function (t, b, c, d) { + return c*(t/=d)*t*t*t*t + b; + }, + easeOutQuint: function (t, b, c, d) { + return c*((t=t/d-1)*t*t*t*t + 1) + b; + }, + easeInOutQuint: function (t, b, c, d) { + if ((t/=d/2) < 1) return c/2*t*t*t*t*t + b; + return c/2*((t-=2)*t*t*t*t + 2) + b; + }, + easeInSine: function (t, b, c, d) { + return -c * Math.cos(t/d * (Math.PI/2)) + c + b; + }, + easeOutSine: function (t, b, c, d) { + return c * Math.sin(t/d * (Math.PI/2)) + b; + }, + easeInOutSine: function (t, b, c, d) { + return -c/2 * (Math.cos(Math.PI*t/d) - 1) + b; + }, + easeInExpo: function (t, b, c, d) { + return (t==0) ? b : c * Math.pow(2, 10 * (t/d - 1)) + b; + }, + easeOutExpo: function (t, b, c, d) { + return (t==d) ? b+c : c * (-Math.pow(2, -10 * t/d) + 1) + b; + }, + easeInOutExpo: function (t, b, c, d) { + if (t==0) return b; + if (t==d) return b+c; + if ((t/=d/2) < 1) return c/2 * Math.pow(2, 10 * (t - 1)) + b; + return c/2 * (-Math.pow(2, -10 * --t) + 2) + b; + }, + easeInCirc: function (t, b, c, d) { + return -c * (Math.sqrt(1 - (t/=d)*t) - 1) + b; + }, + easeOutCirc: function (t, b, c, d) { + return c * Math.sqrt(1 - (t=t/d-1)*t) + b; + }, + easeInOutCirc: function (t, b, c, d) { + if ((t/=d/2) < 1) return -c/2 * (Math.sqrt(1 - t*t) - 1) + b; + return c/2 * (Math.sqrt(1 - (t-=2)*t) + 1) + b; + }, + easeInElastic: function (t, b, c, d) { + var s=1.70158;var p=0;var a=c; + if (t==0) return b; if ((t/=d)==1) return b+c; if (!p) p=d*.3; + if (a < Math.abs(c)) { a=c; var s=p/4; } + else var s = p/(2*Math.PI) * Math.asin (c/a); + return -(a*Math.pow(2,10*(t-=1)) * Math.sin( (t*d-s)*(2*Math.PI)/p )) + b; + }, + easeOutElastic: function (t, b, c, d) { + var s=1.70158;var p=0;var a=c; + if (t==0) return b; if ((t/=d)==1) return b+c; if (!p) p=d*.3; + if (a < Math.abs(c)) { a=c; var s=p/4; } + else var s = p/(2*Math.PI) * Math.asin (c/a); + return a*Math.pow(2,-10*t) * Math.sin( (t*d-s)*(2*Math.PI)/p ) + c + b; + }, + easeInOutElastic: function (t, b, c, d) { + var s=1.70158;var p=0;var a=c; + if (t==0) return b; if ((t/=d/2)==2) return b+c; if (!p) p=d*(.3*1.5); + if (a < Math.abs(c)) { a=c; var s=p/4; } + else var s = p/(2*Math.PI) * Math.asin (c/a); + if (t < 1) return -.5*(a*Math.pow(2,10*(t-=1)) * Math.sin( (t*d-s)*(2*Math.PI)/p )) + b; + return a*Math.pow(2,-10*(t-=1)) * Math.sin( (t*d-s)*(2*Math.PI)/p )*.5 + c + b; + }, + easeInBack: function (t, b, c, d, s) { + if (s == undefined) s = 1.70158; + return c*(t/=d)*t*((s+1)*t - s) + b; + }, + easeOutBack: function (t, b, c, d, s) { + if (s == undefined) s = 1.70158; + return c*((t=t/d-1)*t*((s+1)*t + s) + 1) + b; + }, + easeInOutBack: function (t, b, c, d, s) { + if (s == undefined) s = 1.70158; + if ((t/=d/2) < 1) return c/2*(t*t*(((s*=(1.525))+1)*t - s)) + b; + return c/2*((t-=2)*t*(((s*=(1.525))+1)*t + s) + 2) + b; + }, + easeInBounce: function (t, b, c, d) { + return c - easing.easeOutBounce (d-t, 0, c, d) + b; + }, + easeOutBounce: function (t, b, c, d) { + if ((t/=d) < (1/2.75)) { + return c*(7.5625*t*t) + b; + } else if (t < (2/2.75)) { + return c*(7.5625*(t-=(1.5/2.75))*t + .75) + b; + } else if (t < (2.5/2.75)) { + return c*(7.5625*(t-=(2.25/2.75))*t + .9375) + b; + } else { + return c*(7.5625*(t-=(2.625/2.75))*t + .984375) + b; + } + }, + easeInOutBounce: function (t, b, c, d) { + if (t < d/2) return easing.easeInBounce (t*2, 0, c, d) * .5 + b; + return easing.easeOutBounce (t*2-d, 0, c, d) * .5 + c*.5 + b; + } +}; diff --git a/packages/mixins/transition/index.js b/packages/mixins/transition/index.js new file mode 100644 index 0000000..e4b23eb --- /dev/null +++ b/packages/mixins/transition/index.js @@ -0,0 +1,38 @@ +// **Transition** is a **Mixin** which lends the ability to handle timed +// transitions of arbitrary property methods residing on the mixed-in object. +// +// You can use this mixin like this: +// +// @Transition +// class YourClass { +// constructor() { +// this.x = 0; +// } +// get x() { +// return this.x; +// } +// set x(x) { +// this.x = x; +// } +// } +// +// const yourObject = new YourClass(); +// yourObject.transition({x: 100}, 2000, 'easeOutQuad'); +// +// The value of yourObject.x will transition towards 100 over the course of +// 2000 milliseconds. ***NOTE:*** yourObject **must** have a getter and a +// setter for "x" defined. +// +// This function was heavily inspired by the existence of +// [jQuery.animate](http://api.jquery.com/animate/), though the API is ***NOT*** +// compatible. + +import TransitionResult from './result'; + +export function TransitionMixin(Superclass) { + return class Transition extends Superclass { + transition(props, duration, easing) { + return new TransitionResult(this, props, duration, easing); + } + }; +} diff --git a/packages/mixins/transition/result.js b/packages/mixins/transition/result.js new file mode 100644 index 0000000..0fadd88 --- /dev/null +++ b/packages/mixins/transition/result.js @@ -0,0 +1,120 @@ +import {compose} from '@avocado/core'; + +import {EventEmitterMixin as EventEmitter} from '../event-emitter'; + +import easingFunctions from './easing'; + +const decorate = compose( + EventEmitter, +); + +class TransitionResult { + + constructor(subject, props, duration, easing) { + + // Speed might not get passed. If it doesn't, default to 100 + // milliseconds. + this.duration = parseInt(duration || 100); + this.elapsed = 0; + this.isEmittingProgress_PRIVATE = false; + this.props = props; + this.subject = subject; + + if ('function' === typeof easing) { + this.easing = easing; + } + // If easing isn't passed in as a function, attempt to look it up + // as a string key into Transition.easing. If that fails, then + // default to 'easeOutQuad'. + else { + this.easing = easingFunctions[easing] || easingFunctions['easeOutQuad']; + } + + this.original = {}; + this.change = {}; + for (const i in this.props) { + const value = this.subject[i]; + this.original[i] = value; + this.change[i] = this.props[i] - value; + } + + // Set up the transition object. + this.promise = new Promise((resolve, reject) => { + this.on('stopped', () => resolve()); + }); + } + + get isEmittingProgress() { + return this.isEmittingProgress_PRIVATE; + } + + set isEmittingProgress(isEmittingProgress) { + this.isEmittingProgress_PRIVATE = isEmittingProgress; + } + + // Immediately finish the transition. This will leave the object + // in the fully transitioned state. + skipTransition() { + + // Just trick it into thinking the time passed and do one last + // tick. + this.elapsed = this.duration; + this.tick(0); + } + + // Immediately stop the transition. This will leave the object in + // its current state; potentially partially transitioned. + stopTransition() { + + // Let any listeners know that the transition is complete. + if (this.isEmittingProgress_PRIVATE) { + this.emit('progress', [this.elapsed, this.duration]); + } + this.emit('stopped'); + } + + // Tick callback. Called repeatedly while this transition is + // running. + tick(elapsed) { + + // Update the transition's elapsed time. + this.elapsed += elapsed; + + // If we've overshot the duration, we'll fix it up here, so + // things never transition too far (through the end point). + if (this.elapsed >= this.duration) { + this.elapsed = this.duration; + for (const i in this.change) { + if (this.change[i]) { + this.subject[i] = this.props[i]; + } + } + } + else { + + // Do easing for each property that actually changed. + for (const i in this.change) { + if (this.change[i]) { + this.subject[i] = this.easing( + this.elapsed, + this.original[i], + this.change[i], + this.duration + ); + } + } + } + + // Stop if we're done. + if (this.elapsed === this.duration) { + this.stopTransition(); + } + else { + if (this.isEmittingProgress_PRIVATE) { + this.emit('progress', [this.elapsed, this.duration]); + } + } + } +} + +export default decorate(TransitionResult); diff --git a/packages/react/animation.coffee b/packages/react/animation.coffee new file mode 100644 index 0000000..d286616 --- /dev/null +++ b/packages/react/animation.coffee @@ -0,0 +1,51 @@ +import React from 'react' +import PropTypes from 'prop-types'; + +import {AnimationView, Ticker} from '@avocado/timing' +class AnimationComponent extends React.Component + + @defaultProps: + + animation: null + image: null + + isTicking: true + + constructor: (props) -> + + super props + + @animationView = new AnimationView() + @animationView.on [ + 'animationChanged' + 'imageChanged' + 'sourceRectangleChanged' + ], @tickContainer + + @ticker = new Ticker.OutOfBand() + @ticker.on 'tick', (elapsed) => @props.animation?.tick elapsed + + componentWillUnmount: -> + + @animationView.off [ + 'animationChanged' + 'imageChanged' + 'sourceRectangleChanged' + ], @tickContainer + + @ticker.off 'tick' + @ticker.stop() + + render: -> + + # Side-effects. + @animationView.setAnimation @props.animation + @animationView.setImage @props.image + + @props.setIntoContainer @animationView + + return null + + tickContainer: => @props.tickContainer() + +export default AnimationComponent diff --git a/packages/react/container.coffee b/packages/react/container.coffee new file mode 100644 index 0000000..404d469 --- /dev/null +++ b/packages/react/container.coffee @@ -0,0 +1,79 @@ +import shallowequal from 'shallowequal' +import * as React from 'react' + +import {Container, Renderer} from '@avocado/graphics' +import {Vector} from '@avocado/math' + +class ContainerComponent extends React.Component + + constructor: (props) -> + + super() + + @isDirty = true + @renderer = new Renderer props.size, 'canvas' + @container = new Container() + + @defaultProps: size: [0, 0] + + componentDidMount: -> + + @containerRef.appendChild @renderer.element() + + window.addEventListener 'resize', @recalculateBackgroundSize + + componentDidUpdate: -> + + # Performance: Check if the children actually changed after render. + @isDirty = shallowequal @previousChildren, @container.children() + + # Tick the first time. + return unless @isDirty + + @tick() + @isDirty = false + + componentWillUnmount: -> + + window.removeEventListener 'resize', @recalculateBackgroundSize + + @renderer.destroy(); @renderer = null + + recalculateBackgroundSize: => + + s = getComputedStyle @renderer.element() + domSize = [s.width, s.height].map (n) -> parseInt n + + [w, h] = Vector.mul [16, 16], Vector.div domSize, @props.size + @renderer.element().style.backgroundSize = "#{w}px #{h}px" + + render: -> + + # Literally one big side-effect. + @renderer.resize @props.size + @recalculateBackgroundSize() + + @previousChildren = @container.children() + @container.removeChildren() + children = React.Children.map @props.children, (child) => + + React.cloneElement child, + + setIntoContainer: (renderable) => + + return unless renderable + @container.addChild renderable + + tickContainer: @tick + +
{children}
+ + setContainerRef: (@containerRef) => return + + tick: => @renderer.render @container + +export default ContainerComponent diff --git a/packages/react/image.coffee b/packages/react/image.coffee new file mode 100644 index 0000000..8488af9 --- /dev/null +++ b/packages/react/image.coffee @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import React from 'react' +import {connect} from 'react-redux' +import {all, call, put, takeEvery} from 'redux-saga/effects' + +import {Image, Sprite} from '@avocado/graphics' + +import {processActions} from './util' + +class ImageComponent extends React.Component + + @defaultProps: image: null + + image: -> @props.image + + render: -> + + @props.setIntoSprite? @props.image + return null + +export default ImageComponent diff --git a/packages/react/index.js b/packages/react/index.js new file mode 100644 index 0000000..15067b9 --- /dev/null +++ b/packages/react/index.js @@ -0,0 +1,8 @@ + +// import Animation from './animation'; export {Animation}; +// import Container from './container'; export {Container}; +// import Image from './image'; export {Image}; +// import Primitives from './primitives'; export {Primitives}; +// import Room from './room'; export {Room}; +// import Sprite from './sprite'; export {Sprite}; +import Vector from './vector'; export {Vector}; diff --git a/packages/react/layer.coffee b/packages/react/layer.coffee new file mode 100644 index 0000000..7480811 --- /dev/null +++ b/packages/react/layer.coffee @@ -0,0 +1,24 @@ +import PropTypes from 'prop-types' +import React from 'react' + +import {TileLayer2D, TileLayer2DView, Tileset} from '@truss/environment' + +class Layer2D extends React.Component + + @propTypes = + + layer: PropTypes.instanceOf TileLayer2D + tileset: PropTypes.instanceOf Tileset + + constructor: (props) -> + + super props + @layerView = new TileLayer2DView() + + render: -> + + @layerView.setLayer @props.layer + @layerView.setTileset @props.tileset + @props.setIntoContainer @layerView + +export default Layer2D diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..09bb08e --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,13 @@ +{ + "name": "@avocado/react", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "contempo": "1.x", + "ponere": "1.x", + "react": "^16.5.0", + "redux-form": "^7.4.2" + } +} diff --git a/packages/react/primitives.coffee b/packages/react/primitives.coffee new file mode 100644 index 0000000..a8eafb3 --- /dev/null +++ b/packages/react/primitives.coffee @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import * as React from 'react' + +import {color, Primitives} from '@avocado/graphics' +import {Rectangle} from '@avocado/math' + +class PrimitivesComponent extends React.Component + + constructor: (props) -> + + super props + + @primitives = new Primitives() + + @defaultProps: + + lineStyle: Primitives.LineStyle color 0, 0, 0 + rectangle: [0, 0, 0, 0] + + componentWillUnmount: -> @primitives.destroy() + + render: -> + + @primitives.clear() + + unless Rectangle.isNull @props.rectangle + @primitives.drawRectangle @props.rectangle, @props.lineStyle + + @props.setIntoContainer @primitives + return null + +export default PrimitivesComponent diff --git a/packages/react/room.coffee b/packages/react/room.coffee new file mode 100644 index 0000000..be9c85d --- /dev/null +++ b/packages/react/room.coffee @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types' +import React from 'react' + +import {Room2DView} from '@avocado/environment' + +class Room extends React.Component + + @propTypes = + + roomView: PropTypes.instanceOf(Room2DView).isRequired + + constructor: (props) -> + + super props + + props.roomView.on [ + 'roomChanged', 'tilesetChanged' + ], @tickContainer + + render: -> + + @props.setIntoContainer @props.roomView + @tickContainer() + + return null + + tickContainer: => @props.tickContainer() + +export default Room diff --git a/packages/react/sprite.coffee b/packages/react/sprite.coffee new file mode 100644 index 0000000..0462023 --- /dev/null +++ b/packages/react/sprite.coffee @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import * as React from 'react' + +import {Sprite} from '@avocado/graphics' + +import Image from './image' + +class SpriteComponent extends React.Component + + constructor: (props) -> + + super props + + @sprite = new Sprite() + @sprite.on 'imageOrCanvasChanged sourceRectangleChanged', => + @props.tickContainer() + + @defaultProps: image: null + + @propTypes: + + children: PropTypes.element + + componentWillUnmount: -> @sprite.destroy() + + render: -> + + image = React.Children.only @props.children + + child = React.cloneElement image, + + setIntoSprite: (image) => @sprite.setImageOrCanvas image + + @props.setIntoContainer @sprite + + return child + +export default SpriteComponent diff --git a/packages/react/vector.js b/packages/react/vector.js new file mode 100644 index 0000000..c6d6320 --- /dev/null +++ b/packages/react/vector.js @@ -0,0 +1,46 @@ +import React from 'react'; +import {Field} from 'redux-form/immutable'; + +import contempo from 'contempo'; + +import {NumberField} from 'ponere'; + +@contempo(require('./vector.scss')) +export default class Vector extends React.Component { + + static defaultProps = { + max: [99999, 99999], + min: [0, 0], + xLabel: '', + yLabel: '', + separatorLabel: 'x', + } + + render() { + return
+ + {this.props.label && } +
+
+ +

{this.props.xLabel}

+
+
{this.props.separatorLabel}
+
+ +

{this.props.yLabel}

+
+
+
; + } +} diff --git a/packages/react/vector.scss b/packages/react/vector.scss new file mode 100644 index 0000000..34bc3ac --- /dev/null +++ b/packages/react/vector.scss @@ -0,0 +1,40 @@ +.vector { + display: block; + @media (min-width: 1024px) { + display: inline-block; + } + + .control { + float: left; + + > label { + font-size: 0.8em; + } + + .control label { + border: none; + } + } +} + +.controls { + float: right; + position: relative; + + @media (min-width: 1024px) { + float: none; + } + + &:after { + display: table; + content: ' '; + clear: both; + } +} + +.separator { + float: left; + font-size: 0.8em; + position: relative; + top: 0.4em; +} diff --git a/packages/resource/index.js b/packages/resource/index.js new file mode 100644 index 0000000..4d03be5 --- /dev/null +++ b/packages/resource/index.js @@ -0,0 +1,57 @@ +// import axios from 'axios'; +import uuid from 'uuid/v4'; + +export class Resource { + + constructor() { + this.uri_PRIVATE = undefined; + this.uuid_PRIVATE = undefined; + this.instanceUuid_PRIVATE = uuid(); + } + + fromJSON({uri, uuid}) { + this.uri_PRIVATE = uri; + this.uuid_PRIVATE = uuid; + return this; + } + + get instanceUuid() { + return this.instanceUuid_PRIVATE; + } + + regenerateUuid() { + this.uuid_PRIVATE = uuid(); + } + + get uuid() { + return this.uuid_PRIVATE; + } + + set uuid(uuid) { + this.uuid_PRIVATE = uuid; + } + + get uri() { + return this.uri_PRIVATE; + } + + set uri(uri) { + this.uri_PRIVATE = uri; + } + + toJSON() { + return { + uuid: this.uuid_PRIVATE, + uri: this.uri_PRIVATE, + }; + } + +} + +Resource.createLoader = function(C) { + return (uri) => Resource.read(uri).then((O) => (new C()).fromJSON(O)); +} + +Resource.read = function(uri) { + return axios.get(uri).then(response => response.data); +} diff --git a/packages/resource/package.json b/packages/resource/package.json new file mode 100644 index 0000000..8391bf6 --- /dev/null +++ b/packages/resource/package.json @@ -0,0 +1,11 @@ +{ + "name": "@avocado/resource", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "axios": "^0.18.0", + "uuid": "^3.3.2" + } +} diff --git a/packages/server/index.js b/packages/server/index.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..8a3f3c6 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,10 @@ +{ + "name": "@avocado/server", + "version": "1.0.2", + "main": "index.js", + "author": "cha0s", + "license": "MIT", + "dependencies": { + "socket.io": "2.2.0" + } +} diff --git a/packages/server/socket.js b/packages/server/socket.js new file mode 100644 index 0000000..4df5238 --- /dev/null +++ b/packages/server/socket.js @@ -0,0 +1,21 @@ +const {EventEmitter} = require('events'); +const SocketServer = require('socket.io'); + +export class Server extends EventEmitter { + + constructor(httpServer) { + super(); + this.io = new SocketServer(httpServer, { + path: '/avocado', + serveClient: false, + }); + this.io.on('connect', (socket) => { + this.emit('connect', socket); + }); + } + + broadcast(message) { + this.io.send(message); + } + +} diff --git a/packages/state/index.js b/packages/state/index.js new file mode 100644 index 0000000..1620837 --- /dev/null +++ b/packages/state/index.js @@ -0,0 +1,71 @@ +import * as I from 'immutable'; +import immutablediff from 'immutablediff'; + +export class StateSynchronizer { + + constructor(statefuls) { + this._state = I.Map(); + this._statefuls = statefuls; + this.updateState(); + } + + acceptStateChange(change) { + for (const key in change) { + const stateful = this._statefuls[key]; + if (!stateful) { + continue; + } + stateful.acceptStateChange(change[key]); + } + } + + diff() { + const state = this.state(); + if (this.previousState === state) { + return StateSynchronizer.noChange; + } + // Take a pure JS diff. + const steps = immutablediff(this.previousState, state).toJS(); + let diff = {}; + for (const {op, path, value} of steps) { + if ('replace' === op || 'add' === op) { + if ('/' === path) { + diff = value; + } + else { + const parts = path.split('/'); + parts.shift(); + let walk = diff; + for (let i = 0; i < parts.length; ++i) { + const part = parts[i]; + walk[part] = walk[part] || {}; + if (i === parts.length - 1) { + walk[part] = value; + } + else { + walk = walk[part]; + } + } + } + } + } + // Side-effect. + this.previousState = this.state(); + return diff; + } + + state() { + this.updateState(); + return this._state; + } + + updateState() { + for (const key in this._statefuls) { + const stateful = this._statefuls[key]; + this._state = this._state.set(key, stateful.state()); + } + } + +} + +StateSynchronizer.noChange = {}; diff --git a/packages/state/package.json b/packages/state/package.json new file mode 100644 index 0000000..951306b --- /dev/null +++ b/packages/state/package.json @@ -0,0 +1,7 @@ +{ + "name": "@avocado/state", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT" +} diff --git a/packages/timing/animation-frame.coffee b/packages/timing/animation-frame.coffee new file mode 100644 index 0000000..657a102 --- /dev/null +++ b/packages/timing/animation-frame.coffee @@ -0,0 +1,54 @@ + +# Adapted from https://gist.github.com/paulirish/1579671#gistcomment-91515 +raf = window.requestAnimationFrame +caf = window.cancelAnimationFrame + +w = window +for vendor in ['ms', 'moz', 'webkit', 'o'] + break if raf + raf = w["#{vendor}RequestAnimationFrame"] + caf = ( + w["#{vendor}CancelAnimationFrame"] or + w["#{vendor}CancelRequestAnimationFrame"] + ) + +# rAF is built in but cAF is not. +if raf and not caf + browserRaf = raf + canceled = {} + + raf = (fn) -> id = browserRaf (time) -> + return fn time unless id of canceled + delete canceled[id] + + caf = (id) -> canceled[id] = true + +# Handle legacy browsers which don’t implement rAF +unless raf + targetTime = 0 + + raf = (fn) -> + targetTime = Math.max targetTime + 16, currentTime = +new Date + w.setTimeout (-> fn +new Date), targetTime - currentTime + + caf = (id) -> clearTimeout id + +export requestAnimationFrame = raf +export cancelAnimationFrame = caf + +# setInterval, but for animations. :) +said = 1 +handles = {} +export setAnimation = (fn) -> + id = said++ + + handles[id] = raf ifn = do (id) -> (time) -> + return unless handles[id]? + fn time + handles[id] = raf ifn + + return id + +export clearAnimation = (id) -> + caf handles[id] + delete handles[id] diff --git a/packages/timing/animation-view.coffee b/packages/timing/animation-view.coffee new file mode 100644 index 0000000..4a5b9e9 --- /dev/null +++ b/packages/timing/animation-view.coffee @@ -0,0 +1,82 @@ + +import Promise from 'bluebird' + +import { + EventEmitter, juggleEvents, Mixin, Property +} from '@avocado/composition' +import {Container, Image, Sprite} from '@avocado/graphics' +import {Vector} from '@avocado/math' + +import {Animation} from './animation' + +export class AnimationView extends Mixin(Container).with( + EventEmitter + Property 'animation' + Property 'image', default: null +) + + constructor: (animation) -> + + super() + + @_sprite = null + + @on 'animationChanged', (oldAnimation) => @onAnimationChanged oldAnimation + + @setAnimation animation + + destroy: -> @_sprite?.destroy + + onAnimationChanged: (oldAnimation) -> + + animation = @animation() + + juggleEvents( + this, oldAnimation, animation + positionChanged: @onAnimationPositionChanged + sourceRectangleChanged: @onSourceRectangleChanged + ) + + juggleEvents( + this, oldAnimation, animation + imageUriChanged: @onAnimationImageUriChanged + ) + + @onAnimationImageUriChanged() + + onAnimationImageUriChanged: -> + + return unless animation = @animation() + return unless uri = animation.imageUri() + + Image.load(uri).done (image) => @setImage image + + onAnimationPositionChanged: -> @_sprite.setPosition @position() + + onSourceRectangleChanged: => + @_sprite?.setSourceRectangle @animation().sourceRectangle() + + onSpriteSourceRectangleChanged: => @emit 'sourceRectangleChanged' + + setImage: ( + image + ) -> + + if @_sprite + + @removeChild @_sprite + @_sprite?.off 'sourceRectangleChanged', @onSpriteSourceRectangleChanged + @_sprite.destroy() + + return unless image + + # Add to render list before actually setting inner image. This way, + # AnimationView::onSetImage is useful for render timing. + ownedImage = image.clone() + @addChild @_sprite = new Sprite ownedImage + @_sprite.on 'sourceRectangleChanged', @onSpriteSourceRectangleChanged + @_sprite.setSourceRectangle @animation().sourceRectangle() + + super ownedImage + + sprite: -> @_sprite diff --git a/packages/timing/animation.coffee b/packages/timing/animation.coffee new file mode 100644 index 0000000..c71d8e2 --- /dev/null +++ b/packages/timing/animation.coffee @@ -0,0 +1,114 @@ + +import Promise from 'bluebird' + +import { + EventEmitter, Mixin, Property +} from '@avocado/composition' +import {Rectangle, Vector} from '@avocado/math' +import {Resource} from '@avocado/resource' +import {setterName} from '@avocado/string' +import { + TimedIndexMixin as TimedIndex +} from './timed-index' + +export class Animation extends Mixin(Resource).with( + + EventEmitter + + Property 'direction', default: 0 + Property 'directionCount', default: 1 + TimedIndex 'frame' + Property 'frameSize', default: [0, 0] + Property 'imageUri', default: '' + Vector.Mixin( + 'position', 'x', 'y' + x: default: 0 + y: default: 0 + ) + Property 'sourceRectangle', default: [0, 0, 0, 0] + Property 'uri', default: '' +) + + @load: @createLoad Animation + + @reduce: (O) -> + + O = Object.assign (new Animation()).toJSON(), O + + O.directionCount = parseInt O.directionCount + O.direction = parseInt O.direction ? 0 + O.frameCount = parseInt O.frameCount + O.frameRate = parseInt O.frameRate + O.frameSize = O.frameSize.map (x) -> parseInt x + + O.uri or= '' + O.imageUri or= O.uri.replace '.animation.json', '.png' + + return O + + constructor: -> + + super() + + @on [ + 'directionChanged', 'frameSizeChanged', 'indexChanged' + ], => @setSourceRectangle @rawSourceRectangle @index() + + @on 'directionCountChanged', => @setDirection @direction() + + clampDirection: (direction) -> + + return 0 if @directionCount() is 1 + + direction = Math.min 7, Math.max direction, 0 + direction = { + 4: 1 + 5: 1 + 6: 3 + 7: 3 + }[direction] if @directionCount() is 4 and direction > 3 + + direction + + # Only mutates if not Vector.equals size, [0, 0] + deriveFrameSize: (size) -> + + return frameSize unless Vector.isZero frameSize = @frameSize() ? [0, 0] + return [0, 0] if Vector.isZero size + return [0, 0] if @directionCount() is 0 + return [0, 0] if @frameCount() is 0 + + # If the frame size isn't explicitly given, then calculate the + # size of one frame using the total number of frames and the total + # spritesheet size. Width is calculated by dividing the total + # spritesheet width by the number of frames, and the height is the + # height of the spritesheet divided by the number of directions + # in the animation. + return Vector.div size, [@frameCount(), @directionCount()] + + rawSourceRectangle: (index) -> + + return [0, 0, 0, 0] unless frameCount = @frameCount() + + frameSize = @frameSize() + Rectangle.compose( + Vector.mul frameSize, [ + (index ? @index()) % frameCount + @direction() + ] + frameSize + ) + + setDirection: (direction) -> super @clampDirection parseInt direction + + setDirectionCount: (directionCount) -> super parseInt directionCount + + toJSON: -> + + defaultImageUri = @uri().replace '.animation.json', '.png' + + directionCount: @directionCount() + frameRate: @frameRate() + frameCount: @frameCount() + frameSize: @frameSize() + imageUri: @imageUri() if @imageUri() isnt defaultImageUri diff --git a/packages/timing/cps.coffee b/packages/timing/cps.coffee new file mode 100644 index 0000000..25a3ba3 --- /dev/null +++ b/packages/timing/cps.coffee @@ -0,0 +1,36 @@ +# **Cps** is used to measure the cycles per second of a process. Avocado uses +# this class to measure the cycles per second and renders per second of the +# engine itself. If you instantiate **Cps** and call **Cps**::tick() +# every time a process runs, you can call **Cps**::count() to found how +# many times the cycle runs per second. +# +# *NOTE:* When you instantiate **Cps**, a **frequency** is specified. You +# must call **Cps**.tick() for at least **frequency** milliseconds to get +# an accurate reading. Until then, you will read 0. + +export class Cps + + # Instantiate the CPS counter. By default, it counts the cycles every 250 + # milliseconds. + constructor: (frequency = 250) -> + + previous = Date.now() + + setInterval => + + now = Date.now() + elapsed = now - previous + previous = now + @fps = @c * (1000 / elapsed) + @c = 0 + + , frequency + + @fps = 0 + @c = 0 + + # Call every time the process you want to measure runs. + tick: -> @c++ + + # Call to retrieve how many cycles the process runs per second. + count: -> @fps diff --git a/packages/timing/index.coffee b/packages/timing/index.coffee new file mode 100644 index 0000000..f1362de --- /dev/null +++ b/packages/timing/index.coffee @@ -0,0 +1,13 @@ + +export { + cancelAnimationFrame, clearAnimation, requestAnimationFrame, setAnimation +} from './animation-frame' + +export {Animation} from './animation' +export {AnimationView} from './animation-view' + +export {Cps} from './cps' + +export {Ticker} from './ticker' + +export {TimedIndexMixin as TimedIndex} from './timed-index' diff --git a/packages/timing/package.json b/packages/timing/package.json new file mode 100644 index 0000000..7c29f02 --- /dev/null +++ b/packages/timing/package.json @@ -0,0 +1,7 @@ +{ + "name": "@avocado/timing", + "version": "1.0.0", + "main": "index.js", + "author": "cha0s", + "license": "MIT" +} diff --git a/packages/timing/ticker.coffee b/packages/timing/ticker.coffee new file mode 100644 index 0000000..5dfeacb --- /dev/null +++ b/packages/timing/ticker.coffee @@ -0,0 +1,76 @@ +import {EventEmitter, MixinOf, Property} from '@avocado/composition' + +export class Ticker extends MixinOf( + EventEmitter + Property 'frequency', default: 0 +) + + constructor: (frequency = 0) -> + + super() + + @_remainder = 0 + @setFrequency frequency + + remaining: -> 1 - @_remainder / @frequency() + + reset: -> @_remainder = 0 + + tick: (elapsed) -> + + return if (frequency = @frequency()) is 0 + + ticks = 0 + + @_remainder += elapsed + if @_remainder >= frequency + ticks = Math.floor @_remainder / frequency + @_remainder -= ticks * frequency + + @emit 'tick', frequency for i in [0...ticks] + + return + +class Ticker.OutOfBand extends Ticker + + constructor: -> + + super() + + @_last = Date.now() + @_isStarted = true + + @start() + + elapsedSinceLast: -> + + now = Date.now() + elapsed = now - @_last + @_last = now + + return elapsed + + reset: -> + + super() + + @_last = Date.now() + + start: -> + + return if @_handle + + _tick = => + + elapsed = @elapsedSinceLast() + @emit 'tick', elapsed + setTimeout _tick, 10 - (elapsed - 10) + + @_handle = setTimeout _tick, 10 + + stop: -> + + return unless @_handle + + clearTimeout @_handle + @_handle = null diff --git a/packages/timing/timed-index.coffee b/packages/timing/timed-index.coffee new file mode 100644 index 0000000..20d2482 --- /dev/null +++ b/packages/timing/timed-index.coffee @@ -0,0 +1,48 @@ +import {Ticker} from './ticker' + +import {EventEmitter, Mixin, Property} from '@avocado/composition' + +export TimedIndexMixin = (indexName = 'index') -> (Superclass) -> + + _indexCount = "#{indexName}Count" + _indexRate = "#{indexName}Rate" + + class TimedIndex extends Mixin(Superclass).with( + EventEmitter + Property 'index', default: 0 + Property 'isTicking', default: false + Property _indexCount, default: 0 + Property _indexRate, default: 100 + ) + + constructor: -> + + super() + + @_ticker = new Ticker() + @_ticker.setFrequency @[_indexRate]() + @_ticker.on 'tick', => @_tick() + + @on "#{_indexRate}Changed", => @_ticker.setFrequency @[_indexRate]() + + _clampIndex: (index) -> + + indexCount = @[_indexCount]() + return if indexCount is 0 then 0 else index % @[_indexCount]() + + _tick: -> + + index = @index() + 1 + @emit 'rollingOver' if index >= @[_indexCount]() + @setIndex index + + setIndex: (index, reset = true) -> + + super @_clampIndex index + @_ticker.reset() if reset + + start: -> @setIsTicking true + + stop: -> @setIsTicking false + + tick: (elapsed) -> @_ticker.tick elapsed if @isTicking() diff --git a/webpack.test.config.js b/webpack.test.config.js new file mode 100644 index 0000000..fe136be --- /dev/null +++ b/webpack.test.config.js @@ -0,0 +1,3 @@ +module.exports = { + mode: 'development', +};