Turtle.js

import AgentList from './AgentList.js'
import * as util from './utils.js'

/**
 * Class Turtle instances represent the dynamic, behavioral element of modeling.
 * Each turtle knows the patch it is on, and interacts with that and other
 * patches, as well as other turtles. Turtles are also the end points of Links.
 *
 * You do not call `new Turtle()`, instead class Turtles creates Turtle instances
 * via {@link Turtles#create} or  {@link Turtles#createOne}
 *
 * I.e. class Turtles is a factory for all of it's Turtle instances.
 * So *don't* do this:
 */

class Turtle {
    static defaults = {
        atEdge: 'wrap',
        hidden: false,
        z: 0,

        // Set by AgentSet
        agentSet: null,
        model: null,
        name: null,
    }
    static variables = {
        id: null,
        theta: 0,
        x: 0,
        y: 0,
    }

    constructor() {
        Object.assign(this, Turtle.defaults)
    }
    newInstance(agentProto) {
        const insstance = Object.create(agentProto)
        Object.assign(insstance, Turtle.variables)
        return insstance
    }

    /**
     * Ask this turtle to "die"
     * - Removes itself from the Turtles array
     * - Removes itself from any Turtles breeds
     * - Removes all my Links if any exist
     * - Removes me from my Patch list of turtles on it
     * - Set it's id to -1 to indicate to others it's gone
     */
    die() {
        if (this.id === -1) return
        this.agentSet.removeAgent(this) // remove me from my baseSet and breeds
        // Remove my links if any exist.
        // Careful: don't promote links
        if (this.hasOwnProperty('links')) {
            while (this.links.length > 0) this.links[0].die()
        }
        // Remove me from patch.turtles cache if patch.turtles array exists
        // if (this.patch.turtles != null) {
        //     util.removeArrayItem(this.patch.turtles, this)
        // }
        if (this.patch && this.patch.turtles)
            util.removeArrayItem(this.patch.turtles, this)

        // Set id to -1, indicates that I've died.
        // Useful when other JS objects contain turtles. Views for example.
        this.id = -1
    }
    isDead() {
        return this.id === -1
    }

    /**
     * Factory method: create num new turtles at this turtle's location.
     *
     * @param {number} [num=1] The number of new turtles to create
     * @param {AgentSet} [breed=this.agentSet] The type of turtles to create,
     * defaults to my type
     * @param {Function} [init=turtle => {}] A function to initialize the new
     * turtles, defaults to no-op
     * @returns {Array} An Array of the new Turtles, generally ignored
     * due to the init function
     */
    hatch(num = 1, breed = this.agentSet, init = turtle => {}) {
        return breed.create(num, turtle => {
            // turtle.setxy(this.x, this.y)
            turtle.setxy(this.x, this.y, this.z)
            turtle.theta = this.theta
            // // hatched turtle inherits parents' ownVariables
            // for (const key of breed.ownVariables) {
            //     if (turtle[key] == null) turtle[key] = this[key]
            // }
            init(turtle)
        })
    }
    // Getter for links for this turtle.
    // Uses lazy evaluation to promote links to instance variables.
    /**
     * Returns an array of the Links that have this Turtle as one of the end points
     * @returns {Array} An AgentList Array of my Links
     */
    get links() {
        // lazy promote links from getter to instance prop.
        Object.defineProperty(this, 'links', {
            value: new AgentList(this.model),
            enumerable: true,
        })
        return this.links
    }
    /**
     * Return the patch this Turtle is on. Return null if Turtle off-world.
     */
    get patch() {
        return this.model.patches.patch(this.x, this.y)
    }

    /**
     * Return this Turtle's heading
     */
    get heading() {
        return this.model.fromRads(this.theta)
    }
    /**
     * Sets this Turtle's heading
     */
    set heading(heading) {
        this.theta = this.model.toRads(heading)
    }
    /**
     * Computes the difference between the my heading and the given heading,
     * the smallest angle by which t could be rotated to produce heading.
     *
     * @param {Angle} heading The heading I wish to be roated to.
     * @returns {Angle}
     */
    subtractHeading(heading) {
        // // Using rads so will work with any geometry.
        // const rads1 = this.model.toRads(this.heading)
        // const rads2 = this.model.toRads(heading)
        // const diff = util.subtractRadians(rads2, rads1)
        // return this.model.fromRads(diff)
        return util.subtractHeadings(heading, this.heading)
    }

    /**
     * Set Turtles x, y position. If z given, override default z of 0.
     *
     * @param {number} x Turtle's x coord, a Float in patch space
     * @param {number} y Turtle's Y coord, a Float in patch space
     * @param {number|undefined} [z=undefined] Turtle's Z coord if given
     */
    setxy(x, y, z = undefined) {
        const p0 = this.patch
        this.x = x
        this.y = y
        if (z != null) this.z = z

        this.checkXYZ(p0)
    }
    checkXYZ(p0) {
        this.checkEdge()
        this.checkPatch(p0)
    }
    checkEdge() {
        const { x, y, z } = this
        // if (!(this.model.world.isOnWorld(x, y, z) || this.atEdge === 'OK')) {
        if (!this.model.world.isOnWorld(x, y, z) && this.atEdge !== 'OK') {
            this.handleEdge(x, y, z)
        }
    }
    checkPatch(p0) {
        const p = this.patch
        // both can be null
        if (p != p0) {
            if (p0 && p0.turtles) util.removeArrayItem(p0.turtles, this)
            if (p && p.turtles) p.turtles.push(this)
        }
    }
    /**
     * Handle turtle x,y,z if turtle off-world.
     * Uses the Turtle's atEdge property to determine how to manage the Turtle.
     * Defaults to 'wrap', wrapping the x,y,z to the opposite edge.
     *
     * atEdge can be:
     * - 'die'
     * - 'wrap'
     * - 'bounce'
     * - 'clamp'
     * - 'random'
     * - a function called with the Turtle as it's argument
     *
     * @param {number} x Turtle's x coord
     * @param {number} y Turtle's y coord
     * @param {number|undefined} [z=undefined] Turtle's z coord if not undefined
     */
    handleEdge(x, y, z = undefined) {
        let atEdge = this.atEdge

        if (util.isString(atEdge)) {
            const { minXcor, maxXcor, minYcor, maxYcor, minZcor, maxZcor } =
                this.model.world

            if (atEdge === 'wrap') {
                this.x = util.wrap(x, minXcor, maxXcor)
                this.y = util.wrap(y, minYcor, maxYcor)
                if (z != null) this.z = util.wrap(z, minZcor, maxZcor)
            } else if (atEdge === 'die') {
                this.die()
            } else if (atEdge === 'random') {
                this.setxy(...this.model.world.randomPoint())
            } else if (atEdge === 'clamp' || atEdge === 'bounce') {
                this.x = util.clamp(x, minXcor, maxXcor)
                this.y = util.clamp(y, minYcor, maxYcor)
                if (z != null) this.z = util.clamp(z, minZcor, maxZcor)

                if (atEdge === 'bounce') {
                    if (this.x === minXcor || this.x === maxXcor) {
                        this.theta = Math.PI - this.theta
                    } else if (this.y === minYcor || this.y === maxYcor) {
                        this.theta = -this.theta
                    } else if (this.z === minZcor || this.z === maxZcor) {
                        if (this.pitch) {
                            this.pitch = -this.pitch
                        } else {
                            this.z = util.wrap(z, minZcor, maxZcor)
                        }
                    }
                }
            } else {
                throw Error(`turtle.handleEdge: bad atEdge: ${atEdge}`)
            }
        } else {
            this.atEdge(this)
        }
    }
    /**
     * Place the turtle at the given patch/turtle location
     *
     * @param {Patch|Turtle} agent A Patch or Turtle who's location is used
     */
    moveTo(agent) {
        // this.setxy(agent.x, agent.y)
        this.setxy(agent.x, agent.y, agent.z)
    }
    /**
     * Move forward, along the Turtle's heading d units in Patch coordinates
     *
     * @param {number} d The distance to move
     */
    forward(d) {
        this.setxy(
            this.x + d * Math.cos(this.theta),
            this.y + d * Math.sin(this.theta)
        )
    }

    /**
     * Change Turtle's heading by angle
     *
     * @param {number} angle The angle to rotate by
     */
    rotate(angle) {
        angle = this.model.toCCW(angle)
        this.heading += angle
    }
    /**
     * Turn Turtle right by angle
     *
     * @param {number} angle The angle to rotate by
     */
    right(angle) {
        this.rotate(-angle)
    }
    /**
     * Turn Turtle left by angle
     *
     * @param {number} angle The angle to rotate by
     */
    left(angle) {
        this.rotate(angle)
    }

    /**
     * Turn turtle so at to be facing the given Turtle or Patch
     *
     * @param {Patch|Turtle} agent The agent to face towards
     */
    face(agent) {
        // this.theta = this.towards(agent)
        this.heading = this.towards(agent)
    }
    /**
     * Turn turtle so at to be facing the given x, y patch coordinate
     *
     * @param {number} x The x coordinate
     * @param {number} y The y coordinate
     */
    facexy(x, y) {
        // this.theta = this.towardsXY(x, y)
        this.heading = this.towardsXY(x, y)
    }

    /**
     * Return the patch ahead of this turtle by distance.
     * Return undefined if the distance puts the patch off-world
     * @param {number} distance The distance ahead
     * @returns {Patch|undefined} The patch at the distance ahead of this Turtle
     */
    patchAhead(distance) {
        return this.patchAtHeadingAndDistance(this.heading, distance)
    }
    /**
     * Return the patch angle to the right and ahead by distance
     * Return undefined if the distance puts the patch off-world
     * @param {number} angle The angle to the right
     * @param {number} distance The distance ahead
     * @returns {Patch|undefined} The patch found, or undefined if off-world
     */
    patchRightAndAhead(angle, distance) {
        // if (this.model.geometry === 'heading') angle = -angle
        angle = this.model.toCCW(angle)
        return this.patchAtHeadingAndDistance(this.heading - angle, distance)
    }
    /**
     * Return the patch angle to the left and ahead by distance
     * Return undefined if the distance puts the patch off-world
     * @param {number} angle The angle to the left
     * @param {number} distance The distance ahead
     * @returns {Patch|undefined} The patch found, or undefined if off-world
     */
    patchLeftAndAhead(angle, distance) {
        return this.patchRightAndAhead(-angle, distance)
    }
    /**
     * Can I move forward by distance and not be off-world?
     * @param {number} distance The distance ahead
     * @returns {Boolean} True if moving forward by distance is on-world
     */
    canMove(distance) {
        return this.patchAhead(distance) != null
    }

    /**
     * Distance from this turtle to x, y
     * No off-world test done.
     *
     * 2.5D: use z too if both z & this.z exist.
     * @param {number} x
     * @param {number} y
     * @param {number|undefined} [z=null]
     * @returns {number} distance in patch coordinates.
     */
    distanceXY(x, y, z = null) {
        const useZ = z != null && this.z != null
        return useZ
            ? util.distance3(this.x, this.y, this.z, x, y, z)
            : util.distance(this.x, this.y, x, y)
    }
    /**
     * Return distance from me to the Patch or Turtle
     *
     * 2.5D: use z too if both agent.z and this.z exist
     * @param {Patch|Turtle} agent
     * @returns {number} distance in patch coordinates.
     */
    distance(agent) {
        const { x, y, z } = agent
        return this.distanceXY(x, y, z)
    }
    /**
     * A property for the x-increment if the turtle were to take one step
     * forward in its current heading.
     * @readonly
     */
    get dx() {
        return Math.cos(this.theta)
    }
    /**
     * A property for the y-increment if the turtle were to take one step
     * forward in its current heading.
     * @readonly
     */
    get dy() {
        return Math.sin(this.theta)
    }

    /**
     * Return the heading towards the Patch or Turtle given.
     * @param {Patch|Turtle} agent The agent who's angle from this Turtle we use
     * @returns {number} The angle towards the agent
     */
    towards(agent) {
        return this.towardsXY(agent.x, agent.y)
    }
    /**
     * Return the heading towards the given x,y coordinates.
     * @param {number} x The x coordinarte
     * @param {number} y The y coordinarte
     * @returns {number} The angle towards x,y
     */
    towardsXY(x, y) {
        // return util.radiansTowardXY(this.x, this.y, x, y)
        let rads = util.radiansTowardXY(this.x, this.y, x, y)
        // rads = this.model.toCCW(rads)
        return this.model.fromRads(rads)
    }
    /**
     * The patch at dx, dy from my current position.
     * Return undefined if off-world
     * @param {number} dx The delta x ahead
     * @param {number} dy The delta y ahead
     * @returns {Patch|undefined} The patch dx, dy ahead; undefined if off-world
     */
    patchAt(dx, dy) {
        return this.model.patches.patch(this.x + dx, this.y + dy)
    }

    /**
     * Return the patch at the absolute, not relative heading and distance
     * from this turtle. Return undefined if off-world
     *
     * Use the Left/Right versions for relative heading.
     * @param {number} heading The absolute angle from this turtle
     * @param {number} distance The distance ahead
     * @returns {Patch|undefined} The Patch, or undefined if off-world
     */
    patchAtHeadingAndDistance(heading, distance) {
        return this.model.patches.patchAtHeadingAndDistance(
            this,
            heading,
            distance
        )
    }

    /**
     * Return the other end of this link from me. Link must include me!
     *
     * See links property for all my links, if any.
     * @param {Link} l
     * @returns {Turtle} The other turtle making this Link
     */
    otherEnd(l) {
        return l.end0 === this ? l.end1 : l.end0
    }
    // Return all turtles linked to me
    /**
     * Return all turtles linked to me. Basically me.otherEnd of all my links.
     * @returns {Array} All the turtles linked to me
     */
    linkNeighbors() {
        return this.links.map(l => this.otherEnd(l))
    }
    /**
     * Is the given Turtle linked to me?
     * @param {Turtle} t
     * @returns {Boolean}
     */
    isLinkNeighbor(t) {
        return t in this.linkNeighbors()
    }
}

export default Turtle