feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)

* feat(platform-common): 添加WASM加载器和环境检测API

* feat(rapier2d): 新增Rapier2D WASM绑定包

* feat(physics-rapier2d): 添加跨平台WASM加载器

* feat(asset-system): 添加运行时资产目录和bundle格式

* feat(asset-system-editor): 新增编辑器资产管理包

* feat(editor-core): 添加构建系统和模块管理

* feat(editor-app): 重构浏览器预览使用import maps

* feat(platform-web): 添加BrowserRuntime和资产读取

* feat(engine): 添加材质系统和着色器管理

* feat(material): 新增材质系统和着色器编辑器

* feat(tilemap): 增强tilemap编辑器和动画系统

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

* feat(core): 添加module.json和类型定义更新

* chore: 更新依赖和构建配置

* refactor(plugins): 更新插件模板使用ModuleManifest

* chore: 添加第三方依赖库

* chore: 移除BehaviourTree-ai和ecs-astar子模块

* docs: 更新README和文档主题样式

* fix: 修复Rust文档测试和添加rapier2d WASM绑定

* fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题

* feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea)

* fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖

* fix: 添加缺失的包依赖修复CI构建

* fix: 修复CodeQL检测到的代码问题

* fix: 修复构建错误和缺失依赖

* fix: 修复类型检查错误

* fix(material-system): 修复tsconfig配置支持TypeScript项目引用

* fix(editor-core): 修复Rollup构建配置添加tauri external

* fix: 修复CodeQL检测到的代码问题

* fix: 修复CodeQL检测到的代码问题
This commit is contained in:
YHH
2025-12-03 22:15:22 +08:00
committed by GitHub
parent caf7622aa0
commit 63f006ab62
496 changed files with 77601 additions and 4067 deletions

View File

@@ -0,0 +1,291 @@
/**
* Material Editor Panel
* 材质编辑器面板
*/
import React, { useEffect, useCallback, useState } from 'react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, IFileSystemService } from '@esengine/editor-core';
import { BlendMode, BuiltInShaders } from '@esengine/material-system';
import { useMaterialEditorStore, createDefaultMaterialData } from '../stores/MaterialEditorStore';
import { Save, RefreshCw, FolderOpen } from 'lucide-react';
import '../styles/MaterialEditorPanel.css';
// 文件系统类型
type IFileSystem = {
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
};
/**
* 混合模式选项
*/
const BLEND_MODE_OPTIONS = [
{ value: BlendMode.None, label: 'None (Opaque)', labelZh: '无 (不透明)' },
{ value: BlendMode.Alpha, label: 'Alpha Blend', labelZh: 'Alpha 混合' },
{ value: BlendMode.Additive, label: 'Additive', labelZh: '叠加' },
{ value: BlendMode.Multiply, label: 'Multiply', labelZh: '正片叠底' },
{ value: BlendMode.Screen, label: 'Screen', labelZh: '滤色' },
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha', labelZh: '预乘 Alpha' },
];
/**
* 内置着色器选项
*/
const BUILT_IN_SHADER_OPTIONS = [
{ value: BuiltInShaders.DefaultSprite, label: 'Default Sprite', labelZh: '默认精灵' },
{ value: BuiltInShaders.Grayscale, label: 'Grayscale', labelZh: '灰度' },
{ value: BuiltInShaders.Tint, label: 'Tint', labelZh: '着色' },
{ value: BuiltInShaders.Flash, label: 'Flash', labelZh: '闪烁' },
{ value: BuiltInShaders.Outline, label: 'Outline', labelZh: '描边' },
];
/** Custom shader indicator value. | 自定义着色器指示值。 */
const CUSTOM_SHADER_VALUE = -1;
interface MaterialEditorPanelProps {
locale?: string;
}
export function MaterialEditorPanel({ locale = 'en' }: MaterialEditorPanelProps) {
const {
currentFilePath,
pendingFilePath,
materialData,
isDirty,
isLoading,
setPendingFilePath,
setCurrentFilePath,
setMaterialData,
setLoading,
updateMaterialProperty,
} = useMaterialEditorStore();
const isZh = locale === 'zh';
// 加载材质文件
const loadMaterialFile = useCallback(async (filePath: string) => {
setLoading(true);
try {
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (!fileSystem) {
console.error('[MaterialEditor] FileSystem service not available');
return;
}
const content = await fileSystem.readFile(filePath);
const data = JSON.parse(content);
setMaterialData(data);
setCurrentFilePath(filePath);
} catch (error) {
console.error('[MaterialEditor] Failed to load material:', error);
// 如果加载失败,创建默认材质
const fileName = filePath.split(/[\\/]/).pop()?.replace(/\.mat$/i, '') || 'New Material';
setMaterialData(createDefaultMaterialData(fileName));
setCurrentFilePath(filePath);
} finally {
setLoading(false);
}
}, [setLoading, setMaterialData, setCurrentFilePath]);
// 保存材质文件
const saveMaterialFile = useCallback(async () => {
if (!currentFilePath || !materialData) return;
try {
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (!fileSystem) {
console.error('[MaterialEditor] FileSystem service not available');
return;
}
await fileSystem.writeFile(currentFilePath, JSON.stringify(materialData, null, 2));
useMaterialEditorStore.getState().setDirty(false);
// 发送保存成功消息
const messageHub = Core.services.resolve(MessageHub);
messageHub?.publish('material:saved', { filePath: currentFilePath });
} catch (error) {
console.error('[MaterialEditor] Failed to save material:', error);
}
}, [currentFilePath, materialData]);
// 处理 pendingFilePath
useEffect(() => {
if (pendingFilePath) {
loadMaterialFile(pendingFilePath);
setPendingFilePath(null);
}
}, [pendingFilePath, loadMaterialFile, setPendingFilePath]);
// 监听键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveMaterialFile();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [saveMaterialFile]);
// 渲染加载状态
if (isLoading) {
return (
<div className="material-editor-panel loading">
<RefreshCw className="spin" size={24} />
<span>{isZh ? '加载中...' : 'Loading...'}</span>
</div>
);
}
// 渲染空状态
if (!materialData) {
return (
<div className="material-editor-panel empty">
<span>{isZh ? '双击 .mat 文件打开材质编辑器' : 'Double-click a .mat file to open the material editor'}</span>
</div>
);
}
return (
<div className="material-editor-panel">
{/* 工具栏 */}
<div className="material-editor-toolbar">
<div className="toolbar-left">
<span className="material-name">{materialData.name}</span>
{isDirty && <span className="dirty-indicator">*</span>}
</div>
<div className="toolbar-right">
<button
className="toolbar-button"
onClick={saveMaterialFile}
disabled={!isDirty}
title={isZh ? '保存 (Ctrl+S)' : 'Save (Ctrl+S)'}
>
<Save size={16} />
<span>{isZh ? '保存' : 'Save'}</span>
</button>
</div>
</div>
{/* 属性编辑区 */}
<div className="material-editor-content">
{/* 基本属性 */}
<div className="property-section">
<div className="section-header">{isZh ? '基本属性' : 'Basic Properties'}</div>
<div className="property-row">
<label>{isZh ? '名称' : 'Name'}</label>
<input
type="text"
value={materialData.name}
onChange={(e) => updateMaterialProperty('name', e.target.value)}
/>
</div>
<div className="property-row">
<label>{isZh ? '着色器' : 'Shader'}</label>
<div className="shader-selector">
<select
value={typeof materialData.shader === 'string' ? CUSTOM_SHADER_VALUE : materialData.shader}
onChange={(e) => {
const value = Number(e.target.value);
if (value === CUSTOM_SHADER_VALUE) {
// Keep current custom shader path if already set
if (typeof materialData.shader !== 'string') {
updateMaterialProperty('shader', '');
}
} else {
updateMaterialProperty('shader', value);
}
}}
>
{BUILT_IN_SHADER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{isZh ? opt.labelZh : opt.label}
</option>
))}
<option value={CUSTOM_SHADER_VALUE}>
{isZh ? '自定义着色器...' : 'Custom Shader...'}
</option>
</select>
</div>
</div>
{/* Custom shader path input */}
{typeof materialData.shader === 'string' && (
<div className="property-row">
<label>{isZh ? '着色器路径' : 'Shader Path'}</label>
<div className="file-input-row">
<input
type="text"
value={materialData.shader}
onChange={(e) => updateMaterialProperty('shader', e.target.value)}
placeholder={isZh ? '输入 .shader 文件路径' : 'Enter .shader file path'}
/>
<button
className="browse-button"
onClick={async () => {
// TODO: Implement file browser dialog
// 这里可以集成编辑器的文件选择对话框
}}
title={isZh ? '浏览...' : 'Browse...'}
>
<FolderOpen size={14} />
</button>
</div>
</div>
)}
<div className="property-row">
<label>{isZh ? '混合模式' : 'Blend Mode'}</label>
<select
value={materialData.blendMode}
onChange={(e) => updateMaterialProperty('blendMode', Number(e.target.value))}
>
{BLEND_MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{isZh ? opt.labelZh : opt.label}
</option>
))}
</select>
</div>
</div>
{/* Uniform 参数 */}
<div className="property-section">
<div className="section-header">{isZh ? 'Uniform 参数' : 'Uniform Parameters'}</div>
{Object.keys(materialData.uniforms || {}).length === 0 ? (
<div className="empty-uniforms">
{isZh ? '该着色器没有自定义参数' : 'This shader has no custom parameters'}
</div>
) : (
Object.entries(materialData.uniforms || {}).map(([key, uniform]) => (
<div key={key} className="property-row">
<label>{key}</label>
<span className="uniform-type">{uniform.type}</span>
</div>
))
)}
</div>
{/* 文件信息 */}
<div className="property-section">
<div className="section-header">{isZh ? '文件信息' : 'File Info'}</div>
<div className="property-row file-path">
<label>{isZh ? '路径' : 'Path'}</label>
<span title={currentFilePath || ''}>
{currentFilePath?.split(/[\\/]/).pop() || '-'}
</span>
</div>
</div>
</div>
</div>
);
}
export default MaterialEditorPanel;

View File

@@ -0,0 +1,268 @@
/**
* @esengine/material-editor
*
* Editor support for @esengine/material-system - file creation templates and material asset management
*
* 材质编辑器模块 - 提供材质文件创建功能
* 注意:材质不是独立组件,而是作为渲染组件(如 SpriteComponent的属性使用
* 材质文件 (.mat) 的预览和编辑在 Inspector 中完成
*/
import type { ServiceContainer } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type {
IEditorModuleLoader,
FileCreationTemplate,
IPlugin,
ModuleManifest
} from '@esengine/editor-core';
import {
MessageHub,
FileActionRegistry,
InspectorRegistry,
IInspectorRegistry,
IFileSystemService
} from '@esengine/editor-core';
// Inspector provider
import { MaterialAssetInspectorProvider } from './providers/MaterialAssetInspectorProvider';
// Runtime imports from @esengine/material-system
import {
MaterialRuntimeModule,
BlendMode,
BuiltInShaders
} from '@esengine/material-system';
// Editor components - for re-export only
import { MaterialEditorPanel } from './components/MaterialEditorPanel';
import { useMaterialEditorStore } from './stores/MaterialEditorStore';
// Import styles
import './styles/MaterialEditorPanel.css';
const DEFAULT_MATERIAL_TEMPLATE = {
name: 'New Material',
shader: BuiltInShaders.DefaultSprite,
blendMode: BlendMode.Alpha,
uniforms: {}
};
/**
* Material Editor Module
*
* 提供:
* - 材质文件 (.mat) 创建模板
* - 着色器文件 (.shader) 创建模板
* - 材质资产创建消息处理(用于 PropertyInspector 中的创建按钮)
*
* 注意:.mat 文件的预览和编辑在 Inspector 中完成,不需要单独的编辑器面板
*/
export class MaterialEditorModule implements IEditorModuleLoader {
private inspectorProvider?: MaterialAssetInspectorProvider;
async install(services: ServiceContainer): Promise<void> {
// Register file creation templates
const fileActionRegistry = services.resolve(FileActionRegistry);
if (fileActionRegistry) {
for (const template of this.getFileCreationTemplates()) {
fileActionRegistry.registerCreationTemplate(template);
}
// Register asset creation mapping for .mat files
fileActionRegistry.registerAssetCreationMapping({
extension: '.mat',
createMessage: 'material:create',
canCreate: true
});
}
// Register Material Asset Inspector Provider
const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
if (inspectorRegistry) {
this.inspectorProvider = new MaterialAssetInspectorProvider();
// Set up save handler using file system service
const fileSystem = services.tryResolve(IFileSystemService) as { writeFile(path: string, content: string): Promise<void> } | null;
if (fileSystem) {
this.inspectorProvider.setSaveHandler(async (path, content) => {
await fileSystem.writeFile(path, content);
});
}
inspectorRegistry.register(this.inspectorProvider);
}
// Subscribe to material:create message
const messageHub = services.resolve(MessageHub);
if (messageHub) {
messageHub.subscribe('material:create', async (payload: {
entityId?: string;
onChange?: (value: string | null) => void;
}) => {
await this.handleCreateMaterialAsset(payload);
});
}
}
async uninstall(): Promise<void> {
// Reset store state
useMaterialEditorStore.getState().reset();
}
getFileCreationTemplates(): FileCreationTemplate[] {
return [
{
id: 'create-material',
label: 'Material',
extension: 'mat',
icon: 'Palette',
category: 'Rendering',
getContent: (fileName: string): string => {
const materialName = fileName.replace(/\.mat$/i, '');
return JSON.stringify(
{
...DEFAULT_MATERIAL_TEMPLATE,
name: materialName
},
null,
2
);
}
},
{
id: 'create-shader',
label: 'Shader',
extension: 'shader',
icon: 'Code',
category: 'Rendering',
getContent: (fileName: string): string => {
const shaderName = fileName.replace(/\.shader$/i, '');
return JSON.stringify(
{
version: 1,
shader: {
name: shaderName,
vertexSource: `#version 300 es
precision highp float;
in vec2 a_position;
in vec2 a_texCoord;
in vec4 a_color;
uniform mat4 u_projection;
uniform mat4 u_view;
out vec2 v_texCoord;
out vec4 v_color;
void main() {
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
v_color = a_color;
}`,
fragmentSource: `#version 300 es
precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
vec4 texColor = texture(u_texture, v_texCoord);
fragColor = texColor * v_color;
}`
}
},
null,
2
);
}
}
];
}
/**
* Handle create material asset request
*/
private async handleCreateMaterialAsset(
payload: { entityId?: string; onChange?: (value: string | null) => void }
): Promise<void> {
// Import dialog and file system services dynamically
const { IDialogService, IFileSystemService } = await import('@esengine/editor-core');
type IDialog = { saveDialog(options: any): Promise<string | null> };
type IFileSystem = { writeFile(path: string, content: string): Promise<void> };
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
if (!dialog || !fileSystem) {
console.error('[MaterialEditorModule] Dialog or FileSystem service not available');
return;
}
const filePath = await dialog.saveDialog({
title: 'Create Material Asset',
filters: [{ name: 'Material', extensions: ['mat'] }],
defaultPath: 'new-material.mat'
});
if (!filePath) {
return;
}
const materialName = filePath.split(/[\\/]/).pop()?.replace(/\.mat$/i, '') || 'New Material';
const materialData = {
...DEFAULT_MATERIAL_TEMPLATE,
name: materialName
};
await fileSystem.writeFile(filePath, JSON.stringify(materialData, null, 2));
if (payload.onChange) {
payload.onChange(filePath);
}
}
}
export const materialEditorModule = new MaterialEditorModule();
// Re-exports
export { MaterialEditorPanel } from './components/MaterialEditorPanel';
export { useMaterialEditorStore, createDefaultMaterialData } from './stores/MaterialEditorStore';
export type { MaterialEditorState } from './stores/MaterialEditorStore';
/**
* Material Plugin Manifest
*/
const manifest: ModuleManifest = {
id: '@esengine/material-system',
name: '@esengine/material-system',
displayName: 'Material System',
version: '1.0.0',
description: 'Material and shader system for custom rendering effects',
category: 'Rendering',
isCore: true,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['engine-core'],
exports: {
components: ['MaterialComponent'],
other: ['Material', 'Shader', 'BlendMode']
}
};
/**
* Complete Material Plugin (runtime + editor)
*/
export const MaterialPlugin: IPlugin = {
manifest,
runtimeModule: new MaterialRuntimeModule(),
editorModule: materialEditorModule
};
export default materialEditorModule;

View File

@@ -0,0 +1,465 @@
/**
* MaterialAssetInspectorProvider - Inspector provider for .mat files
* 材质资产检视器提供者 - 用于 .mat 文件的检视器
*/
import React, { useState, useEffect, useCallback } from 'react';
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
import { BlendMode, BuiltInShaders, UniformType } from '@esengine/material-system';
import { Palette, Save, RotateCcw, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import '../styles/MaterialInspector.css';
/**
* Asset file info interface (matches editor-app's AssetFileInfo)
*/
interface AssetFileInfo {
name: string;
path: string;
extension?: string;
size?: number;
modified?: number;
isDirectory: boolean;
}
/**
* Asset file target with content
*/
interface AssetFileTarget {
type: 'asset-file';
data: AssetFileInfo;
content?: string;
}
interface UniformValue {
type: UniformType;
value: number | number[] | string;
}
interface MaterialData {
name: string;
shader: number | string;
blendMode?: BlendMode;
uniforms?: Record<string, UniformValue>;
}
const BLEND_MODE_OPTIONS = [
{ value: BlendMode.None, label: 'None (Opaque)' },
{ value: BlendMode.Alpha, label: 'Alpha' },
{ value: BlendMode.Additive, label: 'Additive' },
{ value: BlendMode.Multiply, label: 'Multiply' },
{ value: BlendMode.Screen, label: 'Screen' },
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha' }
];
const 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' }
];
const UNIFORM_TYPE_OPTIONS = [
{ value: UniformType.Float, label: 'Float' },
{ value: UniformType.Vec2, label: 'Vec2' },
{ value: UniformType.Vec3, label: 'Vec3' },
{ value: UniformType.Vec4, label: 'Vec4' },
{ value: UniformType.Color, label: 'Color' },
{ value: UniformType.Int, label: 'Int' }
];
interface MaterialInspectorViewProps {
fileInfo: AssetFileInfo;
content: string;
onSave?: (path: string, content: string) => Promise<void>;
}
function MaterialInspectorView({ fileInfo, content, onSave }: MaterialInspectorViewProps) {
const [material, setMaterial] = useState<MaterialData | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uniformsExpanded, setUniformsExpanded] = useState(true);
const [newUniformName, setNewUniformName] = useState('');
useEffect(() => {
try {
const parsed = JSON.parse(content);
setMaterial(parsed);
setError(null);
setIsDirty(false);
} catch (e) {
setError('Failed to parse material file');
setMaterial(null);
}
}, [content]);
const handleSave = useCallback(async () => {
if (!material || !onSave) return;
try {
const jsonContent = JSON.stringify(material, null, 2);
await onSave(fileInfo.path, jsonContent);
setIsDirty(false);
} catch (e) {
console.error('Failed to save material:', e);
}
}, [material, fileInfo.path, onSave]);
const handleReset = useCallback(() => {
try {
const parsed = JSON.parse(content);
setMaterial(parsed);
setIsDirty(false);
} catch (e) {
// ignore
}
}, [content]);
const updateMaterial = useCallback((updates: Partial<MaterialData>) => {
setMaterial(prev => prev ? { ...prev, ...updates } : null);
setIsDirty(true);
}, []);
const updateUniform = useCallback((name: string, value: UniformValue) => {
setMaterial(prev => {
if (!prev) return null;
return {
...prev,
uniforms: {
...prev.uniforms,
[name]: value
}
};
});
setIsDirty(true);
}, []);
const removeUniform = useCallback((name: string) => {
setMaterial(prev => {
if (!prev || !prev.uniforms) return prev;
const newUniforms = { ...prev.uniforms };
delete newUniforms[name];
return { ...prev, uniforms: newUniforms };
});
setIsDirty(true);
}, []);
const addUniform = useCallback(() => {
if (!newUniformName.trim()) return;
const name = newUniformName.trim();
setMaterial(prev => {
if (!prev) return null;
return {
...prev,
uniforms: {
...prev.uniforms,
[name]: { type: UniformType.Float, value: 0 }
}
};
});
setNewUniformName('');
setIsDirty(true);
}, [newUniformName]);
const renderUniformEditor = (name: string, uniform: UniformValue) => {
const handleTypeChange = (newType: UniformType) => {
let defaultValue: number | number[] = 0;
switch (newType) {
case UniformType.Vec2:
defaultValue = [0, 0];
break;
case UniformType.Vec3:
defaultValue = [0, 0, 0];
break;
case UniformType.Vec4:
case UniformType.Color:
defaultValue = [1, 1, 1, 1];
break;
default:
defaultValue = 0;
}
updateUniform(name, { type: newType, value: defaultValue });
};
const renderValueEditor = () => {
switch (uniform.type) {
case UniformType.Float:
case UniformType.Int:
return (
<input
type="number"
className="material-input"
value={uniform.value as number}
step={uniform.type === UniformType.Int ? 1 : 0.01}
onChange={(e) => updateUniform(name, { ...uniform, value: parseFloat(e.target.value) || 0 })}
/>
);
case UniformType.Vec2:
case UniformType.Vec3:
case UniformType.Vec4: {
const values = uniform.value as number[];
const labels = ['X', 'Y', 'Z', 'W'];
return (
<div className="material-vec-editor">
{values.map((v, i) => (
<div key={i} className="material-vec-field">
<span className="material-vec-label">{labels[i]}</span>
<input
type="number"
className="material-input material-vec-input"
value={v}
step={0.01}
onChange={(e) => {
const newValues = [...values];
newValues[i] = parseFloat(e.target.value) || 0;
updateUniform(name, { ...uniform, value: newValues });
}}
/>
</div>
))}
</div>
);
}
case UniformType.Color: {
const colorValues = uniform.value as number[];
const r = Math.round((colorValues[0] || 0) * 255);
const g = Math.round((colorValues[1] || 0) * 255);
const b = Math.round((colorValues[2] || 0) * 255);
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return (
<div className="material-color-editor">
<input
type="color"
className="material-color-picker"
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;
updateUniform(name, { ...uniform, value: [newR, newG, newB, colorValues[3] || 1] });
}}
/>
<input
type="number"
className="material-input material-alpha-input"
value={colorValues[3] ?? 1}
min={0}
max={1}
step={0.01}
title="Alpha"
onChange={(e) => {
const newAlpha = parseFloat(e.target.value) || 0;
updateUniform(name, { ...uniform, value: [colorValues[0] ?? 1, colorValues[1] ?? 1, colorValues[2] ?? 1, newAlpha] });
}}
/>
</div>
);
}
default:
return <span className="material-value-text">{JSON.stringify(uniform.value)}</span>;
}
};
return (
<div key={name} className="material-uniform-item">
<div className="material-uniform-header">
<span className="material-uniform-name">{name}</span>
<select
className="material-select material-type-select"
value={uniform.type}
onChange={(e) => handleTypeChange(e.target.value as UniformType)}
>
{UNIFORM_TYPE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
className="material-icon-btn material-delete-btn"
onClick={() => removeUniform(name)}
title="Remove uniform"
>
<Trash2 size={14} />
</button>
</div>
<div className="material-uniform-value">
{renderValueEditor()}
</div>
</div>
);
};
if (error) {
return (
<div className="entity-inspector">
<div className="inspector-header">
<Palette size={16} style={{ color: '#a78bfa' }} />
<span className="entity-name">{fileInfo.name}</span>
</div>
<div className="inspector-content">
<div className="material-error">{error}</div>
</div>
</div>
);
}
if (!material) {
return (
<div className="entity-inspector">
<div className="inspector-header">
<Palette size={16} style={{ color: '#a78bfa' }} />
<span className="entity-name">{fileInfo.name}</span>
</div>
<div className="inspector-content">
<div className="material-loading">Loading...</div>
</div>
</div>
);
}
return (
<div className="entity-inspector material-inspector">
<div className="inspector-header">
<Palette size={16} style={{ color: '#a78bfa' }} />
<span className="entity-name">{material.name || fileInfo.name}</span>
{isDirty && <span className="material-dirty-indicator">*</span>}
</div>
<div className="material-toolbar">
<button
className="material-toolbar-btn"
onClick={handleSave}
disabled={!isDirty || !onSave}
title="Save (Ctrl+S)"
>
<Save size={14} />
<span>Save</span>
</button>
<button
className="material-toolbar-btn"
onClick={handleReset}
disabled={!isDirty}
title="Reset changes"
>
<RotateCcw size={14} />
<span>Reset</span>
</button>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title">Basic Properties</div>
<div className="property-field">
<label className="property-label">Name</label>
<input
type="text"
className="material-input"
value={material.name}
onChange={(e) => updateMaterial({ name: e.target.value })}
/>
</div>
<div className="property-field">
<label className="property-label">Shader</label>
<select
className="material-select"
value={material.shader}
onChange={(e) => updateMaterial({ shader: parseInt(e.target.value) })}
>
{SHADER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="property-field">
<label className="property-label">Blend Mode</label>
<select
className="material-select"
value={material.blendMode ?? BlendMode.Alpha}
onChange={(e) => updateMaterial({ blendMode: parseInt(e.target.value) as BlendMode })}
>
{BLEND_MODE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="inspector-section">
<div
className="section-title section-title-collapsible"
onClick={() => setUniformsExpanded(!uniformsExpanded)}
>
{uniformsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>Uniforms</span>
<span className="material-uniform-count">
({Object.keys(material.uniforms || {}).length})
</span>
</div>
{uniformsExpanded && (
<div className="material-uniforms-content">
{material.uniforms && Object.entries(material.uniforms).map(([name, uniform]) =>
renderUniformEditor(name, uniform)
)}
<div className="material-add-uniform">
<input
type="text"
className="material-input"
placeholder="Uniform name..."
value={newUniformName}
onChange={(e) => setNewUniformName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addUniform()}
/>
<button
className="material-icon-btn material-add-btn"
onClick={addUniform}
disabled={!newUniformName.trim()}
title="Add uniform"
>
<Plus size={14} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
/**
* Material Asset Inspector Provider
*/
export class MaterialAssetInspectorProvider implements IInspectorProvider<AssetFileTarget> {
readonly id = 'material-asset-inspector';
readonly name = 'Material Asset Inspector';
readonly priority = 100;
private saveHandler?: (path: string, content: string) => Promise<void>;
setSaveHandler(handler: (path: string, content: string) => Promise<void>): void {
this.saveHandler = handler;
}
canHandle(target: unknown): target is AssetFileTarget {
if (typeof target !== 'object' || target === null) return false;
const t = target as any;
return t.type === 'asset-file' &&
t.data?.extension?.toLowerCase() === 'mat' &&
typeof t.content === 'string';
}
render(target: AssetFileTarget, _context: InspectorContext): React.ReactElement {
return (
<MaterialInspectorView
fileInfo={target.data}
content={target.content!}
onSave={this.saveHandler}
/>
);
}
}

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { BlendMode, type MaterialDefinition } from '@esengine/material-system';
export interface MaterialEditorState {
currentFilePath: string | null;
pendingFilePath: string | null;
materialData: MaterialDefinition | null;
isDirty: boolean;
isLoading: boolean;
setPendingFilePath: (path: string | null) => void;
setCurrentFilePath: (path: string | null) => void;
setMaterialData: (data: MaterialDefinition | null) => void;
setDirty: (dirty: boolean) => void;
setLoading: (loading: boolean) => void;
updateMaterialProperty: <K extends keyof MaterialDefinition>(key: K, value: MaterialDefinition[K]) => void;
reset: () => void;
}
const initialState = {
currentFilePath: null,
pendingFilePath: null,
materialData: null,
isDirty: false,
isLoading: false,
};
export const useMaterialEditorStore = create<MaterialEditorState>((set) => ({
...initialState,
setPendingFilePath: (path) => set({ pendingFilePath: path }),
setCurrentFilePath: (path) => set({ currentFilePath: path }),
setMaterialData: (data) => set({ materialData: data, isDirty: false }),
setDirty: (dirty) => set({ isDirty: dirty }),
setLoading: (loading) => set({ isLoading: loading }),
updateMaterialProperty: (key, value) => set((state) => {
if (!state.materialData) return state;
return {
materialData: { ...state.materialData, [key]: value },
isDirty: true,
};
}),
reset: () => set(initialState),
}));
export function createDefaultMaterialData(name: string = 'New Material'): MaterialDefinition {
return {
name,
shader: 0,
blendMode: BlendMode.Alpha,
uniforms: {},
};
}

View File

@@ -0,0 +1,193 @@
/**
* Material Editor Panel Styles
* 材质编辑器面板样式
*/
.material-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary, #1e1e1e);
color: var(--text-primary, #e0e0e0);
font-size: 13px;
}
.material-editor-panel.loading,
.material-editor-panel.empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary, #888);
}
.material-editor-panel.loading .spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Toolbar */
.material-editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #333);
background: var(--bg-secondary, #252526);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 4px;
}
.material-name {
font-weight: 500;
}
.dirty-indicator {
color: var(--accent-color, #0078d4);
font-weight: bold;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: var(--button-bg, #333);
color: var(--text-primary, #e0e0e0);
cursor: pointer;
font-size: 12px;
}
.toolbar-button:hover:not(:disabled) {
background: var(--button-hover-bg, #444);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Content */
.material-editor-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Property Section */
.property-section {
margin-bottom: 16px;
}
.section-header {
font-weight: 500;
padding: 6px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color, #333);
color: var(--text-primary, #e0e0e0);
}
.property-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.property-row label {
flex: 0 0 100px;
color: var(--text-secondary, #888);
font-size: 12px;
}
.property-row input,
.property-row select {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-color, #333);
border-radius: 4px;
background: var(--input-bg, #333);
color: var(--text-primary, #e0e0e0);
font-size: 12px;
}
.property-row input:focus,
.property-row select:focus {
outline: none;
border-color: var(--accent-color, #0078d4);
}
.property-row.file-path span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary, #888);
font-size: 11px;
}
.uniform-type {
color: var(--text-tertiary, #666);
font-size: 11px;
font-style: italic;
}
.empty-uniforms {
color: var(--text-secondary, #888);
font-size: 12px;
font-style: italic;
padding: 8px 0;
}
/* Shader Selector */
.shader-selector {
flex: 1;
display: flex;
gap: 4px;
}
.shader-selector select {
flex: 1;
}
/* File Input Row */
.file-input-row {
flex: 1;
display: flex;
gap: 4px;
}
.file-input-row input {
flex: 1;
}
.browse-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border: 1px solid var(--border-color, #333);
border-radius: 4px;
background: var(--button-bg, #333);
color: var(--text-primary, #e0e0e0);
cursor: pointer;
}
.browse-button:hover {
background: var(--button-hover-bg, #444);
}

View File

@@ -0,0 +1,263 @@
/* MaterialInspector Styles */
.material-inspector {
display: flex;
flex-direction: column;
height: 100%;
}
.material-dirty-indicator {
color: #f59e0b;
font-weight: bold;
margin-left: 4px;
}
.material-toolbar {
display: flex;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #333);
background: var(--panel-bg, #1e1e1e);
}
.material-toolbar-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--button-bg, #2d2d2d);
color: var(--text-color, #ccc);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.material-toolbar-btn:hover:not(:disabled) {
background: var(--button-hover-bg, #3d3d3d);
border-color: var(--accent-color, #0078d4);
}
.material-toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.material-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--input-bg, #252525);
color: var(--text-color, #ccc);
font-size: 12px;
outline: none;
transition: border-color 0.15s ease;
}
.material-input:focus {
border-color: var(--accent-color, #0078d4);
}
.material-input::-webkit-inner-spin-button,
.material-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.material-select {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--input-bg, #252525);
color: var(--text-color, #ccc);
font-size: 12px;
outline: none;
cursor: pointer;
}
.material-select:focus {
border-color: var(--accent-color, #0078d4);
}
/* Uniforms section */
.section-title-collapsible {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
}
.section-title-collapsible:hover {
color: var(--accent-color, #0078d4);
}
.material-uniform-count {
font-size: 11px;
color: #888;
margin-left: 4px;
}
.material-uniforms-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.material-uniform-item {
padding: 8px;
border: 1px solid var(--border-color, #333);
border-radius: 4px;
background: var(--item-bg, #1a1a1a);
}
.material-uniform-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.material-uniform-name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-color, #ccc);
}
.material-type-select {
width: auto;
min-width: 80px;
}
.material-uniform-value {
margin-top: 4px;
}
/* Vector editor */
.material-vec-editor {
display: flex;
gap: 8px;
}
.material-vec-field {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.material-vec-label {
font-size: 10px;
color: #888;
width: 12px;
text-align: center;
}
.material-vec-input {
flex: 1;
min-width: 0;
}
/* Color editor */
.material-color-editor {
display: flex;
align-items: center;
gap: 8px;
}
.material-color-picker {
width: 40px;
height: 28px;
padding: 0;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: none;
cursor: pointer;
}
.material-color-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.material-color-picker::-webkit-color-swatch {
border-radius: 2px;
border: none;
}
.material-alpha-input {
width: 60px;
}
/* Icon buttons */
.material-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-color, #888);
cursor: pointer;
transition: all 0.15s ease;
}
.material-icon-btn:hover:not(:disabled) {
background: var(--button-hover-bg, #333);
color: var(--text-color, #ccc);
}
.material-icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.material-delete-btn:hover:not(:disabled) {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.material-add-btn:hover:not(:disabled) {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
/* Add uniform row */
.material-add-uniform {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-color, #333);
}
.material-add-uniform .material-input {
flex: 1;
}
/* Error and loading states */
.material-error {
padding: 20px;
text-align: center;
color: #ef4444;
font-size: 13px;
}
.material-loading {
padding: 20px;
text-align: center;
color: #888;
font-size: 13px;
}
.material-value-text {
font-family: Consolas, Monaco, monospace;
font-size: 11px;
color: #888;
}