UNPKG

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

385 lines (343 loc) 13.2 kB
import { AgentSet, CellGrid } from '../structures' import Agent from '../entities/Agent' import Cell from '../entities/Cell' import { RandomGenerator } from '../numbers' import { Animate2D, InfoPane, Inspector, Render2D } from './ui' import { KeydownCallbackMap } from './ui/Animate2D' import Controls, { ControlVariableConfig } from '../io/Controls' /** * Designates a Model's property as a variable set by a UI control. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function ControlVariable<T extends Runtime<any, any>>() { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type return function(target: T|Function, propertyKey: string | symbol) { Object.defineProperty(target, propertyKey, { get: function() { return Runtime.controls.getSetting(propertyKey as string) }, set: function(value) { Runtime.controls[propertyKey as string] = value } }) } } export type RuntimeConstructor = { // a reference to a div element in the document where // the simulation will be rendered. documentRoot: HTMLDivElement // the width in pixels of the canvas renderWidth: number // a unique identifier for the model's local storage id: string, // the height in pixels of the canvas. // If not provided, the height will be set to the width. renderHeight?: number // an array of user interface controls // for model parameters parameters?: ControlVariableConfig[] // the title of the model to appear // in the canvas header title?: string // a markdown description of the model // to appear in a separate panel. about?: string // the model automatically runs on page load. // true by default. autoPlay?: boolean // the frame rate of the simulation in frames per second frameRate?: number } /** * The base class for all model runtimes. A `Runtime` consists of a `CellGrid` and zero or more `AgentSet`. * The runtime is responsible for updating the simulation's state and rendering the output to the screen. * * The runtime's main loop is split into four methods: * 1. `preUpdate` - runs first, before the model is updated. * 2. `step` - the model's update function. Calls the `act` method for each agent in the model. * 3. `render` - renders the cell grid and agents to the screen. * 4. `postUpdate` - runs last, after the model is rendered. * * While each of these methods can be overridden to provide custom behaviors, most needs should be * met by overriding the `preUpdate` and `postUpdate` methods. */ export abstract class Runtime<U extends Cell, T extends Agent = undefined> { public static controls: Controls | undefined = undefined public static maxPopulation: number = Infinity public static currentPopulation: number = 0 public rng: RandomGenerator public agents: { [agentSet: string]: AgentSet<T> } public grid: CellGrid<U> private id: string private initialized: boolean = false private documentRoot: HTMLDivElement private renderWidth: number private renderHeight: number private frameRate: number private autoPlay: boolean private title: string private runCondition: (self: this) => boolean = () => true private cellClickHandler: (cell: U) => void = () => {} private keydownCallbackMap: KeydownCallbackMap = {} private renderLayers: { grid: boolean, agents: boolean | Record<string, boolean> } = { grid: true, agents: true } constructor(opts: RuntimeConstructor) { const { frameRate = 60, autoPlay = true, title = 'Model', } = opts this.documentRoot = opts.documentRoot this.renderWidth = opts.renderWidth this.id = opts.id this.title = title this.frameRate = frameRate this.autoPlay = autoPlay this.renderHeight = opts.renderHeight || opts.renderWidth if (opts.parameters) { Runtime.controls = new Controls({ root: opts.documentRoot, title: 'Controls', settings: opts.parameters, id: opts.id + '_default_parameter_controls', }) } if (opts.about) { new InfoPane({ root: opts.documentRoot, info: opts.about, id: opts.id }) } // insert the app footer const footer = document.createElement('app-footer') this.documentRoot.appendChild(footer) this.rng = new RandomGenerator() } /** * Enables a maximum population for all agents in the model. * If the population reaches the maximum, agent reproduction is disabled. */ public setMaxPopulation(population: number): void { Runtime.maxPopulation = population } /** * Enables selective rendering of the grid and agent sets. */ public setRenderLayers(opts: { grid?: boolean, agents?: boolean | Record<string, boolean> }): void { this.renderLayers = { grid: opts.grid ?? this.renderLayers.grid, agents: opts.agents ?? this.renderLayers.agents } } /** * Sets the random seed for the model. */ public setRandomSeed(seed: number): void { this.rng = new RandomGenerator(seed) } /** * The model's run condition is a function that returns a boolean. * The model will continue to run until the run condition returns false. */ public setRunCondition(condition: (self: this) => boolean): void { this.runCondition = condition } /** * Sets a callback function to be called when a cell is clicked. * The callback function is passed the `Cell` object that was clicked. */ public onClick(callback: (cell: U) => void): void { this.cellClickHandler = callback } /** * Sets a callback function to be called when a key is pressed. * The key is specified by its code (i.e., `event.code`). * The callback function is passed the `KeyboardEvent` object. * * Optionally, you can specify whether the default behavior should be prevented. * * This method can be called multiple times to set multiple keydown callbacks. * * Default key behaviors are: * - Space: toggles play/pause * - Enter: steps the simulation forward * * Defaults may be overridden by setting a new callback for the same key. */ public onKeydown( code: string, callback: (e: KeyboardEvent) => void, options?: { preventDefault?: boolean, } ): void { this.keydownCallbackMap[code] = { callback, options: options ?? { preventDefault: false } } } /** * This is where you should initialize your grid. * This method should return a `CellGrid` object. */ public abstract initGrid(): CellGrid<U> /** * This is where you should initialize your agents. * This method should return an object where the keys are the names of the * agent sets and the values are the agent sets themselves. * * For models not requiring agents, return an empty object. * * This method is called after the grid has been initialized and inserted into the model, * thus referencing the grid via `this.grid` is possible. */ public abstract initAgents(): { [agentSet: string]: AgentSet<T> } /** * Override this method to provide custom update behaviors * after the model has been updated. Additionally gives * access to the renderer. */ public postUpdate(): (tick: number, renderer: Render2D) => void { return () => {} } /** * Override this method to provide custom behaviors * before the model has been updated. */ public preUpdate(): (tick: number) => void { return () => {} } /** * Starts the simulation. Only needs to be called once. */ public start(): void { if (this.initialized) return this.initialized = true this.grid = this.initGrid() this.agents = this.initAgents() // for each agent set, add the agents to the grid Object.values(this.agents).forEach((agentSet) => { agentSet.forEach((agent) => { this.grid.insertAgent(agent) }) }) new Inspector({ root: this.documentRoot, fetchCell: (pos: [number, number]) => this.grid.getCell(pos), id: this.id + '_inspector', }) new Animate2D({ root: this.documentRoot, renderWidth: this.renderWidth, renderHeight: this.renderHeight, worldWidth: this.grid.width, onCellClick: (location: [number, number], renderer) => { const cell = this.grid.getCell(location) this.cellClickHandler(cell) this.render(renderer) this.postUpdate()(0, renderer) }, keydownCallbackMap: this.keydownCallbackMap, frameRenderCallback: (tick: number, renderer: Render2D) => { if (!this.runCondition(this)) { window.dispatchEvent(new CustomEvent('stop')) console.info('Model has reached end condition.') return } this.preUpdate()(tick) this.step(tick) this.render(renderer) this.postUpdate()(tick, renderer) }, autoPlay: this.autoPlay, frameRate: this.frameRate, title: this.title, }) } /** * Renders the model to the screen by drawing * the grid and then all agent sets. * * Override this method to provide custom rendering behaviors. * * To provide _additional_ render behaviors, use the `postUpdate` method instead. */ public render(renderer: Render2D): void { renderer.clear() // draw the grid if (this.renderLayers.grid) { renderer.drawCellGrid(this.grid, { colorFunction: (cell) => { return { fill: cell.color, stroke: cell.strokeColor } }, }) } // if renderLayers.agents is a map, draw only the agent sets specified. if (typeof this.renderLayers.agents === 'object') { Object.entries(this.renderLayers.agents).forEach(([agentSet, shouldRender]) => { if (shouldRender) { renderer.drawAgentSet(this.agents[agentSet], { colorFunction: (agent) => { return { fill: agent.color, stroke: agent.strokeColor } }, styleFunction: (agent) => agent.style }) } }) // else if renderLayers.agents is a bool, draw everything conditionally } else if (this.renderLayers.agents) { Object.values(this.agents).forEach((agentSet) => { renderer.drawAgentSet(agentSet, { colorFunction: (agent) => { return { fill: agent.color, stroke: agent.strokeColor } }, styleFunction: (agent) => agent.style }) }) } } /** * The model's step function. This is where the the `act` method * for the model's Agents are called and dead agents are removed form their sets. * * Override this method to provide custom logic for the model's step function. * * To provide _additional_ step behaviors, use the `preUpdate` or `postUpdate` methods instead. */ public step(tick: number): void { // cull from sets and the grid agents that have died this.grid.forEach((cell) => { cell.tenantAgents.forEach((agent) => { if (!agent.isAlive) { // remove dead agent from grid cell.tenantAgents.delete(agent) } }) }) // remove from sets agents that have died Object.values(this.agents).forEach((agentSet) => { agentSet.forEach((agent) => { if (!agent.isAlive) { agentSet.remove(agent) } }) }) // get the current population Runtime.currentPopulation = Object.values(this.agents).reduce((acc, agentSet) => { return acc + agentSet.size }, 0) // call the step function for each agent set Object.values(this.agents).forEach((agentSet) => { agentSet.step(this.grid, this.agents, tick) }) } }