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

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2023 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3
View File
@@ -1,4 +1,5 @@
import type { AnimationPlayer, EaseShare } from '../model/animation'; import type { AnimationPlayer, EaseShare } from '../model/animation';
import type { CompositionPlayer } from '../model/composition';
import type { Mat } from '../model/matrix'; import type { Mat } from '../model/matrix';
import type { Position, PropertyBag } from '../model/Properties'; import type { Position, PropertyBag } from '../model/Properties';
import type { FxParams } from '../soundfx/nodes'; import type { FxParams } from '../soundfx/nodes';
@@ -36,6 +37,8 @@ export interface EntityHandle {
emits?: number; emits?: number;
/** What `Play animation` started on this object (see `model/animation.ts`). */ /** What `Play animation` started on this object (see `model/animation.ts`). */
animations?: AnimationPlayer[]; 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. */ /** 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),
],
};
+5
View File
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
import { AddJoint } from './action/AddJoint'; import { AddJoint } from './action/AddJoint';
import { Destroy } from './action/Destroy'; import { Destroy } from './action/Destroy';
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes'; import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
import { PlayComposition, StopComposition } from './action/compositionNodes';
import { Emit } from './action/Emit'; import { Emit } from './action/Emit';
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes'; import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
import { LoadScene } from './action/LoadScene'; import { LoadScene } from './action/LoadScene';
@@ -123,6 +124,7 @@ import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList'; import { SubList } from './data/SubList';
import { TextureSize } from './data/TextureSize'; import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished'; import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnCompositionFinished } from './events/OnCompositionFinished';
import { OnBehavior } from './events/OnBehavior'; import { OnBehavior } from './events/OnBehavior';
import { OnClick } from './events/OnClick'; import { OnClick } from './events/OnClick';
import { OnMouseWheel } from './events/OnMouseWheel'; import { OnMouseWheel } from './events/OnMouseWheel';
@@ -183,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnClick, OnClick,
OnMouseWheel, OnMouseWheel,
OnAnimationFinished, OnAnimationFinished,
OnCompositionFinished,
OnBehavior, OnBehavior,
OnStateEntered, OnStateEntered,
OnStateExited, OnStateExited,
@@ -308,6 +311,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
PlayAnimation, PlayAnimation,
PauseAnimation, PauseAnimation,
StopAnimation, StopAnimation,
PlayComposition,
StopComposition,
PlaySound, PlaySound,
StopSound, StopSound,
SetSoundFxParameters, SetSoundFxParameters,
+2 -4
View File
@@ -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 * 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 * 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 * 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 * `texture` (#402), as it wears an image.
* and plays is still to come (#417).
*/ */
export type PlaceableAssetType = Exclude< export type PlaceableAssetType = Exclude<
AssetType, AssetType,
@@ -110,7 +109,6 @@ export type PlaceableAssetType = Exclude<
| 'variables' | 'variables'
| 'struct' | 'struct'
| 'video' | 'video'
| 'composition'
>; >;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -135,7 +133,6 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'variables', 'variables',
'struct', 'struct',
'video', 'video',
'composition',
]); ]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType { export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
@@ -155,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean {
type === 'rigidbody' || type === 'rigidbody' ||
type === 'joint' || type === 'joint' ||
type === 'container' || type === 'container' ||
type === 'composition' ||
type === 'clip' type === 'clip'
); );
} }
+70 -1
View File
@@ -22,7 +22,14 @@ import type {
CompositionTrack, CompositionTrack,
} from './Asset'; } from './Asset';
import { curveLookup } from './curve'; 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 { findAsset, findAssetOf, type Project } from './Project';
import { booleanOf, type PropertyBag } from './Properties'; import { booleanOf, type PropertyBag } from './Properties';
import { objectName, type SceneObject } from './SceneObject'; 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); 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. */ /** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
export interface ClipAt { export interface ClipAt {
clip: CompositionClip; clip: CompositionClip;
@@ -187,6 +245,17 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram
return holder; 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> => const record = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}; typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
const text = (value: unknown) => (typeof value === 'string' ? value : ''); const text = (value: unknown) => (typeof value === 'string' ? value : '');
+34
View File
@@ -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 * 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 * stored objects, and a behavior may destroy or move one of them – which a resolved copy could
+1
View File
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
'camera', 'camera',
'clip', 'clip',
'collider', 'collider',
'composition',
'container', 'container',
'joint', 'joint',
'particles', 'particles',
+2
View File
@@ -371,6 +371,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'text', 'text',
'collider', 'collider',
'container', 'container',
'composition',
'clip', 'clip',
'particles', 'particles',
'tilemap', 'tilemap',
@@ -438,6 +439,7 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
'text', 'text',
'particles', 'particles',
'container', 'container',
'composition',
'tilemap', 'tilemap',
]); ]);
+1
View File
@@ -71,6 +71,7 @@ export function footprintOf(
case 'behavior': case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container': case 'container':
case 'composition':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
case 'rigidbody': case 'rigidbody':
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL }; return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
+1
View File
@@ -136,6 +136,7 @@ export class EditorRun {
shouldRun: (e) => runsInEditor(e.props), shouldRun: (e) => runsInEditor(e.props),
audio: soundLibrary, audio: soundLibrary,
saves: saveArea(project.id, 'editor'), saves: saveArea(project.id, 'editor'),
playsCompositions: false,
}); });
this.world = fresh; this.world = fresh;
this.hadFocus = this.hooks.focused(); this.hadFocus = this.hooks.focused();
+9 -3
View File
@@ -113,7 +113,7 @@ const LABEL_PAD_Y = 1;
const LABEL_ROW_GAP = 1; const LABEL_ROW_GAP = 1;
const LABEL_PLATE_ALPHA = 0.7; const LABEL_PLATE_ALPHA = 0.7;
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */ /** Types whose icon is all there is of them: they are selected and outlined where it stands. */
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody', 'joint']); const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'composition', 'rigidbody', 'joint']);
/** Owner links in the colour of the icon they leave; anything else in the muted default. */ /** Owner links in the colour of the icon they leave; anything else in the muted default. */
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = { const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
@@ -268,7 +268,13 @@ export class SceneViewport extends FreeViewport {
} }
protected override collectFrame(): Frame { protected override collectFrame(): Frame {
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport, this.editorWorld); return collectHolderItems(
this.state.project,
this.holder(),
Theme.colors.viewport,
this.editorWorld,
this.state.mode === 'edit',
);
} }
protected override get baseColor(): number { protected override get baseColor(): number {
@@ -725,7 +731,7 @@ export class SceneViewport extends FreeViewport {
const minY = Math.min(a.y, b.y); const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y); const maxY = Math.max(a.y, b.y);
const ids = this.lastItems const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY) .filter((i) => i.enabled && !i.sealedIn && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id); .map((i) => i.id);
this.state.select(objectSelection(holder.id, ids)); this.state.select(objectSelection(holder.id, ids));
} }
+13 -3
View File
@@ -54,8 +54,18 @@ const DRAG_THRESHOLD = 2;
const TURN_SNAP = 15; const TURN_SNAP = 15;
/** How strongly the frame round a container's content is drawn. */ /** How strongly the frame round a container's content is drawn. */
const FRAME_ALPHA = 0.6; const FRAME_ALPHA = 0.6;
/** A placed composition is framed like a container, round the picture it shows (#417). */
const holdsContent = (item: DrawItem) => item.type === 'container' || item.type === 'composition';
/** Content that is only an icon or a view, and so takes no room in a container's frame. */ /** Content that is only an icon or a view, and so takes no room in a container's frame. */
const SHAPELESS = new Set<DrawItem['type']>(['behavior', 'rigidbody', 'joint', 'camera', 'container']); const SHAPELESS = new Set<DrawItem['type']>([
'behavior',
'rigidbody',
'joint',
'camera',
'container',
'composition',
]);
/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */ /** The keys a drag writes; the preview puts the values straight on the object, the release a command. */
type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>; type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
@@ -129,7 +139,7 @@ export class SizeGizmoEditor {
/** A container is framed round its content (#242), everything else round its own footprint. */ /** A container is framed round its content (#242), everything else round its own footprint. */
private worldBox(item: DrawItem): Vec2[] { private worldBox(item: DrawItem): Vec2[] {
if (item.type === 'container') { if (holdsContent(item)) {
const frame = contentFrame(item.m, this.contentCorners(item.id)); const frame = contentFrame(item.m, this.contentCorners(item.id));
if (frame) return frame; if (frame) return frame;
} }
@@ -303,7 +313,7 @@ export class SizeGizmoEditor {
}); });
const color = Theme.colors.gizmo; const color = Theme.colors.gizmo;
// The selection outline sits round the container's icon, so the frame the knob belongs to is drawn here. // The selection outline sits round the container's icon, so the frame the knob belongs to is drawn here.
if (item.type === 'container') g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA }); if (holdsContent(item)) g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA });
g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color }); g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color });
const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob'; const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob';
g.circle(knob.x, knob.y, KNOB_RADIUS) g.circle(knob.x, knob.y, KNOB_RADIUS)
+3
View File
@@ -1,6 +1,7 @@
import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
import type { AnimationPlayer } from '../core/model/animation'; import type { AnimationPlayer } from '../core/model/animation';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import type { CompositionPlayer } from '../core/model/composition';
import type { FrameOutline } from '../core/model/frameOutline'; import type { FrameOutline } from '../core/model/frameOutline';
import type { TileEdits } from '../core/model/liveTilemap'; import type { TileEdits } from '../core/model/liveTilemap';
import type { PropertyBag } from '../core/model/Properties'; import type { PropertyBag } from '../core/model/Properties';
@@ -26,6 +27,8 @@ export interface Entity {
parent: Entity | null; parent: Entity | null;
/** The animations playing on this object; kept out of `props`, which the world writes back. */ /** The animations playing on this object; kept out of `props`, which the world writes back. */
animations?: AnimationPlayer[]; animations?: AnimationPlayer[];
/** For a placed composition: what `Play composition` started (#417), out of `props` like `animations`. */
composition?: CompositionPlayer;
/** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */ /** How often an `Emit` node has restarted this particle source; out of `props` for the same reason. */
emits?: number; emits?: number;
/** /**
+47
View File
@@ -96,6 +96,14 @@ function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] {
return keys ?? NO_KEYS; return keys ?? NO_KEYS;
} }
/** `order`'s members of `list` in that order, in the slots of `list` they held before. */
function reorderIn<T>(list: T[], order: readonly T[]): void {
const slots = new Map(list.map((value, index) => [value, index]));
const present = order.filter((value) => slots.has(value));
const indices = present.map((value) => slots.get(value)!).sort((a, b) => a - b);
indices.forEach((slot, i) => (list[slot] = present[i]!));
}
/** /**
* The running game: entities of one scene plus the systems ticking them. Built on the play copy * The running game: entities of one scene plus the systems ticking them. Built on the play copy
* (discarded on stop) or, for the editor run, on the saved model, where its writes stay. * (discarded on stop) or, for the editor run, on the saved model, where its writes stay.
@@ -383,6 +391,45 @@ export class World {
return entity; return entity;
} }
/**
* Adds objects a composition plays into the scene (#417), each below the parent it names, and
* answers their entities. An object of the same id still in the scene – one taken away this tick –
* gives way, so the scene never holds an id twice.
*/
place(objects: readonly SceneObject[]): Entity[] {
const scene = findScene(this.project, this.sceneId);
if (!scene) return [];
const ids = new Set(objects.map((o) => o.id));
for (let i = scene.objects.length - 1; i >= 0; i--)
if (ids.has(scene.objects[i]!.id)) scene.objects.splice(i, 1);
scene.objects.push(...objects);
const placed: Entity[] = [];
for (const object of objects) {
const asset = findAsset(this.project, object.assetId);
if (!asset || !isPlaceableType(asset.type)) continue;
const entity = this.materialize(object, asset);
entity.parent = this.findByObjectId(object.parentId) ?? null;
placed.push(entity);
}
this.structureChanged = true;
return placed;
}
/**
* Puts these entities, and their objects in the scene, in this order among the places they already
* hold – the order that decides between equal `z` (#417).
*/
arrange(order: readonly Entity[]): void {
reorderIn(this.entities, order);
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
const byId = new Map(scene.objects.map((o) => [o.id, o]));
reorderIn(
scene.objects,
order.map((e) => byId.get(e.objectId)).filter((o): o is SceneObject => o !== undefined),
);
}
/** /**
* Writes the entities' state into the scene: positions, property values that differ from * Writes the entities' state into the scene: positions, property values that differ from
* what the model resolves (stored as overrides, so the inspector marks them) and removed * what the model resolves (stored as overrides, so the inspector marks them) and removed
+3 -12
View File
@@ -15,7 +15,7 @@ import {
} from '../../core/model/animation'; } from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { curveLookup } from '../../core/model/curve'; 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 { findAsset, findAssetOf } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema'; 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 * there, so one animation fits containers built alike. A row whose target is gone, or lacks the
* property, is skipped. * 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); const root = findAsset(world.project, asset.root);
if (!root) return []; if (!root) return [];
const bindings: Binding[] = []; const bindings: Binding[] = [];
@@ -167,7 +167,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
else if (track.target === '') entity = owner; else if (track.target === '') entity = owner;
else else
entity = entity =
world.findByObjectId(childId(owner.objectId, track.target)) ?? world.findByObjectId(contentId(owner.objectId, track.target)) ??
byNames(world, owner, targetNamePath(world.project, root, track.target)); byNames(world, owner, targetNamePath(world.project, root, track.target));
const def = entity && defOn(world, entity, key); const def = entity && defOn(world, entity, key);
if (entity && def) { 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); 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. */ /** 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 { function byNames(world: World, owner: Entity, names: string[] | undefined): Entity | undefined {
if (!names) return undefined; if (!names) return undefined;
+14 -3
View File
@@ -43,6 +43,7 @@ import {
collisionEnterPulses, collisionEnterPulses,
collisionExitPulses, collisionExitPulses,
collisionStayPulses, collisionStayPulses,
compositionFinishedPulses,
type Instance, type Instance,
keyDownPulses, keyDownPulses,
type PerCell, type PerCell,
@@ -54,6 +55,7 @@ import {
import { CollisionSystem } from './CollisionSystem'; import { CollisionSystem } from './CollisionSystem';
import type { CollisionSource } from './solids'; import type { CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem'; import { AnimationSystem } from './AnimationSystem';
import { CompositionSystem } from './CompositionSystem';
import type { Input } from './Input'; import type { Input } from './Input';
import type { RecordingSink } from '../../core/recording/recording'; import type { RecordingSink } from '../../core/recording/recording';
import type { System } from './System'; import type { System } from './System';
@@ -118,9 +120,11 @@ export interface GameSystemOptions {
saves?: SaveArea | null; saves?: SaveArea | null;
/** Where `Start recording` records to (#405): only a played game has one. */ /** Where `Start recording` records to (#405): only a played game has one. */
recorder?: RecordingSink | null; 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( export function addGameSystems(
world: World, world: World,
project: Project, project: Project,
@@ -134,11 +138,13 @@ export function addGameSystems(
clock = null, clock = null,
saves = null, saves = null,
recorder = null, recorder = null,
playsCompositions = true,
}: GameSystemOptions = {}, }: GameSystemOptions = {},
): BehaviorSystem { ): BehaviorSystem {
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem(); const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const compositions = new CompositionSystem(playsCompositions);
const animations = new AnimationSystem(); 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); const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
if (carried) behaviors.carryOver(carried); if (carried) behaviors.carryOver(carried);
if (kept) { if (kept) {
@@ -146,6 +152,7 @@ export function addGameSystems(
if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion); if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion);
} }
world.addSystem(collisions); world.addSystem(collisions);
world.addSystem(compositions);
world.addSystem(animations); world.addSystem(animations);
world.addSystem(behaviors); world.addSystem(behaviors);
return behaviors; return behaviors;
@@ -155,6 +162,7 @@ export function addGameSystems(
export interface EventSources { export interface EventSources {
collisions?: (CollisionSource & System) | null; collisions?: (CollisionSource & System) | null;
animations?: AnimationSystem | 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. */ /** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null; audio?: AudioSink | null;
clock?: TimeScaled | null; clock?: TimeScaled | null;
@@ -200,6 +208,7 @@ export class BehaviorSystem implements System {
private adopted: Instance[] = []; private adopted: Instance[] = [];
private readonly collisions: CollisionSource | null; private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null; private readonly animations: AnimationSystem | null;
private readonly compositions: CompositionSystem | null;
private readonly audio: AudioSink; private readonly audio: AudioSink;
private readonly clock: TimeScaled | null; private readonly clock: TimeScaled | null;
private readonly saves: SaveArea | null; private readonly saves: SaveArea | null;
@@ -219,6 +228,7 @@ export class BehaviorSystem implements System {
) { ) {
this.collisions = sources.collisions ?? null; this.collisions = sources.collisions ?? null;
this.animations = sources.animations ?? null; this.animations = sources.animations ?? null;
this.compositions = sources.compositions ?? null;
this.audio = sources.audio ?? SILENT_AUDIO; this.audio = sources.audio ?? SILENT_AUDIO;
this.clock = sources.clock ?? null; this.clock = sources.clock ?? null;
this.saves = sources.saves ?? null; this.saves = sources.saves ?? null;
@@ -614,7 +624,7 @@ export class BehaviorSystem implements System {
const owner = i.entity.parent; const owner = i.entity.parent;
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside; 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) => const perCell: PerCell = (i, node) =>
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i))); asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
const kinds: (() => Pulse[])[] = [ 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) => collisionStayPulses(i, collisions, dt, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))), () => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))), () => (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 }) : [], 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 { parentChain } from '../../core/model/walk';
import type { Entity } from '../Entity'; import type { Entity } from '../Entity';
import type { AnimationSystem } from './AnimationSystem'; import type { AnimationSystem } from './AnimationSystem';
import type { CompositionSystem } from './CompositionSystem';
import type { Input } from './Input'; import type { Input } from './Input';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids'; 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. */ /** 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> { function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
const outputs: Record<string, Value> = { const outputs: Record<string, Value> = {
+19 -2
View File
@@ -1,6 +1,7 @@
import { isHandleEnabled, worldTransformOf } from '../core/graph'; import { isHandleEnabled, worldTransformOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes'; import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { treeOrder } from '../core/model/hierarchy'; import { treeOrder } from '../core/model/hierarchy';
import { placedPicture } from '../core/model/composition';
import { resolvedHolder } from '../core/model/instance'; import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix'; import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties'; import type { PropertyBag } from '../core/model/Properties';
@@ -79,6 +80,8 @@ export interface DrawItem {
clip: ClipRegion | null; clip: ClipRegion | null;
/** The render layer it is on, its parents' choice included (#302): which cameras see it. */ /** The render layer it is on, its parents' choice included (#302): which cameras see it. */
renderLayer: number; renderLayer: number;
/** Part of what a placed composition shows (#417): the placement, which a click on it selects. */
sealedIn?: string;
} }
interface DrawList { interface DrawList {
@@ -200,16 +203,29 @@ function wearsPicture(type: AssetType): boolean {
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor * A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
* positions, with the content of placed containers resolved in. A container has no background of * positions, with the content of placed containers resolved in. A container has no background of
* its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors * its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors
* set are shown on the maps, though they never reach the model (#135). * set are shown on the maps, though they never reach the model (#135). A placed composition shows
* its first frame (#417) unless `pictures` is off – in play mode, where its items are objects of the
* run itself.
*/ */
export function collectHolderItems( export function collectHolderItems(
project: Project, project: Project,
holder: ObjectHolder | undefined, holder: ObjectHolder | undefined,
fallbackBackground: number, fallbackBackground: number,
run: World | null = null, run: World | null = null,
pictures = true,
): DrawList { ): DrawList {
if (!holder) return { items: [], background: fallbackBackground }; if (!holder) return { items: [], background: fallbackBackground };
const view = resolvedHolder(project, holder); const resolved = resolvedHolder(project, holder);
const sealed = new Map<string, string>();
const shown = pictures
? resolved.objects.flatMap((placement) => {
const comp = findAssetOf(project, placement.assetId, 'composition');
const picture = comp ? placedPicture(project, comp, placement.id) : [];
for (const o of picture) sealed.set(o.id, placement.id);
return picture;
})
: [];
const view = shown.length > 0 ? { ...resolved, objects: [...resolved.objects, ...shown] } : resolved;
const items: DrawItem[] = []; const items: DrawItem[] = [];
for (const { object } of treeOrder(view)) { for (const { object } of treeOrder(view)) {
const asset = findAsset(project, object.assetId); const asset = findAsset(project, object.assetId);
@@ -240,6 +256,7 @@ export function collectHolderItems(
frame: asset.type === 'collider' ? colliderFrame(project, props) : null, frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
clip: null, clip: null,
renderLayer: 0, renderLayer: 0,
...(sealed.has(object.id) && { sealedIn: sealed.get(object.id) }),
}); });
} }
const byId = new Map(items.map((i) => [i.id, i])); const byId = new Map(items.map((i) => [i.id, i]));
+4 -2
View File
@@ -16,7 +16,8 @@ export interface PointerHit {
* What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where * What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where
* one of its layers has a tile, and at the height of the highest such layer: an empty cell is * one of its layers has a tile, and at the height of the highest such layer: an empty cell is
* empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn * empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn
* above everything, so they come first. * above everything, so they come first. What a placed composition shows answers as the placement,
* once, where its frontmost part lies (#417).
*/ */
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] { export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
const rank = new Map<string, number>(); const rank = new Map<string, number>();
@@ -35,7 +36,8 @@ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[])
const best = Math.max(-1, ...ranks); const best = Math.max(-1, ...ranks);
if (best >= 0) ranked.push({ id: hit.id, rank: best }); if (best >= 0) ranked.push({ id: hit.id, rank: best });
} }
return ranked.sort((a, b) => b.rank - a.rank).map((r) => r.id); const ids = ranked.sort((a, b) => b.rank - a.rank).map((r) => items[index.get(r.id)!]!.sealedIn ?? r.id);
return [...new Set(ids)];
} }
/** /**
+3 -1
View File
@@ -8,18 +8,20 @@ import { drawSvg, haloSvg, Theme } from '../ui';
import behavior from '../editor/panels/icons/behavior.svg?raw'; import behavior from '../editor/panels/icons/behavior.svg?raw';
import camera from '../editor/panels/icons/camera.svg?raw'; import camera from '../editor/panels/icons/camera.svg?raw';
import collider from '../editor/panels/icons/collider.svg?raw'; import collider from '../editor/panels/icons/collider.svg?raw';
import composition from '../editor/panels/icons/composition.svg?raw';
import container from '../editor/panels/icons/container.svg?raw'; import container from '../editor/panels/icons/container.svg?raw';
import joint from '../editor/panels/icons/joint.svg?raw'; import joint from '../editor/panels/icons/joint.svg?raw';
import particles from '../editor/panels/icons/particles.svg?raw'; import particles from '../editor/panels/icons/particles.svg?raw';
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw'; import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
export type SymbolIcon = export type SymbolIcon =
'behavior' | 'camera' | 'collider' | 'container' | 'joint' | 'particles' | 'rigidbody'; 'behavior' | 'camera' | 'collider' | 'composition' | 'container' | 'joint' | 'particles' | 'rigidbody';
export const SYMBOL_SVG: Record<SymbolIcon, string> = { export const SYMBOL_SVG: Record<SymbolIcon, string> = {
behavior, behavior,
camera, camera,
collider, collider,
composition,
container, container,
joint, joint,
particles, particles,
+1
View File
@@ -174,6 +174,7 @@ export function drawItem(
return; return;
case 'behavior': case 'behavior':
case 'container': case 'container':
case 'composition':
case 'rigidbody': case 'rigidbody':
case 'joint': case 'joint':
return; return;
+344
View File
@@ -0,0 +1,344 @@
/**
* A composition placed in a scene and played (#417): its items become objects below the placement
* while they last, its clips and videos reach them, and `On composition finished` hears the end.
*/
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
} from '../../src/core/commands/animationCommands';
import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands';
import type { CompositionAsset, ContainerAsset } from '../../src/core/model/Asset';
import {
compositionHolder,
placedPicture,
playComposition,
stopComposition,
} from '../../src/core/model/composition';
import { contentId, instanceChildId, resolveObjects } from '../../src/core/model/instance';
import { findAssetOf, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import { stackAt } from '../../src/viewport/pick';
import { collectHolderItems } from '../../src/viewport/items';
import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
interface Stage extends ProjectFixture {
sceneId: string;
spriteId: string;
/** The composition's object in the scene. */
placement: string;
comp(): CompositionAsset;
track(): string;
item(track: string, assetId: string, start: number, length: number, offset?: number): string;
/** An animation over `root` moving x (or a property of `target`) from 0 to 90 over frames 0–9 at 10 fps. */
move(root: string, property?: string, target?: string): string;
clip(track: string, animation: string, start: number): void;
}
/** A scene "Level" with a composition "Cut" at 100/50, ten frames a second, and a sprite "Box". */
function makeStage(): Stage {
const f = makeProject();
const scene = new CreateAssetCommand('scene', 'Level');
const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 });
const sprite = new CreateAssetCommand('sprite', 'Box');
for (const c of [scene, comp, sprite]) f.stack.execute(c);
const place = new PlaceAssetCommand(scene.asset.id, comp.asset.id, { x: 100, y: 50 });
f.stack.execute(place);
const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!;
let next = 0;
return {
...f,
sceneId: scene.asset.id,
spriteId: sprite.asset.id,
placement: place.objectId,
comp: current,
track() {
const id = `t${++next}`;
current().tracks.push({ id, name: id });
return id;
},
item(track, assetId, start, length, offset = 0) {
const id = `i${++next}`;
current().objects.push(createSceneObject(id, assetId, { x: 0, y: 0 }));
current().items.push({ id, track, start, length, offset });
return id;
},
move(root, property = 'x', target = '') {
const create = new CreateAssetCommand('animation', 'Move', { fps: 10, playback: 'loop' });
f.stack.execute(create);
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
const row = new AddAnimationTrackCommand(create.asset.id, target, property);
f.stack.execute(row);
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0));
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 9, 90));
return create.asset.id;
},
clip(track, animation, start) {
current().clips.push({ id: `c${++next}`, track, animation, start });
},
};
}
const first = (stage: Stage) => stage.comp().tracks[0]!.id;
/** A behavior below the placement: On start plays it, On composition finished logs "done". */
function director(stage: Stage): void {
const behavior = makeBehavior(stage, 'Director');
stage.stack.execute(new PlaceAssetCommand(stage.sceneId, behavior.id, { x: 0, y: 0 }, stage.placement));
const edit = graphEditor(stage, behavior);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const play = edit.addNode('PlayComposition');
edit.connect(start, 'exec', play, 'exec');
edit.connect(owner, 'object', play, 'object');
const finished = edit.addNode('OnCompositionFinished', { composition: stage.comp().id });
const log = edit.addNode('Log', { message: 'done' });
edit.connect(finished, 'exec', log, 'exec');
}
/** The scene played as a game: `frame()` ticks a tenth of a second and writes back like Play does. */
function play(stage: Stage, playsCompositions = true) {
const world = World.instantiate(stage.project, stage.sceneId);
addGameSystems(world, stage.project, new Input(), { playsCompositions });
const placement = () => world.findByObjectId(stage.placement)!;
const at = (item: string) => world.findByObjectId(contentId(stage.placement, item));
return {
world,
placement,
at,
frame(times = 1) {
for (let i = 0; i < times; i++) {
world.tick(0.1);
world.syncToModel();
}
},
stored: () => findScene(stage.project, stage.sceneId)!.objects.map((o) => o.id),
};
}
describe('a composition played in a scene', () => {
it('builds an item’s object below the placement when it begins and destroys it when it ends', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 3);
const b = stage.item(first(stage), stage.spriteId, 3, 2);
const run = play(stage);
expect(run.at(a)).toBeUndefined();
run.frame();
// The first tick shows frame 0, the clock runs from the next.
expect(run.at(a)?.parent).toBe(run.placement());
expect(run.at(a)?.objectId).toBe(`${stage.placement}~${a}`);
expect(run.at(b)).toBeUndefined();
run.frame(3);
expect(run.at(a)).toBeUndefined();
expect(run.stored()).not.toContain(contentId(stage.placement, a));
expect(run.at(b)?.name).toBe('Box');
expect(run.stored()).toContain(contentId(stage.placement, b));
run.frame(2);
expect(run.at(b)).toBeUndefined();
expect(run.world.log).toEqual(['done']);
expect(run.placement().composition).toBeUndefined();
run.frame(5);
expect(run.world.log).toEqual(['done']);
});
it('lays the first track in front, whatever began first', () => {
const stage = makeStage();
director(stage);
const back = stage.track();
// Built after the front one, the item of the back track still has to go behind it.
const front = stage.item(first(stage), stage.spriteId, 0, 5);
const late = stage.item(back, stage.spriteId, 2, 5);
const run = play(stage);
run.frame(3);
const order = (ids: string[]) =>
[late, front].map((item) => ids.indexOf(contentId(stage.placement, item)));
const [behind, inFront] = order(run.world.entities.map((e) => e.objectId));
expect(behind).toBeLessThan(inFront!);
const [storedBehind, storedFront] = order(run.stored());
expect(storedBehind).toBeLessThan(storedFront!);
});
it('writes its clips over the items as the editor shows them, and holds the last value', () => {
const stage = makeStage();
director(stage);
const item = stage.item(first(stage), stage.spriteId, 0, 30);
stage.clip(first(stage), stage.move(stage.spriteId), 2);
const run = play(stage);
const shown = (frame: number) =>
compositionHolder(stage.project, stage.comp(), frame).objects.find((o) => o.id === item)!.position.x;
let at = -1;
for (const frame of [0, 1, 2, 7, 11, 20]) {
run.frame(frame - at);
at = frame;
expect(run.at(item)!.x, `frame ${frame}`).toBe(shown(frame));
}
expect(run.at(item)!.x).toBe(90);
});
it('reaches into a container item by its template path', () => {
const stage = makeStage();
director(stage);
const rig = new CreateAssetCommand('container', 'Rig');
const arm = new CreateAssetCommand('sprite', 'Arm');
stage.stack.execute(rig);
stage.stack.execute(arm);
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }),
);
const item = stage.item(first(stage), rig.asset.id, 0, 30);
stage.clip(first(stage), stage.move(rig.asset.id, 'rotation', 'arm'), 0);
const run = play(stage);
run.frame(6);
const armOf = run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm'));
expect(armOf?.objectId).toBe(`${stage.placement}~${item}/arm`);
expect(armOf?.parent).toBe(run.at(item));
expect(armOf?.props.rotation).toBe(50);
});
it('drives the video an item wears at its video time', () => {
const stage = makeStage();
director(stage);
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 10,
fps: 30,
audio: false,
});
stage.stack.execute(video);
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id });
stage.stack.execute(screen);
const item = stage.item(first(stage), screen.asset.id, 0, 50, 1.5);
const run = play(stage);
run.frame(6);
expect(run.at(item)!.props[VIDEO_CLOCK_KEY]).toBe('driven');
expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBeCloseTo(2, 9);
});
it('loops without finishing, building an item anew each time round', () => {
const stage = makeStage();
director(stage);
stage.comp().props.loop = true;
const a = stage.item(first(stage), stage.spriteId, 0, 2);
stage.item(first(stage), stage.spriteId, 2, 2);
const run = play(stage);
run.frame();
const firstRound = run.at(a);
run.frame(4);
expect(run.at(a)).toBeDefined();
expect(run.at(a)).not.toBe(firstRound);
run.frame(20);
expect(run.world.log).toEqual([]);
});
it('takes the items away on Stop, without finishing, and starts over on the next play', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(3);
const before = run.at(a);
stopComposition(run.placement());
run.frame();
expect(run.at(a)).toBeUndefined();
expect(run.placement().composition).toBeUndefined();
playComposition(run.placement());
run.frame();
expect(run.at(a)).toBeDefined();
expect(run.at(a)).not.toBe(before);
expect(run.world.log).toEqual([]);
});
it('builds every item anew on a restart, and plays on when asked to play again', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(5);
const playing = run.at(a);
playComposition(run.placement());
run.frame();
expect(run.at(a)).toBe(playing);
expect(run.placement().composition!.time).toBeCloseTo(0.5, 9);
playComposition(run.placement(), true);
run.frame();
expect(run.at(a)).not.toBe(playing);
expect(run.placement().composition!.time).toBe(0);
});
it('stands still while the game is paused', () => {
const stage = makeStage();
director(stage);
stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage);
run.frame(3);
run.world.paused = true;
run.frame(5);
expect(run.placement().composition!.time).toBeCloseTo(0.2, 9);
});
it('plays nothing in the editor run, and says so', () => {
const stage = makeStage();
director(stage);
const a = stage.item(first(stage), stage.spriteId, 0, 30);
const run = play(stage, false);
run.frame(3);
expect(run.at(a)).toBeUndefined();
expect(run.world.log).toEqual(['Play composition is ignored in the editor run']);
});
});
describe('a placed composition in the editor', () => {
it('shows its first frame below the placement, with the ids its items take in a run', () => {
const stage = makeStage();
const a = stage.item(first(stage), stage.spriteId, 0, 3);
stage.item(first(stage), stage.spriteId, 5, 3);
const comp = stage.comp();
expect(placedPicture(stage.project, comp, stage.placement).map((o) => [o.id, o.parentId])).toEqual([
[contentId(stage.placement, a), stage.placement],
]);
// Nothing of it is stored: the placement is sealed.
expect(resolveObjects(stage.project, findScene(stage.project, stage.sceneId)!)).toHaveLength(1);
});
it('draws that frame where the placement stands, and a click on it picks the placement', () => {
const stage = makeStage();
const a = stage.item(first(stage), stage.spriteId, 0, 3);
const scene = findScene(stage.project, stage.sceneId)!;
const { items } = collectHolderItems(stage.project, scene, 0);
const drawn = items.find((i) => i.id === contentId(stage.placement, a))!;
expect([drawn.x, drawn.y]).toEqual([100, 50]);
expect(drawn.sealedIn).toBe(stage.placement);
expect(stackAt(items, [{ id: drawn.id, layers: null }])).toEqual([stage.placement]);
// Play mode draws the run's own objects instead.
expect(collectHolderItems(stage.project, scene, 0, null, false).items.map((i) => i.id)).toEqual([
stage.placement,
]);
});
it('gives the content of a container item the ids a run gives it', () => {
const stage = makeStage();
const rig = new CreateAssetCommand('container', 'Rig');
stage.stack.execute(rig);
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
createSceneObject('arm', stage.spriteId, { x: 5, y: 0 }),
);
const item = stage.item(first(stage), rig.asset.id, 0, 3);
const picture = placedPicture(stage.project, stage.comp(), stage.placement);
const top = contentId(stage.placement, item);
expect(picture.map((o) => [o.id, o.parentId])).toEqual([
[top, stage.placement],
[contentId(top, 'arm'), top],
]);
expect(contentId(top, 'arm')).toBe(instanceChildId(stage.placement, `${item}/arm`));
});
});
+1 -1
View File
@@ -104,7 +104,7 @@ describe('the order rule', () => {
const events = BUILT_IN_NODES.filter( const events = BUILT_IN_NODES.filter(
(def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR, (def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR,
); );
expect(events).toHaveLength(18); expect(events).toHaveLength(19);
for (const def of events) { for (const def of events) {
expect( expect(
def.params.map((p) => p.key), def.params.map((p) => p.key),
+32
View File
@@ -5,6 +5,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { requireNodeDef } from '../../src/core/commands/require'; import { requireNodeDef } from '../../src/core/commands/require';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { playComposition } from '../../src/core/model/composition';
import { import {
createEmptyGraph, createEmptyGraph,
type DeclaredPort, type DeclaredPort,
@@ -107,6 +108,8 @@ const nestedMover = {
const emitTarget = fakeEntity('Sparks'); const emitTarget = fakeEntity('Sparks');
/** Their own entity each: the animation nodes leave players on the object they are handed. */ /** Their own entity each: the animation nodes leave players on the object they are handed. */
const dancer = fakeEntity('Dancer'); const dancer = fakeEntity('Dancer');
/** A placed composition for Play and Stop composition (#417). */
const director = fakeEntity('Director');
/** An object whose once-reset "swing" has run out and blends back over half a second (#233). */ /** An object whose once-reset "swing" has run out and blends back over half a second (#233). */
const swinging = { const swinging = {
...fakeEntity('Fighter'), ...fakeEntity('Fighter'),
@@ -1976,6 +1979,34 @@ const CASES: Record<string, Case[]> = {
after: () => expect(dancer.animations).toEqual([]), after: () => expect(dancer.animations).toEqual([]),
}, },
], ],
PlayComposition: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null, restart: false },
next: 'exec',
},
{
name: 'starts a player on the object, and a second play leaves it running',
inputs: { object: director, restart: false },
next: 'exec',
after: () => {
expect(director.composition).toMatchObject({ time: 0, shown: false, stopped: false });
const run = director.composition!.run;
playComposition(director);
expect(director.composition!.run).toBe(run);
playComposition(director, true);
expect(director.composition!.run).toBeGreaterThan(run);
},
},
],
StopComposition: [
{
name: 'marks the player stopped for the next tick to take its items away',
inputs: { object: director },
next: 'exec',
after: () => expect(director.composition?.stopped).toBe(true),
},
],
PlaySound: [ PlaySound: [
{ {
name: 'no sound is a no-op that still continues the flow', name: 'no sound is a no-op that still continues the flow',
@@ -2506,6 +2537,7 @@ const EVENT_NODES: Record<string, string> = {
OnClick: 'nodes.test.ts', OnClick: 'nodes.test.ts',
OnMouseWheel: 'clip-areas.test.ts', OnMouseWheel: 'clip-areas.test.ts',
OnAnimationFinished: 'animation.test.ts', OnAnimationFinished: 'animation.test.ts',
OnCompositionFinished: 'composition-play.test.ts',
OnStateEntered: 'state-machine.test.ts', OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts',
}; };
+1
View File
@@ -27,6 +27,7 @@ describe('scene visibility', () => {
'camera', 'camera',
'clip', 'clip',
'collider', 'collider',
'composition',
'container', 'container',
'joint', 'joint',
'particles', 'particles',
+1
View File
@@ -30,6 +30,7 @@ describe('icon files', () => {
'behavior', 'behavior',
'camera', 'camera',
'collider', 'collider',
'composition',
'container', 'container',
'joint', 'joint',
'particles', 'particles',
+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 { song } from './65-song.mjs';
import { video } from './66-video.mjs'; import { video } from './66-video.mjs';
import { recording } from './67-recording.mjs'; import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -139,4 +140,5 @@ export const STEPS = [
song, song,
video, video,
recording, recording,
composition,
]; ];