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

+169
View File
@@ -0,0 +1,169 @@
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* A composition placed in the scene (#417). In the editor the placement shows the composition's
* first frame, and a click on that picture picks the placement. Played, a behavior below it starts
* it: each item becomes an object below the placement while it lasts, the first track in front, a
* clip moves the item it overlaps, and `On composition finished` hears the end, by which time no
* item is left. Nothing of the run stays in the scene.
*
* Everything it adds carries a `cp68` id and goes again at the end.
*/
const PLACE = 'cp68-place';
const DONE = 'cut 68 done';
const node = (id, type, x, y, params = {}) => ({ id: `cp68-${id}`, type, x, y, params });
const link = (id, from, fromPort, to, toPort) => ({
id: `cp68-${id}`,
from: { node: `cp68-${from}`, port: fromPort },
to: { node: `cp68-${to}`, port: toPort },
});
export async function composition(t) {
const { check, sleep, json, evaluate, mutate, play, stop, showTab } = t;
await showTab('scene');
const graph = {
nodes: [
node('start', 'OnStart', 0, 0),
node('owner', 'Owner', 0, 150),
node('play', 'PlayComposition', 300, 0),
node('finished', 'OnCompositionFinished', 0, 300, { composition: 'cp68-cut' }),
node('log', 'Log', 300, 300, { message: DONE }),
],
edges: [
link('e1', 'start', 'exec', 'play', 'exec'),
link('e2', 'owner', 'object', 'play', 'object'),
link('e3', 'finished', 'exec', 'log', 'exec'),
],
};
// Red from the start, a larger blue behind it from the next frame – built after red, it still goes
// behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const look = (o, size, color) => { o.overrides = { width: size, height: size, color, texture: '' }; return o; };
const red = look(make('cp68-red', sprite.id, '', 0, 'Red 68'), 200, 0xff0000);
const blue = look(make('cp68-blue', sprite.id, '', 0, 'Blue 68'), 300, 0x0000ff);
const green = look(make('cp68-green', sprite.id, '', 0, 'Green 68'), 200, 0x00ff00);
for (const o of [red, blue, green]) delete o.groupId;
p.assets.push(
{
id: 'cp68-cut',
type: 'composition',
name: 'Cut 68',
props: { fps: 10, loop: false },
objects: [red, blue, green],
groups: [],
tracks: [{ id: 'cp68-front', name: 'Front' }, { id: 'cp68-back', name: 'Back' }],
items: [
{ id: 'cp68-red', track: 'cp68-front', start: 0, length: 6, offset: 0 },
{ id: 'cp68-blue', track: 'cp68-back', start: 1, length: 5, offset: 0 },
{ id: 'cp68-green', track: 'cp68-front', start: 6, length: 6, offset: 0 },
],
clips: [{ id: 'cp68-clip', track: 'cp68-front', animation: 'cp68-move', start: 6 }],
},
{
id: 'cp68-move',
type: 'animation',
name: 'Slide 68',
props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cp68-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }],
},
{ id: 'cp68-director', type: 'behavior', name: 'Director 68', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} },
);
const place = make('${PLACE}', 'cp68-cut', '', 0, 'Cut 68');
Object.assign(place.position, { x: cam.x, y: cam.y, z: 2000 });
scene.objects.push(place, make('cp68-logic', 'cp68-director', '${PLACE}', 0, 'Director 68'));
__gm.lookAt(cam.x, cam.y);
`);
const id = (item) => `${PLACE}~cp68-${item}`;
// 68a. In the editor the placement shows its first frame – red alone – and a click on the picture
// picks the placement, since what it shows is edited in the composition.
const order = await json('__gm.drawOrder()');
// Off the centre, where the placement's own symbol sits.
const rect = await json(`__gm.objectRect(${JSON.stringify(id('red'))})`);
const stack = rect
? await json(`__gm.sceneStack(${rect.x + rect.w * 0.35}, ${rect.y + rect.h * 0.35})`)
: null;
check(
'a placed composition shows its first frame, and a click on it picks the placement',
order.includes(id('red')) &&
!order.includes(id('blue')) &&
!order.includes(id('green')) &&
JSON.stringify(stack) === JSON.stringify([PLACE]),
JSON.stringify({ order: order.filter((o) => o.startsWith('cp68')), stack }),
);
// 68b. Played: the items come and go below the placement, sampled every frame of the editor.
await play();
const seen = await evaluate(`new Promise((resolve) => {
const frames = [];
const sample = () => {
const world = __gm.world();
if (!world) return resolve(JSON.stringify(frames));
const items = world.entities.filter((e) => e.objectId.startsWith('${PLACE}~'));
const green = items.find((e) => e.objectId === '${id('green')}');
frames.push({
ids: items.map((e) => e.objectId.slice('${PLACE}~cp68-'.length)),
parents: [...new Set(items.map((e) => e.parent))],
greenX: green ? green.x : null,
done: world.log.includes('${DONE}'),
});
if (frames.length > 400 || frames.at(-1).done) return resolve(JSON.stringify(frames));
requestAnimationFrame(sample);
};
sample();
})`);
const frames = JSON.parse(seen);
const both = frames.find((f) => f.ids.length === 2);
const greenXs = frames.map((f) => f.greenX).filter((x) => x !== null);
check(
'Play composition builds the items below the placement, the first track in front, one after the other',
both !== undefined &&
JSON.stringify(both.ids) === JSON.stringify(['blue', 'red']) &&
both.parents.length === 1 &&
both.parents[0] === PLACE &&
!frames.some((f) => f.ids.join('+') === 'red+blue') &&
frames.some((f) => JSON.stringify(f.ids) === JSON.stringify(['green'])) &&
!frames.some((f) => f.ids.includes('green') && f.ids.includes('red')),
JSON.stringify(frames.filter((_, i) => i % 5 === 0).map((f) => f.ids.join('+'))),
);
check(
'a clip moves the item it overlaps, from its value on',
greenXs.length >= 3 &&
greenXs[0] < 40 &&
greenXs.at(-1) > greenXs[0] &&
greenXs.every((x, i) => i === 0 || x >= greenXs[i - 1]),
JSON.stringify(greenXs),
);
const last = frames.at(-1);
check(
'On composition finished hears the end, by which time no item is left',
last?.done === true && last.ids.length === 0,
JSON.stringify(last),
);
await sleep(300);
await stop();
// 68c. The run's objects stay in the run: the scene holds the placement and its behavior only.
const stored = await json(
`__gm.state.activeScene.objects.filter((o) => o.id.startsWith('cp68')).map((o) => o.id).sort()`,
);
check(
'nothing of a played composition stays in the scene',
JSON.stringify(stored) === JSON.stringify(['cp68-logic', PLACE].sort()),
JSON.stringify(stored),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-'));
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`);
}
+2
View File
@@ -70,6 +70,7 @@ import { pattern } from './64-pattern.mjs';
import { song } from './65-song.mjs';
import { video } from './66-video.mjs';
import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
export const STEPS = [
hierarchy,
@@ -139,4 +140,5 @@ export const STEPS = [
song,
video,
recording,
composition,
];