agentscape
Version:
Agentscape is a library for creating agent-based simulations. It provides a simple API for defining agents and their behavior, and for defining the environment in which the agents interact. Agentscape is designed to be flexible and extensible, allowing
378 lines (314 loc) • 11.9 kB
text/typescript
import { CellGrid } from '../structures'
import Agent from './Agent'
import { Angle, Color, RandomGenerator, Vector2 } from '../numbers'
/**
* Derive a value from a function of the cell's current state
* instead of storing it as a constant.
*/
export function Dynamic<T extends Cell, V>(f: (cell: T) => V) {
return function(target: T, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get: function() {
return f(this as T)
},
set: function(value) {
return value
}
})
}
}
export type CellConstructor = {
/**
* The [x,y] location of the cell in the grid beginning at [0,0].
*/
location: [number, number],
color?: Color,
strokeColor?: Color,
energy?: number,
}
/**
* A cell in a grid. The basic unit of space for a model.
* May be used as-is or extended to include additional properties.
*
* The default cell has a `location`, `color`, and `energy`. Only the `location` is required.
* The default color is white and the default energy is 0.
*
* Additionally, a cell has a set of `tenantAgents` which are agents that occupy the cell
* at the current time step.
*/
export default class Cell {
public location: [number, number]
public tenantAgents = new Set<Agent>()
public color: Color
public strokeColor: Color
public energy: number
constructor(opts: CellConstructor) {
this.location = opts.location
this.energy = opts.energy ?? 0
this.color = opts.color ?? Color.fromName('white')
this.strokeColor = opts.strokeColor ?? undefined
}
/**
* Gives the distance between this Cell and another Cell or Agent.
*/
public distanceTo<T extends Cell | Agent>(
world: CellGrid<Cell>,
other: T,
options?: {
metric?: 'euclidean' | 'manhattan'
}
): number {
const { metric = 'euclidean' } = options ?? {}
const otherPosition = other instanceof Agent ? other.position : new Vector2(other.location)
if (world.boundaryCondition === 'periodic') {
const width = world.cells.length
const dx = Math.abs(this.location[0] - otherPosition.x)
const dy = Math.abs(this.location[1] - otherPosition.y)
const toroidalDx = Math.min(dx, width - dx)
const toroidalDy = Math.min(dy, width - dy)
if (metric === 'euclidean') {
return Math.sqrt(toroidalDx ** 2 + toroidalDy ** 2)
} else {
return toroidalDx + toroidalDy
}
} else {
if (metric === 'euclidean') {
return Math.sqrt((this.location[0] - otherPosition.x) ** 2 + (this.location[1] - otherPosition.y) ** 2)
} else {
return Math.abs(this.location[0] - otherPosition.x) + Math.abs(this.location[1] - otherPosition.y)
}
}
}
/**
* Gets all Cells within a given square radius.
*/
public getNeighborsInRadius<T extends Cell>(
world: CellGrid<T>,
options?: {
includeSelf?: boolean,
radius?: number
}
): Array<T> {
const cells = world.cells
const neighbors = new Set<T>()
const x = this.location[0]
const y = this.location[1]
const { radius = 1, includeSelf = false } = options ?? {}
const width = cells.length
// assert degree is a positive integer
if (!Number.isInteger(radius) || radius < 1) {
throw new Error('getNeighborsInRadius radius option must be a positive integer.')
}
for (let i = -radius; i <= radius; i++) {
for (let j = -radius; j <= radius; j++) {
const newX = x + i
const newY = y + j
if (newX >= 0 && newX < width && newY >= 0 && newY < width) {
neighbors.add(cells[newX][newY])
} else if (world.boundaryCondition === 'periodic') {
neighbors.add(cells[(newX + width) % width][(newY + width) % width])
}
}
}
if (!includeSelf) {
neighbors.delete(this as unknown as T)
}
return Array.from(neighbors)
}
/**
* Diffuses a property to neighboring cells.
* The property function should return a number for each cell.
* The cell diffuses equal shares of `rate` percentage of the property to each neighbor.
*/
public diffuse<T extends this>(
world: CellGrid<T>,
property: (cell: T) => number,
setter: (cell: T, value: number) => void,
rate: number,
range: number = 1
): void {
// Get the property value for this cell
const value = property(this as unknown as T)
const neighbors = this.getNeighborsInRadius(world, {
radius: range
})
// Calculate the total amount to diffuse
const totalDiffusionAmount = value * rate
// Calculate the amount to diffuse to each neighbor
const amountPerNeighbor = totalDiffusionAmount / neighbors.length
// Deplete the property on this cell by the total amount diffused
setter(this as unknown as T, value - totalDiffusionAmount)
// Diffuse the property to each neighbor
for (const neighbor of neighbors) {
const neighborValue = property(neighbor)
setter(neighbor, neighborValue + amountPerNeighbor)
}
}
/**
* Performs a 2D convolution on the cell's neighbors using the given kernel.
* The kernel is a 3x3 matrix of numbers.
* The property function should return a number for each cell. *
*/
public getNeighborConvolution<T extends Cell>(world: CellGrid<T>, kernel: number[][], property: (cell: T) => number): number {
const neighbors = []
const x = this.location[0]
const y = this.location[1]
const width = world.cells.length
// TODO: confirm this works with this.getNeighbors
// gets the values of the neighbors in row-major order
// with finite boundary conditions
if (world.boundaryCondition === 'finite') {
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const newX = x + i
const newY = y + j
if (newX >= 0 && newX < width && newY >= 0 && newY < width) {
const cell = world.cells[newX][newY]
neighbors.push(property(cell))
} else {
neighbors.push(0)
}
}
}
} else {
// gets the values of the neighbors in row-major order
// with toroidal boundary conditions
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const newX = (x + i + width) % width
const newY = (y + j + width) % width
const cell = world.cells[newX][newY]
neighbors.push(property(cell))
}
}
}
// perform the convolution
let result = 0
for (let i = 0; i < kernel.length; i++) {
for (let j = 0; j < kernel[i].length; j++) {
result += kernel[i][j] * neighbors[i * 3 + j]
}
}
return result
}
/**
* Gets the four neighbors of the cell.
* Optionally includes the four diagonal neighbors and this cell.
*/
public getNeighbors<T extends Cell>(
world: CellGrid<T>,
options?: {
includeDiagonals?: boolean,
includeSelf?: boolean,
}
): Array<T> {
const boundaryCondition = world.boundaryCondition
const {
includeDiagonals = false,
includeSelf = false,
} = options ?? {}
const cells = world.cells
const neighbors = new Set<T>()
const wrap = (coord, max) => (coord + max) % max
const gridWidth = world.width
const gridHeight = world.height
const x = this.location[0]
const y = this.location[1]
if (boundaryCondition === 'periodic') {
// left
neighbors.add(cells[wrap(x - 1, gridWidth)][wrap(y, gridHeight)])
// right
neighbors.add(cells[wrap(x + 1, gridWidth)][wrap(y, gridHeight)])
// top
neighbors.add(cells[wrap(x, gridHeight)][wrap(y - 1, gridHeight)])
// bottom
neighbors.add(cells[wrap(x, gridHeight)][wrap(y + 1, gridHeight)])
if (includeDiagonals) {
// top left
neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y - 1, gridHeight)])
// top right
neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y - 1, gridHeight)])
// bottom left
neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y + 1, gridHeight)])
// bottom right
neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y + 1, gridHeight)])
}
} else if (boundaryCondition === 'finite') {
// left
if (x > 0) {
neighbors.add(cells[x - 1][y])
}
// right
if (x < gridWidth - 1) {
neighbors.add(cells[x + 1][y])
}
// up
if (y > 0) {
neighbors.add(cells[x][y - 1])
}
// down
if (y < gridHeight - 1) {
neighbors.add(cells[x][y + 1])
}
if (includeDiagonals) {
if (x > 0 && y > 0) {
// top-left
neighbors.add(cells[x - 1][y - 1])
}
if (x > 0 && y < gridHeight - 1) {
// bottom-left
neighbors.add(cells[x - 1][y + 1])
}
if (x < gridWidth - 1 && y > 0) {
// top-right
neighbors.add(cells[x + 1][y - 1])
}
if (x < gridWidth - 1 && y < gridHeight - 1) {
neighbors.add(cells[x + 1][y + 1])
}
}
} else {
throw new Error(`Unknown boundary condition: ${boundaryCondition}`)
}
if (includeSelf) {
neighbors.add(this as unknown as T)
}
return Array.from(neighbors)
}
public toJSON(): object {
const propNames = Object.getOwnPropertyNames(this)
propNames.forEach((key) => {
if (typeof this[key] === 'function') {
delete this[key]
}
})
const json = {}
propNames.forEach((key) => {
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
}
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
}
}