Build your own easing curve and use it on keys and Lerp (#247)
game-maker / gate (push) Successful in 3m44s
game-maker / suite (push) Successful in 10m25s

The built-in eases cover the usual shapes, but a game often wants its
own: a coin that bounces twice, a door that overshoots and settles.
A new asset type holds such a curve, and keys and the Lerp node can
pick it instead of a built-in ease.

- New asset type "curve": points like in Vector art, x is t and y is
  the value. A new curve is the straight line from (0, 0) to (1, 1).
- One pure evaluation in src/core/model/curve.ts serves the timeline
  preview, the running game and the Lerp node.
- The curve always has one value per t. The end points stay at t 0
  and 1, points cannot pass each other, and a handle that reaches past
  its neighbour is shortened along its direction. Loading a hand-edited
  file applies the same rules.
- Curve tab: click on the line to insert a point that keeps the shape,
  click on free space to put one there, drag points and handles,
  double-click or Delete removes, Alt-click toggles round and sharp.
  Every step is one undo step.
- Presets: all 30 built-in eases plus Linear, fitted with adaptive
  subdivision. Each stays within 0.4 % of the built-in ease.
- Preview: a dot runs along the curve once a second, a second dot
  shows only the value. The Preview button stops it.
- Animation keys get "Curve" under Easing with a curve picker. Lerp
  gets "Curve" under Ease, and its result may overshoot b.
- A missing or unpicked curve runs linear. The inspector says so, the
  node shows it as a problem, and deleting a used curve warns with the
  number of keys and Lerp nodes.

Tests:
- ./check.sh: 180 files, 2612 tests, CHECK EXIT 0
- GM_STEPS=1-6,25,53: ALL PASSED, 309 checks
- New e2e module 53 with 13 checks, all with the real mouse. Expected
  values come from its own Bezier evaluation.
- Counter-checks bite: without Bezier evaluation 4 unit tests fail,
  without handle clamping 2. In the browser, a key that cannot find its
  curve, a click that sets no point and a handle drag that moves the
  point all turn checks red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 15:58:05 +02:00
1 parent 320d3e27e1
commit 393b8ec7f9
41 files changed
+1981 -67

No files matched your search

+2 -1
View File
@@ -13,6 +13,7 @@ import {
trackValueAt,
} from '../model/animation';
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
import { curveLookup } from '../model/curve';
import { findAsset, type Project } from '../model/Project';
import type { PropertyValue } from '../model/Properties';
import { coerceValue } from '../properties/PropertySchema';
@@ -233,7 +234,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
requireFrame(this.frame, 'A key sits on a whole frame from 0');
const def = trackDef(project, asset, track);
if (!def) throw new CommandError('The row aims at something that is gone');
const current = trackValueAt(track.keys, def.kind, this.frame);
const current = trackValueAt(track.keys, def.kind, this.frame, false, curveLookup(project));
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
+4
View File
@@ -9,6 +9,7 @@ import type {
VariablesAsset,
} from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { linearCurve } from '../model/curve';
import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
import { isSoundFile } from '../model/sound';
@@ -71,6 +72,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'palette', colors: [...DEFAULT_PALETTE] };
case 'animation':
return { ...base, type: 'animation', root: '', tracks: [] };
// Linear until someone bends it: a key that picks a new curve still moves as it did.
case 'curve':
return { ...base, type: 'curve', points: linearCurve() };
case 'tile':
return { ...base, type: 'tile', pictures: [] };
// A tilemap without a layer could not be painted on, so it starts with one.
+39
View File
@@ -0,0 +1,39 @@
/**
* Editing the points of a Curve asset (#247). One gesture is one command, as with a vector art ring:
* the whole point list is written once when the pointer lets go, so an undo takes the drag back.
* Whatever is written is held to the curve's rules first (`sanitizedCurve`), so no command – and no
* hand-made list from a preset – can leave a curve that runs backwards in `t`.
*/
import type { CurveAsset, VectorPoint } from '../model/Asset';
import { sanitizedCurve } from '../model/curve';
import type { Project } from '../model/Project';
import type { Command } from './Command';
import { requireCurve } from './require';
export class SetCurvePointsCommand implements Command {
readonly label: string;
private previous: VectorPoint[] = [];
constructor(
private readonly assetId: string,
private readonly points: readonly VectorPoint[],
label = 'Edit curve',
) {
this.label = label;
}
/** Nothing to do when the curve already reads exactly like this – no empty undo step. */
static changes(asset: CurveAsset, points: readonly VectorPoint[]): boolean {
return JSON.stringify(asset.points) !== JSON.stringify(sanitizedCurve(points));
}
execute(project: Project): void {
const asset = requireCurve(project, this.assetId);
this.previous = asset.points;
asset.points = sanitizedCurve(this.points);
}
undo(project: Project): void {
requireCurve(project, this.assetId).points = this.previous;
}
}
+7
View File
@@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type {
Asset,
CurveAsset,
TileAsset,
BehaviorAsset,
StateMachineAsset,
@@ -121,6 +122,12 @@ export function requireVectorShape(project: Project, assetId: string, shapeId: s
return shape;
}
export function requireCurve(project: Project, assetId: string): CurveAsset {
const asset = findAssetOf(project, assetId, 'curve');
if (!asset) throw new CommandError(`Curve ${assetId} not found`);
return asset;
}
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
+6 -3
View File
@@ -1,3 +1,4 @@
import type { VectorPoint } from '../model/Asset';
import type { PropertyBag } from '../model/Properties';
import type { Graph } from './Graph';
@@ -35,12 +36,14 @@ export interface StateMachine {
}
/**
* What a node may look up in the project around its graph: the behaviors it can run and the
* state machines it can read. One handle, so no caller can resolve a node with half of it.
* What a node may look up in the project around its graph: the behaviors it can run, the state
* machines it can read and the curves a `Lerp` eases along (#247). One handle, so no caller can
* resolve a node with half of it.
*/
export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null;
machine(assetId: string): StateMachine | null;
curve(assetId: string): readonly VectorPoint[] | null;
}
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null };
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null };
+4 -2
View File
@@ -13,6 +13,7 @@ import {
} from './Graph';
import type { GraphLookup } from './lookup';
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
import { lerpProblem } from './nodes/data/Lerp';
import { agreedType, type SharedPortGroup } from './portTypes';
export const BEHAVIOR_START = 'BehaviorStart';
@@ -273,10 +274,11 @@ export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): strin
/**
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
* *different* graph, so it is the one node that can look perfectly healthy and still be dead. A
* `Lerp` depends on another asset as well, the curve it eases along (#247), and says so here too.
*/
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
if (node.type !== RUN_BEHAVIOR) return null;
if (node.type !== RUN_BEHAVIOR) return lerpProblem(node, lookup);
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
if (!id) return 'No behavior picked';
if (id === ownerId) return 'A behavior cannot run itself';
+23 -5
View File
@@ -1,7 +1,10 @@
import type { GraphNode } from '../../Graph';
import type { GraphLookup } from '../../lookup';
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { num, withDefault } from '../shared';
import { curveValue, LERP, LERP_CURVE } from '../../../model/curve';
import { textOf } from '../../../model/Properties';
/** Quadratic curves over t in 0…1, each running from 0 to 1. */
@@ -13,10 +16,12 @@ const EASES: Record<string, (t: number) => number> = {
};
export const Lerp: NodeDef = {
type: 'Lerp',
type: LERP,
label: 'Lerp',
category: 'data',
description: 'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1.',
description:
'Blends from a to b as t runs from 0 to 1, along the chosen ease. t is held in 0…1. With Ease ' +
'set to Curve it follows the Curve asset picked below, and may overshoot b.',
inputs: [withDefault(num('a'), 0), withDefault(num('b'), 1), withDefault(num('t'), 0)],
outputs: [num('result')],
params: [
@@ -30,13 +35,26 @@ export const Lerp: NodeDef = {
{ value: 'in', label: 'In' },
{ value: 'out', label: 'Out' },
{ value: 'inOut', label: 'In-out' },
{ value: 'curve', label: 'Curve' },
],
},
{ key: LERP_CURVE, label: 'Curve', kind: 'asset', default: '', assetTypes: ['curve'] },
],
run: (_ctx, inputs, params) => {
hiddenParams: (params) => (textOf(params.ease) === 'curve' ? [] : [LERP_CURVE]),
run: (_ctx, inputs, params, _def, _entry, lookup) => {
const a = asNumber(inputs.a);
const t = Math.min(Math.max(asNumber(inputs.t), 0), 1);
const ease = EASES[textOf(params.ease)] ?? EASES.linear!;
return { outputs: { result: a + (asNumber(inputs.b) - a) * ease(t) } };
const ease = textOf(params.ease);
const points = ease === 'curve' ? lookup.curve(textOf(params[LERP_CURVE] ?? '')) : null;
const share = points ? curveValue(points, t) : (EASES[ease] ?? EASES.linear!)(t);
return { outputs: { result: a + (asNumber(inputs.b) - a) * share } };
},
};
/** Why a Lerp set to Curve runs linear instead, or null when it eases as it says. */
export function lerpProblem(node: GraphNode, lookup: GraphLookup): string | null {
if (node.type !== LERP || textOf(node.params.ease) !== 'curve') return null;
const id = textOf(node.params[LERP_CURVE] ?? '');
if (!id) return 'No curve picked – runs linear';
return lookup.curve(id) ? null : 'That curve is gone – runs linear';
}
+19 -1
View File
@@ -160,9 +160,14 @@ 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>`.
* `curve:<id>` blends along a Curve asset (#247); an empty id is "Curve" with none picked yet.
*/
export type KeyInterpolation =
'linear' | 'step' | `ease-${EasingDirection}` | `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`;
| 'linear'
| 'step'
| `ease-${EasingDirection}`
| `${Exclude<EasingCurve, 'quad'>}-${EasingDirection}`
| `curve:${string}`;
/**
* A value on a whole frame. Its interpolation rules the way to the next key. A value that cannot
@@ -424,6 +429,18 @@ export interface PaletteAsset extends AssetBase {
colors: number[];
}
/**
* An easing drawn by hand (#247). The points are those of an open vector art line, read as a graph:
* `x` is the share of the time (`t`, 0…1) and `y` the share of the way (`v`), which may leave 0…1 to
* overshoot. The first point sits at `t` 0 and the last at 1, the points never overtake each other
* in `t` and a handle never reaches past a neighbour – so the curve has one value for every `t`.
* `sanitizedCurve` holds every stored curve to that.
*/
export interface CurveAsset extends AssetBase {
type: 'curve';
points: VectorPoint[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -449,6 +466,7 @@ export type Asset =
| PixelArtAsset
| PaletteAsset
| AnimationAsset
| CurveAsset
| TileAsset
| TilemapAsset
| StateMachineAsset
+5 -1
View File
@@ -13,6 +13,7 @@ export const ASSET_TYPES = [
'pixelart',
'palette',
'animation',
'curve',
'tile',
'tilemap',
'statemachine',
@@ -39,6 +40,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
pixelart: 'Pixel art',
palette: 'Palette',
animation: 'Animation',
curve: 'Curve',
tile: 'Tile',
tilemap: 'Tilemap',
statemachine: 'State machine',
@@ -54,7 +56,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
* is the tilemap.
* is the tilemap. A curve is the way a key or a `Lerp` blends, picked by id like a state machine.
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -64,6 +66,7 @@ export type PlaceableAssetType = Exclude<
| 'pixelart'
| 'palette'
| 'animation'
| 'curve'
| 'tile'
| 'statemachine'
| 'shader'
@@ -79,6 +82,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'pixelart',
'palette',
'animation',
'curve',
'tile',
'statemachine',
'shader',
+38 -11
View File
@@ -11,6 +11,7 @@ import type {
KeyInterpolation,
} from './Asset';
import type { AssetType } from './AssetTypes';
import { type CurveLookup, curveValue, NO_CURVES } from './curve';
import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project';
@@ -115,7 +116,7 @@ export function blends(kind: string): boolean {
return kind === 'number' || kind === 'color';
}
export type EasingShape = 'linear' | 'step' | EasingCurve;
export type EasingShape = 'linear' | 'step' | EasingCurve | 'curve';
/** 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 }[] = [
@@ -131,6 +132,7 @@ export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [
{ value: 'back', label: 'Back' },
{ value: 'elastic', label: 'Elastic' },
{ value: 'bounce', label: 'Bounce' },
{ value: 'curve', label: 'Curve' },
];
/** The second field, for a curve only. */
@@ -170,12 +172,18 @@ 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`. */
/**
* A stored interpolation split into the inspector's fields; unknown text reads as `linear`. `curve`
* is the id of the Curve asset (#247), `''` while none is picked.
*/
export function easingOf(interpolation: KeyInterpolation): {
shape: EasingShape;
direction?: EasingDirection;
curve?: string;
} {
if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation };
if (interpolation.startsWith(CURVE_PREFIX))
return { shape: 'curve', curve: interpolation.slice(CURVE_PREFIX.length) };
const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation);
if (!match) return { shape: 'linear' };
const curve = match[1]!;
@@ -184,16 +192,23 @@ export function easingOf(interpolation: KeyInterpolation): {
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 {
const CURVE_PREFIX = 'curve:';
/** The stored form of the fields; a curve without a direction eases both ways. */
export function easingValue(
shape: EasingShape,
direction: EasingDirection = 'in-out',
curve = '',
): KeyInterpolation {
if (shape === 'linear' || shape === 'step') return shape;
if (shape === 'curve') return `${CURVE_PREFIX}${curve}`;
return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`;
}
export function isKeyInterpolation(value: unknown): value is KeyInterpolation {
if (typeof value !== 'string') return false;
const { shape, direction } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction) === value;
const { shape, direction, curve } = easingOf(value as KeyInterpolation);
return easingValue(shape, direction, curve) === value;
}
export function interpolationOf(key: AnimationKey): KeyInterpolation {
@@ -203,11 +218,20 @@ 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. 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.
* A Curve asset that is gone, or none picked, blends linearly.
*/
export function easedShare(interpolation: KeyInterpolation, t: number): number {
const { shape, direction } = easingOf(interpolation);
export function easedShare(
interpolation: KeyInterpolation,
t: number,
curves: CurveLookup = NO_CURVES,
): number {
const { shape, direction, curve } = easingOf(interpolation);
if (shape === 'linear') return t;
if (shape === 'step') return 0;
if (shape === 'curve') {
const points = curves(curve ?? '');
return points ? curveValue(points, t) : t;
}
const easeIn = EASE_IN[shape];
switch (direction ?? 'in-out') {
case 'in':
@@ -234,13 +258,15 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
* A row's value at a (fractional) frame: the first value before the first key, the last after the
* last one. Between two keys the earlier one's interpolation decides the way. A value that jumps –
* one that cannot blend, or a `step` key – holds until the next key in the direction of play: played
* in `reverse`, that is the key above. `undefined` only for a row without keys.
* in `reverse`, that is the key above. `undefined` only for a row without keys. `curves` finds the
* Curve assets a key may blend along.
*/
export function trackValueAt(
keys: readonly AnimationKey[],
kind: string,
frame: number,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
const first = keys[0];
if (!first) return undefined;
@@ -253,7 +279,7 @@ export function trackValueAt(
if (!blends(kind) || interpolation === 'step')
return reverse && frame > prev.frame ? next.value : prev.value;
const t = (frame - prev.frame) / (next.frame - prev.frame);
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t));
return blendValue(kind, prev.value, next.value, easedShare(interpolation, t, curves));
}
return keys[keys.length - 1]!.value;
}
@@ -270,8 +296,9 @@ export function trackWriteAt(
frame: number,
base: PropertyValue | undefined,
reverse = false,
curves: CurveLookup = NO_CURVES,
): PropertyValue | undefined {
const value = trackValueAt(track.keys, def.kind, frame, reverse);
const value = trackValueAt(track.keys, def.kind, frame, reverse, curves);
if (value === undefined) return undefined;
if (def.kind !== 'number') return value;
const offset = track.relative && typeof base === 'number';
+17 -8
View File
@@ -14,6 +14,7 @@ import {
trackWriteAt,
} from './animation';
import type { AnimationAsset, AnimationTrack, Asset } from './Asset';
import { type CurveLookup, curveLookup } from './curve';
import { instanceChildId, objectRef } from './instance';
import { findAsset, type ObjectHolder, type Project } from './Project';
import { resolveSceneObjectProps } from '../properties/resolve';
@@ -37,9 +38,10 @@ export function previewHolder(
if (!root) return undefined;
const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation);
const holder = root.type === 'scene' ? sceneCopy(root) : placedHolder(root);
const at: ReadAt = { frame, reverse, curves: curveLookup(project) };
for (const track of reset ? [] : animation.tracks) {
if (root.type === 'scene' && track.target === '') writeScene(holder, track, frame, reverse);
else writeObject(project, holder, previewObjectId(root, track.target), track, frame, reverse);
if (root.type === 'scene' && track.target === '') writeScene(holder, track, at);
else writeObject(project, holder, previewObjectId(root, track.target), track, at);
}
return holder;
}
@@ -68,11 +70,19 @@ function placedHolder(root: Asset): ObjectHolder {
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
}
/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */
interface ReadAt {
frame: number;
reverse: boolean;
curves: CurveLookup;
}
/** A row on the scene itself: its value goes into the copy's own properties. */
function writeScene(holder: ObjectHolder, track: AnimationTrack, frame: number, reverse: boolean): void {
function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): void {
const def = animatableDefs('scene').find((d) => d.key === track.property);
if (!def) return;
const value = trackWriteAt(track, def, frame, holder.props[track.property] ?? def.default, reverse);
const base = holder.props[track.property] ?? def.default;
const value = trackWriteAt(track, def, at.frame, base, at.reverse, at.curves);
if (value !== undefined) holder.props[track.property] = value;
}
@@ -81,8 +91,7 @@ function writeObject(
holder: ObjectHolder,
id: string,
track: AnimationTrack,
frame: number,
reverse: boolean,
at: ReadAt,
): void {
const ref = objectRef(project, holder, id);
const asset = ref && findAsset(project, ref.object.assetId);
@@ -93,10 +102,10 @@ function writeObject(
if (!def) return;
const key = track.property;
if (isPositionRow(asset.type, key)) {
const value = trackWriteAt(track, def, frame, ref.object.position[key], reverse);
const value = trackWriteAt(track, def, at.frame, ref.object.position[key], at.reverse, at.curves);
if (value !== undefined) ref.setPosition({ ...ref.object.position, [key]: Number(value) });
return;
}
const value = trackWriteAt(track, def, frame, props[key], reverse);
const value = trackWriteAt(track, def, at.frame, props[key], at.reverse, at.curves);
if (value !== undefined) ref.setOverride(key, value);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Curve assets (#247): an easing drawn by hand, read wherever a key or a `Lerp` node picks one.
* One pure function evaluates it for the Curve tab, the animation player and the graph alike.
*
* A segment is a cubic Bezier from one point to the next, a line where neither end has a handle –
* the same rule vector art draws by. It is solved for `t` like CSS `cubic-bezier`: with both inner
* control points between the ends in `t`, `t` rises monotonically along the segment, so bisection
* finds the one place a `t` is reached.
*/
import { isGraphAsset, type VectorPoint } from './Asset';
import { findAssetOf, type Project } from './Project';
import type { Vec2 } from './Properties';
import { cloneVectorPoint } from './vectorart';
/** The node type that eases along a curve, and its setting naming the curve. */
export const LERP = 'Lerp';
export const LERP_CURVE = 'curve';
/** How many animation keys and `Lerp` nodes ease along a curve – what deleting it has to warn about. */
export function curveUsages(project: Project, curveId: string): { keys: number; nodes: number } {
let keys = 0;
let nodes = 0;
for (const asset of project.assets) {
if (asset.type === 'animation')
for (const track of asset.tracks)
keys += track.keys.filter((key) => key.interpolation === `curve:${curveId}`).length;
if (isGraphAsset(asset))
nodes += asset.graph.nodes.filter((n) => n.type === LERP && n.params[LERP_CURVE] === curveId).length;
}
return { keys, nodes };
}
/** The points of the curve an id names; undefined once it is gone. */
export type CurveLookup = (curveId: string) => readonly VectorPoint[] | undefined;
export const NO_CURVES: CurveLookup = () => undefined;
export function curveLookup(project: Project): CurveLookup {
return (curveId) => (curveId ? findAssetOf(project, curveId, 'curve')?.points : undefined);
}
/** What a new curve is: a straight line from (0, 0) to (1, 1), the same as `linear`. */
export function linearCurve(): VectorPoint[] {
return [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];
}
/** Halvings of the segment; 2^-40 of a segment is far below anything a value could show. */
const BISECTIONS = 40;
/**
* The value at `t`, held to 0…1. Between two points the segment decides; a segment without width –
* two points on one `t` – is a jump, and the later point's value wins from there on.
*/
export function curveValue(points: readonly VectorPoint[], t: number): number {
const first = points[0];
if (!first) return t;
const at = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), 1);
if (at <= first.x) return first.y;
for (let i = 1; i < points.length; i++) {
const to = points[i]!;
if (at > to.x) continue;
return segmentValue(points[i - 1]!, to, at);
}
return points[points.length - 1]!.y;
}
function segmentValue(from: VectorPoint, to: VectorPoint, t: number): number {
const span = to.x - from.x;
if (span <= 0) return to.y;
if (!from.out && !to.in) return from.y + ((to.y - from.y) * (t - from.x)) / span;
// Held between the ends in `t`, whatever is stored: that is what keeps the solve unique.
const c1x = clamp(from.x + (from.out?.x ?? 0), from.x, to.x);
const c2x = clamp(to.x + (to.in?.x ?? 0), from.x, to.x);
const c1y = from.y + (from.out?.y ?? 0);
const c2y = to.y + (to.in?.y ?? 0);
let lo = 0;
let hi = 1;
for (let step = 0; step < BISECTIONS; step++) {
const mid = (lo + hi) / 2;
if (cubic(from.x, c1x, c2x, to.x, mid) < t) lo = mid;
else hi = mid;
}
return cubic(from.y, c1y, c2y, to.y, (lo + hi) / 2);
}
function cubic(a: number, b: number, c: number, d: number, s: number): number {
const u = 1 - s;
return u * u * u * a + 3 * u * u * s * b + 3 * u * s * s * c + s * s * s * d;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* The curve as the rules want it, whatever was handed in. At least two points; sorted by `t`, `t`
* held to 0…1, the first at 0 and the last at 1. The first point has no handle in and the last none
* out. A handle pointing backwards in `t` is turned upright, and one reaching past its neighbour is
* shortened along its own direction, so a smooth point stays smooth. What does not come out a number
* is dropped; with fewer than two points left the curve is linear.
*/
export function sanitizedCurve(points: readonly VectorPoint[]): VectorPoint[] {
const kept = points
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
.map((p) => ({ ...cloneVectorPoint(p), x: clamp(p.x, 0, 1) }))
.sort((a, b) => a.x - b.x);
if (kept.length < 2) return linearCurve();
kept[0]!.x = 0;
kept[kept.length - 1]!.x = 1;
for (const [i, point] of kept.entries()) {
const before = kept[i - 1];
const after = kept[i + 1];
const handleIn = before && point.in ? heldHandle(point.in, before.x - point.x) : undefined;
const handleOut = after && point.out ? heldHandle(point.out, after.x - point.x) : undefined;
if (handleIn) point.in = handleIn;
else delete point.in;
if (handleOut) point.out = handleOut;
else delete point.out;
if (!point.in && !point.out) delete point.mode;
}
return kept;
}
/** A handle kept on its own side of the point, reaching `reach` in `t` at most (negative for `in`). */
function heldHandle(handle: Vec2, reach: number): Vec2 | undefined {
const finite = Number.isFinite(handle.x) && Number.isFinite(handle.y);
if (!finite) return undefined;
let { x, y } = handle;
if (x * reach < 0) x = 0;
if (Math.abs(x) > Math.abs(reach)) {
const scale = Math.abs(reach) / Math.abs(x);
x *= scale;
y *= scale;
}
return Math.hypot(x, y) < 1e-9 ? undefined : { x, y };
}
/** The lowest and highest value the curve can reach – its points and handles – and never less than 0…1. */
export function curveExtent(points: readonly VectorPoint[]): { min: number; max: number } {
let min = 0;
let max = 1;
for (const point of points)
for (const y of [point.y, point.y + (point.in?.y ?? 0), point.y + (point.out?.y ?? 0)]) {
min = Math.min(min, y);
max = Math.max(max, y);
}
return { min, max };
}
+111
View File
@@ -0,0 +1,111 @@
/**
* The built-in eases as starting points for a Curve asset (#247). Each is traced by a few Bezier
* segments: the points sit on the ease, and each handle points along the ease's slope on its side, a
* third of the way to the neighbour (a cubic Hermite piece). A segment that strays more than
* `TOLERANCE` from the ease is halved, and only that one – so a quad keeps its single segment, and
* circ gets ever shorter ones only towards its upright end.
*/
import type { EasingDirection, VectorPoint } from './Asset';
import type { Vec2 } from './Properties';
import { EASING_DIRECTIONS, EASING_SHAPES, easedShare, type EasingShape, easingValue } from './animation';
import { curveValue, linearCurve } from './curve';
export interface CurvePreset {
label: string;
/** The ease it traces; presets of one shape share it, so a menu can group them. */
shape: EasingShape;
build(): VectorPoint[];
}
const TOLERANCE = 0.004;
/** Enough for a bounce; a curve with more points than this is no longer one to edit by hand. */
const MAX_POINTS = 64;
const MAX_ROUNDS = 16;
/** Where each segment is compared with the ease. */
const SAMPLES = 24;
/** The step of the one-sided slopes: small, and never onto the point itself, where expo jumps to 0. */
const H = 1e-6;
export function curvePresets(): CurvePreset[] {
const presets: CurvePreset[] = [{ label: 'Linear', shape: 'linear', build: linearCurve }];
for (const { value: shape, label } of EASING_SHAPES) {
if (shape === 'linear' || shape === 'step' || shape === 'curve') continue;
for (const direction of EASING_DIRECTIONS)
presets.push({
label: `${label} ${direction.label.toLowerCase()}`,
shape,
build: () => easeCurve(shape, direction.value),
});
}
return presets;
}
export function easeCurve(shape: EasingShape, direction: EasingDirection): VectorPoint[] {
const interpolation = easingValue(shape, direction);
return curveFromEase((t) => easedShare(interpolation, t));
}
export function curveFromEase(ease: (t: number) => number): VectorPoint[] {
let at = [0, 1];
let points = traced(ease, at);
for (let round = 0; round < MAX_ROUNDS; round++) {
const next = [0];
for (let i = 1; i < at.length; i++) {
const strays = segmentGap(points[i - 1]!, points[i]!, ease) > TOLERANCE;
if (strays && next.length + at.length - i < MAX_POINTS) next.push((at[i - 1]! + at[i]!) / 2);
next.push(at[i]!);
}
if (next.length === at.length) break;
at = next;
points = traced(ease, at);
}
return points;
}
function traced(ease: (t: number) => number, at: readonly number[]): VectorPoint[] {
const value = (x: number) => clean(ease(x));
return at.map((x, i) => {
const point: VectorPoint = { x, y: value(x) };
const before = at[i - 1];
const after = at[i + 1];
const slopeIn = before === undefined ? null : (ease(x - H) - ease(x - 2 * H)) / H;
const slopeOut = after === undefined ? null : (ease(x + 2 * H) - ease(x + H)) / H;
if (before !== undefined && slopeIn !== null)
point.in = handle((before - x) / 3, slopeIn, point.y - value(before));
if (after !== undefined && slopeOut !== null)
point.out = handle((after - x) / 3, slopeOut, value(after) - point.y);
if (
slopeIn !== null &&
slopeOut !== null &&
Math.abs(slopeIn - slopeOut) < 1e-3 * Math.max(1, Math.abs(slopeOut))
)
point.mode = 'smooth';
return point;
});
}
/**
* A handle along `slope`, reaching `reach` in `t`. Where the ease stands upright – circ at its steep
* end – the slope is huge, so the handle is held to the rise of its segment plus one step, keeping
* its direction: nearly vertical, as the ease is there, instead of shooting off the graph.
*/
function handle(reach: number, slope: number, rise: number): Vec2 {
const y = slope * reach;
const most = Math.abs(rise) + Math.abs(reach) * 3;
const scale = Math.abs(y) > most ? most / Math.abs(y) : 1;
return { x: reach * scale, y: y * scale };
}
/** Rounding noise off, so an ease that starts at 0 starts at 0 and not at 2e-16. */
function clean(value: number): number {
return Math.round(value * 1e12) / 1e12;
}
function segmentGap(from: VectorPoint, to: VectorPoint, ease: (t: number) => number): number {
let gap = 0;
for (let i = 1; i < SAMPLES; i++) {
const t = from.x + ((to.x - from.x) * i) / SAMPLES;
gap = Math.max(gap, Math.abs(curveValue([from, to], t) - ease(t)));
}
return gap;
}
+1
View File
@@ -29,5 +29,6 @@ export function graphLookup(project: Project): GraphLookup {
start: startStateOf(asset.graph, asset.start),
};
},
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
};
}
+2
View File
@@ -405,6 +405,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// The colours are intrinsic data on the asset (see PaletteAsset).
palette: [],
// The points are intrinsic data on the asset (see CurveAsset).
curve: [],
// The root and the rows are intrinsic data on the asset (see AnimationAsset).
animation: [
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
+23 -15
View File
@@ -68,6 +68,7 @@ import { canvasSide, PALETTE_MAX } from '../model/pixelart';
import { isCelFile } from '../model/pixelCel';
import { newId } from '../ids';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { sanitizedCurve } from '../model/curve';
import { hasParentCycle } from '../model/walk';
import {
DECLARED_PORT_KINDS,
@@ -230,6 +231,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, ...normalizePixelArt(a, takenIds) });
} else if (type === 'palette') {
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
} else if (type === 'curve') {
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });
} else if (type === 'animation') {
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
@@ -702,22 +705,8 @@ function normalizeShapes(raw: unknown): VectorShape[] {
const rings: VectorRing[] = [];
for (const r of Array.isArray(s.rings) ? s.rings : []) {
const ring = asRecord(r);
const points: VectorPoint[] = [];
for (const p of Array.isArray(ring.points) ? ring.points : []) {
const point = asRecord(p);
const x = asFinite(point.x);
const y = asFinite(point.y);
if (x === null || y === null) continue;
const kept: VectorPoint = { x, y };
for (const side of ['in', 'out'] as const) {
const handle = normalizeHandle(point[side]);
if (handle) kept[side] = handle;
}
if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode;
points.push(kept);
}
// Closed is what a shape usually is, and what every ring written by the editor says.
rings.push({ points, closed: ring.closed !== false });
rings.push({ points: normalizePoints(ring.points), closed: ring.closed !== false });
}
const shape: VectorShape = {
id: s.id,
@@ -740,6 +729,25 @@ function normalizeShapes(raw: unknown): VectorShape[] {
return shapes;
}
/** The points of a ring or of a curve (#247); the rules are those of `normalizeShapes`. */
function normalizePoints(raw: unknown): VectorPoint[] {
const points: VectorPoint[] = [];
for (const p of Array.isArray(raw) ? raw : []) {
const point = asRecord(p);
const x = asFinite(point.x);
const y = asFinite(point.y);
if (x === null || y === null) continue;
const kept: VectorPoint = { x, y };
for (const side of ['in', 'out'] as const) {
const handle = normalizeHandle(point[side]);
if (handle) kept[side] = handle;
}
if (point.mode === 'smooth' || point.mode === 'symmetric') kept.mode = point.mode;
points.push(kept);
}
return points;
}
/**
* A number as written, or null – unlike `toNumber`, which reads anything unusable as 0. A coordinate
* of 0 is a place on the canvas, so "broken" and "the left edge" must not come out the same.