refactor(ui): UI 系统架构重构 (#309)

* feat(ui): 动态图集系统与渲染调试增强

## 核心功能

### 动态图集系统 (Dynamic Atlas)
- 新增 DynamicAtlasManager:运行时纹理打包,支持 MaxRects 算法
- 新增 DynamicAtlasService:自动纹理加载与图集管理
- 新增 BinPacker:高效矩形打包算法
- 支持动态/固定两种扩展策略
- 自动 UV 重映射,实现 UI 元素合批渲染

### Frame Debugger 增强
- 新增合批分析面板,显示批次中断原因
- 新增 UI 元素层级信息(depth, worldOrderInLayer)
- 新增实体高亮功能,点击可在场景中定位
- 新增动态图集可视化面板
- 改进渲染原语详情展示

### 闪光效果 (Shiny Effect)
- 新增 UIShinyEffectComponent:UI 闪光参数配置
- 新增 UIShinyEffectSystem:材质覆盖驱动的闪光动画
- 新增 ShinyEffectComponent/System(Sprite 版本)

## 引擎层改进

### Rust 纹理管理扩展
- create_blank_texture:创建空白 GPU 纹理
- update_texture_region:局部纹理更新
- 支持动态图集的 GPU 端操作

### 材质系统
- 新增 effects/ 目录:ShinyEffect 等效果实现
- 新增 interfaces/ 目录:IMaterial 等接口定义
- 新增 mixins/ 目录:可组合的材质功能

### EngineBridge 扩展
- 新增 createBlankTexture/updateTextureRegion 方法
- 改进纹理加载回调机制

## UI 渲染改进
- UIRenderCollector:支持合批调试信息
- 稳定排序:addIndex 保证渲染顺序一致性
- 九宫格渲染优化
- 材质覆盖支持

## 其他改进
- 国际化:新增 Frame Debugger 相关翻译
- 编辑器:新增渲染调试入口
- 文档:新增架构设计文档目录

* refactor(ui): 引入新基础组件架构与渲染工具函数

Phase 1 重构 - 组件职责分离与代码复用:

新增基础组件层:
- UIGraphicComponent: 所有可视 UI 元素的基类(颜色、透明度、raycast)
- UIImageComponent: 纹理显示组件(支持简单、切片、平铺、填充模式)
- UISelectableComponent: 可交互元素的基类(状态管理、颜色过渡)

新增渲染工具:
- UIRenderUtils: 提取共享的坐标计算、边框渲染、阴影渲染等工具函数
- getUIRenderTransform: 统一的变换数据提取
- renderBorder/renderShadow: 复用的边框和阴影渲染逻辑

新增渲染系统:
- UIGraphicRenderSystem: 处理新基础组件的统一渲染器

重构现有系统:
- UIRectRenderSystem: 使用新工具函数,移除重复代码
- UIButtonRenderSystem: 使用新工具函数,移除重复代码

这些改动为后续统一渲染系统奠定基础。

* refactor(ui): UIProgressBarRenderSystem 使用渲染工具函数

- 使用 getUIRenderTransform 替代手动变换计算
- 使用 renderBorder 工具函数替代重复的边框渲染
- 使用 lerpColor 工具函数替代重复的颜色插值
- 简化方法签名,使用 UIRenderTransform 类型
- 移除约 135 行重复代码

* refactor(ui): Slider 和 ScrollView 渲染系统使用工具函数

- UISliderRenderSystem: 使用 getUIRenderTransform,简化方法签名
- UIScrollViewRenderSystem: 使用 getUIRenderTransform,简化方法签名
- 统一使用 UIRenderTransform 类型减少参数传递
- 消除重复的变换计算代码

* refactor(ui): 使用 UIWidgetMarker 消除硬编码组件依赖

- 新增 UIWidgetMarker 标记组件
- UIRectRenderSystem 改为检查标记而非硬编码4种组件类型
- 各 Widget 渲染系统自动添加标记组件
- 减少模块间耦合,提高可扩展性

* feat(ui): 实现 Canvas 隔离机制

- 新增 UICanvasComponent 定义 Canvas 渲染组
- UITransformComponent 添加 Canvas 相关字段:canvasEntityId, worldSortingLayer, pixelPerfect
- UILayoutSystem 传播 Canvas 设置给子元素
- UIRenderUtils 使用 Canvas 继承的排序层
- 支持嵌套 Canvas 和不同渲染模式

* refactor(ui): 统一纹理管理工具函数

Phase 4: 纹理管理统一

新增:
- UITextureUtils.ts: 统一的纹理描述符接口和验证函数
  - UITextureDescriptor: 支持 GUID/textureId/path 多种纹理源
  - isValidTextureGuid: GUID 验证
  - getTextureKey: 获取用于合批的纹理键
  - normalizeTextureDescriptor: 规范化各种输入格式
- utils/index.ts: 工具函数导出

修改:
- UIGraphicRenderSystem: 使用新的纹理工具函数
- index.ts: 导出纹理工具类型和函数

* refactor(ui): 实现统一的脏标记机制

Phase 5: Dirty 标记机制

新增:
- UIDirtyFlags.ts: 位标记枚举和追踪工具
  - UIDirtyFlags: Visual/Layout/Transform/Material/Text 标记
  - IDirtyTrackable: 脏追踪接口
  - DirtyTracker: 辅助工具类
  - 帧级别脏状态追踪 (markFrameDirty, isFrameDirty)

修改:
- UIGraphicComponent: 实现 IDirtyTrackable
  - 属性 setter 自动设置脏标记
  - 保留 setDirty/clearDirty 向后兼容
- UIImageComponent: 所有属性支持脏追踪
  - textureGuid/imageType/fillAmount 等变化自动标记
- UIGraphicRenderSystem: 使用 clearDirtyFlags()

导出:
- UIDirtyFlags, IDirtyTrackable, DirtyTracker
- markFrameDirty, isFrameDirty, clearFrameDirty

* refactor(ui): 移除过时的 dirty flag API

移除 UIGraphicComponent 中的兼容性 API:
- 移除 _isDirty getter/setter
- 移除 setDirty() 方法
- 移除 clearDirty() 方法

现在统一使用新的 dirty flag 系统:
- isDirty() / hasDirtyFlag(flags)
- markDirty(flags) / clearDirtyFlags()

* fix(ui): 修复两个 TODO 功能

1. 滑块手柄命中测试 (UIInputSystem)
   - UISliderComponent 添加 getHandleBounds() 计算手柄边界
   - UISliderComponent 添加 isPointInHandle() 精确命中测试
   - UIInputSystem.handleSlider() 使用精确测试更新悬停状态

2. 径向填充渲染 (UIGraphicRenderSystem)
   - 实现 renderRadialFill() 方法
   - 支持 radial90/radial180/radial360 三种模式
   - 支持 fillOrigin (top/right/bottom/left) 和 fillClockwise
   - 使用多段矩形近似饼形填充效果

* feat(ui): 完善 UI 系统架构和九宫格渲染

* fix(ui): 修复文本渲染层级问题并清理调试代码

- 修复纹理就绪后调用 invalidateUIRenderCaches() 导致的无限循环
- 移除 UITextRenderSystem、UIButtonRenderSystem、UIRectRenderSystem 中的首帧调试输出
- 移除 UILayoutSystem 中的布局调试日志
- 清理所有 __UI_RENDER_DEBUG__ 条件日志

* refactor(ui): 优化渲染批处理和输入框组件

渲染系统:
- 修复 RenderBatcher 保持渲染顺序
- 优化 Rust SpriteBatch 避免合并非连续精灵
- 增强 EngineRenderSystem 纹理就绪检测

输入框组件:
- 增强 UIInputFieldComponent 功能
- 改进 UIInputSystem 输入处理
- 新增 TextMeasureService 文本测量服务

* fix(ui): 修复九宫格首帧渲染和InputField输入问题

- 修复九宫格首帧 size=0x0 问题:
  - Viewport.tsx: 预览模式读取图片尺寸存储到 importSettings
  - AssetDatabase: ISpriteSettings 添加 width/height 字段
  - AssetMetadataService: getTextureSpriteInfo 使用元数据尺寸作为后备
  - UIRectRenderSystem: 当 atlasEntry 不存在时使用 spriteInfo 尺寸
  - WebBuildPipeline: 构建时包含 importSettings
  - AssetManager: 从 catalog 初始化时复制 importSettings
  - AssetTypes: IAssetCatalogEntry 添加 importSettings 字段

- 修复 InputField 无法输入问题:
  - UIRuntimeModule: manifest 添加 pluginExport: 'UIPlugin'
  - 确保预览模式正确加载 UI 插件并绑定 UIInputSystem

- 添加调试日志用于排查纹理加载问题

* fix(sprite): 修复类型导出错误

MaterialPropertyOverride 和 MaterialOverrides 应从 @esengine/material-system 导出

* fix(ui-editor): 补充 AnchorPreset 拉伸预设的映射

添加 StretchTop, StretchMiddle, StretchBottom, StretchLeft, StretchCenter, StretchRight 的位置和锚点值映射
This commit is contained in:
YHH
2025-12-19 15:33:36 +08:00
committed by GitHub
parent 958933cd76
commit 536c4c5593
145 changed files with 18187 additions and 1543 deletions

View File

@@ -6,6 +6,7 @@ import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetField } from './inspectors/fields/AssetField';
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
import { EntityRefField } from './inspectors/fields/EntityRefField';
import { useLocale } from '../hooks/useLocale';
import '../styles/PropertyInspector.css';
@@ -339,6 +340,17 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
/>
);
case 'entityRef':
return (
<EntityRefField
key={propertyName}
label={label}
value={value ?? 0}
readonly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
case 'array': {
const arrayMeta = metadata as {
itemType?: { type: string; extensions?: string[]; assetType?: string };

View File

@@ -162,28 +162,25 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const initialValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.width);
} else if (key === 'project.uiDesignResolution.height') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.height);
} else if (key === 'project.uiDesignResolution.preset') {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else if (key === 'project.disabledModules') {
// Load disabled modules from ProjectService
initialValues.set(key, projectService.getDisabledModules());
} else {
initialValues.set(key, descriptor.defaultValue);
}
// 特定的 project 设置需要从 ProjectService 加载
// Specific project settings need to load from ProjectService
if (key === 'project.uiDesignResolution.width' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.width);
} else if (key === 'project.uiDesignResolution.height' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.height);
} else if (key === 'project.uiDesignResolution.preset' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else if (key === 'project.disabledModules' && projectService) {
// Load disabled modules from ProjectService
initialValues.set(key, projectService.getDisabledModules());
} else {
// 其他设置(包括 project.dynamicAtlas.*)从 SettingsService 加载
// Other settings (including project.dynamicAtlas.*) load from SettingsService
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
if (key.startsWith('profiler.')) {
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
}
}
}
@@ -208,12 +205,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
setErrors(newErrors);
// 实时保存设置
// Real-time save settings
const settings = SettingsService.getInstance();
if (!key.startsWith('project.')) {
// 除了特定的 project 设置需要延迟保存外,其他都实时保存
// Save in real-time except for specific project settings that need deferred save
const deferredProjectSettings = [
'project.uiDesignResolution.',
'project.disabledModules'
];
const shouldDeferSave = deferredProjectSettings.some(prefix => key.startsWith(prefix));
if (!shouldDeferSave) {
settings.set(key, value);
console.log(`[SettingsWindow] Saved ${key}:`, value);
// 触发设置变更事件
// Trigger settings changed event
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: { [key]: value }
}));

View File

@@ -321,6 +321,15 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
scaleSnapRef.current = scaleSnapValue;
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
// 发布 Play 状态变化事件,用于层级面板实时同步
// Publish play state change event for hierarchy panel real-time sync
useEffect(() => {
messageHub?.publish('viewport:playState:changed', {
playState,
isPlaying: playState === 'playing'
});
}, [playState, messageHub]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
@@ -376,6 +385,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}, []);
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
useEffect(() => {
commandManagerRef.current = commandManager ?? null;
@@ -438,7 +448,33 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// Left button (0) for transform or camera pan (if no transform mode active)
else if (e.button === 0) {
if (transformModeRef.current === 'select') {
// In select mode, left click pans camera
// In select mode, first check if clicking on a gizmo
// 在选择模式下,首先检查是否点击了 gizmo
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
if (hitEntityId !== null) {
// Find and select the hit entity
// 找到并选中命中的实体
const scene = Core.scene;
if (scene) {
const hitEntity = scene.entities.findEntityById(hitEntityId);
if (hitEntity && messageHubRef.current) {
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.selectEntity(hitEntity);
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
e.preventDefault();
return; // Don't start camera pan
}
}
}
}
// No gizmo hit, left click pans camera
// 没有点击到 gizmo左键拖动相机
isDraggingCameraRef.current = true;
canvas.style.cursor = 'grabbing';
} else {
@@ -478,6 +514,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
x: prev.x - (deltaX * dpr) / zoom,
y: prev.y + (deltaY * dpr) / zoom
}));
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isDraggingTransformRef.current) {
// Transform selected entity based on mode
const entity = selectedEntityRef.current;
@@ -592,11 +629,30 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
});
}
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else {
// Not dragging - update gizmo hover state
// 没有拖拽时 - 更新 gizmo 悬停状态
if (playStateRef.current !== 'playing') {
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
gizmoService.updateMousePosition(worldPos.x, worldPos.y, zoom);
// Update cursor based on hover state
// 根据悬停状态更新光标
const hoveredId = gizmoService.getHoveredEntityId();
if (hoveredId !== null) {
canvas.style.cursor = 'pointer';
} else {
canvas.style.cursor = 'grab';
}
}
}
return;
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
@@ -904,8 +960,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
await EngineService.getInstance().loadSceneResources();
// 同步 EntityStore 并通知层级面板更新
// Sync EntityStore and notify hierarchy panel to update
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.syncFromScene();
// 发布运行时场景切换事件,通知层级面板更新
// Publish runtime scene change event to notify hierarchy panel
const sceneName = fullPath.split(/[/\\]/).pop()?.replace('.ecs', '') || 'Unknown';
messageHub?.publish('runtime:scene:changed', {
path: fullPath,
sceneName,
isPlayMode: true
});
}
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
@@ -1167,7 +1234,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// Build asset catalog and copy files
// 构建资产目录并复制文件
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string; importSettings?: Record<string, unknown> }> = {};
for (const assetPath of assetPaths) {
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
@@ -1180,11 +1247,11 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
// Get filename and determine relative path
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
// Path format: relative to assets directory, without 'assets/' prefix
// 路径格式:包含 'assets/' 前缀,与运行时资产加载器格式一致
// Path format: includes 'assets/' prefix, consistent with runtime asset loader
const filename = assetPath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const relativePath = filename;
const relativePath = `assets/${filename}`;
// Copy file
await TauriAPI.copyFile(assetPath, destPath);
@@ -1206,6 +1273,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// 检查此资产是否通过 GUID 引用(如粒子资产)
// 如果是,使用原始 GUID否则根据路径生成
let guid: string | undefined;
let importSettings: Record<string, unknown> | undefined;
for (const [originalGuid, mappedPath] of guidToPath.entries()) {
if (mappedPath === assetPath) {
guid = originalGuid;
@@ -1216,12 +1284,61 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
}
// Get importSettings from meta file for nine-patch and other settings
// 从 meta 文件获取 importSettings用于九宫格和其他设置
if (assetRegistry) {
try {
const meta = await assetRegistry.metaManager.getOrCreateMeta(assetPath);
if (meta.importSettings) {
importSettings = meta.importSettings as Record<string, unknown>;
}
} catch {
// Meta file may not exist, that's ok
}
}
// For texture assets, read image dimensions and store in importSettings
// 对于纹理资产,读取图片尺寸并存储到 importSettings
if (assetType === 'texture') {
try {
// Read image as base64 and get dimensions
// 读取图片为 base64 并获取尺寸
const base64Data = await TauriAPI.readFileAsBase64(assetPath);
const dimensions = await new Promise<{ width: number; height: number }>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Failed to load image'));
img.src = `data:image/${ext.slice(1)};base64,${base64Data}`;
});
// Ensure importSettings and spriteSettings exist
// 确保 importSettings 和 spriteSettings 存在
if (!importSettings) {
importSettings = {};
}
if (!importSettings.spriteSettings) {
importSettings.spriteSettings = {};
}
// Add dimensions to spriteSettings
// 将尺寸添加到 spriteSettings
const spriteSettings = importSettings.spriteSettings as Record<string, unknown>;
spriteSettings.width = dimensions.width;
spriteSettings.height = dimensions.height;
console.log(`[Viewport] Texture ${filename}: ${dimensions.width}x${dimensions.height}`);
} catch (dimError) {
console.warn(`[Viewport] Failed to get dimensions for ${filename}:`, dimError);
}
}
catalogEntries[guid] = {
guid,
path: relativePath,
type: assetType,
size: 0,
hash: ''
hash: '',
importSettings
};
} catch (error) {
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);

View File

@@ -399,6 +399,24 @@
flex-shrink: 0;
}
/* Batch breaker item highlight */
.event-item.batch-breaker {
background: rgba(245, 158, 11, 0.08);
}
.event-item.batch-breaker:hover {
background: rgba(245, 158, 11, 0.12);
}
.event-item .event-name.batch-breaker {
color: #f59e0b;
font-weight: 500;
}
.event-item .event-icon.breaker {
color: #f59e0b;
}
/* ==================== Right Panel ==================== */
.render-debug-right {
flex: 1;
@@ -536,6 +554,28 @@
font-weight: 600;
}
/* Batch fix tip */
.batch-fix-tip {
padding: 8px 10px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
}
/* Batch breaker warning */
.batch-breaker-warning {
color: #f59e0b !important;
background: rgba(245, 158, 11, 0.15);
border-radius: 3px;
padding: 4px 8px !important;
margin: 0 !important;
border-top: none !important;
}
/* ==================== Stats Bar ==================== */
.render-debug-stats {
display: flex;
@@ -631,3 +671,147 @@
word-break: break-all;
line-height: 1.3;
}
/* ==================== Clickable Stats ==================== */
.render-debug-stats .stat-item.clickable {
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: background 0.15s;
}
.render-debug-stats .stat-item.clickable:hover {
background: #3a3a3a;
}
.render-debug-stats .stat-item.atlas-enabled {
color: #10b981;
}
.render-debug-stats .stat-item.atlas-disabled {
color: #666;
}
/* ==================== Atlas Preview Modal ==================== */
.atlas-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.atlas-preview-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atlas-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
font-weight: 500;
}
.atlas-page-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #1a1a1a;
}
.atlas-page-tab {
padding: 4px 10px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: #aaa;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.atlas-page-tab:hover {
background: #3a3a3a;
color: #ccc;
}
.atlas-page-tab.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.atlas-preview-canvas-container {
flex: 1;
min-height: 350px;
padding: 12px;
background: #1a1a1a;
}
.atlas-preview-canvas-container canvas {
width: 100%;
height: 100%;
cursor: crosshair;
}
.atlas-preview-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 14px;
background: #252525;
border-top: 1px solid #1a1a1a;
min-height: 40px;
}
.atlas-preview-info .hint {
color: #666;
font-style: italic;
}
.atlas-entry-info {
display: flex;
gap: 6px;
font-size: 10px;
}
.atlas-entry-info .label {
color: #888;
}
.atlas-entry-info .value {
color: #4a9eff;
font-family: 'Consolas', monospace;
}
.atlas-preview-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 14px;
background: #2d2d2d;
border-top: 1px solid #1a1a1a;
font-size: 10px;
color: #888;
}
.atlas-preview-stats .error {
color: #ef4444;
}

View File

@@ -26,18 +26,21 @@ import {
Download,
Radio,
Square,
Type
Type,
Grid3x3
} from 'lucide-react';
import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo } from '../../services/RenderDebugService';
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo, type UniformDebugValue, type AtlasStats, type AtlasPageDebugInfo, type AtlasEntryDebugInfo } from '../../services/RenderDebugService';
import type { BatchDebugInfo } from '@esengine/ui';
import { EngineService } from '../../services/EngineService';
import './RenderDebugPanel.css';
/**
* 渲染事件类型
* Render event type
*/
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw';
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw' | 'ui-batch';
/**
* 渲染事件
@@ -52,6 +55,8 @@ interface RenderEvent {
data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any;
drawCalls?: number;
vertices?: number;
/** 合批调试信息 | Batch debug info */
batchInfo?: BatchDebugInfo;
}
interface RenderDebugPanelProps {
@@ -74,6 +79,10 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
const [frameHistory, setFrameHistory] = useState<RenderDebugSnapshot[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode
// 图集预览状态 | Atlas preview state
const [showAtlasPreview, setShowAtlasPreview] = useState(false);
const [selectedAtlasPage, setSelectedAtlasPage] = useState(0);
// 窗口拖动状态 | Window drag state
const [position, setPosition] = useState({ x: 100, y: 60 });
const [size, setSize] = useState({ width: 900, height: 600 });
@@ -84,6 +93,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
const canvasRef = useRef<HTMLCanvasElement>(null);
const windowRef = useRef<HTMLDivElement>(null);
// 高亮相关 | Highlight related
const previousSelectedIdsRef = useRef<number[] | null>(null);
const engineService = useRef(EngineService.getInstance());
// 处理事件选中并高亮实体 | Handle event selection and highlight entity
const handleEventSelect = useCallback((event: RenderEvent | null) => {
setSelectedEvent(event);
// 获取实体 ID | Get entity ID
const entityId = event?.data?.entityId;
if (entityId !== undefined) {
// 保存原始选中状态(只保存一次)| Save original selection (only once)
if (previousSelectedIdsRef.current === null) {
previousSelectedIdsRef.current = engineService.current.getSelectedEntityIds?.() || [];
}
// 高亮选中的实体 | Highlight selected entity
engineService.current.setSelectedEntityIds([entityId]);
} else if (previousSelectedIdsRef.current !== null) {
// 恢复原始选中状态 | Restore original selection
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
previousSelectedIdsRef.current = null;
}
}, []);
// 面板关闭时恢复原始选中状态 | Restore original selection when panel closes
useEffect(() => {
if (!visible && previousSelectedIdsRef.current !== null) {
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
previousSelectedIdsRef.current = null;
}
}, [visible]);
// 弹出为独立窗口 | Pop out to separate window
const handlePopOut = useCallback(async () => {
try {
@@ -181,8 +223,85 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
});
});
// UI 元素 | UI elements
if (snap.uiElements && snap.uiElements.length > 0) {
// UI 批次和元素 | UI batches and elements
// 使用 entityIds 进行精确的批次-元素匹配 | Use entityIds for precise batch-element matching
if (snap.uiBatches && snap.uiBatches.length > 0) {
const uiChildren: RenderEvent[] = [];
// 构建 entityId -> UI 元素的映射 | Build entityId -> UI element map
const uiElementMap = new Map<number, UIDebugInfo>();
snap.uiElements?.forEach(ui => {
if (ui.entityId !== undefined) {
uiElementMap.set(ui.entityId, ui);
}
});
// 为每个批次创建事件,包含其子元素 | Create events for each batch with its child elements
snap.uiBatches.forEach((batch) => {
const reasonLabels: Record<string, string> = {
'first': '',
'sortingLayer': '⚠️ Layer',
'texture': '⚠️ Texture',
'material': '⚠️ Material'
};
const reasonLabel = reasonLabels[batch.reason] || '';
const batchName = batch.reason === 'first'
? `DC ${batch.batchIndex}: ${batch.primitiveCount} prims`
: `DC ${batch.batchIndex} ${reasonLabel}: ${batch.primitiveCount} prims`;
// 从 entityIds 获取此批次的 UI 元素 | Get UI elements for this batch from entityIds
const batchElements: RenderEvent[] = [];
const entityIds = batch.entityIds ?? [];
const firstEntityId = batch.firstEntityId;
entityIds.forEach((entityId) => {
const ui = uiElementMap.get(entityId);
if (ui) {
// 使用 firstEntityId 精确标记打断批次的元素 | Use firstEntityId to precisely mark batch breaker
const isBreaker = entityId === firstEntityId && batch.reason !== 'first';
batchElements.push({
id: eventId++,
type: 'ui' as RenderEventType,
name: isBreaker
? `${ui.type}: ${ui.entityName}`
: `${ui.type}: ${ui.entityName}`,
data: {
...ui,
isBatchBreaker: isBreaker,
breakReason: isBreaker ? batch.reason : undefined,
batchIndex: batch.batchIndex
},
drawCalls: 0,
vertices: 4
});
}
});
uiChildren.push({
id: eventId++,
type: 'ui-batch' as RenderEventType,
name: batchName,
batchInfo: batch,
children: batchElements.length > 0 ? batchElements : undefined,
expanded: batchElements.length > 0 && batchElements.length <= 10,
drawCalls: 1,
vertices: batch.primitiveCount * 4
});
});
const totalPrimitives = snap.uiBatches.reduce((sum, b) => sum + b.primitiveCount, 0);
const dcCount = snap.uiBatches.length;
newEvents.push({
id: eventId++,
type: 'batch',
name: `UI Render (${dcCount} DC, ${snap.uiElements?.length ?? 0} elements)`,
children: uiChildren,
expanded: true,
drawCalls: dcCount,
vertices: totalPrimitives * 4
});
} else if (snap.uiElements && snap.uiElements.length > 0) {
// 回退:没有批次信息时按元素显示 | Fallback: show by element when no batch info
const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({
id: eventId++,
type: 'ui' as RenderEventType,
@@ -234,9 +353,9 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
if (snap) {
setSnapshot(snap);
setEvents(buildEventsFromSnapshot(snap));
setSelectedEvent(null);
handleEventSelect(null);
}
}, [frameHistory, buildEventsFromSnapshot]);
}, [frameHistory, buildEventsFromSnapshot, handleEventSelect]);
// 返回实时模式 | Return to live mode
const goLive = useCallback(() => {
@@ -467,62 +586,122 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
ctx.textAlign = 'left';
ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6);
} else if (data?.uv) {
// Sprite 或单个粒子:显示 UV 区域 | Sprite or single particle: show UV region
const uv = data.uv;
const previewSize = Math.min(viewWidth, viewHeight);
} else if (data?.uv || data?.textureUrl) {
// Sprite 或 UI 元素:显示纹理和 UV 区域 | Sprite or UI element: show texture and UV region
const uv = data.uv ?? [0, 0, 1, 1];
const previewSize = Math.min(viewWidth, viewHeight) - 30; // 留出底部文字空间
const offsetX = (rect.width - previewSize) / 2;
const offsetY = (rect.height - previewSize) / 2;
const offsetY = margin;
// 绘制纹理边框 | Draw texture border
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX, offsetY, previewSize, previewSize);
// 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid
const tilesX = data._animTilesX ?? (data.systemName ? 1 : 1);
const tilesY = data._animTilesY ?? 1;
if (tilesX > 1 || tilesY > 1) {
const cellWidth = previewSize / tilesX;
const cellHeight = previewSize / tilesY;
// 绘制网格 | Draw grid
ctx.strokeStyle = '#2a2a2a';
for (let i = 0; i <= tilesX; i++) {
ctx.beginPath();
ctx.moveTo(offsetX + i * cellWidth, offsetY);
ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize);
ctx.stroke();
}
for (let j = 0; j <= tilesY; j++) {
ctx.beginPath();
ctx.moveTo(offsetX, offsetY + j * cellHeight);
ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight);
ctx.stroke();
// 绘制棋盘格背景(透明度指示)| Draw checkerboard background (transparency indicator)
const checkerSize = 8;
for (let cx = 0; cx < previewSize; cx += checkerSize) {
for (let cy = 0; cy < previewSize; cy += checkerSize) {
const isLight = ((cx / checkerSize) + (cy / checkerSize)) % 2 === 0;
ctx.fillStyle = isLight ? '#2a2a2a' : '#1f1f1f';
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
}
}
// 高亮 UV 区域 | Highlight UV region
const x = offsetX + uv[0] * previewSize;
const y = offsetY + uv[1] * previewSize;
const w = (uv[2] - uv[0]) * previewSize;
const h = (uv[3] - uv[1]) * previewSize;
// 如果有纹理 URL加载并绘制纹理 | If texture URL exists, load and draw texture
if (data.textureUrl) {
const img = document.createElement('img');
img.onload = () => {
// 重新获取 context异步回调中需要| Re-get context (needed in async callback)
const ctx2 = canvas.getContext('2d');
if (!ctx2) return;
ctx2.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.fillStyle = 'rgba(74, 158, 255, 0.3)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
// 绘制纹理 | Draw texture
ctx2.drawImage(img, offsetX, offsetY, previewSize, previewSize);
// 显示 UV 坐标 | Show UV coordinates
ctx.fillStyle = '#4a9eff';
ctx.font = '10px Consolas, monospace';
ctx.textAlign = 'left';
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, offsetY + previewSize + 14);
// 高亮 UV 区域 | Highlight UV region
const x = offsetX + uv[0] * previewSize;
const y = offsetY + uv[1] * previewSize;
const w = (uv[2] - uv[0]) * previewSize;
const h = (uv[3] - uv[1]) * previewSize;
if (data.frame !== undefined) {
ctx.fillText(`Frame: ${data.frame}`, offsetX, offsetY + previewSize + 26);
ctx2.fillStyle = 'rgba(74, 158, 255, 0.2)';
ctx2.fillRect(x, y, w, h);
ctx2.strokeStyle = '#4a9eff';
ctx2.lineWidth = 2;
ctx2.strokeRect(x, y, w, h);
// 绘制边框 | Draw border
ctx2.strokeStyle = '#444';
ctx2.lineWidth = 1;
ctx2.strokeRect(offsetX, offsetY, previewSize, previewSize);
// 显示信息 | Show info
ctx2.fillStyle = '#4a9eff';
ctx2.font = '10px Consolas, monospace';
ctx2.textAlign = 'left';
const infoY = offsetY + previewSize + 14;
ctx2.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
if (data.aspectRatio !== undefined) {
ctx2.fillStyle = '#10b981';
ctx2.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
}
if (data.color) {
ctx2.fillStyle = '#f59e0b';
ctx2.fillText(`color: ${data.color}`, offsetX, infoY + 12);
}
};
img.src = data.textureUrl;
} else {
// 没有纹理时绘制占位符 | Draw placeholder when no texture
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX, offsetY, previewSize, previewSize);
// 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid
const tilesX = data._animTilesX ?? 1;
const tilesY = data._animTilesY ?? 1;
if (tilesX > 1 || tilesY > 1) {
const cellWidth = previewSize / tilesX;
const cellHeight = previewSize / tilesY;
ctx.strokeStyle = '#2a2a2a';
for (let i = 0; i <= tilesX; i++) {
ctx.beginPath();
ctx.moveTo(offsetX + i * cellWidth, offsetY);
ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize);
ctx.stroke();
}
for (let j = 0; j <= tilesY; j++) {
ctx.beginPath();
ctx.moveTo(offsetX, offsetY + j * cellHeight);
ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight);
ctx.stroke();
}
}
// 高亮 UV 区域 | Highlight UV region
const x = offsetX + uv[0] * previewSize;
const y = offsetY + uv[1] * previewSize;
const w = (uv[2] - uv[0]) * previewSize;
const h = (uv[3] - uv[1]) * previewSize;
ctx.fillStyle = 'rgba(74, 158, 255, 0.3)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#4a9eff';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
// 显示信息 | Show info
ctx.fillStyle = '#4a9eff';
ctx.font = '10px Consolas, monospace';
ctx.textAlign = 'left';
const infoY = offsetY + previewSize + 14;
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
if (data.aspectRatio !== undefined) {
ctx.fillStyle = '#10b981';
ctx.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
}
if (data.frame !== undefined) {
ctx.fillText(`Frame: ${data.frame}`, offsetX, infoY + 12);
}
}
} else {
// 其他事件类型 | Other event types
@@ -707,7 +886,7 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
event={event}
depth={0}
selected={selectedEvent?.id === event.id}
onSelect={setSelectedEvent}
onSelect={handleEventSelect}
onToggle={toggleExpand}
/>
))
@@ -767,10 +946,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
<Image size={12} />
<span>Systems: {snapshot?.particles?.length ?? 0}</span>
</div>
{/* 动态图集统计 | Dynamic atlas stats */}
{snapshot?.atlasStats && (
<div
className={`stat-item clickable ${snapshot.atlasStats.enabled ? 'atlas-enabled' : 'atlas-disabled'}`}
title={
snapshot.atlasStats.enabled
? `Click to view atlas. ${snapshot.atlasStats.pageCount} pages, ${snapshot.atlasStats.textureCount} textures, ${(snapshot.atlasStats.averageOccupancy * 100).toFixed(0)}% occupancy`
: 'Dynamic Atlas: Disabled'
}
onClick={() => snapshot.atlasStats?.enabled && setShowAtlasPreview(true)}
>
<Grid3x3 size={12} />
<span>
Atlas: {snapshot.atlasStats.enabled
? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p`
: 'Off'}
</span>
</div>
)}
</div>
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */}
{!standalone && <div className="resize-handle" onMouseDown={handleResizeMouseDown} />}
{/* 图集预览弹窗 | Atlas preview modal */}
{showAtlasPreview && snapshot?.atlasStats?.pages && (
<AtlasPreviewModal
atlasStats={snapshot.atlasStats}
selectedPage={selectedAtlasPage}
onSelectPage={setSelectedAtlasPage}
onClose={() => setShowAtlasPreview(false)}
/>
)}
</div>
);
};
@@ -788,12 +996,14 @@ interface EventItemProps {
const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect, onToggle }) => {
const hasChildren = event.children && event.children.length > 0;
const iconSize = 12;
const isBatchBreaker = event.data?.isBatchBreaker === true;
const getTypeIcon = () => {
switch (event.type) {
case 'sprite': return <Image size={iconSize} className="event-icon sprite" />;
case 'particle': return <Sparkles size={iconSize} className="event-icon particle" />;
case 'ui': return <Square size={iconSize} className="event-icon ui" />;
case 'ui': return <Square size={iconSize} className={`event-icon ui ${isBatchBreaker ? 'breaker' : ''}`} />;
case 'ui-batch': return <Layers size={iconSize} className="event-icon ui" />;
case 'batch': return <Layers size={iconSize} className="event-icon batch" />;
default: return <Monitor size={iconSize} className="event-icon" />;
}
@@ -802,7 +1012,7 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
return (
<>
<div
className={`event-item ${selected ? 'selected' : ''}`}
className={`event-item ${selected ? 'selected' : ''} ${isBatchBreaker ? 'batch-breaker' : ''}`}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelect(event)}
>
@@ -814,8 +1024,8 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
<span className="expand-icon placeholder" />
)}
{getTypeIcon()}
<span className="event-name">{event.name}</span>
{event.drawCalls !== undefined && (
<span className={`event-name ${isBatchBreaker ? 'batch-breaker' : ''}`}>{event.name}</span>
{event.drawCalls !== undefined && event.drawCalls > 0 && (
<span className="event-draws">{event.drawCalls}</span>
)}
</div>
@@ -948,6 +1158,8 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
});
}, [event, data]);
const batchInfo = event.batchInfo;
return (
<div className="details-grid">
<DetailRow label="Event" value={event.name} />
@@ -955,6 +1167,48 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Draw Calls" value={event.drawCalls?.toString() ?? '-'} />
<DetailRow label="Vertices" value={event.vertices?.toString() ?? '-'} />
{/* UI 批次信息 | UI batch info */}
{event.type === 'ui-batch' && batchInfo && (
<>
<div className="details-section">Batch Break Reason</div>
<DetailRow
label="Reason"
value={batchInfo.reason === 'first' ? 'First batch' : batchInfo.reason}
highlight={batchInfo.reason !== 'first'}
/>
<DetailRow label="Detail" value={batchInfo.detail} />
<div className="details-section">Batch Properties</div>
<DetailRow label="Batch Index" value={batchInfo.batchIndex.toString()} />
<DetailRow label="Primitives" value={batchInfo.primitiveCount.toString()} />
<DetailRow label="Sorting Layer" value={batchInfo.sortingLayer} />
<DetailRow label="Order" value={batchInfo.orderInLayer.toString()} />
<DetailRow
label="Texture"
value={batchInfo.textureKey.startsWith('atlas:')
? `🗂️ ${batchInfo.textureKey}`
: batchInfo.textureKey}
highlight={batchInfo.textureKey.startsWith('atlas:')}
/>
<DetailRow label="Material ID" value={batchInfo.materialId.toString()} />
{batchInfo.reason !== 'first' && (
<>
<div className="details-section">How to Fix</div>
<div className="batch-fix-tip">
{batchInfo.reason === 'sortingLayer' && (
<span></span>
)}
{batchInfo.reason === 'texture' && (
<span>使</span>
)}
{batchInfo.reason === 'material' && (
<span>使/</span>
)}
</div>
</>
)}
</>
)}
{data && (
<>
<div className="details-section">Properties</div>
@@ -971,6 +1225,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Sort Layer" value={data.sortingLayer || 'Default'} />
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
<div className="details-section">Material</div>
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight />
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} />
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
<>
<div className="details-section">Uniforms</div>
<UniformList uniforms={data.uniforms} />
</>
)}
<div className="details-section">Vertex Attributes</div>
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
</>
)}
@@ -1018,6 +1283,19 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
{/* UI 元素数据 | UI element data */}
{event.type === 'ui' && data.entityName && (
<>
{/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */}
{data.isBatchBreaker && (
<>
<div className="details-section batch-breaker-warning"> Batch Breaker</div>
<div className="batch-fix-tip">
Draw Call
{data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'}
{data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'}
{data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'}
{data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'}
</div>
</>
)}
<DetailRow label="Entity" value={data.entityName} />
<DetailRow label="Type" value={data.type} highlight />
<DetailRow label="Position" value={`(${data.x?.toFixed(0)}, ${data.y?.toFixed(0)})`} />
@@ -1026,14 +1304,20 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Rotation" value={`${((data.rotation ?? 0) * 180 / Math.PI).toFixed(1)}°`} />
<DetailRow label="Visible" value={data.visible ? 'Yes' : 'No'} />
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} />
<div className="details-section">Sorting</div>
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} highlight={data.isBatchBreaker && data.breakReason === 'sortingLayer'} />
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
<DetailRow label="Depth" value={data.depth?.toString() ?? '0'} />
<DetailRow label="World Order" value={data.worldOrderInLayer?.toString() ?? '0'} highlight />
{data.backgroundColor && (
<DetailRow label="Background" value={data.backgroundColor} />
)}
{data.textureGuid && (
<TexturePreview textureUrl={data.textureUrl} texturePath={data.textureGuid} />
)}
{!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && (
<DetailRow label="Texture" value="(none / solid)" highlight />
)}
{data.text && (
<>
<div className="details-section">Text</div>
@@ -1041,6 +1325,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
{data.fontSize && <DetailRow label="Font Size" value={data.fontSize.toString()} />}
</>
)}
<div className="details-section">Material</div>
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
<>
<div className="details-section">Uniforms</div>
<UniformList uniforms={data.uniforms} />
</>
)}
<div className="details-section">Vertex Attributes</div>
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
</>
)}
</>
@@ -1056,4 +1351,350 @@ const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }>
</div>
);
/**
* 格式化 uniform 值
* Format uniform value
*/
function formatUniformValue(uniform: UniformDebugValue): string {
const { type, value } = uniform;
if (typeof value === 'number') {
return type === 'int' ? value.toString() : value.toFixed(4);
}
if (Array.isArray(value)) {
return value.map(v => v.toFixed(3)).join(', ');
}
return String(value);
}
/**
* Uniform 列表组件
* Uniform list component
*/
const UniformList: React.FC<{ uniforms: Record<string, UniformDebugValue> }> = ({ uniforms }) => {
const entries = Object.entries(uniforms);
if (entries.length === 0) {
return <DetailRow label="Uniforms" value="(none)" />;
}
return (
<>
{entries.map(([name, uniform]) => (
<DetailRow
key={name}
label={name.replace(/^u_/, '')}
value={`${formatUniformValue(uniform)} (${uniform.type})`}
/>
))}
</>
);
};
/**
* 图集预览弹窗组件
* Atlas Preview Modal Component
*/
interface AtlasPreviewModalProps {
atlasStats: AtlasStats;
selectedPage: number;
onSelectPage: (page: number) => void;
onClose: () => void;
}
const AtlasPreviewModal: React.FC<AtlasPreviewModalProps> = ({
atlasStats,
selectedPage,
onSelectPage,
onClose
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [hoveredEntry, setHoveredEntry] = useState<AtlasEntryDebugInfo | null>(null);
const [loadedImages, setLoadedImages] = useState<Map<string, HTMLImageElement>>(new Map());
// 缩放和平移状态 | Zoom and pan state
const [zoom, setZoom] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
const currentPage = atlasStats.pages[selectedPage];
// 重置视图当页面切换时 | Reset view when page changes
useEffect(() => {
setZoom(1);
setPanOffset({ x: 0, y: 0 });
}, [selectedPage]);
// 预加载所有纹理图像 | Preload all texture images
useEffect(() => {
if (!currentPage) return;
const newImages = new Map<string, HTMLImageElement>();
let loadCount = 0;
const totalCount = currentPage.entries.filter(e => e.dataUrl).length;
currentPage.entries.forEach(entry => {
if (entry.dataUrl) {
const img = document.createElement('img');
img.onload = () => {
newImages.set(entry.guid, img);
loadCount++;
if (loadCount === totalCount) {
setLoadedImages(new Map(newImages));
}
};
img.onerror = () => {
loadCount++;
if (loadCount === totalCount) {
setLoadedImages(new Map(newImages));
}
};
img.src = entry.dataUrl;
}
});
// 如果没有图像需要加载,立即设置空 Map
if (totalCount === 0) {
setLoadedImages(new Map());
}
}, [currentPage]);
// 绘制图集预览 | Draw atlas preview
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !currentPage) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const pageSize = currentPage.width;
// 基础缩放:让图集适应画布 | Base scale: fit atlas to canvas
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
// 应用用户缩放 | Apply user zoom
const scale = baseScale * zoom;
// 计算中心偏移 + 用户平移 | Calculate center offset + user pan
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
// 背景 | Background
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, rect.width, rect.height);
// 棋盘格背景(在图集区域内)| Checkerboard background (inside atlas area)
ctx.save();
ctx.beginPath();
ctx.rect(offsetX, offsetY, pageSize * scale, pageSize * scale);
ctx.clip();
const checkerSize = Math.max(8, 16 * zoom);
for (let cx = 0; cx < pageSize * scale; cx += checkerSize) {
for (let cy = 0; cy < pageSize * scale; cy += checkerSize) {
const isLight = (Math.floor(cx / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0;
ctx.fillStyle = isLight ? '#2a2a2a' : '#222';
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
}
}
ctx.restore();
// 绘制图集边框 | Draw atlas border
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX, offsetY, pageSize * scale, pageSize * scale);
// 绘制每个纹理区域 | Draw each texture region
const colors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
currentPage.entries.forEach((entry, idx) => {
const x = offsetX + entry.x * scale;
const y = offsetY + entry.y * scale;
const w = entry.width * scale;
const h = entry.height * scale;
const color = colors[idx % colors.length] ?? '#4a9eff';
const isHovered = hoveredEntry?.guid === entry.guid;
// 尝试绘制图像 | Try to draw image
const img = loadedImages.get(entry.guid);
if (img) {
ctx.drawImage(img, x, y, w, h);
} else {
// 没有图像时显示占位背景 | Show placeholder when no image
ctx.fillStyle = `${color}40`;
ctx.fillRect(x, y, w, h);
}
// 边框 | Border
ctx.strokeStyle = isHovered ? '#fff' : (img ? '#333' : color);
ctx.lineWidth = isHovered ? 2 : 1;
ctx.strokeRect(x, y, w, h);
// 高亮时显示尺寸标签 | Show size label when hovered
if (isHovered || (!img && w > 30 && h > 20)) {
// 半透明背景 | Semi-transparent background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
const labelText = `${entry.width}x${entry.height}`;
ctx.font = `${Math.max(10, 10 * zoom)}px Consolas`;
const textWidth = ctx.measureText(labelText).width;
ctx.fillRect(x + w / 2 - textWidth / 2 - 4, y + h / 2 - 8, textWidth + 8, 16);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(labelText, x + w / 2, y + h / 2 + 4);
}
});
// 绘制信息 | Draw info
ctx.fillStyle = '#666';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`${currentPage.width}x${currentPage.height} | ${(currentPage.occupancy * 100).toFixed(1)}% | Zoom: ${(zoom * 100).toFixed(0)}%`, 8, rect.height - 8);
}, [currentPage, hoveredEntry, loadedImages, zoom, panOffset]);
// 鼠标悬停检测和拖动 | Mouse hover detection and dragging
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || !currentPage) return;
// 处理拖动平移 | Handle pan dragging
if (isPanning) {
const dx = e.clientX - lastMousePos.x;
const dy = e.clientY - lastMousePos.y;
setPanOffset(prev => ({ x: prev.x + dx, y: prev.y + dy }));
setLastMousePos({ x: e.clientX, y: e.clientY });
return;
}
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const pageSize = currentPage.width;
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
const scale = baseScale * zoom;
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
// 检查是否悬停在某个条目上 | Check if hovering over an entry
let found: AtlasEntryDebugInfo | null = null;
for (const entry of currentPage.entries) {
const x = offsetX + entry.x * scale;
const y = offsetY + entry.y * scale;
const w = entry.width * scale;
const h = entry.height * scale;
if (mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + h) {
found = entry;
break;
}
}
setHoveredEntry(found);
}, [currentPage, isPanning, lastMousePos, zoom, panOffset]);
// 滚轮缩放 | Wheel zoom
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setZoom(prev => Math.max(0.5, Math.min(10, prev * delta)));
}, []);
// 开始拖动 | Start dragging
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button === 0 || e.button === 1) { // 左键或中键 | Left or middle button
setIsPanning(true);
setLastMousePos({ x: e.clientX, y: e.clientY });
}
}, []);
// 结束拖动 | End dragging
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// 双击重置视图 | Double click to reset view
const handleDoubleClick = useCallback(() => {
setZoom(1);
setPanOffset({ x: 0, y: 0 });
}, []);
return (
<div className="atlas-preview-modal" onClick={onClose}>
<div className="atlas-preview-content" onClick={e => e.stopPropagation()}>
<div className="atlas-preview-header">
<span>Dynamic Atlas Preview</span>
<button className="window-btn" onClick={onClose}>
<X size={14} />
</button>
</div>
{/* 页面选择器 | Page selector */}
{atlasStats.pages.length > 1 && (
<div className="atlas-page-tabs">
{atlasStats.pages.map((page, idx) => (
<button
key={idx}
className={`atlas-page-tab ${selectedPage === idx ? 'active' : ''}`}
onClick={() => onSelectPage(idx)}
>
Page {idx} ({(page.occupancy * 100).toFixed(0)}%)
</button>
))}
</div>
)}
{/* 图集可视化 | Atlas visualization */}
<div className="atlas-preview-canvas-container">
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => { setHoveredEntry(null); setIsPanning(false); }}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
/>
</div>
{/* 悬停信息 | Hover info */}
<div className="atlas-preview-info">
{hoveredEntry ? (
<>
<div className="atlas-entry-info">
<span className="label">GUID:</span>
<span className="value">{hoveredEntry.guid.slice(0, 8)}...</span>
</div>
<div className="atlas-entry-info">
<span className="label">Position:</span>
<span className="value">({hoveredEntry.x}, {hoveredEntry.y})</span>
</div>
<div className="atlas-entry-info">
<span className="label">Size:</span>
<span className="value">{hoveredEntry.width} x {hoveredEntry.height}</span>
</div>
<div className="atlas-entry-info">
<span className="label">UV:</span>
<span className="value">[{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}]</span>
</div>
</>
) : (
<span className="hint">Scroll to zoom, drag to pan, double-click to reset</span>
)}
</div>
{/* 统计信息 | Statistics */}
<div className="atlas-preview-stats">
<span>Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s)</span>
<span>Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}%</span>
{atlasStats.loadingCount > 0 && <span>Loading: {atlasStats.loadingCount}</span>}
{atlasStats.failedCount > 0 && <span className="error">Failed: {atlasStats.failedCount}</span>}
</div>
</div>
</div>
);
};
export default RenderDebugPanel;

View File

@@ -0,0 +1,87 @@
/**
* Entity Reference Field Styles
* 实体引用字段样式
*
* Uses property-field and property-label from PropertyInspector.css for consistency.
* 使用 PropertyInspector.css 中的 property-field 和 property-label 以保持一致性。
*/
/* Input container - matches property-input styling */
.entity-ref-field__input {
flex: 1;
display: flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
gap: 4px;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.entity-ref-field__input:hover:not(.readonly) {
border-color: #4a4a4a;
}
.entity-ref-field__input.drag-over {
border-color: var(--accent-color, #4a9eff);
background: rgba(74, 158, 255, 0.1);
}
.entity-ref-field__input.readonly {
opacity: 0.7;
cursor: not-allowed;
}
/* Entity name - clickable to navigate */
.entity-ref-field__name {
flex: 1;
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
color: #ddd;
cursor: pointer;
padding: 2px 4px;
border-radius: 2px;
transition: background-color 0.15s ease, color 0.15s ease;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-ref-field__name:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--accent-color, #4a9eff);
}
/* Clear button */
.entity-ref-field__clear {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: #999;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.entity-ref-field__clear:hover {
background: rgba(255, 100, 100, 0.2);
color: #ff6464;
}
/* Placeholder text */
.entity-ref-field__placeholder {
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
color: #666;
font-style: italic;
}

View File

@@ -0,0 +1,127 @@
/**
* Entity Reference Field
* 实体引用字段
*
* Allows drag-and-drop of entities from SceneHierarchy.
* 支持从场景层级面板拖拽实体。
*/
import React, { useCallback, useState } from 'react';
import { Core } from '@esengine/ecs-framework';
import { useHierarchyStore } from '../../../stores';
import './EntityRefField.css';
export interface EntityRefFieldProps {
/** Field label | 字段标签 */
label: string;
/** Current entity ID (0 = none) | 当前实体 ID (0 = 无) */
value: number;
/** Value change callback | 值变更回调 */
onChange: (value: number) => void;
/** Placeholder text | 占位文本 */
placeholder?: string;
/** Read-only mode | 只读模式 */
readonly?: boolean;
}
export const EntityRefField: React.FC<EntityRefFieldProps> = ({
label,
value,
onChange,
placeholder = '拖拽实体到此处 / Drop entity here',
readonly = false
}) => {
const [isDragOver, setIsDragOver] = useState(false);
// Get entity name for display
// 获取实体名称用于显示
const getEntityName = useCallback((): string | null => {
if (!value || value === 0) return null;
const scene = Core.scene;
if (!scene) return null;
const entity = scene.entities.findEntityById(value);
return entity?.name || `Entity #${value}`;
}, [value]);
const entityName = getEntityName();
const handleDragOver = useCallback((e: React.DragEvent) => {
if (readonly) return;
// Check if dragging an entity
// 检查是否拖拽实体
if (e.dataTransfer.types.includes('entity-id')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setIsDragOver(true);
}
}, [readonly]);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
if (readonly) return;
e.preventDefault();
setIsDragOver(false);
const entityIdStr = e.dataTransfer.getData('entity-id');
if (entityIdStr) {
const entityId = parseInt(entityIdStr, 10);
if (!isNaN(entityId) && entityId > 0) {
onChange(entityId);
}
}
}, [readonly, onChange]);
const handleClear = useCallback(() => {
if (readonly) return;
onChange(0);
}, [readonly, onChange]);
const handleNavigateToEntity = useCallback(() => {
if (!value || value === 0) return;
// Select the referenced entity in SceneHierarchy
// 在场景层级面板中选择引用的实体
const { setSelectedIds } = useHierarchyStore.getState();
setSelectedIds(new Set([value]));
}, [value]);
return (
<div className="property-field entity-ref-field">
<label className="property-label">{label}</label>
<div
className={`entity-ref-field__input ${isDragOver ? 'drag-over' : ''} ${readonly ? 'readonly' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{entityName ? (
<>
<span
className="entity-ref-field__name"
onClick={handleNavigateToEntity}
title="点击选择此实体 / Click to select this entity"
>
{entityName}
</span>
{!readonly && (
<button
className="entity-ref-field__clear"
onClick={handleClear}
title="清除引用 / Clear reference"
>
×
</button>
)}
</>
) : (
<span className="entity-ref-field__placeholder">{placeholder}</span>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,369 @@
/**
* Material properties editor component.
* 材质属性编辑器组件。
*
* This component provides a UI for editing shader uniform values
* based on shader property metadata.
* 此组件提供基于着色器属性元数据编辑着色器 uniform 值的 UI。
*/
import React, { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Palette } from 'lucide-react';
import type {
IMaterialOverridable,
ShaderPropertyMeta,
MaterialPropertyOverride
} from '@esengine/material-system';
import {
BuiltInShaders,
getShaderPropertiesById
} from '@esengine/material-system';
// Shader name mapping
const SHADER_NAMES: Record<number, string> = {
0: 'DefaultSprite',
1: 'Grayscale',
2: 'Tint',
3: 'Flash',
4: 'Outline',
5: 'Shiny'
};
interface MaterialPropertiesEditorProps {
/** Target component implementing IMaterialOverridable */
target: IMaterialOverridable;
/** Callback when property changes */
onChange?: (name: string, value: MaterialPropertyOverride) => void;
}
/**
* Material properties editor.
* 材质属性编辑器。
*/
export const MaterialPropertiesEditor: React.FC<MaterialPropertiesEditorProps> = ({
target,
onChange
}) => {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Effect', 'Default']));
const materialId = target.getMaterialId();
const shaderName = SHADER_NAMES[materialId] || `Custom(${materialId})`;
const properties = getShaderPropertiesById(materialId);
// Group properties
const groupedProps = useMemo(() => {
if (!properties) return {};
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
for (const [name, meta] of Object.entries(properties)) {
if (meta.hidden) continue;
const group = meta.group || 'Default';
if (!groups[group]) groups[group] = [];
groups[group].push([name, meta]);
}
return groups;
}, [properties]);
const toggleGroup = (group: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
};
const handleChange = (name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => {
const override: MaterialPropertyOverride = {
type: meta.type === 'texture' ? 'int' : meta.type as MaterialPropertyOverride['type'],
value: newValue
};
// Apply to target
switch (meta.type) {
case 'float':
target.setOverrideFloat(name, newValue as number);
break;
case 'int':
target.setOverrideInt(name, newValue as number);
break;
case 'vec2':
const v2 = newValue as number[];
target.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0);
break;
case 'vec3':
const v3 = newValue as number[];
target.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0);
break;
case 'vec4':
const v4 = newValue as number[];
target.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0);
break;
case 'color':
const c = newValue as number[];
target.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1);
break;
}
onChange?.(name, override);
};
const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => {
const override = target.getOverride(name);
if (override) {
return override.value as number | number[];
}
return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
};
// Parse i18n label
const parseLabel = (label: string): string => {
// Format: "中文 | English" - for now just return as-is
return label;
};
return (
<div className="material-properties-editor" style={{ fontSize: '12px' }}>
{/* Shader selector */}
<div style={{
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
backgroundColor: '#3a3a3a',
borderRadius: '4px',
marginBottom: '8px'
}}>
<Palette size={14} style={{ marginRight: '8px', color: '#888' }} />
<span style={{ color: '#aaa', marginRight: '8px' }}>Shader:</span>
<select
value={materialId}
onChange={(e) => target.setMaterialId(Number(e.target.value))}
style={{
flex: 1,
backgroundColor: '#2a2a2a',
color: '#e0e0e0',
border: '1px solid #4a4a4a',
borderRadius: '3px',
padding: '3px 6px',
fontSize: '12px'
}}
>
<option value={0}>DefaultSprite</option>
<option value={1}>Grayscale</option>
<option value={2}>Tint</option>
<option value={3}>Flash</option>
<option value={4}>Outline</option>
<option value={5}>Shiny</option>
</select>
</div>
{/* Property groups */}
{Object.entries(groupedProps).map(([group, props]) => (
<div key={group} style={{ marginBottom: '4px' }}>
{/* Group header */}
<div
onClick={() => toggleGroup(group)}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
backgroundColor: '#333',
borderRadius: '3px',
cursor: 'pointer',
userSelect: 'none'
}}
>
{expandedGroups.has(group) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span style={{ marginLeft: '4px', color: '#aaa', fontWeight: 500 }}>{group}</span>
</div>
{/* Properties */}
{expandedGroups.has(group) && (
<div style={{ padding: '4px 8px' }}>
{props.map(([name, meta]) => (
<PropertyEditor
key={name}
name={name}
meta={meta}
value={getCurrentValue(name, meta)}
onChange={(v) => handleChange(name, meta, v)}
/>
))}
</div>
)}
</div>
))}
{!properties && (
<div style={{ color: '#666', padding: '8px', fontStyle: 'italic' }}>
No editable properties for {shaderName}
</div>
)}
</div>
);
};
interface PropertyEditorProps {
name: string;
meta: ShaderPropertyMeta;
value: number | number[];
onChange: (value: number | number[]) => void;
}
/**
* Individual property editor.
* 单个属性编辑器。
*/
const PropertyEditor: React.FC<PropertyEditorProps> = ({ name, meta, value, onChange }) => {
const displayName = name.replace(/^u_/, '');
const inputStyle: React.CSSProperties = {
backgroundColor: '#2a2a2a',
color: '#e0e0e0',
border: '1px solid #4a4a4a',
borderRadius: '3px',
padding: '2px 6px',
fontSize: '11px',
width: '60px'
};
const renderInput = () => {
switch (meta.type) {
case 'float':
case 'int':
return (
<input
type="number"
value={typeof value === 'number' ? value : 0}
min={meta.min}
max={meta.max}
step={meta.step ?? (meta.type === 'int' ? 1 : 0.01)}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
style={inputStyle}
/>
);
case 'vec2':
const v2 = Array.isArray(value) ? value : [0, 0];
const v2x = v2[0] ?? 0;
const v2y = v2[1] ?? 0;
return (
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
value={v2x}
step={meta.step ?? 0.01}
onChange={(e) => onChange([parseFloat(e.target.value) || 0, v2y])}
style={{ ...inputStyle, width: '50px' }}
/>
<input
type="number"
value={v2y}
step={meta.step ?? 0.01}
onChange={(e) => onChange([v2x, parseFloat(e.target.value) || 0])}
style={{ ...inputStyle, width: '50px' }}
/>
</div>
);
case 'vec3':
const v3 = Array.isArray(value) ? value : [0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2].map(i => (
<input
key={i}
type="number"
value={v3[i]}
step={meta.step ?? 0.01}
onChange={(e) => {
const newVal = [...v3];
newVal[i] = parseFloat(e.target.value) || 0;
onChange(newVal);
}}
style={{ ...inputStyle, width: '40px' }}
/>
))}
</div>
);
case 'vec4':
const v4 = Array.isArray(value) ? value : [0, 0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2, 3].map(i => (
<input
key={i}
type="number"
value={v4[i]}
step={meta.step ?? 0.01}
onChange={(e) => {
const newVal = [...v4];
newVal[i] = parseFloat(e.target.value) || 0;
onChange(newVal);
}}
style={{ ...inputStyle, width: '35px' }}
/>
))}
</div>
);
case 'color':
const c = Array.isArray(value) ? value : [1, 1, 1, 1];
const cr = c[0] ?? 1;
const cg = c[1] ?? 1;
const cb = c[2] ?? 1;
const ca = c[3] ?? 1;
const hexColor = `#${Math.round(cr * 255).toString(16).padStart(2, '0')}${Math.round(cg * 255).toString(16).padStart(2, '0')}${Math.round(cb * 255).toString(16).padStart(2, '0')}`;
return (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="color"
value={hexColor}
onChange={(e) => {
const hex = e.target.value;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
onChange([r, g, b, ca]);
}}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
<input
type="number"
value={ca}
min={0}
max={1}
step={0.01}
onChange={(e) => onChange([cr, cg, cb, parseFloat(e.target.value) || 1])}
style={{ ...inputStyle, width: '40px' }}
title="Alpha"
/>
</div>
);
default:
return <span style={{ color: '#666' }}>Unsupported type: {meta.type}</span>;
}
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '3px 0',
borderBottom: '1px solid #333'
}}>
<span style={{ color: '#aaa' }} title={meta.tooltip}>
{displayName}
</span>
{renderInput()}
</div>
);
};
export default MaterialPropertiesEditor;

View File

@@ -0,0 +1,6 @@
/**
* Material Inspector components.
* 材质 Inspector 组件。
*/
export { MaterialPropertiesEditor } from './MaterialPropertiesEditor';

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { AssetRegistryService } from '@esengine/editor-core';
import { AssetRegistryService, MessageHub } from '@esengine/editor-core';
import type { ISpriteSettings } from '@esengine/asset-system-editor';
import { EngineService } from '../../../services/EngineService';
import { AssetFileInfo } from '../types';
@@ -315,6 +315,18 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
setSpriteSettings(newSettings);
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
// 通知 EngineService 同步资产数据库(以便渲染系统获取最新的九宫格设置)
// Notify EngineService to sync asset database (so render systems get latest sprite settings)
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('assets:changed', {
type: 'modify',
path: fileInfo.path,
relativePath: assetRegistry.absoluteToRelative(fileInfo.path) || fileInfo.path,
guid: meta.guid
});
}
} catch (error) {
console.error('Failed to update sprite settings:', error);
} finally {