components/NodeResident.js

/**
# COMPONENT **NodeResident**

### Local Broadcasts:
- **next-to-[entity-type]** - This message is triggered when the entity is placed on a node. It will trigger on all neighboring entities, as well as on itself on behalf of neighboring entities.
  - @param entity (Entity) - The entity that is next to the listening entity.
- **with-[entity-type]** - This message is triggered when the entity is placed on a node. It will trigger on all entities residing on the same node, as well as on itself on behalf of all resident entities.
  - @param entity (Entity) - The entity that is with the listening entity.
- **left-node** - Triggered when the entity leaves a node.
  - @param node (Node) - The node that the entity just left.
- **[Messages specified in definition]** - When the entity is placed on a node, it checks out the type of node and triggers a message on the entity if an event is listed for the current node type.

## States
- **on-node** - This state is true when the entity is on a node.
- **moving** - This state is true when the entity is moving from one node to another.
- **going-[direction]** - This state is true when the entity is moving (or has just moved) in a direction (determined by the NodeMap) from one node to another.
  
## JSON Definition
    {
      "type": "NodeResident",
      
      "nodeId": "city-hall",
      // Optional. The id of the node that this entity should start on. Uses the entity's nodeId property if not set here.
      
      "nodes": {"path": "walking", "sidewalk": "walking", "road": "driving"],
      // Optional. This is a list of node types that this entity can reside on. If not set, entity can reside on any type of node.
      
      "shares": ['friends','neighbors','city-council-members'],
      // Optional. This is a list of entities that this entity can reside with on the same node. If not set, this entity can reside with any entities on the same node.
      
      "speed": 5,
      // Optional. Sets the speed with which the entity moves along an edge to an adjacent node. Default is 0 (instantaneous movement).
      
      "updateOrientation": true
      // Optional. Determines whether the entity's orientation is updated by movement across the NodeMap. Default is false.
    }
*/
import {arrayCache, greenSlice} from '../utils/array.js';
import createComponentClass from '../factory.js';

export default (function () {
    var createGateway = function (nodeDefinition, map, gateway) {
            return function () {
                // ensure it's a node if one is available at this gateway
                var node = map.getNode(nodeDefinition);

                if (this.isPassable(node)) {
                    this.destinationNodes.length = 0;
                    this.destinationNodes.push(node);

                    if (this.node) {
                        this.onEdge(node);
                    } else {
                        this.distance = 0;
                    }
                    this.progress = 0;

                    this.setState('going-' + gateway);
                    return true;
                }

                return false;
            };
        },
        distance = function (origin, destination) {
            var x = destination.x - origin.x,
                y = destination.y - origin.y,
                z = destination.z - origin.z;

            return Math.sqrt(x * x + y * y + z * z);
        },
        angle = function (origin, destination, distance, ratio) {
            var x = destination.x - origin.x,
                y = destination.y - origin.y,
                a = 0;

            if (origin.rotation && destination.rotation) {
                x = (origin.rotation + 180) % 360;
                y = (destination.rotation + 180) % 360;
                return (x * (1 - ratio) + y * ratio + 180) % 360;
            } else {
                if (!distance) {
                    return a;
                }

                a = Math.acos(x / distance);
                if (y < 0) {
                    a = (Math.PI * 2) - a;
                }
                return a * 180 / Math.PI;
            }
        },
        axisProgress = function (r, o, d, f) {
            return o * (1 - r) + d * r + f;
        },
        isFriendly = function (entities, kinds) {
            var x = 0,
                y = 0,
                found = false;

            if (kinds === null) {
                return true;
            }

            for (x = 0; x < entities.length; x++) {
                for (y = 0; y < kinds.length; y++) {
                    if (entities[x].type === kinds[y]) {
                        found = true;
                    }
                }
                if (!found) {
                    return false;
                } else {
                    found = false;
                }
            }

            return true;
        };

    return createComponentClass(/** @lends platypus.components.NodeResident.prototype */{
        
        id: 'NodeResident',

        properties: {
            /**
             * This sets the resident's initial node.
             *
             * @property node
             * @type Object
             * @default null
             */
            node: null
        },
        
        publicProperties: {
            /**
             * This describes the rate at which a node resident should progress along an edge to another node. This property is set on the entity itself and can be manipulated in real-time.
             *
             * @property speed
             * @type Number
             * @default 0
             */
            speed: 0
        },
        
        /**
         * This component connects an entity to its parent's [[NodeMap]]. It manages navigating the NodeMap and triggering events on the entity related to its position.
         *
         * @memberof platypus.components
         * @uses platypus.Component
         * @constructs
         * @listens platypus.Entity#handle-logic
         * @fires platypus.Entity#in-location
         */
        initialize: function (definition) {
            const
                offset = definition.offset || this.owner.nodeOffset || {},
                startingNode = this.node;
            
            this.nodeId = this.owner.nodeId = definition.nodeId || this.owner.nodeId;
            
            this.neighbors = {};
            this.friendlyNodes = definition.nodes || null;
            this.friendlyEntities = definition.shares || null;
            this.snapToNodes = definition.snapToNodes || false;
            this.updateOrientation = definition.updateOrientation || false;
            this.distance = 0;
            this.buffer   = definition.buffer || 0;
            this.progress = 0;
            this.offset = {
                x: offset.x || 0,
                y: offset.y || 0,
                z: offset.z || 0
            };
            this.destinationNodes = arrayCache.setUp();
            this.algorithm = definition.algorithm || distance;
            
            this.state = this.owner.state;
            this.state.set('moving', false);
            this.state.set('on-node', false);
            this.currentState = '';

            Object.defineProperty(this.owner, 'node', {
                get: () => this.node,
                set: (value) => {
                    if (value) {
                        this.getOnNode(value);
                    } else {
                        this.getOffNode();
                    }
                }
            });
            this.owner.node = startingNode;
        },
        
        events: {
            "set-algorithm": function (algorithm) {
                this.algorithm = algorithm || distance;
            },
            "handle-logic": function (resp) {
                var i = 0,
                    ratio    = 0,
                    momentum = 0,
                    node     = null,
                    arr = null;
                
                if (!this.owner.node) {
                    arr = arrayCache.setUp(this.owner.x, this.owner.y);
                    this.owner.node = this.owner.parent.getClosestNode(arr);
                    arrayCache.recycle(arr);
                    
                    /**
                     * This event is triggered if the entity is placed on the map but not assigned a node. It is moved to the nearest node and "in-location" is triggered.
                     *
                     * @event platypus.Entity#in-location
                     * @param entity {platypus.Entity} The entity that is in location.
                     */
                    this.owner.triggerEvent('in-location', this.owner);
                }

                if (this.followEntity) {
                    node = this.followEntity.node || this.followEntity;
                    if (node && node.isNode && (node !== this.node)) {
                        this.lag = 0;
                        this.state.set('moving', this.gotoNode());
                        if (this.followDistance) {
                            momentum = this.lag;
                        }
                    } else {
                        this.followEntity = null;
                    }
                } else {
                    momentum = this.speed * resp.delta;
                }

                // if goto-node was blocked, try again.
                if (this.blocked) {
                    this.blocked = false;
                    if (this.goingToNode) {
                        this.owner.triggerEvent('goto-closest-node', this.goingToNode);
                    }
                }
                
                if (this.destinationNodes.length) {
                    this.state.set('moving', (this.speed !== 0));
                    if (this.node) {
                        this.onEdge(this.destinationNodes[0]);
                    } else if (!this.lastNode) {
                        this.owner.node = this.destinationNodes[0];
                        this.destinationNodes.shift();
                        if (!this.destinationNodes.length) {
                            this.state.set('moving', false);
                            return;
                        }
                    }
                    
                    if (this.snapToNodes) {
                        for (i = 0; i < this.destinationNodes.length; i++) {
                            this.owner.node = this.destinationNodes[i];
                        }
                        this.destinationNodes.length = 0;
                    } else {
                        while (this.destinationNodes.length && momentum) {
                            if ((this.progress + momentum) >= this.distance) {
                                node = this.destinationNodes[0];
                                momentum -= (this.distance - this.progress);
                                this.progress = 0;
                                this.destinationNodes.shift();
                                this.owner.node = node;
                                if (this.destinationNodes.length && momentum) {
                                    this.onEdge(this.destinationNodes[0]);
                                }
                            } else {
                                this.progress += momentum;
                                ratio = this.progress / this.distance;
                                this.owner.x = axisProgress(ratio, this.lastNode.x, this.destinationNodes[0].x, this.offset.x);
                                this.owner.y = axisProgress(ratio, this.lastNode.y, this.destinationNodes[0].y, this.offset.y);
                                this.owner.z = axisProgress(ratio, this.lastNode.z, this.destinationNodes[0].z, this.offset.z);
                                if (this.updateOrientation) {
                                    this.owner.rotation = angle(this.lastNode, this.destinationNodes[0], this.distance, ratio);
                                }
                                momentum = 0;
                            }
                        }
                    }
                } else {
                    this.state.set('moving', false);
                }
            },
            "goto-node": function (node) {
                this.gotoNode(node);
            },
            "follow": function (entityOrNode) {
                if (entityOrNode.entity) {
                    this.followDistance = entityOrNode.distance;
                    this.followEntity = entityOrNode.entity;
                } else {
                    this.followDistance = 0;
                    this.followEntity = entityOrNode;
                }
            },
            "goto-closest-node": (function () {
                var checkList = function (here, list) {
                        var i = 0;

                        for (i = 0; i < list.length; i++) {
                            if (list[i] === here) {
                                return true;
                            }
                        }

                        return false;
                    },
                    checkType = function (here, type) {
                        return (here.type === type);
                    },
                    checkObjectType = function (here, node) {
                        return (here.type === node.type);
                    };
                
                return function (nodesOrNodeType) {
                    var travResp = null,
                        depth    = 20, //arbitrary limit
                        origin   = this.node || this.lastNode,
                        test     = null,
                        steps    = nodesOrNodeType.steps || 0,
                        nodes    = null;

                    this.goingToNode = nodesOrNodeType;
                    
                    if (typeof nodesOrNodeType === 'string') {
                        test = checkType;
                    } else if (typeof nodesOrNodeType.type === 'string') {
                        test = checkObjectType;
                    } else {
                        test = checkList;
                    }
                    
                    if (origin && nodesOrNodeType && !test(origin, nodesOrNodeType)) {
                        nodes = arrayCache.setUp();
                        travResp = this.traverseNode({
                            depth: depth,
                            origin: origin,
                            position: origin,
                            test: test,
                            destination: nodesOrNodeType,
                            nodes: nodes,
                            shortestPath: Infinity,
                            distance: 0,
                            found: false,
                            algorithm: this.algorithm,
                            blocked: false
                        });
                        
                        travResp.distance -= this.progress;
                        
                        if (travResp.found) {
                            //TODO: should probably set this up apart from this containing function
                            if (this.followEntity) {
                                if (!this.followDistance) {
                                    this.setPath(travResp, steps);
                                } else if ((travResp.distance + (this.followEntity.progress || 0)) > this.followDistance) {
                                    this.lag = travResp.distance + (this.followEntity.progress || 0) - this.followDistance;
                                    this.setPath(travResp, steps);
                                } else {
                                    this.lag = 0;
                                }
                            } else {
                                this.setPath(travResp, steps);
                            }
                        } else if (travResp.blocked) {
                            this.blocked = true;
                        }
                        
                        arrayCache.recycle(nodes);
                    }
                };
            }()),
            "set-directions": function () {
                var i = '',
                    j = 0,
                    entities = null,
                    node     = this.node,
                    nextNode = null;
                
                this.owner.triggerEvent('remove-directions');
                
                for (i in node.neighbors) {
                    if (node.neighbors.hasOwnProperty(i)) {
                        this.neighbors[i] = createGateway(node.neighbors[i], node.map, i);
                        this.addEventListener(i, this.neighbors[i]);

                        //trigger "next-to" events
                        nextNode = node.map.getNode(node.neighbors[i]);
                        if (nextNode) {
                            entities = nextNode.contains;
                            for (j = 0; j < entities.length; j++) {
                                entities[j].triggerEvent("next-to-" + this.owner.type, this.owner);
                                this.owner.triggerEvent("next-to-" + entities[j].type, entities[j]);
                            }
                        }
                    }
                }
            },
            "remove-directions": function () {
                var i = '';
                
                for (i in this.neighbors) {
                    if (this.neighbors.hasOwnProperty(i)) {
                        this.removeEventListener(i, this.neighbors[i]);
                        delete this.neighbors[i];
                    }
                }
            }
        },
        
        methods: {
            gotoNode: (function () {
                var test = function (here, there) {
                    return (here === there);
                };
                
                return function (node) {
                    var travResp = null,
                        depth = 20, //arbitrary limit
                        origin = this.node || this.lastNode,
                        nodes = null,
                        moving = false;
                    
                    if (!node && this.followEntity) {
                        node = this.followEntity.node || this.followEntity.lastNode || this.followEntity;
                    }
                    
                    if (origin && node && (this.node !== node)) {
                        nodes = arrayCache.setUp();
                        
                        travResp = this.traverseNode({
                            depth: depth,
                            origin: origin,
                            position: origin,
                            test: test,
                            destination: node,
                            nodes: nodes,
                            shortestPath: Infinity,
                            distance: 0,
                            found: false,
                            algorithm: this.algorithm,
                            blocked: false
                        });
                        
                        travResp.distance -= this.progress;
                        
                        if (travResp.found) {
                            //TODO: should probably set this up apart from this containing function
                            if (this.followEntity) {
                                if (!this.followDistance) {
                                    this.setPath(travResp);
                                    moving = true;
                                } else if ((travResp.distance + (this.followEntity.progress || 0)) > this.followDistance) {
                                    this.lag = travResp.distance + (this.followEntity.progress || 0) - this.followDistance;
                                    this.setPath(travResp);
                                    moving = true;
                                } else {
                                    this.lag = 0;
                                }
                            } else {
                                this.setPath(travResp);
                                moving = true;
                            }
                        } else if (travResp.blocked) {
                            this.blocked = true;
                        }
                        
                        arrayCache.recycle(nodes);
                    }
                    
                    return moving;
                };
            }()),

            getOnNode: function (node) {
                var j = 0,
                    entities = null;
                
                this.node = node;
                this.node.removeFromEdge(this.owner);
                if (this.lastNode) {
                    this.lastNode.removeFromEdge(this.owner);
                }
                this.node.addToNode(this.owner);
                
                this.setState('on-node');
                
                this.owner.x = this.node.x + this.offset.x;
                this.owner.y = this.node.y + this.offset.y;
                this.owner.z = this.node.z + this.offset.z;
                if (this.updateOrientation && this.node.rotation) {
                    this.owner.rotation = this.node.rotation;
                }
                
                //add listeners for directions
                this.owner.triggerEvent('set-directions');
                
                //trigger mapped messages for node types
                if (this.friendlyNodes && this.friendlyNodes[node.type]) {
                    this.owner.trigger(this.friendlyNodes[node.type], node);
                }

                //trigger "with" events
                entities = node.contains;
                for (j = 0; j < entities.length; j++) {
                    if (this.owner !== entities[j]) {
                        entities[j].triggerEvent("with-" + this.owner.type, this.owner);
                        this.owner.triggerEvent("with-" + entities[j].type, entities[j]);
                    }
                }

                this.owner.triggerEvent('on-node', node);
            },

            getOffNode: function () {
                if (this.node) {
                    this.node.removeFromNode(this.owner);
                    this.owner.triggerEvent('left-node', this.node);
                    this.owner.triggerEvent('remove-directions');
                }
                this.lastNode = this.node;
                this.node = null;
            },

            isPassable: function (node) {
                return node && (this.node !== node) && (!this.friendlyNodes || (typeof this.friendlyNodes[node.type] !== 'undefined')) && (!node.contains.length || isFriendly(node.contains, this.friendlyEntities));
            },
            traverseNode: function (record) {
                //TODO: may want to make this use A*. Currently node traversal order is arbitrary and essentially searches entire graph, but does clip out paths that are too long.
                
                var i         = 1,
                    j         = '',
                    map       = record.position.map,
                    neighbors = null,
                    node      = null,
                    nodeList  = null,
                    resp      = null,
                    algorithm = record.algorithm || distance,
                    savedResp = {
                        shortestPath: Infinity,
                        found: false,
                        blocked: false
                    },
                    blocked   = true,
                    hasNeighbor = false;

                if ((record.depth === 0) || (record.distance > record.shortestPath)) {
                    // if we've reached our search depth or are following a path longer than our recorded successful distance, bail
                    return record;
                } else if (record.test(record.position, record.destination)) {
                    // if we've reached our destination, set shortest path information and bail.
                    record.found = true;
                    record.shortestPath = record.distance;
                    return record;
                } else {
                    //Make sure we do not trace an infinite node loop.
                    nodeList = record.nodes;
                    for (i = 1; i < nodeList.length - 1; i++) {
                        if (nodeList[i] === record.position) {
                            return record;
                        }
                    }
                        
                    neighbors = record.position.neighbors;
                    for (j in neighbors) {
                        if (neighbors.hasOwnProperty(j)) {
                            node = map.getNode(neighbors[j]);
                            hasNeighbor = true;
                            if (this.isPassable(node)) {
                                nodeList = greenSlice(record.nodes);
                                nodeList.push(node);
                                resp = this.traverseNode({
                                    depth: record.depth - 1,
                                    origin: record.origin,
                                    position: node,
                                    destination: record.destination,
                                    test: record.test,
                                    algorithm: algorithm,
                                    nodes: nodeList,
                                    shortestPath: record.shortestPath,
                                    distance: record.distance + algorithm(record.position, node),
                                    gateway: record.gateway || j,
                                    found: false,
                                    blocked: false
                                });
                                arrayCache.recycle(nodeList);
                                if (resp.found && (savedResp.shortestPath > resp.shortestPath)) {
                                    savedResp = resp;
                                }
                                blocked = false;
                            }
                        }
                    }
                    savedResp.blocked = (hasNeighbor && blocked);
                    return savedResp;
                }
            },
            setPath: function (resp, steps) {
                if (resp.nodes[0] === this.node) {
                    resp.nodes.shift();
                }
                arrayCache.recycle(this.destinationNodes);
                this.destinationNodes = greenSlice(resp.nodes);
                if (steps) {
                    this.destinationNodes.length = Math.min(steps, this.destinationNodes.length);
                }
            },
            setState: function (state) {
                if (state === 'on-node') {
                    this.state.set('on-node', true);
                } else {
                    this.state.set('on-node', false);
                    if (this.currentState) {
                        this.state.set(this.currentState, false);
                    }
                    this.currentState = state;
                    this.state.set(state, true);
                }
            },
            onEdge: function (toNode) {
                this.distance = distance(this.node, toNode);
                if (this.updateOrientation) {
                    this.owner.rotation = angle(this.node, toNode, this.distance, this.progress / this.distance);
                }
                this.node.addToEdge(this.owner);
                toNode.addToEdge(this.owner);
                this.owner.node = null;
            },
            destroy: function () {
                arrayCache.recycle(this.destinationNodes);
                this.destinationNodes = null;
                this.state = null;
            }
        }
    });
}());