Place compositions in scenes and play them from behaviors (#417)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 07:06:33 +02:00
1 parent 73e675835e
commit 0334d2508d
31 files changed
+1035 -33

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
/**
* A composition placed in a scene and played (#417): its items become objects below the placement
* while they last, its clips and videos reach them, and `On composition finished` hears the end.
*/
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
} from '../../src/core/commands/animationCommands';
import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands';
import type { CompositionAsset, ContainerAsset } from '../../src/core/model/Asset';
import {
compositionHolder,
placedPicture,
playComposition,
stopComposition,
} from '../../src/core/model/composition';
import { contentId, instanceChildId, resolveObjects } from '../../src/core/model/instance';
import { findAssetOf, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import { stackAt } from '../../src/viewport/pick';
import { collectHolderItems } from '../../src/viewport/items';
import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
interface Stage extends ProjectFixture {
sceneId: string;
spriteId: string;
/** The composition's object in the scene. */
placement: string;
comp(): CompositionAsset;
track(): string;
item(track: string, assetId: string, start: number, length: number, offset?: number): string;
/** An animation over `root` moving x (or a property of `target`) from 0 to 90 over frames 0–9 at 10 fps. */
move(root: string, property?: string, target?: string): string;
clip(track: string, animation: string, start: number): void;
}
/** A scene "Level" with a composition "Cut" at 100/50, ten frames a second, and a sprite "Box". */
function makeStage(): Stage {
const f = makeProject();
const scene = new CreateAssetCommand('scene', 'Level');
const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 });
const sprite = new CreateAssetCommand('sprite', 'Box');
for (const c of [scene, comp, sprite]) f.stack.execute(c);
const place = new PlaceAssetCommand(scene.asset.id, comp.asset.id, { x: 100, y: 50 });
f.stack.execute(place);
const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!;
let next = 0;
return {
...f,
sceneId: scene.asset.id,
spriteId: sprite.asset.id,
placement: place.objectId,
comp: current,
track() {
const id = `t${++next}`;
current().tracks.push({ id, name: id });
return id;
},
item(track, assetId, start, length, offset = 0) {
const id = `i${++next}`;
current().objects.push(createSceneObject(id, assetId, { x: 0, y: 0 }));
current().items.push({ id, track, start, length, offset });
return id;
},
move(root, property = 'x', target = '') {
const create = new CreateAssetCommand('animation', 'Move', { fps: 10, playback: 'loop' });
f.stack.execute(create);
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
const row = new AddAnimationTrackCommand(create.asset.id, target, property);
f.stack.execute(row);
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0));
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 9, 90));
return create.asset.id;
},
clip(track, animation, start) {
current().clips.push({ id: `c${++next}`, track, animation, start });
},
};
}
const first = (stage: Stage) => stage.comp().tracks[0]!.id;
/** A behavior below the placement: On start plays it, On composition finished logs "done". */
function director(stage: Stage): void {
const behavior = makeBehavior(stage, 'Director');
stage.stack.execute(new PlaceAssetCommand(stage.sceneId, behavior.id, { x: 0, y: 0 }, stage.placement));
const edit = graphEditor(stage, behavior);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const play = edit.addNode('PlayComposition');
edit.connect(start, 'exec', play, 'exec');
edit.connect(owner, 'object', play, 'object');
const finished = edit.addNode('OnCompositionFinished', { composition: stage.comp().id });
const log = edit.addNode('Log', { message: 'done' });
edit.connect(finished, 'exec', log, 'exec');
}
/** The scene played as a game: `frame()` ticks a tenth of a second and writes back like Play does. */
function play(stage: Stage, playsCompositions = true) {
const world = World.instantiate(stage.project, stage.sceneId);
addGameSystems(world, stage.project, new Input(), { playsCompositions });
const placement = () => world.findByObjectId(stage.placement)!;
const at = (item: string) => world.findByObjectId(contentId(stage.placement, item));
return {
world,
placement,
at,
frame(times = 1) {
for (let i = 0; i < times; i++) {
world.tick(0.1);
world.syncToModel();
}
},
stored: () => findScene(stage.project, stage.sceneId)!.objects.map((o) => o.id),
};
}
describe('a composition played in a scene', () => {
it('builds an item’s object below the placement when it begins and destroys it when it ends', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 3);
const b = stage.item(first(stage), stage.spriteId, 3, 2);
const run = play(stage);
expect(run.at(a)).toBeUndefined();
run.frame();
// The first tick shows frame 0, the clock runs from the next.
expect(run.at(a)?.parent).toBe(run.placement());
expect(run.at(a)?.objectId).toBe(`${stage.placement}~${a}`);
expect(run.at(b)).toBeUndefined();
run.frame(3);
expect(run.at(a)).toBeUndefined();
expect(run.stored()).not.toContain(contentId(stage.placement, a));
expect(run.at(b)?.name).toBe('Box');
expect(run.stored()).toContain(contentId(stage.placement, b));
run.frame(2);
expect(run.at(b)).toBeUndefined();
expect(run.world.log).toEqual(['done']);
expect(run.placement().composition).toBeUndefined();
run.frame(5);
expect(run.world.log).toEqual(['done']);
});
it('lays the first track in front, whatever began first', () => {
const stage = makeStage();
director(stage);
const back = stage.track();
// Built after the front one, the item of the back track still has to go behind it.
const front = stage.item(first(stage), stage.spriteId, 0, 5);
const late = stage.item(back, stage.spriteId, 2, 5);
const run = play(stage);
run.frame(3);
const order = (ids: string[]) =>
[late, front].map((item) => ids.indexOf(contentId(stage.placement, item)));
const [behind, inFront] = order(run.world.entities.map((e) => e.objectId));
expect(behind).toBeLessThan(inFront!);
const [storedBehind, storedFront] = order(run.stored());
expect(storedBehind).toBeLessThan(storedFront!);
});
it('writes its clips over the items as the editor shows them, and holds the last value', () => {
const stage = makeStage();
director(stage);
const item = stage.item(first(stage), stage.spriteId, 0, 30);
stage.clip(first(stage), stage.move(stage.spriteId), 2);
const run = play(stage);
const shown = (frame: number) =>
compositionHolder(stage.project, stage.comp(), frame).objects.find((o) => o.id === item)!.position.x;
let at = -1;
for (const frame of [0, 1, 2, 7, 11, 20]) {
run.frame(frame - at);
at = frame;
expect(run.at(item)!.x, `frame ${frame}`).toBe(shown(frame));
}
expect(run.at(item)!.x).toBe(90);
});
it('reaches into a container item by its template path', () => {
const stage = makeStage();
director(stage);
const rig = new CreateAssetCommand('container', 'Rig');
const arm = new CreateAssetCommand('sprite', 'Arm');
stage.stack.execute(rig);
stage.stack.execute(arm);
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }),
);
const item = stage.item(first(stage), rig.asset.id, 0, 30);
stage.clip(first(stage), stage.move(rig.asset.id, 'rotation', 'arm'), 0);
const run = play(stage);
run.frame(6);
const armOf = run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm'));
expect(armOf?.objectId).toBe(`${stage.placement}~${item}/arm`);
expect(armOf?.parent).toBe(run.at(item));
expect(armOf?.props.rotation).toBe(50);
});
it('drives the video an item wears at its video time', () => {
const stage = makeStage();
director(stage);
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 10,
fps: 30,
audio: false,
});
stage.stack.execute(video);
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id });
stage.stack.execute(screen);
const item = stage.item(first(stage), screen.asset.id, 0, 50, 1.5);
const run = play(stage);
run.frame(6);
expect(run.at(item)!.props[VIDEO_CLOCK_KEY]).toBe('driven');
expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBeCloseTo(2, 9);
});
it('loops without finishing, building an item anew each time round', () => {
const stage = makeStage();
director(stage);
stage.comp().props.loop = true;
const a = stage.item(first(stage), stage.spriteId, 0, 2);
stage.item(first(stage), stage.spriteId, 2, 2);
const run = play(stage);
run.frame();
const firstRound = run.at(a);
run.frame(4);
expect(run.at(a)).toBeDefined();
expect(run.at(a)).not.toBe(firstRound);
run.frame(20);
expect(run.world.log).toEqual([]);
});
it('takes the items away on Stop, without finishing, and starts over on the next play', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(3);
const before = run.at(a);
stopComposition(run.placement());
run.frame();
expect(run.at(a)).toBeUndefined();
expect(run.placement().composition).toBeUndefined();
playComposition(run.placement());
run.frame();
expect(run.at(a)).toBeDefined();
expect(run.at(a)).not.toBe(before);
expect(run.world.log).toEqual([]);
});
it('builds every item anew on a restart, and plays on when asked to play again', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(5);
const playing = run.at(a);
playComposition(run.placement());
run.frame();
expect(run.at(a)).toBe(playing);
expect(run.placement().composition!.time).toBeCloseTo(0.5, 9);
playComposition(run.placement(), true);
run.frame();
expect(run.at(a)).not.toBe(playing);
expect(run.placement().composition!.time).toBe(0);
});
it('stands still while the game is paused', () => {
const stage = makeStage();
director(stage);
stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(3);
run.world.paused = true;
run.frame(5);
expect(run.placement().composition!.time).toBeCloseTo(0.2, 9);
});
it('plays nothing in the editor run, and says so', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage, false);
run.frame(3);
expect(run.at(a)).toBeUndefined();
expect(run.world.log).toEqual(['Play composition is ignored in the editor run']);
});
});
describe('a placed composition in the editor', () => {
it('shows its first frame below the placement, with the ids its items take in a run', () => {
const stage = makeStage();
const a = stage.item(first(stage), stage.spriteId, 0, 3);
stage.item(first(stage), stage.spriteId, 5, 3);
const comp = stage.comp();
expect(placedPicture(stage.project, comp, stage.placement).map((o) => [o.id, o.parentId])).toEqual([
[contentId(stage.placement, a), stage.placement],
]);
// Nothing of it is stored: the placement is sealed.
expect(resolveObjects(stage.project, findScene(stage.project, stage.sceneId)!)).toHaveLength(1);
});
it('draws that frame where the placement stands, and a click on it picks the placement', () => {
const stage = makeStage();
const a = stage.item(first(stage), stage.spriteId, 0, 3);
const scene = findScene(stage.project, stage.sceneId)!;
const { items } = collectHolderItems(stage.project, scene, 0);
const drawn = items.find((i) => i.id === contentId(stage.placement, a))!;
expect([drawn.x, drawn.y]).toEqual([100, 50]);
expect(drawn.sealedIn).toBe(stage.placement);
expect(stackAt(items, [{ id: drawn.id, layers: null }])).toEqual([stage.placement]);
// Play mode draws the run's own objects instead.
expect(collectHolderItems(stage.project, scene, 0, null, false).items.map((i) => i.id)).toEqual([
stage.placement,
]);
});
it('gives the content of a container item the ids a run gives it', () => {
const stage = makeStage();
const rig = new CreateAssetCommand('container', 'Rig');
stage.stack.execute(rig);
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
createSceneObject('arm', stage.spriteId, { x: 5, y: 0 }),
);
const item = stage.item(first(stage), rig.asset.id, 0, 3);
const picture = placedPicture(stage.project, stage.comp(), stage.placement);
const top = contentId(stage.placement, item);
expect(picture.map((o) => [o.id, o.parentId])).toEqual([
[top, stage.placement],
[contentId(top, 'arm'), top],
]);
expect(contentId(top, 'arm')).toBe(instanceChildId(stage.placement, `${item}/arm`));
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
);
expect(events).toHaveLength(18);
expect(events).toHaveLength(19);
for (const def of events) {
expect(
def.params.map((p) => p.key),
+32
View File
@@ -5,6 +5,7 @@
import { describe, expect, it } from 'vitest';
import { requireNodeDef } from '../../src/core/commands/require';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { playComposition } from '../../src/core/model/composition';
import {
createEmptyGraph,
type DeclaredPort,
@@ -107,6 +108,8 @@ const nestedMover = {
const emitTarget = fakeEntity('Sparks');
/** Their own entity each: the animation nodes leave players on the object they are handed. */
const dancer = fakeEntity('Dancer');
/** A placed composition for Play and Stop composition (#417). */
const director = fakeEntity('Director');
/** An object whose once-reset "swing" has run out and blends back over half a second (#233). */
const swinging = {
...fakeEntity('Fighter'),
@@ -1976,6 +1979,34 @@ const CASES: Record<string, Case[]> = {
after: () => expect(dancer.animations).toEqual([]),
},
],
PlayComposition: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null, restart: false },
next: 'exec',
},
{
name: 'starts a player on the object, and a second play leaves it running',
inputs: { object: director, restart: false },
next: 'exec',
after: () => {
expect(director.composition).toMatchObject({ time: 0, shown: false, stopped: false });
const run = director.composition!.run;
playComposition(director);
expect(director.composition!.run).toBe(run);
playComposition(director, true);
expect(director.composition!.run).toBeGreaterThan(run);
},
},
],
StopComposition: [
{
name: 'marks the player stopped for the next tick to take its items away',
inputs: { object: director },
next: 'exec',
after: () => expect(director.composition?.stopped).toBe(true),
},
],
PlaySound: [
{
name: 'no sound is a no-op that still continues the flow',
@@ -2506,6 +2537,7 @@ const EVENT_NODES: Record<string, string> = {
OnClick: 'nodes.test.ts',
OnMouseWheel: 'clip-areas.test.ts',
OnAnimationFinished: 'animation.test.ts',
OnCompositionFinished: 'composition-play.test.ts',
OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts',
};