fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染 (#290)

* fix(editor): 修复粒子实体创建和优化检视器

- 添加 effects 分类到右键菜单,修复粒子实体无法创建的问题
- 添加粒子效果的本地化标签
- 简化粒子组件检视器,优先显示资产文件选择
- 高级属性只在未选择资产时显示,且默认折叠
- 添加可折叠的属性分组提升用户体验

* fix(particle): 修复粒子系统在浏览器预览中的资产加载和渲染

- 添加粒子 Gizmo 支持,显示发射形状并响应 Transform 缩放/旋转
- 修复资产热重载:添加 reloadAsset() 方法和 assets:refresh 事件监听
- 修复 VectorFieldEditors 数值输入精度(step 改为 0.01)
- 修复浏览器预览中粒子资产加载失败的问题:
  - 将相对路径转换为绝对路径以正确复制资产文件
  - 使用原始 GUID 而非生成的 GUID 构建 asset catalog
  - 初始化全局 assetManager 单例的 catalog 和 loader
  - 在 GameRuntime 的 systemContext 中添加 engineIntegration
- 公开 AssetManager.initializeFromCatalog 方法供运行时使用
This commit is contained in:
YHH
2025-12-07 01:00:35 +08:00
committed by GitHub
parent 1fb702169e
commit 568b327425
22 changed files with 1628 additions and 782 deletions

View File

@@ -35,6 +35,7 @@ import {
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)
@@ -45,6 +46,8 @@ 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);
@@ -72,10 +75,61 @@ export class ParticleEditorModule implements IEditorModuleLoader {
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> {
// 清理 | Clean up
// 取消订阅事件 | 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[] {

View File

@@ -0,0 +1,310 @@
/**
* 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; // 转换为弧度
const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转
// 根据发射形状绘制 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);
}

View File

@@ -23,3 +23,6 @@ export type { ParticleEditorState } from './stores/ParticleEditorStore';
// Components
export { GradientEditor, CurveEditor } from './components';
// Gizmos
export { registerParticleGizmo, unregisterParticleGizmo } from './gizmos/ParticleGizmo';

View File

@@ -2003,6 +2003,18 @@ export function ParticleEditorPanel() {
}
}, [particleData, filePath, setFilePath, markSaved]);
// 面板容器 ref | Panel container ref
const panelRef = useRef<HTMLDivElement>(null);
// 键盘快捷键处理 | Keyboard shortcut handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
e.stopPropagation();
handleSave();
}
}, [handleSave]);
const handleOpen = useCallback(async () => {
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
if (!dialog) return;
@@ -2130,7 +2142,12 @@ export function ParticleEditorPanel() {
}
return (
<div className="particle-editor-panel">
<div
ref={panelRef}
className="particle-editor-panel"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{/* Toolbar */}
<div className="particle-editor-toolbar">
<div className="toolbar-left">

View File

@@ -1,13 +1,15 @@
/**
* 粒子系统 Inspector Provider
* Particle System Inspector Provider
*
* 检视器显示控制按钮,资产选择通过 @Property 装饰器自动渲染
* Inspector shows control buttons, asset selection is auto-rendered via @Property decorator
*/
import React, { useState, useCallback } from 'react';
import { Play, Pause, RotateCcw, Sparkles } from 'lucide-react';
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';
import { EmissionShape, ParticleBlendMode } from '@esengine/particle';
interface ParticleInspectorData {
entityId: string;
@@ -41,37 +43,65 @@ interface ParticleInspectorUIProps {
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({}), []);
const handlePlay = () => {
// 当资产 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 = () => {
const handlePause = useCallback(() => {
component.pause();
refresh();
};
}, [component, refresh]);
const handleStop = () => {
const handleStop = useCallback(() => {
component.stop(true);
refresh();
};
}, [component, refresh]);
const handleBurst = () => {
component.burst(10);
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 handleChange = <K extends keyof ParticleSystemComponent>(
key: K,
value: ParticleSystemComponent[K]
) => {
(component as any)[key] = value;
component.markDirty();
refresh();
};
const hasAsset = !!component.particleAssetGuid;
const isAssetLoaded = !!component.loadedAsset;
return (
<div className="entity-inspector">
@@ -83,247 +113,50 @@ function ParticleInspectorUI({ data }: ParticleInspectorUIProps) {
onClick={component.isPlaying ? handlePause : handlePlay}
style={buttonStyle}
title={component.isPlaying ? 'Pause' : 'Play'}
disabled={isLoading}
>
{component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
{isLoading ? <Loader2 size={14} className="animate-spin" /> :
component.isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<button onClick={handleStop} style={buttonStyle} title="Stop">
<button onClick={handleStop} style={buttonStyle} title="Stop" disabled={isLoading}>
<RotateCcw size={14} />
</button>
<button onClick={handleBurst} style={buttonStyle} title="Burst 10">
<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.maxParticles}</span>
<span>{component.activeParticleCount} / {component.loadedAsset?.maxParticles ?? component.maxParticles}</span>
</div>
</div>
{/* 基础属性 | Basic Properties */}
<div className="inspector-section">
<div className="section-title">Basic</div>
<NumberInput
label="Max Particles"
value={component.maxParticles}
min={1}
max={10000}
step={100}
onChange={v => handleChange('maxParticles', v)}
/>
<CheckboxInput
label="Looping"
checked={component.looping}
onChange={v => handleChange('looping', v)}
/>
<NumberInput
label="Duration"
value={component.duration}
min={0.1}
step={0.1}
onChange={v => handleChange('duration', v)}
/>
<NumberInput
label="Playback Speed"
value={component.playbackSpeed}
min={0.01}
max={10}
step={0.1}
onChange={v => handleChange('playbackSpeed', v)}
/>
</div>
{/* 发射属性 | Emission Properties */}
<div className="inspector-section">
<div className="section-title">Emission</div>
<NumberInput
label="Emission Rate"
value={component.emissionRate}
min={0}
step={1}
onChange={v => handleChange('emissionRate', v)}
/>
<SelectInput
label="Shape"
value={component.emissionShape}
options={[
{ value: EmissionShape.Point, label: 'Point' },
{ value: EmissionShape.Circle, label: 'Circle (filled)' },
{ value: EmissionShape.Ring, label: 'Ring (edge)' },
{ value: EmissionShape.Rectangle, label: 'Rectangle (filled)' },
{ value: EmissionShape.Edge, label: 'Edge (rect outline)' },
{ value: EmissionShape.Line, label: 'Line' },
{ value: EmissionShape.Cone, label: 'Cone' },
]}
onChange={v => handleChange('emissionShape', v as EmissionShape)}
/>
{component.emissionShape !== EmissionShape.Point && (
<NumberInput
label="Shape Radius"
value={component.shapeRadius}
min={0}
step={1}
onChange={v => handleChange('shapeRadius', v)}
/>
{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>
{/* 粒子属性 | Particle Properties */}
<div className="inspector-section">
<div className="section-title">Particle</div>
<RangeInput
label="Lifetime"
minValue={component.lifetimeMin}
maxValue={component.lifetimeMax}
min={0.01}
step={0.1}
onMinChange={v => handleChange('lifetimeMin', v)}
onMaxChange={v => handleChange('lifetimeMax', v)}
/>
<RangeInput
label="Speed"
minValue={component.speedMin}
maxValue={component.speedMax}
min={0}
step={1}
onMinChange={v => handleChange('speedMin', v)}
onMaxChange={v => handleChange('speedMax', v)}
/>
<NumberInput
label="Direction (°)"
value={component.direction}
min={-180}
max={180}
step={1}
onChange={v => handleChange('direction', v)}
/>
<NumberInput
label="Spread (°)"
value={component.directionSpread}
min={0}
max={360}
step={1}
onChange={v => handleChange('directionSpread', v)}
/>
<RangeInput
label="Scale"
minValue={component.scaleMin}
maxValue={component.scaleMax}
min={0.01}
step={0.1}
onMinChange={v => handleChange('scaleMin', v)}
onMaxChange={v => handleChange('scaleMax', v)}
/>
<NumberInput
label="Gravity X"
value={component.gravityX}
step={1}
onChange={v => handleChange('gravityX', v)}
/>
<NumberInput
label="Gravity Y"
value={component.gravityY}
step={1}
onChange={v => handleChange('gravityY', v)}
/>
</div>
{/* 颜色属性 | Color Properties */}
<div className="inspector-section">
<div className="section-title">Color</div>
<ColorInput
label="Start Color"
value={component.startColor}
onChange={v => handleChange('startColor', v)}
/>
<NumberInput
label="Start Alpha"
value={component.startAlpha}
min={0}
max={1}
step={0.01}
onChange={v => handleChange('startAlpha', v)}
/>
<NumberInput
label="End Alpha"
value={component.endAlpha}
min={0}
max={1}
step={0.01}
onChange={v => handleChange('endAlpha', v)}
/>
<NumberInput
label="End Scale"
value={component.endScale}
min={0}
step={0.1}
onChange={v => handleChange('endScale', v)}
/>
</div>
{/* 渲染属性 | Rendering Properties */}
<div className="inspector-section">
<div className="section-title">Rendering</div>
<NumberInput
label="Particle Size"
value={component.particleSize}
min={1}
step={1}
onChange={v => handleChange('particleSize', v)}
/>
<SelectInput
label="Blend Mode"
value={component.blendMode}
options={[
{ value: ParticleBlendMode.Normal, label: 'Normal' },
{ value: ParticleBlendMode.Additive, label: 'Additive' },
{ value: ParticleBlendMode.Multiply, label: 'Multiply' },
]}
onChange={v => handleChange('blendMode', v as ParticleBlendMode)}
/>
<NumberInput
label="Sorting Order"
value={component.sortingOrder}
step={1}
onChange={v => handleChange('sortingOrder', v)}
/>
</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>
);
}
// ============= UI Components =============
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '4px 8px',
border: '1px solid var(--border-color, #3a3a3a)',
borderRadius: '4px',
background: 'var(--input-background, #2a2a2a)',
color: 'var(--text-color, #e0e0e0)',
fontSize: '12px',
};
const buttonStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
@@ -336,133 +169,3 @@ const buttonStyle: React.CSSProperties = {
cursor: 'pointer',
fontSize: '12px',
};
interface NumberInputProps {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
}
function NumberInput({ label, value, min, max, step = 1, onChange }: NumberInputProps) {
return (
<div className="property-row">
<label>{label}</label>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={e => onChange(parseFloat(e.target.value) || 0)}
style={inputStyle}
/>
</div>
);
}
interface RangeInputProps {
label: string;
minValue: number;
maxValue: number;
min?: number;
max?: number;
step?: number;
onMinChange: (value: number) => void;
onMaxChange: (value: number) => void;
}
function RangeInput({ label, minValue, maxValue, min, max, step = 1, onMinChange, onMaxChange }: RangeInputProps) {
return (
<div className="property-row">
<label>{label}</label>
<div style={{ display: 'flex', gap: '4px', flex: 1 }}>
<input
type="number"
value={minValue}
min={min}
max={max}
step={step}
onChange={e => onMinChange(parseFloat(e.target.value) || 0)}
style={{ ...inputStyle, width: '50%' }}
title="Min"
/>
<input
type="number"
value={maxValue}
min={min}
max={max}
step={step}
onChange={e => onMaxChange(parseFloat(e.target.value) || 0)}
style={{ ...inputStyle, width: '50%' }}
title="Max"
/>
</div>
</div>
);
}
interface CheckboxInputProps {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}
function CheckboxInput({ label, checked, onChange }: CheckboxInputProps) {
return (
<div className="property-row">
<label>{label}</label>
<input
type="checkbox"
checked={checked}
onChange={e => onChange(e.target.checked)}
/>
</div>
);
}
interface SelectInputProps {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}
function SelectInput({ label, value, options, onChange }: SelectInputProps) {
return (
<div className="property-row">
<label>{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
style={inputStyle}
>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
}
interface ColorInputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
function ColorInput({ label, value, onChange }: ColorInputProps) {
return (
<div className="property-row">
<label>{label}</label>
<input
type="color"
value={value}
onChange={e => onChange(e.target.value)}
style={{ ...inputStyle, padding: '2px', height: '24px' }}
/>
</div>
);
}

View File

@@ -78,10 +78,14 @@ export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
setPendingFilePath: (path) => set({ pendingFilePath: path }),
setParticleData: (data) => set({
setParticleData: (data) => set((state) => ({
particleData: data,
isDirty: false,
}),
// 如果有文件路径,修改数据时应该标记为 dirty
// 如果没有文件路径且之前也没有数据,则是加载文件,不标记 dirty
// If has file path, mark as dirty when data changes
// If no file path and no previous data, it's loading, don't mark dirty
isDirty: state.filePath !== null || state.particleData !== null,
})),
updateProperty: (key, value) => set((state) => {
if (!state.particleData) return state;
@@ -115,7 +119,7 @@ export const useParticleEditorStore = create<ParticleEditorState>((set) => ({
particleData: createDefaultParticleAsset(name),
filePath: null,
isDirty: true,
isPlaying: false,
isPlaying: true, // 自动播放 | Auto play
selectedPreset: null,
}),
}));