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:
1 parent
73e675835e
commit
0334d2508d
31 files changed
+1035
-33
No files matched your search
@@ -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`));
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user