From 6141d07d4f2ea7ffd1535f6544617f45a76ca7e1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 21:21:35 +0200 Subject: [PATCH] Behavior lifecycle: On enable, On disable and On destroy events (#42) Order per instance: On start -> On enable -> On update ... -> On disable -> On destroy. On enable fires right after On start and whenever the behavior comes back on (itself and its parents); On disable in the frame it goes off, the instance stays frozen. On destroy fires when the entity leaves the world (Destroy node, child of a destroyed object) and when the world is torn down on stop/restart, preceded by On disable if still on. A new On start node added while playing still does not fire for a running instance (kept as is, documented in the wiki). Co-Authored-By: Claude Fable 5.1 --- src/core/graph/nodes/events/OnDestroy.ts | 12 +++ src/core/graph/nodes/events/OnDisable.ts | 12 +++ src/core/graph/nodes/events/OnEnable.ts | 12 +++ src/core/graph/nodes/index.ts | 6 ++ src/runtime/systems/BehaviorSystem.ts | 56 +++++++++++-- tests/core/behavior-events.test.ts | 102 +++++++++++++++++++++++ tests/core/node-catalog.test.ts | 3 + tests/helpers.ts | 8 +- 8 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/core/graph/nodes/events/OnDestroy.ts create mode 100644 src/core/graph/nodes/events/OnDisable.ts create mode 100644 src/core/graph/nodes/events/OnEnable.ts diff --git a/src/core/graph/nodes/events/OnDestroy.ts b/src/core/graph/nodes/events/OnDestroy.ts new file mode 100644 index 00000000..ff0674c1 --- /dev/null +++ b/src/core/graph/nodes/events/OnDestroy.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const OnDestroy: NodeDef = { + type: 'OnDestroy', + label: 'On destroy', + category: 'event', + description: 'Runs once when the object is destroyed – also when the game stops or restarts.', + inputs: [], + outputs: [exec()], + params: [], +}; diff --git a/src/core/graph/nodes/events/OnDisable.ts b/src/core/graph/nodes/events/OnDisable.ts new file mode 100644 index 00000000..9df924e0 --- /dev/null +++ b/src/core/graph/nodes/events/OnDisable.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const OnDisable: NodeDef = { + type: 'OnDisable', + label: 'On disable', + category: 'event', + description: 'Runs when the behavior is switched off (itself or a parent), and before On destroy.', + inputs: [], + outputs: [exec()], + params: [], +}; diff --git a/src/core/graph/nodes/events/OnEnable.ts b/src/core/graph/nodes/events/OnEnable.ts new file mode 100644 index 00000000..54764dd5 --- /dev/null +++ b/src/core/graph/nodes/events/OnEnable.ts @@ -0,0 +1,12 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec } from '../shared'; + +export const OnEnable: NodeDef = { + type: 'OnEnable', + label: 'On enable', + category: 'event', + description: 'Runs right after On start, and again each time the behavior is switched back on.', + inputs: [], + outputs: [exec()], + params: [], +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index bb1e6bb4..f55750ad 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -71,6 +71,9 @@ import { OnClick } from './events/OnClick'; import { OnCollisionEnter } from './events/OnCollisionEnter'; import { OnCollisionExit } from './events/OnCollisionExit'; import { OnCollisionStay } from './events/OnCollisionStay'; +import { OnDestroy } from './events/OnDestroy'; +import { OnDisable } from './events/OnDisable'; +import { OnEnable } from './events/OnEnable'; import { OnKeyDown } from './events/OnKeyDown'; import { OnKeyHeld } from './events/OnKeyHeld'; import { OnKeyUp } from './events/OnKeyUp'; @@ -94,7 +97,10 @@ import { Variables } from './variable/Variables'; */ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnStart, + OnEnable, OnUpdate, + OnDisable, + OnDestroy, OnKeyDown, OnKeyUp, OnKeyHeld, diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 465dcfd4..167b8b43 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -43,6 +43,8 @@ interface Instance { 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; } /** @@ -104,7 +106,9 @@ export class BehaviorSystem implements System { 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.instances = []; this.world = null; } @@ -114,7 +118,7 @@ export class BehaviorSystem implements System { * switched on, which is what lets a hidden screen wake up when shown. */ private syncInstances(world: World): void { - this.instances = this.instances.filter((i) => world.entities.includes(i.entity)); + this.reap(world); for (const entity of world.entities) { if ( entity.type !== 'behavior' || @@ -137,19 +141,58 @@ export class BehaviorSystem implements System { byNode.set(id, { time: world.time, values: produced }); }, } satisfies TraceListener; - const instance: Instance = { entity, interpreter, trace, values, states: {} }; + const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true }; this.instances.push(instance); - this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0); + this.run( + instance, + (i, ctx) => { + i.fire('OnStart', ctx); + i.fire('OnEnable', ctx); + }, + 0, + ); } } + /** 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); + } + + 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 }) => textOf(node.params.key ?? ''); for (const instance of this.instances) { - if (!world.entities.includes(instance.entity)) continue; // destroyed during play - if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent) - if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running + if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below + if (!this.switched(instance)) continue; this.run( instance, (i, ctx) => { @@ -180,6 +223,7 @@ export class BehaviorSystem implements System { dt, ); } + this.reap(world); } /** diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index 764d80e1..f55d3650 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -323,3 +323,105 @@ describe('a switched-off behavior receives no events', () => { expect(world.log).toEqual(['down', 'tick', 'tick']); }); }); + +/** Every lifecycle event logs its own name, so `world.log` reads as the order they fired in. */ +function lifecycle(): ReturnType { + const f = scene(); + for (const event of ['OnStart', 'OnEnable', 'OnUpdate', 'OnDisable', 'OnDestroy']) + logOn(f, event, {}, event.slice(2).toLowerCase()); + return f; +} + +describe('lifecycle events', () => { + it('fires On start, then On enable, then the first On update', () => { + const { world, frame } = startWorld(lifecycle(), { behaviors: true }); + frame(); + expect(world.log).toEqual(['start', 'enable', 'update']); + }); + + it('fires On disable when switched off and On enable – not On start – when switched back on', () => { + const f = lifecycle(); + const { world, frame } = startWorld(f, { behaviors: true }); + const logic = world.entities.find((e) => e.name === 'Logic')!; + frame(); + + logic.props.enabled = false; + frame(); + frame(); + expect(world.log).toEqual(['start', 'enable', 'update', 'disable']); + + logic.props.enabled = true; + frame(); + expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'enable', 'update']); + }); + + it('treats a parent being switched off the same way', () => { + const f = lifecycle(); + const { world, frame } = startWorld(f, { behaviors: true }); + const box = world.findByObjectId(f.box)!; + frame(); + + box.props.enabled = false; + frame(); + expect(world.log).toEqual(['start', 'enable', 'update', 'disable']); + box.props.enabled = true; + frame(); + expect(world.log.slice(4)).toEqual(['enable', 'update']); + }); + + it('fires On disable then On destroy when the owner is destroyed, in the same frame', () => { + const f = lifecycle(); + // `On key down → Destroy(Owner)`: the behavior destroys the box it hangs below, itself included. + const edit = graphEditor(f, f.behavior); + const key = edit.addNode('OnKeyDown', { key: 'x' }); + const owner = edit.addNode('Owner'); + const destroy = edit.addNode('Destroy'); + edit.wire([ + [key, 'exec', destroy, 'exec'], + [owner, 'object', destroy, 'object'], + ]); + const { world, input, frame, behaviors } = startWorld(f, { behaviors: true }); + frame(); + expect(behaviors!.runningIds()).toHaveLength(1); + + input.press('x'); + frame(); + // The frame that destroys it still runs to its end – the farewell comes after this frame's update. + expect(world.log).toEqual(['start', 'enable', 'update', 'update', 'disable', 'destroy']); + expect(behaviors!.runningIds()).toEqual([]); + expect(world.entities.some((e) => e.name === 'Logic')).toBe(false); + frame(); + expect(world.log).toHaveLength(6); + }); + + it('skips On disable for a behavior destroyed while it was off', () => { + const f = lifecycle(); + const { world, frame } = startWorld(f, { behaviors: true }); + const logic = world.entities.find((e) => e.name === 'Logic')!; + logic.props.enabled = false; + frame(); + world.remove(logic); + frame(); + expect(world.log).toEqual(['start', 'enable', 'disable', 'destroy']); + }); + + it('runs On disable and On destroy for every instance when the world is torn down', () => { + const f = lifecycle(); + place(f, f.behavior.id, { x: 0, y: 0 }); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + world.destroy(); + expect(world.log).toEqual([ + 'start', + 'enable', + 'start', + 'enable', + 'update', + 'update', + 'disable', + 'destroy', + 'disable', + 'destroy', + ]); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d3bcc885..358e4e1a 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -677,7 +677,10 @@ function eqNamesakes(): Record { const EVENT_NODES: Record = { BehaviorStart: 'nested-behavior.test.ts', OnStart: 'graph.test.ts', + OnEnable: 'behavior-events.test.ts', OnUpdate: 'graph.test.ts', + OnDisable: 'behavior-events.test.ts', + OnDestroy: 'behavior-events.test.ts', OnKeyDown: 'behavior-events.test.ts', OnKeyUp: 'behavior-events.test.ts', OnKeyHeld: 'behavior-events.test.ts', diff --git a/tests/helpers.ts b/tests/helpers.ts index c343021d..790a6203 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -239,6 +239,7 @@ export interface WorldOptions { export interface StartedWorld { world: World; collisions: CollisionSystem | null; + behaviors: BehaviorSystem | null; /** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */ input: Input; /** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */ @@ -258,11 +259,14 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S const animations = options.animations ? new AnimationSystem() : null; if (collisions) world.addSystem(collisions); if (animations) world.addSystem(animations); - if (options.behaviors) - world.addSystem(new BehaviorSystem(fixture.project, input, { collisions, animations })); + const behaviors = options.behaviors + ? new BehaviorSystem(fixture.project, input, { collisions, animations }) + : null; + if (behaviors) world.addSystem(behaviors); return { world, collisions, + behaviors, input, frame(dt = 1 / 60) { world.tick(dt);