anim-engine 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +21 -0
- package/README.md +639 -0
- package/dist/animation/create-animation.d.ts +24 -0
- package/dist/animation/create-animation.js +336 -0
- package/dist/animation/update.d.ts +12 -0
- package/dist/animation/update.js +22 -0
- package/dist/benchmarks/easing.bench.d.ts +1 -0
- package/dist/benchmarks/vs-gsap.bench.d.ts +1 -0
- package/dist/color/lerp-oklab.d.ts +26 -0
- package/dist/color/lerp-oklab.js +182 -0
- package/dist/easing/easing.d.ts +16 -0
- package/dist/easing/easing.js +154 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +10 -0
- package/dist/lerp/create-lerp.d.ts +9 -0
- package/dist/lerp/create-lerp.js +65 -0
- package/dist/lerp/step.d.ts +14 -0
- package/dist/lerp/step.js +14 -0
- package/dist/shared/internal.d.ts +4 -0
- package/dist/shared/types.d.ts +29 -0
- package/dist/smooth-clamp/smooth-clamp.d.ts +13 -0
- package/dist/smooth-clamp/smooth-clamp.js +22 -0
- package/dist/smooth-damp/create-smooth-damp.d.ts +10 -0
- package/dist/smooth-damp/create-smooth-damp.js +63 -0
- package/dist/smooth-damp/step.d.ts +13 -0
- package/dist/smooth-damp/step.js +26 -0
- package/dist/spring/create-spring.d.ts +11 -0
- package/dist/spring/create-spring.js +67 -0
- package/dist/spring/verlet.d.ts +13 -0
- package/dist/spring/verlet.js +9 -0
- package/dist/ticker/get-ticker.d.ts +5 -0
- package/dist/ticker/get-ticker.js +10 -0
- package/dist/ticker/ticker.d.ts +22 -0
- package/dist/ticker/ticker.js +74 -0
- package/dist/timeline/create-timeline.d.ts +25 -0
- package/dist/timeline/create-timeline.js +139 -0
- package/package.json +47 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Luke Carl Thompson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
# anim-engine
|
|
2
|
+
|
|
3
|
+
**Renderer-agnostic animation for JavaScript runtimes.** A fast, lightweight, pure-numeric animation engine.
|
|
4
|
+
|
|
5
|
+
- [Getting started](#getting-started)
|
|
6
|
+
- [Design](#design)
|
|
7
|
+
- [Key advantages](#key-advantages)
|
|
8
|
+
- [Primitives](#primitives)
|
|
9
|
+
- [Usage](#usage)
|
|
10
|
+
- [createAnimation (tween)](#createanimation)
|
|
11
|
+
- [createAnimation (keyframes)](#keyframes)
|
|
12
|
+
- [Re-playing and sequencing](#re-playing-and-sequencing)
|
|
13
|
+
- [createTimeline](#createtimeline)
|
|
14
|
+
- [Continuous primitives](#continuous-primitives)
|
|
15
|
+
- [createSmoothClamp](#createsmoothclamp)
|
|
16
|
+
- [Color](#color)
|
|
17
|
+
- [Ticker](#ticker)
|
|
18
|
+
- [Easing](#easing)
|
|
19
|
+
- [Dynamic values](#dynamic-values)
|
|
20
|
+
- [Benchmarks](#benchmarks)
|
|
21
|
+
- [Game engine integration](#game-engine-integration)
|
|
22
|
+
- [API Reference](#api-reference)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
|
|
25
|
+
## Getting started
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install anim-engine
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
ESM only. Tree-shakeable — import only what you use.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createAnimation, getTicker } from "anim-engine";
|
|
35
|
+
|
|
36
|
+
// The ticker drives all animations. Start it once.
|
|
37
|
+
getTicker().start();
|
|
38
|
+
|
|
39
|
+
const anim = createAnimation({
|
|
40
|
+
from: 0,
|
|
41
|
+
to: 100,
|
|
42
|
+
durationMs: 1000,
|
|
43
|
+
ease: "outCubic",
|
|
44
|
+
onUpdate: (value) => (sprite.x = value),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await anim.play();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Design
|
|
51
|
+
|
|
52
|
+
Anim Engine is built around three simple mental models that compose naturally:
|
|
53
|
+
|
|
54
|
+
- **Animation** — a timed tween from value A to value B with easing and delay. The atomic unit of motion.
|
|
55
|
+
- **Keyframes** — multi-segment interpolation that describes what a single value does over time, with per-segment easing and millisecond timing.
|
|
56
|
+
- **Timeline** — orchestration of multiple animations running in parallel, sequence, or staggered offset. Composes tweens and keyframes.
|
|
57
|
+
|
|
58
|
+
Beyond timed animation, the engine provides **continuous primitives** — spring physics, smooth damp, and exponential lerp — for natural, target-chasing motion without a fixed duration.
|
|
59
|
+
|
|
60
|
+
### Why numbers only?
|
|
61
|
+
|
|
62
|
+
By restricting itself to numeric values, the engine eliminates string parsing, color-space branching, and transform-matrix overhead. You get a minimal surface area that is easy to optimise, tree-shake, reason about and fit into any renderer. Color interpolation (Oklab) is provided as a pure function — you compose it yourself into an `onUpdate` callback.
|
|
63
|
+
|
|
64
|
+
## Key advantages
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| ⚡ **Fast** | 1.7–9.9× faster than GSAP across tweens, keyframes, and concurrent animations (see [benchmarks](#benchmarks)). |
|
|
69
|
+
| 🪶 **Lightweight** | Tree-shakeable ESM — import only what you use. No DOM, no canvas, no dependencies. |
|
|
70
|
+
| 🎯 **Numbers only** | A single numeric type for all values. No strings, no transforms, no branches. Predictable performance. |
|
|
71
|
+
| 🔌 **Renderer-agnostic** | Feed values to PixiJS, ThreeJS, DOM, canvas2d, or WebGL. Same API everywhere. |
|
|
72
|
+
| 🧩 **Composable models** | Animations, keyframes, and timelines compose cleanly — put tweens inside timelines, nest keyframes anywhere. |
|
|
73
|
+
| 🔄 **Continuous primitives** | Spring, smooth damp, and lerp chase live targets with zero setup — pass `() => value` for dynamic targets. |
|
|
74
|
+
| 🚫 **No GC pressure** | Zero object allocations in hot update paths. State is mutated in place. |
|
|
75
|
+
| 🎨 **Perceptual color** | Oklab interpolation via `lerpOklab` — no muddy browns. Compose it into any `onUpdate`. |
|
|
76
|
+
| 📐 **TypeScript-first** | Full type exports, exhaustive discriminated unions, no `any`. |
|
|
77
|
+
| 🔧 **Ticker control** | Bring your own game loop or use the built-in rAF ticker. Explicit — never auto-starts. |
|
|
78
|
+
|
|
79
|
+
## Primitives
|
|
80
|
+
|
|
81
|
+
### Timed (return `Animation`)
|
|
82
|
+
|
|
83
|
+
| Primitive | Returns | Description |
|
|
84
|
+
| ------------------------------------------- | ------------ | -------------------------------------------------------- |
|
|
85
|
+
| [`createAnimation`](#createanimation) | `Animation` | Timed tween from A to B with easing and delay |
|
|
86
|
+
| [`createAnimation` (keyframes)](#keyframes) | `Animation` | Multi-segment interpolation with per-segment easing |
|
|
87
|
+
| [`createTimeline`](#createtimeline) | `Timeline` | Orchestrate multiple animations on a shared timeline |
|
|
88
|
+
|
|
89
|
+
Animations have `play()`/`pause()`/`resume()`/`stop()`/`skipToEnd()`/`kill()` controls, return a `Promise` from `play()`, and emit `onUpdate`/`onEnded`/`onProgress` callbacks.
|
|
90
|
+
|
|
91
|
+
### Continuous (return `Interpolation`)
|
|
92
|
+
|
|
93
|
+
| Primitive | Returns | Description |
|
|
94
|
+
| --------------------------------------- | --------------- | ---------------------------------------------------- |
|
|
95
|
+
| [`createSpring`](#createspring) | `Interpolation` | Physics-based spring (Verlet integration) |
|
|
96
|
+
| [`createSmoothDamp`](#createsmoothdamp) | `Interpolation` | Unity-style smooth damp, parameter-free chase |
|
|
97
|
+
| [`createLerp`](#createlerp) | `Interpolation` | First-order exponential chase, single rate parameter |
|
|
98
|
+
|
|
99
|
+
Interpolations have `start()`/`stop()`/`kill()` controls, auto-start on creation, and chase a target without a fixed duration. No promise — they run until stopped.
|
|
100
|
+
|
|
101
|
+
### Utility
|
|
102
|
+
|
|
103
|
+
| Primitive | Returns | Description |
|
|
104
|
+
| ----------------------------------------- | -------------------- | --------------------------------------------- |
|
|
105
|
+
| [`createSmoothClamp`](#createsmoothclamp) | `(n: number) => n` | Asymptotic clamp — saturates toward threshold |
|
|
106
|
+
| [`lerpOklab` / `hexToRgba`](#color) | `RgbaTuple` / parser | Perceptually uniform color interpolation |
|
|
107
|
+
|
|
108
|
+
## Usage
|
|
109
|
+
|
|
110
|
+
### createAnimation
|
|
111
|
+
|
|
112
|
+
Animate a single value from `from` to `to` over `durationMs`.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { createAnimation } from "anim-engine";
|
|
116
|
+
|
|
117
|
+
const anim = createAnimation({
|
|
118
|
+
from: 0,
|
|
119
|
+
to: 100,
|
|
120
|
+
durationMs: 2000,
|
|
121
|
+
ease: "outElastic",
|
|
122
|
+
onUpdate: (value, velocity) => {
|
|
123
|
+
sprite.x = value;
|
|
124
|
+
},
|
|
125
|
+
onEnded: () => console.log("done"),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Promise-based control
|
|
129
|
+
await anim.play(); // plays, resolves when done
|
|
130
|
+
anim.pause();
|
|
131
|
+
anim.resume();
|
|
132
|
+
anim.stop(); // resets to start
|
|
133
|
+
anim.skipToEnd(); // jumps to end, resolves promise
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Single-tween options:**
|
|
137
|
+
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
| ------------ | -------------------------------------------- | ----------- | ------------------------------------------------------------ |
|
|
140
|
+
| `from` | `number \| () => number` | — | Start value (static or dynamic) |
|
|
141
|
+
| `to` | `number \| () => number` | — | End value (static or dynamic) |
|
|
142
|
+
| `durationMs` | `number` | — | Duration in milliseconds |
|
|
143
|
+
| `ease` | `EaseName \| EaseFunction` | `"inOutSine"` | Easing function or name |
|
|
144
|
+
| `delayMs` | `number` | `0` | Delay before starting |
|
|
145
|
+
| `onStarted` | `() => void` | — | Called when animation begins (after delay) |
|
|
146
|
+
| `onUpdate` | `(value: number, velocity: number) => void` | — | Called every frame with current value and velocity (units/s) |
|
|
147
|
+
| `onEnded` | `() => void` | — | Called when animation completes |
|
|
148
|
+
**Returns:** `Animation`
|
|
149
|
+
|
|
150
|
+
### Keyframes
|
|
151
|
+
|
|
152
|
+
Multi-segment animation with per-keyframe easing. The last keyframe's `at` value determines total duration. Mutually exclusive with `from`/`to`/`durationMs`.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { createAnimation } from "anim-engine";
|
|
156
|
+
|
|
157
|
+
const anim = createAnimation({
|
|
158
|
+
keyframes: [
|
|
159
|
+
{ at: 0, value: 0 },
|
|
160
|
+
{ at: 300, value: 50, ease: "outCubic" },
|
|
161
|
+
{ at: 700, value: 80, ease: "inOutQuad" },
|
|
162
|
+
{ at: 1000, value: 100 },
|
|
163
|
+
],
|
|
164
|
+
onUpdate: (value) => (sprite.x = value),
|
|
165
|
+
onProgress: (progress) => console.log(`${Math.round(progress * 100)}%`),
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Each keyframe's `at` is in milliseconds — the last keyframe's `at` sets the total duration (1000ms in this example). If no `ease` is specified, the previous segment's ease carries forward.
|
|
170
|
+
|
|
171
|
+
**Keyframe options:**
|
|
172
|
+
|
|
173
|
+
| Option | Type | Description |
|
|
174
|
+
| ------------ | ------------------------------------------- | --------------------------------------------------------- |
|
|
175
|
+
| `keyframes` | `Keyframe[]` | Array of `{ at, value, ease? }` keyframes |
|
|
176
|
+
| `onUpdate` | `(value: number, velocity: number) => void` | Called every frame with current value and velocity |
|
|
177
|
+
| `onProgress` | `(progress: number) => void` | Called every frame with 0–1 global progress |
|
|
178
|
+
| `onEnded` | `() => void` | Called when the keyframe animation completes |
|
|
179
|
+
|
|
180
|
+
**Returns:** `Animation`
|
|
181
|
+
|
|
182
|
+
### Re-playing and sequencing
|
|
183
|
+
|
|
184
|
+
Since `play()` can be called again after a tween completes, repeat and yoyo patterns are built from the public API rather than baked into the engine:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { createAnimation } from "anim-engine";
|
|
188
|
+
|
|
189
|
+
const anim = createAnimation({
|
|
190
|
+
from: 1,
|
|
191
|
+
to: 1.3,
|
|
192
|
+
durationMs: 600,
|
|
193
|
+
ease: "inOutSine",
|
|
194
|
+
onUpdate: (scale) => sprite.scale.set(scale),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Repeat — just call play() again
|
|
198
|
+
for (let i = 0; i < 3; i++) {
|
|
199
|
+
await anim.play();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Yoyo — swap from/to via dynamic accessors and re-play
|
|
203
|
+
let forward = true;
|
|
204
|
+
const bounce = createAnimation({
|
|
205
|
+
from: () => (forward ? 1 : 1.3),
|
|
206
|
+
to: () => (forward ? 1.3 : 1),
|
|
207
|
+
durationMs: 600,
|
|
208
|
+
ease: "inOutSine",
|
|
209
|
+
onUpdate: (v) => sprite.scale.set(v),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < 6; i++) {
|
|
213
|
+
forward = !forward;
|
|
214
|
+
await bounce.play();
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
For more complex sequences, use `createTimeline`:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { createAnimation, createTimeline } from "anim-engine";
|
|
222
|
+
|
|
223
|
+
const fadeIn = createAnimation({ from: 0, to: 1, durationMs: 300 });
|
|
224
|
+
const fadeOut = createAnimation({ from: 1, to: 0, durationMs: 300 });
|
|
225
|
+
|
|
226
|
+
const flash = createTimeline([
|
|
227
|
+
{ at: 0, animation: fadeIn },
|
|
228
|
+
{ gap: 0, animation: fadeOut },
|
|
229
|
+
]);
|
|
230
|
+
await flash.play();
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
### createTimeline
|
|
235
|
+
|
|
236
|
+
Compose multiple animations on a shared timeline with `at` or `gap` positions.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { createAnimation, createTimeline } from "anim-engine";
|
|
240
|
+
|
|
241
|
+
const fadeIn = createAnimation({
|
|
242
|
+
from: 0,
|
|
243
|
+
to: 1,
|
|
244
|
+
durationMs: 500,
|
|
245
|
+
onUpdate: (v) => (sprite.alpha = v),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const slideIn = createAnimation({
|
|
249
|
+
from: -100,
|
|
250
|
+
to: 0,
|
|
251
|
+
durationMs: 800,
|
|
252
|
+
ease: "outBack",
|
|
253
|
+
onUpdate: (v) => (sprite.x = v),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const timeline = createTimeline(
|
|
257
|
+
[
|
|
258
|
+
{ at: 0, animation: [fadeIn, slideIn] },
|
|
259
|
+
{ gap: 200, animation: slideIn },
|
|
260
|
+
],
|
|
261
|
+
{
|
|
262
|
+
onProgress: (progress) => console.log(`overall: ${progress}`),
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
timeline.play();
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Parameters:**
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
type TimelineLayer =
|
|
273
|
+
| { at: number; animation: Animation | Animation[] }
|
|
274
|
+
| { gap: number; animation: Animation | Animation[] };
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
| Parameter | Type | Description |
|
|
278
|
+
| -------------------- | -------------------- | -------------------------------------------------------------- |
|
|
279
|
+
| `layers` | `TimelineLayer[]` | Array of layers with `at` (absolute) or `gap` (relative) start |
|
|
280
|
+
| `options.onStarted` | `() => void` | Called when timeline begins |
|
|
281
|
+
| `options.onProgress` | `(progress) => void` | Called every frame with overall 0–1 progress |
|
|
282
|
+
| `options.onEnded` | `() => void` | Called when timeline finishes |
|
|
283
|
+
|
|
284
|
+
`gap` is relative to the end of all animations in the previous layer. Pass a single `Animation` or an array for parallel animations within the layer.
|
|
285
|
+
|
|
286
|
+
### Continuous primitives
|
|
287
|
+
|
|
288
|
+
Spring, smooth damp, and lerp are **continuous** — they auto-start and chase a target. They return `Interpolation` (no `play`/`pause`/`promise`).
|
|
289
|
+
|
|
290
|
+
#### createSpring
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import { createSpring } from "anim-engine";
|
|
294
|
+
|
|
295
|
+
const spring = createSpring({
|
|
296
|
+
to: () => 100,
|
|
297
|
+
stiffness: 180,
|
|
298
|
+
damping: 12,
|
|
299
|
+
mass: 1,
|
|
300
|
+
precision: 0.01,
|
|
301
|
+
onUpdate: (value, velocity) => {
|
|
302
|
+
sprite.x = value;
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
spring.setCurrentValue(0); // jump to start — spring chases back to 100
|
|
307
|
+
|
|
308
|
+
// Dynamic target — mouse chase
|
|
309
|
+
const targetX = { value: 0 };
|
|
310
|
+
document.addEventListener("mousemove", (e) => {
|
|
311
|
+
targetX.value = e.clientX;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const follower = createSpring({
|
|
315
|
+
to: () => targetX.value, // re-read every frame
|
|
316
|
+
stiffness: 200,
|
|
317
|
+
damping: 15,
|
|
318
|
+
onUpdate: (v) => (sprite.x = v),
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
All parameters (`stiffness`, `damping`, `mass`, `to`) accept `number | (() => number)` — resolved every frame.
|
|
323
|
+
|
|
324
|
+
**Returns:** `Interpolation` — `start()`, `stop()`, `kill()`, `setCurrentValue(value)`, `currentValue`, `velocity`, `status`. Starts at the `to()` value. Use `setCurrentValue(value)` to jump elsewhere.
|
|
325
|
+
|
|
326
|
+
#### createSmoothDamp
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { createSmoothDamp } from "anim-engine";
|
|
330
|
+
|
|
331
|
+
const damp = createSmoothDamp({
|
|
332
|
+
to: () => 100,
|
|
333
|
+
smoothTimeMs: 300,
|
|
334
|
+
maxSpeed: Infinity,
|
|
335
|
+
onUpdate: (value, velocity) => (sprite.x = value),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
damp.setCurrentValue(0); // jump to start — damp chases back to 100
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Unity-style smooth damp with Taylor-series exponential approximation. No stiffness/damping/mass to tune — just `smoothTimeMs` (milliseconds to reach target).
|
|
342
|
+
|
|
343
|
+
**Returns:** `Interpolation`
|
|
344
|
+
|
|
345
|
+
#### createLerp
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import { createLerp } from "anim-engine";
|
|
349
|
+
|
|
350
|
+
const lerp = createLerp({
|
|
351
|
+
to: () => 100,
|
|
352
|
+
smoothTimeMs: 300, // ms to reach target
|
|
353
|
+
onUpdate: (value, velocity) => (sprite.x = value),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
lerp.setCurrentValue(0); // jump to start — lerp chases back to 100
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
First-order exponential approach: `value += (target - value) * rate * deltaTime`. `smoothTimeMs` is the approximate time in milliseconds to reach the target. Frame-rate independent.
|
|
360
|
+
|
|
361
|
+
**Returns:** `Interpolation`
|
|
362
|
+
|
|
363
|
+
### createSmoothClamp
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { createSmoothClamp } from "anim-engine";
|
|
367
|
+
|
|
368
|
+
const clamp = createSmoothClamp(45); // threshold = 45 units/s
|
|
369
|
+
|
|
370
|
+
const result = clamp(1000); // → ~44.96 (approaches 45 asymptotically)
|
|
371
|
+
const result2 = clamp(-500); // → ~-44.96 (symmetric)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Uses `threshold * (normalized / (1 + |normalized|))` for asymptotic saturation. Handles `Infinity` correctly. Returns a function `(value: number) => number` with a `setCurrentValue(0)` method to reset position.
|
|
375
|
+
|
|
376
|
+
### Color
|
|
377
|
+
|
|
378
|
+
Perceptually uniform Oklab interpolation. Straight RGB lerp produces muddy transitions — red→green goes through brown, blue→yellow goes through grey. Oklab matches human perception: equal steps look like equal changes.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import { lerpOklab, hexToRgba } from "anim-engine";
|
|
382
|
+
|
|
383
|
+
hexToRgba("#ff6b6b"); // → [1, 0.42, 0.42, 1]
|
|
384
|
+
hexToRgba("#f80"); // → [1, 0.533, 0, 1] (shorthand)
|
|
385
|
+
hexToRgba("#ff804080"); // → [1, 0.502, 0.251, 0.502] (with alpha)
|
|
386
|
+
|
|
387
|
+
const fromColor = hexToRgba("#ff6b6b");
|
|
388
|
+
const toColor = hexToRgba("#4ecdc4");
|
|
389
|
+
|
|
390
|
+
createAnimation({
|
|
391
|
+
from: 0,
|
|
392
|
+
to: 1,
|
|
393
|
+
durationMs: 2000,
|
|
394
|
+
ease: "outCubic",
|
|
395
|
+
onUpdate: (t) => {
|
|
396
|
+
const [r, g, b, a] = lerpOklab(fromColor, toColor, t);
|
|
397
|
+
sprite.setColor(r, g, b, a);
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**With continuous primitives** — drive the blend factor via spring, damp, or lerp:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { createSpring, lerpOklab, hexToRgba } from "anim-engine";
|
|
406
|
+
|
|
407
|
+
const fromColor = hexToRgba("#ff6b6b");
|
|
408
|
+
const toColor = hexToRgba("#4ecdc4");
|
|
409
|
+
|
|
410
|
+
const spring = createSpring({
|
|
411
|
+
from: 0,
|
|
412
|
+
to: () => (isHovered ? 1 : 0),
|
|
413
|
+
stiffness: 180,
|
|
414
|
+
damping: 12,
|
|
415
|
+
onUpdate: (t) => {
|
|
416
|
+
const [r, g, b] = lerpOklab(fromColor, toColor, t);
|
|
417
|
+
sprite.setColor(r, g, b, 1);
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
See [`src/color/README.md`](src/color/README.md) for the full Oklab reference including color-space internals.
|
|
423
|
+
|
|
424
|
+
## Ticker
|
|
425
|
+
|
|
426
|
+
The ticker does **not** auto-start. You must explicitly call `start()` (for rAF) or `update(deltaMs)` (for custom game loops) to drive animations.
|
|
427
|
+
|
|
428
|
+
Primitives register themselves with the ticker on creation — no manual registration needed.
|
|
429
|
+
|
|
430
|
+
**Standalone (rAF):** `getTicker().start()` uses its own `requestAnimationFrame` loop. Best for DOM-based demos or when you don't have a game loop.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { getTicker, createAnimation } from "anim-engine";
|
|
434
|
+
|
|
435
|
+
getTicker().start(); // starts the rAF loop — call once
|
|
436
|
+
|
|
437
|
+
createAnimation({
|
|
438
|
+
from: 0,
|
|
439
|
+
to: 100,
|
|
440
|
+
durationMs: 2000,
|
|
441
|
+
ease: "outElastic",
|
|
442
|
+
onUpdate: (v) => (sprite.x = v),
|
|
443
|
+
}).play();
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Custom game loop:** Call `getTicker().update(deltaMs)` from your own loop. Syncs to PixiJS ticker, ThreeJS `requestAnimationFrame`, or a fixed-step physics loop.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { getTicker } from "anim-engine";
|
|
450
|
+
|
|
451
|
+
const animEngineTicker = getTicker();
|
|
452
|
+
|
|
453
|
+
// Call once per frame from your game loop
|
|
454
|
+
function gameLoop(deltaMs: number) {
|
|
455
|
+
animEngineTicker.update(deltaMs);
|
|
456
|
+
// ... physics, rendering
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**Stop:** Call `getTicker().stop()` or `animEngineTicker.stop()` to clean up the rAF loop.
|
|
461
|
+
|
|
462
|
+
## Easing
|
|
463
|
+
|
|
464
|
+
31 Penner easing functions plus custom cubic bezier:
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
import { cubicBezier, EASE_NAMES } from "anim-engine";
|
|
468
|
+
|
|
469
|
+
const customEase = cubicBezier(0.25, 0.1, 0.25, 1); // custom easing function
|
|
470
|
+
|
|
471
|
+
// Use it anywhere you'd use an ease name
|
|
472
|
+
const anim = createAnimation({
|
|
473
|
+
from: 0,
|
|
474
|
+
to: 100,
|
|
475
|
+
durationMs: 1000,
|
|
476
|
+
ease: customEase,
|
|
477
|
+
onUpdate: (v) => (sprite.x = v),
|
|
478
|
+
});
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
You can pass the result directly to any `ease` option — it's an `EaseFunction`, just like the named presets.
|
|
482
|
+
|
|
483
|
+
**Supported ease names:** `linear`, `inQuad`, `outQuad`, `inOutQuad`, `inCubic`, `outCubic`, `inOutCubic`, `inQuart`, `outQuart`, `inOutQuart`, `inQuint`, `outQuint`, `inOutQuint`, `inSine`, `outSine`, `inOutSine`, `inExpo`, `outExpo`, `inOutExpo`, `inCirc`, `outCirc`, `inOutCirc`, `inBack`, `outBack`, `inOutBack`, `inElastic`, `outElastic`, `inOutElastic`, `inBounce`, `outBounce`, `inOutBounce`.
|
|
484
|
+
|
|
485
|
+
## Dynamic values
|
|
486
|
+
|
|
487
|
+
All primitives accept `number | (() => number)` for value parameters. Use a function to update the target every frame without recreating the animation:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
const spring = createSpring({
|
|
491
|
+
to: () => getMousePosition(), // starts at current mouse position // re-read every frame
|
|
492
|
+
stiffness: () => sliderValue, // dynamic stiffness
|
|
493
|
+
damping: () => dampingValue,
|
|
494
|
+
});
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The function is called every frame inside the ticker update — no getter/setter objects, no mutation of the returned controls.
|
|
498
|
+
|
|
499
|
+
## Benchmarks
|
|
500
|
+
|
|
501
|
+
Performance comparison against GSAP (vitest bench, Apple Silicon M-series, Node 24).
|
|
502
|
+
|
|
503
|
+
| Benchmark | anim-engine | GSAP | Ratio |
|
|
504
|
+
| -------------------------------------------- | ------------ | ------------ | ------------ |
|
|
505
|
+
| **Single tween** (cubic, 1000 frames) | 43,265 ops/s | 9,542 ops/s | 4.53× faster |
|
|
506
|
+
| **Single tween** (linear, 1000 frames) | 30,461 ops/s | 16,195 ops/s | 1.88× faster |
|
|
507
|
+
| **Single tween** (cubic bezier, 1000 frames) | 21,439 ops/s | 12,869 ops/s | 1.67× faster |
|
|
508
|
+
| **Keyframe** (3 segments, 1000 frames) | 35,307 ops/s | 3,560 ops/s | 9.92× faster |
|
|
509
|
+
| **50 concurrent tweens** (500 frames) | 942 ops/s | 473 ops/s | 1.99× faster |
|
|
510
|
+
| **50 concurrent keyframes** (500 frames) | 918 ops/s | 171 ops/s | 5.38× faster |
|
|
511
|
+
|
|
512
|
+
Easing functions are matched between libraries (cubic = GSAP `power2.out`, cubic bezier = identical control points via GSAP `CustomEase`). Linear strips out easing to show pure framework overhead.
|
|
513
|
+
|
|
514
|
+
Run locally: `npm run bench`
|
|
515
|
+
|
|
516
|
+
## Game engine integration
|
|
517
|
+
|
|
518
|
+
### PixiJS
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
import { Application, Sprite } from "pixi.js";
|
|
522
|
+
import { createAnimation, getTicker } from "anim-engine";
|
|
523
|
+
|
|
524
|
+
const app = new Application();
|
|
525
|
+
await app.init({ width: 800, height: 600 });
|
|
526
|
+
|
|
527
|
+
const sprite = Sprite.from("texture.png");
|
|
528
|
+
app.stage.addChild(sprite);
|
|
529
|
+
|
|
530
|
+
// Sync anim-engine ticker to PixiJS ticker
|
|
531
|
+
const animEngineTicker = getTicker();
|
|
532
|
+
app.ticker.add((delta) => {
|
|
533
|
+
animEngineTicker.update(delta.deltaMS);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
createAnimation({
|
|
537
|
+
from: 0,
|
|
538
|
+
to: 300,
|
|
539
|
+
durationMs: 2000,
|
|
540
|
+
ease: "outElastic",
|
|
541
|
+
onUpdate: (x) => (sprite.x = x),
|
|
542
|
+
}).play();
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### ThreeJS
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import * as THREE from "three";
|
|
549
|
+
import { createAnimation, getTicker, createSmoothDamp } from "anim-engine";
|
|
550
|
+
|
|
551
|
+
const scene = new THREE.Scene();
|
|
552
|
+
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
|
|
553
|
+
const renderer = new THREE.WebGLRenderer();
|
|
554
|
+
|
|
555
|
+
const mesh = new THREE.Mesh(
|
|
556
|
+
new THREE.BoxGeometry(),
|
|
557
|
+
new THREE.MeshStandardMaterial({ color: "#667eea" }),
|
|
558
|
+
);
|
|
559
|
+
scene.add(mesh);
|
|
560
|
+
|
|
561
|
+
const animEngineTicker = getTicker();
|
|
562
|
+
const clock = new THREE.Clock();
|
|
563
|
+
|
|
564
|
+
function animate() {
|
|
565
|
+
requestAnimationFrame(animate);
|
|
566
|
+
animEngineTicker.update(clock.getDelta() * 1000);
|
|
567
|
+
renderer.render(scene, camera);
|
|
568
|
+
}
|
|
569
|
+
animate();
|
|
570
|
+
|
|
571
|
+
createAnimation({
|
|
572
|
+
from: 0,
|
|
573
|
+
to: Math.PI * 2,
|
|
574
|
+
durationMs: 3000,
|
|
575
|
+
ease: "inOutCubic",
|
|
576
|
+
onUpdate: (angle) => (mesh.rotation.y = angle),
|
|
577
|
+
}).play();
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Custom game loop
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
import { getTicker, createSpring } from "anim-engine";
|
|
584
|
+
|
|
585
|
+
const animEngineTicker = getTicker();
|
|
586
|
+
|
|
587
|
+
function gameLoop(timestamp: number) {
|
|
588
|
+
const deltaMs = timestamp - lastTimestamp;
|
|
589
|
+
lastTimestamp = timestamp;
|
|
590
|
+
|
|
591
|
+
animEngineTicker.update(deltaMs);
|
|
592
|
+
// ... physics, rendering, etc.
|
|
593
|
+
|
|
594
|
+
requestAnimationFrame(gameLoop);
|
|
595
|
+
}
|
|
596
|
+
requestAnimationFrame(gameLoop);
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
## API Reference
|
|
600
|
+
|
|
601
|
+
### Functions
|
|
602
|
+
|
|
603
|
+
| Export | Description |
|
|
604
|
+
| ---------------------------------- | ----------------------------------- |
|
|
605
|
+
| `createAnimation(options)` | Timed or keyframe animation |
|
|
606
|
+
| `createTimeline(layers, options?)` | Composited timeline of animations |
|
|
607
|
+
| `createSpring(options)` | Physics spring (Verlet integration) |
|
|
608
|
+
| `createSmoothDamp(options)` | Unity-style smooth damp |
|
|
609
|
+
| `createLerp(options)` | Exponential lerp chase |
|
|
610
|
+
| `createSmoothClamp(threshold)` | Asymptotic clamp factory |
|
|
611
|
+
| `getTicker()` | Singleton ticker |
|
|
612
|
+
| `cubicBezier(p1x, p1y, p2x, p2y)` | Custom cubic bezier easing |
|
|
613
|
+
| `lerpOklab(from, to, t)` | Oklab color interpolation |
|
|
614
|
+
| `hexToRgba(hex)` | Parse hex color to normalized RGBA |
|
|
615
|
+
|
|
616
|
+
### Type exports
|
|
617
|
+
|
|
618
|
+
| Type | Description |
|
|
619
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
620
|
+
| `Animation` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setCurrentValue`, `setProgress`, `currentValue`, `velocity`, `progress` (readonly), `status` |
|
|
621
|
+
| `Interpolation` | `start`, `stop`, `kill`, `setCurrentValue`, `currentValue`, `velocity`, `status` |
|
|
622
|
+
| `Timeline` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setProgress`, `progress`, `status` |
|
|
623
|
+
| `EaseName` | Union of 31 ease name strings |
|
|
624
|
+
| `EaseFunction` | `(t: number) => number` |
|
|
625
|
+
| `DynamicValue` | `number \| (() => number)` |
|
|
626
|
+
| `AnimationStatus` | `"playing" \| "paused" \| "stopped" \| "dead"` (for `Animation` / `Timeline`) |
|
|
627
|
+
| `InterpolationStatus` | `"active" \| "inactive" \| "dead"` (for `Interpolation`) |
|
|
628
|
+
| `AnimationOptions` | Single tween or keyframe animation options (discriminated union) |
|
|
629
|
+
| `Keyframe` | `{ at, value, ease? }` |
|
|
630
|
+
| `TimelineLayer` | `{ at: number; animation: Animation \| Animation[] } \| { gap: number; animation: Animation \| Animation[] }` |
|
|
631
|
+
| `SpringOptions` | `to`, `stiffness`, `damping`, `mass`, `precision?`, `onUpdate`, `onEnded` |
|
|
632
|
+
| `SmoothDampOptions` | `to`, `smoothTimeMs`, `maxSpeed?`, `precision?`, `onUpdate`, `onEnded` |
|
|
633
|
+
| `LerpOptions` | `to`, `smoothTimeMs`, `precision?`, `onUpdate`, `onEnded` |
|
|
634
|
+
| `RgbaTuple` | `readonly [number, number, number, number]` |
|
|
635
|
+
| `TickerControls` | `start`, `stop`, `update`, `add`, `remove` |
|
|
636
|
+
|
|
637
|
+
## License
|
|
638
|
+
|
|
639
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Animation, DynamicValue, EaseFunction, EaseName } from '../shared/types';
|
|
2
|
+
export type SingleTweenOptions = {
|
|
3
|
+
from: DynamicValue;
|
|
4
|
+
to: DynamicValue;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
ease?: EaseName | EaseFunction;
|
|
7
|
+
delayMs?: number;
|
|
8
|
+
onStarted?: () => void;
|
|
9
|
+
onUpdate?: (value: number, velocity: number) => void;
|
|
10
|
+
onEnded?: () => void;
|
|
11
|
+
};
|
|
12
|
+
export type Keyframe = {
|
|
13
|
+
at: number;
|
|
14
|
+
value: DynamicValue;
|
|
15
|
+
ease?: EaseName | EaseFunction;
|
|
16
|
+
};
|
|
17
|
+
export type KeyframedAnimationOptions = {
|
|
18
|
+
keyframes: Keyframe[];
|
|
19
|
+
onUpdate?: (value: number, velocity: number) => void;
|
|
20
|
+
onProgress?: (progress: number) => void;
|
|
21
|
+
onEnded?: () => void;
|
|
22
|
+
};
|
|
23
|
+
export type AnimationOptions = SingleTweenOptions | KeyframedAnimationOptions;
|
|
24
|
+
export declare const createAnimation: (options: AnimationOptions) => Animation;
|