/** * 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 { findAsset, findAssetOf, findScene } from '../../src/core/model/Project'; import { createSceneObject } from '../../src/core/model/SceneObject'; import { compositionBusPick } from '../../src/core/properties/buses'; import { parseVideoKey } from '../../src/core/model/video'; import { VIDEO_BUS_KEY, VIDEO_CLOCK_KEY, VIDEO_LEVEL_KEY, VIDEO_TIME_KEY, } from '../../src/core/properties/videos'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem'; import type { VideoPictures } from '../../src/runtime/systems/CompositionSystem'; import { Input } from '../../src/runtime/systems/Input'; import { World } from '../../src/runtime/World'; import { stackAt } from '../../src/viewport/pick'; import { collectHolderItems, collectWorldItems } 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, pictures: VideoPictures | null = null) { const world = World.instantiate(stage.project, stage.sceneId); addGameSystems(world, stage.project, new Input(), { playsCompositions, pictures }); 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('shows frame 0 of what an item’s own On start plays in the frame the item appears', () => { 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); const wave = stage.move(arm.asset.id); const logic = makeBehavior(stage, 'Wave'); const edit = graphEditor(stage, logic); const start = edit.addNode('OnStart'); const owner = edit.addNode('Owner'); const playIt = edit.addNode('PlayAnimation', { animation: wave }); edit.connect(start, 'exec', playIt, 'exec'); edit.connect(owner, 'object', playIt, 'object'); (findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push( createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }), createSceneObject('wave', logic.id, { x: 0, y: 0 }, 'arm'), ); const item = stage.item(first(stage), rig.asset.id, 2, 30); const run = play(stage); const armOf = () => run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm')); let ticks = 0; while (!armOf() && ticks < 10) { run.frame(); ticks++; } // Begun mid-run, on the third tick: drawn with frame 0 of its animation, not where it stood. expect(ticks).toBe(3); expect(armOf()!.x).toBe(0); run.frame(); expect(armOf()!.x).toBeCloseTo(10); }); 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('fades the video of an item: its Video volume scaled by the fades, in the game and in the tab (#418)', () => { const stage = makeStage(); director(stage); const video = new CreateVideoCommand('Intro', FILE, { width: 320, height: 180, duration: 10, fps: 30, audio: true, }); stage.stack.execute(video); const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id, [VIDEO_LEVEL_KEY]: 0.8, }); stage.stack.execute(screen); const item = stage.item(first(stage), screen.asset.id, 0, 50); Object.assign(stage.comp().items[0]!, { fadeIn: 10, fadeOut: 20 }); const run = play(stage); run.frame(6); expect(run.at(item)!.props[VIDEO_LEVEL_KEY]).toBeCloseTo(0.4, 9); run.frame(30); expect(run.at(item)!.props[VIDEO_LEVEL_KEY]).toBeCloseTo(0.6, 9); const heard = (frame: number) => { const holder = compositionHolder(stage.project, stage.comp(), frame); const object = holder.objects.find((o) => o.id === item)!; return resolveSceneObjectProps(stage.project, findAsset(stage.project, object.assetId)!, object)[ VIDEO_LEVEL_KEY ]; }; expect(heard(5)).toBeCloseTo(0.4, 9); expect(heard(20)).toBe(0.8); }); it('hears the video of an item on the composition’s bus where it picks none, ahead too (#418)', () => { const stage = makeStage(); director(stage); const video = new CreateVideoCommand('Intro', FILE, { width: 320, height: 180, duration: 10, fps: 30, audio: true, }); const film = new CreateAssetCommand('bus', 'Film'); stage.stack.execute(video); stage.stack.execute(film); const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id }); const own = new CreateAssetCommand('sprite', 'Own', { texture: video.asset.id, [VIDEO_BUS_KEY]: film.asset.id, }); stage.stack.execute(screen); stage.stack.execute(own); const item = stage.item(first(stage), screen.asset.id, 0, 20); const next = stage.item(first(stage), screen.asset.id, 20, 10); const picked = stage.item(first(stage), own.asset.id, 0, 30); const run = play(stage); run.frame(11); const drawn = (id: string) => collectWorldItems(run.world).items.find((i) => i.id === contentId(stage.placement, id))?.texture?.file; const comp = stage.comp().id; expect(run.at(item)!.props[VIDEO_BUS_KEY]).toBe(compositionBusPick(comp)); const key = drawn(item); expect(parseVideoKey(key!)?.bus).toBe(`mix:${comp}`); // The next one is worn ahead in the player it will be drawn with. expect(collectWorldItems(run.world).ahead?.[0]?.texture?.file).toBe(key); expect(run.at(picked)!.props[VIDEO_BUS_KEY]).toBe(film.asset.id); expect(parseVideoKey(drawn(picked)!)?.bus).toBe(`mix:${film.asset.id}`); run.frame(10); expect(drawn(next)).toBe(key); }); it('stands still while the view waits for the first picture of an item’s video, 3 s at most', () => { 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, 100); const missing = new Set(); const run = play(stage, true, { missing: (id) => missing.has(id) }); const time = () => run.placement().composition!.time; run.frame(); // The view drew the new item without its picture. missing.add(contentId(stage.placement, item)); run.frame(5); expect(time()).toBe(0); expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBe(0); missing.clear(); run.frame(2); expect(time()).toBeCloseTo(0.2, 9); // A picture that never comes holds it 3 s, then it goes on without. missing.add(contentId(stage.placement, item)); run.frame(29); expect(time()).toBeCloseTo(0.2, 9); run.frame(10); expect(time()).toBeGreaterThan(1); }); it('hands the view the video of the next item a second ahead, by the id the item will take', () => { 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); stage.item(first(stage), screen.asset.id, 0, 20); const next = stage.item(first(stage), screen.asset.id, 20, 10, 0.5); const run = play(stage); const ahead = () => collectWorldItems(run.world).ahead ?? []; run.frame(10); expect(ahead()).toEqual([]); run.frame(); const [coming] = ahead(); expect(coming?.id).toBe(contentId(stage.placement, next)); expect(coming?.texture?.file).toMatch(/^video:/); expect(coming?.props[VIDEO_CLOCK_KEY]).toBe('driven'); expect(coming?.props[VIDEO_TIME_KEY]).toBe(0.5); run.frame(10); expect(run.at(next)).toBeDefined(); expect(ahead()).toEqual([]); }); 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`)); }); });