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

+3
View File
@@ -1,4 +1,5 @@
import type { AnimationPlayer, EaseShare } from '../model/animation';
import type { CompositionPlayer } from '../model/composition';
import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes';
@@ -36,6 +37,8 @@ export interface EntityHandle {
emits?: number;
/** What `Play animation` started on this object (see `model/animation.ts`). */
animations?: AnimationPlayer[];
/** What `Play composition` started on a placed composition (see `model/composition.ts`). */
composition?: CompositionPlayer;
}
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
@@ -0,0 +1,40 @@
import { playComposition, stopComposition } from '../../../model/composition';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asEntity } from '../../values';
import { bool, exec, obj, withDefault } from '../shared';
export const PlayComposition: NodeDef = {
type: 'PlayComposition',
label: 'Play composition',
category: 'action',
description:
'Plays a composition placed in the scene from its first frame: each item becomes an object below ' +
'it when the item begins – its behaviors start then – and is destroyed when it ends. One already ' +
'playing plays on; Restart starts it over. Without Loop it ends after its last item, and On ' +
'composition finished hears that. The editor run plays none and says so in the log.',
inputs: [exec(), obj('object'), withDefault(bool('restart'), false)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) playComposition(target, asBoolean(inputs.restart), ctx.self);
return { next: 'exec' };
},
};
export const StopComposition: NodeDef = {
type: 'StopComposition',
label: 'Stop composition',
category: 'action',
description:
'Stops a playing composition: the objects of its items are destroyed on the next frame. It does ' +
'not count as finished, and Play composition starts it from its first frame again.',
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) stopComposition(target);
return { next: 'exec' };
},
};
@@ -0,0 +1,24 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { hiddenOrder, orderParam } from '../../eventOrder';
import { exec, obj } from '../shared';
export const OnCompositionFinished: NodeDef = {
type: 'OnCompositionFinished',
label: 'On composition finished',
category: 'event',
description:
'Runs once when a composition placed at the owner, or below it, plays past its last item – and when ' +
'one this behavior played itself does, wherever it stands. A looping one never finishes, and Stop ' +
'composition does not count. Leave Composition at None to hear every composition.',
inputs: [],
outputs: [exec(), obj('object')],
params: [
{ key: 'composition', label: 'Composition', kind: 'asset', default: '', assetTypes: ['composition'] },
orderParam,
],
hiddenParams: (params) => [
...(textOf(params.composition ?? '') ? [] : ['composition']),
...hiddenOrder(params),
],
};
+5
View File
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
import { AddJoint } from './action/AddJoint';
import { Destroy } from './action/Destroy';
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
import { PlayComposition, StopComposition } from './action/compositionNodes';
import { Emit } from './action/Emit';
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
import { LoadScene } from './action/LoadScene';
@@ -123,6 +124,7 @@ import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList';
import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnCompositionFinished } from './events/OnCompositionFinished';
import { OnBehavior } from './events/OnBehavior';
import { OnClick } from './events/OnClick';
import { OnMouseWheel } from './events/OnMouseWheel';
@@ -183,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnClick,
OnMouseWheel,
OnAnimationFinished,
OnCompositionFinished,
OnBehavior,
OnStateEntered,
OnStateExited,
@@ -308,6 +311,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
PlayAnimation,
PauseAnimation,
StopAnimation,
PlayComposition,
StopComposition,
PlaySound,
StopSound,
SetSoundFxParameters,
+2 -4
View File
@@ -84,8 +84,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* sound is played through (#311), heard but never seen, an instrument what a note is played
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
* where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through
* `texture` (#402), as it wears an image. A composition is not placed yet: what a placed one shows
* and plays is still to come (#417).
* `texture` (#402), as it wears an image.
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -110,7 +109,6 @@ export type PlaceableAssetType = Exclude<
| 'variables'
| 'struct'
| 'video'
| 'composition'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -135,7 +133,6 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'variables',
'struct',
'video',
'composition',
]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
@@ -155,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean {
type === 'rigidbody' ||
type === 'joint' ||
type === 'container' ||
type === 'composition' ||
type === 'clip'
);
}
+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 : '');
+34
View File
@@ -436,6 +436,40 @@ export function materializeInstance(
}));
}
/**
* The id the object at `path` below `ownerId` has – one level deeper when the owner is content
* itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives.
*/
export function contentId(ownerId: string, path: string): string {
const split = splitInstanceId(ownerId);
return split
? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`)
: instanceChildId(ownerId, path);
}
/**
* `object` as `id` below `parentId`, followed by its container content with the ids below that one –
* how a composition puts its objects into a scene (#417). Copies, free to be stored and written to.
*/
export function placedWithContent(
project: Project,
object: SceneObject,
id: string,
parentId: string,
): SceneObject[] {
const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1)));
const { groupId: _, ...top } = object;
return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({
...o,
id: rename(o.id),
parentId: o.id === object.id ? parentId : rename(o.parentId),
position: { ...o.position },
editorOffset: { ...o.editorOffset },
overrides: { ...o.overrides },
instanceEdits: {},
}));
}
/**
* Spells out every live instance of the holder. The runtime needs it: entities are built from
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
+1
View File
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
'camera',
'clip',
'collider',
'composition',
'container',
'joint',
'particles',
+2
View File
@@ -371,6 +371,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'text',
'collider',
'container',
'composition',
'clip',
'particles',
'tilemap',
@@ -438,6 +439,7 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
'text',
'particles',
'container',
'composition',
'tilemap',
]);
+1
View File
@@ -71,6 +71,7 @@ export function footprintOf(
case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
case 'composition':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
case 'rigidbody':
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };