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
@@ -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 : '');
|
||||
|
||||
Reference in new issue
Block a user