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
@@ -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;
|
||||
|
||||
@@ -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 }) : [],
|
||||
() =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
Reference in new issue
Block a user