An animation reaches the scene's own background and objects inside placed containers (#109)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
353b348c3c
commit
f68941fa3a
7 files changed
+334
-128
No files matched your search
+59
-16
@@ -2,6 +2,8 @@ import { isKindWith } from '../properties/kinds';
|
||||
import { type PropertyDef, schemaFor } from '../properties/PropertySchema';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset } from './Asset';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import { treeOrder } from './hierarchy';
|
||||
import { instanceChildId, resolveObjects } from './instance';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { colorOf, type PropertyValue, textOf } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
@@ -151,29 +153,71 @@ export function trackWriteAt(
|
||||
|
||||
/** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */
|
||||
export function animatedKind(type: AssetType, key: string): string | undefined {
|
||||
if (isPositionKey(key)) return 'number';
|
||||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows the timeline offers for an asset type: position, then the schema's flat properties. A
|
||||
* list or struct has no single value to key; a shader's own parameters come later (#97).
|
||||
* list or struct has no single value to key; a shader's own parameters come later (#97). A scene is
|
||||
* placed nowhere, so it offers only its own properties.
|
||||
*/
|
||||
export function animatableDefs(type: AssetType): PropertyDef[] {
|
||||
if (type === 'scene') return [];
|
||||
const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct');
|
||||
if (type === 'scene') return own;
|
||||
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
|
||||
key,
|
||||
label: key.toUpperCase(),
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
}));
|
||||
const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct');
|
||||
return [...position, ...own];
|
||||
}
|
||||
|
||||
/** The objects a row may aim at below the root: a container's or scene's whole tree, nothing for the rest. */
|
||||
export function rootObjects(root: Asset | undefined): readonly SceneObject[] {
|
||||
return root && (root.type === 'container' || root.type === 'scene') ? root.objects : [];
|
||||
/**
|
||||
* The id of the one object a placed root stands on – the preview's holder, and the instance the
|
||||
* targets inside a container are resolved below.
|
||||
*/
|
||||
export const PLACED_ROOT_ID = 'preview-root';
|
||||
|
||||
/** The root placed at the origin, its content following the template like any placed container's. */
|
||||
export function placedRoot(root: Asset): SceneObject {
|
||||
return {
|
||||
id: PLACED_ROOT_ID,
|
||||
assetId: root.id,
|
||||
name: root.name,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
editorOffset: { x: 0, y: 0 },
|
||||
overrides: {},
|
||||
parentId: '',
|
||||
instanceEdits: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** One object a row may aim at below the root, under the id the row stores. */
|
||||
export interface AnimationTarget {
|
||||
id: string;
|
||||
object: SceneObject;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a row may aim at below the root, in tree order – into placed containers, too (#109).
|
||||
* A scene's rows name its objects by the ids the running scene gives them, which a baked instance
|
||||
* keeps. A container's rows name a path of template ids, what the runtime appends to whichever
|
||||
* object the animation plays on. A sprite or text has nothing below it.
|
||||
*/
|
||||
export function animationTargets(project: Project, root: Asset | undefined): AnimationTarget[] {
|
||||
if (root?.type === 'scene')
|
||||
return treeOrder({ objects: resolveObjects(project, root) }).map(({ object, depth }) => ({
|
||||
id: object.id,
|
||||
object,
|
||||
depth,
|
||||
}));
|
||||
if (root?.type !== 'container') return [];
|
||||
const prefix = instanceChildId(PLACED_ROOT_ID, '');
|
||||
return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) })
|
||||
.filter(({ object }) => object.id.startsWith(prefix))
|
||||
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
|
||||
}
|
||||
|
||||
/** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */
|
||||
@@ -184,21 +228,20 @@ export function targetAssetOf(
|
||||
): Asset | undefined {
|
||||
const root = findAsset(project, animation.root);
|
||||
if (target === '') return root;
|
||||
const object = rootObjects(root).find((o) => o.id === target);
|
||||
return object ? findAsset(project, object.assetId) : undefined;
|
||||
const aimed = animationTargets(project, root).find((t) => t.id === target);
|
||||
return aimed ? findAsset(project, aimed.object.assetId) : undefined;
|
||||
}
|
||||
|
||||
/** How the timeline names what a row aims at: the object (or the root itself) and the property. */
|
||||
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
|
||||
const root = findAsset(project, animation.root);
|
||||
const object = track.target === '' ? undefined : rootObjects(root).find((o) => o.id === track.target);
|
||||
const aimed = targetAssetOf(project, animation, track.target);
|
||||
const who =
|
||||
const object =
|
||||
track.target === ''
|
||||
? (root?.name ?? '?')
|
||||
: object
|
||||
? object.name || findAsset(project, object.assetId)?.name || '?'
|
||||
: '(missing)';
|
||||
? undefined
|
||||
: animationTargets(project, root).find((t) => t.id === track.target)?.object;
|
||||
const aimed = object ? findAsset(project, object.assetId) : track.target === '' ? root : undefined;
|
||||
const who =
|
||||
track.target === '' ? (root?.name ?? '?') : object ? object.name || aimed?.name || '?' : '(missing)';
|
||||
const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property);
|
||||
return `${who} · ${def?.label ?? track.property}`;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,33 @@
|
||||
* the playhead written over it. Nothing here touches the document – the holder returned is built
|
||||
* fresh on every call from copies, and the preview draws it the way the Scene tab draws a scene.
|
||||
*/
|
||||
import { animatedKind, frameCount, isPositionKey, playbackOf, rootObjects, trackWriteAt } from './animation';
|
||||
import {
|
||||
animatableDefs,
|
||||
animatedKind,
|
||||
frameCount,
|
||||
isPositionKey,
|
||||
PLACED_ROOT_ID,
|
||||
placedRoot,
|
||||
playbackOf,
|
||||
trackWriteAt,
|
||||
} from './animation';
|
||||
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
|
||||
import { type InstanceEdit, instanceChildId } from './instance';
|
||||
import { instanceChildId, objectRef } from './instance';
|
||||
import { findAsset, type ObjectHolder, type Project } from './Project';
|
||||
import type { PropertyValue } from './Properties';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
/** The one object the preview places the root on – what a row aimed at the root itself writes to. */
|
||||
export const PREVIEW_ROOT_ID = 'preview-root';
|
||||
export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
|
||||
|
||||
/**
|
||||
* The holder the preview draws at `frame`: the scene itself for a scene root, otherwise a holder
|
||||
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
|
||||
* of one object placing the root at the origin, its content resolved from the template like any
|
||||
* placed container's. Rows reach the root through that object and its children through the
|
||||
* object's instance edits – the same route a placed container's content takes. `undefined`
|
||||
* without a root. Past the end of a `once-reset` pass nothing is written: the player has put the
|
||||
* starting values back by then.
|
||||
* placed container's. A row reaches an object stored in the holder directly and one resolved from a
|
||||
* container through its instance's edits – the same route an edit in the Scene tab takes, so a row
|
||||
* aimed into a placed container needs nothing of its own (#109). `undefined` without a root. Past
|
||||
* the end of a `once-reset` pass nothing is written: the player has put the starting values back.
|
||||
*/
|
||||
export function previewHolder(
|
||||
project: Project,
|
||||
@@ -30,10 +39,12 @@ export function previewHolder(
|
||||
const root = findAsset(project, animation.root);
|
||||
if (!root) return undefined;
|
||||
const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation);
|
||||
const tracks = reset ? [] : animation.tracks;
|
||||
return root.type === 'scene'
|
||||
? sceneAt(project, tracks, root, frame)
|
||||
: placedAt(project, tracks, root, frame);
|
||||
const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root);
|
||||
for (const track of reset ? [] : animation.tracks) {
|
||||
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame);
|
||||
else writeObject(project, holder, previewObjectId(root, track.target), track, frame);
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
|
||||
/** The id the preview draws a row's target under – for a check that wants to look at it. */
|
||||
@@ -42,70 +53,48 @@ export function previewObjectId(root: Asset | undefined, target: string): string
|
||||
return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target);
|
||||
}
|
||||
|
||||
function sceneAt(
|
||||
project: Project,
|
||||
tracks: readonly AnimationTrack[],
|
||||
root: ObjectHolder,
|
||||
frame: number,
|
||||
): ObjectHolder {
|
||||
const objects = root.objects.map((o) => ({
|
||||
...o,
|
||||
position: { ...o.position },
|
||||
overrides: { ...o.overrides },
|
||||
}));
|
||||
const byId = new Map(objects.map((o) => [o.id, o]));
|
||||
for (const track of tracks) {
|
||||
const object = byId.get(track.target);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
const kind = asset && animatedKind(asset.type, track.property);
|
||||
if (!object || !asset || !kind) continue;
|
||||
const base = isPositionKey(track.property)
|
||||
? object.position[track.property]
|
||||
: resolveSceneObjectProps(project, asset, object)[track.property];
|
||||
const value = trackWriteAt(track, kind, frame, base);
|
||||
if (value === undefined) continue;
|
||||
if (isPositionKey(track.property)) object.position[track.property] = Number(value);
|
||||
else object.overrides[track.property] = value;
|
||||
}
|
||||
return { ...root, objects };
|
||||
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
|
||||
function sceneCopy(scene: ObjectHolder): ObjectHolder {
|
||||
return {
|
||||
...scene,
|
||||
props: { ...scene.props },
|
||||
objects: scene.objects.map((o) => ({
|
||||
...o,
|
||||
position: { ...o.position },
|
||||
overrides: { ...o.overrides },
|
||||
instanceEdits: structuredClone(o.instanceEdits),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function placedAt(
|
||||
function placedHolder(root: Asset): ObjectHolder {
|
||||
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
|
||||
}
|
||||
|
||||
/** A row on the scene itself: its value goes into the copy's own properties. */
|
||||
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number): void {
|
||||
const def = animatableDefs('scene').find((d) => d.key === track.property);
|
||||
if (!def) return;
|
||||
const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default);
|
||||
if (value !== undefined) holder.props[track.property] = value;
|
||||
}
|
||||
|
||||
function writeObject(
|
||||
project: Project,
|
||||
tracks: readonly AnimationTrack[],
|
||||
root: Asset,
|
||||
holder: ObjectHolder,
|
||||
id: string,
|
||||
track: AnimationTrack,
|
||||
frame: number,
|
||||
): ObjectHolder {
|
||||
const placed: SceneObject = {
|
||||
id: PREVIEW_ROOT_ID,
|
||||
assetId: root.id,
|
||||
name: root.name,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
editorOffset: { x: 0, y: 0 },
|
||||
overrides: {},
|
||||
parentId: '',
|
||||
instanceEdits: {},
|
||||
};
|
||||
const templates = rootObjects(root);
|
||||
for (const track of tracks) {
|
||||
const template = track.target === '' ? undefined : templates.find((o) => o.id === track.target);
|
||||
const aimed = track.target === '' ? root : template && findAsset(project, template.assetId);
|
||||
const kind = aimed && animatedKind(aimed.type, track.property);
|
||||
if (!aimed || !kind) continue;
|
||||
const base = template
|
||||
? baseOf(project, aimed, template, track.property)
|
||||
: baseOf(project, aimed, placed, track.property);
|
||||
const value = trackWriteAt(track, kind, frame, base);
|
||||
if (value === undefined) continue;
|
||||
if (!template) writeTo(placed, track.property, value);
|
||||
else
|
||||
writeEdit(
|
||||
(placed.instanceEdits[template.id] ??= { position: {}, overrides: {} }),
|
||||
track.property,
|
||||
value,
|
||||
);
|
||||
}
|
||||
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placed] };
|
||||
): void {
|
||||
const ref = objectRef(project, holder, id);
|
||||
const asset = ref && findAsset(project, ref.object.assetId);
|
||||
const kind = asset && animatedKind(asset.type, track.property);
|
||||
if (!ref || !asset || !kind) return;
|
||||
const value = trackWriteAt(track, kind, frame, baseOf(project, asset, ref.object, track.property));
|
||||
if (value === undefined) return;
|
||||
if (isPositionKey(track.property))
|
||||
ref.setPosition({ ...ref.object.position, [track.property]: Number(value) });
|
||||
else ref.setOverride(track.property, value);
|
||||
}
|
||||
|
||||
/** What the target holds before the animation writes it: the zero of a relative row. */
|
||||
@@ -113,13 +102,3 @@ function baseOf(project: Project, asset: Asset, object: SceneObject, key: string
|
||||
if (isPositionKey(key)) return object.position[key];
|
||||
return resolveSceneObjectProps(project, asset, object)[key];
|
||||
}
|
||||
|
||||
function writeTo(object: SceneObject, key: string, value: PropertyValue): void {
|
||||
if (isPositionKey(key)) object.position[key] = Number(value);
|
||||
else object.overrides[key] = value;
|
||||
}
|
||||
|
||||
function writeEdit(edit: InstanceEdit, key: string, value: PropertyValue): void {
|
||||
if (isPositionKey(key)) edit.position[key] = Number(value);
|
||||
else edit.overrides[key] = value;
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import {
|
||||
AddAnimationTrackCommand,
|
||||
RetargetAnimationTrackCommand,
|
||||
} from '../../core/commands/animationCommands';
|
||||
import { animatableDefs, animatedKind, rootObjects, targetAssetOf } from '../../core/model/animation';
|
||||
import { animatableDefs, animatedKind, animationTargets, targetAssetOf } from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import { treeOrder } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Popup, Spacer, Theme } from '../../ui';
|
||||
@@ -126,12 +125,11 @@ export class RowDialog extends Popup {
|
||||
const root = findAsset(this.state.project, this.asset.root);
|
||||
if (!root) return [];
|
||||
const choices: TargetChoice[] = [{ id: '', label: `${root.name} (itself)` }];
|
||||
if (root.type === 'container' || root.type === 'scene')
|
||||
for (const { object, depth } of treeOrder(root))
|
||||
choices.push({
|
||||
id: object.id,
|
||||
label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`,
|
||||
});
|
||||
for (const { id, object, depth } of animationTargets(this.state.project, root))
|
||||
choices.push({
|
||||
id,
|
||||
label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`,
|
||||
});
|
||||
return choices;
|
||||
}
|
||||
|
||||
@@ -166,9 +164,7 @@ export class RowDialog extends Popup {
|
||||
this.refresh();
|
||||
},
|
||||
}));
|
||||
new Menu(items, {
|
||||
searchable: rootObjects(findAsset(this.state.project, this.asset.root)).length > 8,
|
||||
}).openBelow(this.targetButton);
|
||||
new Menu(items, { searchable: items.length > 9 }).openBelow(this.targetButton);
|
||||
}
|
||||
|
||||
private openProperties(): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
||||
import { newId } from '../core/ids';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||
import { treeOrder } from '../core/model/hierarchy';
|
||||
@@ -34,7 +35,8 @@ export interface ModelWrite {
|
||||
*/
|
||||
export class World {
|
||||
readonly entities: Entity[] = [];
|
||||
backgroundColor = 0x000000;
|
||||
/** The running scene's own properties – read from the model, and written by an animation over the scene. */
|
||||
readonly sceneProps: PropertyBag = {};
|
||||
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
|
||||
stopRequested = false;
|
||||
/** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */
|
||||
@@ -83,6 +85,11 @@ export class World {
|
||||
return this.elapsed;
|
||||
}
|
||||
|
||||
get backgroundColor(): number {
|
||||
const background = this.sceneProps.backgroundColor;
|
||||
return typeof background === 'number' ? background : 0x000000;
|
||||
}
|
||||
|
||||
/** Makes the entities match the scene; props are updated in place so systems holding a bag stay current. */
|
||||
syncFromModel(): void {
|
||||
const scene = findScene(this.project, this.sceneId);
|
||||
@@ -91,8 +98,7 @@ export class World {
|
||||
// behavior destroy one of them for good. Play does it (on its throwaway copy); the editor run
|
||||
// must not, so there the content stays resolved and `objectRef` writes into the instance edits.
|
||||
if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true;
|
||||
const background = scene.props.backgroundColor;
|
||||
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
|
||||
Object.assign(this.sceneProps, scene.props);
|
||||
const objects = new Map(resolveObjects(this.project, scene).map((o) => [o.id, o]));
|
||||
const keep = new Set<Entity>();
|
||||
for (const object of objects.values()) {
|
||||
|
||||
@@ -21,12 +21,14 @@ export interface AnimationFinish {
|
||||
animationId: string;
|
||||
}
|
||||
|
||||
/** A row that reaches something this tick: the entity it writes and the kind it blends as. */
|
||||
/** A row that reaches something this tick: how to read and write it, and the kind it blends as. */
|
||||
interface Binding {
|
||||
track: AnimationTrack;
|
||||
entity: Entity;
|
||||
kind: string;
|
||||
/** Where the player keeps the starting value: `<entity id> <property>`, or `scene <property>`. */
|
||||
base: string;
|
||||
read(): PropertyValue;
|
||||
write(value: PropertyValue): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,18 +55,18 @@ export class AnimationSystem implements System {
|
||||
if (!player.bases) {
|
||||
// The first tick only takes the starting values and shows frame 0; the clock runs from the next.
|
||||
player.bases = {};
|
||||
for (const b of bindings) player.bases[b.base] = read(b.entity, b.track.property);
|
||||
for (const b of bindings) player.bases[b.base] = b.read();
|
||||
} else if (player.playing && isHandleEnabled(entity)) player.time += dt;
|
||||
const head = animationPlayheadAt(asset, player.time);
|
||||
for (const b of bindings) {
|
||||
const value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base]);
|
||||
if (value !== undefined) write(b.entity, b.track.property, value);
|
||||
if (value !== undefined) b.write(value);
|
||||
}
|
||||
if (!head.finished) continue;
|
||||
if (playbackOf(asset) === 'once-reset')
|
||||
for (const b of bindings) {
|
||||
const base = player.bases[b.base];
|
||||
if (base !== undefined) write(b.entity, b.track.property, base);
|
||||
if (base !== undefined) b.write(base);
|
||||
}
|
||||
drop(entity, player);
|
||||
this.finished.push({ entity, animationId: asset.id });
|
||||
@@ -81,32 +83,58 @@ function drop(entity: Entity, player: AnimationPlayer): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that reach something on this object. A scene's rows aim at its objects by id, and only
|
||||
* while that scene runs; everything else aims at the object itself or at what its template placed
|
||||
* inside it. A row whose target is gone, or lacks the property, is skipped.
|
||||
* The rows that reach something on this object. A scene's rows aim at the scene itself or at its
|
||||
* objects by id, and only while that scene runs; everything else aims at the object itself or at
|
||||
* what its template placed inside it, however deep. A row whose target is gone, or lacks the
|
||||
* property, is skipped.
|
||||
*/
|
||||
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
const root = world.project.assets.find((a) => a.id === asset.root);
|
||||
if (!root) return [];
|
||||
const bindings: Binding[] = [];
|
||||
for (const track of asset.tracks) {
|
||||
const key = track.property;
|
||||
if (root.type === 'scene' && track.target === '') {
|
||||
const kind = world.sceneId === root.id ? animatedKind('scene', key) : undefined;
|
||||
if (kind)
|
||||
bindings.push({
|
||||
track,
|
||||
kind,
|
||||
base: `scene ${key}`,
|
||||
read: () => world.sceneProps[key] ?? 0,
|
||||
write: (value) => {
|
||||
world.sceneProps[key] = value;
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let entity: Entity | undefined;
|
||||
if (root.type === 'scene')
|
||||
entity = world.sceneId === root.id && track.target ? world.findByObjectId(track.target) : undefined;
|
||||
entity = world.sceneId === root.id ? world.findByObjectId(track.target) : undefined;
|
||||
else if (track.target === '') entity = owner;
|
||||
else entity = world.findByObjectId(childId(owner.objectId, track.target));
|
||||
const kind = entity && animatedKind(entity.type, track.property);
|
||||
if (entity && kind) bindings.push({ track, entity, kind, base: `${entity.id} ${track.property}` });
|
||||
const kind = entity && animatedKind(entity.type, key);
|
||||
if (entity && kind) {
|
||||
const aimed = entity;
|
||||
bindings.push({
|
||||
track,
|
||||
kind,
|
||||
base: `${aimed.id} ${key}`,
|
||||
read: () => read(aimed, key),
|
||||
write: (value) => write(aimed, key, value),
|
||||
});
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** The id a template object gets below a placed container – nested one deeper when the container is content itself. */
|
||||
function childId(ownerId: string, templateId: string): string {
|
||||
/**
|
||||
* 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}/${templateId}`)
|
||||
: instanceChildId(ownerId, templateId);
|
||||
return split ? instanceChildId(split.instanceId, `${split.path}/${path}`) : instanceChildId(ownerId, path);
|
||||
}
|
||||
|
||||
function read(entity: Entity, key: string): PropertyValue {
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
SetTrackRelativeCommand,
|
||||
} from '../../src/core/commands/animationCommands';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { animatableDefs, animationTargets, targetAssetOf, trackLabel } from '../../src/core/model/animation';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { PREVIEW_ROOT_ID, previewHolder, previewObjectId } from '../../src/core/model/animationPreview';
|
||||
@@ -183,3 +185,86 @@ describe('the preview of an animation over a scene', () => {
|
||||
expect(f.sceneOf().objects.find((o) => o.id === box)!.position.y).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
|
||||
function nested() {
|
||||
const f = makeSpriteScene();
|
||||
const arm = place(f, f.spriteId, { x: 10, y: 20 });
|
||||
const inner = new CreateContainerCommand(f.sceneId, [arm]);
|
||||
f.stack.execute(inner);
|
||||
const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
|
||||
f.stack.execute(outer);
|
||||
const placing = (findAsset(f.project, outer.assetId) as ContainerAsset).objects[0]!;
|
||||
const armTemplate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!;
|
||||
return { f, inner, outer, placing, armTemplate, path: `${placing.id}/${armTemplate.id}` };
|
||||
}
|
||||
|
||||
describe('the reach of a row (#109)', () => {
|
||||
it('offers what a container placed inside the root holds, as a path of template ids', () => {
|
||||
const { f, outer, placing, path } = nested();
|
||||
const targets = animationTargets(f.project, findAsset(f.project, outer.assetId));
|
||||
expect(targets.map((t) => [t.id, t.depth])).toEqual([
|
||||
[placing.id, 0],
|
||||
[path, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers a scene the content of its placed containers under the ids the running scene uses', () => {
|
||||
const { f, outer, placing, path } = nested();
|
||||
const targets = animationTargets(f.project, findAsset(f.project, f.sceneId));
|
||||
expect(targets.map((t) => t.id)).toEqual([
|
||||
outer.objectId,
|
||||
instanceChildId(outer.objectId, placing.id),
|
||||
instanceChildId(outer.objectId, path),
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a row aimed into a nested container on the child, through the instance edits of a copy', () => {
|
||||
const { f, outer, path, armTemplate } = nested();
|
||||
const walk = addAnimation(f, outer.assetId);
|
||||
addRow(f, walk, 'x', [[0, 55]], path);
|
||||
const before = JSON.stringify(f.project);
|
||||
const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PREVIEW_ROOT_ID, path))!;
|
||||
expect(child.position).toEqual({ ...armTemplate.position, x: 55 });
|
||||
expect(JSON.stringify(f.project)).toBe(before);
|
||||
});
|
||||
|
||||
it('draws a scene row aimed into a placed container without writing the scene', () => {
|
||||
const { f, outer, path } = nested();
|
||||
const id = instanceChildId(outer.objectId, path);
|
||||
const walk = addAnimation(f, f.sceneId);
|
||||
addRow(f, walk, 'y', [[0, 300]], id);
|
||||
const before = JSON.stringify(f.project);
|
||||
const holder = previewHolder(f.project, current(f, walk.id), 0)!;
|
||||
expect(resolveObjects(f.project, holder).find((o) => o.id === id)!.position.y).toBe(300);
|
||||
expect(JSON.stringify(f.project)).toBe(before);
|
||||
});
|
||||
|
||||
it("keys the scene's own properties on the scene itself", () => {
|
||||
const f = makeSpriteScene();
|
||||
const walk = addAnimation(f, f.sceneId);
|
||||
expect(animatableDefs('scene').map((d) => d.key)).toEqual(['backgroundColor']);
|
||||
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'x'))).toThrow(CommandError);
|
||||
addRow(f, walk, 'backgroundColor', [
|
||||
[0, 0x000000],
|
||||
[10, 0xff0000],
|
||||
]);
|
||||
const before = f.sceneOf().props.backgroundColor;
|
||||
const holder = previewHolder(f.project, current(f, walk.id), 5)!;
|
||||
expect(holder.props.backgroundColor).toBe(0x800000);
|
||||
expect(collectHolderItems(f.project, holder, 0).background).toBe(0x800000);
|
||||
expect(f.sceneOf().props.backgroundColor).toBe(before);
|
||||
});
|
||||
|
||||
it('reads a row as missing once the template loses its target, and keeps its keys', () => {
|
||||
const { f, outer, inner, path, armTemplate } = nested();
|
||||
const walk = addAnimation(f, outer.assetId);
|
||||
const row = addRow(f, walk, 'x', [[0, 55]], path);
|
||||
f.stack.execute(new RemoveSceneObjectsCommand(inner.assetId, [armTemplate.id]));
|
||||
const now = current(f, walk.id);
|
||||
const track = now.tracks.find((t) => t.id === row)!;
|
||||
expect(targetAssetOf(f.project, now, path)).toBeUndefined();
|
||||
expect(trackLabel(f.project, now, track)).toBe('(missing) · x');
|
||||
expect(track.keys).toEqual([{ frame: 0, value: 55 }]);
|
||||
});
|
||||
});
|
||||
@@ -414,6 +414,75 @@ describe('the AnimationSystem', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reach of a played row (#109)', () => {
|
||||
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
|
||||
function nested() {
|
||||
const f = makeSpriteScene();
|
||||
const arm = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const inner = new CreateContainerCommand(f.sceneId, [arm]);
|
||||
f.stack.execute(inner);
|
||||
const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
|
||||
f.stack.execute(outer);
|
||||
const placing = (findAsset(f.project, outer.assetId) as ContainerAsset).objects[0]!;
|
||||
const armTemplate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!;
|
||||
return { f, outer, path: `${placing.id}/${armTemplate.id}` };
|
||||
}
|
||||
|
||||
const slideX: [number, AnimationKey['value']][] = [
|
||||
[0, 0],
|
||||
[10, 100],
|
||||
];
|
||||
|
||||
it('reaches a child two containers deep on each instance of the root on its own', () => {
|
||||
const { f, outer, path } = nested();
|
||||
const second = place(f, outer.assetId, { x: 500, y: 0 });
|
||||
const walk = addAnimation(f, outer.assetId, { fps: 10 });
|
||||
addRow(f, walk, 'x', slideX, path);
|
||||
const { world, frame } = startWorld(f, { animations: true });
|
||||
playAnimation(world.findByObjectId(outer.objectId)!, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
const x = (instance: string) => world.findByObjectId(instanceChildId(instance, path))!.x;
|
||||
expect(x(outer.objectId)).toBeCloseTo(50);
|
||||
expect(x(second)).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches into a container placed in a scene, baked for play or resolved for the editor run', () => {
|
||||
for (const bakeContainers of [true, false]) {
|
||||
const { f, outer, path } = nested();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const id = instanceChildId(outer.objectId, path);
|
||||
const walk = addAnimation(f, f.sceneId, { fps: 10 });
|
||||
addRow(f, walk, 'x', slideX, id);
|
||||
const world = World.instantiate(f.project, f.sceneId, { bakeContainers });
|
||||
world.addSystem(new AnimationSystem());
|
||||
playAnimation(world.findByObjectId(box)!, walk.id);
|
||||
world.tick(0.1);
|
||||
world.tick(0.5);
|
||||
expect(world.findByObjectId(id)!.x).toBeCloseTo(50);
|
||||
}
|
||||
});
|
||||
|
||||
it("plays the scene's own background, and a once-reset pass puts it back", () => {
|
||||
const f = makeSpriteScene();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
f.sceneOf().props.backgroundColor = 0x123456;
|
||||
const walk = addAnimation(f, f.sceneId, { fps: 10, playback: 'once-reset' });
|
||||
addRow(f, walk, 'backgroundColor', [
|
||||
[0, 0x000000],
|
||||
[10, 0xff0000],
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { animations: true });
|
||||
playAnimation(world.findByObjectId(box)!, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
expect(world.backgroundColor).toBe(0x800000);
|
||||
for (let i = 0; i < 10; i++) frame(0.1);
|
||||
expect(world.backgroundColor).toBe(0x123456);
|
||||
expect(f.sceneOf().props.backgroundColor).toBe(0x123456);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the animation nodes', () => {
|
||||
/** A behavior below the sprite plays `walk` on its owner at start and counts `On animation finished`. */
|
||||
function listening(filter: 'walk' | 'other') {
|
||||
|
||||
Reference in new issue
Block a user