/** * The easing curves of animation keys: ten curves, each in, out and in-out. Quad keeps the stored * name it had as the only ease (`ease-in`), so older projects play as before. */ import { describe, expect, it } from 'vitest'; import { AddAnimationTrackCommand, SetAnimationKeyCommand, SetAnimationRootCommand, SetKeyInterpolationCommand, } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; import { EASING_DIRECTIONS, EASING_SHAPES, easedShare, easingOf, easingValue, isKeyInterpolation, PLACED_ROOT_ID, playAnimation, trackValueAt, trackWriteAt, } from '../../src/core/model/animation'; import { previewHolder } from '../../src/core/model/animationPreview'; import type { AnimationTrack, KeyInterpolation } from '../../src/core/model/Asset'; import { findAssetOf } from '../../src/core/model/Project'; import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip } from '../helpers'; const CURVES = EASING_SHAPES.filter((s) => s.value !== 'linear' && s.value !== 'step').map((s) => s.value); const ALL = CURVES.flatMap((c) => EASING_DIRECTIONS.map((d) => easingValue(c, d.value))); describe('easing curves', () => { it('offers ten curves in three directions, and quad keeps its old stored names', () => { expect(CURVES).toEqual([ 'sine', 'quad', 'cubic', 'quart', 'quint', 'expo', 'circ', 'back', 'elastic', 'bounce', ]); expect(ALL).toHaveLength(30); expect(new Set(ALL).size).toBe(30); expect(easingValue('quad', 'in')).toBe('ease-in'); expect(easingValue('quad', 'in-out')).toBe('ease-in-out'); expect(easingValue('cubic', 'in-out')).toBe('cubic-in-out'); expect(easingValue('step', 'out')).toBe('step'); expect(easingValue('back')).toBe('back-in-out'); }); it('splits every stored name back into its two fields', () => { for (const value of ALL) { const { shape, direction } = easingOf(value); expect(easingValue(shape, direction)).toBe(value); expect(isKeyInterpolation(value)).toBe(true); } expect(easingOf('ease-out')).toEqual({ shape: 'quad', direction: 'out' }); expect(easingOf('linear')).toEqual({ shape: 'linear' }); expect(easingOf('step')).toEqual({ shape: 'step' }); }); it('rejects what is no easing', () => { for (const value of [ 'quad-in', 'bouncy', 'cubic-sideways', 'toString-in', 'constructor-in', 'ease', 'cubic-in-', '', 3, ]) expect(isKeyInterpolation(value)).toBe(false); }); it('starts every curve at 0 and ends it at 1', () => { for (const value of ALL) { expect(easedShare(value, 0)).toBeCloseTo(0, 9); expect(easedShare(value, 1)).toBeCloseTo(1, 9); } }); it('meets half way at half the time going both ways', () => { for (const curve of CURVES) expect(easedShare(easingValue(curve, 'in-out'), 0.5)).toBeCloseTo(0.5, 9); }); it('bends each curve the way its formula says', () => { const at = (value: KeyInterpolation, t = 0.5) => easedShare(value, t); expect(at('sine-in')).toBeCloseTo(1 - Math.SQRT1_2); expect(at('ease-in')).toBeCloseTo(0.25); expect(at('cubic-in')).toBeCloseTo(0.125); expect(at('quart-in')).toBeCloseTo(0.0625); expect(at('quint-in')).toBeCloseTo(0.03125); expect(at('expo-out')).toBeCloseTo(1 - 2 ** -5); expect(at('circ-in')).toBeCloseTo(1 - Math.sqrt(0.75)); expect(at('bounce-out')).toBeCloseTo(0.765625); expect(at('bounce-in')).toBeCloseTo(1 - 0.765625); expect(at('cubic-out', 0.25)).toBeCloseTo(1 - 0.75 ** 3); expect(at('cubic-in-out', 0.25)).toBeCloseTo(0.0625); expect(at('cubic-in-out', 0.75)).toBeCloseTo(0.9375); }); it('lets back and elastic leave 0…1, and keeps the others inside', () => { const samples = (value: KeyInterpolation) => Array.from({ length: 101 }, (_, i) => easedShare(value, i / 100)); expect(Math.min(...samples('back-in'))).toBeCloseTo(-0.1002, 3); expect(Math.max(...samples('back-out'))).toBeCloseTo(1.1002, 3); expect(Math.max(...samples('elastic-out'))).toBeGreaterThan(1.3); expect(Math.min(...samples('elastic-in'))).toBeLessThan(-0.3); for (const curve of ['sine', 'quad', 'cubic', 'quart', 'quint', 'expo', 'circ', 'bounce'] as const) for (const d of EASING_DIRECTIONS) { const values = samples(easingValue(curve, d.value)); expect(Math.min(...values)).toBeGreaterThanOrEqual(-1e-12); expect(Math.max(...values)).toBeLessThanOrEqual(1 + 1e-12); } }); it('eases a row between two keys', () => { const row = (interpolation: KeyInterpolation) => [ { frame: 0, value: 0, interpolation }, { frame: 10, value: 100 }, ]; expect(trackValueAt(row('cubic-in'), 'number', 5)).toBeCloseTo(12.5); expect(trackValueAt(row('back-in'), 'number', 2)).toBeLessThan(0); }); }); describe('an overshooting curve and the limits of a property', () => { const track = (interpolation: KeyInterpolation, relative = false): AnimationTrack => ({ id: 'r', target: '', property: 'scale', relative, keys: [ { frame: 0, value: 0, interpolation }, { frame: 10, value: 1 }, ], }); it('holds a number to min and max, and leaves a property without them free', () => { const limited = { kind: 'number', min: 0, max: 1 }; expect(trackWriteAt(track('back-in'), limited, 2, undefined)).toBe(0); expect(trackWriteAt(track('back-out'), limited, 8, undefined)).toBe(1); expect(trackWriteAt(track('back-in'), { kind: 'number' }, 2, undefined)).toBeLessThan(0); expect(trackWriteAt(track('back-out'), { kind: 'number' }, 8, undefined)).toBeGreaterThan(1); expect(trackWriteAt(track('back-out', true), limited, 8, 0.5)).toBe(1); }); it('holds a colour to its channels', () => { const colours = [ { frame: 0, value: 0x000000, interpolation: 'back-out' as const }, { frame: 10, value: 0xff0000 }, ]; expect(trackValueAt(colours, 'color', 8)).toBe(0xff0000); }); it('never gives a sprite a negative scale, in play and in the preview', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const create = new CreateAssetCommand('animation', 'Pop', { fps: 10, playback: 'once' }); f.stack.execute(create); f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId)); const row = new AddAnimationTrackCommand(create.asset.id, '', 'scale'); f.stack.execute(row); f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0)); f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 10, 1)); f.stack.execute( new SetKeyInterpolationCommand(create.asset.id, [{ trackId: row.trackId, frame: 0 }], 'back-in'), ); const world = World.instantiate(f.project, f.sceneId); world.addSystem(new AnimationSystem()); const sprite = world.findByObjectId(box)!; playAnimation(sprite, create.asset.id); const scales: number[] = []; for (let i = 0; i < 12; i++) { world.tick(0.1); scales.push(Number(sprite.props.scale)); } expect(Math.min(...scales)).toBe(0); expect(scales.at(-1)).toBe(1); const walk = findAssetOf(f.project, create.asset.id, 'animation')!; const root = previewHolder(f.project, walk, 2)!.objects.find((o) => o.id === PLACED_ROOT_ID)!; expect(root.overrides.scale).toBe(0); }); it('takes a new curve as a key easing, keeps it through a round trip, and refuses a made-up one', () => { const f = makeSpriteScene(); const create = new CreateAssetCommand('animation', 'Walk', {}); f.stack.execute(create); f.stack.execute(new SetAnimationRootCommand(create.asset.id, f.spriteId)); const row = new AddAnimationTrackCommand(create.asset.id, '', 'x'); f.stack.execute(row); f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0)); const at = [{ trackId: row.trackId, frame: 0 }]; f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, 'elastic-in-out')); const walk = findAssetOf(f.project, create.asset.id, 'animation')!; expect(walk.tracks[0]!.keys[0]!.interpolation).toBe('elastic-in-out'); expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk); expect(() => f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, 'quad-in' as KeyInterpolation)), ).toThrow(CommandError); }); });