diff --git a/README.md b/README.md index 6031656c..8296c5e4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1393 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1396 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 6b89bf5a..56f92c91 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -149,8 +149,16 @@ export interface TextureAsset extends AssetBase { frames: TextureFrame[]; } -/** How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. */ -export type KeyInterpolation = 'linear' | 'step' | 'ease-in' | 'ease-out' | 'ease-in-out'; +export type EasingCurve = + 'sine' | 'quad' | 'cubic' | 'quart' | 'quint' | 'expo' | 'circ' | 'back' | 'elastic' | 'bounce'; +export type EasingDirection = 'in' | 'out' | 'in-out'; + +/** + * How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. A + * curve is stored as `-`, except quad, which keeps its first name `ease-`. + */ +export type KeyInterpolation = + 'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude}-${EasingDirection}`; /** * A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 3bc2f9a0..04bcfd0e 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -1,7 +1,15 @@ import { isKindWith } from '../properties/kinds'; import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema'; import { shaderOf } from '../properties/shaders'; -import type { AnimationAsset, AnimationKey, AnimationTrack, Asset, KeyInterpolation } from './Asset'; +import type { + AnimationAsset, + AnimationKey, + AnimationTrack, + Asset, + EasingCurve, + EasingDirection, + KeyInterpolation, +} from './Asset'; import type { AssetType } from './AssetTypes'; import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; @@ -104,17 +112,85 @@ export function blends(kind: string): boolean { return kind === 'number' || kind === 'color'; } -/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */ -export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [ +export type EasingShape = 'linear' | 'step' | EasingCurve; + +/** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */ +export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [ { value: 'linear', label: 'Linear' }, { value: 'step', label: 'Step' }, - { value: 'ease-in', label: 'Ease in' }, - { value: 'ease-out', label: 'Ease out' }, - { value: 'ease-in-out', label: 'Ease in-out' }, + { value: 'sine', label: 'Sine' }, + { value: 'quad', label: 'Quad' }, + { value: 'cubic', label: 'Cubic' }, + { value: 'quart', label: 'Quart' }, + { value: 'quint', label: 'Quint' }, + { value: 'expo', label: 'Expo' }, + { value: 'circ', label: 'Circ' }, + { value: 'back', label: 'Back' }, + { value: 'elastic', label: 'Elastic' }, + { value: 'bounce', label: 'Bounce' }, ]; +/** The second field, for a curve only. */ +export const EASING_DIRECTIONS: readonly { value: EasingDirection; label: string }[] = [ + { value: 'in', label: 'In' }, + { value: 'out', label: 'Out' }, + { value: 'in-out', label: 'In-out' }, +]; + +const BACK = 1.70158; +const ELASTIC = (2 * Math.PI) / 3; + +function bounceOut(t: number): number { + const n = 7.5625; + const d = 2.75; + if (t < 1 / d) return n * t * t; + if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75; + if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375; + return n * (t -= 2.625 / d) * t + 0.984375; +} + +/** Each curve's `in` form (Penner's); `out` is it mirrored, `in-out` both halves. Back and elastic leave 0…1. */ +const EASE_IN: Record number> = { + sine: (t) => 1 - Math.cos((t * Math.PI) / 2), + quad: (t) => t * t, + cubic: (t) => t ** 3, + quart: (t) => t ** 4, + quint: (t) => t ** 5, + expo: (t) => (t <= 0 ? 0 : 2 ** (10 * t - 10)), + circ: (t) => 1 - Math.sqrt(1 - t * t), + back: (t) => (BACK + 1) * t ** 3 - BACK * t * t, + elastic: (t) => (t <= 0 ? 0 : t >= 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((10 * t - 10.75) * ELASTIC)), + bounce: (t) => 1 - bounceOut(1 - t), +}; + +function isCurve(value: string): value is EasingCurve { + return Object.hasOwn(EASE_IN, value); +} + +/** A stored interpolation split into the inspector's two fields; unknown text reads as `linear`. */ +export function easingOf(interpolation: KeyInterpolation): { + shape: EasingShape; + direction?: EasingDirection; +} { + if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation }; + const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation); + if (!match) return { shape: 'linear' }; + const curve = match[1]!; + const direction = match[2] as EasingDirection; + if (curve === 'ease') return { shape: 'quad', direction }; + return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' }; +} + +/** The stored form of the two fields; a curve without a direction eases both ways. */ +export function easingValue(shape: EasingShape, direction: EasingDirection = 'in-out'): KeyInterpolation { + if (shape === 'linear' || shape === 'step') return shape; + return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`; +} + export function isKeyInterpolation(value: unknown): value is KeyInterpolation { - return KEY_INTERPOLATIONS.some((i) => i.value === value); + if (typeof value !== 'string') return false; + const { shape, direction } = easingOf(value as KeyInterpolation); + return easingValue(shape, direction) === value; } export function interpolationOf(key: AnimationKey): KeyInterpolation { @@ -122,21 +198,21 @@ export function interpolationOf(key: AnimationKey): KeyInterpolation { } /** - * How far along the way to the next key a value is, `t` the share of the time passed. The eases are - * quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter. + * How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases + * of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both. */ -function easedShare(interpolation: KeyInterpolation, t: number): number { - switch (interpolation) { - case 'linear': - return t; - case 'step': - return 0; - case 'ease-in': - return t * t; - case 'ease-out': - return 1 - (1 - t) * (1 - t); - case 'ease-in-out': - return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); +export function easedShare(interpolation: KeyInterpolation, t: number): number { + const { shape, direction } = easingOf(interpolation); + if (shape === 'linear') return t; + if (shape === 'step') return 0; + const easeIn = EASE_IN[shape]; + switch (direction ?? 'in-out') { + case 'in': + return easeIn(t); + case 'out': + return 1 - easeIn(1 - t); + case 'in-out': + return t < 0.5 ? easeIn(2 * t) / 2 : 1 - easeIn(2 - 2 * t) / 2; } } @@ -182,34 +258,50 @@ export function trackValueAt( /** * What a row writes at a frame: its value, or for a relative number row that value added to `base`, * what the target held when the animation started. Shared by the runtime player and the tab's - * preview, so both show the same picture. `undefined` for a row without keys. + * preview, so both show the same picture. A number is held to the property's `min`/`max`, which a + * back or elastic curve overshoots. `undefined` for a row without keys. */ export function trackWriteAt( track: AnimationTrack, - kind: string, + def: RowDef, frame: number, base: PropertyValue | undefined, reverse = false, ): PropertyValue | undefined { - const value = trackValueAt(track.keys, kind, frame, reverse); + const value = trackValueAt(track.keys, def.kind, frame, reverse); if (value === undefined) return undefined; - const offset = track.relative && kind === 'number' && typeof base === 'number'; - return offset ? base + Number(value) : value; + if (def.kind !== 'number') return value; + const offset = track.relative && typeof base === 'number'; + return heldTo(def, offset ? base + Number(value) : value); } -/** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */ -export function animatedKind(type: AssetType, key: string): string | undefined { - return animatableDefs(type).find((d) => d.key === key)?.kind; +/** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */ +export interface RowDef { + kind: string; + min?: number; + max?: number; } -/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */ -export function animatedKindOf( +function heldTo(def: RowDef, value: PropertyValue): PropertyValue { + if (typeof value !== 'number') return value; + if (def.min !== undefined && value < def.min) return def.min; + if (def.max !== undefined && value > def.max) return def.max; + return value; +} + +/** A row's property on an asset of that type; undefined for one the timeline cannot animate. */ +export function animatedDef(type: AssetType, key: string): PropertyDef | undefined { + return animatableDefs(type).find((d) => d.key === key); +} + +/** The property `key` is on an asset holding `bag` – see `animatableDefsOf`. */ +export function animatedDefOf( project: Project, asset: Asset, bag: PropertyBag, key: string, -): string | undefined { - return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind; +): PropertyDef | undefined { + return animatableDefsOf(project, asset, bag).find((d) => d.key === key); } function hasPosition(type: AssetType): boolean { diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts index 5eafabf6..c1b83c26 100644 --- a/src/core/model/animationPreview.ts +++ b/src/core/model/animationPreview.ts @@ -5,7 +5,7 @@ */ import { animatableDefs, - animatedKindOf, + animatedDefOf, frameCount, isPositionRow, PLACED_ROOT_ID, @@ -72,7 +72,7 @@ function placedHolder(root: Asset): ObjectHolder { function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void { const def = animatableDefs('scene').find((d) => d.key === track.property); if (!def) return; - const value = trackWriteAt(track, def.kind, frame, holder.props[track.property] ?? def.default, reverse); + const value = trackWriteAt(track, def, frame, holder.props[track.property] ?? def.default, reverse); if (value !== undefined) holder.props[track.property] = value; } @@ -89,14 +89,14 @@ function writeObject( if (!ref || !asset) return; // Resolved, so a shader worn through an override or an instance edit brings its parameters (#104). const props = resolveSceneObjectProps(project, asset, ref.object); - const kind = animatedKindOf(project, asset, props, track.property); - if (!kind) return; + const def = animatedDefOf(project, asset, props, track.property); + if (!def) return; const key = track.property; if (isPositionRow(asset.type, key)) { - const value = trackWriteAt(track, kind, frame, ref.object.position[key], reverse); + const value = trackWriteAt(track, def, frame, ref.object.position[key], reverse); if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) }); return; } - const value = trackWriteAt(track, kind, frame, props[key], reverse); + const value = trackWriteAt(track, def, frame, props[key], reverse); if (value !== undefined) ref.setOverride(key, value); } diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 02c80af7..31d5e256 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -10,6 +10,7 @@ import { } from '../../core/commands/animationCommands'; import { animationPlayheadAt, + easingOf, fpsOf, frameCount, hasKey, @@ -683,8 +684,8 @@ export class TimelineCanvas extends Widget { /** * A key's outline tells its easing: the right half is the start of the way to the next key, the left - * half its end, and a round half is a slow one. Linear is the diamond, ease-in-out the circle, a step - * – which holds – the square. + * half its end, and a round half is a slow one. Linear is the diamond, any curve in-out the circle, a + * step – which holds – the square. */ function drawKeyShape( g: Graphics, @@ -694,8 +695,9 @@ function drawKeyShape( half: number, ): Graphics { if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5); - const slowStart = interpolation === 'ease-in' || interpolation === 'ease-in-out'; - const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out'; + const { direction } = easingOf(interpolation); + const slowStart = direction === 'in' || direction === 'in-out'; + const slowEnd = direction === 'out' || direction === 'in-out'; g.moveTo(x, y - half); if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2); else g.lineTo(x + half, y).lineTo(x, y + half); diff --git a/src/editor/inspector/trackSection.ts b/src/editor/inspector/trackSection.ts index 6ef905fb..1b7cab3b 100644 --- a/src/editor/inspector/trackSection.ts +++ b/src/editor/inspector/trackSection.ts @@ -7,15 +7,25 @@ import { } from '../../core/commands/animationCommands'; import { blends, + EASING_DIRECTIONS, + EASING_SHAPES, + easingOf, + type EasingShape, + easingValue, findKey, findTrack, interpolationOf, - KEY_INTERPOLATIONS, type KeyRef, trackDef, trackLabel, } from '../../core/model/animation'; -import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../../core/model/Asset'; +import type { + AnimationAsset, + AnimationKey, + AnimationTrack, + EasingDirection, + KeyInterpolation, +} from '../../core/model/Asset'; import { findAssetOf, type Project } from '../../core/model/Project'; import { Button } from '../../ui'; import { openRowDialog, RowDialog } from '../animation/RowDialog'; @@ -132,30 +142,57 @@ function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void } /** - * How the picked keys blend into their next key – for all of them at once. It shows the easing of the - * key picked last among those that can blend; a row that cannot blend offers none, it always jumps. + * How the picked keys blend into their next key – for all of them at once: a curve and, for one that + * is not linear or step, its direction. It shows the easing of the key picked last among those that + * can blend; a row that cannot blend offers none, it always jumps. */ function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void { const shown = easingKey(host.state.project, asset, keys); if (!shown) return; - const row = createFieldRow( + const easing = easingOf(interpolationOf(shown)); + // Read at the click, not now: the other field may have changed the key since this row was built. + const current = () => { + const key = easingKey(host.state.project, asset, keys); + return easingOf(key ? interpolationOf(key) : 'linear'); + }; + const set = (value: KeyInterpolation) => + host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value)); + const shape = createFieldRow( host, { key: 'easing', label: 'Easing', kind: 'enum', default: 'linear', - options: KEY_INTERPOLATIONS.map((i) => ({ ...i })), + options: EASING_SHAPES.map((i) => ({ ...i })), }, - interpolationOf(shown), + easing.shape, { onInput: () => {}, - onChange: (value) => - host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)), + onChange: (value) => set(easingValue(value as EasingShape, current().direction)), }, ); - host.fields.set('easing', row); - host.body.add(row.row); + host.fields.set('easing', shape); + host.body.add(shape.row); + if (easing.direction) { + const direction = createFieldRow( + host, + { + key: 'direction', + label: 'Direction', + kind: 'enum', + default: 'in-out', + options: EASING_DIRECTIONS.map((i) => ({ ...i })), + }, + easing.direction, + { + onInput: () => {}, + onChange: (value) => set(easingValue(current().shape, value as EasingDirection)), + }, + ); + host.fields.set('direction', direction); + host.body.add(direction.row); + } addNote(host, 'Easing: the way from this key to the next one.', 'small'); } @@ -193,7 +230,11 @@ export function refreshTrackSection( } const eased = easingKey(host.state.project, asset, keys); if (Boolean(eased) !== host.fields.has('easing')) return false; - if (eased) host.fields.get('easing')?.setValue(interpolationOf(eased)); + if (!eased) return true; + const easing = easingOf(interpolationOf(eased)); + if (Boolean(easing.direction) !== host.fields.has('direction')) return false; + host.fields.get('easing')?.setValue(easing.shape); + if (easing.direction) host.fields.get('direction')?.setValue(easing.direction); return true; } diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 83902298..538fb426 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -1,8 +1,8 @@ import { isHandleEnabled } from '../../core/graph'; import { type AnimationPlayer, - animatedKind, - animatedKindOf, + animatedDef, + animatedDefOf, animationPlayheadAt, blendShare, blends, @@ -15,6 +15,7 @@ import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { instanceChildId, splitInstanceId } from '../../core/model/instance'; import { findAsset, findAssetOf } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { System } from './System'; @@ -28,7 +29,7 @@ export interface AnimationFinish { /** A row that reaches something this tick: how to read and write it, and the kind it blends as. */ interface Binding { track: AnimationTrack; - kind: string; + def: PropertyDef; /** Where the player keeps the starting value: ` `, or `scene `. */ base: string; read(): PropertyValue; @@ -80,16 +81,17 @@ export class AnimationSystem implements System { 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); + let value = trackWriteAt(b.track, b.def, 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)) { + if (share < 1 && blends(b.def.kind)) { const from = below ?? blend?.from?.[b.base]; - if (from !== undefined) value = blendValue(b.kind, from, value, share); + if (from !== undefined) value = blendValue(b.def.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; + if (back !== undefined) + value = blends(b.def.kind) ? blendValue(b.def.kind, value, back, fadeShare) : back; } b.write(value); written.add(b.base); @@ -139,11 +141,11 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { for (const track of asset.tracks) { const key = track.property; if (root.type === 'scene' && track.target === '') { - const kind = world.sceneId === root.id ? animatedKind('scene', key) : undefined; - if (kind) + const def = world.sceneId === root.id ? animatedDef('scene', key) : undefined; + if (def) bindings.push({ track, - kind, + def, base: `scene ${key}`, read: () => world.sceneProps[key] ?? 0, write: (value) => { @@ -157,12 +159,12 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { entity = world.sceneId === root.id ? world.findByObjectId(track.target) : undefined; else if (track.target === '') entity = owner; else entity = world.findByObjectId(childId(owner.objectId, track.target)); - const kind = entity && kindOn(world, entity, key); - if (entity && kind) { + const def = entity && defOn(world, entity, key); + if (entity && def) { const aimed = entity; bindings.push({ track, - kind, + def, base: `${aimed.id} ${key}`, read: () => read(aimed, key), write: (value) => write(aimed, key, value), @@ -173,12 +175,12 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { } /** - * The kind `key` has on the entity as it is now: its bag names the shader it wears, and swapping that + * The property `key` is on the entity as it is now: its bag names the shader it wears, and swapping that * shader at runtime takes the parameter rows with it (#104). */ -function kindOn(world: World, entity: Entity, key: string): string | undefined { +function defOn(world: World, entity: Entity, key: string): PropertyDef | undefined { const asset = findAsset(world.project, entity.assetId); - return asset && animatedKindOf(world.project, asset, entity.props, key); + return asset && animatedDefOf(world.project, asset, entity.props, key); } /** diff --git a/tests/core/easing-curves.test.ts b/tests/core/easing-curves.test.ts new file mode 100644 index 00000000..3271738f --- /dev/null +++ b/tests/core/easing-curves.test.ts @@ -0,0 +1,214 @@ +/** + * 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); + }); +}); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 815c5f26..8798b827 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -122,6 +122,73 @@ export async function animation(t) { info, ); + // 25b-ii. Easing is two fields: the curve, and for a curve its direction. Each pick is a step, + // all three are undone here so 25h counts only its own. + const easingOf = async () => (await keysOf()).find((k) => k.frame === 10)?.interpolation ?? 'linear'; + const pick = async (field, label) => { + const row = await json(`__gm.inspectorField(${JSON.stringify(field)})`); + await click(row.x, row.y); + await sleep(100); + const rows = await json('__gm.menuRows()'); + const entry = await t.locate(label, true); + await click(entry.x, entry.y); + await settle(); + return rows; + }; + const shapes = await pick('Easing', 'Cubic'); + const cubicDirection = await json("__gm.inspectorField('Direction')"); + check( + 'the Easing field lists every curve, and a curve brings the Direction field, eased both ways', + JSON.stringify(shapes) === + JSON.stringify([ + 'Linear', + 'Step', + 'Sine', + 'Quad', + 'Cubic', + 'Quart', + 'Quint', + 'Expo', + 'Circ', + 'Back', + 'Elastic', + 'Bounce', + ]) && + (await easingOf()) === 'cubic-in-out' && + Boolean(cubicDirection), + JSON.stringify({ shapes, easing: await easingOf(), cubicDirection }), + ); + const directions = await pick('Direction', 'Out'); + await shot('25-animation-easing'); + const outward = await easingOf(); + await pick('Easing', 'Back'); + const back = await easingOf(); + check( + 'Direction offers In, Out and In-out, and a new curve keeps the direction picked', + JSON.stringify(directions) === JSON.stringify(['In', 'Out', 'In-out']) && + outward === 'cubic-out' && + back === 'back-out', + JSON.stringify({ directions, outward, back }), + ); + await pick('Easing', 'Linear'); + const linearDirection = await json("__gm.inspectorField('Direction')"); + const linear = await easingOf(); + await focusTimeline(); + await undo(); + const backAgain = await easingOf(); + for (let i = 0; i < 3; i++) await undo(); + const easingUndone = await easingOf(); + const keptValue = (await keysOf()).find((k) => k.frame === 10)?.value; + check( + 'linear drops the Direction field and the stored easing, and undo takes the four picks back one by one', + !linearDirection && + linear === 'linear' && + backAgain === 'back-out' && + easingUndone === 'linear' && + keptValue === 100, + JSON.stringify({ linearDirection, linear, backAgain, easingUndone, keptValue }), + ); + // 25c. The ruler scrubs, and the preview draws the child at the playhead. Framed at the far end, // so both ends and the middle are on screen together. const scrubTo = async (frame) => {