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