Feature/render pipeline (#232)
* refactor(engine): 重构2D渲染管线坐标系统 * feat(engine): 完善2D渲染管线和编辑器视口功能 * feat(editor): 实现Viewport变换工具系统 * feat(editor): 优化Inspector渲染性能并修复Gizmo变换工具显示 * feat(editor): 实现Run on Device移动预览功能 * feat(editor): 添加组件属性控制和依赖关系系统 * feat(editor): 实现动画预览功能和优化SpriteAnimator编辑器 * feat(editor): 修复SpriteAnimator动画预览功能并迁移CI到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(editor): 修复SpriteAnimator动画预览并迁移到pnpm * feat(ci): 迁移项目到pnpm并修复CI构建问题 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 迁移CI工作流到pnpm并添加WASM构建支持 * chore: 移除 network 相关包 * chore: 移除 network 相关包
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
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/ecs-components';
|
||||
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;
|
||||
clip.frames = [...clip.frames, { texture: '', 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;
|
||||
const newFrames = texturePaths.map((texture) => ({
|
||||
texture,
|
||||
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
|
||||
value={frame.texture}
|
||||
onChange={(val) => updateFrame(clipIndex, frameIndex, { texture: 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
import { IFieldEditor, FieldEditorProps, MessageHub } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetField } from '../../components/inspectors/fields/AssetField';
|
||||
|
||||
export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
@@ -15,6 +16,13 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
const fileExtension = context.metadata?.fileExtension || '';
|
||||
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AssetField
|
||||
label={label}
|
||||
@@ -23,7 +31,8 @@ export class AssetFieldEditor implements IFieldEditor<string | null> {
|
||||
fileExtension={fileExtension}
|
||||
placeholder={placeholder}
|
||||
readonly={context.readonly}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,4 +196,4 @@ export class ColorFieldEditor implements IFieldEditor<Color> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './AssetFieldEditor';
|
||||
export * from './VectorFieldEditors';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './AnimationClipsFieldEditor';
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Settings size={14} style={{ marginLeft: "4px", color: "#888" }} />
|
||||
<Settings size={14} style={{ marginLeft: '4px', color: '#888' }} />
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
@@ -86,4 +86,4 @@ export class ComponentRenderer implements IPropertyRenderer<ComponentData> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
|
||||
);
|
||||
}
|
||||
|
||||
const isStringArray = value.every(item => typeof item === 'string');
|
||||
const isStringArray = value.every((item) => typeof item === 'string');
|
||||
if (isStringArray && value.length <= 5) {
|
||||
return (
|
||||
<div className="property-field">
|
||||
@@ -136,4 +136,4 @@ export class ArrayRenderer implements IPropertyRenderer<any[]> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,4 @@ export class NullRenderer implements IPropertyRenderer<null> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './PrimitiveRenderers';
|
||||
export * from './VectorRenderers';
|
||||
export * from './ComponentRenderer';
|
||||
export * from './FallbackRenderer';
|
||||
export * from './FallbackRenderer';
|
||||
|
||||
Reference in New Issue
Block a user