Behavior lifecycle: On enable, On disable and On destroy events (#42)
game-maker / suite (push) Successful in 7m29s
game-maker / suite (push) Successful in 7m29s
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0673d1957
commit
6141d07d4f
8 files changed
+203
-8
No files matched your search
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -71,6 +71,9 @@ import { OnClick } from './events/OnClick';
|
|||||||
import { OnCollisionEnter } from './events/OnCollisionEnter';
|
import { OnCollisionEnter } from './events/OnCollisionEnter';
|
||||||
import { OnCollisionExit } from './events/OnCollisionExit';
|
import { OnCollisionExit } from './events/OnCollisionExit';
|
||||||
import { OnCollisionStay } from './events/OnCollisionStay';
|
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 { OnKeyDown } from './events/OnKeyDown';
|
||||||
import { OnKeyHeld } from './events/OnKeyHeld';
|
import { OnKeyHeld } from './events/OnKeyHeld';
|
||||||
import { OnKeyUp } from './events/OnKeyUp';
|
import { OnKeyUp } from './events/OnKeyUp';
|
||||||
@@ -94,7 +97,10 @@ import { Variables } from './variable/Variables';
|
|||||||
*/
|
*/
|
||||||
export const BUILT_IN_NODES: readonly NodeDef[] = [
|
export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||||
OnStart,
|
OnStart,
|
||||||
|
OnEnable,
|
||||||
OnUpdate,
|
OnUpdate,
|
||||||
|
OnDisable,
|
||||||
|
OnDestroy,
|
||||||
OnKeyDown,
|
OnKeyDown,
|
||||||
OnKeyUp,
|
OnKeyUp,
|
||||||
OnKeyHeld,
|
OnKeyHeld,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface Instance {
|
|||||||
values: Map<Graph, Map<string, Produced>>;
|
values: Map<Graph, Map<string, Produced>>;
|
||||||
/** Current state per state machine – this instance's alone, like its variables. */
|
/** Current state per state machine – this instance's alone, like its variables. */
|
||||||
states: Record<string, string>;
|
states: Record<string, string>;
|
||||||
|
/** 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);
|
this.syncInstances(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tearing the world down (stop, restart) destroys every object, so each instance hears it. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
for (const instance of this.instances) this.finish(instance);
|
||||||
this.instances = [];
|
this.instances = [];
|
||||||
this.world = null;
|
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.
|
* switched on, which is what lets a hidden screen wake up when shown.
|
||||||
*/
|
*/
|
||||||
private syncInstances(world: World): void {
|
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) {
|
for (const entity of world.entities) {
|
||||||
if (
|
if (
|
||||||
entity.type !== 'behavior' ||
|
entity.type !== 'behavior' ||
|
||||||
@@ -137,19 +141,58 @@ export class BehaviorSystem implements System {
|
|||||||
byNode.set(id, { time: world.time, values: produced });
|
byNode.set(id, { time: world.time, values: produced });
|
||||||
},
|
},
|
||||||
} satisfies TraceListener;
|
} 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.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 {
|
update(world: World, dt: number): void {
|
||||||
this.syncInstances(world);
|
this.syncInstances(world);
|
||||||
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? '');
|
||||||
for (const instance of this.instances) {
|
for (const instance of this.instances) {
|
||||||
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
||||||
if (!isHandleEnabled(instance.entity)) continue; // switched off (itself or a parent)
|
if (!this.switched(instance)) continue;
|
||||||
if (!this.shouldRun(instance.entity)) continue; // "run in editor" taken back while running
|
|
||||||
this.run(
|
this.run(
|
||||||
instance,
|
instance,
|
||||||
(i, ctx) => {
|
(i, ctx) => {
|
||||||
@@ -180,6 +223,7 @@ export class BehaviorSystem implements System {
|
|||||||
dt,
|
dt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.reap(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -323,3 +323,105 @@ describe('a switched-off behavior receives no events', () => {
|
|||||||
expect(world.log).toEqual(['down', 'tick', 'tick']);
|
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<typeof scene> {
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -677,7 +677,10 @@ function eqNamesakes(): Record<string, Value> {
|
|||||||
const EVENT_NODES: Record<string, string> = {
|
const EVENT_NODES: Record<string, string> = {
|
||||||
BehaviorStart: 'nested-behavior.test.ts',
|
BehaviorStart: 'nested-behavior.test.ts',
|
||||||
OnStart: 'graph.test.ts',
|
OnStart: 'graph.test.ts',
|
||||||
|
OnEnable: 'behavior-events.test.ts',
|
||||||
OnUpdate: 'graph.test.ts',
|
OnUpdate: 'graph.test.ts',
|
||||||
|
OnDisable: 'behavior-events.test.ts',
|
||||||
|
OnDestroy: 'behavior-events.test.ts',
|
||||||
OnKeyDown: 'behavior-events.test.ts',
|
OnKeyDown: 'behavior-events.test.ts',
|
||||||
OnKeyUp: 'behavior-events.test.ts',
|
OnKeyUp: 'behavior-events.test.ts',
|
||||||
OnKeyHeld: 'behavior-events.test.ts',
|
OnKeyHeld: 'behavior-events.test.ts',
|
||||||
|
|||||||
+6
-2
@@ -239,6 +239,7 @@ export interface WorldOptions {
|
|||||||
export interface StartedWorld {
|
export interface StartedWorld {
|
||||||
world: World;
|
world: World;
|
||||||
collisions: CollisionSystem | null;
|
collisions: CollisionSystem | null;
|
||||||
|
behaviors: BehaviorSystem | null;
|
||||||
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
|
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
|
||||||
input: Input;
|
input: Input;
|
||||||
/** One tick plus the end-of-frame bookkeeping the editor's play loop performs. */
|
/** 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;
|
const animations = options.animations ? new AnimationSystem() : null;
|
||||||
if (collisions) world.addSystem(collisions);
|
if (collisions) world.addSystem(collisions);
|
||||||
if (animations) world.addSystem(animations);
|
if (animations) world.addSystem(animations);
|
||||||
if (options.behaviors)
|
const behaviors = options.behaviors
|
||||||
world.addSystem(new BehaviorSystem(fixture.project, input, { collisions, animations }));
|
? new BehaviorSystem(fixture.project, input, { collisions, animations })
|
||||||
|
: null;
|
||||||
|
if (behaviors) world.addSystem(behaviors);
|
||||||
return {
|
return {
|
||||||
world,
|
world,
|
||||||
collisions,
|
collisions,
|
||||||
|
behaviors,
|
||||||
input,
|
input,
|
||||||
frame(dt = 1 / 60) {
|
frame(dt = 1 / 60) {
|
||||||
world.tick(dt);
|
world.tick(dt);
|
||||||
|
|||||||
Reference in new issue
Block a user