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

@@ -28,6 +28,7 @@ function getIconComponent(iconName: string | undefined, size: number = 14): Reac
const categoryIconMap: Record<string, string> = {
'rendering': 'Image',
'ui': 'LayoutGrid',
'effects': 'Sparkles',
'physics': 'Box',
'audio': 'Volume2',
'basic': 'Plus',
@@ -925,6 +926,7 @@ function ContextMenuWithSubmenu({
'ui': { zh: 'UI', en: 'UI' },
'physics': { zh: '物理', en: 'Physics' },
'audio': { zh: '音频', en: 'Audio' },
'effects': { zh: '特效', en: 'Effects' },
'other': { zh: '其他', en: 'Other' },
};
@@ -934,6 +936,7 @@ function ContextMenuWithSubmenu({
'Animated Sprite': { zh: '动画精灵', en: 'Animated Sprite' },
'创建 Tilemap': { zh: '瓦片地图', en: 'Tilemap' },
'Camera 2D': { zh: '2D 相机', en: 'Camera 2D' },
'创建粒子效果': { zh: '粒子效果', en: 'Particle Effect' },
};
const getCategoryLabel = (category: string) => {
@@ -966,7 +969,7 @@ function ContextMenuWithSubmenu({
setActiveSubmenu(category);
};
const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other'];
const categoryOrder = ['rendering', 'ui', 'effects', 'physics', 'audio', 'basic', 'other'];
const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => {
const orderA = categoryOrder.indexOf(a);
const orderB = categoryOrder.indexOf(b);

View File

@@ -843,6 +843,12 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// 从场景中收集所有资产路径
const sceneObj = JSON.parse(sceneData);
const assetPaths = new Set<string>();
// GUID 到路径的映射,用于需要通过 GUID 加载的资产
// GUID to path mapping for assets that need to be loaded by GUID
const guidToPath = new Map<string, string>();
// Get asset registry for resolving GUIDs
const assetRegistry = Core.services.tryResolve(AssetRegistryService);
// Scan all components for asset references
if (sceneObj.entities) {
@@ -865,6 +871,46 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
if (comp.type === 'AudioSource' && comp.data?.clip) {
assetPaths.add(comp.data.clip);
}
// Particle assets - resolve GUID to path
if (comp.type === 'ParticleSystem' && comp.data?.particleAssetGuid) {
const guid = comp.data.particleAssetGuid;
if (assetRegistry) {
const relativePath = assetRegistry.getPathByGuid(guid);
if (relativePath && projectPath) {
// Convert relative path to absolute path
// 将相对路径转换为绝对路径
const absolutePath = `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
assetPaths.add(absolutePath);
guidToPath.set(guid, absolutePath);
// Also check for texture referenced in particle asset
// 同时检查粒子资产中引用的纹理
try {
const particleContent = await TauriAPI.readFileContent(absolutePath);
const particleData = JSON.parse(particleContent);
const textureRef = particleData.textureGuid || particleData.texturePath;
if (textureRef) {
// Check if it's a GUID or a path
if (textureRef.includes('-') && textureRef.length > 30) {
// Looks like a GUID
const textureRelPath = assetRegistry.getPathByGuid(textureRef);
if (textureRelPath && projectPath) {
const textureAbsPath = `${projectPath}\\${textureRelPath.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
guidToPath.set(textureRef, textureAbsPath);
}
} else {
// It's a path
const textureAbsPath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
}
}
} catch {
// Ignore parse errors
}
}
}
}
}
}
}
@@ -899,12 +945,25 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
'.btree': 'btree',
'.tmx': 'tilemap', '.tsx': 'tileset',
'.mp3': 'audio', '.ogg': 'audio', '.wav': 'audio',
'.json': 'json'
'.json': 'json',
'.particle': 'particle'
};
const assetType = typeMap[ext] || 'binary';
// Generate simple GUID based on path
const guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
// Check if this asset was referenced by a GUID (e.g., particle assets)
// If so, use the original GUID; otherwise generate one from the path
// 检查此资产是否通过 GUID 引用(如粒子资产)
// 如果是,使用原始 GUID否则根据路径生成
let guid: string | undefined;
for (const [originalGuid, mappedPath] of guidToPath.entries()) {
if (mappedPath === assetPath) {
guid = originalGuid;
break;
}
}
if (!guid) {
guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
}
catalogEntries[guid] = {
guid,
@@ -913,8 +972,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
size: 0,
hash: ''
};
console.log(`[Viewport] Copied asset: ${filename}`);
} catch (error) {
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);
}
@@ -928,7 +985,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
entries: catalogEntries
};
await TauriAPI.writeFileContent(`${runtimeDir}/asset-catalog.json`, JSON.stringify(assetCatalog, null, 2));
console.log(`[Viewport] Asset catalog created with ${Object.keys(catalogEntries).length} entries`);
// Copy user-runtime.js if it exists
// 如果存在用户运行时,复制 user-runtime.js

View File

@@ -11,19 +11,46 @@ const VectorInput: React.FC<{
onChange: (value: number) => void;
readonly?: boolean;
axis: 'x' | 'y' | 'z' | 'w';
}> = ({ label, value, onChange, readonly, axis }) => (
<div className="property-vector-axis-compact">
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
<input
type="number"
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
disabled={readonly}
step={0.1}
className="property-input property-input-number property-input-number-compact"
/>
</div>
);
step?: number;
}> = ({ label, value, onChange, readonly, axis, step = 0.01 }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 允许空字符串、负号、小数点等中间输入状态
// Allow empty string, minus sign, decimal point as intermediate states
if (inputValue === '' || inputValue === '-' || inputValue === '.' || inputValue === '-.') {
return; // 不触发 onChange等待用户完成输入
}
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
onChange(parsed);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// 失去焦点时,如果是无效值则重置为当前值
// On blur, if value is invalid, reset to current value
const parsed = parseFloat(e.target.value);
if (isNaN(parsed)) {
e.target.value = String(value);
}
};
return (
<div className="property-vector-axis-compact">
<span className={`property-vector-axis-label property-vector-axis-${axis}`}>{label}</span>
<input
type="number"
defaultValue={value}
key={value} // 强制在外部值变化时重新渲染
onChange={handleChange}
onBlur={handleBlur}
disabled={readonly}
step={step}
className="property-input property-input-number property-input-number-compact"
/>
</div>
);
};
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
readonly type = 'vector2';