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

+3 -12
View File
@@ -15,7 +15,7 @@ import {
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { curveLookup } from '../../core/model/curve';
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
import { contentId } from '../../core/model/instance';
import { findAsset, findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
@@ -141,7 +141,7 @@ function drop(entity: Entity, player: AnimationPlayer): void {
* there, so one animation fits containers built alike. A row whose target is gone, or lacks the
* property, is skipped.
*/
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
export function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
const root = findAsset(world.project, asset.root);
if (!root) return [];
const bindings: Binding[] = [];
@@ -167,7 +167,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
else if (track.target === '') entity = owner;
else
entity =
world.findByObjectId(childId(owner.objectId, track.target)) ??
world.findByObjectId(contentId(owner.objectId, track.target)) ??
byNames(world, owner, targetNamePath(world.project, root, track.target));
const def = entity && defOn(world, entity, key);
if (entity && def) {
@@ -193,15 +193,6 @@ function defOn(world: World, entity: Entity, key: string): PropertyDef | undefin
return asset && animatedDefOf(world.project, asset, entity.props, key);
}
/**
* The id a template object gets below a placed container – nested one deeper when the container is
* content itself. `path` may run through containers placed inside the template (`a/b`).
*/
function childId(ownerId: string, path: string): string {
const split = splitInstanceId(ownerId);
return split ? instanceChildId(split.instanceId, `${split.path}/${path}`) : instanceChildId(ownerId, path);
}
/** The object below `owner` with these names, level by level; none when a name is missing or taken twice. */
function byNames(world: World, owner: Entity, names: string[] | undefined): Entity | undefined {
if (!names) return undefined;
+14 -3
View File
@@ -43,6 +43,7 @@ import {
collisionEnterPulses,
collisionExitPulses,
collisionStayPulses,
compositionFinishedPulses,
type Instance,
keyDownPulses,
type PerCell,
@@ -54,6 +55,7 @@ import {
import { CollisionSystem } from './CollisionSystem';
import type { CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem';
import { CompositionSystem } from './CompositionSystem';
import type { Input } from './Input';
import type { RecordingSink } from '../../core/recording/recording';
import type { System } from './System';
@@ -118,9 +120,11 @@ export interface GameSystemOptions {
saves?: SaveArea | null;
/** Where `Start recording` records to (#405): only a played game has one. */
recorder?: RecordingSink | null;
/** Whether `Play composition` plays (#417); the editor run ignores it. */
playsCompositions?: boolean;
}
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
export function addGameSystems(
world: World,
project: Project,
@@ -134,11 +138,13 @@ export function addGameSystems(
clock = null,
saves = null,
recorder = null,
playsCompositions = true,
}: GameSystemOptions = {},
): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const compositions = new CompositionSystem(playsCompositions);
const animations = new AnimationSystem();
const sources = { collisions, animations, audio, clock, saves, recorder };
const sources = { collisions, animations, compositions, audio, clock, saves, recorder };
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
if (carried) behaviors.carryOver(carried);
if (kept) {
@@ -146,6 +152,7 @@ export function addGameSystems(
if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion);
}
world.addSystem(collisions);
world.addSystem(compositions);
world.addSystem(animations);
world.addSystem(behaviors);
return behaviors;
@@ -155,6 +162,7 @@ export function addGameSystems(
export interface EventSources {
collisions?: (CollisionSource & System) | null;
animations?: AnimationSystem | null;
compositions?: CompositionSystem | null;
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null;
clock?: TimeScaled | null;
@@ -200,6 +208,7 @@ export class BehaviorSystem implements System {
private adopted: Instance[] = [];
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly compositions: CompositionSystem | null;
private readonly audio: AudioSink;
private readonly clock: TimeScaled | null;
private readonly saves: SaveArea | null;
@@ -219,6 +228,7 @@ export class BehaviorSystem implements System {
) {
this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null;
this.compositions = sources.compositions ?? null;
this.audio = sources.audio ?? SILENT_AUDIO;
this.clock = sources.clock ?? null;
this.saves = sources.saves ?? null;
@@ -614,7 +624,7 @@ export class BehaviorSystem implements System {
const owner = i.entity.parent;
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
};
const { input, collisions, animations } = this;
const { input, collisions, animations, compositions } = this;
const perCell: PerCell = (i, node) =>
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
const kinds: (() => Pulse[])[] = [
@@ -630,6 +640,7 @@ export class BehaviorSystem implements System {
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
() => (frozen ? [] : ticking.flatMap((i) => compositionFinishedPulses(i, compositions))),
() =>
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
() =>
+161
View File
@@ -0,0 +1,161 @@
import { isHandleEnabled } from '../../core/graph';
import { trackWriteAt } from '../../core/model/animation';
import {
compositionAt,
compositionFrameAt,
compositionFps,
compositionLength,
compositionLoops,
type LiveItem,
} from '../../core/model/composition';
import type { CompositionAsset } from '../../core/model/Asset';
import { curveLookup } from '../../core/model/curve';
import { contentId, placedWithContent } from '../../core/model/instance';
import { findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { findVideo } from '../../core/model/video';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bind } from './AnimationSystem';
import type { System } from './System';
export interface CompositionFinish {
/** The placed composition. */
entity: Entity;
compositionId: string;
startedBy: readonly object[];
}
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
interface Spawned {
entity: Entity;
bases: Map<string, PropertyValue>;
videos: Entity[];
}
interface Run {
id: number;
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
live: Map<string, Spawned>;
}
/**
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
* when the item begins and destroys it when it ends, writes the clips over them and drives their
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
* a cut would stay behind.
*/
export class CompositionSystem implements System {
finished: CompositionFinish[] = [];
private readonly runs = new Map<Entity, Run>();
constructor(private readonly plays = true) {}
update(world: World, dt: number): void {
this.finished = [];
for (const placement of this.runs.keys())
if (!world.entities.includes(placement)) this.runs.delete(placement);
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
for (const placement of started) this.advance(world, placement, dt);
}
stop(): void {
this.finished = [];
this.runs.clear();
}
private advance(world: World, placement: Entity, dt: number): void {
const player = placement.composition!;
if (!this.plays) {
world.log.push('Play composition is ignored in the editor run');
delete placement.composition;
return;
}
let run = this.runs.get(placement);
if (run && run.id !== player.run) this.end(world, placement);
const comp = findAssetOf(world.project, placement.assetId, 'composition');
if (!comp || player.stopped) {
this.end(world, placement);
delete placement.composition;
return;
}
run = this.runs.get(placement);
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() }));
if (player.shown && isHandleEnabled(placement)) player.time += dt;
player.shown = true;
const length = compositionLength(world.project, comp);
let frame = compositionFrameAt(comp, player.time);
if (frame >= length && compositionLoops(comp) && length > 0) {
player.time %= length / compositionFps(comp);
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
} else if (frame >= length) {
this.end(world, placement);
delete placement.composition;
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
return;
}
this.show(world, comp, placement, run, frame);
}
/** Takes every item of the placement's run away. */
private end(world: World, placement: Entity): void {
const run = this.runs.get(placement);
if (!run) return;
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
this.runs.delete(placement);
}
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
const live = compositionAt(world.project, comp, frame);
const alive = new Set(live.map((l) => l.item.id));
for (const [id, { entity }] of run.live) {
if (alive.has(id)) continue;
if (world.entities.includes(entity)) world.remove(entity);
run.live.delete(id);
}
let born = false;
for (const item of live) {
if (run.live.has(item.item.id)) continue;
const spawned = spawn(world, placement, item);
if (!spawned) continue;
run.live.set(item.item.id, spawned);
born = true;
}
// In the order the tracks lay them, whenever they began.
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
const curves = curveLookup(world.project);
for (const item of live) {
const spawned = run.live.get(item.item.id);
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
}
}
}
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
const id = contentId(placement.objectId, item.object.id);
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
const entity = entities.find((e) => e.objectId === id);
if (!entity) return null;
// Below the item, a sprite set to be driven by hand keeps its own time.
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
return { entity, bases: new Map(), videos };
}
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
for (const sprite of spawned.videos) {
const texture = sprite.props.texture;
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
}
for (const clip of item.clips) {
for (const b of bind(world, clip.animation, spawned.entity)) {
let base = spawned.bases.get(b.base);
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
if (value !== undefined) b.write(value);
}
}
}
+24
View File
@@ -10,6 +10,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
import { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity';
import type { AnimationSystem } from './AnimationSystem';
import type { CompositionSystem } from './CompositionSystem';
import type { Input } from './Input';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
@@ -226,6 +227,29 @@ export function animationFinishedPulses(instance: Instance, animations: Animatio
});
}
/**
* A behavior hears the compositions placed at its owner or below it, and those it played itself
* wherever they stand – the rule of `On animation finished`.
*/
export function compositionFinishedPulses(
instance: Instance,
compositions: CompositionSystem | null,
): Pulse[] {
const owner = instance.entity.parent;
const finished = (compositions?.finished ?? []).filter(
({ entity, startedBy }) =>
!owner ||
startedBy.includes(instance.entity) ||
[...parentChain(entity, (e) => e.parent)].includes(owner),
);
return instance.interpreter.eventNodes('OnCompositionFinished').flatMap((node) => {
const wanted = textOf(node.params.composition ?? '');
return finished
.filter(({ compositionId }) => wanted === '' || wanted === compositionId)
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
});
}
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = {