import { v4 as uuidv4 } from 'uuid' import { AgentSet, CellGrid } from '../structures' import { RandomGenerator, Vector2, Angle, Color } from '../numbers' import type Cell from './Cell' import { Runtime } from '../runtime' import { BoundaryCondition } from '../structures/CellGrid' export enum AgentStyle { CIRCLE = 'circle', SQUARE = 'square', TRIANGLE = 'triangle', } export type TraitOptions = { min?: number, max?: number } /** * Traits represent some custom property of the agent. * Traits are inherited by children via the Agent's `reproduce` method * and are initialized with the parent's current value. Traits are constants * and do not change independently over time or by reproduction. * * For genetic traits that are inherited with mutation, see {@link GeneticTraitValue}. * For dynamic traits that are a function of the agent's current state, see {@link DynamicTrait}. */ export const Trait = (options?: TraitOptions) => { return (target: T, propertyKey: string) => { const clamp = (value: number) => { const { min = -Infinity, max = Infinity } = options ?? {} return Math.min(Math.max(value, min), max) } Object.defineProperty(target, propertyKey, { get: function () { const value = (this as T).inheritedTraits.get(propertyKey) if (typeof value === 'number') { return clamp(value) } else { return value } }, set: function (value: number|boolean) { // return (this as T).inheritedTraits.set(propertyKey, value) if (typeof value === 'number') { return (this as T).inheritedTraits.set(propertyKey, clamp(value)) } else { return (this as T).inheritedTraits.set(propertyKey, value) } } }) } } export type GeneticTraitValue = T|Array export type GeneticTraitOptions = { min?: number, max?: number, /** * A function that mutates the trait value slightly. * @param value The current value of the trait * @param rng A reference to the agent's random number generator * @param traitMutationRate The agent's average mutation rate for genetic traits * @returns New value of the trait */ mutationFunction?: (value: GeneticTraitValue, rng: RandomGenerator, traitMutationRate: number) => GeneticTraitValue, /** * A function that recombines the trait value with another agent's trait value. * @param value1 The current value of the trait * @param value2 The other agent's value of the trait * @param rng A reference to this agent's random number generator * @returns The new value of the trait */ recombinantFunction?: (value1: GeneticTraitValue, value2: GeneticTraitValue, rng: RandomGenerator) => GeneticTraitValue } /** * Genetic traits are inherited by children and initialized * with the parent's current value plus some mutation. * This can be used with new properties or to override existing properties. * * Default mutation functions are provided for numbers, booleans, and colors. * * - Numbers: The value is mutated by a random amount between `-traitMutationRate` and `traitMutationRate`. * - Booleans: The value has a `traitMutationRate` chance of flipping. * - Colors: The color's RGB values are mutated by a normally distributed random amount with mean `traitMutationRate`. * * Default recombinant functions are provided for numbers, booleans, and colors. * * - Numbers: The value is the average of the two parent values. * - Booleans: The value is randomly chosen from the two parent values. * - Colors: The color is a blend of the two parent colors. * * These defaults can be overridden by providing custom mutation and recombinant functions. * See {@link GeneticTraitOptions} for more information. * * See also: {@link Trait} */ export const GeneticTrait = (options?: GeneticTraitOptions ) => { return (target: T, propertyKey: string) => { Object.defineProperty(target, propertyKey, { get: function () { return (this as T).geneticTraits.get(propertyKey).value }, set: function (value: GeneticTraitValue) { const defaultOptions = { mutationFunction: (value: GeneticTraitValue, rng: RandomGenerator, traitMutationRate: number): GeneticTraitValue => { if ( typeof value === 'boolean' ) { // there is a traitMutationRate chance that the trait will flip return rng.uniformFloat(0, 1) < traitMutationRate ? !value as GeneticTraitValue : value } else if (typeof value === 'number') { // mutate the value slightly, plus or minus traitMutationRate of the value const modifier = rng.normalFloat(0, traitMutationRate) - rng.normalFloat(0, traitMutationRate) return value + modifier as GeneticTraitValue } else if (typeof value === 'object' && value instanceof Color) { // mutate the color by a random amount const r = Math.min(Math.max(value.r + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255) const g = Math.min(Math.max(value.g + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255) const b = Math.min(Math.max(value.b + rng.normalFloat(0, traitMutationRate) * 1000, 0), 255) return Color.fromRGB(r, g, b) as GeneticTraitValue } else if (typeof value === 'object' && value instanceof Array) { // what type does the array contain? // type can be bool, string or number const type = typeof value[0] if (type === 'number') { // each number in the array is mutated by a random amount return (value as number[]).map( (v: number) => { const modifier = rng.normalFloat(0, traitMutationRate) - rng.normalFloat(0, traitMutationRate) return v + modifier }) as GeneticTraitValue } else if (type === 'boolean') { // there is a `traitMutationRate` chance for each array value to flip return (value as boolean[]).map( (v: boolean) => rng.uniformFloat(0, 1) < traitMutationRate ? !v : v) as GeneticTraitValue } else { throw new Error(`Array<${type}> is not supported by the default mutation function.`) } } }, recombinantFunction: (value: GeneticTraitValue, otherValue: GeneticTraitValue, rng: RandomGenerator) => { if (typeof value === 'number' && typeof otherValue === 'number') { return (value + otherValue) / 2 } else if (typeof value === 'boolean' && typeof otherValue === 'boolean') { return rng.uniformFloat(0, 1) < 0.5 ? value : otherValue } else if (typeof value === 'object' && value instanceof Color && otherValue instanceof Color) { return value.blend(otherValue, 0.5) } else if (typeof value === 'object' && value instanceof Array && otherValue instanceof Array) { // what type does the array contain? // type can be bool, string or number const type = typeof value[0] const otherType = typeof otherValue[0] if (type === 'number' && otherType === 'number') { // each number in the array is the average of the two parent values return (value as number[]).map( (v: number, i: number) => (v + (otherValue as number[])[i]) / 2) } else if (type === 'boolean' && otherType === 'boolean') { // each boolean in the array is randomly chosen from the two parent values return (value as boolean[]).map( (v: boolean, i: number) => rng.uniformFloat(0, 1) < 0.5 ? v : otherValue[i]) } else { throw new Error(`Array<${type}> and Array<${otherType}> is not supported by the default recombinant function.`) } } } } const traitObject = { value, min: options?.min ?? undefined, max: options?.max ?? undefined, mutationFunction: options?.mutationFunction ?? defaultOptions.mutationFunction, recombinantFunction: options?.recombinantFunction ?? defaultOptions.recombinantFunction, } return (this as T).geneticTraits.set(propertyKey, traitObject) } }) } } /** * Dynamic traits are `Traits` which are a function of the agent's current state. * This can be used with new properties or to override existing properties. * * See also: {@link Trait} */ export function DynamicTrait(f: (agent: T) => V) { return function(target: T, propertyKey: string) { Object.defineProperty(target, propertyKey, { get: function() { return f(this as T) }, set: function(value) { return value } }) } } /** * An Agent-class decorator that enables the agent's energy to decrease as it moves. * By default, the agent's energy decreases by `metabolism` units per cell moved. * If the agent's energy drops below 0, the agent dies. * * Can also be enabled by setting `this.enableHunger = true` in the agent's constructor. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type export function Hungry(constructor: T) { return class extends constructor { enableHunger = true } } export interface AgentConstructor { initialPosition: [number, number] rotation?: Angle, initialEnergy?: number, maxEnergy?: number, metabolism?: number, reproductionThreshold?: number, sightRange?: number fov?: Angle randomSeed?: number radius?: number id?: string color?: Color strokeColor?: Color style?: AgentStyle traitMutationRate?: number traitCrossoverRate?: number enableHunger?: boolean } /** * Base class for all agents in the simulation. * The agent comes with the following defaults: * * - energy = 1 * - maxEnergy = 5 * - metabolism = 0.01 * - reproductionThreshold = maxEnergy / 2 * - sightRange = 10 * - fov = 90 degrees * - radius = 1 * - color = blue * - strokeColor = black * - style = circle * - traitMutationRate = 0.01 * - traitCrossoverRate = 0.5 * * By default, all traits are stable and do not change by reproduction. * This behavior can be overridden by using the `GeneticTrait` decorator. * Moreover, the agent's energy will not decrease as it moves. * This behavior can be enabled using the `Hungry` class decorator. * * No agent should be instantiated directly. Instead, create a subclass that implements the `act` method. * Any custom fields which are to be inherited by children using the `reproduce` method * should be decorated with the `Trait` decorator. */ export default abstract class Agent { /** * The agent's position in the world. * Setting this value directly instead of using the agent's movement methods * will not update the agent's energy level, rotation, and other side effects * applied by the movement methods. * * See also: {@link moveTo()}, {@link move()}, {@link wander()} */ public position: Vector2 /** * The agent's previous position in the world */ public previousPosition: Vector2 /** * The agent's rotation in radians */ public rotation: Angle /** * The agent's energy level. If the energy level drops below 0, * the agent is considered dead. */ public energy: number /** * The agent's maximum energy level. * The agent's energy level will not increase beyond this value. */ public maxEnergy: number /** * A boolean flag indicating whether the agent is alive or dead. */ public isAlive: boolean /** * The amount of energy the agent loses per cell of distance moved. */ @Trait() public metabolism: number /** * The energy level at which the agent can reproduce. */ @Trait() public reproductionThreshold: number /** * The range of the agent's vision measured in cells. */ @Trait() public sightRange: number /** * The agent's field of view. */ @Trait() public fov: Angle /** * The radius of the agent, measured in cells. */ @Trait() public radius: number /** * The agent's color when rendered. */ @Trait() public color: Color /** * The agent's stroke color when rendered. */ @Trait() public strokeColor: Color /** * The shape of the agent when rendered. */ @Trait() public style: AgentStyle /** * The average mutation rate for genetic traits * passed down to children. */ @Trait() public traitMutationRate: number /** * The probability that a child inherits a * new genetic trait from a parent. */ @Trait() public traitCrossoverRate: number /** * The random number generator used by the agent. */ public rng: RandomGenerator /** * The unique identifier for the agent */ public readonly id: string = uuidv4() /** * Used as a time step for the agent's behavior. */ public dt: number = 0.1 /** * The generation of the agent. * Children created by this agent will have their `generation` * value incremented by 1. */ public generation: number = 0 /** * The agent's genetic traits. These traits are inherited by children with some mutation. * Genetic traits should be decorated with the `GeneticTrait` decorator. * See also: {@link GeneticTraitValue} */ public geneticTraits: Map< string, GeneticTraitOptions & { value: GeneticTraitValue } > = new Map() /** * The agent's inherited traits. These traits are inherited by children without mutation. * Inherited traits should be decorated with the `Trait` or `DynamicTrait` decorator. * See also: {@link Trait}, {@link DynamicTrait} */ public inheritedTraits: Map> = new Map() public className: string = 'Agent' public enableHunger: boolean /** * Agent Constructor * @constructor * @param {AgentConstructor} opts - The options for the agent */ constructor(opts: AgentConstructor) { this.className = this.constructor.name // position is the only required input this.position = new Vector2(opts.initialPosition) this.previousPosition = this.position this.rotation = opts.rotation ?? new Angle(0, 'rad') this.rng = new RandomGenerator(opts.randomSeed) this.isAlive = true this.radius = opts.radius ?? 1 this.energy = opts.initialEnergy ?? 1 this.maxEnergy = opts.maxEnergy ?? 5 this.metabolism = opts.metabolism ?? 0.01 this.reproductionThreshold = opts.reproductionThreshold ?? this.maxEnergy / 2 this.sightRange = opts.sightRange ?? 10 this.fov = opts.fov ?? new Angle(90, 'deg') this.traitMutationRate = opts.traitMutationRate ?? 0.01 this.traitCrossoverRate = opts.traitCrossoverRate ?? 0.5 this.color = opts.color ?? Color.fromName('blue') this.strokeColor = opts.strokeColor ?? Color.fromName('black') this.style = opts.style ?? AgentStyle.CIRCLE this.enableHunger = opts.enableHunger ?? false } /** * The agent's main behavior. This method should be implemented by subclasses. * It is used to update the agent's state and interact with the world. */ public abstract act( grid: CellGrid, agentSets: { [key: string]: AgentSet }, tick: number ): void /** * Moves the agent to a specified location while updating related properties * such as the agent's energy level and rotation. * * If the next location is outside the world bounds: * - **FINITE** An error will be thrown. * - **PERIODIC** The next location will be recalculated to wrap the agent around to the other side of the world. */ public moveTo( world: CellGrid, nextLocation: [number, number], ): void { // assert the next location is within the world bounds if (world.boundaryCondition === 'finite' && !world.isInBounds(nextLocation)) { throw new Error(`Agent cannot move to location ${nextLocation} because it is outside the world bounds: (X: ${world.width}, Y: ${world.height})`) } let adjustedLocation = nextLocation if (world.boundaryCondition === 'periodic') { adjustedLocation = [ (nextLocation[0] + world.width) % world.width, (nextLocation[1] + world.height) % world.height ] } // remove the agent from the current cell world.getCellNearest(this.position.components).tenantAgents.delete(this) // calculate the displacement based on the world's boundary condition let displacement: number if (world.boundaryCondition === 'finite') { displacement = Math.sqrt((adjustedLocation[0] - this.position.x) ** 2 + (adjustedLocation[1] - this.position.y) ** 2) } else if (world.boundaryCondition === 'periodic') { const dx = Math.abs(adjustedLocation[0] - this.position.x) const dy = Math.abs(adjustedLocation[1] - this.position.y) const x = Math.min(dx, world.width - dx) const y = Math.min(dy, world.height - dy) displacement = Math.sqrt(x ** 2 + y ** 2) } // update the agent's position this.previousPosition = this.position this.position = new Vector2(adjustedLocation) // update angle as the agent's current position and the last position // the rotation is normalized to the range [0, 2π) let newRotation = Math.atan2(this.position.y - this.previousPosition.y, this.position.x - this.previousPosition.x) if (newRotation < 0) { newRotation += 2 * Math.PI } this.rotation.set(newRotation, 'rad') // add the agent to the new cell world.getCellNearest(this.position.components).tenantAgents.add(this) // update the agent's energy level if hunger is enabled if (this.enableHunger) { this.energy -= (this.metabolism * displacement) } if (this.energy < 0) { this.die() } } /** * Moves the agent in the direction it is facing. * The agent's default time step is used to determine the distance moved. Else, a specified distance may be used. * * Depending on the world's boundary condition, the agent will behave differently: * - **FINITE** If the next location is outside the world bounds, the agent will move to the edge of the world instead. * - **PERIODIC** If the next location is outside the world bounds, the agent will wrap around to the other side of the world. * * See {@link moveTo()} for Agent side effects. */ public move(world: CellGrid, distance: number = this.dt): void { const dx = distance * Math.cos(this.rotation.asRadians()) const dy = distance * Math.sin(this.rotation.asRadians()) const nextLocation = [this.position.x + dx, this.position.y + dy] as [number, number] if (world.boundaryCondition === 'finite') { const x = Math.min(Math.max(nextLocation[0], 0), world.width - 1) const y = Math.min(Math.max(nextLocation[1], 0), world.height - 1) this.moveTo(world, [x, y]) return } if (world.boundaryCondition === 'periodic') { this.moveTo(world, nextLocation) return } // else, throw an error throw new Error(`Boundary condition '${world.boundaryCondition}' is not recognized.`) } /** * Swaps the agent's position with another agent without effecting their energy level or rotation. */ public swap(world: CellGrid, other: T): void { if (!other) { throw new Error('Agent cannot swap with undefined agent') } // remove each agents from their current cells this.getCell(world).tenantAgents.delete(this) other.getCell(world).tenantAgents.delete(other) // swap the agents' positions const tempPosition = this.position this.position = other.position other.position = tempPosition // add the agents to their new cells this.getCell(world).tenantAgents.add(this) other.getCell(world).tenantAgents.add(other) } /** * The agent moves in a random direction with a specified wiggle angle (default 90 deg). * The agent's `dt` property is used to determine the default distance moved. */ public wander( world: CellGrid, options?: { wiggle?: Angle, strideLength?: number } ): void { const { wiggle = new Angle(90, 'deg'), strideLength = this.dt } = options ?? {} const theta = this.rng.uniformFloat(0, wiggle.asDegrees()) - this.rng.uniformFloat(0, wiggle.asDegrees()) this.rotation.increment(theta, 'deg') this.move(world, strideLength) } /** * Rotates the agent to face a specified cell. */ public faceCell(world: CellGrid, cell: U): void { if (world.boundaryCondition === 'finite') { this.rotation.set(Math.atan2(cell.location[1] - this.position.y, cell.location[0] - this.position.x), 'rad') return } if (world.boundaryCondition === 'periodic') { const dx = Math.abs(this.position.x - cell.location[0]) const dy = Math.abs(this.position.y - cell.location[1]) const wrappedDistance_x = Math.min(dx, world.width - dx) const wrappedDistance_y = Math.min(dy, world.height - dy) const wrappedDistanceToCell = Math.sqrt(wrappedDistance_x ** 2 + wrappedDistance_y ** 2) const directDistanceToCell = Math.sqrt(dx ** 2 + dy ** 2) if (directDistanceToCell <= wrappedDistanceToCell) { this.rotation.set(Math.atan2(cell.location[1] - this.position.y, cell.location[0] - this.position.x), 'rad') return } else { return } } throw new Error('Boundary condition not recognized') } /** * Rotates the agent to face a specified agent. */ public faceAgent(world: CellGrid, agent: T): void { if (world.boundaryCondition === 'finite') { this.rotation.set(Math.atan2(agent.position.y - this.position.y, agent.position.x - this.position.x), 'rad') return } if (world.boundaryCondition === 'periodic') { const dx = Math.abs(this.position.x - agent.position.x) const dy = Math.abs(this.position.y - agent.position.y) const wrappedDistance_x = Math.min(dx, world.width - dx) const wrappedDistance_y = Math.min(dy, world.height - dy) const wrappedDistanceToCell = Math.sqrt(wrappedDistance_x ** 2 + wrappedDistance_y ** 2) const directDistanceToCell = Math.sqrt(dx ** 2 + dy ** 2) if (directDistanceToCell <= wrappedDistanceToCell) { this.rotation.set(Math.atan2(agent.position.y - this.position.y, agent.position.x - this.position.x), 'rad') return } else { return } } throw new Error('Boundary condition not recognized') } /** * The agent consumes an environmental resource and gains energy. The source of the energy can be another agent or a cell. * The agent's net energy gain is determined by the source's (Agent or Cell) energy level and the `efficiencyFunction` and `greed` options. * An agent eats by following these rules in order: * 1. Agents are greedy and will take 100% of the energy of the source. This can be modified by the `greed` option. * 2. Agents are perfectly efficient and will add 100% of the taken energy to their energy. This can be modified by the `efficiencyFunction` option. * * Both options are functions that take an energy level at a stage in the process and return a new energy level. By default, both functions are the identity function * of their input. */ public eat( source: Agent | Cell, options?: { greedFunction?: (sourceEnergy: number) => number, efficiencyFunction?: (energyTakenFromSource: number) => number, } ): void { const greedFunction = options?.greedFunction ?? ((sourceEnergy: number) => sourceEnergy) const efficiencyFunction = options?.efficiencyFunction ?? ((energyTakenFromSource: number) => energyTakenFromSource) const energyTakenFromSource = greedFunction(source.energy) const energyConsumed = efficiencyFunction(energyTakenFromSource) source.energy -= energyTakenFromSource this.energy = Math.min(this.energy + energyConsumed, this.maxEnergy) if ( source instanceof Agent ) { source.die() } } /** * Sets `isAlive` to `false`. * An agent dies when it runs out of energy or is eaten by another agent. */ public die(): void { this.isAlive = false } /** * Gives the distance between the agent and another agent or cell. */ public distanceTo( world: CellGrid, other: T, options?: { metric?: 'euclidean' | 'manhattan', boundaryCondition?: BoundaryCondition } ): number { const { metric = 'euclidean', boundaryCondition = world.boundaryCondition } = options ?? {} const otherPosition = other instanceof Agent ? other.position : new Vector2(other.location) if (boundaryCondition === 'finite') { if (metric === 'euclidean') { return Math.sqrt((this.position.x - otherPosition.x) ** 2 + (this.position.y - otherPosition.y) ** 2) } else { return Math.abs(this.position.x - otherPosition.x) + Math.abs(this.position.y - otherPosition.y) } } if (boundaryCondition === 'periodic') { const dx = Math.abs(this.position.x - otherPosition.x) const dy = Math.abs(this.position.y - otherPosition.y) const distance_x = Math.min(dx, world.width - dx) const distance_y = Math.min(dy, world.height - dy) if (metric === 'euclidean') { return Math.sqrt(distance_x ** 2 + distance_y ** 2) } else { return distance_x + distance_y } } throw new Error('Boundary condition not recognized') } /** * Reproduces the agent by creating a new agent with half the energy of the parent. * The parent agent loses half of its energy in the process. * * If a maximum population is set and the current population is at the maximum, the agent will not reproduce * and this function will return `undefined`. * * The new agent inherits all of the parent's traits. Genetic traits are mutated according to their `mutationFunction`. * If another agent is provided, the new agent will inherit traits that both agents share and a * `traitCrossoverRate` chance of inheriting new traits that the other agent does not have. * Genetic traits are recombined according to their `recombinantFunction`. * * A new random seed is generated for the new agent using the parent's random number generator. */ public reproduce( opts?: { childPosition?: [number, number], other?: T} ): T | undefined { if (Runtime.currentPopulation >= Runtime.maxPopulation) { return undefined } const { childPosition = this.position.components, other = undefined } = opts ?? {} this.energy /= 2 // eslint-disable-next-line @typescript-eslint/no-explicit-any const child = new (this.constructor as any)({ initialPosition: childPosition, initialEnergy: this.energy / 2, randomSeed: this.rng.uniformInt(0, 1000000), }) as T this.inheritedTraits.forEach( (value, key) => { child.inheritedTraits.set(key, value) }) // if another agent is provided, apply crossover to traits they share if (other) { for (const [key, value] of this.geneticTraits) { if (!other.geneticTraits.has(key)) { // if the other agent does not have the trait, // there is a small chance the child will inherit the trait if (this.rng.uniformFloat(0, 1) < this.traitCrossoverRate) { child.geneticTraits.set(key, value) } continue } const otherValue = other.geneticTraits.get(key).value const newValue = value.recombinantFunction(value.value, otherValue, this.rng) child.geneticTraits.set(key, { value: newValue, min: value.min, max: value.max, mutationFunction: value.mutationFunction, recombinantFunction: value.recombinantFunction }) } } // apply mutation to all genetic traits this.geneticTraits.forEach((value, key) => { let newValue = value.mutationFunction(value.value, this.rng, this.traitMutationRate) // clamp mutated value to min and max if they exist if (typeof value.value === 'number' && typeof newValue === 'number') { newValue = Math.min(Math.max(newValue, value.min ?? -Infinity), value.max ?? Infinity) } child.geneticTraits.set(key, { value: newValue, min: value.min, max: value.max, mutationFunction: value.mutationFunction, recombinantFunction: value.recombinantFunction }) }) child.generation = this.generation + 1 return child } /** * Gets the cell the agent is currently occupying. * If the agent's position is not an integer, it will be rounded to the nearest integer. * If the agent's position is not a number, an error will be thrown. * If the agent's position is outside the world bounds, undefined will be returned. */ public getCell(world: CellGrid): T | undefined { // the agent's location may not be an integer const x = Math.floor(this.position.x) const y = Math.floor(this.position.y) // check if x & y are numbers if (isNaN(x) || isNaN(y)) { throw new Error(`Agent position is not a number: ${this.position.x}, ${this.position.y}`) } return world.getCell([x, y]) } /** * Gets the cells adjacent to the agent. Optionally include diagonal cells. * Excludes the agent's location cell by default. */ public getNeighborCells( world: CellGrid, options?: { includeDiagonals?: boolean, includeOwnCell?: boolean } ): T[] { const { includeDiagonals = false, includeOwnCell = false } = options ?? {} const thisCell = this.getCell(world) if (!thisCell) { throw new Error(`Agent is outside the world bounds: ${this.position.x}, ${this.position.y}`) } return thisCell.getNeighbors(world, {includeDiagonals, includeSelf: includeOwnCell}) } /** * Gets the neighboring cells within the agent's vision range. * Uses the agent's `sightRange` property by default. * Excludes the agent's own cell by default. * * If the agent is outside the world bounds, an empty array will be returned. */ public getCellsWithinRange( world: CellGrid, options?: { range?: number, includeOwnCell?: boolean } ): T[] { if (!world.isInBounds(this.position.components)) { return [] } const { range = this.sightRange, includeOwnCell = false } = options ?? {} const thisCell = this.getCell(world) if (!thisCell) { throw new Error(`Agent is outside the world bounds: ${this.position.x}, ${this.position.y}`) } return thisCell.getNeighborsInRadius(world, { radius: range, includeSelf: includeOwnCell }) } /** * Gets agents within this agent's adjacent cells. */ public getNeighbors( world: CellGrid, options?: { includeDiagonals?: boolean, includeSelf?: boolean, } ): AgentSet { return AgentSet.fromArray( this.getNeighborCells(world, options).flatMap(cell => [...cell.tenantAgents] as T[]) ) } /** * Gets the agents within the some range of the agent. * Uses the agent's `sightRange` property by default. */ public getAgentsWithinRange( world: CellGrid, agents: AgentSet, options?: { range?: number, includeSelf?: boolean, metric?: 'euclidean' | 'manhattan' } ): AgentSet { const { includeSelf = false, range = this.sightRange, } = options ?? {} const neighbors = agents.getWithinRange(world, this.position.components, range, options) if (!includeSelf) { neighbors.remove(this as unknown as T) } return neighbors } /** * Gets the agents within the agent's field of view and range. * Uses agent's `fov` and `sightRange` properties by default. */ public getAgentsWithinCone( world: CellGrid, options?: { fov?: Angle, range?: number, includeSelf?: boolean, metric?: 'euclidean' | 'manhattan' } ): AgentSet { const cells = this.getCellsWithinCone(world, options) const agents = cells.flatMap(cell => [...cell.tenantAgents]) as T[] return AgentSet.fromArray(agents) } /** * Gets any cells within the agent's field of view. * Uses agent's `fov` and `sightRange` properties by default. */ public getCellsWithinCone( world: CellGrid, options?: { fov?: Angle, range?: number, } ): T[] { const { fov = this.fov, range = this.sightRange } = options ?? {} const prelimNeighbors = this.getCellsWithinRange(world, { range }) const isPointInArc = (x, y, xc, yc, r, theta1, theta2, L) => { // Normalize the point and center for periodic boundary conditions const dx = ((x - xc + L) % L + L) % L // Wrap x-distance const dy = ((y - yc + L) % L + L) % L // Wrap y-distance // Adjust dx and dy to account for the shortest periodic distance const adjustedDx = dx > L / 2 ? dx - L : dx const adjustedDy = dy > L / 2 ? dy - L : dy // Calculate the distance from the center const distance = Math.sqrt(adjustedDx ** 2 + adjustedDy ** 2) // Check if the point lies within the radius of the arc if (distance > r) return false // Calculate the angle of the point relative to the arc center const pointAngle = Math.atan2(adjustedDy, adjustedDx) // Normalize angles to [0, 2π] const normalizeAngle = (angle) => (angle + 2 * Math.PI) % (2 * Math.PI) const normalizedTheta1 = normalizeAngle(theta1) const normalizedTheta2 = normalizeAngle(theta2) const normalizedPointAngle = normalizeAngle(pointAngle) // Check if the point angle lies within the arc's angle range if (normalizedTheta1 <= normalizedTheta2) { return ( normalizedTheta1 <= normalizedPointAngle && normalizedPointAngle <= normalizedTheta2 ) } else { // Handle arcs that cross the 0 angle (e.g., 350° to 10°) return ( normalizedPointAngle >= normalizedTheta1 || normalizedPointAngle <= normalizedTheta2 ) } } return prelimNeighbors.filter(cell => isPointInArc( cell.location[0], cell.location[1], this.position.x, this.position.y, range, this.rotation.asRadians() - (fov.asRadians() / 2), this.rotation.asRadians() + (fov.asRadians() / 2), world.width ) ) } /** * Gets the cell in front of the agent according to its current * position and rotation, and world boundary conditions. */ public getCellInFront(world: CellGrid): T | undefined { const dx = Math.cos(this.rotation.asRadians()) const dy = Math.sin(this.rotation.asRadians()) const x = Math.round(this.position.x + dx) const y = Math.round(this.position.y + dy) return world.getCellNearest([x, y]) } /** * Gets the cell in front and to the left of the agent according to its current * position and rotation, and world boundary conditions. */ public getCellInFrontAndLeft(world: CellGrid): T | undefined { const dx = Math.cos(this.rotation.asRadians() + Math.PI / 4) const dy = Math.sin(this.rotation.asRadians() + Math.PI / 4) const x = Math.round(this.position.x + dx) const y = Math.round(this.position.y + dy) return world.getCellNearest([x, y]) } /** * Gets the cell in front and to the right of the agent according to its current * position and rotation, and world boundary conditions. */ public getCellInFrontAndRight(world: CellGrid): T | undefined { const dx = Math.cos(this.rotation.asRadians() - Math.PI / 4) const dy = Math.sin(this.rotation.asRadians() - Math.PI / 4) const x = Math.round(this.position.x + dx) const y = Math.round(this.position.y + dy) return world.getCellNearest([x, y]) } /** * Serializes an agent as a JSON object. */ public toJSON(): object { const propNames = Object.getOwnPropertyNames(this) const json = {} propNames.forEach((key) => { if (this[key] instanceof Agent) { return } if (this[key] instanceof Set) { json[key] = Array.from(this[key]) return } if (this[key] instanceof Vector2) { json[key] = this[key].components return } if (this[key] instanceof RandomGenerator) { return this[key].seed } if (this[key] instanceof Map) { json[key] = Array.from(this[key].entries()) return } if (this[key] instanceof Angle) { json[key] = `${this[key].asDegrees()} deg` return } if (this[key] instanceof Color) { json[key] = this[key].toRGB() return } json[key] = this[key] }) return json } }