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

+19 -2
View File
@@ -1,6 +1,7 @@
import { isHandleEnabled, worldTransformOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { treeOrder } from '../core/model/hierarchy';
import { placedPicture } from '../core/model/composition';
import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties';
@@ -79,6 +80,8 @@ export interface DrawItem {
clip: ClipRegion | null;
/** The render layer it is on, its parents' choice included (#302): which cameras see it. */
renderLayer: number;
/** Part of what a placed composition shows (#417): the placement, which a click on it selects. */
sealedIn?: string;
}
interface DrawList {
@@ -200,16 +203,29 @@ function wearsPicture(type: AssetType): boolean {
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
* positions, with the content of placed containers resolved in. A container has no background of
* its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors
* set are shown on the maps, though they never reach the model (#135).
* set are shown on the maps, though they never reach the model (#135). A placed composition shows
* its first frame (#417) unless `pictures` is off – in play mode, where its items are objects of the
* run itself.
*/
export function collectHolderItems(
project: Project,
holder: ObjectHolder | undefined,
fallbackBackground: number,
run: World | null = null,
pictures = true,
): DrawList {
if (!holder) return { items: [], background: fallbackBackground };
const view = resolvedHolder(project, holder);
const resolved = resolvedHolder(project, holder);
const sealed = new Map<string, string>();
const shown = pictures
? resolved.objects.flatMap((placement) => {
const comp = findAssetOf(project, placement.assetId, 'composition');
const picture = comp ? placedPicture(project, comp, placement.id) : [];
for (const o of picture) sealed.set(o.id, placement.id);
return picture;
})
: [];
const view = shown.length > 0 ? { ...resolved, objects: [...resolved.objects, ...shown] } : resolved;
const items: DrawItem[] = [];
for (const { object } of treeOrder(view)) {
const asset = findAsset(project, object.assetId);
@@ -240,6 +256,7 @@ export function collectHolderItems(
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
clip: null,
renderLayer: 0,
...(sealed.has(object.id) && { sealedIn: sealed.get(object.id) }),
});
}
const byId = new Map(items.map((i) => [i.id, i]));
+4 -2
View File
@@ -16,7 +16,8 @@ export interface PointerHit {
* What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where
* one of its layers has a tile, and at the height of the highest such layer: an empty cell is
* empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn
* above everything, so they come first.
* above everything, so they come first. What a placed composition shows answers as the placement,
* once, where its frontmost part lies (#417).
*/
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
const rank = new Map<string, number>();
@@ -35,7 +36,8 @@ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[])
const best = Math.max(-1, ...ranks);
if (best >= 0) ranked.push({ id: hit.id, rank: best });
}
return ranked.sort((a, b) => b.rank - a.rank).map((r) => r.id);
const ids = ranked.sort((a, b) => b.rank - a.rank).map((r) => items[index.get(r.id)!]!.sealedIn ?? r.id);
return [...new Set(ids)];
}
/**
+3 -1
View File
@@ -8,18 +8,20 @@ import { drawSvg, haloSvg, Theme } from '../ui';
import behavior from '../editor/panels/icons/behavior.svg?raw';
import camera from '../editor/panels/icons/camera.svg?raw';
import collider from '../editor/panels/icons/collider.svg?raw';
import composition from '../editor/panels/icons/composition.svg?raw';
import container from '../editor/panels/icons/container.svg?raw';
import joint from '../editor/panels/icons/joint.svg?raw';
import particles from '../editor/panels/icons/particles.svg?raw';
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
export type SymbolIcon =
'behavior' | 'camera' | 'collider' | 'container' | 'joint' | 'particles' | 'rigidbody';
'behavior' | 'camera' | 'collider' | 'composition' | 'container' | 'joint' | 'particles' | 'rigidbody';
export const SYMBOL_SVG: Record<SymbolIcon, string> = {
behavior,
camera,
collider,
composition,
container,
joint,
particles,
+1
View File
@@ -174,6 +174,7 @@ export function drawItem(
return;
case 'behavior':
case 'container':
case 'composition':
case 'rigidbody':
case 'joint':
return;