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

+47
View File
@@ -96,6 +96,14 @@ function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] {
return keys ?? NO_KEYS;
}
/** `order`'s members of `list` in that order, in the slots of `list` they held before. */
function reorderIn<T>(list: T[], order: readonly T[]): void {
const slots = new Map(list.map((value, index) => [value, index]));
const present = order.filter((value) => slots.has(value));
const indices = present.map((value) => slots.get(value)!).sort((a, b) => a - b);
indices.forEach((slot, i) => (list[slot] = present[i]!));
}
/**
* The running game: entities of one scene plus the systems ticking them. Built on the play copy
* (discarded on stop) or, for the editor run, on the saved model, where its writes stay.
@@ -383,6 +391,45 @@ export class World {
return entity;
}
/**
* Adds objects a composition plays into the scene (#417), each below the parent it names, and
* answers their entities. An object of the same id still in the scene – one taken away this tick –
* gives way, so the scene never holds an id twice.
*/
place(objects: readonly SceneObject[]): Entity[] {
const scene = findScene(this.project, this.sceneId);
if (!scene) return [];
const ids = new Set(objects.map((o) => o.id));
for (let i = scene.objects.length - 1; i >= 0; i--)
if (ids.has(scene.objects[i]!.id)) scene.objects.splice(i, 1);
scene.objects.push(...objects);
const placed: Entity[] = [];
for (const object of objects) {
const asset = findAsset(this.project, object.assetId);
if (!asset || !isPlaceableType(asset.type)) continue;
const entity = this.materialize(object, asset);
entity.parent = this.findByObjectId(object.parentId) ?? null;
placed.push(entity);
}
this.structureChanged = true;
return placed;
}
/**
* Puts these entities, and their objects in the scene, in this order among the places they already
* hold – the order that decides between equal `z` (#417).
*/
arrange(order: readonly Entity[]): void {
reorderIn(this.entities, order);
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
const byId = new Map(scene.objects.map((o) => [o.id, o]));
reorderIn(
scene.objects,
order.map((e) => byId.get(e.objectId)).filter((o): o is SceneObject => o !== undefined),
);
}
/**
* Writes the entities' state into the scene: positions, property values that differ from
* what the model resolves (stored as overrides, so the inspector marks them) and removed