diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index f5ca9b70..5de001c5 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -22,8 +22,8 @@ export const PlayAnimation: NodeDef = { 'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' + 'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' + 'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' + - 'of its frames from what they held when it took over; everything else switches at once.', - inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (frames)'), 0)], + 'seconds from what they held when it took over; everything else switches at once.', + inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (s)'), 0)], outputs: [exec()], params: [], run: (_ctx, inputs) => { diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 08c91ad2..662e84d0 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -392,20 +392,21 @@ export interface AnimationPlayer { } /** - * A crossfade into a player that just took over (#106): over `frames` of its own, a row that blends - * mixes from what the property held into the player's value. `from` is taken on the first tick of the - * blend; a property an earlier player writes in the same tick is mixed from that value instead, live. + * A crossfade into a player that just took over (#106): over `seconds`, whatever the animation's frame + * rate, a row that blends mixes from what the property held into the player's value. `from` is taken + * on the first tick of the blend; a property an earlier player writes in the same tick is mixed from + * that value instead, live. */ export interface AnimationBlend { - frames: number; + seconds: number; elapsed: number; from?: Record; } -/** How far a blend has come, 0…1; an animation without a frame rate has nothing to blend over. */ -export function blendShare(blend: AnimationBlend, fps: number): number { - if (!(blend.frames > 0) || !(fps > 0)) return 1; - return Math.min(1, (blend.elapsed * fps) / blend.frames); +/** How far a blend has come, 0…1. */ +export function blendShare(blend: AnimationBlend): number { + if (!(blend.seconds > 0)) return 1; + return Math.min(1, blend.elapsed / blend.seconds); } interface PlayerHolder { @@ -418,13 +419,13 @@ let startCount = 0; * Starts the animation on the object, or lets a paused one run on. One already running keeps its * frame, so a graph may ask for it every frame without holding it at its first frame – but it counts * as started last again: turning back to a walk that still runs has to show that walk. - * `blendFrames` crossfades into it – but only when it takes over: asked for again while it already + * `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already * leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it. */ -export function playAnimation(holder: PlayerHolder, animationId: string, blendFrames = 0): void { +export function playAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void { const players = (holder.animations ??= []); const known = players.find((p) => p.animationId === animationId); - const blend = blendFrames > 0 ? { frames: blendFrames, elapsed: 0 } : undefined; + const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined; if (known) { const leads = players.every((p) => p.order <= known.order); known.playing = true; diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 757b7ddd..1d9c0c2b 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -7,7 +7,6 @@ import { blendShare, blends, blendValue, - fpsOf, isPositionRow, playbackOf, trackWriteAt, @@ -73,7 +72,7 @@ export class AnimationSystem implements System { if (blend) blend.elapsed += dt; } const head = animationPlayheadAt(asset, player.time); - const share = blend ? blendShare(blend, fpsOf(asset)) : 1; + const share = blend ? blendShare(blend) : 1; for (const b of bindings) { let value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse); if (value === undefined) continue; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index f650f86c..09a37166 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -601,11 +601,11 @@ describe('blending into an animation that takes over (#106)', () => { return { ...s, stand }; } - it('mixes from the animation it takes over, linearly over its blend frames', () => { + it('mixes from the animation it takes over, linearly over its blend time', () => { const { walk, stand, sprite, frame } = standing(100); playAnimation(sprite, stand.id); frame(0.1); - playAnimation(sprite, walk.id, 4); + playAnimation(sprite, walk.id, 0.4); const seen: number[] = []; for (let i = 0; i < 5; i++) { frame(0.1); @@ -619,7 +619,7 @@ describe('blending into an animation that takes over (#106)', () => { it('mixes from what the property held when no animation before it writes there – without easing off', () => { const { walk, sprite, frame } = slide(); sprite.x = 100; - playAnimation(sprite, walk.id, 4); + playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); expect(sprite.x).toBeCloseTo(77.5); @@ -628,13 +628,26 @@ describe('blending into an animation that takes over (#106)', () => { expect(sprite.x).toBeCloseTo(60); }); + it('takes its blend time in seconds, whatever the frame rate of the animation', () => { + const { walk, sprite, frame } = slide({ fps: 30 }); + sprite.x = 100; + playAnimation(sprite, walk.id, 0.4); + const seen: number[] = []; + for (let i = 0; i < 3; i++) { + frame(0.1); + seen.push(sprite.x); + } + // The walk at 0, 0.1, 0.2 s is 0, 30, 60; mixed in by 0, 1/4, 1/2 – not by 3/4, 3/2 as frames would. + [100, 82.5, 80].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + }); + it('switches a value that cannot blend at once', () => { const { f, walk, stand, sprite, frame } = standing(100); addRow(f, stand, 'mirrorX', [[0, false]]); addRow(f, walk, 'mirrorX', [[0, true]]); playAnimation(sprite, stand.id); frame(0.1); - playAnimation(sprite, walk.id, 4); + playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); expect(sprite.props.mirrorX).toBe(true); @@ -646,14 +659,14 @@ describe('blending into an animation that takes over (#106)', () => { sprite.x = 100; const seen: number[] = []; for (let i = 0; i < 3; i++) { - playAnimation(sprite, walk.id, 4); + playAnimation(sprite, walk.id, 0.4); frame(0.1); seen.push(sprite.x); } [100, 77.5, 60].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); - it('blends again when a running animation wins back, and not at all without blend frames', () => { + it('blends again when a running animation wins back, and not at all without a blend time', () => { const { walk, stand, sprite, frame } = standing(100); playAnimation(sprite, walk.id); frame(0.1); @@ -661,7 +674,7 @@ describe('blending into an animation that takes over (#106)', () => { playAnimation(sprite, stand.id); frame(0.1); expect(sprite.x).toBe(100); - playAnimation(sprite, walk.id, 2); + playAnimation(sprite, walk.id, 0.2); frame(0.1); // The walk runs on at frame 3 (30), half blended in from the stand's 100. expect(sprite.x).toBeCloseTo(65); @@ -676,10 +689,10 @@ describe('blending into an animation that takes over (#106)', () => { addRow(f, duck, 'x', [[0, -100]]); playAnimation(sprite, stand.id); frame(0.1); - playAnimation(sprite, walk.id, 4); + playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); - playAnimation(sprite, duck.id, 2); + playAnimation(sprite, duck.id, 0.2); const seen: number[] = []; for (let i = 0; i < 3; i++) { frame(0.1); @@ -929,13 +942,13 @@ describe('the animation nodes', () => { const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const owner = edit.addNode('Owner'); - const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 6 }); + const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 0.6 }); edit.wire([ [start, 'exec', play, 'exec'], [owner, 'object', play, 'object'], ]); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.1); - expect(world.findByObjectId(box)!.animations?.[0]?.blend?.frames).toBe(6); + expect(world.findByObjectId(box)!.animations?.[0]?.blend?.seconds).toBe(0.6); }); });