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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : '');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
|
|||||||
'camera',
|
'camera',
|
||||||
'clip',
|
'clip',
|
||||||
'collider',
|
'collider',
|
||||||
|
'composition',
|
||||||
'container',
|
'container',
|
||||||
'joint',
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }) : [],
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { isHandleEnabled } from '../../core/graph';
|
||||||
|
import { trackWriteAt } from '../../core/model/animation';
|
||||||
|
import {
|
||||||
|
compositionAt,
|
||||||
|
compositionFrameAt,
|
||||||
|
compositionFps,
|
||||||
|
compositionLength,
|
||||||
|
compositionLoops,
|
||||||
|
type LiveItem,
|
||||||
|
} from '../../core/model/composition';
|
||||||
|
import type { CompositionAsset } from '../../core/model/Asset';
|
||||||
|
import { curveLookup } from '../../core/model/curve';
|
||||||
|
import { contentId, placedWithContent } from '../../core/model/instance';
|
||||||
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import { findVideo } from '../../core/model/video';
|
||||||
|
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
|
||||||
|
import type { Entity } from '../Entity';
|
||||||
|
import type { World } from '../World';
|
||||||
|
import { bind } from './AnimationSystem';
|
||||||
|
import type { System } from './System';
|
||||||
|
|
||||||
|
export interface CompositionFinish {
|
||||||
|
/** The placed composition. */
|
||||||
|
entity: Entity;
|
||||||
|
compositionId: string;
|
||||||
|
startedBy: readonly object[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
|
||||||
|
interface Spawned {
|
||||||
|
entity: Entity;
|
||||||
|
bases: Map<string, PropertyValue>;
|
||||||
|
videos: Entity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Run {
|
||||||
|
id: number;
|
||||||
|
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
|
||||||
|
live: Map<string, Spawned>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
|
||||||
|
* when the item begins and destroys it when it ends, writes the clips over them and drives their
|
||||||
|
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
|
||||||
|
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
|
||||||
|
* a cut would stay behind.
|
||||||
|
*/
|
||||||
|
export class CompositionSystem implements System {
|
||||||
|
finished: CompositionFinish[] = [];
|
||||||
|
private readonly runs = new Map<Entity, Run>();
|
||||||
|
|
||||||
|
constructor(private readonly plays = true) {}
|
||||||
|
|
||||||
|
update(world: World, dt: number): void {
|
||||||
|
this.finished = [];
|
||||||
|
for (const placement of this.runs.keys())
|
||||||
|
if (!world.entities.includes(placement)) this.runs.delete(placement);
|
||||||
|
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
|
||||||
|
for (const placement of started) this.advance(world, placement, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.finished = [];
|
||||||
|
this.runs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(world: World, placement: Entity, dt: number): void {
|
||||||
|
const player = placement.composition!;
|
||||||
|
if (!this.plays) {
|
||||||
|
world.log.push('Play composition is ignored in the editor run');
|
||||||
|
delete placement.composition;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let run = this.runs.get(placement);
|
||||||
|
if (run && run.id !== player.run) this.end(world, placement);
|
||||||
|
const comp = findAssetOf(world.project, placement.assetId, 'composition');
|
||||||
|
if (!comp || player.stopped) {
|
||||||
|
this.end(world, placement);
|
||||||
|
delete placement.composition;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run = this.runs.get(placement);
|
||||||
|
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() }));
|
||||||
|
if (player.shown && isHandleEnabled(placement)) player.time += dt;
|
||||||
|
player.shown = true;
|
||||||
|
const length = compositionLength(world.project, comp);
|
||||||
|
let frame = compositionFrameAt(comp, player.time);
|
||||||
|
if (frame >= length && compositionLoops(comp) && length > 0) {
|
||||||
|
player.time %= length / compositionFps(comp);
|
||||||
|
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
|
||||||
|
} else if (frame >= length) {
|
||||||
|
this.end(world, placement);
|
||||||
|
delete placement.composition;
|
||||||
|
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.show(world, comp, placement, run, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes every item of the placement's run away. */
|
||||||
|
private end(world: World, placement: Entity): void {
|
||||||
|
const run = this.runs.get(placement);
|
||||||
|
if (!run) return;
|
||||||
|
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
|
||||||
|
this.runs.delete(placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
|
||||||
|
const live = compositionAt(world.project, comp, frame);
|
||||||
|
const alive = new Set(live.map((l) => l.item.id));
|
||||||
|
for (const [id, { entity }] of run.live) {
|
||||||
|
if (alive.has(id)) continue;
|
||||||
|
if (world.entities.includes(entity)) world.remove(entity);
|
||||||
|
run.live.delete(id);
|
||||||
|
}
|
||||||
|
let born = false;
|
||||||
|
for (const item of live) {
|
||||||
|
if (run.live.has(item.item.id)) continue;
|
||||||
|
const spawned = spawn(world, placement, item);
|
||||||
|
if (!spawned) continue;
|
||||||
|
run.live.set(item.item.id, spawned);
|
||||||
|
born = true;
|
||||||
|
}
|
||||||
|
// In the order the tracks lay them, whenever they began.
|
||||||
|
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
|
||||||
|
const curves = curveLookup(world.project);
|
||||||
|
for (const item of live) {
|
||||||
|
const spawned = run.live.get(item.item.id);
|
||||||
|
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
|
||||||
|
const id = contentId(placement.objectId, item.object.id);
|
||||||
|
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
|
||||||
|
const entity = entities.find((e) => e.objectId === id);
|
||||||
|
if (!entity) return null;
|
||||||
|
// Below the item, a sprite set to be driven by hand keeps its own time.
|
||||||
|
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
|
||||||
|
return { entity, bases: new Map(), videos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
|
||||||
|
for (const sprite of spawned.videos) {
|
||||||
|
const texture = sprite.props.texture;
|
||||||
|
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
|
||||||
|
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
|
||||||
|
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
|
||||||
|
}
|
||||||
|
for (const clip of item.clips) {
|
||||||
|
for (const b of bind(world, clip.animation, spawned.entity)) {
|
||||||
|
let base = spawned.bases.get(b.base);
|
||||||
|
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
|
||||||
|
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
|
||||||
|
if (value !== undefined) b.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
|
|||||||
import { parentChain } from '../../core/model/walk';
|
import { 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
@@ -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]));
|
||||||
|
|||||||
@@ -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)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('scene visibility', () => {
|
|||||||
'camera',
|
'camera',
|
||||||
'clip',
|
'clip',
|
||||||
'collider',
|
'collider',
|
||||||
|
'composition',
|
||||||
'container',
|
'container',
|
||||||
'joint',
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('icon files', () => {
|
|||||||
'behavior',
|
'behavior',
|
||||||
'camera',
|
'camera',
|
||||||
'collider',
|
'collider',
|
||||||
|
'composition',
|
||||||
'container',
|
'container',
|
||||||
'joint',
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
|
|||||||
@@ -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-'));`);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user