/** * Curve assets (#247): an easing drawn by hand. One function evaluates it for the tab, the animation * player and the `Lerp` node; every stored curve is held to the rules first, so it has exactly one * value for every `t`. */ import { describe, expect, it } from 'vitest'; import { AddAnimationTrackCommand, SetAnimationKeyCommand, SetAnimationRootCommand, SetKeyInterpolationCommand, } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { SetCurvePointsCommand } from '../../src/core/commands/curveCommands'; import { easedShare, easingOf, easingValue, isKeyInterpolation, playAnimation, trackValueAt, } from '../../src/core/model/animation'; import type { VectorPoint } from '../../src/core/model/Asset'; import { curveLookup, curveUsages, curveValue, linearCurve, sanitizedCurve, } from '../../src/core/model/curve'; import { curvePresets, easeCurve } from '../../src/core/model/curvePresets'; import { findAssetOf } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; import { World } from '../../src/runtime/World'; import { makeProject, makeSpriteScene, place, roundTrip } from '../helpers'; /** Quad in, exactly: a cubic whose `t` runs evenly and whose value is s². */ const QUAD_IN: VectorPoint[] = [ { x: 0, y: 0, out: { x: 1 / 3, y: 0 } }, { x: 1, y: 1, in: { x: -1 / 3, y: -2 / 3 } }, ]; const steps = (count: number) => Array.from({ length: count + 1 }, (_, i) => i / count); describe('evaluating a curve', () => { it('reads a new curve as linear', () => { for (const t of steps(20)) expect(curveValue(linearCurve(), t)).toBeCloseTo(t, 12); }); it('solves a Bezier segment for t', () => { for (const t of steps(16)) expect(curveValue(QUAD_IN, t)).toBeCloseTo(t * t, 9); // CSS `ease-in-out`: symmetric about the middle, and rising all the way. const cssEaseInOut: VectorPoint[] = [ { x: 0, y: 0, out: { x: 0.42, y: 0 } }, { x: 1, y: 1, in: { x: -0.42, y: 0 } }, ]; expect(curveValue(cssEaseInOut, 0.5)).toBeCloseTo(0.5, 9); expect(curveValue(cssEaseInOut, 0.25) + curveValue(cssEaseInOut, 0.75)).toBeCloseTo(1, 9); const values = steps(100).map((t) => curveValue(cssEaseInOut, t)); for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]!); }); it('hits the ends exactly and holds t to 0…1', () => { const back = easeCurve('back', 'out'); expect(curveValue(back, 0)).toBe(0); expect(curveValue(back, 1)).toBe(1); expect(curveValue(back, -3)).toBe(0); expect(curveValue(back, 7)).toBe(1); }); it('overshoots where the points go past 1', () => { const overshoot: VectorPoint[] = [ { x: 0, y: 0 }, { x: 0.5, y: 1.5 }, { x: 1, y: 1 }, ]; expect(curveValue(overshoot, 0.25)).toBeCloseTo(0.75, 12); expect(curveValue(overshoot, 0.5)).toBeCloseTo(1.5, 12); expect(curveValue(overshoot, 0.75)).toBeCloseTo(1.25, 12); expect(Math.max(...steps(100).map((t) => curveValue(easeCurve('back', 'out'), t)))).toBeGreaterThan(1.09); }); it('keeps one value for every t even when the stored handles reach past a neighbour', () => { const wild: VectorPoint[] = [ { x: 0, y: 0, out: { x: 5, y: 1 } }, { x: 1, y: 1, in: { x: -5, y: -1 } }, ]; const values = steps(200).map((t) => curveValue(wild, t)); for (let i = 1; i < values.length; i++) expect(values[i]!).toBeGreaterThanOrEqual(values[i - 1]! - 1e-12); }); }); describe('the rules a stored curve is held to', () => { it('sorts the points, pins the ends to t 0 and 1 and keeps t in 0…1', () => { const held = sanitizedCurve([ { x: 0.9, y: 0.5 }, { x: 0.2, y: 2 }, { x: 1.4, y: 1 }, { x: -0.3, y: 0.1 }, ]); expect(held.map((p) => [p.x, p.y])).toEqual([ [0, 0.1], [0.2, 2], [0.9, 0.5], [1, 1], ]); }); it('drops what is no number, and falls back to linear below two points', () => { expect( sanitizedCurve([ { x: Number.NaN, y: 0 }, { x: 0.5, y: 0.5 }, ]), ).toEqual(linearCurve()); expect(sanitizedCurve([])).toEqual(linearCurve()); }); it('shortens a handle reaching past its neighbour along its own direction', () => { const [first, last] = sanitizedCurve([ { x: 0, y: 0, out: { x: 2, y: 1 } }, { x: 1, y: 1, in: { x: -4, y: -2 } }, ]); expect(first!.out).toEqual({ x: 1, y: 0.5 }); expect(last!.in).toEqual({ x: -1, y: -0.5 }); }); it('turns a handle pointing backwards in t upright, and drops the ends outer handles', () => { const [first, middle, last] = sanitizedCurve([ { x: 0, y: 0, in: { x: -0.1, y: 0 }, out: { x: 0.1, y: 0.2 } }, { x: 0.5, y: 0.5, in: { x: 0.2, y: 0.3 }, out: { x: -0.1, y: 0.4 }, mode: 'smooth' }, { x: 1, y: 1, out: { x: 0.2, y: 0 } }, ]); expect(first!.in).toBeUndefined(); expect(middle!.in).toEqual({ x: 0, y: 0.3 }); expect(middle!.out).toEqual({ x: 0, y: 0.4 }); expect(middle!.mode).toBe('smooth'); expect(last!.out).toBeUndefined(); }); }); describe('a key and a Lerp that ease along a curve', () => { it('stores the curve on the key and splits it back', () => { expect(easingValue('curve', undefined, 'abc')).toBe('curve:abc'); expect(easingOf('curve:abc')).toEqual({ shape: 'curve', curve: 'abc' }); expect(isKeyInterpolation('curve:abc')).toBe(true); expect(isKeyInterpolation('curve:')).toBe(true); expect(easingOf('curve:')).toEqual({ shape: 'curve', curve: '' }); }); it('blends a row along the curve, and linearly once it is gone', () => { const { project, stack } = makeProject(); const create = new CreateAssetCommand('curve', 'Quad'); stack.execute(create); stack.execute(new SetCurvePointsCommand(create.asset.id, QUAD_IN)); const row = [ { frame: 0, value: 0, interpolation: easingValue('curve', undefined, create.asset.id) }, { frame: 10, value: 100 }, ]; expect(trackValueAt(row, 'number', 5, false, curveLookup(project))).toBeCloseTo(25, 6); expect(trackValueAt(row, 'number', 5)).toBeCloseTo(50, 9); expect(easedShare('curve:gone', 0.3, curveLookup(project))).toBe(0.3); expect(easedShare('curve:', 0.3, curveLookup(project))).toBe(0.3); }); it('plays a key along the curve in the running game', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const curve = new CreateAssetCommand('curve', 'Quad'); f.stack.execute(curve); f.stack.execute(new SetCurvePointsCommand(curve.asset.id, QUAD_IN)); const create = new CreateAssetCommand('animation', 'Slide', { 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, '', 'x'); 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, 100)); const at = [{ trackId: row.trackId, frame: 0 }]; f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`)); const world = World.instantiate(f.project, f.sceneId); world.addSystem(new AnimationSystem()); const sprite = world.findByObjectId(box)!; playAnimation(sprite, create.asset.id); // The first tick takes the starting values and shows frame 0; five more reach frame 5, t = 0.5. world.tick(0.1); for (let i = 0; i < 5; i++) world.tick(0.1); expect(sprite.x).toBeCloseTo(25, 4); }); it('counts the keys and Lerp nodes using a curve, for the warning before deleting it', () => { const f = makeSpriteScene(); const curve = new CreateAssetCommand('curve', 'Pop'); f.stack.execute(curve); const create = new CreateAssetCommand('animation', 'Slide', {}); 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); for (const frame of [0, 5, 10]) f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, 0)); const at = [0, 5].map((frame) => ({ trackId: row.trackId, frame })); f.stack.execute(new SetKeyInterpolationCommand(create.asset.id, at, `curve:${curve.asset.id}`)); const behavior = new CreateAssetCommand('behavior', 'Mover'); f.stack.execute(behavior); findAssetOf(f.project, behavior.asset.id, 'behavior')!.graph.nodes.push( { id: 'l1', type: 'Lerp', x: 0, y: 0, params: { ease: 'curve', curve: curve.asset.id } }, { id: 'l2', type: 'Lerp', x: 0, y: 0, params: { ease: 'linear' } }, { id: 's1', type: 'SetSoundFxParameters', x: 0, y: 0, params: { ease: 'curve', curve: curve.asset.id }, }, ); expect(curveUsages(f.project, curve.asset.id)).toEqual({ keys: 2, nodes: 2 }); }); }); describe('storing and editing a curve', () => { it('starts as a straight line and keeps its points through a round trip', () => { const { project, stack } = makeProject(); const create = new CreateAssetCommand('curve', 'Pop'); stack.execute(create); expect(findAssetOf(project, create.asset.id, 'curve')!.points).toEqual(linearCurve()); stack.execute(new SetCurvePointsCommand(create.asset.id, easeCurve('elastic', 'out'))); const curve = findAssetOf(project, create.asset.id, 'curve')!; expect(findAssetOf(roundTrip(project), create.asset.id, 'curve')).toEqual(curve); }); it('holds a hand-written file to the rules on loading', () => { const { project, stack } = makeProject(); const create = new CreateAssetCommand('curve', 'Pop'); stack.execute(create); const raw = serializeProject(project) as { assets: { id: string; points?: unknown }[] }; raw.assets.find((a) => a.id === create.asset.id)!.points = [ { x: 1, y: 1 }, { x: 'far', y: 0 }, { x: 0.4, y: 0.8, out: { x: 3, y: 0 } }, { x: 0.2, y: 0 }, ]; const loaded = findAssetOf(deserializeProject(raw), create.asset.id, 'curve')!; expect(loaded.points).toEqual([ { x: 0, y: 0 }, { x: 0.4, y: 0.8, out: { x: 0.6, y: 0 } }, { x: 1, y: 1 }, ]); }); it('writes the curve in one undo step, held to the rules, and takes it back', () => { const { project, stack } = makeProject(); const create = new CreateAssetCommand('curve', 'Pop'); stack.execute(create); const curve = () => findAssetOf(project, create.asset.id, 'curve')!; stack.execute( new SetCurvePointsCommand(create.asset.id, [ { x: 0.3, y: 0 }, { x: 0.6, y: 1.2 }, { x: 0.8, y: 1 }, ]), ); expect(curve().points.map((p) => p.x)).toEqual([0, 0.6, 1]); expect(SetCurvePointsCommand.changes(curve(), curve().points)).toBe(false); stack.undo(); expect(curve().points).toEqual(linearCurve()); }); }); describe('the built-in eases as presets', () => { it('traces every ease to within half a percent, and each is a legal curve as it is', () => { const presets = curvePresets(); expect(presets).toHaveLength(31); expect(presets[0]!.build()).toEqual(linearCurve()); for (const preset of presets.slice(1)) { const points = preset.build(); expect(sanitizedCurve(points), preset.label).toEqual(points); const [shape, direction] = preset.label.toLowerCase().split(' ') as [string, string]; const interpolation = easingValue( shape === 'quad' ? 'quad' : (shape as never), direction as 'in' | 'out' | 'in-out', ); for (const t of steps(97)) expect( Math.abs(curveValue(points, t) - easedShare(interpolation, t)), `${preset.label} at ${t}`, ).toBeLessThan(0.005); } }); it('needs one segment for a quad, and more for what bounces', () => { expect(easeCurve('quad', 'in')).toHaveLength(2); expect(easeCurve('bounce', 'out').length).toBeGreaterThan(8); }); });