diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index 5de001c5..7813ddd6 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, asNumber, asString } from '../../values'; -import { exec, num, obj, withDefault } from '../shared'; +import { asBoolean, asEntity, asNumber, asString } from '../../values'; +import { bool, exec, num, obj, withDefault } from '../shared'; /** * The `animation` input of the three animation nodes; unconnected it shows the animation picker, the @@ -22,14 +22,31 @@ 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 ' + - '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)], + 'seconds from what they held when it took over; everything else switches at once. Blend out is for ' + + 'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' + + 'Restart starts one already running or paused over from its first frame – blended in from where it ' + + 'stands when Blend is set.', + inputs: [ + exec(), + obj('object'), + animationPort(), + withDefault(num('blend', 'blend (s)'), 0), + withDefault(num('blendOut', 'blend out (s)'), 0), + withDefault(bool('restart'), false), + ], outputs: [exec()], params: [], run: (_ctx, inputs) => { const target = asEntity(inputs.object); const animation = asString(inputs.animation); - if (target && animation) playAnimation(target, animation, Math.max(0, asNumber(inputs.blend))); + if (target && animation) + playAnimation( + target, + animation, + Math.max(0, asNumber(inputs.blend)), + Math.max(0, asNumber(inputs.blendOut)), + asBoolean(inputs.restart), + ); return { next: 'exec' }; }, }; @@ -39,13 +56,15 @@ export const StopAnimation: NodeDef = { label: 'Stop animation', category: 'action', description: - 'Ends an animation on an object where it stands – the values it set stay. Leave Animation at None to end every one.', - inputs: [exec(), obj('object'), animationPort()], + 'Ends an animation on an object where it stands – the values it set stay. With a blend it fades out ' + + 'over that many seconds instead, back to what another animation sets there or else to what the ' + + 'values were when it started. Leave Animation at None to end every one.', + inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (s)'), 0)], outputs: [exec()], params: [], run: (_ctx, inputs) => { const target = asEntity(inputs.object); - if (target) stopAnimation(target, asString(inputs.animation)); + if (target) stopAnimation(target, asString(inputs.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 662e84d0..7e839129 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -389,6 +389,9 @@ export interface AnimationPlayer { order: number; bases?: Record; blend?: AnimationBlend; + /** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */ + blendOut?: number; + fade?: AnimationFade; } /** @@ -403,8 +406,20 @@ export interface AnimationBlend { from?: Record; } -/** How far a blend has come, 0…1. */ -export function blendShare(blend: AnimationBlend): number { +/** + * A player on its way out (#106): over `seconds` a row that blends mixes from the player's value back + * to what lies below it – what an earlier player writes in the same tick, live, or else the value the + * row found at the start. Everything else goes back at once. Then the player is gone. `ended` marks + * the fade of a `once-reset` pass whose frames ran out: playing it again starts it over. + */ +export interface AnimationFade { + seconds: number; + elapsed: number; + ended?: boolean; +} + +/** How far a blend or a fade has come, 0…1. */ +export function blendShare(blend: { seconds: number; elapsed: number }): number { if (!(blend.seconds > 0)) return 1; return Math.min(1, blend.elapsed / blend.seconds); } @@ -421,25 +436,63 @@ let startCount = 0; * as started last again: turning back to a walk that still runs has to show that walk. * `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. + * One fading out turns round: it blends back in from the value it has reached, without a jump – or, + * 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. */ -export function playAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void { +export function playAnimation( + holder: PlayerHolder, + animationId: string, + blendSeconds = 0, + blendOutSeconds = 0, + restart = false, +): 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) { const leads = players.every((p) => p.order <= known.order); + const fade = known.fade; known.playing = true; known.order = ++startCount; - if (!leads) known.blend = blend; + known.blendOut = blendOut; + delete known.fade; + if (restart || fade?.ended) { + known.time = 0; + known.blend = blend; + } else if (fade && blend) { + // Mixed back from the starting values it stands where the fade left it: `share` = 1 - fade share. + blend.elapsed = blendSeconds * (1 - blendShare(fade)); + known.blend = { ...blend, ...(known.bases && { from: { ...known.bases } }) }; + } else if (fade || !leads) known.blend = blend; return; } - players.push({ animationId, time: 0, playing: true, order: ++startCount, ...(blend && { blend }) }); + players.push({ + animationId, + time: 0, + playing: true, + order: ++startCount, + ...(blend && { blend }), + ...(blendOut && { blendOut }), + }); } -/** Ends it where it stands – the values stay; `''` ends every animation on the object. */ -export function stopAnimation(holder: PlayerHolder, animationId: string): void { +/** + * Ends it where it stands – the values stay; `''` ends every animation on the object. With + * `blendSeconds` it fades out instead, back to what lies below it (see `AnimationFade`). A player that + * has not reached anything yet ends at once either way. + */ +export function stopAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void { if (!holder.animations) return; - holder.animations = holder.animations.filter((p) => animationId !== '' && p.animationId !== animationId); + const hit = (p: AnimationPlayer) => animationId === '' || p.animationId === animationId; + const fades = blendSeconds > 0; + holder.animations = holder.animations.filter((p) => !hit(p) || (fades && p.bases !== undefined)); + if (fades) + for (const player of holder.animations) + if (hit(player) && !player.fade) player.fade = { seconds: blendSeconds, elapsed: 0 }; } /** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */ diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 1d9c0c2b..008dee0a 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -37,7 +37,7 @@ interface Binding { /** * Plays what `Play animation` started. Players run in start order, so of two setting one property - * the later start wins – or, while it blends in, mixes over what was there. Runs before the + * the later start wins – or, while it blends in or fades out, 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 { @@ -67,31 +67,53 @@ export class AnimationSystem implements System { // 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; - if (blend) blend.elapsed += dt; + } else if (isHandleEnabled(entity)) { + if (player.playing) { + player.time += dt; + if (blend) blend.elapsed += dt; + } + // A paused player that was stopped with a blend still fades out. + if (player.fade) player.fade.elapsed += dt; } const head = animationPlayheadAt(asset, player.time); const share = blend ? blendShare(blend) : 1; + const fade = player.fade; + const fadeShare = fade ? blendShare(fade) : 0; for (const b of bindings) { let value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base], head.reverse); if (value === undefined) continue; + const below = written.has(b.base) ? b.read() : undefined; if (share < 1 && blends(b.kind)) { - const from = written.has(b.base) ? b.read() : blend?.from?.[b.base]; + const from = below ?? blend?.from?.[b.base]; if (from !== undefined) value = blendValue(b.kind, from, value, share); } + if (fade) { + const back = below ?? player.bases[b.base]; + if (back !== undefined) value = blends(b.kind) ? blendValue(b.kind, value, back, fadeShare) : back; + } b.write(value); written.add(b.base); } if (blend && share >= 1) delete player.blend; + if (fade) { + if (fadeShare >= 1) drop(entity, player); + continue; + } if (!head.finished) continue; - if (playbackOf(asset) === 'once-reset') - for (const b of bindings) { - const base = player.bases[b.base]; - if (base !== undefined) b.write(base); - } - drop(entity, player); this.finished.push({ entity, animationId: asset.id }); + if (playbackOf(asset) !== 'once-reset') { + drop(entity, player); + continue; + } + if (player.blendOut) { + player.fade = { seconds: player.blendOut, elapsed: 0, ended: true }; + continue; + } + for (const b of bindings) { + const base = player.bases[b.base]; + if (base !== undefined) b.write(base); + } + drop(entity, player); } } diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 09a37166..cebf5f28 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -703,6 +703,189 @@ describe('blending into an animation that takes over (#106)', () => { }); }); +describe('fading an animation out (#106)', () => { + it('blends a once-reset pass back to where it started, while another animation runs on', () => { + const { f, walk, box } = slide({ playback: 'once-reset' }); + const bob = addAnimation(f, f.spriteId, { fps: 10, playback: 'pingpong' }, 'Bob'); + addRow(f, bob, 'y', [ + [0, 0], + [4, 40], + ]); + const world = World.instantiate(f.project, f.sceneId); + const system = new AnimationSystem(); + world.addSystem(system); + const sprite = world.findByObjectId(box)!; + sprite.x = 10; + playAnimation(sprite, bob.id); + playAnimation(sprite, walk.id, 0, 0.4); + const reports: number[] = []; + const tick = () => { + world.tick(0.1); + reports.push(system.finished.length); + }; + for (let i = 0; i < 11; i++) tick(); + expect(sprite.x).toBe(90); + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < 4; i++) { + tick(); + xs.push(sprite.x); + ys.push(sprite.y); + } + // From the last key's 90 back to the starting 10 by 1/4 steps; the bob turns on at 40 meanwhile. + [70, 50, 30, 10].forEach((x, i) => expect(xs[i]).toBeCloseTo(x)); + [30, 40, 30, 20].forEach((y, i) => expect(ys[i]).toBeCloseTo(y)); + expect(reports.filter((n) => n > 0)).toEqual([1]); + expect(sprite.animations?.map((p) => p.animationId)).toEqual([bob.id]); + }); + + it('fades a stopped animation back to what an earlier animation writes there, live', () => { + const { f, walk, sprite, frame } = slide(); + const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck'); + addRow(f, duck, 'x', [[0, -100]]); + playAnimation(sprite, walk.id); + frame(0.1); + playAnimation(sprite, duck.id); + frame(0.1); + frame(0.1); + expect(sprite.x).toBe(-100); + stopAnimation(sprite, duck.id, 0.4); + const seen: number[] = []; + for (let i = 0; i < 5; i++) { + frame(0.1); + seen.push(sprite.x); + } + // The walk runs on at 30, 40, 50, 60, 70; the duck gives way by 1/4, 1/2, 3/4, all – then is gone. + [-67.5, -30, 12.5, 60, 70].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + expect(sprite.animations?.map((p) => p.animationId)).toEqual([walk.id]); + }); + + it('fades back to the starting value with nothing below, switches what cannot blend at once, also paused', () => { + const { f, walk, sprite, frame } = slide(); + addRow(f, walk, 'mirrorX', [[0, true]]); + sprite.x = 100; + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.2); + expect(sprite.props.mirrorX).toBe(true); + pauseAnimation(sprite, walk.id); + stopAnimation(sprite, walk.id, 0.2); + frame(0.1); + // Held at 20 by the pause, half way back to 100. + expect(sprite.x).toBeCloseTo(60); + expect(sprite.props.mirrorX).toBe(false); + frame(0.1); + expect(sprite.x).toBeCloseTo(100); + expect(sprite.animations).toEqual([]); + }); + + it('ends one that has not reached anything yet at once, blend or not', () => { + const { walk, sprite } = slide(); + playAnimation(sprite, walk.id); + stopAnimation(sprite, walk.id, 0.5); + expect(sprite.animations).toEqual([]); + }); + + it('turns a fade round without a jump when played again', () => { + const { walk, sprite, frame } = slide(); + sprite.x = 100; + playAnimation(sprite, walk.id); + frame(0.1); + frame(0.2); + stopAnimation(sprite, walk.id, 0.8); + frame(0.1); + // The walk at 30, 1/8 of the way back to 100. + expect(sprite.x).toBeCloseTo(38.75); + playAnimation(sprite, walk.id, 1.6); + expect(sprite.animations?.[0]?.fade).toBeUndefined(); + const seen: number[] = []; + for (let i = 0; i < 2; i++) { + frame(0.1); + seen.push(sprite.x); + } + // Blending in from 100 it stands at 7/8 – where the fade left it – and runs on from there: walk 40 + // at 15/16, walk 50 all in. + [43.75, 50].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + }); + + it('starts a once-reset pass over when played again while it fades back, from where it stands', () => { + const { walk, sprite, frame } = slide({ playback: 'once-reset' }); + sprite.x = 10; + playAnimation(sprite, walk.id, 0, 0.4); + for (let i = 0; i < 13; i++) frame(0.1); + expect(sprite.x).toBeCloseTo(50); + playAnimation(sprite, walk.id, 0.2, 0.4); + const player = sprite.animations?.[0]; + expect(player).toMatchObject({ time: 0, blendOut: 0.4 }); + expect(player?.fade).toBeUndefined(); + frame(0.1); + // Frame 1 of the walk (10), blended in half way from the 50 the fade had reached. + expect(sprite.x).toBeCloseTo(30); + // Relative to the same start: it still goes back to 10 at the end. + expect(player?.bases).toEqual({ [`${sprite.id} x`]: 10 }); + }); +}); + +describe('restarting an animation that already runs', () => { + /** `walk` played for 0.4 s: frame 4, x 40. */ + function walked() { + const s = slide(); + s.sprite.x = 5; + playAnimation(s.sprite, s.walk.id); + s.frame(0.1); + s.frame(0.2); + s.frame(0.2); + expect(s.sprite.x).toBeCloseTo(40); + return s; + } + + it('starts a running one over from its first frame, keeping its starting values', () => { + const { walk, sprite, frame } = walked(); + const bases = { ...sprite.animations?.[0]?.bases }; + playAnimation(sprite, walk.id); + frame(0.1); + expect(sprite.x).toBeCloseTo(50); + playAnimation(sprite, walk.id, 0, 0, true); + expect(sprite.animations?.[0]?.time).toBe(0); + frame(0.1); + expect(sprite.x).toBeCloseTo(10); + expect(sprite.animations?.[0]?.bases).toEqual(bases); + }); + + it('starts a paused one over and lets it run', () => { + const { walk, sprite, frame } = walked(); + pauseAnimation(sprite, walk.id); + frame(0.1); + playAnimation(sprite, walk.id, 0, 0, true); + frame(0.1); + frame(0.1); + expect(sprite.x).toBeCloseTo(20); + }); + + it('blends the new start in from where it stands', () => { + const { walk, sprite, frame } = walked(); + playAnimation(sprite, walk.id, 0.2, 0, true); + const seen: number[] = []; + for (let i = 0; i < 2; i++) { + frame(0.1); + seen.push(sprite.x); + } + // Walk 10 half blended in from 40, then walk 20 all in. + [25, 20].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); + }); + + it('starts a fading one over instead of turning it round', () => { + const { walk, sprite, frame } = walked(); + stopAnimation(sprite, walk.id, 0.8); + frame(0.1); + playAnimation(sprite, walk.id, 0, 0, true); + expect(sprite.animations?.[0]).toMatchObject({ time: 0, playing: true }); + expect(sprite.animations?.[0]?.fade).toBeUndefined(); + frame(0.1); + expect(sprite.x).toBeCloseTo(10); + }); +}); + 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() { @@ -942,13 +1125,76 @@ 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: 0.6 }); + const play = edit.addNode('PlayAnimation', { animation: walk.id, blend: 0.6, blendOut: 0.3 }); 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?.seconds).toBe(0.6); + expect(world.findByObjectId(box)!.animations?.[0]).toMatchObject({ + blend: { seconds: 0.6 }, + blendOut: 0.3, + }); + }); + + it('restarts the animation every time when Restart is on, and lets it run on when off', () => { + const run = (restart: boolean) => { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10 }); + addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + const behavior = makeBehavior(f, 'Logic', []); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const play = edit.addNode('PlayAnimation', { animation: walk.id, restart }); + edit.wire([ + [update, 'exec', play, 'exec'], + [owner, 'object', play, 'object'], + ]); + const { world, frame } = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < 5; i++) frame(0.1); + return world.findByObjectId(box)!.animations?.[0]?.time; + }; + expect(run(false)).toBeGreaterThan(0.3); + expect(run(true)).toBeLessThan(0.15); + }); + + it('passes Blend on to Stop animation, which fades the player out (#106)', () => { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10 }); + addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + const cue = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' }, 'Cue'); + 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 playWalk = edit.addNode('PlayAnimation', { animation: walk.id }); + const playCue = edit.addNode('PlayAnimation', { animation: cue.id }); + const finished = edit.addNode('OnAnimationFinished', { animation: cue.id }); + const stop = edit.addNode('StopAnimation', { animation: walk.id, blend: 0.5 }); + edit.wire([ + [start, 'exec', playWalk, 'exec'], + [playWalk, 'exec', playCue, 'exec'], + [owner, 'object', playWalk, 'object'], + [owner, 'object', playCue, 'object'], + [finished, 'exec', stop, 'exec'], + [owner, 'object', stop, 'object'], + ]); + const { world, frame } = startWorld(f, { animations: true, behaviors: true }); + for (let i = 0; i < 3; i++) frame(0.1); + expect(world.findByObjectId(box)!.animations).toMatchObject([ + { animationId: walk.id, fade: { seconds: 0.5 } }, + ]); }); });