Adding up the frame's dt in On update makes anything that counts in steps depend on the frame rate. A pitch sweep sounds a little different on every run. On fixed update runs 60 times per second of game time with dt exactly 1/60, so the same graph gives the same result every time. - fixedSteps in src/core/time.ts is the one rule for the fixed step: spend the frame in steps of 1/60, keep the rest, catch up 8 steps at most after a hitch. - The physics now counts its steps with fixedSteps too. It behaves as before, and On fixed update runs exactly as often as the engine steps. - The steps run after On mouse wheel and before On update, each as a kind of its own with the usual Order setting. - Each step gets its own context, so a node inside that reads dt (Time, Move, Apply force) gets 1/60 rather than the frame. - A paused game has no steps, also for a behavior that runs while paused. - The engine still takes all of a frame's steps before the behaviors run. A force set in On fixed update acts from the next frame on. Tests: - fixed-update.test.ts (new): 5 tests. Step counting, uneven frames adding up to 60 steps a second, the hitch cap, two steps at 30 Hz before On update with dt 1/60 for the event and for Time, one step every other frame at 120 Hz, none while paused. - event-order, node-catalog, physics-system, game-pause: 430 tests together with the new file, all green. - Counter-check: with the frame's dt inside a step and without the pause guard both world tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
758 lines
31 KiB
TypeScript
758 lines
31 KiB
TypeScript
import {
|
||
adoptDeclarations,
|
||
asBoolean,
|
||
BEHAVIOR_PARAM,
|
||
buildSharedState,
|
||
carriedForward,
|
||
declaredVariables,
|
||
emptySharedState,
|
||
type Graph,
|
||
GraphInterpreter,
|
||
type GraphNode,
|
||
hierarchyRanks,
|
||
inEventOrder,
|
||
isHandleEnabled,
|
||
type NodeContext,
|
||
ON_BEHAVIOR,
|
||
restartedAssets,
|
||
restoreSaved,
|
||
type SaveArea,
|
||
savedSnapshot,
|
||
type SharedState,
|
||
startStates,
|
||
type TraceListener,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import { GraphCache } from '../../core/graph/graphIndex';
|
||
import { PER_CELL_INPUT } from '../../core/graph/nodes/shared';
|
||
import { keyName } from '../../core/keys';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||
import { treeOrder } from '../../core/model/hierarchy';
|
||
import { resolveObjects } from '../../core/model/instance';
|
||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||
import { FIXED_STEP, fixedSteps } from '../../core/time';
|
||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import type { Rapier } from '../physics/engine';
|
||
import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem';
|
||
import { behaviorContext, type TimeScaled } from './behaviorContext';
|
||
import {
|
||
animationFinishedPulses,
|
||
collisionEnterPulses,
|
||
collisionExitPulses,
|
||
collisionStayPulses,
|
||
type Instance,
|
||
keyDownPulses,
|
||
type PerCell,
|
||
type Produced,
|
||
type Pulse,
|
||
pulsesOf,
|
||
timerPulses,
|
||
} from './behaviorPulses';
|
||
import { CollisionSystem } from './CollisionSystem';
|
||
import type { CollisionSource } from './solids';
|
||
import { AnimationSystem } from './AnimationSystem';
|
||
import type { Input } from './Input';
|
||
import type { System } from './System';
|
||
|
||
export type { TimeScaled } from './behaviorContext';
|
||
|
||
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
||
export const TRACE_FADE = 0.35;
|
||
|
||
/** Recency of exec edges and nodes, keyed by id, in world time. */
|
||
export interface Trace {
|
||
edges: Map<string, number>;
|
||
nodes: Map<string, number>;
|
||
values: Map<string, Record<string, Value>>;
|
||
}
|
||
|
||
/**
|
||
* A move one graph made, waiting for its events. `source` is the instance that caused it, and who
|
||
* hears it when the machine is local; a `global` machine is heard by every instance, so the move it
|
||
* reports has no owner (`null` for the start state entered before any instance existed).
|
||
*/
|
||
interface StateChange {
|
||
machineId: string;
|
||
from: string;
|
||
to: string;
|
||
source: Instance | null;
|
||
}
|
||
|
||
/** How often state events may cause the next move before the run is called endless. */
|
||
const MAX_STATE_ROUNDS = 100;
|
||
|
||
const ERROR_PREFIX = 'Error in behavior';
|
||
|
||
/**
|
||
* What the kept objects of a scene take into the world of the next one besides themselves (#165):
|
||
* their running behaviors, the index of the graphs those run on, and how their bodies were moving.
|
||
*/
|
||
export interface KeptState {
|
||
instances: Instance[];
|
||
graphs: GraphCache;
|
||
motion: ReadonlyMap<Entity, BodyMotion>;
|
||
}
|
||
|
||
export interface GameSystemOptions {
|
||
shouldRun?: (entity: Entity) => boolean;
|
||
audio?: AudioSink;
|
||
/** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */
|
||
carried?: Readonly<SharedState> | null;
|
||
/** What the kept objects of the world before bring along (see `BehaviorSystem.release`). */
|
||
kept?: KeptState | null;
|
||
/**
|
||
* The loaded engine: a played game simulates its rigidbodies on it (#146). Without one the
|
||
* collisions are detected only, which is what the editor run still does.
|
||
*/
|
||
physics?: Rapier | null;
|
||
/**
|
||
* The clock whose scale `Set time scale` turns (#167). Only a played game hands it over: the editor
|
||
* run shares the clock with the previews, and a behavior there must not slow the editor down.
|
||
*/
|
||
clock?: TimeScaled | null;
|
||
/** Where persistent Variables assets are read from at the start and written to as they change (#166). */
|
||
saves?: SaveArea | null;
|
||
}
|
||
|
||
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||
export function addGameSystems(
|
||
world: World,
|
||
project: Project,
|
||
input: Input,
|
||
{
|
||
shouldRun,
|
||
audio = SILENT_AUDIO,
|
||
carried = null,
|
||
kept = null,
|
||
physics = null,
|
||
clock = null,
|
||
saves = null,
|
||
}: GameSystemOptions = {},
|
||
): BehaviorSystem {
|
||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||
const animations = new AnimationSystem();
|
||
const sources = { collisions, animations, audio, clock, saves };
|
||
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
|
||
if (carried) behaviors.carryOver(carried);
|
||
if (kept) {
|
||
behaviors.adopt(kept);
|
||
if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion);
|
||
}
|
||
world.addSystem(collisions);
|
||
world.addSystem(animations);
|
||
world.addSystem(behaviors);
|
||
return behaviors;
|
||
}
|
||
|
||
/** The systems whose per-frame results become events; a missing one means no such events. */
|
||
export interface EventSources {
|
||
collisions?: (CollisionSource & System) | null;
|
||
animations?: AnimationSystem | null;
|
||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||
audio?: AudioSink | null;
|
||
clock?: TimeScaled | null;
|
||
saves?: SaveArea | null;
|
||
}
|
||
|
||
/** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */
|
||
function runsWhilePaused(props: PropertyBag): boolean {
|
||
return props.runWhilePaused === true;
|
||
}
|
||
|
||
/** The behavior failure that stopped a run, if that is why it stopped. */
|
||
export function behaviorError(world: World): string | undefined {
|
||
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
|
||
}
|
||
|
||
/**
|
||
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
|
||
* therefore its own timers and variable state); the project is only read to find graphs.
|
||
*/
|
||
export class BehaviorSystem implements System {
|
||
/** A paused frame still ticks the behaviors that run while paused (see `update`). */
|
||
readonly runsWhilePaused = true;
|
||
private instances: Instance[] = [];
|
||
private world: World | null = null;
|
||
private sharedState: SharedState = emptySharedState();
|
||
/** Handed over by the previous world of the same run; consumed by the next `start`. */
|
||
private carried: SharedState | null = null;
|
||
/** Which of the carried machines this scene starts over, so their entry is announced again. */
|
||
private restarted: ReadonlySet<string> = new Set();
|
||
/** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */
|
||
private pending: StateChange[] = [];
|
||
/** Game time not yet spent on a fixed step (see `fixedSteps`); counted like the physics' own. */
|
||
private fixedBacklog = 0;
|
||
/** Set when the scene may have been edited, so the next sync puts the instances in order again. */
|
||
private unranked = true;
|
||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
||
private declared: Record<string, PropertyBag> = {};
|
||
/** Shared by every instance; an edit to the project empties it (see `syncFromModel`). */
|
||
private graphs = new GraphCache();
|
||
/** Running behaviors of kept objects, handed over by the world before; consumed by the next `start`. */
|
||
private adopted: Instance[] = [];
|
||
private readonly collisions: CollisionSource | null;
|
||
private readonly animations: AnimationSystem | null;
|
||
private readonly audio: AudioSink;
|
||
private readonly clock: TimeScaled | null;
|
||
private readonly saves: SaveArea | null;
|
||
/** What was last written to `saves`, so a frame that changed nothing writes nothing. */
|
||
private lastSaved = '';
|
||
|
||
constructor(
|
||
private readonly project: Project,
|
||
private readonly input: Input,
|
||
sources: EventSources = {},
|
||
/**
|
||
* Which behaviors this system is responsible for. Playing runs them all; the editor run
|
||
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
|
||
*/
|
||
private readonly shouldRun: (entity: Entity) => boolean = () => true,
|
||
) {
|
||
this.collisions = sources.collisions ?? null;
|
||
this.animations = sources.animations ?? null;
|
||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||
this.clock = sources.clock ?? null;
|
||
this.saves = sources.saves ?? null;
|
||
}
|
||
|
||
/** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */
|
||
get shared(): Readonly<SharedState> {
|
||
return this.sharedState;
|
||
}
|
||
|
||
/** The values a Variables asset shares this run; null when this run holds none for it. */
|
||
liveVariables(assetId: string): PropertyBag | null {
|
||
return this.sharedState.variables[assetId] ?? null;
|
||
}
|
||
|
||
/** An edit to the project while the run is on: declarations and the hierarchy may have changed. */
|
||
syncFromModel(): void {
|
||
this.graphs.clear();
|
||
this.syncDeclarations();
|
||
this.unranked = true;
|
||
}
|
||
|
||
/** An edit to a declaration while the run is on reaches the running values through here. */
|
||
syncDeclarations(): void {
|
||
const now = declaredVariables(this.project);
|
||
adoptDeclarations(this.sharedState.variables, this.declared, now);
|
||
this.declared = now;
|
||
}
|
||
|
||
/**
|
||
* Takes over the shared state of the world before it – the values of the Variables assets and
|
||
* where the global machines stand – instead of building a fresh one. That is what belongs to the
|
||
* run rather than to the scene, and it is what makes a score survive the next level. Call before
|
||
* the world is started.
|
||
*/
|
||
carryOver(state: Readonly<SharedState>): void {
|
||
// The bags of what is `global` travel as they are – a running value is the point – while what is
|
||
// `scene` is built again from the declarations, so a level counts from zero (#164).
|
||
this.carried = carriedForward(this.project, state);
|
||
this.restarted = restartedAssets(this.project);
|
||
}
|
||
|
||
/**
|
||
* Hands out the running behaviors on these entities, so the end of this world is not theirs: no
|
||
* `On destroy`, their sounds play on, and the next world's system carries them on where they are
|
||
* (see `adopt`). Call before the world is destroyed.
|
||
*/
|
||
release(entities: readonly Entity[]): KeptState {
|
||
const leaving = new Set(entities);
|
||
const instances = this.instances.filter((i) => leaving.has(i.entity));
|
||
this.instances = this.instances.filter((i) => !leaving.has(i.entity));
|
||
const physics = this.collisions instanceof PhysicsSystem ? this.collisions : null;
|
||
return { instances, graphs: this.graphs, motion: physics?.motionOf(entities) ?? new Map() };
|
||
}
|
||
|
||
/**
|
||
* Takes over behaviors that ran in the world before. They are no new instances, so they hear no
|
||
* `On start`; their timers, variables and local machines stand where they stood. Call before the
|
||
* world is started.
|
||
*/
|
||
adopt(kept: KeptState): void {
|
||
this.adopted = kept.instances;
|
||
// Their interpreters read this index, and an edit to the project has to reach it through us.
|
||
this.graphs = kept.graphs;
|
||
}
|
||
|
||
start(world: World): void {
|
||
this.world = world;
|
||
this.instances = this.adopted;
|
||
this.adopted = [];
|
||
// The trace is timed by the world, and this one starts at second zero.
|
||
for (const instance of this.instances) {
|
||
instance.trace.edges.clear();
|
||
instance.trace.nodes.clear();
|
||
for (const byNode of instance.values.values())
|
||
for (const produced of byNode.values()) produced.time = 0;
|
||
this.listen(instance, world);
|
||
}
|
||
this.pending = [];
|
||
const carried = this.carried;
|
||
this.carried = null;
|
||
// Built fresh for every run: a shared variable starts at its declared value each time Play does.
|
||
this.sharedState = carried ?? buildSharedState(this.project);
|
||
// A persistent asset picks up where the last run left it; after a scene swap it already has.
|
||
if (!carried && this.saves) restoreSaved(this.project, this.sharedState.variables, this.saves.read());
|
||
this.lastSaved = JSON.stringify(savedSnapshot(this.project, this.sharedState.variables));
|
||
this.declared = declaredVariables(this.project);
|
||
// A `global` machine is entered once for the whole run, before the first instance exists – so
|
||
// the move is queued here and every instance the first sync creates hears that one entry.
|
||
// Not after a scene swap: the machine is already standing where it stands, and announcing the
|
||
// entry again would be a move that never happened.
|
||
// A `scene` machine is the exception after a swap: it really was entered again, just now.
|
||
for (const [machineId, start] of Object.entries(this.sharedState.states))
|
||
if (start && (!carried || this.restarted.has(machineId)))
|
||
this.pending.push({ machineId, from: '', to: start, source: null });
|
||
this.restarted = new Set();
|
||
this.syncInstances(world);
|
||
}
|
||
|
||
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
|
||
stop(): void {
|
||
this.finish(this.instances);
|
||
// After the goodbyes: what `On destroy` writes is the last thing the run did.
|
||
this.persist();
|
||
this.instances = [];
|
||
this.pending = [];
|
||
this.world = null;
|
||
}
|
||
|
||
/**
|
||
* Writes the persistent values when they differ from the last write. Every frame rather than at
|
||
* the end, because a tab closed mid-game has no end to wait for.
|
||
*/
|
||
private persist(): void {
|
||
if (!this.saves) return;
|
||
const snapshot = savedSnapshot(this.project, this.sharedState.variables);
|
||
const text = JSON.stringify(snapshot);
|
||
if (text === this.lastSaved) return;
|
||
this.lastSaved = text;
|
||
this.saves.write(snapshot);
|
||
}
|
||
|
||
/**
|
||
* One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when
|
||
* switched on, which is what lets a hidden screen wake up when shown.
|
||
*/
|
||
private syncInstances(world: World): void {
|
||
this.reap(world);
|
||
// Read once, and only where an instance is actually born: most frames create none at all.
|
||
let starts: Record<string, string> | null = null;
|
||
const born = new Set<Instance>();
|
||
for (const entity of world.entities) {
|
||
if (
|
||
entity.type !== 'behavior' ||
|
||
!isHandleEnabled(entity) ||
|
||
!this.shouldRun(entity) ||
|
||
this.instances.some((i) => i.entity === entity)
|
||
)
|
||
continue;
|
||
const asset = findAssetOf(this.project, entity.assetId, 'behavior');
|
||
if (!asset) continue;
|
||
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
|
||
const values = new Map<Graph, Map<string, Produced>>();
|
||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project), this.graphs);
|
||
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
||
this.listen(instance, world);
|
||
this.instances.push(instance);
|
||
born.add(instance);
|
||
}
|
||
if (born.size > 0 || this.unranked) this.syncHeard(world, born);
|
||
if (born.size > 0 || this.unranked) this.rank(world);
|
||
const fresh = this.instances.filter((i) => born.has(i));
|
||
for (const instance of fresh) this.enterStartStates(instance, (starts ??= startStates(this.project)));
|
||
const contextOf = this.contexts(world, 0);
|
||
this.runPulses(pulsesOf(fresh, 'OnStart'), contextOf);
|
||
this.runPulses(pulsesOf(fresh, 'OnEnable'), contextOf);
|
||
this.flushStateChanges();
|
||
}
|
||
|
||
/**
|
||
* One further instance per `On behavior` node (#294), on the entity of the behavior holding it and
|
||
* for as long as that one runs – and so on down, since the behavior heard may listen in turn. An
|
||
* edit to a graph while it runs adds and drops them like objects coming and going.
|
||
*/
|
||
private syncHeard(world: World, born: Set<Instance>): void {
|
||
const held = new Map<Instance, Instance[]>();
|
||
for (const instance of this.instances) {
|
||
const host = instance.host?.instance;
|
||
if (host) held.set(host, [...(held.get(host) ?? []), instance]);
|
||
}
|
||
const dropped: Instance[] = [];
|
||
const drop = (instance: Instance) => {
|
||
dropped.push(instance);
|
||
for (const below of held.get(instance) ?? []) drop(below);
|
||
};
|
||
const visit = (host: Instance, chain: readonly string[]) => {
|
||
const below = held.get(host) ?? [];
|
||
const kept = new Set<Instance>();
|
||
for (const node of host.interpreter.eventNodes(ON_BEHAVIOR)) {
|
||
const behaviorId = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||
// A behavior hearing itself, however far round, would be built without end.
|
||
if (chain.includes(behaviorId) || !findAssetOf(this.project, behaviorId, 'behavior')) continue;
|
||
const heard =
|
||
below.find((i) => i.host?.nodeId === node.id && i.host.behaviorId === behaviorId) ??
|
||
this.hear(host, node.id, behaviorId, world);
|
||
if (!heard) continue;
|
||
if (!below.includes(heard)) born.add(heard);
|
||
kept.add(heard);
|
||
visit(heard, [...chain, behaviorId]);
|
||
}
|
||
for (const instance of below) if (!kept.has(instance)) drop(instance);
|
||
};
|
||
for (const instance of [...this.instances])
|
||
if (!instance.host) visit(instance, [instance.entity.assetId]);
|
||
if (dropped.length === 0) return;
|
||
this.instances = this.instances.filter((i) => !dropped.includes(i));
|
||
this.finish(dropped);
|
||
}
|
||
|
||
/** A running copy of that behavior for one `On behavior` node, with variables of its own. */
|
||
private hear(host: Instance, nodeId: string, behaviorId: string, world: World): Instance | null {
|
||
const lookup = graphLookup(this.project);
|
||
const program = lookup.behavior(behaviorId);
|
||
if (!program) return null;
|
||
const heard: Instance = {
|
||
entity: host.entity,
|
||
interpreter: new GraphInterpreter(program.graph, lookup, this.graphs),
|
||
trace: { edges: new Map(), nodes: new Map() },
|
||
values: new Map(),
|
||
states: {},
|
||
active: true,
|
||
host: { instance: host, nodeId, behaviorId },
|
||
variables: structuredClone(program.defaults),
|
||
};
|
||
this.listen(heard, world);
|
||
this.instances.push(heard);
|
||
return heard;
|
||
}
|
||
|
||
/** Has the instance note what it runs, timed by the world it runs in. */
|
||
private listen({ interpreter, trace, values }: Instance, world: World): void {
|
||
interpreter.trace = {
|
||
onEdge: (id) => trace.edges.set(id, world.time),
|
||
onNode: (id) => trace.nodes.set(id, world.time),
|
||
onValues: (graph, id, produced) => {
|
||
let byNode = values.get(graph);
|
||
if (!byNode) values.set(graph, (byNode = new Map<string, Produced>()));
|
||
byNode.set(id, { time: world.time, values: produced });
|
||
},
|
||
} satisfies TraceListener;
|
||
}
|
||
|
||
/**
|
||
* Puts the instances in hierarchy order – what equal orders fall back on (#264). Only when one is
|
||
* born or the scene was edited, since the hierarchy is walked in full.
|
||
*/
|
||
private rank(world: World): void {
|
||
this.unranked = false;
|
||
const scene = findScene(this.project, world.sceneId);
|
||
const tree = scene ? treeOrder({ objects: resolveObjects(this.project, scene) }) : [];
|
||
const ranks = hierarchyRanks(
|
||
world.entities,
|
||
tree.map(({ object }) => object.id),
|
||
);
|
||
this.instances.sort((a, b) => (ranks.get(a.entity) ?? 0) - (ranks.get(b.entity) ?? 0));
|
||
}
|
||
|
||
/**
|
||
* A fresh instance begins in each local machine's start state, and hears `On state entered` for
|
||
* it – after its `On start`, like every other move. A `global` machine is not entered here: it
|
||
* was entered once when the run began, and a later instance joins it where it already is.
|
||
*/
|
||
private enterStartStates(instance: Instance, starts: Record<string, string>): void {
|
||
for (const [machineId, start] of Object.entries(starts)) {
|
||
if (machineId in this.sharedState.states) continue;
|
||
instance.states[machineId] = start;
|
||
this.pending.push({ machineId, from: '', to: start, source: instance });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The events every queued move owes, and those the events themselves cause, until none are left.
|
||
* Each move is delivered whole – `exited` to everyone who hears it, then `entered` – so the two
|
||
* never interleave across instances.
|
||
*/
|
||
private flushStateChanges(): void {
|
||
const world = this.world;
|
||
if (!world) {
|
||
this.pending = [];
|
||
return;
|
||
}
|
||
for (let round = 0; this.pending.length > 0; round++) {
|
||
if (round >= MAX_STATE_ROUNDS) {
|
||
this.pending = [];
|
||
world.log.push(`${ERROR_PREFIX} graphs: state changes keep causing each other (endless loop?)`);
|
||
world.stopRequested = true;
|
||
return;
|
||
}
|
||
const batch = this.pending;
|
||
this.pending = [];
|
||
for (const change of batch) this.deliver(change, world);
|
||
}
|
||
}
|
||
|
||
private deliver(change: StateChange, world: World): void {
|
||
const global = change.machineId in this.sharedState.states;
|
||
const targets = (global ? this.instances : [change.source]).filter(
|
||
(i): i is Instance => i !== null && this.instances.includes(i),
|
||
);
|
||
const contextOf = this.contexts(world, 0);
|
||
const pulses = (type: string, state: string): Pulse[] =>
|
||
state
|
||
? targets.flatMap((instance) =>
|
||
instance.interpreter
|
||
.stateEventNodes(type, change.machineId)
|
||
.map((node) => ({ instance, node, port: state })),
|
||
)
|
||
: [];
|
||
this.runPulses(pulses('OnStateExited', change.from), contextOf);
|
||
this.runPulses(pulses('OnStateEntered', change.to), contextOf);
|
||
}
|
||
|
||
/**
|
||
* Which state each instance holds of one machine, for the State machine tab's live highlight.
|
||
* A `global` machine answers even with no instance running: its state belongs to the run.
|
||
*/
|
||
machineStates(machineId: string): { state: string; objectId: string; ownerId: string }[] {
|
||
const shared = this.sharedState.states[machineId];
|
||
const held = this.instances
|
||
.map((i) => ({
|
||
state: shared ?? i.states[machineId] ?? '',
|
||
objectId: i.entity.objectId,
|
||
ownerId: i.entity.parent?.objectId ?? '',
|
||
}))
|
||
.filter((entry) => entry.state !== '');
|
||
if (held.length === 0 && shared) return [{ state: shared, objectId: '', ownerId: '' }];
|
||
return held;
|
||
}
|
||
|
||
/** Instances whose entity left the world get their last events and are dropped. */
|
||
private reap(world: World): void {
|
||
const gone = this.instances.filter((i) => !world.entities.includes(i.entity));
|
||
if (gone.length === 0) return;
|
||
this.instances = this.instances.filter((i) => !gone.includes(i));
|
||
this.finish(gone);
|
||
}
|
||
|
||
private finish(instances: Instance[]): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
const contextOf = this.contexts(world, 0);
|
||
this.runPulses(
|
||
pulsesOf(
|
||
instances.filter((i) => i.active),
|
||
'OnDisable',
|
||
),
|
||
contextOf,
|
||
);
|
||
for (const instance of instances) instance.active = false;
|
||
this.runPulses(pulsesOf(instances, 'OnDestroy'), contextOf);
|
||
// After `On destroy`, not before: that graph may still say something, and a sound it wants to
|
||
// outlive the object says so itself by being detached.
|
||
// Its sounds are the entity's, so they end with the behavior placed there and not with one it hears.
|
||
for (const instance of instances) if (!instance.host) this.audio.stopOwner(instance.entity.id);
|
||
}
|
||
|
||
/**
|
||
* Follows the effective switch – off when the behavior or a parent is off, or when "run in editor"
|
||
* was taken back while running – and fires the edge each instance just crossed. Returns those on.
|
||
*/
|
||
private switched(instances: Instance[], contextOf: (instance: Instance) => NodeContext): Instance[] {
|
||
const flipped = instances.filter(
|
||
(i) => (isHandleEnabled(i.entity) && this.shouldRun(i.entity)) !== i.active,
|
||
);
|
||
for (const instance of flipped) instance.active = !instance.active;
|
||
this.runPulses(
|
||
pulsesOf(
|
||
flipped.filter((i) => !i.active),
|
||
'OnDisable',
|
||
),
|
||
contextOf,
|
||
);
|
||
this.runPulses(
|
||
pulsesOf(
|
||
flipped.filter((i) => i.active),
|
||
'OnEnable',
|
||
),
|
||
contextOf,
|
||
);
|
||
this.flushStateChanges();
|
||
return instances.filter((i) => i.active);
|
||
}
|
||
|
||
/**
|
||
* One event kind after the other, each across every behavior in `order` (#264): a lower order runs
|
||
* before a higher one whatever behavior it sits in. What a kind hands on is gathered only when its
|
||
* turn comes, so a click is hit-tested where the key events of this frame left the objects.
|
||
*/
|
||
update(world: World, dt: number): void {
|
||
this.syncInstances(world);
|
||
const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? ''));
|
||
const frozen = world.frozen;
|
||
const contextOf = this.contexts(world, dt);
|
||
const ticking = this.switched(
|
||
this.instances.filter((i) => !frozen || runsWhilePaused(i.entity.props)),
|
||
contextOf,
|
||
);
|
||
const pointer = world.pointer;
|
||
// With an owner the click has to land on it; a behavior without one has no shape to be hit,
|
||
// so it listens to the whole game view (anywhere inside the camera frame).
|
||
const pointed = (i: Instance) => {
|
||
const owner = i.entity.parent;
|
||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||
};
|
||
const { input, collisions, animations } = this;
|
||
const perCell: PerCell = (i, node) =>
|
||
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
|
||
const kinds: (() => Pulse[])[] = [
|
||
() =>
|
||
ticking.flatMap((i) =>
|
||
i.interpreter.eventNodes('OnKeyDown').flatMap((n) => keyDownPulses(i, n, input, dt)),
|
||
),
|
||
() => pulsesOf(ticking, 'OnKeyUp', {}, (n) => input.justReleased.has(keyOf(n))),
|
||
() => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => input.heldKeys.has(keyOf(n))),
|
||
() => ticking.flatMap((i) => timerPulses(i, dt)),
|
||
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
||
() => (frozen ? [] : ticking.flatMap((i) => collisionEnterPulses(i, collisions, perCell))),
|
||
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
|
||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
|
||
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||
() =>
|
||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||
() =>
|
||
pointer.wheel !== 0
|
||
? pulsesOf(ticking.filter(pointed), 'OnMouseWheel', {
|
||
delta: pointer.wheel,
|
||
x: pointer.x,
|
||
y: pointer.y,
|
||
})
|
||
: [],
|
||
];
|
||
const run = (pulses: Pulse[], context = contextOf) => {
|
||
this.runPulses(pulses, context, true);
|
||
// After the kind, not inside it: a state entered halfway through `On update` must not push
|
||
// its events between the nodes that are still to run.
|
||
this.flushStateChanges();
|
||
};
|
||
for (const kind of kinds) run(kind());
|
||
if (!frozen) {
|
||
const fixed = fixedSteps(this.fixedBacklog, dt);
|
||
this.fixedBacklog = fixed.backlog;
|
||
// A context of its own per step: what a node reads as `dt` inside is the step, not the frame.
|
||
for (let step = 0; step < fixed.steps; step++)
|
||
run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP));
|
||
}
|
||
run(pulsesOf(ticking, 'OnUpdate', { dt }));
|
||
this.reap(world);
|
||
this.flushStateChanges();
|
||
this.persist();
|
||
}
|
||
|
||
runningIds(): string[] {
|
||
return this.instances.filter((i) => !i.host).map((i) => i.entity.objectId);
|
||
}
|
||
|
||
/**
|
||
* Live highlight data for the graph editor: merged over all instances of the asset – and over
|
||
* those that ran it as a node, which is the only way a nested behavior's graph ever lights up.
|
||
*/
|
||
traceFor(assetId: string): Trace {
|
||
const merged: Trace = { edges: new Map(), nodes: new Map(), values: new Map() };
|
||
const latest = (into: Map<string, number>, from: Map<string, number>) => {
|
||
for (const [id, t] of from) into.set(id, Math.max(t, into.get(id) ?? -Infinity));
|
||
};
|
||
const graph = findAssetOf(this.project, assetId, 'behavior')?.graph;
|
||
const newest = new Map<string, Produced>();
|
||
for (const instance of this.instances) {
|
||
const own = instance.host?.behaviorId ?? instance.entity.assetId;
|
||
if (own !== assetId && !instance.interpreter.calledBehaviors.has(assetId)) continue;
|
||
latest(merged.edges, instance.trace.edges);
|
||
latest(merged.nodes, instance.trace.nodes);
|
||
for (const [id, produced] of (graph && instance.values.get(graph)) ?? []) {
|
||
const known = newest.get(id);
|
||
if (!known || produced.time >= known.time) newest.set(id, produced);
|
||
}
|
||
}
|
||
for (const [id, produced] of newest) merged.values.set(id, produced.values);
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* Runs the pulses of one event kind in their order (see `core/graph/eventOrder.ts`). An exception
|
||
* stops play and is logged, never thrown. `living`: a pulse whose object an earlier one destroyed
|
||
* this frame is dropped – `reap` gives it its goodbye.
|
||
*/
|
||
private runPulses(pulses: Pulse[], contextOf: (instance: Instance) => NodeContext, living = false): void {
|
||
const world = this.world;
|
||
if (!world || pulses.length === 0) return;
|
||
for (const { instance, node, outputs, port } of inEventOrder(pulses)) {
|
||
if (living && !world.entities.includes(instance.entity)) continue;
|
||
try {
|
||
this.give(instance, contextOf);
|
||
instance.interpreter.pulse(node, contextOf(instance), outputs, port);
|
||
this.surface(instance, contextOf);
|
||
} catch (error) {
|
||
world.log.push(
|
||
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
world.stopRequested = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The `On behavior` node an instance is heard by, as the graph holds it now. */
|
||
private hearing(instance: Instance): { host: Instance; node: GraphNode } | null {
|
||
const host = instance.host;
|
||
const node = host?.instance.interpreter.eventNodes(ON_BEHAVIOR).find((n) => n.id === host.nodeId);
|
||
return host && node ? { host: host.instance, node } : null;
|
||
}
|
||
|
||
/** A heard behavior's start node hands out the inputs of the node hearing it, read anew for each pulse. */
|
||
private give(instance: Instance, contextOf: (instance: Instance) => NodeContext): void {
|
||
const heard = this.hearing(instance);
|
||
if (heard)
|
||
instance.interpreter.given = heard.host.interpreter.eventInputs(heard.node, contextOf(heard.host));
|
||
}
|
||
|
||
/**
|
||
* A pulse of a heard behavior that reached its end node goes on at the node hearing it, through
|
||
* the output named like the way it took – and from there further up, if that graph ends in turn.
|
||
*/
|
||
private surface(instance: Instance, contextOf: (instance: Instance) => NodeContext): void {
|
||
for (let inner = instance; inner.host; inner = inner.host.instance) {
|
||
const ending = inner.interpreter.ending;
|
||
const heard = this.hearing(inner);
|
||
if (!ending || !heard) return;
|
||
heard.host.interpreter.pulse(heard.node, contextOf(heard.host), ending.values, ending.port);
|
||
}
|
||
}
|
||
|
||
/** One context per instance for as long as the answer is used – a frame, or one round of events. */
|
||
private contexts(world: World, dt: number): (instance: Instance) => NodeContext {
|
||
const made = new Map<Instance, NodeContext>();
|
||
return (instance) => {
|
||
let ctx = made.get(instance);
|
||
if (!ctx) made.set(instance, (ctx = this.context(instance, world, dt)));
|
||
return ctx;
|
||
};
|
||
}
|
||
|
||
private context(instance: Instance, world: World, dt: number): NodeContext {
|
||
return behaviorContext(instance, world, dt, {
|
||
project: this.project,
|
||
input: this.input,
|
||
collisions: this.collisions,
|
||
audio: this.audio,
|
||
clock: this.clock,
|
||
shared: this.sharedState,
|
||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||
});
|
||
}
|
||
}
|