feat(modules): 添加module.json配置

This commit is contained in:
yhh
2025-12-03 16:20:48 +08:00
parent e1d494b415
commit 37ab494e4a
26 changed files with 2356 additions and 147 deletions

View File

@@ -0,0 +1,983 @@
/**
* Sprite Component Inspector.
* 精灵组件检查器。
*
* Provides custom inspector UI for SpriteComponent with material override support.
* 为 SpriteComponent 提供带材质覆盖支持的自定义检查器 UI。
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { IComponentInspector, ComponentInspectorContext, MessageHub, IFileSystemService, IFileSystem, ProjectService } from '@esengine/editor-core';
import { SpriteComponent, MaterialOverrides, MaterialPropertyOverride } from '@esengine/sprite';
import { getMaterialManager, Material, BlendMode, BuiltInShaders, UniformType } from '@esengine/material-system';
import { ChevronDown, ChevronRight, X, Plus, Save, ExternalLink, RefreshCw } from 'lucide-react';
import './SpriteComponentInspector.css';
/**
* Blend mode options.
* 混合模式选项。
*/
const BLEND_MODE_OPTIONS = [
{ value: BlendMode.None, label: 'None (Opaque)' },
{ value: BlendMode.Alpha, label: 'Alpha Blend' },
{ value: BlendMode.Additive, label: 'Additive' },
{ value: BlendMode.Multiply, label: 'Multiply' },
{ value: BlendMode.Screen, label: 'Screen' },
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha' },
];
/**
* Built-in shader options.
* 内置着色器选项。
*/
const BUILT_IN_SHADER_OPTIONS = [
{ value: BuiltInShaders.DefaultSprite, label: 'Default Sprite' },
{ value: BuiltInShaders.Grayscale, label: 'Grayscale' },
{ value: BuiltInShaders.Tint, label: 'Tint' },
{ value: BuiltInShaders.Flash, label: 'Flash' },
{ value: BuiltInShaders.Outline, label: 'Outline' },
];
/**
* Shader option with path info.
* 带路径信息的着色器选项。
*/
interface ShaderOption {
value: number;
label: string;
path?: string;
}
/**
* Get all available shaders (built-in + custom loaded).
* 获取所有可用着色器(内置 + 自定义加载的)。
*/
function getAvailableShaders(): ShaderOption[] {
const materialManager = getMaterialManager();
if (!materialManager) {
return BUILT_IN_SHADER_OPTIONS;
}
const shaderIds = materialManager.getShaderIds();
const options: ShaderOption[] = [];
for (const id of shaderIds) {
const shader = materialManager.getShader(id);
if (shader) {
// Check if it's a built-in shader.
// 检查是否是内置着色器。
const builtIn = BUILT_IN_SHADER_OPTIONS.find(opt => opt.value === id);
options.push({
value: id,
label: builtIn ? builtIn.label : shader.name
});
}
}
return options;
}
/**
* Scan and load all shader files from project.
* 扫描并加载项目中所有的着色器文件。
*/
async function scanAndLoadProjectShaders(): Promise<ShaderOption[]> {
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
const projectService = Core.services.tryResolve(ProjectService);
const materialManager = getMaterialManager();
if (!fileSystem || !projectService || !materialManager) {
return getAvailableShaders();
}
const currentProject = projectService.getCurrentProject();
if (!currentProject) {
return getAvailableShaders();
}
try {
// Scan for .shader files in project.
// 扫描项目中的 .shader 文件。
const shaderFiles = await fileSystem.scanFiles(currentProject.path, '**/*.shader');
// Load each shader.
// 加载每个着色器。
for (const shaderPath of shaderFiles) {
// Skip if already loaded.
// 如果已加载则跳过。
if (materialManager.hasShaderByPath(shaderPath)) {
continue;
}
try {
await materialManager.loadShaderFromPath(shaderPath);
} catch (error) {
console.warn('[SpriteComponentInspector] Failed to load shader:', shaderPath, error);
}
}
} catch (error) {
console.warn('[SpriteComponentInspector] Failed to scan shader files:', error);
}
return getAvailableShaders();
}
/**
* Uniform type display names.
* Uniform 类型显示名称。
*/
const UNIFORM_TYPE_LABELS: Record<string, string> = {
'float': 'Float',
'vec2': 'Vec2',
'vec3': 'Vec3',
'vec4': 'Vec4',
'color': 'Color',
'int': 'Int',
'mat3': 'Mat3',
'mat4': 'Mat4',
'sampler': 'Sampler',
};
/**
* Inline material editor props.
* 内联材质编辑器属性。
*/
interface InlineMaterialEditorProps {
material: Material;
materialPath: string;
onMaterialChange: () => void;
}
/**
* Inline material editor component.
* 内联材质编辑器组件。
*
* Allows editing material properties directly in the sprite inspector.
* 允许直接在精灵检查器中编辑材质属性。
*/
function InlineMaterialEditor({ material, materialPath, onMaterialChange }: InlineMaterialEditorProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isDirty, setIsDirty] = useState(false);
const [isLoadingShaders, setIsLoadingShaders] = useState(false);
const [shaderOptions, setShaderOptions] = useState<ShaderOption[]>(() => getAvailableShaders());
const [localMaterial, setLocalMaterial] = useState(() => ({
name: material.name,
shader: material.shaderId,
blendMode: material.blendMode,
uniforms: Object.fromEntries(material.getUniforms())
}));
// Scan and load project shaders on mount.
// 挂载时扫描并加载项目着色器。
useEffect(() => {
let mounted = true;
setIsLoadingShaders(true);
scanAndLoadProjectShaders().then(options => {
if (mounted) {
setShaderOptions(options);
setIsLoadingShaders(false);
}
});
return () => { mounted = false; };
}, []);
// Sync with material changes.
// 同步材质变化。
useEffect(() => {
setLocalMaterial({
name: material.name,
shader: material.shaderId,
blendMode: material.blendMode,
uniforms: Object.fromEntries(material.getUniforms())
});
setIsDirty(false);
}, [material]);
const handleShaderChange = (shaderId: number) => {
material.shaderId = shaderId;
setLocalMaterial(prev => ({ ...prev, shader: shaderId }));
setIsDirty(true);
onMaterialChange();
};
const handleRefreshShaders = async () => {
// Re-scan project shaders.
// 重新扫描项目着色器。
setIsLoadingShaders(true);
const options = await scanAndLoadProjectShaders();
setShaderOptions(options);
setIsLoadingShaders(false);
};
const handleBlendModeChange = (blendMode: BlendMode) => {
material.blendMode = blendMode;
setLocalMaterial(prev => ({ ...prev, blendMode }));
setIsDirty(true);
onMaterialChange();
};
const handleUniformChange = (name: string, value: number | number[]) => {
// Get the uniform type from current material.
// 从当前材质获取 uniform 类型。
const currentUniform = material.getUniform(name);
if (!currentUniform) return;
// Set uniform based on type.
// 根据类型设置 uniform。
switch (currentUniform.type) {
case UniformType.Float:
if (typeof value === 'number') {
material.setFloat(name, value);
}
break;
case UniformType.Int:
if (typeof value === 'number') {
material.setInt(name, value);
}
break;
case UniformType.Vec2:
if (Array.isArray(value) && value.length >= 2) {
material.setVec2(name, value[0], value[1]);
}
break;
case UniformType.Vec3:
if (Array.isArray(value) && value.length >= 3) {
material.setVec3(name, value[0], value[1], value[2]);
}
break;
case UniformType.Vec4:
if (Array.isArray(value) && value.length >= 4) {
material.setVec4(name, value[0], value[1], value[2], value[3]);
}
break;
case UniformType.Color:
if (Array.isArray(value) && value.length >= 4) {
material.setColor(name, value[0], value[1], value[2], value[3]);
}
break;
}
setLocalMaterial(prev => ({
...prev,
uniforms: { ...prev.uniforms, [name]: { ...prev.uniforms[name], value } }
}));
setIsDirty(true);
onMaterialChange();
};
const handleSave = async () => {
if (!materialPath) return;
try {
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
if (!fileSystem) {
console.error('[InlineMaterialEditor] FileSystem service not available');
return;
}
// Build material data.
// 构建材质数据。
const materialData = {
name: material.name,
shader: material.shaderId,
blendMode: material.blendMode,
uniforms: Object.fromEntries(
Array.from(material.getUniforms().entries()).map(([k, v]) => [k, { type: v.type, value: v.value }])
)
};
await fileSystem.writeFile(materialPath, JSON.stringify(materialData, null, 2));
setIsDirty(false);
// Notify
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('material:saved', { filePath: materialPath });
}
} catch (error) {
console.error('[InlineMaterialEditor] Failed to save material:', error);
}
};
const handleOpenInEditor = () => {
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub && materialPath) {
messageHub.publish('asset:open', { filePath: materialPath, type: 'material' });
}
};
const uniforms = Array.from(material.getUniforms().entries());
return (
<div className="inline-material-editor">
<div
className="inline-material-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="inline-material-expand">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="inline-material-title">
Material: {material.name}
{isDirty && <span className="inline-material-dirty">*</span>}
</span>
<div className="inline-material-actions" onClick={e => e.stopPropagation()}>
<button
className="inline-material-btn"
onClick={handleSave}
disabled={!isDirty}
title="Save Material"
>
<Save size={12} />
</button>
<button
className="inline-material-btn"
onClick={handleOpenInEditor}
title="Open in Material Editor"
>
<ExternalLink size={12} />
</button>
</div>
</div>
{isExpanded && (
<div className="inline-material-content">
{/* Shader */}
<div className="inline-material-row">
<label>Shader</label>
<div className="inline-material-shader-select">
<select
value={localMaterial.shader}
onChange={e => handleShaderChange(Number(e.target.value))}
disabled={isLoadingShaders}
>
{shaderOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
className={`inline-material-refresh-btn ${isLoadingShaders ? 'loading' : ''}`}
onClick={handleRefreshShaders}
disabled={isLoadingShaders}
title="Refresh shader list"
>
<RefreshCw size={12} />
</button>
</div>
</div>
{/* Blend Mode */}
<div className="inline-material-row">
<label>Blend Mode</label>
<select
value={localMaterial.blendMode}
onChange={e => handleBlendModeChange(Number(e.target.value) as BlendMode)}
>
{BLEND_MODE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Uniforms */}
{uniforms.length > 0 && (
<div className="inline-material-uniforms">
<div className="inline-material-uniforms-header">Uniforms</div>
{uniforms.map(([name, uniform]) => (
<div key={name} className="inline-material-uniform">
<label>{name}</label>
<UniformValueEditor
type={uniform.type as MaterialPropertyOverride['type']}
value={uniform.value as number | number[]}
onChange={v => handleUniformChange(name, v)}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
/**
* Uniform value editor component (reused for both material and overrides).
* Uniform 值编辑器组件(用于材质和覆盖)。
*/
function UniformValueEditor({ type, value, onChange }: {
type: MaterialPropertyOverride['type'];
value: number | number[];
onChange: (value: number | number[]) => void;
}) {
switch (type) {
case 'float':
case 'int':
return (
<input
type="number"
className="uniform-input uniform-input-number"
value={typeof value === 'number' ? value : 0}
step={type === 'int' ? 1 : 0.1}
onChange={(e) => {
const v = type === 'int'
? Math.floor(parseFloat(e.target.value) || 0)
: parseFloat(e.target.value) || 0;
onChange(v);
}}
/>
);
case 'vec2':
return (
<div className="uniform-vector">
{['X', 'Y'].map((axis, i) => (
<div key={axis} className="uniform-vector-axis">
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="uniform-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'vec3':
return (
<div className="uniform-vector">
{['X', 'Y', 'Z'].map((axis, i) => (
<div key={axis} className="uniform-vector-axis">
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="uniform-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'vec4':
return (
<div className="uniform-vector uniform-vector-4">
{['X', 'Y', 'Z', 'W'].map((axis, i) => (
<div key={axis} className="uniform-vector-axis">
<span className={`uniform-axis-label uniform-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="uniform-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0, 0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'color': {
const colorArray = Array.isArray(value) ? value : [1, 1, 1, 1];
const r = Math.round((colorArray[0] ?? 1) * 255);
const g = Math.round((colorArray[1] ?? 1) * 255);
const b = Math.round((colorArray[2] ?? 1) * 255);
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return (
<div className="uniform-color">
<div
className="uniform-color-preview"
style={{ backgroundColor: hexColor }}
/>
<input
type="color"
className="uniform-color-input"
value={hexColor}
onChange={(e) => {
const hex = e.target.value;
const newR = parseInt(hex.slice(1, 3), 16) / 255;
const newG = parseInt(hex.slice(3, 5), 16) / 255;
const newB = parseInt(hex.slice(5, 7), 16) / 255;
onChange([newR, newG, newB, colorArray[3] ?? 1]);
}}
/>
<input
type="number"
className="uniform-input uniform-alpha"
value={colorArray[3] ?? 1}
min={0}
max={1}
step={0.1}
title="Alpha"
onChange={(e) => {
const alpha = Math.max(0, Math.min(1, parseFloat(e.target.value) || 0));
onChange([colorArray[0] ?? 1, colorArray[1] ?? 1, colorArray[2] ?? 1, alpha]);
}}
/>
</div>
);
}
default:
return <span className="uniform-unsupported">Unsupported type</span>;
}
}
/**
* Material override editor props.
* 材质覆盖编辑器属性。
*/
interface MaterialOverrideEditorProps {
sprite: SpriteComponent;
material: Material | null;
onChange: (propertyName: string, value: unknown) => void;
}
/**
* Material override editor component.
* 材质覆盖编辑器组件。
*/
function MaterialOverrideEditor({ sprite, material, onChange }: MaterialOverrideEditorProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [showAddMenu, setShowAddMenu] = useState(false);
// Get available uniforms from material.
// 从材质获取可用的 uniforms。
const availableUniforms = useMemo(() => {
if (!material) return [];
const uniforms = material.getUniforms();
return Array.from(uniforms.entries()).map(([name, value]) => ({
name,
type: value.type,
defaultValue: value.value
}));
}, [material]);
// Get current overrides.
// 获取当前覆盖。
const currentOverrides = sprite.materialOverrides || {};
const overrideKeys = Object.keys(currentOverrides);
// Get uniforms not yet overridden.
// 获取尚未覆盖的 uniforms。
const unoverriddenUniforms = availableUniforms.filter(
u => !overrideKeys.includes(u.name)
);
const handleAddOverride = (uniformName: string) => {
const uniform = availableUniforms.find(u => u.name === uniformName);
if (!uniform) return;
// Convert defaultValue to appropriate type
let value: number | number[];
if (typeof uniform.defaultValue === 'number') {
value = uniform.defaultValue;
} else if (Array.isArray(uniform.defaultValue)) {
value = uniform.defaultValue as number[];
} else {
value = 0;
}
const newOverride: MaterialPropertyOverride = {
type: uniform.type as MaterialPropertyOverride['type'],
value
};
const newOverrides = { ...currentOverrides, [uniformName]: newOverride };
onChange('materialOverrides', newOverrides);
setShowAddMenu(false);
};
const handleRemoveOverride = (uniformName: string) => {
const newOverrides = { ...currentOverrides };
delete newOverrides[uniformName];
onChange('materialOverrides', newOverrides);
};
const handleOverrideChange = (uniformName: string, value: number | number[]) => {
const current = currentOverrides[uniformName];
if (!current) return;
const newOverrides = {
...currentOverrides,
[uniformName]: { ...current, value }
};
onChange('materialOverrides', newOverrides);
};
if (!sprite.material) {
return null;
}
return (
<div className="material-override-section">
<div
className="material-override-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="material-override-expand">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="material-override-title">Material Overrides</span>
{overrideKeys.length > 0 && (
<span className="material-override-count">{overrideKeys.length}</span>
)}
</div>
{isExpanded && (
<div className="material-override-content">
{/* Existing overrides */}
{overrideKeys.map(key => {
const override = currentOverrides[key];
if (!override) return null;
return (
<div key={key} className="material-override-item">
<div className="material-override-item-header">
<span className="material-override-name">{key}</span>
<span className="material-override-type">
{UNIFORM_TYPE_LABELS[override.type] || override.type}
</span>
<button
className="material-override-remove"
onClick={() => handleRemoveOverride(key)}
title="Remove override"
>
<X size={12} />
</button>
</div>
<OverrideValueEditor
type={override.type}
value={override.value}
onChange={(v) => handleOverrideChange(key, v)}
/>
</div>
);
})}
{/* Add override button */}
{unoverriddenUniforms.length > 0 && (
<div className="material-override-add-container">
<button
className="material-override-add-btn"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus size={12} />
<span>Add Override</span>
</button>
{showAddMenu && (
<div className="material-override-add-menu">
{unoverriddenUniforms.map(u => (
<button
key={u.name}
className="material-override-add-item"
onClick={() => handleAddOverride(u.name)}
>
<span>{u.name}</span>
<span className="material-override-type-hint">
{UNIFORM_TYPE_LABELS[u.type] || u.type}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Empty state */}
{overrideKeys.length === 0 && unoverriddenUniforms.length === 0 && (
<div className="material-override-empty">
{material ? 'No parameters available' : 'Select a material first'}
</div>
)}
</div>
)}
</div>
);
}
/**
* Override value editor props.
* 覆盖值编辑器属性。
*/
interface OverrideValueEditorProps {
type: MaterialPropertyOverride['type'];
value: number | number[];
onChange: (value: number | number[]) => void;
}
/**
* Override value editor component.
* 覆盖值编辑器组件。
*/
function OverrideValueEditor({ type, value, onChange }: OverrideValueEditorProps) {
switch (type) {
case 'float':
case 'int':
return (
<input
type="number"
className="override-input override-input-number"
value={typeof value === 'number' ? value : 0}
step={type === 'int' ? 1 : 0.1}
onChange={(e) => {
const v = type === 'int'
? Math.floor(parseFloat(e.target.value) || 0)
: parseFloat(e.target.value) || 0;
onChange(v);
}}
/>
);
case 'vec2':
return (
<div className="override-vector">
{['X', 'Y'].map((axis, i) => (
<div key={axis} className="override-vector-axis">
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="override-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'vec3':
return (
<div className="override-vector">
{['X', 'Y', 'Z'].map((axis, i) => (
<div key={axis} className="override-vector-axis">
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="override-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'vec4':
return (
<div className="override-vector override-vector-4">
{['X', 'Y', 'Z', 'W'].map((axis, i) => (
<div key={axis} className="override-vector-axis">
<span className={`override-axis-label override-axis-${axis.toLowerCase()}`}>{axis}</span>
<input
type="number"
className="override-input"
value={Array.isArray(value) ? (value[i] ?? 0) : 0}
step={0.1}
onChange={(e) => {
const arr = Array.isArray(value) ? [...value] : [0, 0, 0, 0];
arr[i] = parseFloat(e.target.value) || 0;
onChange(arr);
}}
/>
</div>
))}
</div>
);
case 'color': {
const colorArray = Array.isArray(value) ? value : [1, 1, 1, 1];
const r = Math.round((colorArray[0] ?? 1) * 255);
const g = Math.round((colorArray[1] ?? 1) * 255);
const b = Math.round((colorArray[2] ?? 1) * 255);
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return (
<div className="override-color">
<div
className="override-color-preview"
style={{ backgroundColor: hexColor }}
/>
<input
type="color"
className="override-color-input"
value={hexColor}
onChange={(e) => {
const hex = e.target.value;
const newR = parseInt(hex.slice(1, 3), 16) / 255;
const newG = parseInt(hex.slice(3, 5), 16) / 255;
const newB = parseInt(hex.slice(5, 7), 16) / 255;
onChange([newR, newG, newB, colorArray[3] ?? 1]);
}}
/>
<input
type="number"
className="override-input override-alpha"
value={colorArray[3] ?? 1}
min={0}
max={1}
step={0.1}
title="Alpha"
onChange={(e) => {
const alpha = Math.max(0, Math.min(1, parseFloat(e.target.value) || 0));
onChange([colorArray[0] ?? 1, colorArray[1] ?? 1, colorArray[2] ?? 1, alpha]);
}}
/>
</div>
);
}
default:
return <span className="override-unsupported">Unsupported type</span>;
}
}
/**
* Sprite inspector content component.
* 精灵检查器内容组件。
*/
function SpriteInspectorContent({ context }: { context: ComponentInspectorContext }) {
const sprite = context.component as SpriteComponent;
const [material, setMaterial] = useState<Material | null>(null);
const [, forceUpdate] = useState({});
// Load material when sprite.material changes.
// 当 sprite.material 变化时加载材质。
useEffect(() => {
if (!sprite.material) {
setMaterial(null);
return;
}
const materialManager = getMaterialManager();
if (!materialManager) {
setMaterial(null);
return;
}
// Try to get cached material by ID.
// 尝试通过 ID 获取缓存的材质。
const materialId = materialManager.getMaterialIdByPath(sprite.material);
if (materialId > 0) {
const mat = materialManager.getMaterial(materialId);
setMaterial(mat || null);
return;
}
// Load material asynchronously.
// 异步加载材质。
materialManager.loadMaterialFromPath(sprite.material)
.then(matId => {
const mat = materialManager.getMaterial(matId);
setMaterial(mat || null);
})
.catch(() => {
setMaterial(null);
});
}, [sprite.material]);
const handleChange = useCallback((propertyName: string, value: unknown) => {
(sprite as unknown as Record<string, unknown>)[propertyName] = value;
context.onChange?.(propertyName, value);
forceUpdate({});
// Publish scene:modified.
// 发布 scene:modified。
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
}, [sprite, context]);
const handleMaterialChange = useCallback(() => {
forceUpdate({});
// Publish scene:modified for material changes.
// 发布 scene:modified 用于材质变更。
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
}, []);
// No material selected
if (!sprite.material) {
return null;
}
return (
<div className="sprite-component-inspector">
{/* Inline material editor */}
{material && (
<InlineMaterialEditor
material={material}
materialPath={sprite.material}
onMaterialChange={handleMaterialChange}
/>
)}
{/* Material override section */}
<MaterialOverrideEditor
sprite={sprite}
material={material}
onChange={handleChange}
/>
</div>
);
}
/**
* Sprite component inspector implementation.
* 精灵组件检查器实现。
*
* Uses 'append' mode to show material overrides after the default PropertyInspector.
* 使用 'append' 模式在默认 PropertyInspector 后显示材质覆盖。
*/
export class SpriteComponentInspector implements IComponentInspector<SpriteComponent> {
readonly id = 'sprite-component-inspector';
readonly name = 'Sprite Component Inspector';
readonly priority = 100;
readonly targetComponents = ['Sprite', 'SpriteComponent'];
readonly renderMode = 'append' as const;
canHandle(component: Component): component is SpriteComponent {
const typeName = getComponentInstanceTypeName(component);
return typeName === 'Sprite' || typeName === 'SpriteComponent';
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(SpriteInspectorContent, {
context,
key: `sprite-${context.version}`
});
}
}