370 lines
9.5 KiB
TypeScript
370 lines
9.5 KiB
TypeScript
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|