Files
esengine/packages/editor-app/src/infrastructure/field-editors/AnimationClipsFieldEditor.tsx

494 lines
21 KiB
TypeScript
Raw Normal View History

import React, { useState, useCallback, useEffect } from 'react';
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { Plus, Trash2, ChevronDown, ChevronRight, Film, Upload, Star, Play, Square } from 'lucide-react';
import type { AnimationClip, AnimationFrame, SpriteAnimatorComponent } from '@esengine/sprite';
import { AssetField } from '../../components/inspectors/fields/AssetField';
import { EngineService } from '../../services/EngineService';
interface DraggableNumberProps {
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
disabled?: boolean;
label: string;
}
function DraggableNumber({ value, min = 0, max = 10, step = 0.1, onChange, disabled, label }: DraggableNumberProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return;
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartValue(value);
e.preventDefault();
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartX;
const sensitivity = e.shiftKey ? 0.1 : 1;
let newValue = dragStartValue + delta * step * sensitivity;
newValue = Math.max(min, Math.min(max, newValue));
newValue = parseFloat(newValue.toFixed(2));
onChange(newValue);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
return (
<label className="clip-draggable-number">
<span
className="clip-draggable-label"
onMouseDown={handleMouseDown}
style={{ cursor: disabled ? 'default' : 'ew-resize' }}
>
{label}
</span>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(parseFloat(e.target.value) || 1)}
disabled={disabled}
/>
</label>
);
}
export class AnimationClipsFieldEditor implements IFieldEditor<AnimationClip[]> {
readonly type = 'animationClips';
readonly name = 'Animation Clips Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'animationClips';
}
render({ label, value, onChange, context }: FieldEditorProps<AnimationClip[]>): React.ReactElement {
return (
<AnimationClipsEditor
label={label}
clips={value || []}
onChange={onChange}
readonly={context.readonly}
component={context.metadata?.component as SpriteAnimatorComponent}
onDefaultAnimationChange={context.metadata?.onDefaultAnimationChange}
/>
);
}
}
interface AnimationClipsEditorProps {
label: string;
clips: AnimationClip[];
onChange: (clips: AnimationClip[]) => void;
readonly?: boolean;
component?: SpriteAnimatorComponent;
onDefaultAnimationChange?: (value: string) => void;
}
function AnimationClipsEditor({ label, clips, onChange, readonly, component, onDefaultAnimationChange }: AnimationClipsEditorProps) {
const [expandedClips, setExpandedClips] = useState<Set<number>>(new Set());
const [playingClip, setPlayingClip] = useState<string | null>(null);
const handleNavigate = useCallback((path: string) => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('asset:reveal', { path });
}
}, []);
const toggleClip = (index: number) => {
const newExpanded = new Set(expandedClips);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedClips(newExpanded);
};
const addClip = () => {
const newName = `Animation ${clips.length + 1}`;
const newClip: AnimationClip = {
name: newName,
frames: [],
loop: true,
speed: 1
};
onChange([...clips, newClip]);
setExpandedClips(new Set([...expandedClips, clips.length]));
// Auto-set first clip as default animation
if (clips.length === 0 && component && !component.defaultAnimation) {
component.defaultAnimation = newName;
setDefaultAnimation(newName);
if (onDefaultAnimationChange) {
onDefaultAnimationChange(newName);
}
}
};
const removeClip = (index: number) => {
const newClips = clips.filter((_, i) => i !== index);
onChange(newClips);
};
const updateClip = (index: number, updates: Partial<AnimationClip>) => {
const newClips = [...clips];
const existingClip = newClips[index];
if (!existingClip) return;
newClips[index] = { ...existingClip, ...updates } as AnimationClip;
onChange(newClips);
};
const addFrame = (clipIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
feat(asset): 统一资产引用使用 GUID 替代路径 (#287) * feat(world-streaming): 添加世界流式加载系统 实现基于区块的世界流式加载系统,支持开放世界游戏: 运行时包 (@esengine/world-streaming): - ChunkComponent: 区块实体组件,包含坐标、边界、状态 - StreamingAnchorComponent: 流式锚点组件(玩家/摄像机) - ChunkLoaderComponent: 流式加载配置组件 - ChunkStreamingSystem: 区块加载/卸载调度系统 - ChunkCullingSystem: 区块可见性剔除系统 - ChunkManager: 区块生命周期管理服务 - SpatialHashGrid: 空间哈希网格 - ChunkSerializer: 区块序列化 编辑器包 (@esengine/world-streaming-editor): - ChunkVisualizer: 区块可视化覆盖层 - ChunkLoaderInspectorProvider: 区块加载器检视器 - StreamingAnchorInspectorProvider: 流式锚点检视器 - WorldStreamingPlugin: 完整插件导出 * feat(asset): 统一资产引用使用 GUID 替代路径 将所有组件的资产引用字段从路径改为 GUID: - SpriteComponent: texture -> textureGuid, material -> materialGuid - SpriteAnimatorComponent: AnimationFrame.texture -> textureGuid - UIRenderComponent: texture -> textureGuid - UIButtonComponent: normalTexture -> normalTextureGuid 等 - AudioSourceComponent: clip -> clipGuid - ParticleSystemComponent: 已使用 textureGuid 修复 AssetRegistryService 注册问题和路径规范化, 添加渲染系统的 GUID 解析支持。 * fix(sprite-editor): 更新 material 为 materialGuid * fix(editor-app): 更新 AnimationFrame.texture 为 textureGuid
2025-12-06 14:08:48 +08:00
clip.frames = [...clip.frames, { textureGuid: '', duration: 0.1 }];
onChange(newClips);
};
const removeFrame = (clipIndex: number, frameIndex: number) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = clip.frames.filter((_, i) => i !== frameIndex);
onChange(newClips);
};
const updateFrame = (clipIndex: number, frameIndex: number, updates: Partial<AnimationFrame>) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
clip.frames = [...clip.frames];
const existingFrame = clip.frames[frameIndex];
if (!existingFrame) return;
clip.frames[frameIndex] = { ...existingFrame, ...updates } as AnimationFrame;
onChange(newClips);
};
const addFramesBatch = (clipIndex: number, texturePaths: string[]) => {
const newClips = [...clips];
const clip = newClips[clipIndex];
if (!clip) return;
feat(asset): 统一资产引用使用 GUID 替代路径 (#287) * feat(world-streaming): 添加世界流式加载系统 实现基于区块的世界流式加载系统,支持开放世界游戏: 运行时包 (@esengine/world-streaming): - ChunkComponent: 区块实体组件,包含坐标、边界、状态 - StreamingAnchorComponent: 流式锚点组件(玩家/摄像机) - ChunkLoaderComponent: 流式加载配置组件 - ChunkStreamingSystem: 区块加载/卸载调度系统 - ChunkCullingSystem: 区块可见性剔除系统 - ChunkManager: 区块生命周期管理服务 - SpatialHashGrid: 空间哈希网格 - ChunkSerializer: 区块序列化 编辑器包 (@esengine/world-streaming-editor): - ChunkVisualizer: 区块可视化覆盖层 - ChunkLoaderInspectorProvider: 区块加载器检视器 - StreamingAnchorInspectorProvider: 流式锚点检视器 - WorldStreamingPlugin: 完整插件导出 * feat(asset): 统一资产引用使用 GUID 替代路径 将所有组件的资产引用字段从路径改为 GUID: - SpriteComponent: texture -> textureGuid, material -> materialGuid - SpriteAnimatorComponent: AnimationFrame.texture -> textureGuid - UIRenderComponent: texture -> textureGuid - UIButtonComponent: normalTexture -> normalTextureGuid 等 - AudioSourceComponent: clip -> clipGuid - ParticleSystemComponent: 已使用 textureGuid 修复 AssetRegistryService 注册问题和路径规范化, 添加渲染系统的 GUID 解析支持。 * fix(sprite-editor): 更新 material 为 materialGuid * fix(editor-app): 更新 AnimationFrame.texture 为 textureGuid
2025-12-06 14:08:48 +08:00
const newFrames = texturePaths.map((textureGuid) => ({
textureGuid,
duration: 0.1
}));
clip.frames = [...clip.frames, ...newFrames];
onChange(newClips);
};
const handleFramesDrop = useCallback((clipIndex: number, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const data = e.dataTransfer.getData('application/json');
if (data) {
try {
const items = JSON.parse(data);
if (Array.isArray(items)) {
const textures = items
.filter((item: { type: string; path: string }) =>
item.type === 'file' && /\.(png|jpg|jpeg|webp|gif)$/i.test(item.path))
.map((item: { path: string }) => item.path)
.sort();
if (textures.length > 0) {
addFramesBatch(clipIndex, textures);
}
}
} catch {
// Try text data for single file
const text = e.dataTransfer.getData('text/plain');
if (text && /\.(png|jpg|jpeg|webp|gif)$/i.test(text)) {
addFramesBatch(clipIndex, [text]);
}
}
}
}, [clips, onChange]);
const handleFramesDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const [defaultAnimation, setDefaultAnimation] = useState(component?.defaultAnimation || '');
// Sync with component changes
useEffect(() => {
if (component) {
setDefaultAnimation(component.defaultAnimation || '');
}
}, [component?.defaultAnimation]);
const setAsDefaultAnimationHandler = (clipName: string) => {
if (component) {
component.defaultAnimation = clipName;
setDefaultAnimation(clipName);
// Notify parent to update the defaultAnimation field
if (onDefaultAnimationChange) {
onDefaultAnimationChange(clipName);
}
}
};
const isDefaultAnimation = (clipName: string) => {
return defaultAnimation === clipName;
};
const handlePlayPreview = (clipName: string) => {
if (component) {
const engineService = EngineService.getInstance();
// Get the actual component from scene entity (not the one passed as prop)
const scene = engineService.getScene();
const entityId = component.entityId;
let actualComponent = component;
if (scene && entityId !== undefined && entityId !== null) {
const sceneEntity = scene.findEntityById(entityId);
if (sceneEntity) {
const sceneAnimator = sceneEntity.getComponent(component.constructor as any);
if (sceneAnimator) {
actualComponent = sceneAnimator as SpriteAnimatorComponent;
}
}
}
if (playingClip === clipName) {
// Stop playing
actualComponent.stop();
setPlayingClip(null);
engineService.disableAnimationPreview();
} else {
// Stop previous animation if any
actualComponent.stop();
// Sync clips data to component before playing
actualComponent.clips = clips;
// Enable animation preview if not already enabled
if (!engineService.isAnimationPreviewEnabled()) {
engineService.enableAnimationPreview();
}
// Play this clip
actualComponent.play(clipName);
setPlayingClip(clipName);
}
}
};
// Sync playingClip state with actual component state
useEffect(() => {
if (component && playingClip) {
// Check if component is still playing
if (!component.isPlaying()) {
setPlayingClip(null);
}
}
});
// Stop preview when component unmounts
useEffect(() => {
return () => {
if (component) {
component.stop();
const engineService = EngineService.getInstance();
engineService.disableAnimationPreview();
}
};
}, [component]);
return (
<div className="animation-clips-editor">
<div className="clips-header">
<span className="clips-label">{label}</span>
{!readonly && (
<button className="add-clip-btn" onClick={addClip} title="Add Animation Clip">
<Plus size={12} />
</button>
)}
</div>
{clips.length === 0 ? (
<div className="clips-empty">
<Film size={24} strokeWidth={1} />
<span>No animation clips</span>
</div>
) : (
<div className="clips-list">
{clips.map((clip, clipIndex) => (
<div key={clipIndex} className="clip-item">
<div className="clip-header" onClick={() => toggleClip(clipIndex)}>
{expandedClips.has(clipIndex) ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
<Film size={14} />
<input
className="clip-name-input"
value={clip.name}
onChange={(e) => {
e.stopPropagation();
updateClip(clipIndex, { name: e.target.value });
}}
onClick={(e) => e.stopPropagation()}
disabled={readonly}
/>
<span className="frame-count">{clip.frames.length} frames</span>
{component && clip.frames.length > 0 && (
<button
className={`preview-clip-btn ${playingClip === clip.name ? 'is-playing' : ''}`}
onClick={(e) => {
e.stopPropagation();
handlePlayPreview(clip.name);
}}
title={playingClip === clip.name ? 'Stop Preview' : 'Preview Animation'}
>
{playingClip === clip.name ? <Square size={10} /> : <Play size={10} />}
</button>
)}
{component && !readonly && (
<button
className={`set-default-btn ${isDefaultAnimation(clip.name) ? 'is-default' : ''}`}
onClick={(e) => {
e.stopPropagation();
setAsDefaultAnimationHandler(clip.name);
}}
title={isDefaultAnimation(clip.name) ? 'Current Default Animation' : 'Set as Default Animation'}
>
<Star size={12} fill={isDefaultAnimation(clip.name) ? 'currentColor' : 'none'} />
</button>
)}
{!readonly && (
<button
className="remove-clip-btn"
onClick={(e) => {
e.stopPropagation();
removeClip(clipIndex);
}}
title="Remove Clip"
>
<Trash2 size={12} />
</button>
)}
</div>
{expandedClips.has(clipIndex) && (
<div className="clip-content">
<div className="clip-settings">
<label>
<input
type="checkbox"
checked={clip.loop}
onChange={(e) => updateClip(clipIndex, { loop: e.target.checked })}
disabled={readonly}
/>
Loop
</label>
<DraggableNumber
label="Speed:"
value={clip.speed}
min={0}
max={10}
step={0.1}
onChange={(val) => updateClip(clipIndex, { speed: val })}
disabled={readonly}
/>
</div>
<div
className="frames-section"
onDrop={(e) => handleFramesDrop(clipIndex, e)}
onDragOver={handleFramesDragOver}
>
<div className="frames-header">
<span>Frames</span>
{!readonly && (
<button onClick={() => addFrame(clipIndex)} title="Add Frame">
<Plus size={10} />
</button>
)}
</div>
{clip.frames.length === 0 ? (
<div className="frames-empty frames-drop-zone">
<Upload size={16} />
<span>Drop images here or click + to add</span>
</div>
) : (
<div className="frames-list">
{clip.frames.map((frame, frameIndex) => (
<div key={frameIndex} className="frame-item">
<span className="frame-index">{frameIndex + 1}</span>
<div className="frame-texture-field">
<AssetField
feat(asset): 统一资产引用使用 GUID 替代路径 (#287) * feat(world-streaming): 添加世界流式加载系统 实现基于区块的世界流式加载系统,支持开放世界游戏: 运行时包 (@esengine/world-streaming): - ChunkComponent: 区块实体组件,包含坐标、边界、状态 - StreamingAnchorComponent: 流式锚点组件(玩家/摄像机) - ChunkLoaderComponent: 流式加载配置组件 - ChunkStreamingSystem: 区块加载/卸载调度系统 - ChunkCullingSystem: 区块可见性剔除系统 - ChunkManager: 区块生命周期管理服务 - SpatialHashGrid: 空间哈希网格 - ChunkSerializer: 区块序列化 编辑器包 (@esengine/world-streaming-editor): - ChunkVisualizer: 区块可视化覆盖层 - ChunkLoaderInspectorProvider: 区块加载器检视器 - StreamingAnchorInspectorProvider: 流式锚点检视器 - WorldStreamingPlugin: 完整插件导出 * feat(asset): 统一资产引用使用 GUID 替代路径 将所有组件的资产引用字段从路径改为 GUID: - SpriteComponent: texture -> textureGuid, material -> materialGuid - SpriteAnimatorComponent: AnimationFrame.texture -> textureGuid - UIRenderComponent: texture -> textureGuid - UIButtonComponent: normalTexture -> normalTextureGuid 等 - AudioSourceComponent: clip -> clipGuid - ParticleSystemComponent: 已使用 textureGuid 修复 AssetRegistryService 注册问题和路径规范化, 添加渲染系统的 GUID 解析支持。 * fix(sprite-editor): 更新 material 为 materialGuid * fix(editor-app): 更新 AnimationFrame.texture 为 textureGuid
2025-12-06 14:08:48 +08:00
value={frame.textureGuid}
onChange={(val) => updateFrame(clipIndex, frameIndex, { textureGuid: val || '' })}
fileExtension=".png"
placeholder="Texture..."
readonly={readonly}
onNavigate={handleNavigate}
/>
</div>
<input
className="frame-duration"
type="number"
min={0.01}
step={0.01}
value={frame.duration}
onChange={(e) => updateFrame(clipIndex, frameIndex, { duration: parseFloat(e.target.value) || 0.1 })}
disabled={readonly}
title="Duration (seconds)"
/>
{!readonly && (
<button
onClick={() => removeFrame(clipIndex, frameIndex)}
title="Remove Frame"
>
<Trash2 size={10} />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}