/** * A running behavior, and the pulses the events of one frame send into it: one per event node that * fires, with what the event hands on. `BehaviorSystem` decides when each kind is gathered and runs * them in their order. */ import { asNumber, type Graph, type GraphInterpreter, type GraphNode, type Value } from '../../core/graph'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; import { keyName } from '../../core/keys'; import { type PropertyBag, textOf } from '../../core/model/Properties'; import { parentChain } from '../../core/model/walk'; import type { Entity } from '../Entity'; import type { AnimationSystem } from './AnimationSystem'; import type { Input } from './Input'; import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids'; /** What one node last handed on, and when – the graph tab shows the newest of every instance. */ export interface Produced { time: number; values: Record; } export interface Instance { entity: Entity; interpreter: GraphInterpreter; /** Recency of exec edges and nodes, keyed by id, in world time. */ trace: { edges: Map; nodes: Map }; values: Map>; /** Current state per state machine – this instance's alone, like its variables. */ states: Record; /** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */ active: boolean; /** * Set on a behavior an `On behavior` node listens to (#294): it runs on the same entity as the * one holding that node, which hears every pulse of it that reaches the end node. */ host?: { instance: Instance; nodeId: string; behaviorId: string }; /** Its own variables, where it has a host; the entity's belong to the behavior placed on it. */ variables?: PropertyBag; } /** One event node about to run for one instance: what the event hands it, and the output it leaves by. */ export interface Pulse { instance: Instance; node: GraphNode; outputs?: Record; port?: string; } /** A collision pair seen from one behavior's body: its own collider and the one across. */ interface Side { pair: CollisionPair; own: Entity; other: Entity; } /** Every node of one event type in these instances, in hierarchy and then node order. */ export function pulsesOf( instances: readonly Instance[], type: string, outputs: Record = {}, filter?: (node: GraphNode) => boolean, ): Pulse[] { return instances.flatMap((instance) => instance.interpreter .eventNodes(type) .filter((node) => !filter || filter(node)) .map((node) => ({ instance, node, outputs })), ); } /** * The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock * in `nodeState`, so two nodes on the same key repeat on their own schedules. */ export function keyDownPulses(instance: Instance, node: GraphNode, input: Input, dt: number): Pulse[] { const i = instance.interpreter; const key = keyName(textOf(node.params.key ?? '')); const stored = i.nodeState.get(node.id); const { state, fires } = stepKeyRepeat( stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, { pressed: input.justPressed.has(key), down: input.heldKeys.has(key), dt, // The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds. after: asNumber(node.params.repeatAfter ?? 0) / 1000, interval: asNumber(node.params.repeatInterval ?? 0) / 1000, }, ); if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats }); else i.nodeState.delete(node.id); return Array.from({ length: fires }, () => ({ instance, node })); } export function timerPulses(instance: Instance, dt: number): Pulse[] { const i = instance.interpreter; const pulses: Pulse[] = []; for (const node of i.eventNodes('OnTimer')) { const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; i.nodeState.set(node.id, state); const interval = Math.max(0.01, Number(node.params.interval ?? 1)); state.elapsed = (state.elapsed ?? 0) + dt; // What is left after taking one interval is how long ago this tick was due. When one frame // catches up several ticks, the first one was due longest ago and is the latest. while (state.elapsed >= interval) { state.elapsed -= interval; pulses.push({ instance, node, outputs: { late: state.elapsed } }); } } return pulses; } /** * The collision pairs of one kind that concern this behavior: every pair in which one collider * belongs to the behavior's body (its owner, or itself when it has none), with `other` being the * body on the far side. */ function sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] { const body = instance.entity.parent ?? instance.entity; return pairs.flatMap((pair) => { const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null; return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : []; }); } /** Reads an event node's "per cell" input for its instance (#160). */ export type PerCell = (instance: Instance, node: GraphNode) => boolean; /** * What of the pairs one node hears. A trigger tile is told twice, per cell and per area (#160), and * the node's "per cell" input picks one; it is only read where a tile is among the pairs at all. */ function heardBy(instance: Instance, node: GraphNode, all: Side[], perCell: PerCell): Side[] { if (!all.some((side) => side.pair.tile)) return all; const cells = perCell(instance, node); return all.filter((side) => !side.pair.tile || side.pair.tile.perCell === cells); } export function collisionEnterPulses( instance: Instance, collisions: CollisionSource | null, perCell: PerCell, ): Pulse[] { const entered = sides(instance, collisions?.enters ?? []); return instance.interpreter.eventNodes('OnCollisionEnter').flatMap((node) => heardBy(instance, node, entered, perCell).map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair), })), ); } /** * Stay fires every frame or, with an interval, once each time that long has passed since the pair * met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone * takes its clock with it. */ export function collisionStayPulses( instance: Instance, collisions: CollisionSource | null, dt: number, perCell: PerCell, ): Pulse[] { if (!collisions) return []; const interpreter = instance.interpreter; const stays = sides(instance, collisions.stays); const contacts = new Map(); const contactOf = (pair: CollisionPair) => { if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); return contacts.get(pair)!; }; const pulses: Pulse[] = []; for (const node of interpreter.eventNodes('OnCollisionStay')) { const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0; const clocks = interpreter.nodeState.get(node.id) ?? {}; const running: Record = {}; for (const side of heardBy(instance, node, stays, perCell)) { let fires = 1; if (interval > 0) { const key = pairKey(side.pair); let elapsed = (clocks[key] ?? 0) + dt; for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval; running[key] = elapsed; } for (let i = 0; i < fires; i++) pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) }); } if (interval > 0) interpreter.nodeState.set(node.id, running); else interpreter.nodeState.delete(node.id); } return pulses; } export function collisionExitPulses( instance: Instance, collisions: CollisionSource | null, perCell: PerCell, ): Pulse[] { const left = sides(instance, collisions?.exits ?? []); return instance.interpreter .eventNodes('OnCollisionExit') .flatMap((node) => heardBy(instance, node, left, perCell).map((side) => ({ instance, node, outputs: sideOutputs(side) })), ); } /** * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. * What it played itself it hears wherever that stands: on the owner's parent, or across the scene. */ export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] { const owner = instance.entity.parent; const finished = (animations?.finished ?? []).filter( ({ entity, startedBy }) => !owner || startedBy.includes(instance.entity) || [...parentChain(entity, (e) => e.parent)].includes(owner), ); return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => { const wanted = textOf(node.params.animation ?? ''); return finished .filter(({ animationId }) => wanted === '' || wanted === animationId) .map(({ entity }) => ({ instance, node, outputs: { object: entity } })); }); } /** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */ function sideOutputs({ own, other, pair }: Side, contact?: Partial | null): Record { const outputs: Record = { other: bodyOf(other), otherCollider: other, collider: own, tile: pair.tile?.tileId ?? '', cellX: pair.tile?.cell.x ?? -1, cellY: pair.tile?.cell.y ?? -1, }; if (contact !== undefined) { // The pair's normal points towards `a`; seen from `b` it points the other way. const sign = own === pair.a ? 1 : -1; outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0; outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0; outputs.impulse = contact?.impulse ?? 0; outputs.pointX = contact?.point?.x ?? 0; outputs.pointY = contact?.point?.y ?? 0; } return outputs; }