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

+1
View File
@@ -136,6 +136,7 @@ export class EditorRun {
shouldRun: (e) => runsInEditor(e.props),
audio: soundLibrary,
saves: saveArea(project.id, 'editor'),
playsCompositions: false,
});
this.world = fresh;
this.hadFocus = this.hooks.focused();
+9 -3
View File
@@ -113,7 +113,7 @@ const LABEL_PAD_Y = 1;
const LABEL_ROW_GAP = 1;
const LABEL_PLATE_ALPHA = 0.7;
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody', 'joint']);
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'composition', 'rigidbody', 'joint']);
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
@@ -268,7 +268,13 @@ export class SceneViewport extends FreeViewport {
}
protected override collectFrame(): Frame {
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport, this.editorWorld);
return collectHolderItems(
this.state.project,
this.holder(),
Theme.colors.viewport,
this.editorWorld,
this.state.mode === 'edit',
);
}
protected override get baseColor(): number {
@@ -725,7 +731,7 @@ export class SceneViewport extends FreeViewport {
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.filter((i) => i.enabled && !i.sealedIn && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
this.state.select(objectSelection(holder.id, ids));
}
+13 -3
View File
@@ -54,8 +54,18 @@ const DRAG_THRESHOLD = 2;
const TURN_SNAP = 15;
/** How strongly the frame round a container's content is drawn. */
const FRAME_ALPHA = 0.6;
/** A placed composition is framed like a container, round the picture it shows (#417). */
const holdsContent = (item: DrawItem) => item.type === 'container' || item.type === 'composition';
/** Content that is only an icon or a view, and so takes no room in a container's frame. */
const SHAPELESS = new Set<DrawItem['type']>(['behavior', 'rigidbody', 'joint', 'camera', 'container']);
const SHAPELESS = new Set<DrawItem['type']>([
'behavior',
'rigidbody',
'joint',
'camera',
'container',
'composition',
]);
/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */
type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
@@ -129,7 +139,7 @@ export class SizeGizmoEditor {
/** A container is framed round its content (#242), everything else round its own footprint. */
private worldBox(item: DrawItem): Vec2[] {
if (item.type === 'container') {
if (holdsContent(item)) {
const frame = contentFrame(item.m, this.contentCorners(item.id));
if (frame) return frame;
}
@@ -303,7 +313,7 @@ export class SizeGizmoEditor {
});
const color = Theme.colors.gizmo;
// The selection outline sits round the container's icon, so the frame the knob belongs to is drawn here.
if (item.type === 'container') g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA });
if (holdsContent(item)) g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA });
g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color });
const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob';
g.circle(knob.x, knob.y, KNOB_RADIUS)