refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 粒子编辑器模块
|
||||
* Particle Editor Module
|
||||
*
|
||||
* Registers file handlers, panels, and templates for .particle files.
|
||||
*/
|
||||
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentInspectorProviderDef,
|
||||
FileActionHandler,
|
||||
FileCreationTemplate,
|
||||
IEditorPlugin,
|
||||
ModuleManifest
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
PanelPosition,
|
||||
InspectorRegistry,
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
EditorComponentRegistry,
|
||||
FileActionRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import {
|
||||
ParticleSystemComponent,
|
||||
ParticleRuntimeModule,
|
||||
createDefaultParticleAsset
|
||||
} from '@esengine/particle';
|
||||
|
||||
import { ParticleEditorPanel } from './panels/ParticleEditorPanel';
|
||||
import { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
|
||||
import { useParticleEditorStore } from './stores/ParticleEditorStore';
|
||||
import { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';
|
||||
|
||||
// 导入编辑器 CSS 样式(会被 vite 自动处理并注入到 DOM)
|
||||
// Import editor CSS styles (automatically handled and injected by vite)
|
||||
import './styles/ParticleEditor.css';
|
||||
|
||||
/**
|
||||
* 粒子编辑器模块
|
||||
* Particle Editor Module
|
||||
*/
|
||||
export class ParticleEditorModule implements IEditorModuleLoader {
|
||||
private _assetsRefreshUnsubscribe: (() => void) | null = null;
|
||||
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册检视器提供者 | Register inspector provider
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
inspectorRegistry.register(new ParticleInspectorProvider());
|
||||
}
|
||||
|
||||
// 注册组件到编辑器组件注册表 | Register to editor component registry
|
||||
const componentRegistry = services.resolve(EditorComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
componentRegistry.register({
|
||||
name: 'ParticleSystem',
|
||||
type: ParticleSystemComponent,
|
||||
category: 'components.category.effects',
|
||||
description: 'Particle system for 2D visual effects',
|
||||
icon: 'Sparkles'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册资产创建消息映射 | Register asset creation message mappings
|
||||
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||
if (fileActionRegistry) {
|
||||
fileActionRegistry.registerAssetCreationMapping({
|
||||
extension: '.particle',
|
||||
createMessage: 'particle:create-asset'
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Gizmo | Register gizmo
|
||||
registerParticleGizmo();
|
||||
|
||||
// 监听资产刷新事件,当 .particle 文件保存时重新加载所有粒子组件
|
||||
// Listen for assets refresh event to reload particle components when .particle files are saved
|
||||
const messageHub = services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
this._assetsRefreshUnsubscribe = messageHub.subscribe('assets:refresh', () => {
|
||||
this._reloadAllParticleAssets();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 取消订阅事件 | Unsubscribe events
|
||||
if (this._assetsRefreshUnsubscribe) {
|
||||
this._assetsRefreshUnsubscribe();
|
||||
this._assetsRefreshUnsubscribe = null;
|
||||
}
|
||||
|
||||
// 取消注册 Gizmo | Unregister gizmo
|
||||
unregisterParticleGizmo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载所有粒子资产
|
||||
* Reload all particle assets
|
||||
*
|
||||
* 当资产文件变化时调用,强制所有粒子组件重新加载资产。
|
||||
* Called when asset files change, forcing all particle components to reload.
|
||||
*/
|
||||
private _reloadAllParticleAssets(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 遍历所有带有 ParticleSystemComponent 的实体
|
||||
// Iterate all entities with ParticleSystemComponent
|
||||
scene.entities.forEach((entity: Entity) => {
|
||||
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
|
||||
if (particle && particle.particleAssetGuid) {
|
||||
// 异步重新加载资产 | Async reload asset
|
||||
particle.reloadAsset().then((success: boolean) => {
|
||||
if (success) {
|
||||
console.log(`[ParticleEditorModule] Reloaded particle asset for entity: ${entity.name}`);
|
||||
// 标记需要重建并重新播放 | Mark dirty and replay
|
||||
particle.markDirty();
|
||||
if (particle.isPlaying) {
|
||||
particle.stop(true);
|
||||
particle.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'particle-editor',
|
||||
title: 'Particle Editor',
|
||||
position: PanelPosition.Center,
|
||||
closable: true,
|
||||
component: ParticleEditorPanel,
|
||||
isDynamic: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getInspectorProviders(): ComponentInspectorProviderDef[] {
|
||||
return [
|
||||
{
|
||||
componentType: 'ParticleSystem',
|
||||
priority: 100,
|
||||
render: (component, entity, onChange) => {
|
||||
const provider = new ParticleInspectorProvider();
|
||||
return provider.render(
|
||||
{ entityId: String(entity.id), component },
|
||||
{ target: component, onChange }
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-particle-entity',
|
||||
label: '创建粒子效果',
|
||||
icon: 'Sparkles',
|
||||
category: 'effects',
|
||||
order: 100,
|
||||
create: (): number => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const particleCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith('ParticleSystem ')).length;
|
||||
const entityName = `ParticleSystem ${particleCount + 1}`;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new ParticleSystemComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileActionHandlers(): FileActionHandler[] {
|
||||
return [
|
||||
{
|
||||
extensions: ['particle', 'json'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 只处理 .particle 和 .particle.json 文件
|
||||
// Only handle .particle and .particle.json files
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (!lowerPath.endsWith('.particle') && !lowerPath.endsWith('.particle.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先设置待打开的文件路径到 store
|
||||
// Set pending file path to store first
|
||||
useParticleEditorStore.getState().setPendingFilePath(filePath);
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
// 打开粒子编辑器面板(面板挂载后会从 store 读取 pendingFilePath)
|
||||
// Open particle editor panel (panel will read pendingFilePath from store after mount)
|
||||
messageHub.publish('dynamic-panel:open', {
|
||||
panelId: 'particle-editor',
|
||||
title: `Particle Editor - ${filePath.split(/[\\/]/).pop()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileCreationTemplates(): FileCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-particle',
|
||||
label: 'Particle Effect',
|
||||
extension: 'particle',
|
||||
icon: 'Sparkles',
|
||||
category: 'effects',
|
||||
getContent: (fileName: string) => {
|
||||
const assetData = createDefaultParticleAsset(fileName.replace('.particle', ''));
|
||||
return JSON.stringify(assetData, null, 2);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const particleEditorModule = new ParticleEditorModule();
|
||||
|
||||
/**
|
||||
* 粒子插件清单
|
||||
* Particle Plugin Manifest
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/particle',
|
||||
name: '@esengine/particle',
|
||||
displayName: 'Particle System',
|
||||
version: '1.0.0',
|
||||
description: 'Particle system for 2D visual effects',
|
||||
category: 'Rendering',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['ParticleSystemComponent'],
|
||||
systems: ['ParticleUpdateSystem'],
|
||||
loaders: ['ParticleLoader']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的粒子插件(运行时 + 编辑器)
|
||||
* Complete Particle Plugin (runtime + editor)
|
||||
*/
|
||||
export const ParticlePlugin: IEditorPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new ParticleRuntimeModule(),
|
||||
editorModule: particleEditorModule
|
||||
};
|
||||
|
||||
export default particleEditorModule;
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* 曲线编辑器组件
|
||||
* Curve Editor Component
|
||||
*
|
||||
* A visual editor for animation curves used in particle systems.
|
||||
* 用于粒子系统的动画曲线可视化编辑器。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import type { ScaleKey } from '@esengine/particle';
|
||||
import { ScaleCurveType } from '@esengine/particle';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
|
||||
interface CurveEditorProps {
|
||||
/** 曲线关键帧 | Curve keyframes */
|
||||
keys: ScaleKey[];
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (keys: ScaleKey[]) => void;
|
||||
/** 曲线类型 | Curve type */
|
||||
curveType: ScaleCurveType;
|
||||
/** 曲线类型变化回调 | Curve type change callback */
|
||||
onCurveTypeChange?: (type: ScaleCurveType) => void;
|
||||
/** Y 轴最小值 | Y-axis minimum */
|
||||
minY?: number;
|
||||
/** Y 轴最大值 | Y-axis maximum */
|
||||
maxY?: number;
|
||||
}
|
||||
|
||||
// 内边距 | Padding
|
||||
const PADDING = { left: 28, right: 8, top: 8, bottom: 16 };
|
||||
const POINT_RADIUS = 5;
|
||||
const HIT_RADIUS = 10;
|
||||
|
||||
/**
|
||||
* 曲线编辑器
|
||||
* Curve Editor
|
||||
*/
|
||||
export function CurveEditor({
|
||||
keys,
|
||||
onChange,
|
||||
curveType,
|
||||
onCurveTypeChange,
|
||||
minY = 0,
|
||||
maxY = 2,
|
||||
}: CurveEditorProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
// 监听容器大小变化 | Watch container size changes
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateSize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
setCanvasSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// 获取绘图区域 | Get drawing area
|
||||
const getDrawArea = useCallback(() => {
|
||||
return {
|
||||
x: PADDING.left,
|
||||
y: PADDING.top,
|
||||
width: canvasSize.width - PADDING.left - PADDING.right,
|
||||
height: canvasSize.height - PADDING.top - PADDING.bottom,
|
||||
};
|
||||
}, [canvasSize]);
|
||||
|
||||
// 数据坐标转画布坐标 | Data to canvas coordinates
|
||||
const dataToCanvas = useCallback((time: number, scale: number) => {
|
||||
const area = getDrawArea();
|
||||
return {
|
||||
x: area.x + time * area.width,
|
||||
y: area.y + area.height - ((scale - minY) / (maxY - minY)) * area.height,
|
||||
};
|
||||
}, [getDrawArea, minY, maxY]);
|
||||
|
||||
// 画布坐标转数据坐标 | Canvas to data coordinates
|
||||
const canvasToData = useCallback((canvasX: number, canvasY: number) => {
|
||||
const area = getDrawArea();
|
||||
const time = Math.max(0, Math.min(1, (canvasX - area.x) / area.width));
|
||||
const scale = maxY - ((canvasY - area.y) / area.height) * (maxY - minY);
|
||||
return {
|
||||
time,
|
||||
scale: Math.max(minY, Math.min(maxY, scale)),
|
||||
};
|
||||
}, [getDrawArea, minY, maxY]);
|
||||
|
||||
// 获取鼠标在 canvas 上的坐标 | Get mouse position on canvas
|
||||
const getMousePos = useCallback((e: React.MouseEvent | MouseEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 查找点击的关键帧 | Find clicked keyframe
|
||||
const findKeyAtPos = useCallback((x: number, y: number): number => {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const pos = dataToCanvas(keys[i].time, keys[i].scale);
|
||||
const dx = pos.x - x;
|
||||
const dy = pos.y - y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < HIT_RADIUS) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [keys, dataToCanvas]);
|
||||
|
||||
// 绘制曲线 | Draw curve
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvasSize.width === 0) return;
|
||||
|
||||
// 设置 canvas 实际像素大小 | Set canvas pixel size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvasSize.width * dpr;
|
||||
canvas.height = canvasSize.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const area = getDrawArea();
|
||||
|
||||
// 清空画布 | Clear canvas
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
|
||||
|
||||
// 绘制绘图区域背景 | Draw area background
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.fillRect(area.x, area.y, area.width, area.height);
|
||||
|
||||
// 绘制网格 | Draw grid
|
||||
ctx.strokeStyle = '#2a2a2a';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 垂直网格线 (0, 0.25, 0.5, 0.75, 1) | Vertical grid lines
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const x = Math.floor(area.x + (i / 4) * area.width) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, area.y);
|
||||
ctx.lineTo(x, area.y + area.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 水平网格线 | Horizontal grid lines
|
||||
const ySteps = 4;
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
const y = Math.floor(area.y + (i / ySteps) * area.height) + 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(area.x, y);
|
||||
ctx.lineTo(area.x + area.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制 1.0 参考线(如果在范围内)| Draw 1.0 reference line
|
||||
if (minY <= 1 && maxY >= 1) {
|
||||
const onePos = dataToCanvas(0, 1);
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(area.x, onePos.y);
|
||||
ctx.lineTo(area.x + area.width, onePos.y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// 绘制 Y 轴标签 | Draw Y-axis labels
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
const value = maxY - (i / ySteps) * (maxY - minY);
|
||||
const y = area.y + (i / ySteps) * area.height;
|
||||
ctx.fillText(value.toFixed(1), area.x - 4, y);
|
||||
}
|
||||
|
||||
// 绘制 X 轴标签 | Draw X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText('0', area.x, area.y + area.height + 2);
|
||||
ctx.fillText('0.5', area.x + area.width / 2, area.y + area.height + 2);
|
||||
ctx.fillText('1', area.x + area.width, area.y + area.height + 2);
|
||||
|
||||
// 绘制曲线 | Draw curve
|
||||
if (keys.length > 0) {
|
||||
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
|
||||
|
||||
ctx.strokeStyle = '#4a9eff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
// 采样曲线 | Sample curve
|
||||
const samples = 100;
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples;
|
||||
const value = evaluateCurve(sortedKeys, t, curveType);
|
||||
const pos = dataToCanvas(t, value);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
} else {
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制关键帧点 | Draw keyframe points
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const pos = dataToCanvas(key.time, key.scale);
|
||||
const isSelected = selectedIndex === i;
|
||||
const isHovered = hoverIndex === i;
|
||||
|
||||
// 外圈 | Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, POINT_RADIUS + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSelected ? '#4a9eff' : (isHovered ? '#666' : 'transparent');
|
||||
ctx.fill();
|
||||
|
||||
// 内圈 | Inner circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, POINT_RADIUS, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSelected ? '#fff' : '#4a9eff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [keys, curveType, minY, maxY, selectedIndex, hoverIndex, canvasSize, getDrawArea, dataToCanvas]);
|
||||
|
||||
// 处理鼠标移动(悬停效果)| Handle mouse move (hover effect)
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (isDragging) return;
|
||||
const pos = getMousePos(e);
|
||||
const index = findKeyAtPos(pos.x, pos.y);
|
||||
setHoverIndex(index >= 0 ? index : null);
|
||||
}, [isDragging, getMousePos, findKeyAtPos]);
|
||||
|
||||
// 处理鼠标离开 | Handle mouse leave
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!isDragging) {
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
// 处理点击 | Handle click
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
const index = findKeyAtPos(pos.x, pos.y);
|
||||
|
||||
if (index >= 0) {
|
||||
// 选中现有点 | Select existing point
|
||||
setSelectedIndex(index);
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
// 检查是否在绘图区域内 | Check if in draw area
|
||||
const area = getDrawArea();
|
||||
if (pos.x >= area.x && pos.x <= area.x + area.width &&
|
||||
pos.y >= area.y && pos.y <= area.y + area.height) {
|
||||
// 添加新点 | Add new point
|
||||
const data = canvasToData(pos.x, pos.y);
|
||||
const newKey: ScaleKey = { time: data.time, scale: data.scale };
|
||||
const newKeys = [...keys, newKey].sort((a, b) => a.time - b.time);
|
||||
const newIndex = newKeys.findIndex(k => k.time === data.time && k.scale === data.scale);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(newIndex);
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
}, [getMousePos, findKeyAtPos, getDrawArea, canvasToData, keys, onChange]);
|
||||
|
||||
// 全局拖拽处理 | Global drag handling
|
||||
useEffect(() => {
|
||||
if (!isDragging || selectedIndex === null) return;
|
||||
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
const pos = getMousePos(e);
|
||||
const data = canvasToData(pos.x, pos.y);
|
||||
|
||||
// 更新选中点的位置 | Update selected point position
|
||||
const newKeys = keys.map((key, i) => {
|
||||
if (i === selectedIndex) {
|
||||
return { time: data.time, scale: data.scale };
|
||||
}
|
||||
return key;
|
||||
});
|
||||
|
||||
// 保持当前选中的点,不重新排序(拖拽时保持索引)
|
||||
// Keep current selection, don't resort during drag
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
// 拖拽结束后排序 | Sort after drag ends
|
||||
const sortedKeys = [...keys].sort((a, b) => a.time - b.time);
|
||||
if (selectedIndex !== null) {
|
||||
const selectedKey = keys[selectedIndex];
|
||||
const newIndex = sortedKeys.findIndex(
|
||||
k => k.time === selectedKey.time && k.scale === selectedKey.scale
|
||||
);
|
||||
setSelectedIndex(newIndex >= 0 ? newIndex : null);
|
||||
}
|
||||
onChange(sortedKeys);
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [isDragging, selectedIndex, keys, onChange, getMousePos, canvasToData]);
|
||||
|
||||
// 删除选中关键帧 | Delete selected keyframe
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedIndex === null || keys.length <= 1) return;
|
||||
const newKeys = keys.filter((_, i) => i !== selectedIndex);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(null);
|
||||
}, [selectedIndex, keys, onChange]);
|
||||
|
||||
// 处理数值输入 | Handle value input
|
||||
const handleValueChange = useCallback((field: 'time' | 'scale', inputValue: string) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
const newKeys = [...keys];
|
||||
if (field === 'time') {
|
||||
newKeys[selectedIndex] = {
|
||||
...newKeys[selectedIndex],
|
||||
time: Math.max(0, Math.min(1, numValue))
|
||||
};
|
||||
} else {
|
||||
newKeys[selectedIndex] = {
|
||||
...newKeys[selectedIndex],
|
||||
scale: Math.max(minY, Math.min(maxY, numValue))
|
||||
};
|
||||
}
|
||||
const sortedKeys = newKeys.sort((a, b) => a.time - b.time);
|
||||
const newIndex = sortedKeys.findIndex(
|
||||
k => k.time === newKeys[selectedIndex].time && k.scale === newKeys[selectedIndex].scale
|
||||
);
|
||||
onChange(sortedKeys);
|
||||
setSelectedIndex(newIndex >= 0 ? newIndex : null);
|
||||
}, [selectedIndex, keys, onChange, minY, maxY]);
|
||||
|
||||
// 应用预设 | Apply preset
|
||||
const applyPreset = useCallback((preset: ScaleKey[]) => {
|
||||
onChange(preset);
|
||||
setSelectedIndex(null);
|
||||
}, [onChange]);
|
||||
|
||||
const selectedKey = selectedIndex !== null ? keys[selectedIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="curve-editor">
|
||||
{/* 曲线类型选择 | Curve type selector */}
|
||||
{onCurveTypeChange && (
|
||||
<div className="curve-type-row">
|
||||
<label>Curve</label>
|
||||
<select
|
||||
value={curveType}
|
||||
onChange={e => onCurveTypeChange(e.target.value as ScaleCurveType)}
|
||||
className="curve-type-select"
|
||||
>
|
||||
<option value={ScaleCurveType.Linear}>Linear</option>
|
||||
<option value={ScaleCurveType.EaseIn}>Ease In</option>
|
||||
<option value={ScaleCurveType.EaseOut}>Ease Out</option>
|
||||
<option value={ScaleCurveType.EaseInOut}>Ease In Out</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 曲线画布 | Curve canvas */}
|
||||
<div ref={containerRef} className="curve-canvas-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="curve-canvas"
|
||||
style={{ cursor: isDragging ? 'grabbing' : (hoverIndex !== null ? 'grab' : 'crosshair') }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 编辑面板 | Edit panel */}
|
||||
<div className="curve-edit-row">
|
||||
{selectedKey ? (
|
||||
<>
|
||||
<label>T</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={selectedKey.time.toFixed(2)}
|
||||
onChange={e => handleValueChange('time', e.target.value)}
|
||||
className="curve-value-input"
|
||||
/>
|
||||
<label>V</label>
|
||||
<input
|
||||
type="number"
|
||||
min={minY}
|
||||
max={maxY}
|
||||
step={0.1}
|
||||
value={selectedKey.scale.toFixed(2)}
|
||||
onChange={e => handleValueChange('scale', e.target.value)}
|
||||
className="curve-value-input"
|
||||
/>
|
||||
<button
|
||||
className="curve-delete-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={keys.length <= 1}
|
||||
title="Delete point"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="curve-hint">Click to add point</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设按钮 | Preset buttons */}
|
||||
<div className="curve-presets">
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 1 }])}
|
||||
title="Constant value"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 1, scale: 1 }])}
|
||||
title="Fade in"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 1, scale: 0 }])}
|
||||
title="Fade out"
|
||||
>
|
||||
↘
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 0 }, { time: 0.5, scale: 1 }, { time: 1, scale: 0 }])}
|
||||
title="Bell curve"
|
||||
>
|
||||
∩
|
||||
</button>
|
||||
<button
|
||||
className="curve-preset-btn"
|
||||
onClick={() => applyPreset([{ time: 0, scale: 1 }, { time: 0.5, scale: 0 }, { time: 1, scale: 1 }])}
|
||||
title="U curve"
|
||||
>
|
||||
∪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算曲线值
|
||||
* Evaluate curve value
|
||||
*/
|
||||
function evaluateCurve(keys: ScaleKey[], t: number, curveType: ScaleCurveType): number {
|
||||
if (keys.length === 0) return 1;
|
||||
if (keys.length === 1) return keys[0].scale;
|
||||
|
||||
// 查找相邻关键帧 | Find adjacent keyframes
|
||||
let left = keys[0];
|
||||
let right = keys[keys.length - 1];
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (keys[i].time <= t && keys[i + 1].time >= t) {
|
||||
left = keys[i];
|
||||
right = keys[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (t <= left.time) return left.scale;
|
||||
if (t >= right.time) return right.scale;
|
||||
|
||||
// 计算插值因子 | Calculate interpolation factor
|
||||
let factor = (t - left.time) / (right.time - left.time);
|
||||
|
||||
// 应用缓动函数 | Apply easing function
|
||||
switch (curveType) {
|
||||
case ScaleCurveType.EaseIn:
|
||||
factor = factor * factor;
|
||||
break;
|
||||
case ScaleCurveType.EaseOut:
|
||||
factor = 1 - (1 - factor) * (1 - factor);
|
||||
break;
|
||||
case ScaleCurveType.EaseInOut:
|
||||
factor = factor < 0.5
|
||||
? 2 * factor * factor
|
||||
: 1 - 2 * (1 - factor) * (1 - factor);
|
||||
break;
|
||||
// Linear - no modification
|
||||
}
|
||||
|
||||
return left.scale + (right.scale - left.scale) * factor;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 渐变编辑器组件
|
||||
* Gradient Editor Component
|
||||
*
|
||||
* A visual editor for color gradients used in particle systems.
|
||||
* 用于粒子系统的颜色渐变可视化编辑器。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { ColorKey } from '@esengine/particle';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface GradientEditorProps {
|
||||
/** 颜色关键帧 | Color keyframes */
|
||||
colorKeys: ColorKey[];
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (keys: ColorKey[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渐变编辑器
|
||||
* Gradient Editor
|
||||
*/
|
||||
export function GradientEditor({
|
||||
colorKeys,
|
||||
onChange,
|
||||
}: GradientEditorProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 生成 CSS 渐变 | Generate CSS gradient
|
||||
const gradientStyle = useCallback(() => {
|
||||
if (colorKeys.length === 0) {
|
||||
return 'linear-gradient(to right, #ffffff, #ffffff)';
|
||||
}
|
||||
if (colorKeys.length === 1) {
|
||||
const c = colorKeys[0];
|
||||
const color = `rgba(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)}, ${c.a})`;
|
||||
return `linear-gradient(to right, ${color}, ${color})`;
|
||||
}
|
||||
|
||||
const sorted = [...colorKeys].sort((a, b) => a.time - b.time);
|
||||
const stops = sorted.map(k => {
|
||||
const color = `rgba(${Math.round(k.r * 255)}, ${Math.round(k.g * 255)}, ${Math.round(k.b * 255)}, ${k.a})`;
|
||||
return `${color} ${k.time * 100}%`;
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [colorKeys]);
|
||||
|
||||
// 处理点击添加关键帧 | Handle click to add keyframe
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!trackRef.current) return;
|
||||
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const time = Math.max(0, Math.min(1, x / width));
|
||||
|
||||
// 检查是否点击了现有关键帧 | Check if clicking existing keyframe
|
||||
const threshold = 10;
|
||||
const clickedIndex = colorKeys.findIndex(k => {
|
||||
const keyX = k.time * width;
|
||||
return Math.abs(keyX - x) < threshold;
|
||||
});
|
||||
|
||||
if (clickedIndex >= 0) {
|
||||
setSelectedIndex(clickedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新关键帧 | Add new keyframe
|
||||
const interpolatedColor = interpolateColor(colorKeys, time);
|
||||
const newKey: ColorKey = {
|
||||
time,
|
||||
...interpolatedColor
|
||||
};
|
||||
|
||||
const newKeys = [...colorKeys, newKey].sort((a, b) => a.time - b.time);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(newKeys.findIndex(k => k.time === time));
|
||||
};
|
||||
|
||||
// 处理拖拽开始 | Handle drag start
|
||||
const handleStopMouseDown = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIndex(index);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!trackRef.current || selectedIndex === null) return;
|
||||
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const time = Math.max(0, Math.min(1, x / width));
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], time };
|
||||
onChange(newKeys.sort((a, b) => a.time - b.time));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, selectedIndex, colorKeys, onChange]);
|
||||
|
||||
// 处理颜色变化 | Handle color change
|
||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const hex = e.target.value;
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], r, g, b };
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
// 处理 alpha 变化 | Handle alpha change
|
||||
const handleAlphaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const newKeys = [...colorKeys];
|
||||
newKeys[selectedIndex] = { ...newKeys[selectedIndex], a: parseFloat(e.target.value) };
|
||||
onChange(newKeys);
|
||||
};
|
||||
|
||||
// 删除选中关键帧 | Delete selected keyframe
|
||||
const handleDelete = () => {
|
||||
if (selectedIndex === null || colorKeys.length <= 1) return;
|
||||
|
||||
const newKeys = colorKeys.filter((_, i) => i !== selectedIndex);
|
||||
onChange(newKeys);
|
||||
setSelectedIndex(null);
|
||||
};
|
||||
|
||||
// 获取选中关键帧的十六进制颜色 | Get selected keyframe hex color
|
||||
const selectedKey = selectedIndex !== null ? colorKeys[selectedIndex] : null;
|
||||
const selectedHex = selectedKey
|
||||
? `#${Math.round(selectedKey.r * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.g * 255).toString(16).padStart(2, '0')}${Math.round(selectedKey.b * 255).toString(16).padStart(2, '0')}`
|
||||
: '#ffffff';
|
||||
|
||||
return (
|
||||
<div className="gradient-editor">
|
||||
{/* 渐变条 | Gradient bar */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="gradient-track"
|
||||
style={{ background: gradientStyle() }}
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{/* 棋盘格背景 | Checkerboard background */}
|
||||
<div className="gradient-track-checker" />
|
||||
|
||||
{/* 关键帧手柄 | Keyframe handles */}
|
||||
{colorKeys.map((key, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`gradient-stop ${selectedIndex === index ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: `${key.time * 100}%`,
|
||||
backgroundColor: `rgba(${Math.round(key.r * 255)}, ${Math.round(key.g * 255)}, ${Math.round(key.b * 255)}, ${key.a})`,
|
||||
}}
|
||||
onMouseDown={e => handleStopMouseDown(e, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 编辑面板 | Edit panel */}
|
||||
{selectedKey && (
|
||||
<div className="gradient-color-row">
|
||||
<label>Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={selectedHex}
|
||||
onChange={handleColorChange}
|
||||
className="gradient-color-input"
|
||||
/>
|
||||
<label>Alpha</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={selectedKey.a}
|
||||
onChange={handleAlphaChange}
|
||||
className="gradient-alpha-slider"
|
||||
/>
|
||||
<span className="gradient-alpha-value">{selectedKey.a.toFixed(2)}</span>
|
||||
<button
|
||||
className="gradient-delete-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={colorKeys.length <= 1}
|
||||
title="Delete stop"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在渐变中插值颜色
|
||||
* Interpolate color in gradient
|
||||
*/
|
||||
function interpolateColor(keys: ColorKey[], time: number): { r: number; g: number; b: number; a: number } {
|
||||
if (keys.length === 0) {
|
||||
return { r: 1, g: 1, b: 1, a: 1 };
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
return { r: keys[0].r, g: keys[0].g, b: keys[0].b, a: keys[0].a };
|
||||
}
|
||||
|
||||
const sorted = [...keys].sort((a, b) => a.time - b.time);
|
||||
|
||||
// 查找相邻关键帧 | Find adjacent keyframes
|
||||
let left = sorted[0];
|
||||
let right = sorted[sorted.length - 1];
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
if (sorted[i].time <= time && sorted[i + 1].time >= time) {
|
||||
left = sorted[i];
|
||||
right = sorted[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (time <= left.time) {
|
||||
return { r: left.r, g: left.g, b: left.b, a: left.a };
|
||||
}
|
||||
if (time >= right.time) {
|
||||
return { r: right.r, g: right.g, b: right.b, a: right.a };
|
||||
}
|
||||
|
||||
const t = (time - left.time) / (right.time - left.time);
|
||||
return {
|
||||
r: left.r + (right.r - left.r) * t,
|
||||
g: left.g + (right.g - left.g) * t,
|
||||
b: left.b + (right.b - left.b) * t,
|
||||
a: left.a + (right.a - left.a) * t,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 纹理选择器组件
|
||||
* Texture Picker Component
|
||||
*
|
||||
* A component for selecting particle textures.
|
||||
* 用于选择粒子纹理的组件。
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Image, ChevronDown, FolderOpen, X } from 'lucide-react';
|
||||
|
||||
interface TexturePickerProps {
|
||||
/** 当前纹理路径 | Current texture path */
|
||||
value: string | null;
|
||||
/** 变化回调 | Change callback */
|
||||
onChange: (path: string | null) => void;
|
||||
/** 打开文件选择对话框 | Open file selection dialog */
|
||||
onBrowse?: () => Promise<string | null>;
|
||||
/** 纹理预览 URL | Texture preview URL */
|
||||
previewUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纹理选择器
|
||||
* Texture Picker
|
||||
*/
|
||||
export function TexturePicker({
|
||||
value,
|
||||
onChange,
|
||||
onBrowse,
|
||||
previewUrl,
|
||||
}: TexturePickerProps) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 处理浏览按钮点击 | Handle browse button click
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (onBrowse) {
|
||||
const result = await onBrowse();
|
||||
if (result) {
|
||||
onChange(result);
|
||||
}
|
||||
}
|
||||
}, [onBrowse, onChange]);
|
||||
|
||||
// 处理清除 | Handle clear
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
}, [onChange]);
|
||||
|
||||
// 处理拖放 | Handle drag & drop
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
// 这里理想情况下需要将文件转换为项目内的路径
|
||||
// In ideal case, we'd convert file to project path
|
||||
// For now, just use the file name
|
||||
onChange(file.name);
|
||||
}
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 获取显示名称 | Get display name
|
||||
const displayName = value ? value.split(/[\\/]/).pop() || value : null;
|
||||
|
||||
return (
|
||||
<div className="texture-picker">
|
||||
<div className="asset-field-row">
|
||||
<span className="asset-field-label">Texture</span>
|
||||
<div className="asset-field-content">
|
||||
{/* 缩略图 | Thumbnail */}
|
||||
<div
|
||||
className={`asset-field-thumbnail ${isDragOver ? 'dragging' : ''}`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="Texture preview" />
|
||||
) : (
|
||||
<Image size={20} className="asset-field-thumbnail-icon" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-field-right">
|
||||
{/* 下拉选择器 | Dropdown selector */}
|
||||
<div
|
||||
className={`asset-field-dropdown ${value ? 'has-value' : ''}`}
|
||||
onClick={handleBrowse}
|
||||
>
|
||||
<span className="asset-field-value">
|
||||
{displayName || 'None'}
|
||||
</span>
|
||||
<ChevronDown size={12} className="asset-field-dropdown-arrow" />
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="asset-field-actions">
|
||||
<button
|
||||
className="asset-field-btn"
|
||||
onClick={handleBrowse}
|
||||
title="Browse..."
|
||||
>
|
||||
<FolderOpen size={12} />
|
||||
</button>
|
||||
{value && (
|
||||
<button
|
||||
className="asset-field-btn asset-field-btn--clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 粒子编辑器组件
|
||||
* Particle editor components
|
||||
*/
|
||||
|
||||
export { GradientEditor } from './GradientEditor';
|
||||
export { CurveEditor } from './CurveEditor';
|
||||
export { TexturePicker } from './TexturePicker';
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Particle System Gizmo Implementation
|
||||
* 粒子系统 Gizmo 实现
|
||||
*
|
||||
* 显示粒子发射区域形状,支持 Transform 缩放和旋转。
|
||||
* Displays particle emission shape, supports Transform scale and rotation.
|
||||
*/
|
||||
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IGizmoRenderData,
|
||||
IRectGizmoData,
|
||||
ICircleGizmoData,
|
||||
ILineGizmoData,
|
||||
GizmoColor
|
||||
} from '@esengine/editor-core';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { ParticleSystemComponent, EmissionShape } from '@esengine/particle';
|
||||
|
||||
/**
|
||||
* 粒子 Gizmo 颜色配置
|
||||
* Particle gizmo color configuration
|
||||
*/
|
||||
const ParticleGizmoColors = {
|
||||
emissionShape: { r: 1.0, g: 0.6, b: 0.0, a: 0.8 } as GizmoColor,
|
||||
emissionShapeSelected: { r: 1.0, g: 0.8, b: 0.2, a: 1.0 } as GizmoColor,
|
||||
direction: { r: 0.0, g: 0.8, b: 1.0, a: 0.9 } as GizmoColor,
|
||||
centerMark: { r: 1.0, g: 1.0, b: 1.0, a: 0.8 } as GizmoColor,
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建中心点标记 Gizmo (十字形)
|
||||
* Create center mark gizmo (cross shape)
|
||||
*/
|
||||
function createCenterMarkGizmo(x: number, y: number, size: number, color: GizmoColor): ILineGizmoData[] {
|
||||
const halfSize = size / 2;
|
||||
return [
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: x - halfSize, y: y },
|
||||
{ x: x + halfSize, y: y }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: x, y: y - halfSize },
|
||||
{ x: x, y: y + halfSize }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建方向箭头
|
||||
* Create direction arrow
|
||||
*/
|
||||
function createDirectionArrow(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: number,
|
||||
length: number,
|
||||
color: GizmoColor
|
||||
): ILineGizmoData[] {
|
||||
const endX = x + Math.cos(direction) * length;
|
||||
const endY = y + Math.sin(direction) * length;
|
||||
const arrowSize = length * 0.2;
|
||||
const arrowAngle = Math.PI / 6;
|
||||
|
||||
return [
|
||||
// 主线
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: endX, y: endY }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
// 箭头左
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: endX, y: endY },
|
||||
{
|
||||
x: endX - arrowSize * Math.cos(direction - arrowAngle),
|
||||
y: endY - arrowSize * Math.sin(direction - arrowAngle)
|
||||
}
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
},
|
||||
// 箭头右
|
||||
{
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: endX, y: endY },
|
||||
{
|
||||
x: endX - arrowSize * Math.cos(direction + arrowAngle),
|
||||
y: endY - arrowSize * Math.sin(direction + arrowAngle)
|
||||
}
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ParticleSystemComponent gizmo provider
|
||||
* 粒子系统组件 gizmo 提供者
|
||||
*/
|
||||
function particleSystemGizmoProvider(
|
||||
particle: ParticleSystemComponent,
|
||||
entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) return [];
|
||||
|
||||
const gizmos: IGizmoRenderData[] = [];
|
||||
const color = isSelected
|
||||
? ParticleGizmoColors.emissionShapeSelected
|
||||
: ParticleGizmoColors.emissionShape;
|
||||
|
||||
// 获取 Transform 数据 | Get transform data
|
||||
const worldX = transform.worldPosition?.x ?? transform.position.x;
|
||||
const worldY = transform.worldPosition?.y ?? transform.position.y;
|
||||
|
||||
const rot = transform.worldRotation ?? transform.rotation;
|
||||
const worldRotation = rot?.z ?? 0;
|
||||
|
||||
const scale = transform.worldScale ?? transform.scale;
|
||||
const scaleX = scale?.x ?? 1;
|
||||
const scaleY = scale?.y ?? 1;
|
||||
|
||||
// 从加载的资产获取发射形状配置 | Get emission shape config from loaded asset
|
||||
const asset = particle.loadedAsset;
|
||||
const emissionShape = asset?.emissionShape ?? EmissionShape.Point;
|
||||
const shapeRadius = (asset?.shapeRadius ?? 0) * Math.max(scaleX, scaleY);
|
||||
const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX;
|
||||
const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY;
|
||||
const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度
|
||||
// 转换为弧度并应用世界旋转 | Convert to radians and apply world rotation
|
||||
// worldRotation 是度(顺时针),转为弧度(逆时针)用于数学计算
|
||||
// worldRotation is degrees(clockwise), convert to radians(counter-clockwise) for math
|
||||
const direction = ((asset?.direction ?? 90) * Math.PI / 180) - (worldRotation * Math.PI / 180);
|
||||
|
||||
// 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape
|
||||
switch (emissionShape) {
|
||||
case EmissionShape.Point:
|
||||
// 点发射:显示中心点和方向 | Point: show center and direction
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color));
|
||||
if (isSelected) {
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, 30, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Circle:
|
||||
// 填充圆形:显示圆形区域 | Filled circle: show circle area
|
||||
gizmos.push({
|
||||
type: 'circle',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
radius: shapeRadius || 20,
|
||||
color
|
||||
} as ICircleGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, shapeRadius + 20 || 40, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Ring:
|
||||
// 圆环:显示圆环边缘 | Ring: show ring edge
|
||||
gizmos.push({
|
||||
type: 'circle',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
radius: shapeRadius || 20,
|
||||
color
|
||||
} as ICircleGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Rectangle:
|
||||
// 矩形:显示矩形区域 | Rectangle: show rect area
|
||||
gizmos.push({
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: shapeWidth || 40,
|
||||
height: shapeHeight || 20,
|
||||
rotation: worldRotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
} as IRectGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, Math.max(shapeWidth, shapeHeight) / 2 + 20 || 40, ParticleGizmoColors.direction));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Edge:
|
||||
// 矩形边缘:显示矩形边框 | Rectangle edge: show rect border
|
||||
gizmos.push({
|
||||
type: 'rect',
|
||||
x: worldX,
|
||||
y: worldY,
|
||||
width: shapeWidth || 40,
|
||||
height: shapeHeight || 20,
|
||||
rotation: worldRotation,
|
||||
originX: 0.5,
|
||||
originY: 0.5,
|
||||
color,
|
||||
showHandles: false
|
||||
} as IRectGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Line:
|
||||
// 线段:显示发射线 | Line: show emission line
|
||||
{
|
||||
const halfWidth = (shapeWidth || 40) / 2;
|
||||
const cos = Math.cos(direction + Math.PI / 2);
|
||||
const sin = Math.sin(direction + Math.PI / 2);
|
||||
gizmos.push({
|
||||
type: 'line',
|
||||
points: [
|
||||
{ x: worldX + cos * halfWidth, y: worldY + sin * halfWidth },
|
||||
{ x: worldX - cos * halfWidth, y: worldY - sin * halfWidth }
|
||||
],
|
||||
color,
|
||||
closed: false
|
||||
} as ILineGizmoData);
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, halfWidth + 20, ParticleGizmoColors.direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case EmissionShape.Cone:
|
||||
// 圆锥/扇形:显示扇形区域 | Cone/fan: show fan area
|
||||
{
|
||||
const radius = shapeRadius || 30;
|
||||
const halfAngle = shapeAngle / 2;
|
||||
const startAngle = direction - halfAngle;
|
||||
const endAngle = direction + halfAngle;
|
||||
|
||||
// 绘制扇形的两条边和弧线 | Draw two edges and arc of the fan
|
||||
const segments = 16;
|
||||
const points: { x: number; y: number }[] = [{ x: worldX, y: worldY }];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const angle = startAngle + (endAngle - startAngle) * (i / segments);
|
||||
points.push({
|
||||
x: worldX + Math.cos(angle) * radius,
|
||||
y: worldY + Math.sin(angle) * radius
|
||||
});
|
||||
}
|
||||
points.push({ x: worldX, y: worldY });
|
||||
|
||||
gizmos.push({
|
||||
type: 'line',
|
||||
points,
|
||||
color,
|
||||
closed: true
|
||||
} as ILineGizmoData);
|
||||
|
||||
if (isSelected) {
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 8, ParticleGizmoColors.centerMark));
|
||||
gizmos.push(...createDirectionArrow(worldX, worldY, direction, radius + 20, ParticleGizmoColors.direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 默认:显示中心点 | Default: show center point
|
||||
gizmos.push(...createCenterMarkGizmo(worldX, worldY, 10, color));
|
||||
break;
|
||||
}
|
||||
|
||||
return gizmos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register gizmo provider for ParticleSystemComponent.
|
||||
* 为 ParticleSystemComponent 注册 gizmo 提供者。
|
||||
*/
|
||||
export function registerParticleGizmo(): void {
|
||||
GizmoRegistry.register(ParticleSystemComponent, particleSystemGizmoProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister gizmo provider for ParticleSystemComponent.
|
||||
* 取消注册 ParticleSystemComponent 的 gizmo 提供者。
|
||||
*/
|
||||
export function unregisterParticleGizmo(): void {
|
||||
GizmoRegistry.unregister(ParticleSystemComponent);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Particle Editor Locale Hook
|
||||
* 粒子编辑器语言钩子
|
||||
*
|
||||
* Uses the unified plugin i18n infrastructure from editor-runtime.
|
||||
* 使用 editor-runtime 的统一插件国际化基础设施。
|
||||
*/
|
||||
import {
|
||||
createPluginLocale,
|
||||
createPluginTranslator,
|
||||
getCurrentLocale
|
||||
} from '@esengine/editor-runtime';
|
||||
import { en, zh, es } from '../locales';
|
||||
import type { Locale, TranslationParams } from '@esengine/editor-core';
|
||||
|
||||
// Create translations bundle
|
||||
// 创建翻译包
|
||||
const translations = { en, zh, es };
|
||||
|
||||
/**
|
||||
* Hook for accessing particle editor translations
|
||||
* 访问粒子编辑器翻译的 Hook
|
||||
*
|
||||
* Uses the unified createPluginLocale factory from editor-runtime.
|
||||
* 使用 editor-runtime 的统一 createPluginLocale 工厂。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { t, locale } = useParticleLocale();
|
||||
* return <PropertyInput label={t('basic.name')} />;
|
||||
* ```
|
||||
*/
|
||||
export const useParticleLocale = createPluginLocale(translations);
|
||||
|
||||
// Create non-React translator using the unified infrastructure
|
||||
// 使用统一基础设施创建非 React 翻译器
|
||||
const particleTranslator = createPluginTranslator(translations);
|
||||
|
||||
/**
|
||||
* Non-React translation function for particle editor
|
||||
* 粒子编辑器的非 React 翻译函数
|
||||
*
|
||||
* Use this in services, utilities, and other non-React contexts.
|
||||
* 在服务、工具类和其他非 React 上下文中使用。
|
||||
*
|
||||
* @param key - Translation key | 翻译键
|
||||
* @param locale - Optional locale, defaults to current locale | 可选语言,默认使用当前语言
|
||||
* @param params - Optional interpolation parameters | 可选插值参数
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With explicit locale
|
||||
* translateParticle('notifications.fileSaved', 'zh', { path: '/path/to/file' });
|
||||
*
|
||||
* // With current locale (auto-detected)
|
||||
* translateParticle('basic.name');
|
||||
* ```
|
||||
*/
|
||||
export function translateParticle(
|
||||
key: string,
|
||||
locale?: Locale,
|
||||
params?: TranslationParams
|
||||
): string {
|
||||
const targetLocale = locale || getCurrentLocale();
|
||||
return particleTranslator(key, targetLocale, params);
|
||||
}
|
||||
|
||||
// Re-export for external use
|
||||
// 重新导出供外部使用
|
||||
export { getCurrentLocale } from '@esengine/editor-runtime';
|
||||
28
packages/editor/plugins/particle-editor/src/index.ts
Normal file
28
packages/editor/plugins/particle-editor/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 粒子编辑器模块入口
|
||||
* Particle Editor Module Entry
|
||||
*/
|
||||
|
||||
// Module
|
||||
export {
|
||||
ParticleEditorModule,
|
||||
particleEditorModule,
|
||||
ParticlePlugin,
|
||||
particleEditorModule as default
|
||||
} from './ParticleEditorModule';
|
||||
|
||||
// Providers
|
||||
export { ParticleInspectorProvider } from './providers/ParticleInspectorProvider';
|
||||
|
||||
// Panels
|
||||
export { ParticleEditorPanel } from './panels/ParticleEditorPanel';
|
||||
|
||||
// Stores
|
||||
export { useParticleEditorStore } from './stores/ParticleEditorStore';
|
||||
export type { ParticleEditorState } from './stores/ParticleEditorStore';
|
||||
|
||||
// Components
|
||||
export { GradientEditor, CurveEditor } from './components';
|
||||
|
||||
// Gizmos
|
||||
export { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';
|
||||
267
packages/editor/plugins/particle-editor/src/locales/en.ts
Normal file
267
packages/editor/plugins/particle-editor/src/locales/en.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* English translations for Particle Editor
|
||||
* 粒子编辑器英文翻译
|
||||
*/
|
||||
export const en = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: 'Particle Editor',
|
||||
noFileOpen: 'No particle file is open',
|
||||
dropToOpen: 'Drop a .particle file here or use Open button'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
restart: 'Restart',
|
||||
save: 'Save',
|
||||
open: 'Open',
|
||||
maximize: 'Maximize preview',
|
||||
minimize: 'Minimize preview',
|
||||
followMouse: 'Follow mouse',
|
||||
resetPosition: 'Reset position'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Sections
|
||||
// ========================================
|
||||
sections: {
|
||||
basic: 'Basic',
|
||||
emission: 'Emission',
|
||||
particle: 'Particle',
|
||||
color: 'Color',
|
||||
modules: 'Modules',
|
||||
presets: 'Presets'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Basic Properties
|
||||
// ========================================
|
||||
basic: {
|
||||
name: 'Name',
|
||||
texture: 'Texture',
|
||||
maxParticles: 'Max Particles',
|
||||
looping: 'Looping',
|
||||
duration: 'Duration',
|
||||
prewarm: 'Prewarm',
|
||||
playSpeed: 'Play Speed',
|
||||
blendMode: 'Blend Mode',
|
||||
space: 'Space',
|
||||
particleSize: 'Particle Size',
|
||||
sortOrder: 'Sort Order'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Blend Modes
|
||||
// ========================================
|
||||
blendMode: {
|
||||
normal: 'Normal',
|
||||
additive: 'Additive',
|
||||
multiply: 'Multiply'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Simulation Space
|
||||
// ========================================
|
||||
space: {
|
||||
world: 'World',
|
||||
local: 'Local'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Properties
|
||||
// ========================================
|
||||
emission: {
|
||||
rate: 'Rate',
|
||||
shape: 'Shape',
|
||||
radius: 'Radius',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
coneAngle: 'Cone Angle'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Shapes
|
||||
// ========================================
|
||||
shapes: {
|
||||
point: 'Point',
|
||||
circle: 'Circle',
|
||||
ring: 'Ring',
|
||||
rectangle: 'Rectangle',
|
||||
edge: 'Edge',
|
||||
line: 'Line',
|
||||
cone: 'Cone'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Particle Properties
|
||||
// ========================================
|
||||
particle: {
|
||||
lifetime: 'Lifetime',
|
||||
speed: 'Speed',
|
||||
direction: 'Direction',
|
||||
spread: 'Spread',
|
||||
scale: 'Scale',
|
||||
gravity: 'Gravity'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Color Properties
|
||||
// ========================================
|
||||
color: {
|
||||
startColor: 'Start Color',
|
||||
startAlpha: 'Start Alpha',
|
||||
endAlpha: 'End Alpha',
|
||||
endScale: 'End Scale'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Module Names
|
||||
// ========================================
|
||||
modules: {
|
||||
colorOverLifetime: 'Color Over Lifetime',
|
||||
sizeOverLifetime: 'Size Over Lifetime',
|
||||
velocityOverLifetime: 'Velocity Over Lifetime',
|
||||
rotationOverLifetime: 'Rotation Over Lifetime',
|
||||
noise: 'Noise',
|
||||
collision: 'Collision',
|
||||
forceField: 'Force Field'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Velocity Over Lifetime
|
||||
// ========================================
|
||||
velocity: {
|
||||
drag: 'Drag',
|
||||
orbital: 'Orbital',
|
||||
radial: 'Radial'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Rotation Over Lifetime
|
||||
// ========================================
|
||||
rotation: {
|
||||
startMult: 'Start Mult',
|
||||
endMult: 'End Mult',
|
||||
additional: 'Additional'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Noise Module
|
||||
// ========================================
|
||||
noise: {
|
||||
position: 'Position',
|
||||
velocity: 'Velocity',
|
||||
rotation: 'Rotation',
|
||||
frequency: 'Frequency',
|
||||
scroll: 'Scroll'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Module
|
||||
// ========================================
|
||||
collision: {
|
||||
boundary: 'Boundary',
|
||||
behavior: 'Behavior',
|
||||
left: 'Left',
|
||||
right: 'Right',
|
||||
top: 'Top',
|
||||
bottom: 'Bottom',
|
||||
radius: 'Radius',
|
||||
bounce: 'Bounce',
|
||||
lifeLoss: 'Life Loss'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Boundary Types
|
||||
// ========================================
|
||||
boundaryType: {
|
||||
none: 'None',
|
||||
rectangle: 'Rectangle',
|
||||
circle: 'Circle'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Behaviors
|
||||
// ========================================
|
||||
collisionBehavior: {
|
||||
kill: 'Kill',
|
||||
bounce: 'Bounce',
|
||||
wrap: 'Wrap'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Module
|
||||
// ========================================
|
||||
forceField: {
|
||||
type: 'Type',
|
||||
strength: 'Strength',
|
||||
directionX: 'Direction X',
|
||||
directionY: 'Direction Y',
|
||||
centerX: 'Center X',
|
||||
centerY: 'Center Y',
|
||||
range: 'Range',
|
||||
falloff: 'Falloff'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Types
|
||||
// ========================================
|
||||
forceFieldType: {
|
||||
wind: 'Wind',
|
||||
point: 'Point',
|
||||
vortex: 'Vortex',
|
||||
turbulence: 'Turbulence'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Curve Editor
|
||||
// ========================================
|
||||
curve: {
|
||||
deletePoint: 'Delete point',
|
||||
constant: 'Constant value',
|
||||
fadeIn: 'Fade in',
|
||||
fadeOut: 'Fade out',
|
||||
bellCurve: 'Bell curve',
|
||||
uCurve: 'U curve'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Gradient Editor
|
||||
// ========================================
|
||||
gradient: {
|
||||
deleteStop: 'Delete stop'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Texture Picker
|
||||
// ========================================
|
||||
texturePicker: {
|
||||
browse: 'Browse...',
|
||||
clear: 'Clear'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Notifications
|
||||
// ========================================
|
||||
notifications: {
|
||||
fileSaved: 'File saved: {{path}}',
|
||||
fileSaveFailed: 'Failed to save file',
|
||||
fileOpened: 'File opened: {{path}}',
|
||||
fileOpenFailed: 'Failed to open file'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTexture: 'Select texture image',
|
||||
selectParticleFile: 'Select particle file',
|
||||
saveParticleFile: 'Save particle file'
|
||||
}
|
||||
};
|
||||
267
packages/editor/plugins/particle-editor/src/locales/es.ts
Normal file
267
packages/editor/plugins/particle-editor/src/locales/es.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Spanish translations for Particle Editor
|
||||
* Traducciones en español del editor de partículas
|
||||
*/
|
||||
export const es = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: 'Editor de Partículas',
|
||||
noFileOpen: 'No hay archivo de partículas abierto',
|
||||
dropToOpen: 'Arrastre un archivo .particle aquí o use el botón Abrir'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
play: 'Reproducir',
|
||||
pause: 'Pausar',
|
||||
restart: 'Reiniciar',
|
||||
save: 'Guardar',
|
||||
open: 'Abrir',
|
||||
maximize: 'Maximizar vista previa',
|
||||
minimize: 'Minimizar vista previa',
|
||||
followMouse: 'Seguir ratón',
|
||||
resetPosition: 'Restablecer posición'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Sections
|
||||
// ========================================
|
||||
sections: {
|
||||
basic: 'Básico',
|
||||
emission: 'Emisión',
|
||||
particle: 'Partícula',
|
||||
color: 'Color',
|
||||
modules: 'Módulos',
|
||||
presets: 'Preajustes'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Basic Properties
|
||||
// ========================================
|
||||
basic: {
|
||||
name: 'Nombre',
|
||||
texture: 'Textura',
|
||||
maxParticles: 'Máx. Partículas',
|
||||
looping: 'Bucle',
|
||||
duration: 'Duración',
|
||||
prewarm: 'Precalentamiento',
|
||||
playSpeed: 'Velocidad',
|
||||
blendMode: 'Modo mezcla',
|
||||
space: 'Espacio',
|
||||
particleSize: 'Tamaño partícula',
|
||||
sortOrder: 'Orden'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Blend Modes
|
||||
// ========================================
|
||||
blendMode: {
|
||||
normal: 'Normal',
|
||||
additive: 'Aditivo',
|
||||
multiply: 'Multiplicar'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Simulation Space
|
||||
// ========================================
|
||||
space: {
|
||||
world: 'Mundo',
|
||||
local: 'Local'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Properties
|
||||
// ========================================
|
||||
emission: {
|
||||
rate: 'Tasa',
|
||||
shape: 'Forma',
|
||||
radius: 'Radio',
|
||||
width: 'Ancho',
|
||||
height: 'Alto',
|
||||
coneAngle: 'Ángulo cono'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Shapes
|
||||
// ========================================
|
||||
shapes: {
|
||||
point: 'Punto',
|
||||
circle: 'Círculo',
|
||||
ring: 'Anillo',
|
||||
rectangle: 'Rectángulo',
|
||||
edge: 'Borde',
|
||||
line: 'Línea',
|
||||
cone: 'Cono'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Particle Properties
|
||||
// ========================================
|
||||
particle: {
|
||||
lifetime: 'Vida',
|
||||
speed: 'Velocidad',
|
||||
direction: 'Dirección',
|
||||
spread: 'Dispersión',
|
||||
scale: 'Escala',
|
||||
gravity: 'Gravedad'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Color Properties
|
||||
// ========================================
|
||||
color: {
|
||||
startColor: 'Color inicial',
|
||||
startAlpha: 'Alfa inicial',
|
||||
endAlpha: 'Alfa final',
|
||||
endScale: 'Escala final'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Module Names
|
||||
// ========================================
|
||||
modules: {
|
||||
colorOverLifetime: 'Color durante vida',
|
||||
sizeOverLifetime: 'Tamaño durante vida',
|
||||
velocityOverLifetime: 'Velocidad durante vida',
|
||||
rotationOverLifetime: 'Rotación durante vida',
|
||||
noise: 'Ruido',
|
||||
collision: 'Colisión',
|
||||
forceField: 'Campo de fuerza'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Velocity Over Lifetime
|
||||
// ========================================
|
||||
velocity: {
|
||||
drag: 'Arrastre',
|
||||
orbital: 'Orbital',
|
||||
radial: 'Radial'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Rotation Over Lifetime
|
||||
// ========================================
|
||||
rotation: {
|
||||
startMult: 'Mult. inicial',
|
||||
endMult: 'Mult. final',
|
||||
additional: 'Adicional'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Noise Module
|
||||
// ========================================
|
||||
noise: {
|
||||
position: 'Posición',
|
||||
velocity: 'Velocidad',
|
||||
rotation: 'Rotación',
|
||||
frequency: 'Frecuencia',
|
||||
scroll: 'Desplazamiento'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Module
|
||||
// ========================================
|
||||
collision: {
|
||||
boundary: 'Límite',
|
||||
behavior: 'Comportamiento',
|
||||
left: 'Izquierda',
|
||||
right: 'Derecha',
|
||||
top: 'Arriba',
|
||||
bottom: 'Abajo',
|
||||
radius: 'Radio',
|
||||
bounce: 'Rebote',
|
||||
lifeLoss: 'Pérdida de vida'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Boundary Types
|
||||
// ========================================
|
||||
boundaryType: {
|
||||
none: 'Ninguno',
|
||||
rectangle: 'Rectángulo',
|
||||
circle: 'Círculo'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Behaviors
|
||||
// ========================================
|
||||
collisionBehavior: {
|
||||
kill: 'Eliminar',
|
||||
bounce: 'Rebotar',
|
||||
wrap: 'Envolver'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Module
|
||||
// ========================================
|
||||
forceField: {
|
||||
type: 'Tipo',
|
||||
strength: 'Fuerza',
|
||||
directionX: 'Dirección X',
|
||||
directionY: 'Dirección Y',
|
||||
centerX: 'Centro X',
|
||||
centerY: 'Centro Y',
|
||||
range: 'Rango',
|
||||
falloff: 'Caída'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Types
|
||||
// ========================================
|
||||
forceFieldType: {
|
||||
wind: 'Viento',
|
||||
point: 'Punto',
|
||||
vortex: 'Vórtice',
|
||||
turbulence: 'Turbulencia'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Curve Editor
|
||||
// ========================================
|
||||
curve: {
|
||||
deletePoint: 'Eliminar punto',
|
||||
constant: 'Valor constante',
|
||||
fadeIn: 'Aparecer',
|
||||
fadeOut: 'Desvanecer',
|
||||
bellCurve: 'Curva campana',
|
||||
uCurve: 'Curva U'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Gradient Editor
|
||||
// ========================================
|
||||
gradient: {
|
||||
deleteStop: 'Eliminar parada'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Texture Picker
|
||||
// ========================================
|
||||
texturePicker: {
|
||||
browse: 'Examinar...',
|
||||
clear: 'Limpiar'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Notifications
|
||||
// ========================================
|
||||
notifications: {
|
||||
fileSaved: 'Archivo guardado: {{path}}',
|
||||
fileSaveFailed: 'Error al guardar archivo',
|
||||
fileOpened: 'Archivo abierto: {{path}}',
|
||||
fileOpenFailed: 'Error al abrir archivo'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTexture: 'Seleccionar imagen de textura',
|
||||
selectParticleFile: 'Seleccionar archivo de partículas',
|
||||
saveParticleFile: 'Guardar archivo de partículas'
|
||||
}
|
||||
};
|
||||
11
packages/editor/plugins/particle-editor/src/locales/index.ts
Normal file
11
packages/editor/plugins/particle-editor/src/locales/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Particle Editor Locales
|
||||
* 粒子编辑器语言包
|
||||
*
|
||||
* Export all locale translations for the particle editor plugin.
|
||||
* 导出粒子编辑器插件的所有语言翻译。
|
||||
*/
|
||||
|
||||
export { en } from './en';
|
||||
export { zh } from './zh';
|
||||
export { es } from './es';
|
||||
267
packages/editor/plugins/particle-editor/src/locales/zh.ts
Normal file
267
packages/editor/plugins/particle-editor/src/locales/zh.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Chinese translations for Particle Editor
|
||||
* 粒子编辑器中文翻译
|
||||
*/
|
||||
export const zh = {
|
||||
// ========================================
|
||||
// Panel
|
||||
// ========================================
|
||||
panel: {
|
||||
title: '粒子编辑器',
|
||||
noFileOpen: '没有打开的粒子文件',
|
||||
dropToOpen: '拖放 .particle 文件到这里或使用打开按钮'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Toolbar
|
||||
// ========================================
|
||||
toolbar: {
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
restart: '重新开始',
|
||||
save: '保存',
|
||||
open: '打开',
|
||||
maximize: '最大化预览',
|
||||
minimize: '最小化预览',
|
||||
followMouse: '跟随鼠标',
|
||||
resetPosition: '重置位置'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Sections
|
||||
// ========================================
|
||||
sections: {
|
||||
basic: '基础',
|
||||
emission: '发射',
|
||||
particle: '粒子',
|
||||
color: '颜色',
|
||||
modules: '模块',
|
||||
presets: '预设'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Basic Properties
|
||||
// ========================================
|
||||
basic: {
|
||||
name: '名称',
|
||||
texture: '纹理',
|
||||
maxParticles: '最大粒子数',
|
||||
looping: '循环',
|
||||
duration: '持续时间',
|
||||
prewarm: '预热',
|
||||
playSpeed: '播放速度',
|
||||
blendMode: '混合模式',
|
||||
space: '空间',
|
||||
particleSize: '粒子大小',
|
||||
sortOrder: '排序顺序'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Blend Modes
|
||||
// ========================================
|
||||
blendMode: {
|
||||
normal: '普通',
|
||||
additive: '叠加',
|
||||
multiply: '乘法'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Simulation Space
|
||||
// ========================================
|
||||
space: {
|
||||
world: '世界',
|
||||
local: '本地'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Properties
|
||||
// ========================================
|
||||
emission: {
|
||||
rate: '发射率',
|
||||
shape: '形状',
|
||||
radius: '半径',
|
||||
width: '宽度',
|
||||
height: '高度',
|
||||
coneAngle: '锥形角度'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Emission Shapes
|
||||
// ========================================
|
||||
shapes: {
|
||||
point: '点',
|
||||
circle: '圆形',
|
||||
ring: '环形',
|
||||
rectangle: '矩形',
|
||||
edge: '边缘',
|
||||
line: '线',
|
||||
cone: '锥形'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Particle Properties
|
||||
// ========================================
|
||||
particle: {
|
||||
lifetime: '生命周期',
|
||||
speed: '速度',
|
||||
direction: '方向',
|
||||
spread: '散布',
|
||||
scale: '缩放',
|
||||
gravity: '重力'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Color Properties
|
||||
// ========================================
|
||||
color: {
|
||||
startColor: '起始颜色',
|
||||
startAlpha: '起始透明度',
|
||||
endAlpha: '结束透明度',
|
||||
endScale: '结束缩放'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Module Names
|
||||
// ========================================
|
||||
modules: {
|
||||
colorOverLifetime: '颜色随生命周期',
|
||||
sizeOverLifetime: '大小随生命周期',
|
||||
velocityOverLifetime: '速度随生命周期',
|
||||
rotationOverLifetime: '旋转随生命周期',
|
||||
noise: '噪声',
|
||||
collision: '碰撞',
|
||||
forceField: '力场'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Velocity Over Lifetime
|
||||
// ========================================
|
||||
velocity: {
|
||||
drag: '阻力',
|
||||
orbital: '轨道',
|
||||
radial: '径向'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Rotation Over Lifetime
|
||||
// ========================================
|
||||
rotation: {
|
||||
startMult: '起始倍数',
|
||||
endMult: '结束倍数',
|
||||
additional: '附加'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Noise Module
|
||||
// ========================================
|
||||
noise: {
|
||||
position: '位置',
|
||||
velocity: '速度',
|
||||
rotation: '旋转',
|
||||
frequency: '频率',
|
||||
scroll: '滚动'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Module
|
||||
// ========================================
|
||||
collision: {
|
||||
boundary: '边界',
|
||||
behavior: '行为',
|
||||
left: '左',
|
||||
right: '右',
|
||||
top: '上',
|
||||
bottom: '下',
|
||||
radius: '半径',
|
||||
bounce: '弹跳',
|
||||
lifeLoss: '生命损失'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Boundary Types
|
||||
// ========================================
|
||||
boundaryType: {
|
||||
none: '无',
|
||||
rectangle: '矩形',
|
||||
circle: '圆形'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Collision Behaviors
|
||||
// ========================================
|
||||
collisionBehavior: {
|
||||
kill: '消灭',
|
||||
bounce: '弹跳',
|
||||
wrap: '环绕'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Module
|
||||
// ========================================
|
||||
forceField: {
|
||||
type: '类型',
|
||||
strength: '强度',
|
||||
directionX: '方向 X',
|
||||
directionY: '方向 Y',
|
||||
centerX: '中心 X',
|
||||
centerY: '中心 Y',
|
||||
range: '范围',
|
||||
falloff: '衰减'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Force Field Types
|
||||
// ========================================
|
||||
forceFieldType: {
|
||||
wind: '风',
|
||||
point: '点',
|
||||
vortex: '漩涡',
|
||||
turbulence: '湍流'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Curve Editor
|
||||
// ========================================
|
||||
curve: {
|
||||
deletePoint: '删除点',
|
||||
constant: '常量',
|
||||
fadeIn: '淡入',
|
||||
fadeOut: '淡出',
|
||||
bellCurve: '钟形曲线',
|
||||
uCurve: 'U 形曲线'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Gradient Editor
|
||||
// ========================================
|
||||
gradient: {
|
||||
deleteStop: '删除色标'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Texture Picker
|
||||
// ========================================
|
||||
texturePicker: {
|
||||
browse: '浏览...',
|
||||
clear: '清除'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Notifications
|
||||
// ========================================
|
||||
notifications: {
|
||||
fileSaved: '文件已保存: {{path}}',
|
||||
fileSaveFailed: '保存文件失败',
|
||||
fileOpened: '文件已打开: {{path}}',
|
||||
fileOpenFailed: '打开文件失败'
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// Dialogs
|
||||
// ========================================
|
||||
dialogs: {
|
||||
selectTexture: '选择纹理图片',
|
||||
selectParticleFile: '选择粒子文件',
|
||||
saveParticleFile: '保存粒子文件'
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 粒子系统 Inspector Provider
|
||||
* Particle System Inspector Provider
|
||||
*
|
||||
* 检视器显示控制按钮,资产选择通过 @Property 装饰器自动渲染
|
||||
* Inspector shows control buttons, asset selection is auto-rendered via @Property decorator
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Play, Pause, RotateCcw, Sparkles, Loader2 } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import type { ParticleSystemComponent } from '@esengine/particle';
|
||||
|
||||
interface ParticleInspectorData {
|
||||
entityId: string;
|
||||
component: ParticleSystemComponent;
|
||||
}
|
||||
|
||||
export class ParticleInspectorProvider implements IInspectorProvider<ParticleInspectorData> {
|
||||
readonly id = 'particle-system-inspector';
|
||||
readonly name = 'Particle System Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(target: unknown): target is ParticleInspectorData {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const obj = target as Record<string, unknown>;
|
||||
return 'entityId' in obj && 'component' in obj &&
|
||||
obj.component !== null &&
|
||||
typeof obj.component === 'object' &&
|
||||
'maxParticles' in (obj.component as Record<string, unknown>) &&
|
||||
'emissionRate' in (obj.component as Record<string, unknown>);
|
||||
}
|
||||
|
||||
render(data: ParticleInspectorData, _context: InspectorContext): React.ReactElement {
|
||||
return <ParticleInspectorUI data={data} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParticleInspectorUIProps {
|
||||
data: ParticleInspectorData;
|
||||
}
|
||||
|
||||
function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
|
||||
const { component } = data;
|
||||
const [, forceUpdate] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastGuid, setLastGuid] = useState(component.particleAssetGuid);
|
||||
|
||||
const refresh = useCallback(() => forceUpdate({}), []);
|
||||
|
||||
// 当资产 GUID 变化时自动加载资产
|
||||
// Auto-load asset when GUID changes
|
||||
useEffect(() => {
|
||||
const currentGuid = component.particleAssetGuid;
|
||||
if (currentGuid !== lastGuid) {
|
||||
setLastGuid(currentGuid);
|
||||
if (currentGuid) {
|
||||
setIsLoading(true);
|
||||
component.loadAsset(currentGuid).finally(() => {
|
||||
setIsLoading(false);
|
||||
refresh();
|
||||
});
|
||||
} else {
|
||||
// 清除已加载的资产
|
||||
component.setAssetData(null);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
}, [component, component.particleAssetGuid, lastGuid, refresh]);
|
||||
|
||||
const handlePlay = useCallback(async () => {
|
||||
// 如果有资产 GUID 但尚未加载,先加载
|
||||
if (component.particleAssetGuid && !component.loadedAsset) {
|
||||
setIsLoading(true);
|
||||
await component.loadAsset(component.particleAssetGuid);
|
||||
setIsLoading(false);
|
||||
}
|
||||
component.play();
|
||||
refresh();
|
||||
}, [component, refresh]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
component.pause();
|
||||
refresh();
|
||||
}, [component, refresh]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
component.stop(true);
|
||||
refresh();
|
||||
}, [component, refresh]);
|
||||
|
||||
const handleBurst = useCallback(async () => {
|
||||
// 如果有资产 GUID 但尚未加载,先加载
|
||||
if (component.particleAssetGuid && !component.loadedAsset) {
|
||||
setIsLoading(true);
|
||||
await component.loadAsset(component.particleAssetGuid);
|
||||
setIsLoading(false);
|
||||
}
|
||||
component.emit(10);
|
||||
refresh();
|
||||
}, [component, refresh]);
|
||||
|
||||
const hasAsset = !!component.particleAssetGuid;
|
||||
const isAssetLoaded = !!component.loadedAsset;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
{/* 控制按钮 | Control buttons */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Controls</div>
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '8px' }}>
|
||||
<button
|
||||
onClick={component.isPlaying ? handlePause : handlePlay}
|
||||
style={buttonStyle}
|
||||
title={component.isPlaying ? 'Pause' : 'Play'}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 size={14} className="animate-spin" /> :
|
||||
component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button onClick={handleStop} style={buttonStyle} title="Stop" disabled={isLoading}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={handleBurst} style={buttonStyle} title="Burst 10" disabled={isLoading}>
|
||||
<Sparkles size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Active Particles</label>
|
||||
<span>{component.activeParticleCount} / {component.loadedAsset?.maxParticles ?? component.maxParticles}</span>
|
||||
</div>
|
||||
{hasAsset && (
|
||||
<div className="property-row">
|
||||
<label>Asset Status</label>
|
||||
<span style={{ color: isAssetLoaded ? 'var(--color-success, #4caf50)' : 'var(--color-warning, #ff9800)' }}>
|
||||
{isLoading ? 'Loading...' : isAssetLoaded ? 'Loaded' : 'Not loaded'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 | Hint */}
|
||||
{!hasAsset && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--color-warning-bg, rgba(255, 193, 7, 0.1))',
|
||||
border: '1px solid var(--color-warning-border, rgba(255, 193, 7, 0.3))',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
No particle asset selected. Drag a <code>.particle</code> file to the Particle Asset field above, or create one in Content Browser.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 10px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--button-background, #3a3a3a)',
|
||||
color: 'var(--text-color, #e0e0e0)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 粒子编辑器状态管理
|
||||
* Particle editor state management
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { IParticleAsset } from '@esengine/particle';
|
||||
import { createDefaultParticleAsset } from '@esengine/particle';
|
||||
|
||||
/** Tab 类型 | Tab type */
|
||||
export type ParticleEditorTab = 'basic' | 'emission' | 'particle' | 'color' | 'modules' | 'burst';
|
||||
|
||||
/**
|
||||
* 粒子编辑器状态
|
||||
* Particle editor state
|
||||
*/
|
||||
export interface ParticleEditorState {
|
||||
// ===== 文件状态 | File State =====
|
||||
/** 当前编辑的文件路径 | Current file path being edited */
|
||||
filePath: string | null;
|
||||
/** 待打开的文件路径 | Pending file path to open */
|
||||
pendingFilePath: string | null;
|
||||
/** 当前粒子数据 | Current particle data */
|
||||
particleData: IParticleAsset | null;
|
||||
/** 是否已修改 | Is modified */
|
||||
isDirty: boolean;
|
||||
/** 是否正在加载 | Is loading */
|
||||
isLoading: boolean;
|
||||
|
||||
// ===== UI 状态 | UI State =====
|
||||
/** 当前激活的 Tab | Current active tab */
|
||||
activeTab: ParticleEditorTab;
|
||||
/** 是否全屏 | Is fullscreen */
|
||||
isFullscreen: boolean;
|
||||
/** 选中的预设名称 | Selected preset name */
|
||||
selectedPreset: string | null;
|
||||
|
||||
// ===== 预览状态 | Preview State =====
|
||||
/** 是否正在预览 | Is previewing */
|
||||
isPlaying: boolean;
|
||||
/** 是否跟随鼠标 | Is following mouse */
|
||||
followMouse: boolean;
|
||||
/** 爆发触发计数器 | Burst trigger counter */
|
||||
burstTrigger: number;
|
||||
|
||||
// ===== Actions =====
|
||||
/** 设置文件路径 | Set file path */
|
||||
setFilePath: (path: string | null) => void;
|
||||
/** 设置待打开的文件路径 | Set pending file path */
|
||||
setPendingFilePath: (path: string | null) => void;
|
||||
/** 设置粒子数据 | Set particle data */
|
||||
setParticleData: (data: IParticleAsset | null) => void;
|
||||
/** 更新粒子属性 | Update particle property */
|
||||
updateProperty: <K extends keyof IParticleAsset>(key: K, value: IParticleAsset[K]) => void;
|
||||
/** 标记为已修改 | Mark as dirty */
|
||||
markDirty: () => void;
|
||||
/** 标记为已保存 | Mark as saved */
|
||||
markSaved: () => void;
|
||||
/** 设置播放状态 | Set playing state */
|
||||
setPlaying: (playing: boolean) => void;
|
||||
/** 设置选中预设 | Set selected preset */
|
||||
setSelectedPreset: (preset: string | null) => void;
|
||||
/** 重置编辑器 | Reset editor */
|
||||
reset: () => void;
|
||||
/** 创建新粒子效果 | Create new particle effect */
|
||||
createNew: (name?: string) => void;
|
||||
|
||||
// ===== UI Actions =====
|
||||
/** 设置激活的 Tab | Set active tab */
|
||||
setActiveTab: (tab: ParticleEditorTab) => void;
|
||||
/** 切换全屏 | Toggle fullscreen */
|
||||
toggleFullscreen: () => void;
|
||||
/** 切换跟随鼠标 | Toggle follow mouse */
|
||||
toggleFollowMouse: () => void;
|
||||
/** 触发爆发 | Trigger burst */
|
||||
triggerBurst: () => void;
|
||||
|
||||
// ===== 文件操作 | File Operations =====
|
||||
/** 加载文件 | Load file */
|
||||
loadFile: (path: string, fileSystem: { readFile: (path: string) => Promise<string> }) => Promise<void>;
|
||||
/** 保存文件 | Save file */
|
||||
saveFile: (fileSystem: { writeFile: (path: string, content: string) => Promise<void> }, dialogService?: { saveDialog: (options: any) => Promise<string | null> }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子编辑器 Store
|
||||
* Particle editor store
|
||||
*/
|
||||
export const useParticleEditorStore = create<ParticleEditorState>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
// ===== 初始状态 | Initial State =====
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
activeTab: 'basic' as ParticleEditorTab,
|
||||
isFullscreen: false,
|
||||
selectedPreset: null,
|
||||
isPlaying: false,
|
||||
followMouse: false,
|
||||
burstTrigger: 0,
|
||||
|
||||
// ===== 基础 Actions | Basic Actions =====
|
||||
setFilePath: (path) => set({ filePath: path }),
|
||||
|
||||
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||
|
||||
setParticleData: (data) => set((state) => ({
|
||||
particleData: data,
|
||||
// 加载时不标记 dirty | Don't mark dirty when loading
|
||||
isDirty: state.isLoading ? false : (state.filePath !== null || state.particleData !== null),
|
||||
})),
|
||||
|
||||
updateProperty: (key, value) => set((state) => {
|
||||
if (!state.particleData) return state;
|
||||
return {
|
||||
particleData: {
|
||||
...state.particleData,
|
||||
[key]: value,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
|
||||
markSaved: () => set({ isDirty: false }),
|
||||
|
||||
setPlaying: (playing) => set({ isPlaying: playing }),
|
||||
|
||||
setSelectedPreset: (preset) => set({ selectedPreset: preset }),
|
||||
|
||||
reset: () => set({
|
||||
filePath: null,
|
||||
pendingFilePath: null,
|
||||
particleData: null,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
activeTab: 'basic',
|
||||
isFullscreen: false,
|
||||
selectedPreset: null,
|
||||
isPlaying: false,
|
||||
followMouse: false,
|
||||
burstTrigger: 0,
|
||||
}),
|
||||
|
||||
createNew: (name = 'New Particle') => set({
|
||||
particleData: createDefaultParticleAsset(name),
|
||||
filePath: null,
|
||||
isDirty: true,
|
||||
isPlaying: true, // 自动播放 | Auto play
|
||||
selectedPreset: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
// ===== UI Actions =====
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
toggleFullscreen: () => set((state) => ({ isFullscreen: !state.isFullscreen })),
|
||||
|
||||
toggleFollowMouse: () => set((state) => ({ followMouse: !state.followMouse })),
|
||||
|
||||
triggerBurst: () => set((state) => ({ burstTrigger: state.burstTrigger + 1 })),
|
||||
|
||||
// ===== 文件操作 | File Operations =====
|
||||
loadFile: async (path, fileSystem) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const content = await fileSystem.readFile(path);
|
||||
const data = JSON.parse(content) as IParticleAsset;
|
||||
const defaults = createDefaultParticleAsset();
|
||||
set({
|
||||
particleData: { ...defaults, ...data },
|
||||
filePath: path,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
pendingFilePath: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorStore] Failed to load file:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveFile: async (fileSystem, dialogService) => {
|
||||
const state = get();
|
||||
if (!state.particleData) return false;
|
||||
|
||||
let savePath = state.filePath;
|
||||
|
||||
// 如果没有路径,弹出保存对话框 | If no path, show save dialog
|
||||
if (!savePath && dialogService) {
|
||||
savePath = await dialogService.saveDialog({
|
||||
title: 'Save Particle Effect',
|
||||
filters: [{ name: 'Particle Effect', extensions: ['particle'] }],
|
||||
defaultPath: `${state.particleData.name || 'new-particle'}.particle`,
|
||||
});
|
||||
if (!savePath) return false;
|
||||
}
|
||||
|
||||
if (!savePath) return false;
|
||||
|
||||
try {
|
||||
await fileSystem.writeFile(savePath, JSON.stringify(state.particleData, null, 2));
|
||||
set({ filePath: savePath, isDirty: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ParticleEditorStore] Failed to save:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user