import { AgentSet, CellGrid } from '../structures' import Agent from '../entities/Agent' import Cell from '../entities/Cell' import { RandomGenerator } from '../numbers' import { AnimateWebGL2D, InfoPane, Inspector, Render2D, WebGL2D } 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>() { // 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 { public static controls: Controls | undefined = undefined public static maxPopulation: number = Infinity public static currentPopulation: number = 0 public rng: RandomGenerator public agents: { [agentSet: string]: AgentSet } public grid: CellGrid 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 } = { 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 }): 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 /** * 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 } /** * 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 | WebGL2D) => 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 AnimateWebGL2D({ 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: WebGL2D) => { 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 | WebGL2D): 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) }) } }