diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index f754f01e..c21f35c0 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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}`; } diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index 9f3bcefc..1164daaf 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -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; -} diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index 98a05ad8..98b3da20 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -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 { diff --git a/src/runtime/World.ts b/src/runtime/World.ts index afde4c53..4e6b73a8 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); for (const object of objects.values()) { diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 0d45b9ad..62b0d6a3 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -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: ` `, or `scene `. */ 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 { diff --git a/tests/core/animation-preview.test.ts b/tests/core/animation-preview.test.ts index 2de5b689..55c32131 100644 --- a/tests/core/animation-preview.test.ts +++ b/tests/core/animation-preview.test.ts @@ -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 }]); + }); +}); diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index adb0a2a4..3a3098a2 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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') {