Destroying a container used to take everything below it along. Now Destroy can remove just the object itself and pass its direct children on, so a behavior can keep working with what was inside. - New input keep children (bool, default off). When on, only the object goes. Its direct children move up to the object's parent, or to the top level if there is none. Grandchildren stay where they are. - New output children (object list) with the direct children that were kept. It is empty when keep children is off. - The children keep their place on screen. carriedTransform folds the destroyed object's position, rotation and scale into their own, the same as reparenting in the editor. - Behaviors and the rigidbody of the object are always destroyed with it and are not in the list. Moved up they would steer or simulate the parent. - World remembers which children it reparented, and syncToModel writes their new parent into the scene. A reload of the model keeps it. - Limit, read from the code only: in the editor run the contents of a placed container are not baked. Moved children there last for the run only. Tests: - ./check.sh: 170 files, 2478 tests, CHECK EXIT 0 - e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks - world-sync: world matrix of child and grandchild unchanged under a rotated and scaled parent, parent survives syncFromModel, top level case, already destroyed object gives [] - destroy-keep-children: real graph Destroy(Owner) -> Log(children) logs Lid, Label without the grandchild Nail - 3 counter-probes, all caught: no world transform (1 test fails), no parent in the scene (1), behaviors and rigidbody moved up (2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
814 lines
34 KiB
TypeScript
814 lines
34 KiB
TypeScript
import {
|
||
adoptDeclarations,
|
||
asNumber,
|
||
buildSharedState,
|
||
carriedForward,
|
||
declaredVariables,
|
||
emptySharedState,
|
||
type EntityHandle,
|
||
type Graph,
|
||
type GraphNode,
|
||
GraphInterpreter,
|
||
isHandleEnabled,
|
||
type NodeContext,
|
||
type PhysicsControl,
|
||
restartedAssets,
|
||
restoreSaved,
|
||
type SaveArea,
|
||
savedSnapshot,
|
||
type SharedState,
|
||
startStates,
|
||
type TraceListener,
|
||
type Value,
|
||
} from '../../core/graph';
|
||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||
import { keyName } from '../../core/keys';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
|
||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||
import { soundFileOf } from '../../core/model/sound';
|
||
import { parentChain } from '../../core/model/walk';
|
||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import type { Rapier } from '../physics/engine';
|
||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||
import { CollisionSystem } from './CollisionSystem';
|
||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids';
|
||
import { AnimationSystem } from './AnimationSystem';
|
||
import type { Input } from './Input';
|
||
import type { System } from './System';
|
||
|
||
/** 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>>;
|
||
}
|
||
|
||
interface Produced {
|
||
time: number;
|
||
values: 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;
|
||
}
|
||
|
||
const NO_DYNAMIC_BODY =
|
||
'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.';
|
||
|
||
/** How often state events may cause the next move before the run is called endless. */
|
||
const MAX_STATE_ROUNDS = 100;
|
||
|
||
/** A collision pair seen from one behavior's body: its own collider and the one across. */
|
||
interface Side {
|
||
pair: CollisionPair;
|
||
own: Entity;
|
||
other: Entity;
|
||
}
|
||
|
||
interface Instance {
|
||
entity: Entity;
|
||
interpreter: GraphInterpreter;
|
||
trace: Omit<Trace, 'values'>;
|
||
values: Map<Graph, Map<string, Produced>>;
|
||
/** Current state per state machine – this instance's alone, like its variables. */
|
||
states: Record<string, string>;
|
||
/** Whether the behavior was effectively on when last looked at; a change fires On enable/On disable. */
|
||
active: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
const ERROR_PREFIX = 'Error in behavior';
|
||
|
||
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;
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
export interface TimeScaled {
|
||
scale: number;
|
||
}
|
||
|
||
/** 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,
|
||
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);
|
||
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));
|
||
}
|
||
|
||
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[] = [];
|
||
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
|
||
private declared: Record<string, PropertyBag> = {};
|
||
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 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);
|
||
}
|
||
|
||
start(world: World): void {
|
||
this.world = world;
|
||
this.instances = [];
|
||
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 {
|
||
for (const instance of this.instances) {
|
||
this.finish(instance);
|
||
this.audio.stopOwner(instance.entity.id);
|
||
}
|
||
// 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;
|
||
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));
|
||
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;
|
||
const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true };
|
||
this.instances.push(instance);
|
||
this.enterStartStates(instance, (starts ??= startStates(this.project)));
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
i.fire('OnStart', ctx);
|
||
i.fire('OnEnable', ctx);
|
||
},
|
||
0,
|
||
);
|
||
}
|
||
this.flushStateChanges();
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
|
||
private deliver(change: StateChange): 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 fire = (type: string, state: string) => {
|
||
for (const instance of targets)
|
||
this.run(instance, (i, ctx) => i.fireStateEvent(type, ctx, change.machineId, state), 0);
|
||
};
|
||
fire('OnStateExited', change.from);
|
||
fire('OnStateEntered', change.to);
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
for (const instance of gone) {
|
||
this.finish(instance);
|
||
// 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.
|
||
this.audio.stopOwner(instance.entity.id);
|
||
}
|
||
}
|
||
|
||
private finish(instance: Instance): void {
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
if (instance.active) i.fire('OnDisable', ctx);
|
||
instance.active = false;
|
||
i.fire('OnDestroy', ctx);
|
||
},
|
||
0,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 it just crossed.
|
||
*/
|
||
private switched(instance: Instance): boolean {
|
||
const on = isHandleEnabled(instance.entity) && this.shouldRun(instance.entity);
|
||
if (on !== instance.active) {
|
||
instance.active = on;
|
||
this.run(instance, (i, ctx) => i.fire(on ? 'OnEnable' : 'OnDisable', ctx), 0);
|
||
}
|
||
return on;
|
||
}
|
||
|
||
update(world: World, dt: number): void {
|
||
this.syncInstances(world);
|
||
const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? ''));
|
||
const frozen = world.frozen;
|
||
for (const instance of this.instances) {
|
||
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
||
if (frozen && !runsWhilePaused(instance.entity.props)) continue;
|
||
if (!this.switched(instance)) continue;
|
||
this.run(
|
||
instance,
|
||
(i, ctx) => {
|
||
for (const node of i.eventNodes('OnKeyDown')) this.fireKeyDown(i, ctx, node, dt);
|
||
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
|
||
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
|
||
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;
|
||
i.fire('OnTimer', ctx, { late: state.elapsed }, (n) => n.id === node.id);
|
||
}
|
||
}
|
||
// Neither system ticked in a paused frame: what they hold is last frame's, already told.
|
||
if (!frozen) {
|
||
this.fireCollisions(instance, i, ctx, dt);
|
||
this.fireAnimationsFinished(instance, i, ctx);
|
||
}
|
||
// 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 pointer = world.pointer;
|
||
const owner = instance.entity.parent;
|
||
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
|
||
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
|
||
}
|
||
i.fire('OnUpdate', ctx, { dt });
|
||
},
|
||
dt,
|
||
);
|
||
// After this instance's frame, 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();
|
||
}
|
||
this.reap(world);
|
||
this.flushStateChanges();
|
||
this.persist();
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void {
|
||
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: this.input.justPressed.has(key),
|
||
down: this.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);
|
||
for (let fired = 0; fired < fires; fired++) i.fire('OnKeyDown', ctx, {}, (n) => n.id === node.id);
|
||
}
|
||
|
||
/**
|
||
* Collision events for this behavior: every pair in which one collider belongs to the
|
||
* behavior's body (its owner, or itself when it has none) fires, with `other` being the body on
|
||
* the far side – enter and exit once, stay 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.
|
||
*/
|
||
private fireCollisions(
|
||
instance: Instance,
|
||
interpreter: GraphInterpreter,
|
||
ctx: NodeContext,
|
||
dt: number,
|
||
): void {
|
||
const collisions = this.collisions;
|
||
if (!collisions) return;
|
||
const body = instance.entity.parent ?? instance.entity;
|
||
const mine = (pairs: CollisionPair[]) =>
|
||
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 }] : [];
|
||
});
|
||
const outputsOf = ({ own, other, pair }: Side, contact?: Partial<Contact> | null) => {
|
||
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
||
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;
|
||
};
|
||
for (const side of mine(collisions.enters))
|
||
interpreter.fire('OnCollisionEnter', ctx, outputsOf(side, side.pair));
|
||
const stays = mine(collisions.stays);
|
||
const contacts = new Map<CollisionPair, Contact | null>();
|
||
const contactOf = (pair: CollisionPair) => {
|
||
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
|
||
return contacts.get(pair)!;
|
||
};
|
||
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<string, number> = {};
|
||
for (const side of stays) {
|
||
let fires = 1;
|
||
if (interval > 0) {
|
||
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
||
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++)
|
||
interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node);
|
||
}
|
||
if (interval > 0) interpreter.nodeState.set(node.id, running);
|
||
else interpreter.nodeState.delete(node.id);
|
||
}
|
||
for (const side of mine(collisions.exits)) interpreter.fire('OnCollisionExit', ctx, outputsOf(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.
|
||
*/
|
||
private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
|
||
const owner = instance.entity.parent;
|
||
for (const { entity, animationId } of this.animations?.finished ?? []) {
|
||
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue;
|
||
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => {
|
||
const wanted = textOf(node.params.animation ?? '');
|
||
return wanted === '' || wanted === animationId;
|
||
});
|
||
}
|
||
}
|
||
|
||
runningIds(): string[] {
|
||
return this.instances.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) {
|
||
if (instance.entity.assetId !== 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;
|
||
}
|
||
|
||
/**
|
||
* What the graph asked for, as the sink understands it: the asset's own volume scaled by the
|
||
* node's, and its loop flag unless the node overrules it either way. The asset is what a sound
|
||
* *is* – a node that says nothing therefore plays it as the project describes it.
|
||
*/
|
||
private playSound(assetId: string, volume: number, loop: boolean | undefined, owner: string): void {
|
||
const asset = findAssetOf(this.project, assetId, 'sound');
|
||
if (!asset?.file) return;
|
||
const props = resolveAssetProps(this.project, asset);
|
||
const level = Math.max(0, Math.min(1, volumeOf(props) * volume));
|
||
this.audio.play(asset.file, level, loop ?? loopsOf(props), owner);
|
||
}
|
||
|
||
/** Runs graph code for one instance; an exception stops play and is logged, never thrown. */
|
||
private run(
|
||
instance: Instance,
|
||
body: (interpreter: GraphInterpreter, ctx: NodeContext) => void,
|
||
dt: number,
|
||
): void {
|
||
const world = this.world;
|
||
if (!world) return;
|
||
const ctx = this.context(instance, world, dt);
|
||
try {
|
||
body(instance.interpreter, ctx);
|
||
} catch (error) {
|
||
world.log.push(
|
||
`${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
world.stopRequested = true;
|
||
}
|
||
}
|
||
|
||
private context(instance: Instance, world: World, dt: number): NodeContext {
|
||
const { entity, states } = instance;
|
||
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
||
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
||
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
||
return {
|
||
self: entity,
|
||
owner: entity.parent,
|
||
variables: entity.props,
|
||
states,
|
||
shared: this.sharedState,
|
||
stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }),
|
||
time: world.time,
|
||
dt,
|
||
findByName: (name) => world.findByName(name) ?? null,
|
||
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
||
isAlive: (target) => world.entities.some((e) => e === target),
|
||
childByName: (parent, name) => {
|
||
const found = resolve(parent);
|
||
return (found && world.childByName(found, name)) ?? null;
|
||
},
|
||
isKeyDown: (key) => this.input.isDown(key),
|
||
pointer: world.pointer,
|
||
hitTest: (target, x, y) => {
|
||
const found = resolve(target);
|
||
return Boolean(found && world.hitTest(found, x, y));
|
||
},
|
||
isTouching: (a, b) => {
|
||
const ea = resolve(a);
|
||
const eb = resolve(b);
|
||
return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb));
|
||
},
|
||
moveAndSlide: (target, dx, dy) => {
|
||
const found = resolve(target);
|
||
if (!found) return NOT_BLOCKED;
|
||
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
|
||
found.x += dx;
|
||
found.y += dy;
|
||
return NOT_BLOCKED;
|
||
},
|
||
raycast: (from, direction, length, { ignore, triggers, layers }) => {
|
||
const hit = this.collisions?.raycast(world, from, direction, length, {
|
||
ignore: resolve(ignore) ?? null,
|
||
triggers,
|
||
layers,
|
||
});
|
||
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
|
||
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
|
||
},
|
||
raycastAll: (from, direction, length, { ignore, triggers, layers, maxHits }) => {
|
||
const hits =
|
||
this.collisions?.raycastAll(
|
||
world,
|
||
from,
|
||
direction,
|
||
length,
|
||
{ ignore: resolve(ignore) ?? null, triggers, layers },
|
||
maxHits,
|
||
) ?? [];
|
||
// Drawn to the last thing it met, not to the first: that is how far this ray looked.
|
||
const last = hits[hits.length - 1];
|
||
const end = last ? last.point : pointAlong(from, direction, length);
|
||
world.noteRay(from, end, hits.length > 0);
|
||
return hits.map((hit) => ({ ...hit, object: bodyOf(hit.collider) }));
|
||
},
|
||
ignoreOneWay: (target, seconds) => {
|
||
const found = resolve(target);
|
||
if (found) world.ignoreOneWay(found, seconds);
|
||
},
|
||
isTilemap: (target) => {
|
||
const found = resolve(target);
|
||
return found ? world.isTilemap(found) : false;
|
||
},
|
||
tileAt: (target, layer, cell) => {
|
||
const found = resolve(target);
|
||
return found ? world.tileAt(found, layer, cell) : null;
|
||
},
|
||
setTile: (target, layer, cell, tileId) => {
|
||
const found = resolve(target);
|
||
return found ? world.setTile(found, layer, cell, tileId) : false;
|
||
},
|
||
physics:
|
||
this.collisions instanceof PhysicsSystem
|
||
? physicsControl(world, this.collisions, resolve)
|
||
: undefined,
|
||
log: (message) => {
|
||
world.log.push(message);
|
||
// The `Log` node's whole purpose: the author wants this in the browser console.
|
||
// eslint-disable-next-line no-console
|
||
console.log(`[behavior ${entity.name}]`, message);
|
||
},
|
||
playSound: (assetId, volume, loop, detached) =>
|
||
this.playSound(assetId, volume, loop, detached ? '' : entity.id),
|
||
// An id nothing resolves stops nothing, rather than silencing the whole run by accident;
|
||
// only the empty id means "everything", which is what the node promises.
|
||
stopSound: (assetId) => {
|
||
const file = assetId === '' ? '' : soundFileOf(this.project, assetId);
|
||
if (assetId === '' || file) this.audio.stop(file);
|
||
},
|
||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||
destroy: (target, keepChildren) => {
|
||
const found = resolve(target);
|
||
if (!found) return [];
|
||
if (keepChildren) return world.removeKeepingChildren(found);
|
||
world.remove(found);
|
||
return [];
|
||
},
|
||
loadScene: (sceneId) => {
|
||
// An id naming no scene is dropped here rather than half way through the swap, where the
|
||
// run would already have torn down the world it is still standing in.
|
||
if (findScene(this.project, sceneId)) world.loadSceneRequested = sceneId;
|
||
else world.log.push(`Load scene: no scene ${sceneId}`);
|
||
},
|
||
stop: () => {
|
||
world.stopRequested = true;
|
||
},
|
||
restart: () => {
|
||
world.restartRequested = true;
|
||
},
|
||
timeScale: () => this.clock?.scale ?? 1,
|
||
setTimeScale: (scale) => {
|
||
if (this.clock) this.clock.scale = scale;
|
||
else world.log.push(`Set time scale is ignored in the editor run (${scale})`);
|
||
},
|
||
isPaused: () => world.paused,
|
||
setPaused: (paused) => {
|
||
world.paused = paused;
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
||
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
||
const d = Math.hypot(direction.x, direction.y);
|
||
if (!(d > 0) || !(length > 0)) return from;
|
||
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
|
||
}
|
||
|
||
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
|
||
function physicsControl(
|
||
world: World,
|
||
physics: PhysicsSystem,
|
||
resolve: (handle: EntityHandle | null) => Entity | undefined,
|
||
): PhysicsControl {
|
||
const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => {
|
||
const found = resolve(handle);
|
||
if (!found) return false;
|
||
if (action(found)) return true;
|
||
physics.hint(world, found, NO_DYNAMIC_BODY);
|
||
return false;
|
||
};
|
||
return {
|
||
velocityOf: (handle) => {
|
||
const found = resolve(handle);
|
||
const v = found ? physics.velocityOf(found) : null;
|
||
return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null;
|
||
},
|
||
setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })),
|
||
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
|
||
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
|
||
setGravity: (x, y) => physics.setGravity(world, { x, y }),
|
||
};
|
||
}
|