diff --git a/benchmark/engine-prompt.md b/benchmark/engine-prompt.md index a1a430a2..26023fa4 100644 --- a/benchmark/engine-prompt.md +++ b/benchmark/engine-prompt.md @@ -304,7 +304,7 @@ An animation asset is a timeline over one **root asset** (a sprite, text, contai - Numbers and colours blend between keys; textures, booleans, texts and choices hold until the next key. The playhead moves every tick, not only on whole frames. A row may be **relative**: its numbers are added to what the target held when the animation started. - Each key of a blending row has an **easing** to the next key: Linear, Step, the curves Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce each as In, Out or In-out, or a curve asset. - **Animation tab:** a header (root picker, *+ Row*, Play/Pause, Fit), a preview that shows the root at the playhead without changing the document, and a timeline with a frame ruler: double-click a cell to set a key, click or band-select keys, drag to move them, `Ctrl`+drag to duplicate, `Delete` removes them, copy and paste at the playhead, click the ruler to scrub. The selected key's value and easing are edited in the inspector. -- **Runtime:** `Play animation` (object, animation, `blend` seconds to fade in from the current values, `blend out` for Once (reset), `restart`), `Stop animation` (with optional blend out), `Pause animation`. Playing an animation on a placed container plays it on that instance alone. Several animations may run on one object; where two write the same property the one started later wins, and a behavior writing it in the same frame wins over both. `On animation finished` fires exactly once in the frame a Once animation ends. `Animation playing` returns `playing`, `progress` (0 to 1) and `time left`; `Animation length` returns the seconds of one pass. +- **Runtime:** `Play animation` (object, animation, `blend` seconds to fade in from the current values, `blend out` for Once (reset), `restart`), `Stop animation` (with optional blend out), `Pause animation`. Playing an animation on a placed container plays it on that instance alone. Several animations may run on one object; where two write the same property the one started later wins, and a behavior writing it in the same frame wins over both. `On animation finished` fires exactly once in the frame a Once animation ends. A behavior hears it for animations on its owner or below it, and for every animation it played itself on any object. `Animation playing` returns `playing`, `progress` (0 to 1) and `time left`; `Animation length` returns the seconds of one pass. - Animations run in play only; the scene view shows objects as stored. ## 16. Shaders diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index b03ccabb..d8357325 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -36,7 +36,7 @@ export const PlayAnimation: NodeDef = { ], outputs: [exec()], params: [], - run: (_ctx, inputs) => { + run: (ctx, inputs) => { const target = asEntity(inputs.object); const animation = asString(inputs.animation); if (target && animation) @@ -46,6 +46,7 @@ export const PlayAnimation: NodeDef = { Math.max(0, asNumber(inputs.blend)), Math.max(0, asNumber(inputs.blendOut)), asBoolean(inputs.restart), + ctx.self, ); return { next: 'exec' }; }, diff --git a/src/core/graph/nodes/events/OnAnimationFinished.ts b/src/core/graph/nodes/events/OnAnimationFinished.ts index c2fd9cbf..b77eed70 100644 --- a/src/core/graph/nodes/events/OnAnimationFinished.ts +++ b/src/core/graph/nodes/events/OnAnimationFinished.ts @@ -8,7 +8,8 @@ export const OnAnimationFinished: NodeDef = { label: 'On animation finished', category: 'event', description: - 'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.', + 'Runs once when an animation played once on the owner, or on an object below it, reaches its end – ' + + 'and when one this behavior played itself does, on whatever object. Leave Animation at None to hear every animation.', inputs: [], outputs: [exec(), obj('object')], params: [ diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 7c526769..1deffbb8 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -548,6 +548,11 @@ export interface AnimationPlayer { /** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */ blendOut?: number; fade?: AnimationFade; + /** + * Who asked for it – the behaviors that played it, which hear its end wherever the object stands. + * Opaque here: the runtime hands in its behavior entities. + */ + startedBy?: object[]; } /** @@ -596,7 +601,8 @@ let startCount = 0; * when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of * a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in * from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row - * does not add up. + * does not add up. `startedBy` is noted on the player next to whoever asked before, so each of them + * hears it finish. */ export function playAnimation( holder: PlayerHolder, @@ -604,12 +610,15 @@ export function playAnimation( blendSeconds = 0, blendOutSeconds = 0, restart = false, + startedBy?: object, ): void { const players = (holder.animations ??= []); const known = players.find((p) => p.animationId === animationId); const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined; const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined; if (known) { + if (startedBy && !known.startedBy?.includes(startedBy)) + known.startedBy = [...(known.startedBy ?? []), startedBy]; const leads = players.every((p) => p.order <= known.order); const fade = known.fade; known.playing = true; @@ -633,6 +642,7 @@ export function playAnimation( order: ++startCount, ...(blend && { blend }), ...(blendOut && { blendOut }), + ...(startedBy && { startedBy: [startedBy] }), }); } diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 3dc48369..fce72803 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -25,6 +25,8 @@ export interface AnimationFinish { /** The object the animation was played on. */ entity: Entity; animationId: string; + /** The behaviors that played it; they hear it wherever `entity` stands. */ + startedBy: readonly object[]; } /** A row that reaches something this tick: how to read and write it, and the kind it blends as. */ @@ -104,7 +106,7 @@ export class AnimationSystem implements System { continue; } if (!head.finished) continue; - this.finished.push({ entity, animationId: asset.id }); + this.finished.push({ entity, animationId: asset.id, startedBy: player.startedBy ?? [] }); if (playbackOf(asset) !== 'once-reset') { drop(entity, player); continue; diff --git a/src/runtime/systems/behaviorPulses.ts b/src/runtime/systems/behaviorPulses.ts index cf9567da..4be355f6 100644 --- a/src/runtime/systems/behaviorPulses.ts +++ b/src/runtime/systems/behaviorPulses.ts @@ -208,11 +208,15 @@ export function collisionExitPulses( /** * A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below * it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view. + * What it played itself it hears wherever that stands: on the owner's parent, or across the scene. */ export function animationFinishedPulses(instance: Instance, animations: AnimationSystem | null): Pulse[] { const owner = instance.entity.parent; const finished = (animations?.finished ?? []).filter( - ({ entity }) => !owner || [...parentChain(entity, (e) => e.parent)].includes(owner), + ({ entity, startedBy }) => + !owner || + startedBy.includes(instance.entity) || + [...parentChain(entity, (e) => e.parent)].includes(owner), ); return instance.interpreter.eventNodes('OnAnimationFinished').flatMap((node) => { const wanted = textOf(node.params.animation ?? ''); diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index e0c91540..41fcedf3 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -1307,6 +1307,58 @@ describe('the animation nodes', () => { expect(listening('other').done).toBe(0); }); + /** + * Two arms below a box, a behavior on each. The first plays `walk` on the box, its owner's parent; + * the second only listens, or plays it too. Both count `On animation finished`. + */ + function above(second: 'listens' | 'plays too', times = 8) { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' }); + addRow(f, walk, 'x', [ + [0, 0], + [2, 20], + ]); + const counts = (name: string, plays: boolean) => { + const arm = place(f, f.spriteId, { x: 0, y: 0 }, box); + const behavior = makeBehavior(f, name, ['done']); + place(f, behavior.id, { x: 0, y: 0 }, arm); + const edit = graphEditor(f, behavior); + const finished = edit.addNode('OnAnimationFinished'); + const store = edit.variables(['done'], { write: true }); + const add = edit.addNode('Add', { b: 1 }); + edit.wire([ + [finished, 'exec', store, 'exec'], + [store, 'done', add, 'a'], + [add, 'result', store, 'done'], + ]); + if (!plays) return; + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const parent = edit.addNode('Parent'); + const play = edit.addNode('PlayAnimation', { animation: walk.id }); + edit.wire([ + [start, 'exec', play, 'exec'], + [owner, 'object', parent, 'object'], + [parent, 'parent', play, 'object'], + ]); + }; + counts('First', true); + counts('Second', second === 'plays too'); + const { world, frame } = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < times; i++) frame(0.1); + const done = world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.done); + return { done, x: world.findByObjectId(box)!.x }; + } + + it('hears an animation it played on the parent of its owner, and a bystander does not', () => { + expect(above('listens')).toEqual({ done: [1, 0], x: 20 }); + }); + + it('is heard by each behavior that played it, once', () => { + expect(above('plays too')).toEqual({ done: [1, 1], x: 20 }); + }); + it('passes Blend on to the player it starts (#106)', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 });