diff --git a/README.md b/README.md index 718ad416..83cff607 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1549 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1551 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 65ba8843..90eccedb 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -108,32 +108,33 @@ export class GraphInterpreter { let fired = 0; for (const node of this.eventNodes(type)) { if (filter && !filter(node)) continue; - this.frame = emptyFrame(this.graph); - this.callStack = []; - this.callSites = []; - this.steps = 0; - this.frame.outputCache.set(node.id, { ...outputs }); - this.trace?.onNode(node.id); - this.record(node.id, outputs); - this.follow(node, port, ctx, 0); + this.pulse(node, ctx, outputs, port); fired++; } return fired; } + /** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */ + pulse(node: GraphNode, ctx: NodeContext, outputs: Record = {}, port = 'exec'): void { + this.frame = emptyFrame(this.graph); + this.callStack = []; + this.callSites = []; + this.steps = 0; + this.frame.outputCache.set(node.id, { ...outputs }); + this.trace?.onNode(node.id); + this.record(node.id, outputs); + this.follow(node, port, ctx, 0); + } + /** - * `On state entered` / `On state exited` for one move: only the nodes watching that machine, and - * out of the one output the state stands for. A node whose machine input carries a running value - * rather than a pick has no state outputs at all, so it is never among them. + * The `On state entered` / `On state exited` nodes watching one machine. A node whose machine + * input carries a running value rather than a pick has no state outputs at all, so it is never + * among them. */ - fireStateEvent(type: string, ctx: NodeContext, machineId: string, stateId: string): number { - if (!machineId || !stateId) return 0; - return this.fire( - type, - ctx, - {}, + stateEventNodes(type: string, machineId: string): GraphNode[] { + if (!machineId) return []; + return this.eventNodes(type).filter( (node) => machineIdOf({ node, graph: this.graph, lookup: this.lookup }) === machineId, - stateId, ); } diff --git a/src/core/graph/eventOrder.ts b/src/core/graph/eventOrder.ts new file mode 100644 index 00000000..b56b30d8 --- /dev/null +++ b/src/core/graph/eventOrder.ts @@ -0,0 +1,55 @@ +import type { PropertyBag } from '../model/Properties'; +import type { PropertyDef } from '../properties/PropertySchema'; +import type { GraphNode } from './Graph'; +import { asNumber } from './values'; + +/* + * When event nodes run within a frame (#264). The frame goes event kind by event kind – every key + * event of every behavior, then every timer, …, every On update last – and within one kind by the + * nodes' `order`: lower first. Equal orders keep the hierarchy, then the order the nodes were made. + */ + +/** The setting every event node carries; a node saved without one is `0`. */ +export const EVENT_ORDER = 'order'; + +export const orderParam: PropertyDef = { + key: EVENT_ORDER, + label: 'Order', + kind: 'number', + default: 0, + step: 1, + decimals: 0, +}; + +export function eventOrder(node: GraphNode): number { + const order = Math.round(asNumber(node.params[EVENT_ORDER] ?? 0)); + return Number.isFinite(order) ? order : 0; +} + +/** The order row says nothing at `0`, so the node body leaves it off like any setting at rest. */ +export function hiddenOrder(params: PropertyBag): string[] { + return Math.round(asNumber(params[EVENT_ORDER] ?? 0)) === 0 ? [EVENT_ORDER] : []; +} + +/** + * The pulses of one event kind in the order they run. They arrive in hierarchy order, and within one + * object in node order, so a stable sort by `order` is all the rule there is. + */ +export function inEventOrder(pulses: readonly T[]): T[] { + return pulses + .map((pulse) => ({ pulse, order: eventOrder(pulse.node) })) + .sort((a, b) => a.order - b.order) + .map(({ pulse }) => pulse); +} + +/** + * Where each object stands top to bottom: by the hierarchy's order for those it lists, the rest after + * them in the order they come. + */ +export function hierarchyRanks( + items: readonly T[], + treeIds: readonly string[], +): Map { + const place = new Map(treeIds.map((id, index) => [id, index])); + return new Map(items.map((item, index) => [item, place.get(item.objectId) ?? treeIds.length + index])); +} diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 1394adf1..89a83ec0 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -2,6 +2,7 @@ import './nodes'; export * from './dropTarget'; +export * from './eventOrder'; export * from './Graph'; export * from './lookup'; export * from './findBlock'; diff --git a/src/core/graph/nodes/events/OnAnimationFinished.ts b/src/core/graph/nodes/events/OnAnimationFinished.ts index 28309975..c2fd9cbf 100644 --- a/src/core/graph/nodes/events/OnAnimationFinished.ts +++ b/src/core/graph/nodes/events/OnAnimationFinished.ts @@ -1,5 +1,6 @@ import { textOf } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, obj } from '../shared'; export const OnAnimationFinished: NodeDef = { @@ -10,6 +11,12 @@ export const OnAnimationFinished: NodeDef = { 'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.', inputs: [], outputs: [exec(), obj('object')], - params: [{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }], - hiddenParams: (params) => (textOf(params.animation ?? '') ? [] : ['animation']), + params: [ + { key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }, + orderParam, + ], + hiddenParams: (params) => [ + ...(textOf(params.animation ?? '') ? [] : ['animation']), + ...hiddenOrder(params), + ], }; diff --git a/src/core/graph/nodes/events/OnClick.ts b/src/core/graph/nodes/events/OnClick.ts index 3cb92063..9664f3e3 100644 --- a/src/core/graph/nodes/events/OnClick.ts +++ b/src/core/graph/nodes/events/OnClick.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, num } from '../shared'; export const OnClick: NodeDef = { @@ -10,5 +11,6 @@ export const OnClick: NodeDef = { 'Without an owner it runs on a click anywhere in the game view.', inputs: [], outputs: [exec(), num('x', 'world x'), num('y', 'world y')], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnCollisionEnter.ts b/src/core/graph/nodes/events/OnCollisionEnter.ts index 090a28ff..3187980f 100644 --- a/src/core/graph/nodes/events/OnCollisionEnter.ts +++ b/src/core/graph/nodes/events/OnCollisionEnter.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { collisionOutputs, contactOutputs } from '../shared'; export const OnCollisionEnter: NodeDef = { @@ -11,5 +12,6 @@ export const OnCollisionEnter: NodeDef = { 'point x/y where (all 0 for a trigger).', inputs: [], outputs: [...collisionOutputs(), ...contactOutputs()], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnCollisionExit.ts b/src/core/graph/nodes/events/OnCollisionExit.ts index 86bb9c83..a99ef8b3 100644 --- a/src/core/graph/nodes/events/OnCollisionExit.ts +++ b/src/core/graph/nodes/events/OnCollisionExit.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { collisionOutputs } from '../shared'; export const OnCollisionExit: NodeDef = { @@ -8,5 +9,6 @@ export const OnCollisionExit: NodeDef = { description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnCollisionStay.ts b/src/core/graph/nodes/events/OnCollisionStay.ts index d5512954..403642a9 100644 --- a/src/core/graph/nodes/events/OnCollisionStay.ts +++ b/src/core/graph/nodes/events/OnCollisionStay.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { collisionOutputs, contactOutputs } from '../shared'; export const OnCollisionStay: NodeDef = { @@ -20,5 +21,7 @@ export const OnCollisionStay: NodeDef = { min: 0, step: 0.1, }, + orderParam, ], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnDestroy.ts b/src/core/graph/nodes/events/OnDestroy.ts index ff0674c1..f3ee870f 100644 --- a/src/core/graph/nodes/events/OnDestroy.ts +++ b/src/core/graph/nodes/events/OnDestroy.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec } from '../shared'; export const OnDestroy: NodeDef = { @@ -8,5 +9,6 @@ export const OnDestroy: NodeDef = { description: 'Runs once when the object is destroyed – also when the game stops or restarts.', inputs: [], outputs: [exec()], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnDisable.ts b/src/core/graph/nodes/events/OnDisable.ts index 9df924e0..7308735c 100644 --- a/src/core/graph/nodes/events/OnDisable.ts +++ b/src/core/graph/nodes/events/OnDisable.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec } from '../shared'; export const OnDisable: NodeDef = { @@ -8,5 +9,6 @@ export const OnDisable: NodeDef = { description: 'Runs when the behavior is switched off (itself or a parent), and before On destroy.', inputs: [], outputs: [exec()], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnEnable.ts b/src/core/graph/nodes/events/OnEnable.ts index 54764dd5..ed28c29e 100644 --- a/src/core/graph/nodes/events/OnEnable.ts +++ b/src/core/graph/nodes/events/OnEnable.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec } from '../shared'; export const OnEnable: NodeDef = { @@ -8,5 +9,6 @@ export const OnEnable: NodeDef = { description: 'Runs right after On start, and again each time the behavior is switched back on.', inputs: [], outputs: [exec()], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnKeyDown.ts b/src/core/graph/nodes/events/OnKeyDown.ts index e5183c10..3203aa08 100644 --- a/src/core/graph/nodes/events/OnKeyDown.ts +++ b/src/core/graph/nodes/events/OnKeyDown.ts @@ -1,5 +1,6 @@ import type { NodeDef } from '../../NodeRegistry'; import { asNumber } from '../../values'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, keyParam } from '../shared'; export const OnKeyDown: NodeDef = { @@ -31,7 +32,11 @@ export const OnKeyDown: NodeDef = { step: 10, decimals: 0, }, + orderParam, ], // Both rows go together: with no delay there is no repeat, so the interval is not in play either. - hiddenParams: (params) => (asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']), + hiddenParams: (params) => [ + ...(asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']), + ...hiddenOrder(params), + ], }; diff --git a/src/core/graph/nodes/events/OnKeyHeld.ts b/src/core/graph/nodes/events/OnKeyHeld.ts index 2d95d6e0..f1997efa 100644 --- a/src/core/graph/nodes/events/OnKeyHeld.ts +++ b/src/core/graph/nodes/events/OnKeyHeld.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, keyParam, num } from '../shared'; export const OnKeyHeld: NodeDef = { @@ -8,5 +9,6 @@ export const OnKeyHeld: NodeDef = { description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt')], - params: [keyParam], + params: [keyParam, orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnKeyUp.ts b/src/core/graph/nodes/events/OnKeyUp.ts index dd9eed49..4a0c7422 100644 --- a/src/core/graph/nodes/events/OnKeyUp.ts +++ b/src/core/graph/nodes/events/OnKeyUp.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, keyParam } from '../shared'; export const OnKeyUp: NodeDef = { @@ -8,5 +9,6 @@ export const OnKeyUp: NodeDef = { description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], - params: [keyParam], + params: [keyParam, orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnMouseWheel.ts b/src/core/graph/nodes/events/OnMouseWheel.ts index 04cf99d3..2d8774f2 100644 --- a/src/core/graph/nodes/events/OnMouseWheel.ts +++ b/src/core/graph/nodes/events/OnMouseWheel.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, num } from '../shared'; export const OnMouseWheel: NodeDef = { @@ -11,5 +12,6 @@ export const OnMouseWheel: NodeDef = { 'positive downwards; a trackpad gives fractions.', inputs: [], outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnStart.ts b/src/core/graph/nodes/events/OnStart.ts index 85283dc1..0b673114 100644 --- a/src/core/graph/nodes/events/OnStart.ts +++ b/src/core/graph/nodes/events/OnStart.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec } from '../shared'; export const OnStart: NodeDef = { @@ -8,5 +9,6 @@ export const OnStart: NodeDef = { description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnStateEntered.ts b/src/core/graph/nodes/events/OnStateEntered.ts index 5f8e377c..c46d3ff9 100644 --- a/src/core/graph/nodes/events/OnStateEntered.ts +++ b/src/core/graph/nodes/events/OnStateEntered.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; /** @@ -16,6 +17,7 @@ export const OnStateEntered: NodeDef = { 'count; a transition drawn from a state back into itself does.', inputs: [machinePort()], outputs: [], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), }; diff --git a/src/core/graph/nodes/events/OnStateExited.ts b/src/core/graph/nodes/events/OnStateExited.ts index d153e544..3a74c9f6 100644 --- a/src/core/graph/nodes/events/OnStateExited.ts +++ b/src/core/graph/nodes/events/OnStateExited.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { machineOf, machinePort, statePorts } from '../../stateMachine'; /** The other half of the move: the pulse leaves through the state that was left, before `entered`. */ @@ -11,6 +12,7 @@ export const OnStateExited: NodeDef = { 'state at all has nothing to leave, so nothing runs.', inputs: [machinePort()], outputs: [], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }), }; diff --git a/src/core/graph/nodes/events/OnTimer.ts b/src/core/graph/nodes/events/OnTimer.ts index 740a3f8e..cf210917 100644 --- a/src/core/graph/nodes/events/OnTimer.ts +++ b/src/core/graph/nodes/events/OnTimer.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, num } from '../shared'; export const OnTimer: NodeDef = { @@ -9,5 +10,9 @@ export const OnTimer: NodeDef = { 'Runs every N seconds. It can only run on a frame, so late says how far this frame is past the due time.', inputs: [], outputs: [exec(), num('late')], - params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], + params: [ + { key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }, + orderParam, + ], + hiddenParams: hiddenOrder, }; diff --git a/src/core/graph/nodes/events/OnUpdate.ts b/src/core/graph/nodes/events/OnUpdate.ts index 6a1ad5b0..cc18ed62 100644 --- a/src/core/graph/nodes/events/OnUpdate.ts +++ b/src/core/graph/nodes/events/OnUpdate.ts @@ -1,4 +1,5 @@ import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; import { exec, num } from '../shared'; export const OnUpdate: NodeDef = { @@ -8,5 +9,6 @@ export const OnUpdate: NodeDef = { description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt')], - params: [], + params: [orderParam], + hiddenParams: hiddenOrder, }; diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 7cee0d22..fadd0d91 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -115,7 +115,7 @@ export class EditorRun { const world = this.world; if (world && world.project === this.state.project && world.sceneId === scene.id) { world.syncFromModel(); - this.behaviors?.syncDeclarations(); + this.behaviors?.syncFromModel(); return; } this.stop(); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 8b2a3faf..d17127ff 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -110,7 +110,7 @@ export class PlaySession { syncFromModel(): void { this.world?.syncFromModel(); - this.behaviors?.syncDeclarations(); + this.behaviors?.syncFromModel(); } /** The values a Variables asset shares in this run; null when nothing is playing. */ diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 5da6a38c..2c7b5954 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -9,6 +9,8 @@ import { type Graph, type GraphNode, GraphInterpreter, + hierarchyRanks, + inEventOrder, isHandleEnabled, type NodeContext, type PhysicsControl, @@ -25,6 +27,8 @@ 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 { treeOrder } from '../../core/model/hierarchy'; +import { resolveObjects } from '../../core/model/instance'; import { findAssetOf, findScene, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; import { parentChain } from '../../core/model/walk'; @@ -81,6 +85,14 @@ interface Side { other: Entity; } +/** One event node about to run for one instance: what the event hands it, and the output it leaves by. */ +interface Pulse { + instance: Instance; + node: GraphNode; + outputs?: Record; + port?: string; +} + interface Instance { entity: Entity; interpreter: GraphInterpreter; @@ -178,6 +190,8 @@ export class BehaviorSystem implements System { private restarted: ReadonlySet = new Set(); /** Moves whose events are still owed; drained by `flushStateChanges` between exec flows. */ private pending: StateChange[] = []; + /** 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 = {}; private readonly collisions: CollisionSource | null; @@ -215,6 +229,12 @@ export class BehaviorSystem implements System { 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.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); @@ -261,10 +281,7 @@ export class BehaviorSystem implements System { /** 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); - } + this.finish(this.instances); // After the goodbyes: what `On destroy` writes is the last thing the run did. this.persist(); this.instances = []; @@ -293,6 +310,7 @@ export class BehaviorSystem implements System { this.reap(world); // Read once, and only where an instance is actually born: most frames create none at all. let starts: Record | null = null; + const born = new Set(); for (const entity of world.entities) { if ( entity.type !== 'behavior' || @@ -317,19 +335,32 @@ export class BehaviorSystem implements System { } 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, - ); + born.add(instance); } + 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(); } + /** + * 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 @@ -363,21 +394,26 @@ export class BehaviorSystem implements System { } const batch = this.pending; this.pending = []; - for (const change of batch) this.deliver(change); + for (const change of batch) this.deliver(change, world); } } - private deliver(change: StateChange): void { + 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 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); + 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); } /** @@ -402,86 +438,102 @@ export class BehaviorSystem implements System { 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); - } + this.finish(gone); } - 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, + 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. + for (const instance of instances) 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 it just crossed. + * was taken back while running – and fires the edge each instance just crossed. Returns those on. */ - 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; + 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; - 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 }); - } - if (pointer.wheel !== 0 && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) { - i.fire('OnMouseWheel', ctx, { delta: pointer.wheel, 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. + 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 kinds: (() => Pulse[])[] = [ + () => + ticking.flatMap((i) => i.interpreter.eventNodes('OnKeyDown').flatMap((n) => this.keyDown(i, n, dt))), + () => pulsesOf(ticking, 'OnKeyUp', {}, (n) => this.input.justReleased.has(keyOf(n))), + () => pulsesOf(ticking, 'OnKeyHeld', { dt }, (n) => this.input.heldKeys.has(keyOf(n))), + () => ticking.flatMap((i) => this.timers(i, dt)), + // Neither system ticked in a paused frame: what they hold is last frame's, already told. + () => (frozen ? [] : ticking.flatMap((i) => this.collisionEnters(i))), + () => (frozen ? [] : ticking.flatMap((i) => this.collisionStays(i, dt))), + () => (frozen ? [] : ticking.flatMap((i) => this.collisionExits(i))), + () => (frozen ? [] : ticking.flatMap((i) => this.animationsFinished(i))), + () => + 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, + }) + : [], + () => pulsesOf(ticking, 'OnUpdate', { dt }), + ]; + for (const kind of kinds) { + this.runPulses(kind(), contextOf, 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(); } this.reap(world); @@ -493,7 +545,8 @@ export class BehaviorSystem implements System { * 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 { + private keyDown(instance: Instance, node: GraphNode, dt: number): Pulse[] { + const i = instance.interpreter; const key = keyName(textOf(node.params.key ?? '')); const stored = i.nodeState.get(node.id); const { state, fires } = stepKeyRepeat( @@ -509,51 +562,63 @@ export class BehaviorSystem implements System { ); 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); + return Array.from({ length: fires }, () => ({ instance, node })); + } + + private timers(instance: Instance, dt: number): Pulse[] { + const i = instance.interpreter; + const pulses: Pulse[] = []; + for (const node of i.eventNodes('OnTimer')) { + const state = i.nodeState.get(node.id) ?? { elapsed: 0 }; + i.nodeState.set(node.id, state); + const interval = Math.max(0.01, Number(node.params.interval ?? 1)); + state.elapsed = (state.elapsed ?? 0) + dt; + // What is left after taking one interval is how long ago this tick was due. When one frame + // catches up several ticks, the first one was due longest ago and is the latest. + while (state.elapsed >= interval) { + state.elapsed -= interval; + pulses.push({ instance, node, outputs: { late: state.elapsed } }); + } + } + return pulses; } /** - * 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. + * The collision pairs of one kind that concern this behavior: every pair in which one collider + * belongs to the behavior's body (its owner, or itself when it has none), with `other` being the + * body on the far side. */ - private fireCollisions( - instance: Instance, - interpreter: GraphInterpreter, - ctx: NodeContext, - dt: number, - ): void { - const collisions = this.collisions; - if (!collisions) return; + private sides(instance: Instance, pairs: readonly CollisionPair[]): Side[] { 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 | null) => { - const outputs: Record = { 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); + return pairs.flatMap((pair) => { + const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null; + return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : []; + }); + } + + private collisionEnters(instance: Instance): Pulse[] { + const sides = this.sides(instance, this.collisions?.enters ?? []); + return instance.interpreter + .eventNodes('OnCollisionEnter') + .flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side, side.pair) }))); + } + + /** + * Stay fires every frame or, with an interval, once each time that long has passed since the pair + * met (#149). Each stay node keeps a clock per pair in its node state, and a pair that is gone + * takes its clock with it. + */ + private collisionStays(instance: Instance, dt: number): Pulse[] { + const collisions = this.collisions; + if (!collisions) return []; + const interpreter = instance.interpreter; + const stays = this.sides(instance, collisions.stays); const contacts = new Map(); const contactOf = (pair: CollisionPair) => { if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair)); return contacts.get(pair)!; }; + const pulses: Pulse[] = []; for (const node of interpreter.eventNodes('OnCollisionStay')) { const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0; const clocks = interpreter.nodeState.get(node.id) ?? {}; @@ -567,27 +632,36 @@ export class BehaviorSystem implements System { running[key] = elapsed; } for (let i = 0; i < fires; i++) - interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node); + pulses.push({ instance, node, outputs: sideOutputs(side, contactOf(side.pair)) }); } if (interval > 0) interpreter.nodeState.set(node.id, running); else interpreter.nodeState.delete(node.id); } - for (const side of mine(collisions.exits)) interpreter.fire('OnCollisionExit', ctx, outputsOf(side)); + return pulses; + } + + private collisionExits(instance: Instance): Pulse[] { + const sides = this.sides(instance, this.collisions?.exits ?? []); + return instance.interpreter + .eventNodes('OnCollisionExit') + .flatMap((node) => sides.map((side) => ({ instance, node, outputs: sideOutputs(side) }))); } /** * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. */ - private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void { + private animationsFinished(instance: Instance): Pulse[] { 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; - }); - } + const finished = (this.animations?.finished ?? []).filter( + ({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner), + ); + return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => { + const wanted = textOf(node.params.animation ?? ''); + return finished + .filter(({ animationId }) => wanted === '' || wanted === animationId) + .map(({ entity }) => ({ instance, node, outputs: { object: entity } })); + }); } runningIds(): string[] { @@ -631,25 +705,37 @@ export class BehaviorSystem implements System { 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 { + /** + * 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) 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; + if (!world || pulses.length === 0) return; + for (const { instance, node, outputs, port } of inEventOrder(pulses)) { + if (living && !world.entities.includes(instance.entity)) continue; + try { + instance.interpreter.pulse(node, contextOf(instance), outputs, port); + } catch (error) { + world.log.push( + `${ERROR_PREFIX} "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`, + ); + world.stopRequested = true; + } } } + /** 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(); + 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 { const { entity, states } = instance; // Handles arriving from the graph are the entities themselves, or test doubles with the same id. @@ -787,6 +873,36 @@ export class BehaviorSystem implements System { } } +/** Every node of one event type in these instances, in hierarchy and then node order. */ +function pulsesOf( + instances: readonly Instance[], + type: string, + outputs: Record = {}, + filter?: (node: GraphNode) => boolean, +): Pulse[] { + return instances.flatMap((instance) => + instance.interpreter + .eventNodes(type) + .filter((node) => !filter || filter(node)) + .map((node) => ({ instance, node, outputs })), + ); +} + +/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */ +function sideOutputs({ own, other, pair }: Side, contact?: Partial | null): Record { + const outputs: Record = { other: bodyOf(other), otherCollider: other, collider: own }; + 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; +} + /** 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); diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index 85c22e72..cc723afe 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -462,12 +462,12 @@ describe('lifecycle events', () => { 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']); + // Gone for the rest of the frame: no On update after the key event that destroyed it (#264). + expect(world.log).toEqual(['start', 'enable', 'update', 'disable', 'destroy']); expect(behaviors!.runningIds()).toEqual([]); expect(world.entities.some((e) => e.name === 'Logic')).toBe(false); frame(); - expect(world.log).toHaveLength(6); + expect(world.log).toHaveLength(5); }); it('skips On disable for a behavior destroyed while it was off', () => { @@ -487,16 +487,17 @@ describe('lifecycle events', () => { const { world, frame } = startWorld(f, { behaviors: true }); frame(); world.destroy(); + // Event kind by event kind (#264): every start before the first enable, every disable before a destroy. expect(world.log).toEqual([ 'start', - 'enable', 'start', 'enable', + 'enable', 'update', 'update', 'disable', + 'disable', 'destroy', - 'disable', 'destroy', ]); }); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts new file mode 100644 index 00000000..9a7635cf --- /dev/null +++ b/tests/core/event-order.test.ts @@ -0,0 +1,217 @@ +/** + * When event nodes run within a frame (#264): event kind by event kind across every behavior, within a + * kind by the nodes' `order`, equal orders by the hierarchy and then by the order the nodes were made. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + SetMachineScopeCommand, + SetMachineStartCommand, +} from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands'; +import { ReorderObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + BEHAVIOR_START, + EVENT_ORDER, + eventOrder, + type GraphNode, + hiddenOrder, + hierarchyRanks, + inEventOrder, + MACHINE_PORT, + STATE, + STATE_NAME_PARAM, +} from '../../src/core/graph'; +import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { findAssetOf } from '../../src/core/model/Project'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + type SceneFixture, + startWorld, +} from '../helpers'; + +const node = (id: string, order?: unknown): GraphNode => + ({ + id, + type: 'OnUpdate', + x: 0, + y: 0, + params: order === undefined ? {} : { [EVENT_ORDER]: order }, + }) as GraphNode; + +/** Wires ` → Log(message)` into the behavior and returns the event node. */ +function logOn( + f: SceneFixture, + behavior: BehaviorAsset, + event: string, + message: string, + params: Record = {}, +): GraphNode { + const edit = graphEditor(f, behavior); + const fired = edit.addNode(event, params); + edit.connect(fired, 'exec', edit.addNode('Log', { message }), 'exec'); + return fired; +} + +/** Two behaviors placed at the top level, `A` above `B`. */ +function twoBehaviors(): SceneFixture & { + a: BehaviorAsset; + b: BehaviorAsset; + placedA: string; + placedB: string; +} { + const f = makeSpriteScene('Box'); + const a = makeBehavior(f, 'A'); + const b = makeBehavior(f, 'B'); + const placedA = place(f, a.id, { x: 0, y: 0 }); + const placedB = place(f, b.id, { x: 0, y: 0 }); + return { ...f, a, b, placedA, placedB }; +} + +describe('the order rule', () => { + it('reads a missing, broken or fractional order as a whole number, 0 when there is none', () => { + expect(eventOrder(node('n'))).toBe(0); + expect(eventOrder(node('n', 'soon'))).toBe(0); + expect(eventOrder(node('n', -3))).toBe(-3); + expect(eventOrder(node('n', 2.4))).toBe(2); + }); + + it('sorts by order and keeps the arrival order among equals', () => { + const pulses = [ + { node: node('a', 5) }, + { node: node('b') }, + { node: node('c', -1) }, + { node: node('d') }, + { node: node('e', 5) }, + ]; + expect(inEventOrder(pulses).map((p) => p.node.id)).toEqual(['c', 'b', 'd', 'a', 'e']); + }); + + it('ranks by the hierarchy and puts what it does not list after it, in the order given', () => { + const [x, y, z] = [{ objectId: 'x' }, { objectId: 'y' }, { objectId: 'z' }]; + const ranks = hierarchyRanks([x, y, z], ['z', 'x']); + expect([x, y, z].sort((p, q) => ranks.get(p)! - ranks.get(q)!)).toEqual([z, x, y]); + }); + + it('gives every event node the setting, and leaves the row off the node at 0', () => { + const events = BUILT_IN_NODES.filter((def) => def.category === 'event' && def.type !== BEHAVIOR_START); + expect(events).toHaveLength(17); + for (const def of events) { + expect( + def.params.map((p) => p.key), + def.type, + ).toContain(EVENT_ORDER); + expect(def.hiddenParams?.({ [EVENT_ORDER]: 0 }), def.type).toContain(EVENT_ORDER); + expect(def.hiddenParams?.({ [EVENT_ORDER]: 4 }), def.type).not.toContain(EVENT_ORDER); + } + expect(hiddenOrder({})).toEqual([EVENT_ORDER]); + }); +}); + +describe('the frame', () => { + it('runs a lower order first across behaviors, and the hierarchy among equals', () => { + const f = twoBehaviors(); + logOn(f, f.a, 'OnUpdate', 'A'); + const late = logOn(f, f.b, 'OnUpdate', 'B'); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['A', 'B']); + + f.stack.execute(new SetNodeParamCommand(f.b.id, late.id, EVENT_ORDER, -1)); + world.log.length = 0; + frame(); + expect(world.log).toEqual(['B', 'A']); + }); + + it('goes event kind by event kind: a key event of any behavior before every On update', () => { + const f = twoBehaviors(); + logOn(f, f.a, 'OnUpdate', 'A update'); + logOn(f, f.b, 'OnKeyDown', 'B key', { key: 'x' }); + const { world, input, frame } = startWorld(f, { behaviors: true }); + input.press('x'); + frame(); + expect(world.log).toEqual(['B key', 'A update']); + }); + + it('runs two nodes of one graph in the order they were made, unless their order says otherwise', () => { + const f = twoBehaviors(); + logOn(f, f.a, 'OnUpdate', 'first'); + const second = logOn(f, f.a, 'OnUpdate', 'second'); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['first', 'second']); + + f.stack.execute(new SetNodeParamCommand(f.a.id, second.id, EVENT_ORDER, -5)); + world.log.length = 0; + frame(); + expect(world.log).toEqual(['second', 'first']); + }); + + it('orders On start the same way', () => { + const f = twoBehaviors(); + logOn(f, f.a, 'OnStart', 'A'); + logOn(f, f.b, 'OnStart', 'B', { [EVENT_ORDER]: -2 }); + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['B', 'A']); + }); + + it('follows the hierarchy as it is now, also for a behavior switched on later', () => { + const f = twoBehaviors(); + logOn(f, f.a, 'OnUpdate', 'A'); + logOn(f, f.b, 'OnUpdate', 'B'); + f.stack.execute(new SetOverrideCommand(f.sceneId, f.placedA, 'enabled', false)); + const { world, frame, behaviors } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['B']); + + // Switched on after B started: it still runs first, since it stands above B. + world.entities.find((e) => e.objectId === f.placedA)!.props.enabled = true; + world.log.length = 0; + frame(); + expect(world.log).toEqual(['A', 'B']); + + // Moved below B while running: the next frame after the edit reaches the run has it second. + f.stack.execute(new ReorderObjectCommand(f.sceneId, f.placedA, '')); + behaviors!.syncFromModel(); + world.log.length = 0; + frame(); + expect(world.log).toEqual(['B', 'A']); + }); + + it('delivers a state event of a shared machine to its listeners by order', () => { + const f = twoBehaviors(); + const machine = new CreateAssetCommand('statemachine', 'Doors'); + f.stack.execute(machine); + const state = new AddNodeCommand(machine.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: 'Open' }); + f.stack.execute(state); + f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); + f.stack.execute(new SetMachineStartCommand(machine.asset.id, state.node.id)); + for (const [behavior, message, order] of [ + [f.a, 'A', 0], + [f.b, 'B', -1], + ] as const) { + const edit = graphEditor(f, behavior); + const entered = edit.addNode('OnStateEntered', { + [MACHINE_PORT]: machine.asset.id, + [EVENT_ORDER]: order, + }); + edit.connect(entered, state.node.id, edit.addNode('Log', { message }), 'exec'); + } + const { world, frame } = startWorld(f, { behaviors: true }); + frame(); + expect(world.log).toEqual(['B', 'A']); + }); +}); + +it('keeps the order through saving and loading', () => { + const f = twoBehaviors(); + const fired = logOn(f, f.a, 'OnUpdate', 'A', { [EVENT_ORDER]: 7 }); + const loaded = findAssetOf(roundTrip(f.project), f.a.id, 'behavior')!; + expect(eventOrder(loaded.graph.nodes.find((n) => n.id === fired.id)!)).toBe(7); +}); diff --git a/tests/core/key-repeat.test.ts b/tests/core/key-repeat.test.ts index 95ddabee..1c26feae 100644 --- a/tests/core/key-repeat.test.ts +++ b/tests/core/key-repeat.test.ts @@ -57,7 +57,8 @@ describe('key repeat', () => { }); describe('the repeat rows on the node body', () => { - const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.(params); + // With an order set, so the order row (#264) stays out of what these look at. + const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.({ order: 1, ...params }); it('leaves both off while the repeat is switched off', () => { expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']); diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index 20a53d7a..d4d8846e 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -465,8 +465,9 @@ describe('collision events from the engine', () => { seconds(1); // Each side once: its normal, then the impulse. expect(world.log).toHaveLength(4); - const [ballNormal, i1, groundNormal, i2] = world.log.map(Number) as [number, number, number, number]; - // The ball's behavior runs first. The ground pushes the ball up; the ball pushes the ground down. + const [groundNormal, i1, ballNormal, i2] = world.log.map(Number) as [number, number, number, number]; + // The ground's behavior runs first, it stands higher in the hierarchy (#264). The ground pushes + // the ball up; the ball pushes the ground down. expect(ballNormal).toBe(-1); expect(groundNormal).toBe(1); // The ball of 32 hits at about √(2·980·164) ≈ 567 per second, and its mass is π·0.16² ≈ 0.08. diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 9e2665af..6e680b5c 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1105,6 +1105,7 @@ export async function graph(t) { await findMatchNodes(); await splitOutputs(); await pluralLookups(); + await eventOrder(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1655,4 +1656,74 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => !o.id.startsWith('pl-')); `); } + // 6z-viii. Event order (#264): an event node shows no Order row at 0. Typed into the inspector, the + // order lands on the node and on its row, and a run starts the lower order first, even + // when its behavior stands lower in the hierarchy. + async function eventOrder() { + await mutate(` + const p = __gm.state.project; + const behavior = (id, name, message) => ({ + id, + type: 'behavior', + name, + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { + nodes: [ + { id: id + '-start', type: 'OnStart', x: 0, y: 0, params: { order: 0 } }, + { id: id + '-log', type: 'Log', x: 260, y: 0, params: { message } }, + ], + edges: [{ id: id + '-e', from: { node: id + '-start', port: 'exec' }, to: { node: id + '-log', port: 'exec' } }], + }, + }); + p.assets.push(behavior('ord-a', 'Order A', 'A'), behavior('ord-b', 'Order B', 'B')); + const scene = __gm.state.activeScene; + for (const [id, assetId, name] of [['ord-first', 'ord-a', 'Order first'], ['ord-second', 'ord-b', 'Order second']]) { + const o = structuredClone(scene.objects[0]); + Object.assign(o, { id, assetId, parentId: '', name, position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete o.groupId; + scene.objects.push(o); + } + __gm.state.select({ kind: 'asset', assetId: 'ord-b' }); + `); + await showTab('graph'); + await clickText('Fit'); + const atRest = await json(`__gm.graph.nodeSettings('ord-b-start')`); + await select({ kind: 'node', assetId: 'ord-b', nodeId: 'ord-b-start' }); + const orderField = await json(`__gm.inspectorField('Order')`); + if (orderField) { + await click(orderField.x, orderField.y); + await typeText('-1'); + await press('Enter'); + } + const params = (await asset('ord-b')).graph.nodes.find((n) => n.id === 'ord-b-start')?.params; + const shown = await json(`__gm.graph.nodeSettings('ord-b-start')`); + check( + 'an event node shows no Order row at 0; -1 typed into the inspector lands on the node and its row', + JSON.stringify(atRest) === '[]' && + params?.order === -1 && + JSON.stringify(shown) === JSON.stringify(['Order: -1']), + JSON.stringify({ atRest, orderField, params, shown }), + ); + await shot('27-event-order'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Order ')) + .map((line) => line.split(']').pop().trim()); + check( + 'a run starts the behavior with Order -1 first, though it stands below the other one', + JSON.stringify(logged) === JSON.stringify(['B', 'A']), + JSON.stringify(logged), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'ord-a' && a.id !== 'ord-b'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('ord-')); + `); + } }