A game should be able to stop a video on a sprite, jump to a spot in it and start it again, and react when it has run out. Until now a video on a sprite either ran free on its own or followed a composition, and no behavior could touch it. - Five new nodes: Play video, Pause video and Seek video (time in s from the clip start), On video finished with a Video field, and the data node Video time with time, length and playing. - Play, Pause and Seek act on the owner when object is not linked. - On video finished with an empty field hears every video. A chosen video also hears its clips. - A new VideoSystem keeps a clock for every sprite with a video. It counts from the moment the sprite appears, as long as it is on. For a Driven sprite the clock is its Video time. A paused game holds all clocks. - A free sprite that one of the nodes touches gets the clock "steered" at run time. It then has its own player, and other sprites with the same clip play on. Pause of the game and sound go the existing Driven way. - On a Driven sprite and in the editor run the nodes do nothing and write a line to the log. - Until its own player has a frame, a steered sprite shows the frame of the shared player. Without that it would blink out on the first Pause video. - Play at the end of a clip without loop starts it from the start. The issue does not say, this is my choice. Found, not fixed: with a Driven sprite in the scene the run waits up to 3 s for its frame at the start. The world does not tick then, but the free videos already play. Their clocks lag behind the picture by that wait, so On video finished comes that much late. Tests: - Unit: 23 new (video-steer 16, node catalog 7), affected groups 4820/4820. - Counter-probes: 16 bends, all red. - e2e 66 on atx: ALL PASSED, 39 checks. New 66s: Pause at 0.15 s held its own player at 0.2 s, the picture stayed red for 1 s. Seek landed on 0.5 s exactly, green, held. Play showed green, blue, white, and white stayed at the end. On video finished came once. Without the clock "steered" all three 66s checks are red. - Gate on atx: CHECK EXIT 0, 336 files, 5199 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(23);
|
|
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);
|
|
});
|