Animation rows can target behavior variables and shader parameters (#104)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b278605c53
commit
beeea355d9
9 files changed
+273
-77
No files matched your search
@@ -1,12 +1,12 @@
|
||||
import { newId } from '../ids';
|
||||
import {
|
||||
ANIMATION_ROOT_TYPES,
|
||||
animatableDefs,
|
||||
animatedKind,
|
||||
blends,
|
||||
isKeyInterpolation,
|
||||
normalizeKeys,
|
||||
targetAssetOf,
|
||||
targetDefs,
|
||||
trackDef,
|
||||
trackValueAt,
|
||||
} from '../model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
|
||||
@@ -42,10 +42,13 @@ function requireAnimatable(
|
||||
property: string,
|
||||
): string {
|
||||
if (!findAsset(project, asset.root)) throw new CommandError(`${asset.name} has nothing to animate yet`);
|
||||
const aimed = targetAssetOf(project, asset, target);
|
||||
if (!aimed) throw new CommandError('That object is not part of the animated asset');
|
||||
const kind = animatedKind(aimed.type, property);
|
||||
if (!kind) throw new CommandError(`${aimed.name} has no property ${property} to animate`);
|
||||
const defs = targetDefs(project, asset, target);
|
||||
if (!defs) throw new CommandError('That object is not part of the animated asset');
|
||||
const kind = defs.find((d) => d.key === property)?.kind;
|
||||
if (!kind)
|
||||
throw new CommandError(
|
||||
`${targetAssetOf(project, asset, target)?.name} has no property ${property} to animate`,
|
||||
);
|
||||
return kind;
|
||||
}
|
||||
|
||||
@@ -163,8 +166,7 @@ export class RetargetAnimationTrackCommand extends TimelineCommand {
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
const track = requireTrack(asset, this.trackId);
|
||||
const kind = requireAnimatable(project, asset, this.target, this.property);
|
||||
const aimed = targetAssetOf(project, asset, track.target);
|
||||
const was = aimed ? animatedKind(aimed.type, track.property) : undefined;
|
||||
const was = trackDef(project, asset, track)?.kind;
|
||||
if (was !== undefined && was !== kind) throw new CommandError(`A ${was} row cannot become a ${kind} row`);
|
||||
requireUnique(asset, this.target, this.property, track.id);
|
||||
track.target = this.target;
|
||||
@@ -217,8 +219,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
const track = requireTrack(asset, this.trackId);
|
||||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||||
throw new CommandError('A key sits on a whole frame from 0');
|
||||
const aimed = targetAssetOf(project, asset, track.target);
|
||||
const def = aimed && animatableDefs(aimed.type).find((d) => d.key === track.property);
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame);
|
||||
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
|
||||
@@ -252,8 +253,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
const aimed = targetAssetOf(project, asset, track.target);
|
||||
const kind = aimed ? animatedKind(aimed.type, track.property) : undefined;
|
||||
const kind = trackDef(project, asset, track)?.kind;
|
||||
if (kind !== undefined && !blends(kind)) continue;
|
||||
if (this.interpolation === 'linear') delete key.interpolation;
|
||||
else key.interpolation = this.interpolation;
|
||||
|
||||
+87
-22
@@ -1,11 +1,12 @@
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { type PropertyDef, schemaFor } from '../properties/PropertySchema';
|
||||
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
|
||||
import { shaderOf } from '../properties/shaders';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } 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 { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||||
@@ -209,14 +210,27 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
|
||||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
function hasPosition(type: AssetType): boolean {
|
||||
return type !== 'scene' && type !== 'behavior';
|
||||
}
|
||||
|
||||
/** Whether a row sets the object's position rather than a value in its bag – never a behavior's `x`. */
|
||||
export function isPositionRow(type: AssetType, key: string): key is (typeof POSITION_KEYS)[number] {
|
||||
return hasPosition(type) && isPositionKey(key);
|
||||
}
|
||||
|
||||
function isFlat(def: PropertyDef): boolean {
|
||||
return def.kind !== 'list' && def.kind !== 'struct';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). A scene is
|
||||
* placed nowhere, so it offers only its own properties.
|
||||
* list or struct has no single value to key. A scene is placed nowhere and a behavior drawn nowhere,
|
||||
* so neither offers a position – a behavior's variable `x` is the variable.
|
||||
*/
|
||||
export function animatableDefs(type: AssetType): PropertyDef[] {
|
||||
const own = schemaFor(type).filter((d) => d.kind !== 'list' && d.kind !== 'struct');
|
||||
if (type === 'scene') return own;
|
||||
const own = schemaFor(type).filter(isFlat);
|
||||
if (!hasPosition(type)) return own;
|
||||
const position: PropertyDef[] = POSITION_KEYS.map((key) => ({
|
||||
key,
|
||||
label: key.toUpperCase(),
|
||||
@@ -226,6 +240,28 @@ export function animatableDefs(type: AssetType): PropertyDef[] {
|
||||
return [...position, ...own];
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows one asset offers when it holds `bag`: its type's, then what the asset itself declares (#104)
|
||||
* – a behavior's variables, a sprite's parameters of the shader the bag names. Which shader that is
|
||||
* can differ per object, and at runtime per frame, hence the bag. A declared row never shadows a row
|
||||
* of the type.
|
||||
*/
|
||||
export function animatableDefsOf(
|
||||
project: Project,
|
||||
asset: Asset,
|
||||
bag: PropertyBag = asset.props,
|
||||
): PropertyDef[] {
|
||||
const own = animatableDefs(asset.type);
|
||||
const declared =
|
||||
asset.type === 'behavior'
|
||||
? asset.variables
|
||||
: asset.type === 'sprite'
|
||||
? shaderParamRows(project, shaderOf(bag))
|
||||
: [];
|
||||
const taken = new Set(own.map((d) => d.key));
|
||||
return [...own, ...declared.filter((d) => isFlat(d) && !taken.has(d.key))];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -273,39 +309,68 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani
|
||||
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
|
||||
}
|
||||
|
||||
/** What a row aims at: the asset, the object placing it (none for the root itself) and the bag it holds. */
|
||||
interface Aimed {
|
||||
asset: Asset;
|
||||
object?: SceneObject;
|
||||
bag: PropertyBag;
|
||||
}
|
||||
|
||||
function aimedAt(project: Project, animation: AnimationAsset, target: string): Aimed | undefined {
|
||||
const root = findAsset(project, animation.root);
|
||||
if (target === '') return root && { asset: root, bag: root.props };
|
||||
const object = animationTargets(project, root).find((t) => t.id === target)?.object;
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
return object && asset && { asset, object, bag: { ...asset.props, ...object.overrides } };
|
||||
}
|
||||
|
||||
/** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */
|
||||
export function targetAssetOf(
|
||||
project: Project,
|
||||
animation: AnimationAsset,
|
||||
target: string,
|
||||
): Asset | undefined {
|
||||
const root = findAsset(project, animation.root);
|
||||
if (target === '') return root;
|
||||
const aimed = animationTargets(project, root).find((t) => t.id === target);
|
||||
return aimed ? findAsset(project, aimed.object.assetId) : undefined;
|
||||
return aimedAt(project, animation, target)?.asset;
|
||||
}
|
||||
|
||||
/** The rows the object a row may aim at offers; undefined once that object is gone. */
|
||||
export function targetDefs(
|
||||
project: Project,
|
||||
animation: AnimationAsset,
|
||||
target: string,
|
||||
): PropertyDef[] | undefined {
|
||||
const aimed = aimedAt(project, animation, target);
|
||||
return aimed && animatableDefsOf(project, aimed.asset, aimed.bag);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property a row sets. Undefined when its object is gone – or has lost the property: a behavior
|
||||
* taken off, a variable renamed, a shader swapped for one without the parameter all read as missing,
|
||||
* and the keys stay.
|
||||
*/
|
||||
export function trackDef(
|
||||
project: Project,
|
||||
animation: AnimationAsset,
|
||||
track: AnimationTrack,
|
||||
): PropertyDef | undefined {
|
||||
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
|
||||
}
|
||||
|
||||
/** 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
|
||||
: 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}`;
|
||||
const aimed = aimedAt(project, animation, track.target);
|
||||
const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name;
|
||||
const def =
|
||||
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
|
||||
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
|
||||
}
|
||||
|
||||
/** Whether a value names something in the project – what deleting that something has to count. */
|
||||
export function referencesAny(
|
||||
track: AnimationTrack,
|
||||
type: AssetType | undefined,
|
||||
kind: string | undefined,
|
||||
ids: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const kind = type ? animatedKind(type, track.property) : undefined;
|
||||
if (!kind || !isKindWith('reference', kind)) return false;
|
||||
return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value));
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
*/
|
||||
import {
|
||||
animatableDefs,
|
||||
animatedKind,
|
||||
animatableDefsOf,
|
||||
frameCount,
|
||||
isPositionKey,
|
||||
isPositionRow,
|
||||
PLACED_ROOT_ID,
|
||||
placedRoot,
|
||||
playbackOf,
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
|
||||
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 = PLACED_ROOT_ID;
|
||||
@@ -91,17 +89,17 @@ function writeObject(
|
||||
): 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), reverse);
|
||||
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. */
|
||||
function baseOf(project: Project, asset: Asset, object: SceneObject, key: string): PropertyValue | undefined {
|
||||
if (isPositionKey(key)) return object.position[key];
|
||||
return resolveSceneObjectProps(project, asset, object)[key];
|
||||
if (!ref || !asset) return;
|
||||
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
|
||||
const props = resolveSceneObjectProps(project, asset, ref.object);
|
||||
const kind = animatableDefsOf(project, asset, props).find((d) => d.key === track.property)?.kind;
|
||||
if (!kind) return;
|
||||
const key = track.property;
|
||||
if (isPositionRow(asset.type, key)) {
|
||||
const value = trackWriteAt(track, kind, frame, ref.object.position[key], reverse);
|
||||
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
|
||||
return;
|
||||
}
|
||||
const value = trackWriteAt(track, kind, frame, props[key], reverse);
|
||||
if (value !== undefined) ref.setOverride(key, value);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { referencesAny, targetAssetOf } from './animation';
|
||||
import { referencesAny, trackDef } from './animation';
|
||||
import type { AnimationAsset, Asset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
@@ -68,7 +68,6 @@ export function animationsUsing(project: Project, ids: readonly string[]): Anima
|
||||
const wanted = new Set(ids);
|
||||
return project.assets.filter(
|
||||
(a): a is AnimationAsset =>
|
||||
a.type === 'animation' &&
|
||||
a.tracks.some((t) => referencesAny(t, targetAssetOf(project, a, t.target)?.type, wanted)),
|
||||
a.type === 'animation' && a.tracks.some((t) => referencesAny(t, trackDef(project, a, t)?.kind, wanted)),
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
AddAnimationTrackCommand,
|
||||
RetargetAnimationTrackCommand,
|
||||
} from '../../core/commands/animationCommands';
|
||||
import { animatableDefs, animatedKind, animationTargets, targetAssetOf } from '../../core/model/animation';
|
||||
import { animationTargets, targetDefs, trackDef } from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
@@ -135,10 +135,10 @@ export class RowDialog extends Popup {
|
||||
|
||||
/** The properties the picked object offers, less those another row already animates. */
|
||||
private properties(): PropertyDef[] {
|
||||
const aimed = targetAssetOf(this.state.project, this.asset, this.target);
|
||||
if (!aimed) return [];
|
||||
const defs = targetDefs(this.state.project, this.asset, this.target);
|
||||
if (!defs) return [];
|
||||
const was = this.trackKind();
|
||||
return animatableDefs(aimed.type).filter((def) => {
|
||||
return defs.filter((def) => {
|
||||
if (was !== null && def.kind !== was) return false;
|
||||
return !this.asset.tracks.some(
|
||||
(t) => t.id !== this.track?.id && t.target === this.target && t.property === def.key,
|
||||
@@ -146,11 +146,9 @@ export class RowDialog extends Popup {
|
||||
});
|
||||
}
|
||||
|
||||
/** The kind the row being changed has; null when its target is gone, so it may go anywhere. */
|
||||
/** The kind the row being changed has; null when its target or property is gone, so it may go anywhere. */
|
||||
private trackKind(): string | null {
|
||||
if (!this.track) return null;
|
||||
const aimed = targetAssetOf(this.state.project, this.asset, this.track.target);
|
||||
return aimed ? (animatedKind(aimed.type, this.track.property) ?? null) : null;
|
||||
return this.track ? (trackDef(this.state.project, this.asset, this.track)?.kind ?? null) : null;
|
||||
}
|
||||
|
||||
private openTargets(): void {
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
SetAnimationKeyCommand,
|
||||
} from '../../core/commands/animationCommands';
|
||||
import {
|
||||
animatedKind,
|
||||
animationPlayheadAt,
|
||||
frameCount,
|
||||
interpolationOf,
|
||||
targetAssetOf,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||
@@ -297,8 +296,7 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
/** A double click: a key with the value the row has there, picked so the inspector shows it. */
|
||||
private setKey(asset: AnimationAsset, track: AnimationTrack, frame: number): void {
|
||||
const aimed = targetAssetOf(this.state.project, asset, track.target);
|
||||
if (!aimed || !animatedKind(aimed.type, track.property)) {
|
||||
if (!trackDef(this.state.project, asset, track)) {
|
||||
this.state.setNotice('This row aims at something that is gone – change its target first.');
|
||||
return;
|
||||
}
|
||||
@@ -526,7 +524,7 @@ export class TimelineCanvas extends Widget {
|
||||
this.labels.set(track.id, label);
|
||||
this.labelLayer.addChild(label);
|
||||
}
|
||||
const missing = !asset || !targetAssetOf(this.state.project, asset, track.target);
|
||||
const missing = !asset || !trackDef(this.state.project, asset, track);
|
||||
const text = asset ? trackLabel(this.state.project, asset, track) : '';
|
||||
if (label.text !== text) label.text = text;
|
||||
label.style = textStyle(missing ? 'warning' : track.id === selectedRow ? 'accent' : 'body');
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
SetTrackRelativeCommand,
|
||||
} from '../../core/commands/animationCommands';
|
||||
import {
|
||||
animatableDefs,
|
||||
blends,
|
||||
interpolationOf,
|
||||
KEY_INTERPOLATIONS,
|
||||
targetAssetOf,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||
@@ -201,8 +200,7 @@ export function refreshTrackSection(
|
||||
}
|
||||
|
||||
function defOf(project: Project, asset: AnimationAsset, track: AnimationTrack) {
|
||||
const aimed = targetAssetOf(project, asset, track.target);
|
||||
return aimed ? animatableDefs(aimed.type).find((d) => d.key === track.property) : undefined;
|
||||
return trackDef(project, asset, track);
|
||||
}
|
||||
|
||||
function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import {
|
||||
type AnimationPlayer,
|
||||
animatableDefsOf,
|
||||
animatedKind,
|
||||
animationPlayheadAt,
|
||||
isPositionKey,
|
||||
isPositionRow,
|
||||
playbackOf,
|
||||
trackWriteAt,
|
||||
} from '../../core/model/animation';
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -113,7 +114,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
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, key);
|
||||
const kind = entity && kindOn(world, entity, key);
|
||||
if (entity && kind) {
|
||||
const aimed = entity;
|
||||
bindings.push({
|
||||
@@ -128,6 +129,15 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind `key` has on the entity as it is now: its bag names the shader it wears, and swapping that
|
||||
* shader at runtime takes the parameter rows with it (#104).
|
||||
*/
|
||||
function kindOn(world: World, entity: Entity, key: string): string | undefined {
|
||||
const asset = findAsset(world.project, entity.assetId);
|
||||
return asset && animatableDefsOf(world.project, asset, entity.props).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
@@ -138,12 +148,12 @@ function childId(ownerId: string, path: string): string {
|
||||
}
|
||||
|
||||
function read(entity: Entity, key: string): PropertyValue {
|
||||
if (isPositionKey(key)) return entity[key];
|
||||
if (isPositionRow(entity.type, key)) return entity[key];
|
||||
return entity.props[key] ?? 0;
|
||||
}
|
||||
|
||||
function write(entity: Entity, key: string, value: PropertyValue): void {
|
||||
if (isPositionKey(key)) {
|
||||
if (isPositionRow(entity.type, key)) {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) entity[key] = n;
|
||||
} else entity.props[key] = value;
|
||||
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
SetKeyInterpolationCommand,
|
||||
SetTrackRelativeCommand,
|
||||
} from '../../src/core/commands/animationCommands';
|
||||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateTextureCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
animationDuration,
|
||||
frameCount,
|
||||
@@ -25,8 +31,12 @@ import {
|
||||
playAnimation,
|
||||
playheadAt,
|
||||
stopAnimation,
|
||||
targetDefs,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
trackValueAt,
|
||||
} from '../../src/core/model/animation';
|
||||
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||||
import { PREVIEW_ROOT_ID, previewHolder } from '../../src/core/model/animationPreview';
|
||||
import type {
|
||||
AnimationAsset,
|
||||
@@ -77,6 +87,8 @@ function addRow(
|
||||
}
|
||||
|
||||
const rowOf = (animation: AnimationAsset, id: string) => animation.tracks.find((t) => t.id === id)!;
|
||||
const current = (f: SceneFixture, animation: AnimationAsset) =>
|
||||
findAssetOf(f.project, animation.id, 'animation')!;
|
||||
|
||||
describe('where the playhead stands', () => {
|
||||
it('counts the last key frame as a frame of its own, so keys on 0…9 last one second at 10 fps', () => {
|
||||
@@ -649,6 +661,124 @@ describe('the reach of a played row (#109)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior variables and shader parameters as rows (#104)', () => {
|
||||
/** A box in the scene with a `Mover` behavior below it, and a shader declaring `glow`. */
|
||||
function rig() {
|
||||
const f = makeSpriteScene();
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const mover = makeBehavior(f, 'Mover', ['speed', 'x']);
|
||||
const moverObject = place(f, mover.id, { x: 0, y: 0 }, box);
|
||||
const shader = new CreateAssetCommand('shader', 'Glow');
|
||||
f.stack.execute(shader);
|
||||
f.stack.execute(
|
||||
new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, {
|
||||
name: 'glow',
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
);
|
||||
const walk = addAnimation(f, f.sceneId, { fps: 10 });
|
||||
return { f, box, mover, moverObject, shaderId: shader.asset.id, walk };
|
||||
}
|
||||
|
||||
const ramp: [number, AnimationKey['value']][] = [
|
||||
[0, 0],
|
||||
[10, 100],
|
||||
];
|
||||
const keysOn = (f: SceneFixture, walk: AnimationAsset, target: string) =>
|
||||
targetDefs(f.project, walk, target)!.map((d) => d.key);
|
||||
|
||||
it("offers a behavior's variables and no position, and a variable `x` is the variable", () => {
|
||||
const { f, moverObject, walk } = rig();
|
||||
expect(keysOn(f, walk, moverObject)).toEqual(['enabled', 'runInEditor', 'speed', 'x']);
|
||||
const row = addRow(f, walk, 'x', ramp, moverObject);
|
||||
expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · x');
|
||||
const { world, frame } = startWorld(f, { animations: true });
|
||||
const entity = world.findByObjectId(moverObject)!;
|
||||
playAnimation(entity, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
expect(entity.props.x).toBeCloseTo(50);
|
||||
expect(entity.x).toBe(0);
|
||||
const drawn = previewHolder(f.project, current(f, walk), 5)!.objects.find((o) => o.id === moverObject)!;
|
||||
expect(drawn.overrides.x).toBeCloseTo(50);
|
||||
expect(drawn.position.x).toBe(0);
|
||||
});
|
||||
|
||||
it('writes a variable before the behavior runs, so the behavior reads it this frame', () => {
|
||||
const { f, box, mover, moverObject, walk } = rig();
|
||||
addRow(f, walk, 'speed', ramp, moverObject);
|
||||
// The graph copies `speed` into `x` every frame: what it reads is what the animation just wrote.
|
||||
const edit = graphEditor(f, mover);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const read = edit.variables(['speed']);
|
||||
const write = edit.variables(['x'], { write: true });
|
||||
edit.wire([
|
||||
[update, 'exec', write, 'exec'],
|
||||
[read, 'speed', write, 'x'],
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
|
||||
playAnimation(world.findByObjectId(box)!, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
const entity = world.findByObjectId(moverObject)!;
|
||||
expect(entity.props.speed).toBeCloseTo(50);
|
||||
expect(entity.props.x).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('offers the parameters of the shader the object wears – through its own override, too', () => {
|
||||
const { f, box, shaderId, walk } = rig();
|
||||
const plain = place(f, f.spriteId, { x: 50, y: 0 });
|
||||
expect(keysOn(f, walk, box)).not.toContain('glow');
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, box, SHADER_KEY, shaderId));
|
||||
expect(keysOn(f, walk, box)).toContain('glow');
|
||||
expect(keysOn(f, walk, plain)).not.toContain('glow');
|
||||
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, plain, 'glow'))).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it('plays a shader parameter at runtime and in the preview alike', () => {
|
||||
const { f, box, shaderId, walk } = rig();
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, shaderId));
|
||||
addRow(f, walk, 'glow', ramp, box);
|
||||
const { world, frame } = startWorld(f, { animations: true });
|
||||
const entity = world.findByObjectId(box)!;
|
||||
playAnimation(entity, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
expect(entity.props.glow).toBeCloseTo(50);
|
||||
const drawn = previewHolder(f.project, current(f, walk), 5)!.objects.find((o) => o.id === box)!;
|
||||
expect(drawn.overrides.glow).toBeCloseTo(50);
|
||||
expect(f.sceneOf().objects.find((o) => o.id === box)!.overrides.glow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads as missing once the variable is renamed or the shader taken off, and keeps its keys', () => {
|
||||
const { f, box, mover, moverObject, shaderId, walk } = rig();
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, shaderId));
|
||||
const speed = addRow(f, walk, 'speed', ramp, moverObject);
|
||||
const glow = addRow(f, walk, 'glow', ramp, box);
|
||||
f.stack.execute(new RenameVariableCommand(mover.id, 'speed', 'pace'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, ''));
|
||||
const now = current(f, walk);
|
||||
for (const id of [speed, glow]) {
|
||||
expect(trackDef(f.project, now, rowOf(now, id))).toBeUndefined();
|
||||
expect(trackLabel(f.project, now, rowOf(now, id))).toMatch(/\(missing\)$/);
|
||||
expect(rowOf(now, id).keys).toHaveLength(2);
|
||||
}
|
||||
const { world, frame } = startWorld(f, { animations: true });
|
||||
playAnimation(world.findByObjectId(box)!, walk.id);
|
||||
frame(0.1);
|
||||
frame(0.5);
|
||||
expect(world.findByObjectId(moverObject)!.props.speed).toBeUndefined();
|
||||
expect(world.findByObjectId(box)!.props.glow).toBeUndefined();
|
||||
// A missing row may go anywhere; the kind rule holds again once it aims at something.
|
||||
f.stack.execute(new RetargetAnimationTrackCommand(walk.id, speed, moverObject, 'pace'));
|
||||
expect(() =>
|
||||
f.stack.execute(new RetargetAnimationTrackCommand(walk.id, speed, moverObject, 'enabled')),
|
||||
).toThrow(CommandError);
|
||||
});
|
||||
});
|
||||
|
||||
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