Stage 2 of #338. A game that syncs to its music has to know where the music is heard. This stage reads that on the audio clock and hands it to behaviors as events and as a position. - New event nodes On beat (playback, bar, beat, late), On bar (playback, bar, late) and On song marker (playback, marker, name, late). - New data node Song position with bar, beat, fraction, seconds and playing. - The nodes have the fields Song and Pattern. With neither picked, every playback counts. A linked playback input picks that one. Compositions do not count. - The Marker field offers the markers of all songs as Song / Marker, as a sound field offers its clips. - Everything follows what is heard: the audio clock minus the output latency. That holds for baked songs and for Play pattern too. - Only the last beat since the frame before fires, and only when it is at most half a beat late, 0.15 s at most. Bars and markers alike. - A copied song gets markers with ids of its own, so a Marker field never names two songs. - The audio clock is only asked while something plays. Asked in every frame, it made the AudioContext without a user gesture (#436). Tests: - beats 14 and music-events 6 new, 4 in song-play, 2 in song-markers, 2 catalog cases, the touched groups 756/756 - 16 counter-probes, all compile, all red - e2e 65 on atx: ALL PASSED, 71 checks, 65k2 new: 11 beats 1.1 to 2.4 and 2.1 again, none missing or twice, across the pause too. late 0.015 to 0.027 s. Counter-probe without the last position: red - gate on atx: CHECK EXIT 0, 334 files, 5121 tests 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(22);
|
|
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);
|
|
});
|