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