From beeea355d929cb1f45b51d1f39ebe321b9153015 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 17 Sep 2026 09:04:29 +0200 Subject: [PATCH] Animation rows can target behavior variables and shader parameters (#104) Co-Authored-By: Claude Opus 5 --- src/core/commands/animationCommands.ts | 24 ++--- src/core/model/animation.ts | 109 +++++++++++++++----- src/core/model/animationPreview.ts | 32 +++--- src/core/model/usages.ts | 5 +- src/editor/animation/RowDialog.ts | 14 ++- src/editor/animation/TimelineCanvas.ts | 8 +- src/editor/inspector/trackSection.ts | 6 +- src/runtime/systems/AnimationSystem.ts | 20 +++- tests/core/animation.test.ts | 132 ++++++++++++++++++++++++- 9 files changed, 273 insertions(+), 77 deletions(-) diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 2840a39a..6740dcee 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -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; diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 480c4589..def0155c 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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, ): 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)); } diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index 36ef26df..34de1b34 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -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); } diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 18b41d66..7680a0be 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -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)), ); } diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index 98b3da20..b7fbb60a 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -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 { diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 3b485c1a..51105642 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -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'); diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts index bec517cc..fe93f34e 100644 --- a/src/editor/inspector/trackSection.ts +++ b/src/editor/inspector/trackSection.ts @@ -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 { diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 1e72218c..70425488 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -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; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index badaa5e8..98a578b7 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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') {