diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index fe6dac4e..f5ca9b70 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -1,7 +1,7 @@ import { pauseAnimation, playAnimation, stopAnimation } from '../../../model/animation'; import type { NodeDef, PortDef } from '../../NodeRegistry'; -import { asEntity, asString } from '../../values'; -import { exec, obj } from '../shared'; +import { asEntity, asNumber, asString } from '../../values'; +import { exec, num, obj, withDefault } from '../shared'; /** * The `animation` input of the three animation nodes; unconnected it shows the animation picker, the @@ -21,14 +21,15 @@ export const PlayAnimation: NodeDef = { description: '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.', - inputs: [exec(), obj('object'), animationPort()], + '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)], outputs: [exec()], params: [], run: (_ctx, inputs) => { const target = asEntity(inputs.object); const animation = asString(inputs.animation); - if (target && animation) playAnimation(target, animation); + if (target && animation) playAnimation(target, animation, Math.max(0, asNumber(inputs.blend))); return { next: 'exec' }; }, }; diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index def0155c..08c91ad2 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -388,6 +388,24 @@ export interface AnimationPlayer { /** Start order across the whole run: of two players setting one property, the later start wins. */ order: number; bases?: Record; + blend?: AnimationBlend; +} + +/** + * 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. + */ +export interface AnimationBlend { + frames: 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); } interface PlayerHolder { @@ -400,16 +418,21 @@ 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 + * 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): void { +export function playAnimation(holder: PlayerHolder, animationId: string, blendFrames = 0): void { const players = (holder.animations ??= []); const known = players.find((p) => p.animationId === animationId); + const blend = blendFrames > 0 ? { frames: blendFrames, elapsed: 0 } : undefined; if (known) { + const leads = players.every((p) => p.order <= known.order); known.playing = true; known.order = ++startCount; + if (!leads) known.blend = blend; return; } - players.push({ animationId, time: 0, playing: true, order: ++startCount }); + players.push({ animationId, time: 0, playing: true, order: ++startCount, ...(blend && { blend }) }); } /** Ends it where it stands – the values stay; `''` ends every animation on the object. */ diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 70425488..757b7ddd 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -4,6 +4,10 @@ import { animatableDefsOf, animatedKind, animationPlayheadAt, + blendShare, + blends, + blendValue, + fpsOf, isPositionRow, playbackOf, trackWriteAt, @@ -34,8 +38,8 @@ interface Binding { /** * Plays what `Play animation` started. Players run in start order, so of two setting one property - * the later start wins. Runs before the BehaviorSystem: a behavior writing the same property writes - * last, and hears this frame's `finished`. + * the later start wins – or, while it blends in, mixes over what was there. Runs before the + * BehaviorSystem: a behavior writing the same property writes last, and hears this frame's `finished`. */ export class AnimationSystem implements System { finished: AnimationFinish[] = []; @@ -46,6 +50,8 @@ export class AnimationSystem implements System { for (const entity of world.entities) for (const player of entity.animations ?? []) running.push({ entity, player }); running.sort((a, b) => a.player.order - b.player.order); + // What the players before this one wrote this tick – a blend mixes from that value, live. + const written = new Set(); for (const { entity, player } of running) { const asset = findAssetOf(world.project, player.animationId, 'animation'); if (!asset) { @@ -53,16 +59,32 @@ export class AnimationSystem implements System { continue; } const bindings = bind(world, asset, entity); + const blend = player.blend; + if (blend && !blend.from) { + blend.from = {}; + for (const b of bindings) blend.from[b.base] = b.read(); + } 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] = 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], head.reverse); - if (value !== undefined) b.write(value); + } else if (player.playing && isHandleEnabled(entity)) { + player.time += dt; + if (blend) blend.elapsed += dt; } + const head = animationPlayheadAt(asset, player.time); + const share = blend ? blendShare(blend, fpsOf(asset)) : 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; + if (share < 1 && blends(b.kind)) { + const from = written.has(b.base) ? b.read() : blend?.from?.[b.base]; + if (from !== undefined) value = blendValue(b.kind, from, value, share); + } + b.write(value); + written.add(b.base); + } + if (blend && share >= 1) delete player.blend; if (!head.finished) continue; if (playbackOf(asset) === 'once-reset') for (const b of bindings) { diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 98a578b7..f650f86c 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -592,6 +592,104 @@ describe('the AnimationSystem', () => { }); }); +describe('blending into an animation that takes over (#106)', () => { + /** `slide` plus a `Stand` holding `x` at `at`, at 10 fps. */ + function standing(at: number, name = 'Stand') { + const s = slide(); + const stand = addAnimation(s.f, s.f.spriteId, { fps: 10 }, name); + addRow(s.f, stand, 'x', [[0, at]]); + return { ...s, stand }; + } + + it('mixes from the animation it takes over, linearly over its blend frames', () => { + const { walk, stand, sprite, frame } = standing(100); + playAnimation(sprite, stand.id); + frame(0.1); + playAnimation(sprite, walk.id, 4); + const seen: number[] = []; + for (let i = 0; i < 5; i++) { + frame(0.1); + seen.push(sprite.x); + } + // The walk at frames 0…4 is 0, 10, 20, 30, 40; mixed in by 0, 1/4, 1/2, 3/4, all. + [100, 77.5, 60, 47.5, 40].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + expect(sprite.animations?.find((p) => p.animationId === walk.id)?.blend).toBeUndefined(); + }); + + 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); + frame(0.1); + frame(0.1); + expect(sprite.x).toBeCloseTo(77.5); + // Mixed from last tick's 77.5 it would read 48.75: the blend runs from the value at its start. + frame(0.1); + expect(sprite.x).toBeCloseTo(60); + }); + + 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); + frame(0.1); + frame(0.1); + expect(sprite.props.mirrorX).toBe(true); + expect(sprite.x).toBeCloseTo(77.5); + }); + + it('keeps its blend when a graph asks for it every frame', () => { + const { walk, sprite, frame } = slide(); + sprite.x = 100; + const seen: number[] = []; + for (let i = 0; i < 3; i++) { + playAnimation(sprite, walk.id, 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', () => { + const { walk, stand, sprite, frame } = standing(100); + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.1); + playAnimation(sprite, stand.id); + frame(0.1); + expect(sprite.x).toBe(100); + playAnimation(sprite, walk.id, 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); + playAnimation(sprite, stand.id, 0); + frame(0.1); + expect(sprite.x).toBe(100); + }); + + it('lets a third start during a blend mix from the value already mixed, without a jump', () => { + const { f, walk, stand, sprite, frame } = standing(100); + const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck'); + addRow(f, duck, 'x', [[0, -100]]); + playAnimation(sprite, stand.id); + frame(0.1); + playAnimation(sprite, walk.id, 4); + frame(0.1); + frame(0.1); + playAnimation(sprite, duck.id, 2); + const seen: number[] = []; + for (let i = 0; i < 3; i++) { + frame(0.1); + seen.push(sprite.x); + } + // Walk over stand gives 60, 47.5, 40; the duck takes each in by 0, 1/2, all. + [60, -26.25, -100].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + }); +}); + 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() { @@ -821,4 +919,23 @@ describe('the animation nodes', () => { it('is not heard by an On animation finished naming another animation', () => { expect(listening('other').done).toBe(0); }); + + it('passes Blend on to the player it starts (#106)', () => { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10 }); + const behavior = makeBehavior(f, 'Logic', []); + place(f, behavior.id, { x: 0, y: 0 }, box); + 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 }); + 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); + }); });