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
@@ -1,4 +1,5 @@
|
||||
import type { AnimationPlayer, EaseShare } from '../model/animation';
|
||||
import type { CompositionPlayer } from '../model/composition';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
@@ -36,6 +37,8 @@ export interface EntityHandle {
|
||||
emits?: number;
|
||||
/** What `Play animation` started on this object (see `model/animation.ts`). */
|
||||
animations?: AnimationPlayer[];
|
||||
/** What `Play composition` started on a placed composition (see `model/composition.ts`). */
|
||||
composition?: CompositionPlayer;
|
||||
}
|
||||
|
||||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { playComposition, stopComposition } from '../../../model/composition';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asEntity } from '../../values';
|
||||
import { bool, exec, obj, withDefault } from '../shared';
|
||||
|
||||
export const PlayComposition: NodeDef = {
|
||||
type: 'PlayComposition',
|
||||
label: 'Play composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a composition placed in the scene from its first frame: each item becomes an object below ' +
|
||||
'it when the item begins – its behaviors start then – and is destroyed when it ends. One already ' +
|
||||
'playing plays on; Restart starts it over. Without Loop it ends after its last item, and On ' +
|
||||
'composition finished hears that. The editor run plays none and says so in the log.',
|
||||
inputs: [exec(), obj('object'), withDefault(bool('restart'), false)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) playComposition(target, asBoolean(inputs.restart), ctx.self);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const StopComposition: NodeDef = {
|
||||
type: 'StopComposition',
|
||||
label: 'Stop composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Stops a playing composition: the objects of its items are destroyed on the next frame. It does ' +
|
||||
'not count as finished, and Play composition starts it from its first frame again.',
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) stopComposition(target);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, obj } from '../shared';
|
||||
|
||||
export const OnCompositionFinished: NodeDef = {
|
||||
type: 'OnCompositionFinished',
|
||||
label: 'On composition finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a composition placed at the owner, or below it, plays past its last item – and when ' +
|
||||
'one this behavior played itself does, wherever it stands. A looping one never finishes, and Stop ' +
|
||||
'composition does not count. Leave Composition at None to hear every composition.',
|
||||
inputs: [],
|
||||
outputs: [exec(), obj('object')],
|
||||
params: [
|
||||
{ key: 'composition', label: 'Composition', kind: 'asset', default: '', assetTypes: ['composition'] },
|
||||
orderParam,
|
||||
],
|
||||
hiddenParams: (params) => [
|
||||
...(textOf(params.composition ?? '') ? [] : ['composition']),
|
||||
...hiddenOrder(params),
|
||||
],
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
|
||||
import { AddJoint } from './action/AddJoint';
|
||||
import { Destroy } from './action/Destroy';
|
||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||
import { PlayComposition, StopComposition } from './action/compositionNodes';
|
||||
import { Emit } from './action/Emit';
|
||||
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
@@ -123,6 +124,7 @@ import { SetItem } from './data/SetItem';
|
||||
import { SubList } from './data/SubList';
|
||||
import { TextureSize } from './data/TextureSize';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
import { OnCompositionFinished } from './events/OnCompositionFinished';
|
||||
import { OnBehavior } from './events/OnBehavior';
|
||||
import { OnClick } from './events/OnClick';
|
||||
import { OnMouseWheel } from './events/OnMouseWheel';
|
||||
@@ -183,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnClick,
|
||||
OnMouseWheel,
|
||||
OnAnimationFinished,
|
||||
OnCompositionFinished,
|
||||
OnBehavior,
|
||||
OnStateEntered,
|
||||
OnStateExited,
|
||||
@@ -308,6 +311,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
PlayAnimation,
|
||||
PauseAnimation,
|
||||
StopAnimation,
|
||||
PlayComposition,
|
||||
StopComposition,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
SetSoundFxParameters,
|
||||
|
||||
@@ -84,8 +84,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* sound is played through (#311), heard but never seen, an instrument what a note is played
|
||||
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
|
||||
* where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through
|
||||
* `texture` (#402), as it wears an image. A composition is not placed yet: what a placed one shows
|
||||
* and plays is still to come (#417).
|
||||
* `texture` (#402), as it wears an image.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -110,7 +109,6 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
| 'video'
|
||||
| 'composition'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -135,7 +133,6 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'variables',
|
||||
'struct',
|
||||
'video',
|
||||
'composition',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
@@ -155,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean {
|
||||
type === 'rigidbody' ||
|
||||
type === 'joint' ||
|
||||
type === 'container' ||
|
||||
type === 'composition' ||
|
||||
type === 'clip'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,14 @@ import type {
|
||||
CompositionTrack,
|
||||
} from './Asset';
|
||||
import { curveLookup } from './curve';
|
||||
import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance';
|
||||
import {
|
||||
contentId,
|
||||
instanceChildId,
|
||||
objectRef,
|
||||
placedWithContent,
|
||||
resolveObjects,
|
||||
splitInstanceId,
|
||||
} from './instance';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
@@ -104,6 +111,57 @@ export function fittedLength(project: Project, comp: CompositionAsset, item: Com
|
||||
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* A composition placed in a running game that `Play composition` started; kept on its entity, out of
|
||||
* `props`. Its items exist only while it plays.
|
||||
*/
|
||||
export interface CompositionPlayer {
|
||||
/** Tells a start apart from the one before, so a restart builds every item anew. */
|
||||
run: number;
|
||||
/** Seconds since its first frame. */
|
||||
time: number;
|
||||
/** Whether the first frame was shown: the clock runs from the tick after the start. */
|
||||
shown: boolean;
|
||||
/** Set by `Stop composition`: the next tick takes its items away. */
|
||||
stopped: boolean;
|
||||
/** The behaviors that played it; they hear `On composition finished` wherever it stands. */
|
||||
startedBy: object[];
|
||||
}
|
||||
|
||||
let runCount = 0;
|
||||
|
||||
/**
|
||||
* Starts the composition placed as `holder` from its first frame. One already playing plays on, unless
|
||||
* `restart` – or a `Stop composition` this tick – asks to start over.
|
||||
*/
|
||||
export function playComposition(
|
||||
holder: { composition?: CompositionPlayer },
|
||||
restart = false,
|
||||
startedBy?: object,
|
||||
): void {
|
||||
const playing = holder.composition;
|
||||
if (playing && !playing.stopped && !restart) {
|
||||
if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy);
|
||||
return;
|
||||
}
|
||||
holder.composition = {
|
||||
run: ++runCount,
|
||||
time: 0,
|
||||
shown: false,
|
||||
stopped: false,
|
||||
startedBy: startedBy ? [startedBy] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function stopComposition(holder: { composition?: CompositionPlayer }): void {
|
||||
if (holder.composition) holder.composition.stopped = true;
|
||||
}
|
||||
|
||||
/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */
|
||||
export function compositionFrameAt(comp: CompositionAsset, time: number): number {
|
||||
return Math.floor(time * compositionFps(comp) + 1e-6);
|
||||
}
|
||||
|
||||
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
|
||||
export interface ClipAt {
|
||||
clip: CompositionClip;
|
||||
@@ -187,6 +245,17 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a composition placed as `placementId` shows in the editor: its first frame, as objects below
|
||||
* the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and
|
||||
* its items are edited in the composition only.
|
||||
*/
|
||||
export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] {
|
||||
return compositionHolder(project, comp, 0).objects.flatMap((o) =>
|
||||
placedWithContent(project, o, contentId(placementId, o.id), placementId),
|
||||
);
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||||
|
||||
@@ -436,6 +436,40 @@ export function materializeInstance(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The id the object at `path` below `ownerId` has – one level deeper when the owner is content
|
||||
* itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives.
|
||||
*/
|
||||
export function contentId(ownerId: string, path: string): string {
|
||||
const split = splitInstanceId(ownerId);
|
||||
return split
|
||||
? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`)
|
||||
: instanceChildId(ownerId, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* `object` as `id` below `parentId`, followed by its container content with the ids below that one –
|
||||
* how a composition puts its objects into a scene (#417). Copies, free to be stored and written to.
|
||||
*/
|
||||
export function placedWithContent(
|
||||
project: Project,
|
||||
object: SceneObject,
|
||||
id: string,
|
||||
parentId: string,
|
||||
): SceneObject[] {
|
||||
const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1)));
|
||||
const { groupId: _, ...top } = object;
|
||||
return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({
|
||||
...o,
|
||||
id: rename(o.id),
|
||||
parentId: o.id === object.id ? parentId : rename(o.parentId),
|
||||
position: { ...o.position },
|
||||
editorOffset: { ...o.editorOffset },
|
||||
overrides: { ...o.overrides },
|
||||
instanceEdits: {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spells out every live instance of the holder. The runtime needs it: entities are built from
|
||||
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
|
||||
|
||||
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
|
||||
'camera',
|
||||
'clip',
|
||||
'collider',
|
||||
'composition',
|
||||
'container',
|
||||
'joint',
|
||||
'particles',
|
||||
|
||||
@@ -371,6 +371,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'text',
|
||||
'collider',
|
||||
'container',
|
||||
'composition',
|
||||
'clip',
|
||||
'particles',
|
||||
'tilemap',
|
||||
@@ -438,6 +439,7 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'text',
|
||||
'particles',
|
||||
'container',
|
||||
'composition',
|
||||
'tilemap',
|
||||
]);
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function footprintOf(
|
||||
case 'behavior':
|
||||
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||||
case 'container':
|
||||
case 'composition':
|
||||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||||
case 'rigidbody':
|
||||
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
||||
|
||||
Reference in new issue
Block a user