Place compositions in scenes and play them from behaviors (#417)
A composition can now sit in a scene like a container and play there. Its items become real objects while they run, so behaviors, colliders and events work on them as on any other object. - A composition is placeable with its own symbol, transform and render layer. The scene view shows its picture at frame 0. A click on that picture selects the placement, not a single item. - CompositionSystem spawns each item as an object under the placement when it begins, with the id <placement>~<item>. On start and the other events run. The object is destroyed when the item ends. - The top track lies in front no matter which item started first. World.arrange() orders the entity list and the scene objects. - Clips and driven videos follow the same rules as compositionAt(). Loop, restart, stop and the game pause work. - New nodes: Play composition (with restart), Stop composition and the event On composition finished. They were planned for step 4 but come now, because without autoplay nothing could play otherwise. - The editor run ignores Play composition and says so in the log. It writes into the saved project, and the items would stay behind. - contentId() moved to instance.ts. The animation system uses it for nested ids too. - Compositions get no "Sort with parent" for now. Tests: the gate passed with 301 files and 4643 tests, 18 more than step 1. 13 of them are in tests/core/composition-play.test.ts. The first gate run failed in event-order.test.ts, which counts the event nodes and now finds 19. Five counter-checks each turned exactly their test red. Two order tests stayed green without the fix at first and were rebuilt. e2e 68 is new and passed with 5 own checks. Its counter-check without arrange() failed as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73e675835e
commit
0334d2508d
31 files changed
+1035
-33
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import type { AnimationPlayer, EaseShare } from '../model/animation';
|
||||
import type { CompositionPlayer } from '../model/composition';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
@@ -36,6 +37,8 @@ export interface EntityHandle {
|
||||
emits?: number;
|
||||
/** What `Play animation` started on this object (see `model/animation.ts`). */
|
||||
animations?: AnimationPlayer[];
|
||||
/** What `Play composition` started on a placed composition (see `model/composition.ts`). */
|
||||
composition?: CompositionPlayer;
|
||||
}
|
||||
|
||||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { playComposition, stopComposition } from '../../../model/composition';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asEntity } from '../../values';
|
||||
import { bool, exec, obj, withDefault } from '../shared';
|
||||
|
||||
export const PlayComposition: NodeDef = {
|
||||
type: 'PlayComposition',
|
||||
label: 'Play composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a composition placed in the scene from its first frame: each item becomes an object below ' +
|
||||
'it when the item begins – its behaviors start then – and is destroyed when it ends. One already ' +
|
||||
'playing plays on; Restart starts it over. Without Loop it ends after its last item, and On ' +
|
||||
'composition finished hears that. The editor run plays none and says so in the log.',
|
||||
inputs: [exec(), obj('object'), withDefault(bool('restart'), false)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) playComposition(target, asBoolean(inputs.restart), ctx.self);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const StopComposition: NodeDef = {
|
||||
type: 'StopComposition',
|
||||
label: 'Stop composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Stops a playing composition: the objects of its items are destroyed on the next frame. It does ' +
|
||||
'not count as finished, and Play composition starts it from its first frame again.',
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) stopComposition(target);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, obj } from '../shared';
|
||||
|
||||
export const OnCompositionFinished: NodeDef = {
|
||||
type: 'OnCompositionFinished',
|
||||
label: 'On composition finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a composition placed at the owner, or below it, plays past its last item – and when ' +
|
||||
'one this behavior played itself does, wherever it stands. A looping one never finishes, and Stop ' +
|
||||
'composition does not count. Leave Composition at None to hear every composition.',
|
||||
inputs: [],
|
||||
outputs: [exec(), obj('object')],
|
||||
params: [
|
||||
{ key: 'composition', label: 'Composition', kind: 'asset', default: '', assetTypes: ['composition'] },
|
||||
orderParam,
|
||||
],
|
||||
hiddenParams: (params) => [
|
||||
...(textOf(params.composition ?? '') ? [] : ['composition']),
|
||||
...hiddenOrder(params),
|
||||
],
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
|
||||
import { AddJoint } from './action/AddJoint';
|
||||
import { Destroy } from './action/Destroy';
|
||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||
import { PlayComposition, StopComposition } from './action/compositionNodes';
|
||||
import { Emit } from './action/Emit';
|
||||
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
@@ -123,6 +124,7 @@ import { SetItem } from './data/SetItem';
|
||||
import { SubList } from './data/SubList';
|
||||
import { TextureSize } from './data/TextureSize';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
import { OnCompositionFinished } from './events/OnCompositionFinished';
|
||||
import { OnBehavior } from './events/OnBehavior';
|
||||
import { OnClick } from './events/OnClick';
|
||||
import { OnMouseWheel } from './events/OnMouseWheel';
|
||||
@@ -183,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnClick,
|
||||
OnMouseWheel,
|
||||
OnAnimationFinished,
|
||||
OnCompositionFinished,
|
||||
OnBehavior,
|
||||
OnStateEntered,
|
||||
OnStateExited,
|
||||
@@ -308,6 +311,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
PlayAnimation,
|
||||
PauseAnimation,
|
||||
StopAnimation,
|
||||
PlayComposition,
|
||||
StopComposition,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
SetSoundFxParameters,
|
||||
|
||||
@@ -84,8 +84,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* sound is played through (#311), heard but never seen, an instrument what a note is played
|
||||
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
|
||||
* where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through
|
||||
* `texture` (#402), as it wears an image. A composition is not placed yet: what a placed one shows
|
||||
* and plays is still to come (#417).
|
||||
* `texture` (#402), as it wears an image.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -110,7 +109,6 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
| 'video'
|
||||
| 'composition'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -135,7 +133,6 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'variables',
|
||||
'struct',
|
||||
'video',
|
||||
'composition',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
@@ -155,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean {
|
||||
type === 'rigidbody' ||
|
||||
type === 'joint' ||
|
||||
type === 'container' ||
|
||||
type === 'composition' ||
|
||||
type === 'clip'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,14 @@ import type {
|
||||
CompositionTrack,
|
||||
} from './Asset';
|
||||
import { curveLookup } from './curve';
|
||||
import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance';
|
||||
import {
|
||||
contentId,
|
||||
instanceChildId,
|
||||
objectRef,
|
||||
placedWithContent,
|
||||
resolveObjects,
|
||||
splitInstanceId,
|
||||
} from './instance';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
@@ -104,6 +111,57 @@ export function fittedLength(project: Project, comp: CompositionAsset, item: Com
|
||||
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* A composition placed in a running game that `Play composition` started; kept on its entity, out of
|
||||
* `props`. Its items exist only while it plays.
|
||||
*/
|
||||
export interface CompositionPlayer {
|
||||
/** Tells a start apart from the one before, so a restart builds every item anew. */
|
||||
run: number;
|
||||
/** Seconds since its first frame. */
|
||||
time: number;
|
||||
/** Whether the first frame was shown: the clock runs from the tick after the start. */
|
||||
shown: boolean;
|
||||
/** Set by `Stop composition`: the next tick takes its items away. */
|
||||
stopped: boolean;
|
||||
/** The behaviors that played it; they hear `On composition finished` wherever it stands. */
|
||||
startedBy: object[];
|
||||
}
|
||||
|
||||
let runCount = 0;
|
||||
|
||||
/**
|
||||
* Starts the composition placed as `holder` from its first frame. One already playing plays on, unless
|
||||
* `restart` – or a `Stop composition` this tick – asks to start over.
|
||||
*/
|
||||
export function playComposition(
|
||||
holder: { composition?: CompositionPlayer },
|
||||
restart = false,
|
||||
startedBy?: object,
|
||||
): void {
|
||||
const playing = holder.composition;
|
||||
if (playing && !playing.stopped && !restart) {
|
||||
if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy);
|
||||
return;
|
||||
}
|
||||
holder.composition = {
|
||||
run: ++runCount,
|
||||
time: 0,
|
||||
shown: false,
|
||||
stopped: false,
|
||||
startedBy: startedBy ? [startedBy] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function stopComposition(holder: { composition?: CompositionPlayer }): void {
|
||||
if (holder.composition) holder.composition.stopped = true;
|
||||
}
|
||||
|
||||
/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */
|
||||
export function compositionFrameAt(comp: CompositionAsset, time: number): number {
|
||||
return Math.floor(time * compositionFps(comp) + 1e-6);
|
||||
}
|
||||
|
||||
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
|
||||
export interface ClipAt {
|
||||
clip: CompositionClip;
|
||||
@@ -187,6 +245,17 @@ export function compositionHolder(project: Project, comp: CompositionAsset, fram
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a composition placed as `placementId` shows in the editor: its first frame, as objects below
|
||||
* the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and
|
||||
* its items are edited in the composition only.
|
||||
*/
|
||||
export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] {
|
||||
return compositionHolder(project, comp, 0).objects.flatMap((o) =>
|
||||
placedWithContent(project, o, contentId(placementId, o.id), placementId),
|
||||
);
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||||
|
||||
@@ -436,6 +436,40 @@ export function materializeInstance(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The id the object at `path` below `ownerId` has – one level deeper when the owner is content
|
||||
* itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives.
|
||||
*/
|
||||
export function contentId(ownerId: string, path: string): string {
|
||||
const split = splitInstanceId(ownerId);
|
||||
return split
|
||||
? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`)
|
||||
: instanceChildId(ownerId, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* `object` as `id` below `parentId`, followed by its container content with the ids below that one –
|
||||
* how a composition puts its objects into a scene (#417). Copies, free to be stored and written to.
|
||||
*/
|
||||
export function placedWithContent(
|
||||
project: Project,
|
||||
object: SceneObject,
|
||||
id: string,
|
||||
parentId: string,
|
||||
): SceneObject[] {
|
||||
const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1)));
|
||||
const { groupId: _, ...top } = object;
|
||||
return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({
|
||||
...o,
|
||||
id: rename(o.id),
|
||||
parentId: o.id === object.id ? parentId : rename(o.parentId),
|
||||
position: { ...o.position },
|
||||
editorOffset: { ...o.editorOffset },
|
||||
overrides: { ...o.overrides },
|
||||
instanceEdits: {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spells out every live instance of the holder. The runtime needs it: entities are built from
|
||||
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
|
||||
|
||||
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
|
||||
'camera',
|
||||
'clip',
|
||||
'collider',
|
||||
'composition',
|
||||
'container',
|
||||
'joint',
|
||||
'particles',
|
||||
|
||||
@@ -371,6 +371,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'text',
|
||||
'collider',
|
||||
'container',
|
||||
'composition',
|
||||
'clip',
|
||||
'particles',
|
||||
'tilemap',
|
||||
@@ -438,6 +439,7 @@ const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'text',
|
||||
'particles',
|
||||
'container',
|
||||
'composition',
|
||||
'tilemap',
|
||||
]);
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function footprintOf(
|
||||
case 'behavior':
|
||||
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||||
case 'container':
|
||||
case 'composition':
|
||||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||||
case 'rigidbody':
|
||||
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
||||
|
||||
@@ -136,6 +136,7 @@ export class EditorRun {
|
||||
shouldRun: (e) => runsInEditor(e.props),
|
||||
audio: soundLibrary,
|
||||
saves: saveArea(project.id, 'editor'),
|
||||
playsCompositions: false,
|
||||
});
|
||||
this.world = fresh;
|
||||
this.hadFocus = this.hooks.focused();
|
||||
|
||||
@@ -113,7 +113,7 @@ const LABEL_PAD_Y = 1;
|
||||
const LABEL_ROW_GAP = 1;
|
||||
const LABEL_PLATE_ALPHA = 0.7;
|
||||
/** 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. */
|
||||
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||
@@ -268,7 +268,13 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -725,7 +731,7 @@ export class SceneViewport extends FreeViewport {
|
||||
const minY = Math.min(a.y, b.y);
|
||||
const maxY = Math.max(a.y, b.y);
|
||||
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);
|
||||
this.state.select(objectSelection(holder.id, ids));
|
||||
}
|
||||
|
||||
@@ -54,8 +54,18 @@ const DRAG_THRESHOLD = 2;
|
||||
const TURN_SNAP = 15;
|
||||
/** How strongly the frame round a container's content is drawn. */
|
||||
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. */
|
||||
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. */
|
||||
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. */
|
||||
private worldBox(item: DrawItem): Vec2[] {
|
||||
if (item.type === 'container') {
|
||||
if (holdsContent(item)) {
|
||||
const frame = contentFrame(item.m, this.contentCorners(item.id));
|
||||
if (frame) return frame;
|
||||
}
|
||||
@@ -303,7 +313,7 @@ export class SizeGizmoEditor {
|
||||
});
|
||||
const color = Theme.colors.gizmo;
|
||||
// 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 });
|
||||
const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob';
|
||||
g.circle(knob.x, knob.y, KNOB_RADIUS)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
|
||||
import type { AnimationPlayer } from '../core/model/animation';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import type { CompositionPlayer } from '../core/model/composition';
|
||||
import type { FrameOutline } from '../core/model/frameOutline';
|
||||
import type { TileEdits } from '../core/model/liveTilemap';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
@@ -26,6 +27,8 @@ export interface Entity {
|
||||
parent: Entity | null;
|
||||
/** The animations playing on this object; kept out of `props`, which the world writes back. */
|
||||
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. */
|
||||
emits?: number;
|
||||
/**
|
||||
|
||||
@@ -96,6 +96,14 @@ function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] {
|
||||
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
|
||||
* (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* what the model resolves (stored as overrides, so the inspector marks them) and removed
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import { curveLookup } from '../../core/model/curve';
|
||||
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
|
||||
import { contentId } from '../../core/model/instance';
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
@@ -141,7 +141,7 @@ function drop(entity: Entity, player: AnimationPlayer): void {
|
||||
* there, so one animation fits containers built alike. A row whose target is gone, or lacks the
|
||||
* property, is skipped.
|
||||
*/
|
||||
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
export function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
const root = findAsset(world.project, asset.root);
|
||||
if (!root) return [];
|
||||
const bindings: Binding[] = [];
|
||||
@@ -167,7 +167,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
else if (track.target === '') entity = owner;
|
||||
else
|
||||
entity =
|
||||
world.findByObjectId(childId(owner.objectId, track.target)) ??
|
||||
world.findByObjectId(contentId(owner.objectId, track.target)) ??
|
||||
byNames(world, owner, targetNamePath(world.project, root, track.target));
|
||||
const def = entity && defOn(world, entity, key);
|
||||
if (entity && def) {
|
||||
@@ -193,15 +193,6 @@ function defOn(world: World, entity: Entity, key: string): PropertyDef | undefin
|
||||
return asset && animatedDefOf(world.project, asset, entity.props, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a template object gets below a placed container – nested one deeper when the container is
|
||||
* content itself. `path` may run through containers placed inside the template (`a/b`).
|
||||
*/
|
||||
function childId(ownerId: string, path: string): string {
|
||||
const split = splitInstanceId(ownerId);
|
||||
return split ? instanceChildId(split.instanceId, `${split.path}/${path}`) : instanceChildId(ownerId, path);
|
||||
}
|
||||
|
||||
/** The object below `owner` with these names, level by level; none when a name is missing or taken twice. */
|
||||
function byNames(world: World, owner: Entity, names: string[] | undefined): Entity | undefined {
|
||||
if (!names) return undefined;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
collisionEnterPulses,
|
||||
collisionExitPulses,
|
||||
collisionStayPulses,
|
||||
compositionFinishedPulses,
|
||||
type Instance,
|
||||
keyDownPulses,
|
||||
type PerCell,
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
import { CollisionSystem } from './CollisionSystem';
|
||||
import type { CollisionSource } from './solids';
|
||||
import { AnimationSystem } from './AnimationSystem';
|
||||
import { CompositionSystem } from './CompositionSystem';
|
||||
import type { Input } from './Input';
|
||||
import type { RecordingSink } from '../../core/recording/recording';
|
||||
import type { System } from './System';
|
||||
@@ -118,9 +120,11 @@ export interface GameSystemOptions {
|
||||
saves?: SaveArea | null;
|
||||
/** Where `Start recording` records to (#405): only a played game has one. */
|
||||
recorder?: RecordingSink | null;
|
||||
/** Whether `Play composition` plays (#417); the editor run ignores it. */
|
||||
playsCompositions?: boolean;
|
||||
}
|
||||
|
||||
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||||
/** Collisions, compositions and animations are ticked first, so behaviors see this frame's events. */
|
||||
export function addGameSystems(
|
||||
world: World,
|
||||
project: Project,
|
||||
@@ -134,11 +138,13 @@ export function addGameSystems(
|
||||
clock = null,
|
||||
saves = null,
|
||||
recorder = null,
|
||||
playsCompositions = true,
|
||||
}: GameSystemOptions = {},
|
||||
): BehaviorSystem {
|
||||
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
|
||||
const compositions = new CompositionSystem(playsCompositions);
|
||||
const animations = new AnimationSystem();
|
||||
const sources = { collisions, animations, audio, clock, saves, recorder };
|
||||
const sources = { collisions, animations, compositions, audio, clock, saves, recorder };
|
||||
const behaviors = new BehaviorSystem(project, input, sources, shouldRun);
|
||||
if (carried) behaviors.carryOver(carried);
|
||||
if (kept) {
|
||||
@@ -146,6 +152,7 @@ export function addGameSystems(
|
||||
if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion);
|
||||
}
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(compositions);
|
||||
world.addSystem(animations);
|
||||
world.addSystem(behaviors);
|
||||
return behaviors;
|
||||
@@ -155,6 +162,7 @@ export function addGameSystems(
|
||||
export interface EventSources {
|
||||
collisions?: (CollisionSource & System) | null;
|
||||
animations?: AnimationSystem | null;
|
||||
compositions?: CompositionSystem | null;
|
||||
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
|
||||
audio?: AudioSink | null;
|
||||
clock?: TimeScaled | null;
|
||||
@@ -200,6 +208,7 @@ export class BehaviorSystem implements System {
|
||||
private adopted: Instance[] = [];
|
||||
private readonly collisions: CollisionSource | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
private readonly compositions: CompositionSystem | null;
|
||||
private readonly audio: AudioSink;
|
||||
private readonly clock: TimeScaled | null;
|
||||
private readonly saves: SaveArea | null;
|
||||
@@ -219,6 +228,7 @@ export class BehaviorSystem implements System {
|
||||
) {
|
||||
this.collisions = sources.collisions ?? null;
|
||||
this.animations = sources.animations ?? null;
|
||||
this.compositions = sources.compositions ?? null;
|
||||
this.audio = sources.audio ?? SILENT_AUDIO;
|
||||
this.clock = sources.clock ?? null;
|
||||
this.saves = sources.saves ?? null;
|
||||
@@ -614,7 +624,7 @@ export class BehaviorSystem implements System {
|
||||
const owner = i.entity.parent;
|
||||
return owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside;
|
||||
};
|
||||
const { input, collisions, animations } = this;
|
||||
const { input, collisions, animations, compositions } = this;
|
||||
const perCell: PerCell = (i, node) =>
|
||||
asBoolean(i.interpreter.eventInput(node, PER_CELL_INPUT, contextOf(i)));
|
||||
const kinds: (() => Pulse[])[] = [
|
||||
@@ -630,6 +640,7 @@ export class BehaviorSystem implements System {
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionStayPulses(i, collisions, dt, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => collisionExitPulses(i, collisions, perCell))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => animationFinishedPulses(i, animations))),
|
||||
() => (frozen ? [] : ticking.flatMap((i) => compositionFinishedPulses(i, compositions))),
|
||||
() =>
|
||||
pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [],
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import { trackWriteAt } from '../../core/model/animation';
|
||||
import {
|
||||
compositionAt,
|
||||
compositionFrameAt,
|
||||
compositionFps,
|
||||
compositionLength,
|
||||
compositionLoops,
|
||||
type LiveItem,
|
||||
} from '../../core/model/composition';
|
||||
import type { CompositionAsset } from '../../core/model/Asset';
|
||||
import { curveLookup } from '../../core/model/curve';
|
||||
import { contentId, placedWithContent } from '../../core/model/instance';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findVideo } from '../../core/model/video';
|
||||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bind } from './AnimationSystem';
|
||||
import type { System } from './System';
|
||||
|
||||
export interface CompositionFinish {
|
||||
/** The placed composition. */
|
||||
entity: Entity;
|
||||
compositionId: string;
|
||||
startedBy: readonly object[];
|
||||
}
|
||||
|
||||
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
|
||||
interface Spawned {
|
||||
entity: Entity;
|
||||
bases: Map<string, PropertyValue>;
|
||||
videos: Entity[];
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: number;
|
||||
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
|
||||
live: Map<string, Spawned>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
|
||||
* when the item begins and destroys it when it ends, writes the clips over them and drives their
|
||||
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
|
||||
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
|
||||
* a cut would stay behind.
|
||||
*/
|
||||
export class CompositionSystem implements System {
|
||||
finished: CompositionFinish[] = [];
|
||||
private readonly runs = new Map<Entity, Run>();
|
||||
|
||||
constructor(private readonly plays = true) {}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
this.finished = [];
|
||||
for (const placement of this.runs.keys())
|
||||
if (!world.entities.includes(placement)) this.runs.delete(placement);
|
||||
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
|
||||
for (const placement of started) this.advance(world, placement, dt);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.finished = [];
|
||||
this.runs.clear();
|
||||
}
|
||||
|
||||
private advance(world: World, placement: Entity, dt: number): void {
|
||||
const player = placement.composition!;
|
||||
if (!this.plays) {
|
||||
world.log.push('Play composition is ignored in the editor run');
|
||||
delete placement.composition;
|
||||
return;
|
||||
}
|
||||
let run = this.runs.get(placement);
|
||||
if (run && run.id !== player.run) this.end(world, placement);
|
||||
const comp = findAssetOf(world.project, placement.assetId, 'composition');
|
||||
if (!comp || player.stopped) {
|
||||
this.end(world, placement);
|
||||
delete placement.composition;
|
||||
return;
|
||||
}
|
||||
run = this.runs.get(placement);
|
||||
if (!run) this.runs.set(placement, (run = { id: player.run, live: new Map() }));
|
||||
if (player.shown && isHandleEnabled(placement)) player.time += dt;
|
||||
player.shown = true;
|
||||
const length = compositionLength(world.project, comp);
|
||||
let frame = compositionFrameAt(comp, player.time);
|
||||
if (frame >= length && compositionLoops(comp) && length > 0) {
|
||||
player.time %= length / compositionFps(comp);
|
||||
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
|
||||
} else if (frame >= length) {
|
||||
this.end(world, placement);
|
||||
delete placement.composition;
|
||||
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
|
||||
return;
|
||||
}
|
||||
this.show(world, comp, placement, run, frame);
|
||||
}
|
||||
|
||||
/** Takes every item of the placement's run away. */
|
||||
private end(world: World, placement: Entity): void {
|
||||
const run = this.runs.get(placement);
|
||||
if (!run) return;
|
||||
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
|
||||
this.runs.delete(placement);
|
||||
}
|
||||
|
||||
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
|
||||
const live = compositionAt(world.project, comp, frame);
|
||||
const alive = new Set(live.map((l) => l.item.id));
|
||||
for (const [id, { entity }] of run.live) {
|
||||
if (alive.has(id)) continue;
|
||||
if (world.entities.includes(entity)) world.remove(entity);
|
||||
run.live.delete(id);
|
||||
}
|
||||
let born = false;
|
||||
for (const item of live) {
|
||||
if (run.live.has(item.item.id)) continue;
|
||||
const spawned = spawn(world, placement, item);
|
||||
if (!spawned) continue;
|
||||
run.live.set(item.item.id, spawned);
|
||||
born = true;
|
||||
}
|
||||
// In the order the tracks lay them, whenever they began.
|
||||
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
|
||||
const curves = curveLookup(world.project);
|
||||
for (const item of live) {
|
||||
const spawned = run.live.get(item.item.id);
|
||||
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
|
||||
const id = contentId(placement.objectId, item.object.id);
|
||||
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
|
||||
const entity = entities.find((e) => e.objectId === id);
|
||||
if (!entity) return null;
|
||||
// Below the item, a sprite set to be driven by hand keeps its own time.
|
||||
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
|
||||
return { entity, bases: new Map(), videos };
|
||||
}
|
||||
|
||||
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
|
||||
for (const sprite of spawned.videos) {
|
||||
const texture = sprite.props.texture;
|
||||
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
|
||||
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
|
||||
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
|
||||
}
|
||||
for (const clip of item.clips) {
|
||||
for (const b of bind(world, clip.animation, spawned.entity)) {
|
||||
let base = spawned.bases.get(b.base);
|
||||
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
|
||||
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
|
||||
if (value !== undefined) b.write(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { AnimationSystem } from './AnimationSystem';
|
||||
import type { CompositionSystem } from './CompositionSystem';
|
||||
import type { Input } from './Input';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, pairKey } from './solids';
|
||||
|
||||
@@ -226,6 +227,29 @@ export function animationFinishedPulses(instance: Instance, animations: Animatio
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A behavior hears the compositions placed at its owner or below it, and those it played itself
|
||||
* wherever they stand – the rule of `On animation finished`.
|
||||
*/
|
||||
export function compositionFinishedPulses(
|
||||
instance: Instance,
|
||||
compositions: CompositionSystem | null,
|
||||
): Pulse[] {
|
||||
const owner = instance.entity.parent;
|
||||
const finished = (compositions?.finished ?? []).filter(
|
||||
({ entity, startedBy }) =>
|
||||
!owner ||
|
||||
startedBy.includes(instance.entity) ||
|
||||
[...parentChain(entity, (e) => e.parent)].includes(owner),
|
||||
);
|
||||
return instance.interpreter.eventNodes('OnCompositionFinished').flatMap((node) => {
|
||||
const wanted = textOf(node.params.composition ?? '');
|
||||
return finished
|
||||
.filter(({ compositionId }) => wanted === '' || wanted === compositionId)
|
||||
.map(({ entity }) => ({ instance, node, outputs: { object: entity } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** What a collision event hands on, seen from the behavior's side; `contact` only for enter and stay. */
|
||||
function sideOutputs({ own, other, pair }: Side, contact?: Partial<Contact> | null): Record<string, Value> {
|
||||
const outputs: Record<string, Value> = {
|
||||
|
||||
+19
-2
@@ -1,6 +1,7 @@
|
||||
import { isHandleEnabled, worldTransformOf } from '../core/graph';
|
||||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||
import { treeOrder } from '../core/model/hierarchy';
|
||||
import { placedPicture } from '../core/model/composition';
|
||||
import { resolvedHolder } from '../core/model/instance';
|
||||
import type { Mat } from '../core/model/matrix';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
@@ -79,6 +80,8 @@ export interface DrawItem {
|
||||
clip: ClipRegion | null;
|
||||
/** The render layer it is on, its parents' choice included (#302): which cameras see it. */
|
||||
renderLayer: number;
|
||||
/** Part of what a placed composition shows (#417): the placement, which a click on it selects. */
|
||||
sealedIn?: string;
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
* 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(
|
||||
project: Project,
|
||||
holder: ObjectHolder | undefined,
|
||||
fallbackBackground: number,
|
||||
run: World | null = null,
|
||||
pictures = true,
|
||||
): DrawList {
|
||||
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[] = [];
|
||||
for (const { object } of treeOrder(view)) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
@@ -240,6 +256,7 @@ export function collectHolderItems(
|
||||
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
|
||||
clip: null,
|
||||
renderLayer: 0,
|
||||
...(sealed.has(object.id) && { sealedIn: sealed.get(object.id) }),
|
||||
});
|
||||
}
|
||||
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
|
||||
* 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
|
||||
* 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[] {
|
||||
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);
|
||||
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 camera from '../editor/panels/icons/camera.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 joint from '../editor/panels/icons/joint.svg?raw';
|
||||
import particles from '../editor/panels/icons/particles.svg?raw';
|
||||
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
|
||||
|
||||
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> = {
|
||||
behavior,
|
||||
camera,
|
||||
collider,
|
||||
composition,
|
||||
container,
|
||||
joint,
|
||||
particles,
|
||||
|
||||
@@ -174,6 +174,7 @@ export function drawItem(
|
||||
return;
|
||||
case 'behavior':
|
||||
case 'container':
|
||||
case 'composition':
|
||||
case 'rigidbody':
|
||||
case 'joint':
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user