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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user