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

+70 -1
View File
@@ -22,7 +22,14 @@ import type {
CompositionTrack,
} from './Asset';
import { curveLookup } from './curve';
import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance';
import {
contentId,
instanceChildId,
objectRef,
placedWithContent,
resolveObjects,
splitInstanceId,
} from './instance';
import { findAsset, findAssetOf, type Project } from './Project';
import { booleanOf, type PropertyBag } from './Properties';
import { objectName, type SceneObject } from './SceneObject';
@@ -104,6 +111,57 @@ export function fittedLength(project: Project, comp: CompositionAsset, item: Com
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
}
/**
* A composition placed in a running game that `Play composition` started; kept on its entity, out of
* `props`. Its items exist only while it plays.
*/
export interface CompositionPlayer {
/** Tells a start apart from the one before, so a restart builds every item anew. */
run: number;
/** Seconds since its first frame. */
time: number;
/** Whether the first frame was shown: the clock runs from the tick after the start. */
shown: boolean;
/** Set by `Stop composition`: the next tick takes its items away. */
stopped: boolean;
/** The behaviors that played it; they hear `On composition finished` wherever it stands. */
startedBy: object[];
}
let runCount = 0;
/**
* Starts the composition placed as `holder` from its first frame. One already playing plays on, unless
* `restart` – or a `Stop composition` this tick – asks to start over.
*/
export function playComposition(
holder: { composition?: CompositionPlayer },
restart = false,
startedBy?: object,
): void {
const playing = holder.composition;
if (playing && !playing.stopped && !restart) {
if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy);
return;
}
holder.composition = {
run: ++runCount,
time: 0,
shown: false,
stopped: false,
startedBy: startedBy ? [startedBy] : [],
};
}
export function stopComposition(holder: { composition?: CompositionPlayer }): void {
if (holder.composition) holder.composition.stopped = true;
}
/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */
export function compositionFrameAt(comp: CompositionAsset, time: number): number {
return Math.floor(time * compositionFps(comp) + 1e-6);
}
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
export interface ClipAt {
clip: CompositionClip;
@@ -187,6 +245,17 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram
return holder;
}
/**
* What a composition placed as `placementId` shows in the editor: its first frame, as objects below
* the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and
* its items are edited in the composition only.
*/
export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] {
return compositionHolder(project, comp, 0).objects.flatMap((o) =>
placedWithContent(project, o, contentId(placementId, o.id), placementId),
);
}
const record = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
const text = (value: unknown) => (typeof value === 'string' ? value : '');