An animation reaches the scene's own background and objects inside placed containers (#109)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m37s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 06:48:21 +02:00
1 parent 353b348c3c
commit f68941fa3a
7 files changed
+334 -128

No files matched your search

+59 -16
View File
@@ -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}`;
}
+61 -82
View File
@@ -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;
}
+7 -11
View File
@@ -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 {
+9 -3
View File
@@ -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()) {
+44 -16
View File
@@ -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 {
+85
View File
@@ -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 }]);
});
});
+69
View File
@@ -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') {