A composition can now sit in a scene like a container and play there. Its items become real objects while they run, so behaviors, colliders and events work on them as on any other object. - A composition is placeable with its own symbol, transform and render layer. The scene view shows its picture at frame 0. A click on that picture selects the placement, not a single item. - CompositionSystem spawns each item as an object under the placement when it begins, with the id <placement>~<item>. On start and the other events run. The object is destroyed when the item ends. - The top track lies in front no matter which item started first. World.arrange() orders the entity list and the scene objects. - Clips and driven videos follow the same rules as compositionAt(). Loop, restart, stop and the game pause work. - New nodes: Play composition (with restart), Stop composition and the event On composition finished. They were planned for step 4 but come now, because without autoplay nothing could play otherwise. - The editor run ignores Play composition and says so in the log. It writes into the saved project, and the items would stay behind. - contentId() moved to instance.ts. The animation system uses it for nested ids too. - Compositions get no "Sort with parent" for now. Tests: the gate passed with 301 files and 4643 tests, 18 more than step 1. 13 of them are in tests/core/composition-play.test.ts. The first gate run failed in event-order.test.ts, which counts the event nodes and now finds 19. Five counter-checks each turned exactly their test red. Two order tests stayed green without the fix at first and were rebuilt. e2e 68 is new and passed with 5 own checks. Its counter-check without arrange() failed as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
7.8 KiB
TypeScript
222 lines
7.8 KiB
TypeScript
/**
|
|
* 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,
|
|
ON_BEHAVIOR,
|
|
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 `<event> → Log(message)` into the behavior and returns the event node. */
|
|
function logOn(
|
|
f: SceneFixture,
|
|
behavior: BehaviorAsset,
|
|
event: string,
|
|
message: string,
|
|
params: Record<string, string | number> = {},
|
|
): 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', () => {
|
|
// `On behavior` fires inside a pulse of the behavior it hears, whose event nodes carry the order.
|
|
const events = BUILT_IN_NODES.filter(
|
|
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
|
|
);
|
|
expect(events).toHaveLength(19);
|
|
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);
|
|
});
|