Files
esengine/packages/sprite/src/SpriteAnimatorComponent.ts

370 lines
9.5 KiB
TypeScript
Raw Normal View History

import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
/**
*
* Animation frame data
*/
export interface AnimationFrame {
/** 纹理路径 | Texture path */
texture: string;
/** 帧持续时间(秒) | Frame duration in seconds */
duration: number;
/** UV坐标 [u0, v0, u1, v1] | UV coordinates */
uv?: [number, number, number, number];
}
/**
*
* Animation clip data
*/
export interface AnimationClip {
/** 动画名称 | Animation name */
name: string;
/** 动画帧列表 | Animation frames */
frames: AnimationFrame[];
/** 是否循环 | Whether to loop */
loop: boolean;
/** 播放速度倍数 | Playback speed multiplier */
speed: number;
}
/**
* -
* Sprite animator component - manages sprite frame animation
*/
@ECSComponent('SpriteAnimator', { requires: ['Sprite'] })
@Serializable({ version: 1, typeId: 'SpriteAnimator' })
export class SpriteAnimatorComponent extends Component {
/**
*
* Animation clips
*/
@Serialize()
@Property({
type: 'animationClips',
label: 'Animation Clips',
controls: [{ component: 'Sprite', property: 'texture' }]
})
public clips: AnimationClip[] = [];
/**
*
* Currently playing animation name
*/
@Serialize()
@Property({ type: 'string', label: 'Default Animation' })
public defaultAnimation: string = '';
/**
*
* Auto play on start
*/
@Serialize()
@Property({ type: 'boolean', label: 'Auto Play' })
public autoPlay: boolean = true;
/**
*
* Global playback speed
*/
@Serialize()
@Property({ type: 'number', label: 'Speed', min: 0, max: 10, step: 0.1 })
public speed: number = 1;
// Runtime state (not serialized)
private _currentClip: AnimationClip | null = null;
private _currentFrameIndex: number = 0;
private _frameTimer: number = 0;
private _isPlaying: boolean = false;
private _isPaused: boolean = false;
// Callbacks
private _onAnimationComplete?: (clipName: string) => void;
private _onFrameChange?: (frameIndex: number, frame: AnimationFrame) => void;
constructor() {
super();
}
/**
*
* Add animation clip
*/
addClip(clip: AnimationClip): void {
// Remove existing clip with same name
this.clips = this.clips.filter((c) => c.name !== clip.name);
this.clips.push(clip);
}
/**
*
* Create animation clip from sprite atlas
*
* @param name - | Animation name
* @param texture - | Texture path
* @param frameCount - | Number of frames
* @param frameWidth - | Frame width
* @param frameHeight - | Frame height
* @param atlasWidth - | Atlas width
* @param atlasHeight - | Atlas height
* @param fps - | Frames per second
* @param loop - | Whether to loop
*/
createClipFromAtlas(
name: string,
texture: string,
frameCount: number,
frameWidth: number,
frameHeight: number,
atlasWidth: number,
atlasHeight: number,
fps: number = 12,
loop: boolean = true
): AnimationClip {
const frames: AnimationFrame[] = [];
const duration = 1 / fps;
const cols = Math.floor(atlasWidth / frameWidth);
for (let i = 0; i < frameCount; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * frameWidth;
const y = row * frameHeight;
frames.push({
texture,
duration,
uv: [
x / atlasWidth,
y / atlasHeight,
(x + frameWidth) / atlasWidth,
(y + frameHeight) / atlasHeight
]
});
}
const clip: AnimationClip = {
name,
frames,
loop,
speed: 1
};
this.addClip(clip);
return clip;
}
/**
*
* Create animation clip from frame sequence
*
* @param name - | Animation name
* @param textures - | Array of texture paths
* @param fps - | Frames per second
* @param loop - | Whether to loop
*/
createClipFromSequence(
name: string,
textures: string[],
fps: number = 12,
loop: boolean = true
): AnimationClip {
const duration = 1 / fps;
const frames: AnimationFrame[] = textures.map((texture) => ({
texture,
duration
}));
const clip: AnimationClip = {
name,
frames,
loop,
speed: 1
};
this.addClip(clip);
return clip;
}
/**
*
* Play animation
*/
play(clipName?: string): void {
const name = clipName || this.defaultAnimation;
if (!name) return;
const clip = this.clips.find((c) => c.name === name);
if (!clip || clip.frames.length === 0) {
console.warn(`Animation clip not found: ${name}`);
return;
}
this._currentClip = clip;
this._currentFrameIndex = 0;
this._frameTimer = 0;
this._isPlaying = true;
this._isPaused = false;
this._notifyFrameChange();
}
/**
*
* Stop animation
*/
stop(): void {
this._isPlaying = false;
this._isPaused = false;
this._currentFrameIndex = 0;
this._frameTimer = 0;
}
/**
*
* Pause animation
*/
pause(): void {
if (this._isPlaying) {
this._isPaused = true;
}
}
/**
*
* Resume animation
*/
resume(): void {
if (this._isPlaying && this._isPaused) {
this._isPaused = false;
}
}
/**
*
* Update animation (called by system)
*/
update(deltaTime: number): void {
if (!this._isPlaying || this._isPaused || !this._currentClip) return;
const clip = this._currentClip;
const frame = clip.frames[this._currentFrameIndex];
if (!frame) return;
this._frameTimer += deltaTime * this.speed * clip.speed;
if (this._frameTimer >= frame.duration) {
this._frameTimer -= frame.duration;
this._currentFrameIndex++;
if (this._currentFrameIndex >= clip.frames.length) {
if (clip.loop) {
this._currentFrameIndex = 0;
} else {
this._currentFrameIndex = clip.frames.length - 1;
this._isPlaying = false;
this._onAnimationComplete?.(clip.name);
return;
}
}
this._notifyFrameChange();
}
}
/**
*
* Get current frame
*/
getCurrentFrame(): AnimationFrame | null {
if (!this._currentClip) return null;
return this._currentClip.frames[this._currentFrameIndex] || null;
}
/**
*
* Get current frame index
*/
getCurrentFrameIndex(): number {
return this._currentFrameIndex;
}
/**
*
* Set current frame
*/
setFrame(index: number): void {
if (!this._currentClip) return;
this._currentFrameIndex = Math.max(0, Math.min(index, this._currentClip.frames.length - 1));
this._frameTimer = 0;
this._notifyFrameChange();
}
/**
*
* Whether animation is playing
*/
isPlaying(): boolean {
return this._isPlaying && !this._isPaused;
}
/**
*
* Get current animation name
*/
getCurrentClipName(): string | null {
return this._currentClip?.name || null;
}
/**
*
* Set animation complete callback
*/
onAnimationComplete(callback: (clipName: string) => void): void {
this._onAnimationComplete = callback;
}
/**
*
* Set frame change callback
*/
onFrameChange(callback: (frameIndex: number, frame: AnimationFrame) => void): void {
this._onFrameChange = callback;
}
private _notifyFrameChange(): void {
const frame = this.getCurrentFrame();
if (frame) {
this._onFrameChange?.(this._currentFrameIndex, frame);
}
}
/**
*
* Get animation clip by name
*/
getClip(name: string): AnimationClip | undefined {
return this.clips.find((c) => c.name === name);
}
/**
*
* Remove animation clip
*/
removeClip(name: string): void {
this.clips = this.clips.filter((c) => c.name !== name);
if (this._currentClip?.name === name) {
this.stop();
this._currentClip = null;
}
}
/**
*
* Get all animation names
*/
getClipNames(): string[] {
return this.clips.map((c) => c.name);
}
}