Let animation keys ease with Sine to Bounce in, out or in-out
A key could only ease quadratically, which makes quick moves like a sword swing feel soft. Animators know the Penner curves from CSS, GSAP and Unity, so a key now offers all of them. - Ten curves (Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce), each in, out and in-out, next to Linear and Step. - The inspector shows two fields instead of one long list: Easing, and Direction only when a curve is picked. Switching the curve keeps the direction. Coming from Linear or Step starts at in-out. - Old projects keep working. ease-in/out/in-out now read as Quad and are still saved under that name. New curves save as cubic-out, back-in-out and so on. - Back and Elastic overshoot on purpose. A number property with min or max is clamped in play and preview, so a Back in from scale 0 does not flip the sprite. trackWriteAt now takes the property definition, and animatedKind* is renamed to animatedDef*. - The timeline marker still shows only the direction. - In-out is built for every curve from its in-curve and its mirror. easings.net uses own constants for Back and Elastic in-out, so our Back in-out winds up about 5 % instead of 10 %. Tests: - ./check.sh: 165 files, 2413 tests, CHECK EXIT 0. - 12 new unit tests in tests/core/easing-curves.test.ts: round trip of all 30 values, start/end/midpoint of every curve, reference values, only Back and Elastic leave 0..1, clamping in play and preview, unknown values are rejected. - e2e 25: ALL PASSED, 28 checks. New 25b-ii (3 checks) clicks both fields and undoes the four choices. Check sum 1393 -> 1396. - Counter-checks, each fails its test: no clamping (2), Quad saved as quad-* (2), mirrored in-out wrong (2), curve lookup with `in` instead of Object.hasOwn (1), no Direction field (e2e 25). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94749b4f3e
commit
147f8ffbea
9 files changed
+500
-74
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+10
-2
@@ -149,8 +149,16 @@ export interface TextureAsset extends AssetBase {
|
|||||||
frames: TextureFrame[];
|
frames: TextureFrame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a key blends into the next one (#101); absent is `linear`. `step` holds until the next key. */
|
export type EasingCurve =
|
||||||
export type KeyInterpolation = 'linear' | 'step' | 'ease-in' | 'ease-out' | 'ease-in-out';
|
'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 `<curve>-<direction>`, except quad, which keeps its first name `ease-<direction>`.
|
||||||
|
*/
|
||||||
|
export type KeyInterpolation =
|
||||||
|
'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
|
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
|
||||||
|
|||||||
+125
-33
@@ -1,7 +1,15 @@
|
|||||||
import { isKindWith } from '../properties/kinds';
|
import { isKindWith } from '../properties/kinds';
|
||||||
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
|
import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema';
|
||||||
import { shaderOf } from '../properties/shaders';
|
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 type { AssetType } from './AssetTypes';
|
||||||
import { treeOrder } from './hierarchy';
|
import { treeOrder } from './hierarchy';
|
||||||
import { instanceChildId, resolveObjects } from './instance';
|
import { instanceChildId, resolveObjects } from './instance';
|
||||||
@@ -104,17 +112,85 @@ export function blends(kind: string): boolean {
|
|||||||
return kind === 'number' || kind === 'color';
|
return kind === 'number' || kind === 'color';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */
|
export type EasingShape = 'linear' | 'step' | EasingCurve;
|
||||||
export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [
|
|
||||||
|
/** 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: 'linear', label: 'Linear' },
|
||||||
{ value: 'step', label: 'Step' },
|
{ value: 'step', label: 'Step' },
|
||||||
{ value: 'ease-in', label: 'Ease in' },
|
{ value: 'sine', label: 'Sine' },
|
||||||
{ value: 'ease-out', label: 'Ease out' },
|
{ value: 'quad', label: 'Quad' },
|
||||||
{ value: 'ease-in-out', label: 'Ease in-out' },
|
{ 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<EasingCurve, (t: number) => 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 {
|
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 {
|
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
|
* How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases
|
||||||
* 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.
|
* 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 {
|
export function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||||||
switch (interpolation) {
|
const { shape, direction } = easingOf(interpolation);
|
||||||
case 'linear':
|
if (shape === 'linear') return t;
|
||||||
return t;
|
if (shape === 'step') return 0;
|
||||||
case 'step':
|
const easeIn = EASE_IN[shape];
|
||||||
return 0;
|
switch (direction ?? 'in-out') {
|
||||||
case 'ease-in':
|
case 'in':
|
||||||
return t * t;
|
return easeIn(t);
|
||||||
case 'ease-out':
|
case 'out':
|
||||||
return 1 - (1 - t) * (1 - t);
|
return 1 - easeIn(1 - t);
|
||||||
case 'ease-in-out':
|
case 'in-out':
|
||||||
return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
|
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 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
|
* 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(
|
export function trackWriteAt(
|
||||||
track: AnimationTrack,
|
track: AnimationTrack,
|
||||||
kind: string,
|
def: RowDef,
|
||||||
frame: number,
|
frame: number,
|
||||||
base: PropertyValue | undefined,
|
base: PropertyValue | undefined,
|
||||||
reverse = false,
|
reverse = false,
|
||||||
): PropertyValue | undefined {
|
): PropertyValue | undefined {
|
||||||
const value = trackValueAt(track.keys, kind, frame, reverse);
|
const value = trackValueAt(track.keys, def.kind, frame, reverse);
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
const offset = track.relative && kind === 'number' && typeof base === 'number';
|
if (def.kind !== 'number') return value;
|
||||||
return offset ? base + Number(value) : 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. */
|
/** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */
|
||||||
export function animatedKind(type: AssetType, key: string): string | undefined {
|
export interface RowDef {
|
||||||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
kind: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
|
function heldTo(def: RowDef, value: PropertyValue): PropertyValue {
|
||||||
export function animatedKindOf(
|
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,
|
project: Project,
|
||||||
asset: Asset,
|
asset: Asset,
|
||||||
bag: PropertyBag,
|
bag: PropertyBag,
|
||||||
key: string,
|
key: string,
|
||||||
): string | undefined {
|
): PropertyDef | undefined {
|
||||||
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
|
return animatableDefsOf(project, asset, bag).find((d) => d.key === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPosition(type: AssetType): boolean {
|
function hasPosition(type: AssetType): boolean {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
animatableDefs,
|
animatableDefs,
|
||||||
animatedKindOf,
|
animatedDefOf,
|
||||||
frameCount,
|
frameCount,
|
||||||
isPositionRow,
|
isPositionRow,
|
||||||
PLACED_ROOT_ID,
|
PLACED_ROOT_ID,
|
||||||
@@ -72,7 +72,7 @@ function placedHolder(root: Asset): ObjectHolder {
|
|||||||
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
|
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
|
||||||
const def = animatableDefs('scene').find((d) => d.key === track.property);
|
const def = animatableDefs('scene').find((d) => d.key === track.property);
|
||||||
if (!def) return;
|
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;
|
if (value !== undefined) holder.props[track.property] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +89,14 @@ function writeObject(
|
|||||||
if (!ref || !asset) return;
|
if (!ref || !asset) return;
|
||||||
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
|
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
|
||||||
const props = resolveSceneObjectProps(project, asset, ref.object);
|
const props = resolveSceneObjectProps(project, asset, ref.object);
|
||||||
const kind = animatedKindOf(project, asset, props, track.property);
|
const def = animatedDefOf(project, asset, props, track.property);
|
||||||
if (!kind) return;
|
if (!def) return;
|
||||||
const key = track.property;
|
const key = track.property;
|
||||||
if (isPositionRow(asset.type, key)) {
|
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) });
|
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
|
||||||
return;
|
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);
|
if (value !== undefined) ref.setOverride(key, value);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../core/commands/animationCommands';
|
} from '../../core/commands/animationCommands';
|
||||||
import {
|
import {
|
||||||
animationPlayheadAt,
|
animationPlayheadAt,
|
||||||
|
easingOf,
|
||||||
fpsOf,
|
fpsOf,
|
||||||
frameCount,
|
frameCount,
|
||||||
hasKey,
|
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
|
* 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
|
* half its end, and a round half is a slow one. Linear is the diamond, any curve in-out the circle, a
|
||||||
* – which holds – the square.
|
* step – which holds – the square.
|
||||||
*/
|
*/
|
||||||
function drawKeyShape(
|
function drawKeyShape(
|
||||||
g: Graphics,
|
g: Graphics,
|
||||||
@@ -694,8 +695,9 @@ function drawKeyShape(
|
|||||||
half: number,
|
half: number,
|
||||||
): Graphics {
|
): Graphics {
|
||||||
if (interpolation === 'step') return g.rect(x - half * 0.75, y - half * 0.75, half * 1.5, half * 1.5);
|
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 { direction } = easingOf(interpolation);
|
||||||
const slowEnd = interpolation === 'ease-out' || interpolation === 'ease-in-out';
|
const slowStart = direction === 'in' || direction === 'in-out';
|
||||||
|
const slowEnd = direction === 'out' || direction === 'in-out';
|
||||||
g.moveTo(x, y - half);
|
g.moveTo(x, y - half);
|
||||||
if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2);
|
if (slowStart) g.arc(x, y, half, -Math.PI / 2, Math.PI / 2);
|
||||||
else g.lineTo(x + half, y).lineTo(x, y + half);
|
else g.lineTo(x + half, y).lineTo(x, y + half);
|
||||||
|
|||||||
@@ -7,15 +7,25 @@ import {
|
|||||||
} from '../../core/commands/animationCommands';
|
} from '../../core/commands/animationCommands';
|
||||||
import {
|
import {
|
||||||
blends,
|
blends,
|
||||||
|
EASING_DIRECTIONS,
|
||||||
|
EASING_SHAPES,
|
||||||
|
easingOf,
|
||||||
|
type EasingShape,
|
||||||
|
easingValue,
|
||||||
findKey,
|
findKey,
|
||||||
findTrack,
|
findTrack,
|
||||||
interpolationOf,
|
interpolationOf,
|
||||||
KEY_INTERPOLATIONS,
|
|
||||||
type KeyRef,
|
type KeyRef,
|
||||||
trackDef,
|
trackDef,
|
||||||
trackLabel,
|
trackLabel,
|
||||||
} from '../../core/model/animation';
|
} 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 { findAssetOf, type Project } from '../../core/model/Project';
|
||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { openRowDialog, RowDialog } from '../animation/RowDialog';
|
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
|
* How the picked keys blend into their next key – for all of them at once: a curve and, for one that
|
||||||
* key picked last among those that can blend; a row that cannot blend offers none, it always jumps.
|
* 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 {
|
function buildEasing(host: InspectorHost, asset: AnimationAsset, keys: readonly KeyRef[]): void {
|
||||||
const shown = easingKey(host.state.project, asset, keys);
|
const shown = easingKey(host.state.project, asset, keys);
|
||||||
if (!shown) return;
|
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,
|
host,
|
||||||
{
|
{
|
||||||
key: 'easing',
|
key: 'easing',
|
||||||
label: 'Easing',
|
label: 'Easing',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
default: 'linear',
|
default: 'linear',
|
||||||
options: KEY_INTERPOLATIONS.map((i) => ({ ...i })),
|
options: EASING_SHAPES.map((i) => ({ ...i })),
|
||||||
},
|
},
|
||||||
interpolationOf(shown),
|
easing.shape,
|
||||||
{
|
{
|
||||||
onInput: () => {},
|
onInput: () => {},
|
||||||
onChange: (value) =>
|
onChange: (value) => set(easingValue(value as EasingShape, current().direction)),
|
||||||
host.state.execute(new SetKeyInterpolationCommand(asset.id, [...keys], value as KeyInterpolation)),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
host.fields.set('easing', row);
|
host.fields.set('easing', shape);
|
||||||
host.body.add(row.row);
|
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');
|
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);
|
const eased = easingKey(host.state.project, asset, keys);
|
||||||
if (Boolean(eased) !== host.fields.has('easing')) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isHandleEnabled } from '../../core/graph';
|
import { isHandleEnabled } from '../../core/graph';
|
||||||
import {
|
import {
|
||||||
type AnimationPlayer,
|
type AnimationPlayer,
|
||||||
animatedKind,
|
animatedDef,
|
||||||
animatedKindOf,
|
animatedDefOf,
|
||||||
animationPlayheadAt,
|
animationPlayheadAt,
|
||||||
blendShare,
|
blendShare,
|
||||||
blends,
|
blends,
|
||||||
@@ -15,6 +15,7 @@ import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
|||||||
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
|
import { instanceChildId, splitInstanceId } from '../../core/model/instance';
|
||||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
import type { System } from './System';
|
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. */
|
/** A row that reaches something this tick: how to read and write it, and the kind it blends as. */
|
||||||
interface Binding {
|
interface Binding {
|
||||||
track: AnimationTrack;
|
track: AnimationTrack;
|
||||||
kind: string;
|
def: PropertyDef;
|
||||||
/** Where the player keeps the starting value: `<entity id> <property>`, or `scene <property>`. */
|
/** Where the player keeps the starting value: `<entity id> <property>`, or `scene <property>`. */
|
||||||
base: string;
|
base: string;
|
||||||
read(): PropertyValue;
|
read(): PropertyValue;
|
||||||
@@ -80,16 +81,17 @@ export class AnimationSystem implements System {
|
|||||||
const fade = player.fade;
|
const fade = player.fade;
|
||||||
const fadeShare = fade ? blendShare(fade) : 0;
|
const fadeShare = fade ? blendShare(fade) : 0;
|
||||||
for (const b of bindings) {
|
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;
|
if (value === undefined) continue;
|
||||||
const below = written.has(b.base) ? b.read() : undefined;
|
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];
|
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) {
|
if (fade) {
|
||||||
const back = below ?? player.bases[b.base];
|
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);
|
b.write(value);
|
||||||
written.add(b.base);
|
written.add(b.base);
|
||||||
@@ -139,11 +141,11 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
|||||||
for (const track of asset.tracks) {
|
for (const track of asset.tracks) {
|
||||||
const key = track.property;
|
const key = track.property;
|
||||||
if (root.type === 'scene' && track.target === '') {
|
if (root.type === 'scene' && track.target === '') {
|
||||||
const kind = world.sceneId === root.id ? animatedKind('scene', key) : undefined;
|
const def = world.sceneId === root.id ? animatedDef('scene', key) : undefined;
|
||||||
if (kind)
|
if (def)
|
||||||
bindings.push({
|
bindings.push({
|
||||||
track,
|
track,
|
||||||
kind,
|
def,
|
||||||
base: `scene ${key}`,
|
base: `scene ${key}`,
|
||||||
read: () => world.sceneProps[key] ?? 0,
|
read: () => world.sceneProps[key] ?? 0,
|
||||||
write: (value) => {
|
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;
|
entity = world.sceneId === root.id ? world.findByObjectId(track.target) : undefined;
|
||||||
else if (track.target === '') entity = owner;
|
else if (track.target === '') entity = owner;
|
||||||
else entity = world.findByObjectId(childId(owner.objectId, track.target));
|
else entity = world.findByObjectId(childId(owner.objectId, track.target));
|
||||||
const kind = entity && kindOn(world, entity, key);
|
const def = entity && defOn(world, entity, key);
|
||||||
if (entity && kind) {
|
if (entity && def) {
|
||||||
const aimed = entity;
|
const aimed = entity;
|
||||||
bindings.push({
|
bindings.push({
|
||||||
track,
|
track,
|
||||||
kind,
|
def,
|
||||||
base: `${aimed.id} ${key}`,
|
base: `${aimed.id} ${key}`,
|
||||||
read: () => read(aimed, key),
|
read: () => read(aimed, key),
|
||||||
write: (value) => write(aimed, key, value),
|
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).
|
* 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -122,6 +122,73 @@ export async function animation(t) {
|
|||||||
info,
|
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,
|
// 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.
|
// so both ends and the middle are on screen together.
|
||||||
const scrubTo = async (frame) => {
|
const scrubTo = async (frame) => {
|
||||||
|
|||||||
Reference in new issue
Block a user