diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 104d1d8..8e24945 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -39,7 +39,7 @@ npm run build | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -94,7 +94,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling, weighted alias sampling, object pooling, Fisher–Yates shuffle. -- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events. +- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats. - **Search:** Fuzzy search + scoring, Trie-based autocomplete, binary search, Levenshtein distance. - **Data tools:** Diff operations (LCS), deep clone, groupBy, JSON diff/patch helpers. - **Graph:** BFS distance map, DFS traversal, topological sort. diff --git a/README.md b/README.md index 7983ed2..77d5bed 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CDN usage: | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index d89c6f1..ad11393 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,7 @@ - [x] 2D camera system (smooth follow, dead zones, screen shake) - [x] Particle system with configurable emitters - [x] Sprite animation controller (frame timing, events) - - [ ] Tween/lerp utility for smooth interpolation + - [x] Tween/lerp utility for smooth interpolation - [ ] Platformer physics helper (gravity, coyote time, jump buffering) - [ ] Top-down movement helper (8-direction) - [ ] Tile map renderer helpers (chunking, layering, collision tags) diff --git a/docs/index.d.ts b/docs/index.d.ts index 443c537..7493af5 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1151,6 +1151,78 @@ export interface SpriteAnimationController { */ export function createSpriteAnimation(options: SpriteAnimationOptions): SpriteAnimationController; +/** + * Tween status lifecycle. + * Use for: checking if a tween is idle, running, or completed. + * Import: gameplay/tween.ts + */ +export type TweenStatus = 'idle' | 'running' | 'completed'; + +/** + * Tween configuration options. + * Use for: defining interpolation ranges, delay, repeats, and callbacks. + * Import: gameplay/tween.ts + */ +export interface TweenOptions { + duration: number; + delay?: number; + from: number; + to: number; + easing?: (t: number) => number; + onUpdate?: (value: number, progress: number) => void; + onComplete?: () => void; + repeat?: number; + yoyo?: boolean; +} + +/** + * Tween controller API. + * Use for: updating individual tweens, pausing, resetting, and inspecting progress. + * Import: gameplay/tween.ts + */ +export interface TweenController { + update(delta: number): void; + getValue(): number; + getProgress(): number; + getStatus(): TweenStatus; + getElapsed(): number; + play(): void; + pause(): void; + reset(): void; + setSpeed(multiplier: number): void; + isPlaying(): boolean; +} + +/** + * Tween system configuration options. + * Use for: setting a global speed multiplier. + * Import: gameplay/tween.ts + */ +export interface TweenSystemOptions { + speed?: number; +} + +/** + * Tween system interface. + * Use for: creating tweens, updating all active tweens, and adjusting global speed. + * Import: gameplay/tween.ts + */ +export interface TweenSystem { + create(options: TweenOptions): TweenController; + update(delta: number): void; + setGlobalSpeed(multiplier: number): void; + getGlobalSpeed(): number; + clear(): void; +} + +/** + * Creates a tween system with optional global speed control. + * Use for: coordinating UI transitions and gameplay feedback animations. + * Performance: O(active tweens) per update. + * Import: gameplay/tween.ts + */ +export function createTweenSystem(options?: TweenSystemOptions): TweenSystem; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/tween.ts b/examples/tween.ts new file mode 100644 index 0000000..0a32c07 --- /dev/null +++ b/examples/tween.ts @@ -0,0 +1,21 @@ +import { createTweenSystem } from '../src/index.js'; + +const tweens = createTweenSystem(); +const tween = tweens.create({ + from: 0, + to: 100, + duration: 2, + easing: (t) => t * t, + onUpdate(value, progress) { + console.log('value:', value.toFixed(2), 'progress:', progress.toFixed(2)); + }, + onComplete() { + console.log('tween complete'); + }, +}); + +let elapsed = 0; +while (elapsed < 2.5) { + tween.update(0.5); + elapsed += 0.5; +} diff --git a/src/gameplay/tween.ts b/src/gameplay/tween.ts new file mode 100644 index 0000000..784aa70 --- /dev/null +++ b/src/gameplay/tween.ts @@ -0,0 +1,328 @@ +export type TweenStatus = 'idle' | 'running' | 'completed'; + +export interface TweenOptions { + /** Duration in seconds (> 0). */ + duration: number; + /** Optional delay before starting in seconds (>= 0). */ + delay?: number; + /** Starting value. */ + from: number; + /** Target value. */ + to: number; + /** Optional easing function mapping progress [0,1] -> [0,1]. */ + easing?: (t: number) => number; + /** Optional onUpdate callback invoked every tick. */ + onUpdate?: (value: number, progress: number) => void; + /** Optional onComplete callback when tween finishes. */ + onComplete?: () => void; + /** Number of times to repeat the tween (0 = none). */ + repeat?: number; + /** Whether to reverse direction after each repeat. */ + yoyo?: boolean; +} + +export interface TweenController { + update(delta: number): void; + getValue(): number; + getProgress(): number; + getStatus(): TweenStatus; + getElapsed(): number; + play(): void; + pause(): void; + reset(): void; + setSpeed(multiplier: number): void; + isPlaying(): boolean; +} + +export type TweenFactory = (options: TweenOptions) => TweenController; + +export interface TweenSystemOptions { + /** Speed multiplier applied to all tweens. Defaults to 1. */ + speed?: number; +} + +export interface TweenSystem { + create(options: TweenOptions): TweenController; + update(delta: number): void; + setGlobalSpeed(multiplier: number): void; + getGlobalSpeed(): number; + clear(): void; +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function clamp01(value: number): number { + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +} + +function defaultEasing(t: number): number { + return t; +} + +interface InternalTween { + options: Required> & { + repeat: number; + yoyo: boolean; + }; + value: number; + direction: 1 | -1; + elapsed: number; + delayRemaining: number; + status: TweenStatus; + repeatsDone: number; + playing: boolean; + localSpeed: number; +} + +function normalizeOptions(options: TweenOptions): InternalTween { + assertFinite(options.duration, 'duration'); + if (options.duration <= 0) { + throw new Error('duration must be greater than 0.'); + } + const delay = options.delay ?? 0; + assertFinite(delay, 'delay'); + if (delay < 0) { + throw new Error('delay must be >= 0.'); + } + assertFinite(options.from, 'from'); + assertFinite(options.to, 'to'); + + const easing = options.easing ?? defaultEasing; + if (typeof easing !== 'function') { + throw new Error('easing must be a function.'); + } + if (options.onUpdate && typeof options.onUpdate !== 'function') { + throw new Error('onUpdate must be a function.'); + } + if (options.onComplete && typeof options.onComplete !== 'function') { + throw new Error('onComplete must be a function.'); + } + const repeat = options.repeat ?? 0; + assertFinite(repeat, 'repeat'); + if (!Number.isInteger(repeat) || repeat < 0) { + throw new Error('repeat must be a non-negative integer.'); + } + const yoyo = options.yoyo ?? false; + + return { + options: { + duration: options.duration, + delay, + from: options.from, + to: options.to, + easing, + onUpdate: options.onUpdate ?? (() => {}), + onComplete: options.onComplete ?? (() => {}), + repeat, + yoyo, + }, + value: options.from, + direction: 1, + elapsed: 0, + delayRemaining: delay, + status: 'idle', + repeatsDone: 0, + playing: true, + localSpeed: 1, + }; +} + +function updateTween(tween: InternalTween, delta: number, globalSpeed: number): void { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + if (!tween.playing || tween.status === 'completed') { + return; + } + + const scaledDelta = delta * globalSpeed * tween.localSpeed; + + if (tween.delayRemaining > 0) { + tween.delayRemaining -= scaledDelta; + if (tween.delayRemaining > 0) { + return; + } + tween.delayRemaining = 0; + } + + const { duration, from, to, easing, onUpdate, onComplete, repeat, yoyo } = tween.options; + if (tween.status === 'idle') { + tween.status = 'running'; + } + + tween.elapsed += scaledDelta * tween.direction; + let progress = tween.elapsed / duration; + + if (progress >= 1 || progress <= 0) { + progress = clamp01(progress); + tween.elapsed = progress * duration; + const easedBoundary = easing(progress); + tween.value = from + (to - from) * easedBoundary; + onUpdate(tween.value, progress); + + if (yoyo && tween.direction === 1) { + tween.direction = -1; + tween.elapsed = duration; + return; + } + if (yoyo && tween.direction === -1) { + tween.direction = 1; + tween.elapsed = 0; + tween.repeatsDone += 1; + } + + if (!yoyo) { + tween.repeatsDone += 1; + } + + if (tween.repeatsDone > repeat) { + tween.status = 'completed'; + tween.playing = false; + tween.value = to; + onComplete(); + return; + } + + tween.elapsed = yoyo ? (tween.direction === -1 ? duration : 0) : 0; + const restartProgress = clamp01(tween.elapsed / duration); + const easedRestart = easing(restartProgress); + tween.value = from + (to - from) * easedRestart; + onUpdate(tween.value, restartProgress); + return; + } + + progress = clamp01(progress); + const eased = easing(progress); + tween.value = from + (to - from) * eased; + onUpdate(tween.value, progress); +} + +function createTweenController( + initial: InternalTween, + registry: Set, + speedMultiplierRef: { value: number } +): TweenController { + const tween = initial; + + return { + update(delta: number) { + updateTween(tween, delta, speedMultiplierRef.value); + if (tween.status === 'completed') { + registry.delete(tween); + } + }, + getValue() { + return tween.value; + }, + getProgress() { + return clamp01(tween.elapsed / tween.options.duration); + }, + getStatus() { + return tween.status; + }, + getElapsed() { + const clamped = clamp01(tween.elapsed / tween.options.duration); + return clamped * tween.options.duration; + }, + play() { + if (tween.status === 'completed') { + return; + } + tween.playing = true; + }, + pause() { + tween.playing = false; + }, + reset() { + tween.elapsed = 0; + tween.delayRemaining = tween.options.delay; + tween.value = tween.options.from; + tween.direction = 1; + tween.repeatsDone = 0; + tween.status = 'idle'; + tween.playing = true; + registry.add(tween); + }, + setSpeed(multiplier: number) { + assertFinite(multiplier, 'speed'); + if (multiplier < 0) { + throw new Error('speed must be >= 0.'); + } + tween.localSpeed = multiplier; + }, + isPlaying() { + return tween.playing; + }, + }; +} + +/** + * Creates a tween factory for value interpolation with optional global speed control. + * Useful for: UI animations, transitions, and gameplay feedback effects. + */ +export function createTweenSystem({ speed = 1 }: TweenSystemOptions = {}): TweenSystem { + assertFinite(speed, 'speed'); + if (speed < 0) { + throw new Error('speed must be >= 0.'); + } + + const activeTweens = new Set(); + const speedMultiplierRef = { value: speed }; + + function createTween(options: TweenOptions): TweenController { + const tween = normalizeOptions(options); + activeTweens.add(tween); + const controller = createTweenController(tween, activeTweens, speedMultiplierRef); + return controller; + } + + function update(delta: number): void { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + + const snapshot = Array.from(activeTweens); + for (const tween of snapshot) { + const previousStatus = tween.status; + updateTween(tween, delta, speedMultiplierRef.value); + if (tween.status === 'completed' && previousStatus !== 'completed') { + activeTweens.delete(tween); + } + } + } + + function setGlobalSpeed(multiplier: number): void { + assertFinite(multiplier, 'speed'); + if (multiplier < 0) { + throw new Error('speed must be >= 0.'); + } + speedMultiplierRef.value = multiplier; + } + + function clear(): void { + activeTweens.clear(); + } + + return { + create: createTween, + update, + setGlobalSpeed, + getGlobalSpeed: () => speedMultiplierRef.value, + clear, + }; +} + +/** @internal */ +export const __internals = { + normalizeOptions, + updateTween, + clamp01, +}; diff --git a/src/index.ts b/src/index.ts index 18aa836..e8c4e34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,7 @@ export const examples = { createCamera2D: 'examples/camera2D.ts', createParticleSystem: 'examples/particleSystem.ts', createSpriteAnimation: 'examples/spriteAnimation.ts', + createTweenSystem: 'examples/tween.ts', }, ai: { seek: 'examples/steering.ts', @@ -532,6 +533,24 @@ export type { SpritePlaybackMode, } from './gameplay/spriteAnimation.js'; +/** + * Tween system for interpolating numeric values with easing and repeats. + * + * Example file: examples/tween.ts + */ +export { createTweenSystem } from './gameplay/tween.js'; + +/** + * Tween system configuration, controller, and status types. + */ +export type { + TweenOptions, + TweenController, + TweenSystemOptions, + TweenSystem, + TweenStatus, +} from './gameplay/tween.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index ddd3dd7..d31c410 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -29,6 +29,7 @@ describe('package entry point', () => { expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); expect(examples.gameplay.createParticleSystem).toBe('examples/particleSystem.ts'); expect(examples.gameplay.createSpriteAnimation).toBe('examples/spriteAnimation.ts'); + expect(examples.gameplay.createTweenSystem).toBe('examples/tween.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -101,6 +102,7 @@ describe('package entry point', () => { | 'createCamera2D' | 'createParticleSystem' | 'createSpriteAnimation' + | 'createTweenSystem' >(); }); }); diff --git a/tests/tween.test.ts b/tests/tween.test.ts new file mode 100644 index 0000000..09ba93b --- /dev/null +++ b/tests/tween.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTweenSystem } from '../src/index.js'; + +describe('createTweenSystem', () => { + it('interpolates values with easing and repeats', () => { + const onUpdate = vi.fn(); + const onComplete = vi.fn(); + + const tweens = createTweenSystem(); + const tween = tweens.create({ + from: 0, + to: 10, + duration: 1, + repeat: 1, + yoyo: true, + onUpdate, + onComplete, + easing: (t) => t * t, + }); + + let total = 0; + while (total < 4.5) { + tween.update(0.25); + total += 0.25; + } + + expect(onUpdate).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(tween.getValue()).toBeCloseTo(10, 5); + expect(tween.getStatus()).toBe('completed'); + }); + + it('respects delay and global speed', () => { + const tweens = createTweenSystem({ speed: 0.5 }); + const tween = tweens.create({ + from: 0, + to: 1, + duration: 1, + delay: 1, + }); + + tweens.update(0.5); + expect(tween.getValue()).toBe(0); + + tweens.update(1); + expect(tween.getValue()).toBe(0); + + tweens.update(1); + expect(tween.getValue()).toBeGreaterThan(0); + + tweens.setGlobalSpeed(2); + tweens.update(0.25); + expect(tween.getValue()).toBeCloseTo(1, 1); + }); + + it('validates inputs', () => { + const tweens = createTweenSystem(); + + expect(() => + tweens.create({ + from: 0, + to: 1, + duration: 0, + }) + ).toThrow(/duration/); + + const tween = tweens.create({ + from: 0, + to: 1, + duration: 1, + }); + + expect(() => tween.update(-1)).toThrow(/delta/); + expect(() => tween.setSpeed(-1)).toThrow(/speed/); + }); +});