From ceca06a4926e39c3b69b0f0f2ae8e29bc0f47771 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 09:06:27 +0900 Subject: [PATCH] feat: add sprite animation controller --- PROJECT_DESCRIPTION.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/index.d.ts | 73 +++++++ examples/spriteAnimation.ts | 20 ++ src/gameplay/spriteAnimation.ts | 325 ++++++++++++++++++++++++++++++++ src/index.ts | 19 ++ tests/index.test.ts | 2 + tests/spriteAnimation.test.ts | 115 +++++++++++ 9 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 examples/spriteAnimation.ts create mode 100644 src/gameplay/spriteAnimation.ts create mode 100644 tests/spriteAnimation.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index a012704..104d1d8 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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.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` | | 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. +- **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. - **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 d9fac81..7983ed2 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` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.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` | | 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/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/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 e1205b6..d89c6f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ - Real-time systems: - [x] 2D camera system (smooth follow, dead zones, screen shake) - [x] Particle system with configurable emitters - - [ ] Sprite animation controller (frame timing, events) + - [x] Sprite animation controller (frame timing, events) - [ ] Tween/lerp utility for smooth interpolation - [ ] Platformer physics helper (gravity, coyote time, jump buffering) - [ ] Top-down movement helper (8-direction) diff --git a/docs/index.d.ts b/docs/index.d.ts index 01f7c31..443c537 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1078,6 +1078,79 @@ export interface ParticleSystem { */ export function createParticleSystem(options: ParticleSystemOptions): ParticleSystem; +/** + * Sprite animation playback mode. + * Use for: switching between looping, single-shot, and ping-pong playback. + * Import: gameplay/spriteAnimation.ts + */ +export type SpritePlaybackMode = 'loop' | 'once' | 'ping-pong'; + +/** + * Sprite frame definition. + * Use for: associating timing, payload, and events with each sprite frame. + * Import: gameplay/spriteAnimation.ts + */ +export interface SpriteFrame { + frame: T; + duration: number; + events?: ReadonlyArray; +} + +/** + * Sprite animation configuration options. + * Use for: configuring playback mode, speed, and starting state. + * Import: gameplay/spriteAnimation.ts + */ +export interface SpriteAnimationOptions { + frames: ReadonlyArray>; + mode?: SpritePlaybackMode; + speed?: number; + playOnStart?: boolean; + startFrame?: number; +} + +/** + * Sprite animation event payload. + * Use for: reacting to frame enter, loop, complete, or custom events. + * Import: gameplay/spriteAnimation.ts + */ +export interface SpriteAnimationEvent { + type: string; + frame: SpriteFrame; + frameIndex: number; + loopCount: number; +} + +/** + * Sprite animation controller runtime API. + * Use for: updating time, subscribing to events, changing speed/mode. + * Import: gameplay/spriteAnimation.ts + */ +export interface SpriteAnimationController { + update(delta: number): void; + getFrame(): SpriteFrame; + getFrameIndex(): number; + getFrameTime(): number; + getProgress(): number; + getLoopCount(): number; + isPlaying(): boolean; + isFinished(): boolean; + play(): void; + pause(): void; + reset(frameIndex?: number): void; + setSpeed(speed: number): void; + setMode(mode: SpritePlaybackMode): void; + on(event: string, handler: (event: SpriteAnimationEvent) => void): () => void; +} + +/** + * Creates a sprite animation controller with frame timing and events. + * Use for: sprite sheets, UI timelines, icon animations. + * Performance: O(k) per update where k is frames advanced. + * Import: gameplay/spriteAnimation.ts + */ +export function createSpriteAnimation(options: SpriteAnimationOptions): SpriteAnimationController; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/spriteAnimation.ts b/examples/spriteAnimation.ts new file mode 100644 index 0000000..5325ae9 --- /dev/null +++ b/examples/spriteAnimation.ts @@ -0,0 +1,20 @@ +import { createSpriteAnimation } from '../src/index.js'; + +const animation = createSpriteAnimation({ + frames: [ + { frame: 'walk-0', duration: 0.08 }, + { frame: 'walk-1', duration: 0.08 }, + { frame: 'walk-2', duration: 0.08 }, + { frame: 'walk-3', duration: 0.08, events: ['footstep'] }, + ], + mode: 'loop', +}); + +animation.on('footstep', (event) => { + console.log('footstep event on frame', event.frameIndex); +}); + +for (let i = 0; i < 6; i += 1) { + animation.update(0.08); + console.log('current frame:', animation.getFrame().frame); +} diff --git a/src/gameplay/spriteAnimation.ts b/src/gameplay/spriteAnimation.ts new file mode 100644 index 0000000..b39f0f4 --- /dev/null +++ b/src/gameplay/spriteAnimation.ts @@ -0,0 +1,325 @@ +export type SpritePlaybackMode = 'loop' | 'once' | 'ping-pong'; + +export interface SpriteFrame { + /** Arbitrary frame payload (texture index, UV id, etc.). */ + frame: T; + /** Duration of the frame in seconds (> 0). */ + duration: number; + /** Optional events fired when this frame becomes active. */ + events?: ReadonlyArray; +} + +export interface SpriteAnimationOptions { + frames: ReadonlyArray>; + /** Playback mode, defaults to 'loop'. */ + mode?: SpritePlaybackMode; + /** Playback speed multiplier, defaults to 1. */ + speed?: number; + /** Whether playback starts immediately, defaults to true. */ + playOnStart?: boolean; + /** Optional starting frame index. */ + startFrame?: number; +} + +export interface SpriteAnimationEvent { + type: string; + frame: SpriteFrame; + frameIndex: number; + loopCount: number; +} + +export interface SpriteAnimationController { + update(delta: number): void; + getFrame(): SpriteFrame; + getFrameIndex(): number; + getFrameTime(): number; + getProgress(): number; + getLoopCount(): number; + isPlaying(): boolean; + isFinished(): boolean; + play(): void; + pause(): void; + reset(frameIndex?: number): void; + setSpeed(speed: number): void; + setMode(mode: SpritePlaybackMode): void; + on(event: string, handler: (event: SpriteAnimationEvent) => void): () => void; +} + +interface InternalFrame extends SpriteFrame { + events?: string[]; +} + +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 normalizeFrames(frames: ReadonlyArray>): InternalFrame[] { + if (!frames || frames.length === 0) { + throw new Error('frames array must contain at least one frame.'); + } + return frames.map((frame, index) => { + if (!frame) { + throw new Error(`frames[${index}] is undefined.`); + } + assertFinite(frame.duration, `frames[${index}].duration`); + if (frame.duration <= 0) { + throw new Error(`frames[${index}].duration must be > 0.`); + } + return { + frame: frame.frame, + duration: frame.duration, + events: frame.events ? [...frame.events] : undefined, + }; + }); +} + +function clampIndex(index: number, length: number): number { + if (!Number.isInteger(index)) { + throw new Error('frameIndex must be an integer.'); + } + if (index < 0) { + return 0; + } + if (index >= length) { + return length - 1; + } + return index; +} + +function assertMode(mode: SpritePlaybackMode): void { + if (mode !== 'loop' && mode !== 'once' && mode !== 'ping-pong') { + throw new Error('mode must be loop, once, or ping-pong.'); + } +} + +/** + * Creates a sprite animation controller with frame timing and events. + * Useful for: sprite sheets, UI animations, and timeline-based effects. + */ +export function createSpriteAnimation({ + frames: rawFrames, + mode = 'loop', + speed = 1, + playOnStart = true, + startFrame = 0, +}: SpriteAnimationOptions): SpriteAnimationController { + const frames = normalizeFrames(rawFrames); + assertMode(mode); + assertFinite(speed, 'speed'); + if (speed < 0) { + throw new Error('speed must be >= 0.'); + } + assertFinite(startFrame, 'startFrame'); + + let playbackMode: SpritePlaybackMode = mode; + let playbackSpeed = speed; + const listeners = new Map) => void>>(); + + let currentIndex = clampIndex(Math.trunc(startFrame), frames.length); + let timeInFrame = 0; + let playing = playOnStart; + let finished = false; + let direction = 1; + let loopCount = 0; + + function emit(type: string): void { + const frame = frames[currentIndex]; + const handlers = listeners.get(type); + if (!handlers || handlers.size === 0) { + return; + } + const event: SpriteAnimationEvent = { + type, + frame, + frameIndex: currentIndex, + loopCount, + }; + handlers.forEach((handler) => handler(event)); + } + + function emitFrameEvents(): void { + emit('frame-enter'); + const frameEvents = frames[currentIndex].events; + if (frameEvents) { + frameEvents.forEach((name) => emit(name)); + } + } + + function goToFrame(index: number, emitEvents: boolean): void { + currentIndex = clampIndex(index, frames.length); + timeInFrame = 0; + if (emitEvents) { + emitFrameEvents(); + } + } + + function advanceFrame(): void { + if (frames.length === 1) { + if (playbackMode === 'loop' || playbackMode === 'ping-pong') { + loopCount += 1; + emit('loop'); + } else { + finished = true; + playing = false; + emit('complete'); + } + return; + } + + if (playbackMode === 'loop') { + currentIndex += 1; + if (currentIndex >= frames.length) { + currentIndex = 0; + loopCount += 1; + emit('loop'); + } + timeInFrame = 0; + emitFrameEvents(); + return; + } + + if (playbackMode === 'once') { + if (currentIndex >= frames.length - 1) { + finished = true; + playing = false; + timeInFrame = frames[currentIndex].duration; + emit('complete'); + return; + } + currentIndex += 1; + timeInFrame = 0; + emitFrameEvents(); + return; + } + + // ping-pong + let nextIndex = currentIndex + direction; + if (nextIndex >= frames.length || nextIndex < 0) { + direction *= -1; + loopCount += 1; + emit('loop'); + nextIndex = currentIndex + direction; + } + currentIndex = clampIndex(nextIndex, frames.length); + timeInFrame = 0; + emitFrameEvents(); + } + + function update(delta: number): void { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + if (!playing || finished || playbackSpeed === 0) { + return; + } + let remaining = delta * playbackSpeed; + while (remaining > 0 && playing && !finished) { + const currentFrame = frames[currentIndex]; + const timeRemaining = currentFrame.duration - timeInFrame; + if (remaining < timeRemaining) { + timeInFrame += remaining; + remaining = 0; + break; + } + + remaining -= timeRemaining; + timeInFrame = currentFrame.duration; + advanceFrame(); + if (!playing || finished) { + break; + } + } + } + + function on(event: string, handler: (event: SpriteAnimationEvent) => void): () => void { + if (typeof event !== 'string' || event.length === 0) { + throw new Error('event name must be a non-empty string.'); + } + if (typeof handler !== 'function') { + throw new Error('handler must be a function.'); + } + let handlers = listeners.get(event); + if (!handlers) { + handlers = new Set(); + listeners.set(event, handlers); + } + handlers.add(handler); + return () => { + handlers?.delete(handler); + if (handlers && handlers.size === 0) { + listeners.delete(event); + } + }; + } + + function play(): void { + if (!finished) { + playing = true; + } + } + + function pause(): void { + playing = false; + } + + function reset(frameIndex = 0): void { + const wasPlaying = playing; + currentIndex = clampIndex(frameIndex, frames.length); + timeInFrame = 0; + finished = false; + loopCount = 0; + direction = 1; + emitFrameEvents(); + playing = wasPlaying; + } + + function setSpeed(speedValue: number): void { + assertFinite(speedValue, 'speed'); + if (speedValue < 0) { + throw new Error('speed must be >= 0.'); + } + playbackSpeed = speedValue; + } + + function setMode(newMode: SpritePlaybackMode): void { + assertMode(newMode); + if (playbackMode === newMode) { + return; + } + playbackMode = newMode; + finished = false; + loopCount = 0; + direction = 1; + } + + goToFrame(currentIndex, false); + emitFrameEvents(); + playing = playOnStart; + + return { + update, + getFrame: () => frames[currentIndex], + getFrameIndex: () => currentIndex, + getFrameTime: () => timeInFrame, + getProgress: () => (frames[currentIndex].duration === 0 ? 0 : timeInFrame / frames[currentIndex].duration), + getLoopCount: () => loopCount, + isPlaying: () => playing, + isFinished: () => finished, + play, + pause, + reset, + setSpeed, + setMode, + on, + }; +} + +/** @internal */ +export const __internals = { + normalizeFrames, + clampIndex, + assertMode, +}; diff --git a/src/index.ts b/src/index.ts index 99b5d98..18aa836 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,7 @@ export const examples = { createFixedTimestepLoop: 'examples/fixedTimestep.ts', createCamera2D: 'examples/camera2D.ts', createParticleSystem: 'examples/particleSystem.ts', + createSpriteAnimation: 'examples/spriteAnimation.ts', }, ai: { seek: 'examples/steering.ts', @@ -513,6 +514,24 @@ export type { ParticleSystem, } from './gameplay/particleSystem.js'; +/** + * Sprite animation controller with frame timing and events. + * + * Example file: examples/spriteAnimation.ts + */ +export { createSpriteAnimation } from './gameplay/spriteAnimation.js'; + +/** + * Sprite animation configuration and event types. + */ +export type { + SpriteFrame, + SpriteAnimationOptions, + SpriteAnimationController, + SpriteAnimationEvent, + SpritePlaybackMode, +} from './gameplay/spriteAnimation.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index b495ef8..ddd3dd7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -28,6 +28,7 @@ describe('package entry point', () => { expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); 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.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -99,6 +100,7 @@ describe('package entry point', () => { | 'createFixedTimestepLoop' | 'createCamera2D' | 'createParticleSystem' + | 'createSpriteAnimation' >(); }); }); diff --git a/tests/spriteAnimation.test.ts b/tests/spriteAnimation.test.ts new file mode 100644 index 0000000..cba4cf0 --- /dev/null +++ b/tests/spriteAnimation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { createSpriteAnimation } from '../src/index.js'; + +describe('createSpriteAnimation', () => { + it('loops through frames and emits events', () => { + const controller = createSpriteAnimation({ + frames: [ + { frame: 'idle-0', duration: 0.1, events: ['spawn'] }, + { frame: 'idle-1', duration: 0.2 }, + ], + playOnStart: false, + }); + + const events: string[] = []; + controller.on('frame-enter', (event) => { + events.push(`frame:${event.frame.frame}`); + }); + controller.on('spawn', () => { + events.push('spawn'); + }); + controller.on('loop', (event) => { + events.push(`loop:${event.loopCount}`); + }); + + controller.play(); + controller.reset(); + expect(events).toEqual(['frame:idle-0', 'spawn']); + + events.length = 0; + controller.update(0.1); + expect(events).toEqual(['frame:idle-1']); + + events.length = 0; + controller.update(0.2); + expect(events).toEqual(['loop:1', 'frame:idle-0', 'spawn']); + expect(controller.getLoopCount()).toBe(1); + expect(controller.getFrame().frame).toBe('idle-0'); + }); + + it('stops in once mode and emits complete', () => { + const controller = createSpriteAnimation({ + frames: [ + { frame: 0, duration: 0.1 }, + { frame: 1, duration: 0.1 }, + ], + mode: 'once', + playOnStart: false, + }); + + let completed = false; + controller.on('complete', () => { + completed = true; + }); + + controller.play(); + controller.reset(); + controller.update(0.2); + expect(controller.isFinished()).toBe(true); + expect(controller.isPlaying()).toBe(false); + expect(completed).toBe(true); + expect(controller.getFrame().frame).toBe(1); + }); + + it('ping-pong mode reverses direction', () => { + const controller = createSpriteAnimation({ + frames: [ + { frame: 'a', duration: 0.1 }, + { frame: 'b', duration: 0.1 }, + { frame: 'c', duration: 0.1 }, + ], + mode: 'ping-pong', + playOnStart: false, + }); + + controller.play(); + controller.reset(); + + const indices: number[] = [controller.getFrameIndex()]; + controller.update(0.1); + indices.push(controller.getFrameIndex()); + controller.update(0.1); + indices.push(controller.getFrameIndex()); + controller.update(0.1); + indices.push(controller.getFrameIndex()); + controller.update(0.1); + indices.push(controller.getFrameIndex()); + + expect(indices).toEqual([0, 1, 2, 1, 0]); + expect(controller.getLoopCount()).toBeGreaterThan(0); + }); + + it('validates inputs and setters', () => { + expect(() => + createSpriteAnimation({ + frames: [{ frame: 'oops', duration: 0 }], + }) + ).toThrow(/duration/); + + const controller = createSpriteAnimation({ + frames: [{ frame: 0, duration: 0.1 }], + playOnStart: false, + }); + + expect(() => controller.update(-0.1)).toThrow(/delta/); + expect(() => controller.setSpeed(-1)).toThrow(/speed/); + expect(() => controller.on('', () => {})).toThrow(/event name/); + + controller.setSpeed(2); + controller.setMode('loop'); + controller.play(); + controller.reset(); + expect(controller.isPlaying()).toBe(true); + }); +});