refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
15
packages/editor/plugins/material-editor/module.json
Normal file
15
packages/editor/plugins/material-editor/module.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "material-editor",
|
||||
"name": "@esengine/material-editor",
|
||||
"displayName": "Material Editor",
|
||||
"description": "Editor support for material system | 材质系统编辑器支持",
|
||||
"version": "1.0.0",
|
||||
"category": "Editor",
|
||||
"icon": "Palette",
|
||||
"isEditorPlugin": true,
|
||||
"runtimeModule": "@esengine/material-system",
|
||||
"exports": {
|
||||
"inspectors": ["MaterialInspector"],
|
||||
"panels": ["MaterialEditorPanel"]
|
||||
}
|
||||
}
|
||||
52
packages/editor/plugins/material-editor/package.json
Normal file
52
packages/editor/plugins/material-editor/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@esengine/material-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor components for material system",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/material-system": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/editor-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"zustand": "^5.0.8",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"material",
|
||||
"shader",
|
||||
"editor"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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 { useMaterialLocale } from '../hooks/useMaterialLocale';
|
||||
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>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 混合模式选项
|
||||
* Blend mode options with translation keys
|
||||
*/
|
||||
const BLEND_MODE_OPTIONS = [
|
||||
{ value: BlendMode.None, labelKey: 'blendModes.none' },
|
||||
{ value: BlendMode.Alpha, labelKey: 'blendModes.alpha' },
|
||||
{ value: BlendMode.Additive, labelKey: 'blendModes.additive' },
|
||||
{ value: BlendMode.Multiply, labelKey: 'blendModes.multiply' },
|
||||
{ value: BlendMode.Screen, labelKey: 'blendModes.screen' },
|
||||
{ value: BlendMode.PremultipliedAlpha, labelKey: 'blendModes.premultipliedAlpha' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 内置着色器选项
|
||||
* Built-in shader options with translation keys
|
||||
*/
|
||||
const BUILT_IN_SHADER_OPTIONS = [
|
||||
{ value: BuiltInShaders.DefaultSprite, labelKey: 'shaders.defaultSprite' },
|
||||
{ value: BuiltInShaders.Grayscale, labelKey: 'shaders.grayscale' },
|
||||
{ value: BuiltInShaders.Tint, labelKey: 'shaders.tint' },
|
||||
{ value: BuiltInShaders.Flash, labelKey: 'shaders.flash' },
|
||||
{ value: BuiltInShaders.Outline, labelKey: 'shaders.outline' },
|
||||
];
|
||||
|
||||
/** Custom shader indicator value. | 自定义着色器指示值。 */
|
||||
const CUSTOM_SHADER_VALUE = -1;
|
||||
|
||||
interface MaterialEditorPanelProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function MaterialEditorPanel({ locale: _locale }: MaterialEditorPanelProps) {
|
||||
const { t } = useMaterialLocale();
|
||||
const {
|
||||
currentFilePath,
|
||||
pendingFilePath,
|
||||
materialData,
|
||||
isDirty,
|
||||
isLoading,
|
||||
setPendingFilePath,
|
||||
setCurrentFilePath,
|
||||
setMaterialData,
|
||||
setLoading,
|
||||
updateMaterialProperty,
|
||||
} = useMaterialEditorStore();
|
||||
|
||||
// 加载材质文件
|
||||
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>{t('panel.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
if (!materialData) {
|
||||
return (
|
||||
<div className="material-editor-panel empty">
|
||||
<span>{t('panel.emptyState')}</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={t('panel.saveTooltip')}
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>{t('panel.save')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性编辑区 */}
|
||||
<div className="material-editor-content">
|
||||
{/* 基本属性 */}
|
||||
<div className="property-section">
|
||||
<div className="section-header">{t('properties.basicTitle')}</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>{t('properties.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={materialData.name}
|
||||
onChange={(e) => updateMaterialProperty('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>{t('properties.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}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_SHADER_VALUE}>
|
||||
{t('properties.customShader')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom shader path input */}
|
||||
{typeof materialData.shader === 'string' && (
|
||||
<div className="property-row">
|
||||
<label>{t('properties.shaderPath')}</label>
|
||||
<div className="file-input-row">
|
||||
<input
|
||||
type="text"
|
||||
value={materialData.shader}
|
||||
onChange={(e) => updateMaterialProperty('shader', e.target.value)}
|
||||
placeholder={t('properties.shaderPathPlaceholder')}
|
||||
/>
|
||||
<button
|
||||
className="browse-button"
|
||||
onClick={async () => {
|
||||
// TODO: Implement file browser dialog
|
||||
// 这里可以集成编辑器的文件选择对话框
|
||||
}}
|
||||
title={t('properties.browse')}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="property-row">
|
||||
<label>{t('properties.blendMode')}</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}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uniform 参数 */}
|
||||
<div className="property-section">
|
||||
<div className="section-header">{t('uniforms.title')}</div>
|
||||
|
||||
{Object.keys(materialData.uniforms || {}).length === 0 ? (
|
||||
<div className="empty-uniforms">
|
||||
{t('uniforms.empty')}
|
||||
</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">{t('fileInfo.title')}</div>
|
||||
<div className="property-row file-path">
|
||||
<label>{t('fileInfo.path')}</label>
|
||||
<span title={currentFilePath || ''}>
|
||||
{currentFilePath?.split(/[\\/]/).pop() || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaterialEditorPanel;
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Material Editor Hooks
|
||||
* 材质编辑器钩子导出
|
||||
*/
|
||||
export { useMaterialLocale, translateMaterial } from './useMaterialLocale';
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Material Editor Locale Hook
|
||||
* 材质编辑器语言钩子
|
||||
*
|
||||
* 提供材质编辑器专用的翻译功能
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { LocaleService } from '@esengine/editor-core';
|
||||
import { en, zh, es } from '../locales';
|
||||
|
||||
type Locale = 'en' | 'zh' | 'es';
|
||||
type TranslationParams = Record<string, string | number>;
|
||||
|
||||
const translations = { en, zh, es } as const;
|
||||
|
||||
/**
|
||||
* 获取嵌套对象的值
|
||||
* Get nested object value by dot notation key
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const keys = key.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const k of keys) {
|
||||
if (current && typeof current === 'object' && k in current) {
|
||||
current = (current as Record<string, unknown>)[k];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换参数占位符
|
||||
* Replace parameter placeholders in string
|
||||
*/
|
||||
function interpolate(text: string, params?: TranslationParams): string {
|
||||
if (!params) return text;
|
||||
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const value = params[key];
|
||||
return value !== undefined ? String(value) : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从 LocaleService 获取当前语言
|
||||
* Try to get current locale from LocaleService
|
||||
*/
|
||||
function tryGetLocaleFromService(): Locale | null {
|
||||
try {
|
||||
// 尝试动态获取 LocaleService
|
||||
const localeService = Core.services.tryResolve(LocaleService);
|
||||
|
||||
if (localeService?.getCurrentLocale) {
|
||||
return localeService.getCurrentLocale() as Locale;
|
||||
}
|
||||
} catch {
|
||||
// LocaleService 不可用
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅语言变化
|
||||
* Subscribe to locale changes
|
||||
*/
|
||||
function subscribeToLocaleChanges(callback: (locale: Locale) => void): (() => void) | undefined {
|
||||
try {
|
||||
const localeService = Core.services.tryResolve(LocaleService);
|
||||
|
||||
if (localeService?.onChange) {
|
||||
return localeService.onChange((newLocale) => {
|
||||
callback(newLocale as Locale);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// LocaleService 不可用
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing material editor translations
|
||||
* 访问材质编辑器翻译的 Hook
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { t, locale } = useMaterialLocale();
|
||||
* return <button title={t('panel.saveTooltip')}>{t('panel.save')}</button>;
|
||||
* ```
|
||||
*/
|
||||
export function useMaterialLocale() {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
return tryGetLocaleFromService() || 'en';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化时获取当前语言
|
||||
const currentLocale = tryGetLocaleFromService();
|
||||
if (currentLocale) {
|
||||
setLocale(currentLocale);
|
||||
}
|
||||
|
||||
// 订阅语言变化
|
||||
const unsubscribe = subscribeToLocaleChanges((newLocale) => {
|
||||
setLocale(newLocale);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
* Translation function
|
||||
*
|
||||
* @param key - 翻译键,如 'panel.save'
|
||||
* @param params - 插值参数
|
||||
* @param fallback - 回退文本
|
||||
*/
|
||||
const t = useCallback((key: string, params?: TranslationParams, fallback?: string): string => {
|
||||
const currentTranslations = translations[locale] || translations.en;
|
||||
const value = getNestedValue(currentTranslations as Record<string, unknown>, key);
|
||||
|
||||
if (value) {
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
// 如果当前语言没有,尝试英文
|
||||
if (locale !== 'en') {
|
||||
const enValue = getNestedValue(translations.en as Record<string, unknown>, key);
|
||||
if (enValue) {
|
||||
return interpolate(enValue, params);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 fallback 或 key 本身
|
||||
return fallback || key;
|
||||
}, [locale]);
|
||||
|
||||
return { t, locale, setLocale };
|
||||
}
|
||||
|
||||
/**
|
||||
* 非 React 环境下的翻译函数
|
||||
* Translation function for non-React context
|
||||
*/
|
||||
export function translateMaterial(key: string, locale: Locale = 'en', params?: TranslationParams): string {
|
||||
const currentTranslations = translations[locale] || translations.en;
|
||||
const value = getNestedValue(currentTranslations as Record<string, unknown>, key);
|
||||
|
||||
if (value) {
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const enValue = getNestedValue(translations.en as Record<string, unknown>, key);
|
||||
if (enValue) {
|
||||
return interpolate(enValue, params);
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
292
packages/editor/plugins/material-editor/src/index.ts
Normal file
292
packages/editor/plugins/material-editor/src/index.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @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,
|
||||
IEditorPlugin,
|
||||
ModuleManifest
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
MessageHub,
|
||||
FileActionRegistry,
|
||||
InspectorRegistry,
|
||||
IInspectorRegistry,
|
||||
IFileSystemService,
|
||||
LocaleService
|
||||
} from '@esengine/editor-core';
|
||||
|
||||
// Import locale translations
|
||||
import { en, zh, es } from './locales';
|
||||
|
||||
// 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> {
|
||||
// 注意:文件创建模板由 PluginManager.activatePluginEditor() 自动注册
|
||||
// 不要在这里手动注册,否则会重复
|
||||
// NOTE: File creation templates are auto-registered by PluginManager.activatePluginEditor()
|
||||
// Do not manually register here to avoid duplicates
|
||||
|
||||
// Register asset creation mapping for .mat files
|
||||
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||
if (fileActionRegistry) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Register translations
|
||||
this.registerTranslations(services);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件翻译到 LocaleService
|
||||
* Register plugin translations to LocaleService
|
||||
*/
|
||||
private registerTranslations(services: ServiceContainer): void {
|
||||
try {
|
||||
const localeService = services.tryResolve(LocaleService);
|
||||
if (localeService) {
|
||||
localeService.extendTranslations('material', { en, zh, es });
|
||||
console.info('[MaterialEditorModule] Translations registered');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaterialEditorModule] Failed to register translations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
export { useMaterialLocale, translateMaterial } from './hooks/useMaterialLocale';
|
||||
|
||||
/**
|
||||
* 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: IEditorPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new MaterialRuntimeModule(),
|
||||
editorModule: materialEditorModule
|
||||
};
|
||||
|
||||
export default materialEditorModule;
|
||||
71
packages/editor/plugins/material-editor/src/locales/en.ts
Normal file
71
packages/editor/plugins/material-editor/src/locales/en.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Material Editor English Translations
|
||||
* 材质编辑器英文翻译
|
||||
*/
|
||||
export const en = {
|
||||
// Editor Panel
|
||||
panel: {
|
||||
loading: 'Loading...',
|
||||
emptyState: 'Double-click a .mat file to open the material editor',
|
||||
save: 'Save',
|
||||
saveTooltip: 'Save (Ctrl+S)',
|
||||
},
|
||||
|
||||
// Properties Section
|
||||
properties: {
|
||||
basicTitle: 'Basic Properties',
|
||||
name: 'Name',
|
||||
shader: 'Shader',
|
||||
shaderPath: 'Shader Path',
|
||||
shaderPathPlaceholder: 'Enter .shader file path',
|
||||
browse: 'Browse...',
|
||||
blendMode: 'Blend Mode',
|
||||
customShader: 'Custom Shader...',
|
||||
},
|
||||
|
||||
// Uniforms Section
|
||||
uniforms: {
|
||||
title: 'Uniform Parameters',
|
||||
empty: 'This shader has no custom parameters',
|
||||
namePlaceholder: 'Uniform name...',
|
||||
addTooltip: 'Add uniform',
|
||||
removeTooltip: 'Remove uniform',
|
||||
},
|
||||
|
||||
// File Info Section
|
||||
fileInfo: {
|
||||
title: 'File Info',
|
||||
path: 'Path',
|
||||
},
|
||||
|
||||
// Blend Mode Options
|
||||
blendModes: {
|
||||
none: 'None (Opaque)',
|
||||
alpha: 'Alpha Blend',
|
||||
additive: 'Additive',
|
||||
multiply: 'Multiply',
|
||||
screen: 'Screen',
|
||||
premultipliedAlpha: 'Premultiplied Alpha',
|
||||
},
|
||||
|
||||
// Built-in Shader Options
|
||||
shaders: {
|
||||
defaultSprite: 'Default Sprite',
|
||||
grayscale: 'Grayscale',
|
||||
tint: 'Tint',
|
||||
flash: 'Flash',
|
||||
outline: 'Outline',
|
||||
},
|
||||
|
||||
// Inspector Panel
|
||||
inspector: {
|
||||
saveTooltip: 'Save (Ctrl+S)',
|
||||
save: 'Save',
|
||||
reset: 'Reset',
|
||||
resetTooltip: 'Reset changes',
|
||||
basicProperties: 'Basic Properties',
|
||||
uniforms: 'Uniforms',
|
||||
loading: 'Loading...',
|
||||
parseError: 'Failed to parse material file',
|
||||
},
|
||||
};
|
||||
71
packages/editor/plugins/material-editor/src/locales/es.ts
Normal file
71
packages/editor/plugins/material-editor/src/locales/es.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Material Editor Spanish Translations
|
||||
* 材质编辑器西班牙语翻译
|
||||
*/
|
||||
export const es = {
|
||||
// Editor Panel
|
||||
panel: {
|
||||
loading: 'Cargando...',
|
||||
emptyState: 'Haga doble clic en un archivo .mat para abrir el editor de materiales',
|
||||
save: 'Guardar',
|
||||
saveTooltip: 'Guardar (Ctrl+S)',
|
||||
},
|
||||
|
||||
// Properties Section
|
||||
properties: {
|
||||
basicTitle: 'Propiedades Básicas',
|
||||
name: 'Nombre',
|
||||
shader: 'Shader',
|
||||
shaderPath: 'Ruta del Shader',
|
||||
shaderPathPlaceholder: 'Ingrese la ruta del archivo .shader',
|
||||
browse: 'Explorar...',
|
||||
blendMode: 'Modo de Mezcla',
|
||||
customShader: 'Shader Personalizado...',
|
||||
},
|
||||
|
||||
// Uniforms Section
|
||||
uniforms: {
|
||||
title: 'Parámetros Uniform',
|
||||
empty: 'Este shader no tiene parámetros personalizados',
|
||||
namePlaceholder: 'Nombre del uniform...',
|
||||
addTooltip: 'Agregar uniform',
|
||||
removeTooltip: 'Eliminar uniform',
|
||||
},
|
||||
|
||||
// File Info Section
|
||||
fileInfo: {
|
||||
title: 'Información del Archivo',
|
||||
path: 'Ruta',
|
||||
},
|
||||
|
||||
// Blend Mode Options
|
||||
blendModes: {
|
||||
none: 'Ninguno (Opaco)',
|
||||
alpha: 'Mezcla Alpha',
|
||||
additive: 'Aditivo',
|
||||
multiply: 'Multiplicar',
|
||||
screen: 'Pantalla',
|
||||
premultipliedAlpha: 'Alpha Premultiplicado',
|
||||
},
|
||||
|
||||
// Built-in Shader Options
|
||||
shaders: {
|
||||
defaultSprite: 'Sprite por Defecto',
|
||||
grayscale: 'Escala de Grises',
|
||||
tint: 'Tinte',
|
||||
flash: 'Destello',
|
||||
outline: 'Contorno',
|
||||
},
|
||||
|
||||
// Inspector Panel
|
||||
inspector: {
|
||||
saveTooltip: 'Guardar (Ctrl+S)',
|
||||
save: 'Guardar',
|
||||
reset: 'Restablecer',
|
||||
resetTooltip: 'Restablecer cambios',
|
||||
basicProperties: 'Propiedades Básicas',
|
||||
uniforms: 'Uniforms',
|
||||
loading: 'Cargando...',
|
||||
parseError: 'Error al analizar el archivo de material',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Material Editor Locale Exports
|
||||
* 材质编辑器语言导出
|
||||
*/
|
||||
export { en } from './en';
|
||||
export { zh } from './zh';
|
||||
export { es } from './es';
|
||||
71
packages/editor/plugins/material-editor/src/locales/zh.ts
Normal file
71
packages/editor/plugins/material-editor/src/locales/zh.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Material Editor Chinese Translations
|
||||
* 材质编辑器中文翻译
|
||||
*/
|
||||
export const zh = {
|
||||
// Editor Panel
|
||||
panel: {
|
||||
loading: '加载中...',
|
||||
emptyState: '双击 .mat 文件打开材质编辑器',
|
||||
save: '保存',
|
||||
saveTooltip: '保存 (Ctrl+S)',
|
||||
},
|
||||
|
||||
// Properties Section
|
||||
properties: {
|
||||
basicTitle: '基本属性',
|
||||
name: '名称',
|
||||
shader: '着色器',
|
||||
shaderPath: '着色器路径',
|
||||
shaderPathPlaceholder: '输入 .shader 文件路径',
|
||||
browse: '浏览...',
|
||||
blendMode: '混合模式',
|
||||
customShader: '自定义着色器...',
|
||||
},
|
||||
|
||||
// Uniforms Section
|
||||
uniforms: {
|
||||
title: 'Uniform 参数',
|
||||
empty: '该着色器没有自定义参数',
|
||||
namePlaceholder: 'Uniform 名称...',
|
||||
addTooltip: '添加 uniform',
|
||||
removeTooltip: '删除 uniform',
|
||||
},
|
||||
|
||||
// File Info Section
|
||||
fileInfo: {
|
||||
title: '文件信息',
|
||||
path: '路径',
|
||||
},
|
||||
|
||||
// Blend Mode Options
|
||||
blendModes: {
|
||||
none: '无 (不透明)',
|
||||
alpha: 'Alpha 混合',
|
||||
additive: '叠加',
|
||||
multiply: '正片叠底',
|
||||
screen: '滤色',
|
||||
premultipliedAlpha: '预乘 Alpha',
|
||||
},
|
||||
|
||||
// Built-in Shader Options
|
||||
shaders: {
|
||||
defaultSprite: '默认精灵',
|
||||
grayscale: '灰度',
|
||||
tint: '着色',
|
||||
flash: '闪烁',
|
||||
outline: '描边',
|
||||
},
|
||||
|
||||
// Inspector Panel
|
||||
inspector: {
|
||||
saveTooltip: '保存 (Ctrl+S)',
|
||||
save: '保存',
|
||||
reset: '重置',
|
||||
resetTooltip: '重置更改',
|
||||
basicProperties: '基本属性',
|
||||
uniforms: 'Uniforms',
|
||||
loading: '加载中...',
|
||||
parseError: '材质文件解析失败',
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
32
packages/editor/plugins/material-editor/tsconfig.json
Normal file
32
packages/editor/plugins/material-editor/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../framework/core"
|
||||
},
|
||||
{
|
||||
"path": "../../../engine/engine-core"
|
||||
},
|
||||
{
|
||||
"path": "../../../engine/material-system"
|
||||
},
|
||||
{
|
||||
"path": "../../../editor/editor-core"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/editor/plugins/material-editor/tsup.config.ts
Normal file
7
packages/editor/plugins/material-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../../../tools/build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
Reference in New Issue
Block a user