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

+161
View File
@@ -0,0 +1,161 @@
import { isHandleEnabled } from '../../core/graph';
import { trackWriteAt } from '../../core/model/animation';
import {
compositionAt,
compositionFrameAt,
compositionFps,
compositionLength,
compositionLoops,
type LiveItem,
} from '../../core/model/composition';
import type { CompositionAsset } from '../../core/model/Asset';
import { curveLookup } from '../../core/model/curve';
import { contentId, placedWithContent } from '../../core/model/instance';
import { findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { findVideo } from '../../core/model/video';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bind } from './AnimationSystem';
import type { System } from './System';
export interface CompositionFinish {
/** The placed composition. */
entity: Entity;
compositionId: string;
startedBy: readonly object[];
}
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
interface Spawned {
entity: Entity;
bases: Map<string, PropertyValue>;
videos: Entity[];
}
interface Run {
id: number;
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
live: Map<string, Spawned>;
}
/**
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
* when the item begins and destroys it when it ends, writes the clips over them and drives their
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
* a cut would stay behind.
*/
export class CompositionSystem implements System {
finished: CompositionFinish[] = [];
private readonly runs = new Map<Entity, Run>();
constructor(private readonly plays = true) {}
update(world: World, dt: number): void {
this.finished = [];
for (const placement of this.runs.keys())
if (!world.entities.includes(placement)) this.runs.delete(placement);
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
for (const placement of started) this.advance(world, placement, dt);
}
stop(): void {
this.finished = [];
this.runs.clear();
}
private advance(world: World, placement: Entity, dt: number): void {
const player = placement.composition!;
if (!this.plays) {
world.log.push('Play composition is ignored in the editor run');
delete placement.composition;
return;
}
let run = this.runs.get(placement);
if (run && run.id !== player.run) this.end(world, placement);
const comp = findAssetOf(world.project, placement.assetId, 'composition');
if (!comp || player.stopped) {
this.end(world, placement);
delete placement.composition;
return;
}
run = this.runs.get(placement);
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() }));
if (player.shown && isHandleEnabled(placement)) player.time += dt;
player.shown = true;
const length = compositionLength(world.project, comp);
let frame = compositionFrameAt(comp, player.time);
if (frame >= length && compositionLoops(comp) && length > 0) {
player.time %= length / compositionFps(comp);
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
} else if (frame >= length) {
this.end(world, placement);
delete placement.composition;
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
return;
}
this.show(world, comp, placement, run, frame);
}
/** Takes every item of the placement's run away. */
private end(world: World, placement: Entity): void {
const run = this.runs.get(placement);
if (!run) return;
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
this.runs.delete(placement);
}
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
const live = compositionAt(world.project, comp, frame);
const alive = new Set(live.map((l) => l.item.id));
for (const [id, { entity }] of run.live) {
if (alive.has(id)) continue;
if (world.entities.includes(entity)) world.remove(entity);
run.live.delete(id);
}
let born = false;
for (const item of live) {
if (run.live.has(item.item.id)) continue;
const spawned = spawn(world, placement, item);
if (!spawned) continue;
run.live.set(item.item.id, spawned);
born = true;
}
// In the order the tracks lay them, whenever they began.
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
const curves = curveLookup(world.project);
for (const item of live) {
const spawned = run.live.get(item.item.id);
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
}
}
}
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
const id = contentId(placement.objectId, item.object.id);
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
const entity = entities.find((e) => e.objectId === id);
if (!entity) return null;
// Below the item, a sprite set to be driven by hand keeps its own time.
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
return { entity, bases: new Map(), videos };
}
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
for (const sprite of spawned.videos) {
const texture = sprite.props.texture;
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
}
for (const clip of item.clips) {
for (const b of bind(world, clip.animation, spawned.entity)) {
let base = spawned.bases.get(b.base);
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
if (value !== undefined) b.write(value);
}
}
}