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:
@@ -162,6 +162,22 @@ function App() {
|
||||
const [commandManager] = useState(() => new CommandManager());
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
|
||||
// Play 模式状态(用于层级面板实时同步)
|
||||
// Play mode state (for hierarchy panel real-time sync)
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// 监听 Play 状态变化
|
||||
// Listen for play state changes
|
||||
useEffect(() => {
|
||||
if (!messageHubRef.current || !initialized) return;
|
||||
|
||||
const unsubscribe = messageHubRef.current.subscribe('viewport:playState:changed', (data: { isPlaying: boolean }) => {
|
||||
setIsPlaying(data.isPlaying);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [initialized]);
|
||||
|
||||
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
|
||||
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
|
||||
useStoreSubscriptions({
|
||||
@@ -169,6 +185,7 @@ function App() {
|
||||
entityStore: entityStoreRef.current,
|
||||
sceneManager: sceneManagerRef.current,
|
||||
enabled: initialized,
|
||||
isPlaying,
|
||||
});
|
||||
|
||||
// 同步 locale 到 TauriDialogService
|
||||
|
||||
@@ -77,7 +77,8 @@ import {
|
||||
Vector3FieldEditor,
|
||||
Vector4FieldEditor,
|
||||
ColorFieldEditor,
|
||||
AnimationClipsFieldEditor
|
||||
AnimationClipsFieldEditor,
|
||||
EntityRefFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||
@@ -249,6 +250,7 @@ export class ServiceRegistry {
|
||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
fieldEditorRegistry.register(new EntityRefFieldEditor());
|
||||
|
||||
// 注册组件检查器
|
||||
// Register component inspectors
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Material Inspector components.
|
||||
* 材质 Inspector 组件。
|
||||
*/
|
||||
|
||||
export { MaterialPropertiesEditor } from './MaterialPropertiesEditor';
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,8 @@ interface UseStoreSubscriptionsOptions {
|
||||
entityStore: EntityStoreService | null;
|
||||
sceneManager: SceneManagerService | null;
|
||||
enabled: boolean;
|
||||
/** 是否处于 Play 模式 | Whether in play mode */
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,8 +37,10 @@ export function useStoreSubscriptions({
|
||||
entityStore,
|
||||
sceneManager,
|
||||
enabled,
|
||||
isPlaying = false,
|
||||
}: UseStoreSubscriptionsOptions): void {
|
||||
const initializedRef = useRef(false);
|
||||
const lastEntityCountRef = useRef(0);
|
||||
|
||||
// ===== HierarchyStore 订阅 | HierarchyStore subscriptions =====
|
||||
useEffect(() => {
|
||||
@@ -68,9 +72,38 @@ export function useStoreSubscriptions({
|
||||
};
|
||||
|
||||
// 处理实体选择 | Handle entity selection
|
||||
// Also expand parent nodes so selected entity is visible
|
||||
// 同时展开父节点以便选中的实体可见
|
||||
const handleEntitySelection = (data: { entity: { id: number } | null }) => {
|
||||
if (data.entity) {
|
||||
setSelectedIds(new Set([data.entity.id]));
|
||||
|
||||
// Expand all ancestor nodes | 展开所有祖先节点
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
const entity = scene.entities.findEntityById(data.entity.id);
|
||||
if (entity) {
|
||||
const ancestorIds: number[] = [];
|
||||
// Use HierarchyComponent to get parent chain
|
||||
// 使用 HierarchyComponent 获取父节点链
|
||||
let currentEntity = entity;
|
||||
let hierarchy = currentEntity.getComponent(HierarchyComponent);
|
||||
while (hierarchy?.parentId != null) {
|
||||
ancestorIds.push(hierarchy.parentId);
|
||||
const parentEntity = scene.entities.findEntityById(hierarchy.parentId);
|
||||
if (!parentEntity) break;
|
||||
currentEntity = parentEntity;
|
||||
hierarchy = currentEntity.getComponent(HierarchyComponent);
|
||||
}
|
||||
if (ancestorIds.length > 0) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestorIds.forEach(id => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
@@ -129,7 +162,25 @@ export function useStoreSubscriptions({
|
||||
});
|
||||
const unsubSaved = messageHub.subscribe('scene:saved', updateSceneInfo);
|
||||
const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo);
|
||||
const unsubRestored = messageHub.subscribe('scene:restored', updateEntities);
|
||||
// scene:restored 在 Stop 时触发,需要同时更新场景信息和实体列表
|
||||
// scene:restored is triggered on Stop, needs to update both scene info and entities
|
||||
const unsubRestored = messageHub.subscribe('scene:restored', () => {
|
||||
updateSceneInfo();
|
||||
updateEntities();
|
||||
});
|
||||
|
||||
// 订阅运行时场景切换事件(Play 模式下的场景切换)
|
||||
// Subscribe to runtime scene change event (scene switching in Play mode)
|
||||
const unsubRuntimeSceneChanged = messageHub.subscribe('runtime:scene:changed', (data: any) => {
|
||||
if (data.sceneName) {
|
||||
setSceneInfo({
|
||||
sceneName: `[Play] ${data.sceneName}`,
|
||||
sceneFilePath: data.path || null,
|
||||
isModified: false,
|
||||
});
|
||||
}
|
||||
updateEntities();
|
||||
});
|
||||
|
||||
// 订阅实体事件 | Subscribe to entity events
|
||||
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
|
||||
@@ -150,6 +201,7 @@ export function useStoreSubscriptions({
|
||||
unsubSaved();
|
||||
unsubModified();
|
||||
unsubRestored();
|
||||
unsubRuntimeSceneChanged();
|
||||
unsubAdd();
|
||||
unsubRemove();
|
||||
unsubClear();
|
||||
@@ -348,4 +400,43 @@ export function useStoreSubscriptions({
|
||||
unsubPropertyChanged();
|
||||
};
|
||||
}, [enabled, messageHub]);
|
||||
|
||||
// ===== Play 模式实时同步 | Play mode real-time sync =====
|
||||
// 在 Play 模式下定期检查场景实体变化,同步到层级面板
|
||||
// Periodically check scene entity changes in play mode and sync to hierarchy panel
|
||||
useEffect(() => {
|
||||
if (!enabled || !entityStore || !isPlaying) return;
|
||||
|
||||
const { setEntities } = useHierarchyStore.getState();
|
||||
|
||||
// 同步实体列表(检查是否有变化)
|
||||
// Sync entity list (check for changes)
|
||||
const syncEntities = () => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const currentCount = scene.entities.count;
|
||||
|
||||
// 只有实体数量变化时才同步(性能优化)
|
||||
// Only sync when entity count changes (performance optimization)
|
||||
if (currentCount !== lastEntityCountRef.current) {
|
||||
lastEntityCountRef.current = currentCount;
|
||||
entityStore.syncFromScene();
|
||||
setEntities([...entityStore.getRootEntities()]);
|
||||
}
|
||||
};
|
||||
|
||||
// 每 500ms 检查一次(Play 模式下足够实时)
|
||||
// Check every 500ms (real-time enough for play mode)
|
||||
const intervalId = setInterval(syncEntities, 500);
|
||||
|
||||
// 立即同步一次
|
||||
// Sync immediately
|
||||
syncEntities();
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
lastEntityCountRef.current = 0;
|
||||
};
|
||||
}, [enabled, entityStore, isPlaying]);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zh from './locales/zh.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: 'zh',
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"hierarchy": {
|
||||
"visibility": "Toggle Visibility",
|
||||
"hideEntity": "Hide Entity",
|
||||
"showEntity": "Show Entity",
|
||||
"emptyHint": "No entities in scene"
|
||||
},
|
||||
"behaviorTree": {
|
||||
"title": "Behavior Tree Editor",
|
||||
"close": "Close",
|
||||
"nodePalette": "Node Palette",
|
||||
"properties": "Properties",
|
||||
"blackboard": "Blackboard",
|
||||
"noNodeSelected": "No node selected",
|
||||
"noConfigurableProperties": "This node has no configurable properties",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"addVariable": "Add Variable",
|
||||
"variableName": "Variable Name",
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"defaultGroup": "Default Group",
|
||||
"rootNode": "Root Node",
|
||||
"rootNodeOnlyOneChild": "Root node can only connect to one child",
|
||||
"dragToCreate": "Drag nodes from the left to below the root node to start creating behavior tree",
|
||||
"connectFirst": "Connect the root node with the first node first",
|
||||
"nodeCount": "Nodes",
|
||||
"noSelection": "No selection",
|
||||
"selectedCount": "{{count}} nodes selected",
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"step": "Step",
|
||||
"run": "Run",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop",
|
||||
"stepExecution": "Step Execution",
|
||||
"resetExecution": "Reset",
|
||||
"clear": "Clear",
|
||||
"resetView": "Reset View",
|
||||
"tick": "Tick",
|
||||
"executing": "Executing",
|
||||
"success": "Success",
|
||||
"failure": "Failure",
|
||||
"startingExecution": "Starting execution from root...",
|
||||
"tickNumber": "Tick {{tick}}",
|
||||
"executionStopped": "Execution stopped after {{tick}} ticks",
|
||||
"executionPaused": "Execution paused",
|
||||
"executionResumed": "Execution resumed",
|
||||
"resetToInitial": "Reset to initial state",
|
||||
"currentValue": "Current Value"
|
||||
},
|
||||
"components": {
|
||||
"category": {
|
||||
"core": "Core",
|
||||
"rendering": "Rendering",
|
||||
"physics": "Physics",
|
||||
"audio": "Audio",
|
||||
"tilemap": "Tilemap"
|
||||
},
|
||||
"material": {
|
||||
"name": "Material",
|
||||
"description": "Custom material and shader component"
|
||||
},
|
||||
"transform": {
|
||||
"description": "Transform - Position, Rotation, Scale"
|
||||
},
|
||||
"sprite": {
|
||||
"description": "Sprite - 2D Image Rendering"
|
||||
},
|
||||
"text": {
|
||||
"description": "Text - Text Rendering"
|
||||
},
|
||||
"camera": {
|
||||
"description": "Camera - View Control"
|
||||
},
|
||||
"rigidBody": {
|
||||
"description": "RigidBody - Physics Simulation"
|
||||
},
|
||||
"boxCollider": {
|
||||
"description": "Box Collider"
|
||||
},
|
||||
"circleCollider": {
|
||||
"description": "Circle Collider"
|
||||
},
|
||||
"audioSource": {
|
||||
"description": "Audio Source"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "Material",
|
||||
"shader": "Shader"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "Material Entity"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"hierarchy": {
|
||||
"visibility": "切换可见性",
|
||||
"hideEntity": "隐藏实体",
|
||||
"showEntity": "显示实体",
|
||||
"emptyHint": "场景中没有实体"
|
||||
},
|
||||
"behaviorTree": {
|
||||
"title": "行为树编辑器",
|
||||
"close": "关闭",
|
||||
"nodePalette": "节点面板",
|
||||
"properties": "属性",
|
||||
"blackboard": "黑板",
|
||||
"noNodeSelected": "未选择节点",
|
||||
"noConfigurableProperties": "此节点没有可配置的属性",
|
||||
"apply": "应用",
|
||||
"reset": "重置",
|
||||
"addVariable": "添加变量",
|
||||
"variableName": "变量名",
|
||||
"type": "类型",
|
||||
"value": "值",
|
||||
"defaultGroup": "默认分组",
|
||||
"rootNode": "根节点",
|
||||
"rootNodeOnlyOneChild": "根节点只能连接一个子节点",
|
||||
"dragToCreate": "从左侧拖拽节点到根节点下方开始创建行为树",
|
||||
"connectFirst": "先连接根节点与第一个节点",
|
||||
"nodeCount": "节点数",
|
||||
"noSelection": "未选择节点",
|
||||
"selectedCount": "已选择 {{count}} 个节点",
|
||||
"idle": "空闲",
|
||||
"running": "运行中",
|
||||
"paused": "已暂停",
|
||||
"step": "单步",
|
||||
"run": "运行",
|
||||
"pause": "暂停",
|
||||
"resume": "继续",
|
||||
"stop": "停止",
|
||||
"stepExecution": "单步执行",
|
||||
"resetExecution": "重置",
|
||||
"clear": "清空",
|
||||
"resetView": "重置视图",
|
||||
"tick": "帧",
|
||||
"executing": "执行中",
|
||||
"success": "成功",
|
||||
"failure": "失败",
|
||||
"startingExecution": "从根节点开始执行...",
|
||||
"tickNumber": "第 {{tick}} 帧",
|
||||
"executionStopped": "执行停止,共 {{tick}} 帧",
|
||||
"executionPaused": "执行已暂停",
|
||||
"executionResumed": "执行已恢复",
|
||||
"resetToInitial": "重置到初始状态",
|
||||
"currentValue": "当前值"
|
||||
},
|
||||
"components": {
|
||||
"category": {
|
||||
"core": "基础",
|
||||
"rendering": "渲染",
|
||||
"physics": "物理",
|
||||
"audio": "音频",
|
||||
"tilemap": "瓦片地图"
|
||||
},
|
||||
"material": {
|
||||
"name": "材质",
|
||||
"description": "自定义材质和着色器组件"
|
||||
},
|
||||
"transform": {
|
||||
"description": "变换组件 - 位置、旋转、缩放"
|
||||
},
|
||||
"sprite": {
|
||||
"description": "精灵组件 - 2D图像渲染"
|
||||
},
|
||||
"text": {
|
||||
"description": "文本组件 - 文本渲染"
|
||||
},
|
||||
"camera": {
|
||||
"description": "相机组件 - 视图控制"
|
||||
},
|
||||
"rigidBody": {
|
||||
"description": "刚体组件 - 物理模拟"
|
||||
},
|
||||
"boxCollider": {
|
||||
"description": "盒型碰撞器"
|
||||
},
|
||||
"circleCollider": {
|
||||
"description": "圆形碰撞器"
|
||||
},
|
||||
"audioSource": {
|
||||
"description": "音频源组件"
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"create": {
|
||||
"material": "材质",
|
||||
"shader": "着色器"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"create": {
|
||||
"materialEntity": "材质实体"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Entity Reference Field Editor
|
||||
* 实体引用字段编辑器
|
||||
*
|
||||
* Handles editing of entity reference fields with drag-and-drop support.
|
||||
* 处理实体引用字段的编辑,支持拖放操作。
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
|
||||
import { EntityRefField } from '../../components/inspectors/fields/EntityRefField';
|
||||
|
||||
/**
|
||||
* Field editor for entity references (entity IDs)
|
||||
* 实体引用(实体 ID)的字段编辑器
|
||||
*
|
||||
* Supports:
|
||||
* - Drag-and-drop entities from SceneHierarchy
|
||||
* - Click to navigate to referenced entity
|
||||
* - Clear button to remove reference
|
||||
*
|
||||
* 支持:
|
||||
* - 从场景层级面板拖放实体
|
||||
* - 点击导航到引用的实体
|
||||
* - 清除按钮移除引用
|
||||
*/
|
||||
export class EntityRefFieldEditor implements IFieldEditor<number> {
|
||||
readonly type = 'entityRef';
|
||||
readonly name = 'Entity Reference Field Editor';
|
||||
readonly priority = 100;
|
||||
|
||||
/**
|
||||
* Check if this editor can handle the given field type
|
||||
* 检查此编辑器是否可以处理给定的字段类型
|
||||
*/
|
||||
canHandle(fieldType: string): boolean {
|
||||
return fieldType === 'entityRef' ||
|
||||
fieldType === 'entityReference' ||
|
||||
fieldType === 'EntityRef' ||
|
||||
fieldType.endsWith('EntityId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entity reference field
|
||||
* 渲染实体引用字段
|
||||
*/
|
||||
render({ label, value, onChange, context }: FieldEditorProps<number>): React.ReactElement {
|
||||
const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here';
|
||||
|
||||
return (
|
||||
<EntityRefField
|
||||
label={label}
|
||||
value={value ?? 0}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
readonly={context.readonly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './AssetFieldEditor';
|
||||
export * from './VectorFieldEditors';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './AnimationClipsFieldEditor';
|
||||
export * from './EntityRefFieldEditor';
|
||||
|
||||
@@ -1223,6 +1223,32 @@ export const en: Translations = {
|
||||
label: 'Module List',
|
||||
description: 'Uncheck modules you do not need. Core modules cannot be disabled. New modules are enabled by default.'
|
||||
}
|
||||
},
|
||||
dynamicAtlas: {
|
||||
title: 'Dynamic Atlas',
|
||||
description: 'Runtime atlas configuration for UI batching optimization',
|
||||
enabled: {
|
||||
label: 'Enable Dynamic Atlas',
|
||||
description: 'Enable runtime dynamic atlas to reduce Draw Calls'
|
||||
},
|
||||
expansionStrategy: {
|
||||
label: 'Expansion Strategy',
|
||||
description: 'Choose how the atlas expands',
|
||||
fixed: 'Fixed Size (No rebuild cost)',
|
||||
dynamic: 'Dynamic Expansion (Better memory efficiency)'
|
||||
},
|
||||
fixedPageSize: {
|
||||
label: 'Page Size',
|
||||
description: 'Size of each atlas page in fixed mode'
|
||||
},
|
||||
maxPages: {
|
||||
label: 'Max Pages',
|
||||
description: 'Maximum number of atlas pages allowed'
|
||||
},
|
||||
maxTextureSize: {
|
||||
label: 'Max Texture Size',
|
||||
description: 'Maximum size of individual textures that can be added to the atlas'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1139,6 +1139,32 @@ export const es: Translations = {
|
||||
label: 'Lista de Módulos',
|
||||
description: 'Desmarcar módulos que no necesitas. Los módulos principales no se pueden deshabilitar. Los nuevos módulos se habilitan por defecto.'
|
||||
}
|
||||
},
|
||||
dynamicAtlas: {
|
||||
title: 'Atlas Dinámico',
|
||||
description: 'Configuración de atlas en tiempo de ejecución para optimización de batching de UI',
|
||||
enabled: {
|
||||
label: 'Habilitar Atlas Dinámico',
|
||||
description: 'Habilitar atlas dinámico en tiempo de ejecución para reducir Draw Calls'
|
||||
},
|
||||
expansionStrategy: {
|
||||
label: 'Estrategia de Expansión',
|
||||
description: 'Elegir cómo se expande el atlas',
|
||||
fixed: 'Tamaño Fijo (Sin costo de reconstrucción)',
|
||||
dynamic: 'Expansión Dinámica (Mejor eficiencia de memoria)'
|
||||
},
|
||||
fixedPageSize: {
|
||||
label: 'Tamaño de Página',
|
||||
description: 'Tamaño de cada página del atlas en modo fijo'
|
||||
},
|
||||
maxPages: {
|
||||
label: 'Páginas Máximas',
|
||||
description: 'Número máximo de páginas de atlas permitidas'
|
||||
},
|
||||
maxTextureSize: {
|
||||
label: 'Tamaño Máximo de Textura',
|
||||
description: 'Tamaño máximo de texturas individuales que pueden añadirse al atlas'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1223,6 +1223,32 @@ export const zh: Translations = {
|
||||
label: '模块列表',
|
||||
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。'
|
||||
}
|
||||
},
|
||||
dynamicAtlas: {
|
||||
title: '动态图集',
|
||||
description: '运行时图集配置,用于 UI 合批优化',
|
||||
enabled: {
|
||||
label: '启用动态图集',
|
||||
description: '启用运行时动态图集以减少 Draw Call'
|
||||
},
|
||||
expansionStrategy: {
|
||||
label: '扩展策略',
|
||||
description: '选择图集的扩展方式',
|
||||
fixed: '固定大小(无重建开销)',
|
||||
dynamic: '动态扩展(内存效率更高)'
|
||||
},
|
||||
fixedPageSize: {
|
||||
label: '页面大小',
|
||||
description: '固定模式下每个图集页面的大小'
|
||||
},
|
||||
maxPages: {
|
||||
label: '最大页数',
|
||||
description: '允许的最大图集页面数量'
|
||||
},
|
||||
maxTextureSize: {
|
||||
label: '最大纹理尺寸',
|
||||
description: '可加入图集的最大单个纹理尺寸,超过此尺寸的纹理将不会被合批'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
import './styles/index.css';
|
||||
import './i18n/config';
|
||||
|
||||
// Set log level to Warn in production to reduce console noise
|
||||
setGlobalLogLevel(LogLevel.Warn);
|
||||
|
||||
@@ -136,6 +136,74 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
}
|
||||
} as any // Cast to any to allow custom props
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'dynamic-atlas',
|
||||
title: '$pluginSettings.project.dynamicAtlas.title',
|
||||
description: '$pluginSettings.project.dynamicAtlas.description',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.dynamicAtlas.enabled',
|
||||
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.expansionStrategy',
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
|
||||
type: 'select',
|
||||
defaultValue: 'fixed',
|
||||
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
|
||||
options: [
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
|
||||
value: 'fixed'
|
||||
},
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
|
||||
value: 'dynamic'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.fixedPageSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 1024,
|
||||
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
|
||||
options: [
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 },
|
||||
{ label: '2048 x 2048', value: 2048 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxPages',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
|
||||
type: 'select',
|
||||
defaultValue: 4,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
|
||||
options: [
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '8', value: 8 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxTextureSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 512,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
|
||||
options: [
|
||||
{ label: '256 x 256', value: 256 },
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -172,11 +240,35 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
logger.info('UI design resolution changed, applying...');
|
||||
this.applyUIDesignResolution();
|
||||
}
|
||||
|
||||
// Check if dynamic atlas settings changed
|
||||
// 检查动态图集设置是否更改
|
||||
if ('project.dynamicAtlas.enabled' in changedSettings ||
|
||||
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
|
||||
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
|
||||
'project.dynamicAtlas.maxPages' in changedSettings ||
|
||||
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
|
||||
|
||||
logger.info('Dynamic atlas settings changed, reinitializing...');
|
||||
this.applyDynamicAtlasSettings();
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', this.settingsListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dynamic atlas settings
|
||||
* 应用动态图集设置
|
||||
*/
|
||||
private applyDynamicAtlasSettings(): void {
|
||||
const engineService = EngineService.getInstance();
|
||||
if (engineService.isInitialized()) {
|
||||
engineService.reinitializeDynamicAtlas();
|
||||
logger.info('Dynamic atlas settings applied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI design resolution from ProjectService
|
||||
* 从 ProjectService 应用 UI 设计分辨率
|
||||
|
||||
149
packages/editor-app/src/services/EditorAssetFileLoader.ts
Normal file
149
packages/editor-app/src/services/EditorAssetFileLoader.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Editor Asset File Loader
|
||||
* 编辑器资产文件加载器
|
||||
*
|
||||
* Platform-specific implementation of IAssetFileLoader for Tauri editor.
|
||||
* Combines path resolution with TauriAssetReader for unified asset loading.
|
||||
* Tauri 编辑器的 IAssetFileLoader 平台特定实现。
|
||||
* 结合路径解析和 TauriAssetReader 实现统一的资产加载。
|
||||
*/
|
||||
|
||||
import type { IAssetFileLoader, IAssetReader } from '@esengine/asset-system';
|
||||
|
||||
/**
|
||||
* Configuration for EditorAssetFileLoader.
|
||||
* EditorAssetFileLoader 配置。
|
||||
*/
|
||||
export interface EditorAssetFileLoaderConfig {
|
||||
/**
|
||||
* Function to get current project path.
|
||||
* 获取当前项目路径的函数。
|
||||
*/
|
||||
getProjectPath: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor asset file loader implementation.
|
||||
* 编辑器资产文件加载器实现。
|
||||
*
|
||||
* This loader combines:
|
||||
* - Path resolution: converts relative asset paths to absolute paths
|
||||
* - Platform reading: uses IAssetReader (TauriAssetReader) for actual file loading
|
||||
*
|
||||
* 此加载器结合:
|
||||
* - 路径解析:将相对资产路径转换为绝对路径
|
||||
* - 平台读取:使用 IAssetReader (TauriAssetReader) 进行实际文件加载
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const loader = new EditorAssetFileLoader(assetReader, {
|
||||
* getProjectPath: () => projectService.getCurrentProject()?.path
|
||||
* });
|
||||
*
|
||||
* // Load from relative asset path
|
||||
* const image = await loader.loadImage('assets/demo/button.png');
|
||||
* ```
|
||||
*/
|
||||
export class EditorAssetFileLoader implements IAssetFileLoader {
|
||||
/**
|
||||
* Create a new editor asset file loader.
|
||||
* 创建新的编辑器资产文件加载器。
|
||||
*
|
||||
* @param assetReader - Platform-specific asset reader (e.g., TauriAssetReader).
|
||||
* 平台特定的资产读取器。
|
||||
* @param config - Loader configuration. | 加载器配置。
|
||||
*/
|
||||
constructor(
|
||||
private readonly assetReader: IAssetReader,
|
||||
private readonly config: EditorAssetFileLoaderConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load image from asset path.
|
||||
* 从资产路径加载图片。
|
||||
*/
|
||||
async loadImage(assetPath: string): Promise<HTMLImageElement> {
|
||||
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||
return this.assetReader.loadImage(absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load text content from asset path.
|
||||
* 从资产路径加载文本内容。
|
||||
*/
|
||||
async loadText(assetPath: string): Promise<string> {
|
||||
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||
return this.assetReader.readText(absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load binary data from asset path.
|
||||
* 从资产路径加载二进制数据。
|
||||
*/
|
||||
async loadBinary(assetPath: string): Promise<ArrayBuffer> {
|
||||
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||
return this.assetReader.readBinary(absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if asset file exists.
|
||||
* 检查资产文件是否存在。
|
||||
*/
|
||||
async exists(assetPath: string): Promise<boolean> {
|
||||
const absolutePath = this.resolveToAbsolutePath(assetPath);
|
||||
return this.assetReader.exists(absolutePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative asset path to absolute file system path.
|
||||
* 将相对资产路径解析为绝对文件系统路径。
|
||||
*
|
||||
* @param assetPath - Relative asset path (e.g., "assets/demo/button.png").
|
||||
* 相对资产路径。
|
||||
* @returns Absolute file system path. | 绝对文件系统路径。
|
||||
*/
|
||||
private resolveToAbsolutePath(assetPath: string): string {
|
||||
// Already an absolute path or URL - return as-is
|
||||
// 已经是绝对路径或 URL - 直接返回
|
||||
if (this.isAbsoluteOrUrl(assetPath)) {
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
// Get project path and combine with asset path
|
||||
// 获取项目路径并与资产路径组合
|
||||
const projectPath = this.config.getProjectPath();
|
||||
if (!projectPath) {
|
||||
// No project open, return original path
|
||||
// 没有打开项目,返回原始路径
|
||||
console.warn('[EditorAssetFileLoader] No project open, cannot resolve path:', assetPath);
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
// Determine separator based on project path format
|
||||
// 根据项目路径格式确定分隔符
|
||||
const separator = projectPath.includes('\\') ? '\\' : '/';
|
||||
|
||||
// Normalize asset path separators to match project path
|
||||
// 规范化资产路径分隔符以匹配项目路径
|
||||
const normalizedAssetPath = assetPath.replace(/\//g, separator);
|
||||
|
||||
// Combine paths
|
||||
// 组合路径
|
||||
return `${projectPath}${separator}${normalizedAssetPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is already absolute or a URL.
|
||||
* 检查路径是否已经是绝对路径或 URL。
|
||||
*/
|
||||
private isAbsoluteOrUrl(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('http://') ||
|
||||
path.startsWith('https://') ||
|
||||
path.startsWith('data:') ||
|
||||
path.startsWith('asset://') ||
|
||||
path.startsWith('/') ||
|
||||
/^[a-zA-Z]:/.test(path) // Windows absolute path (e.g., "C:\...")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,24 @@
|
||||
* Uses the unified GameRuntime architecture
|
||||
*/
|
||||
|
||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, type SystemContext } from '@esengine/editor-core';
|
||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, GizmoInteractionService, GizmoInteractionServiceToken, type SystemContext } from '@esengine/editor-core';
|
||||
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, PluginServiceRegistry } from '@esengine/ecs-framework';
|
||||
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
|
||||
import {
|
||||
invalidateUIRenderCaches,
|
||||
UIRenderProviderToken,
|
||||
UIInputSystemToken,
|
||||
initializeDynamicAtlasService,
|
||||
reinitializeDynamicAtlasService,
|
||||
registerTexturePathMapping,
|
||||
AtlasExpansionStrategy,
|
||||
type IAtlasEngineBridge,
|
||||
type DynamicAtlasConfig
|
||||
} from '@esengine/ui';
|
||||
import { SettingsService } from './SettingsService';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
AssetManager,
|
||||
@@ -22,8 +33,12 @@ import {
|
||||
SceneResourceManager,
|
||||
AssetType,
|
||||
AssetManagerToken,
|
||||
isValidGUID
|
||||
isValidGUID,
|
||||
setGlobalAssetDatabase,
|
||||
setGlobalEngineBridge,
|
||||
setGlobalAssetFileLoader
|
||||
} from '@esengine/asset-system';
|
||||
import { EditorAssetFileLoader } from './EditorAssetFileLoader';
|
||||
import {
|
||||
GameRuntime,
|
||||
createGameRuntime,
|
||||
@@ -56,6 +71,7 @@ export class EngineService {
|
||||
private _modulesInitialized = false;
|
||||
private _running = false;
|
||||
private _canvasId: string | null = null;
|
||||
private _gizmoInteractionService: GizmoInteractionService | null = null;
|
||||
|
||||
// 资产系统相关
|
||||
private _assetManager: AssetManager | null = null;
|
||||
@@ -68,6 +84,9 @@ export class EngineService {
|
||||
// 编辑器相机状态(用于恢复)
|
||||
private _editorCameraState = { x: 0, y: 0, zoom: 1 };
|
||||
|
||||
// 当前选中的实体 IDs(用于高亮)| Currently selected entity IDs (for highlighting)
|
||||
private _selectedEntityIds: number[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
@@ -146,6 +165,13 @@ export class EngineService {
|
||||
|
||||
await this._runtime.initialize();
|
||||
|
||||
// 设置 MaterialManager 的引擎桥接(上传内置 shader 到 GPU)
|
||||
// Set engine bridge for MaterialManager (upload built-in shaders to GPU)
|
||||
const materialManager = getMaterialManager();
|
||||
if (materialManager && this._runtime.bridge) {
|
||||
materialManager.setEngineBridge(this._runtime.bridge);
|
||||
}
|
||||
|
||||
// 启用性能分析器(编辑器模式默认启用)
|
||||
ProfilerSDK.setEnabled(true);
|
||||
|
||||
@@ -157,6 +183,21 @@ export class EngineService {
|
||||
GizmoRegistry.hasProvider(component.constructor as any)
|
||||
);
|
||||
|
||||
// 初始化 Gizmo 交互服务
|
||||
// Initialize Gizmo Interaction Service
|
||||
this._gizmoInteractionService = new GizmoInteractionService();
|
||||
Core.pluginServices.register(GizmoInteractionServiceToken, this._gizmoInteractionService);
|
||||
|
||||
// 设置 Gizmo 交互函数到渲染系统
|
||||
// Set gizmo interaction functions to render system
|
||||
if (this._runtime.renderSystem) {
|
||||
this._runtime.renderSystem.setGizmoInteraction(
|
||||
(entityId: number, baseColor: { r: number; g: number; b: number; a: number }, isSelected: boolean) =>
|
||||
this._gizmoInteractionService!.getHighlightColor(entityId, baseColor, isSelected),
|
||||
() => this._gizmoInteractionService!.getHoveredEntityId()
|
||||
);
|
||||
}
|
||||
|
||||
// 初始化资产系统
|
||||
await this._initializeAssetSystem();
|
||||
|
||||
@@ -437,6 +478,22 @@ export class EngineService {
|
||||
// 将 AssetRegistryService 的数据同步到 assetManager 的数据库
|
||||
await this._syncAssetRegistryToManager();
|
||||
|
||||
// 设置全局资产数据库(供渲染系统查询 sprite 元数据)
|
||||
// Set global asset database (for render systems to query sprite metadata)
|
||||
setGlobalAssetDatabase(this._assetManager.getDatabase());
|
||||
|
||||
// 设置全局资产文件加载器(供动态图集服务等使用)
|
||||
// Set global asset file loader (for DynamicAtlasService etc.)
|
||||
const editorAssetFileLoader = new EditorAssetFileLoader(assetReader, {
|
||||
getProjectPath: () => {
|
||||
if (projectService && projectService.isProjectOpen()) {
|
||||
return projectService.getCurrentProject()?.path ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
setGlobalAssetFileLoader(editorAssetFileLoader);
|
||||
|
||||
const pathTransformerFn = (path: string) => {
|
||||
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
||||
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
||||
@@ -461,6 +518,33 @@ export class EngineService {
|
||||
});
|
||||
|
||||
if (this._runtime?.bridge) {
|
||||
// 为 EngineBridge 设置路径解析器(用于 getTextureInfoByPath 等方法)
|
||||
// Set path resolver for EngineBridge (for getTextureInfoByPath etc.)
|
||||
this._runtime.bridge.setPathResolver((assetPath: string) => {
|
||||
// 空路径直接返回
|
||||
if (!assetPath) return assetPath;
|
||||
|
||||
// 已经是 URL 则直接返回
|
||||
if (assetPath.startsWith('http://') ||
|
||||
assetPath.startsWith('https://') ||
|
||||
assetPath.startsWith('data:') ||
|
||||
assetPath.startsWith('asset://')) {
|
||||
return assetPath;
|
||||
}
|
||||
|
||||
// 使用 pathTransformerFn 转换路径为 Tauri URL
|
||||
let fullPath = assetPath;
|
||||
// 如果路径不以 'assets/' 开头,添加前缀
|
||||
if (!assetPath.startsWith('assets/') && !assetPath.startsWith('assets\\')) {
|
||||
fullPath = `assets/${assetPath}`;
|
||||
}
|
||||
return pathTransformerFn(fullPath);
|
||||
});
|
||||
|
||||
// 设置全局引擎桥(供渲染系统查询纹理尺寸 - 唯一事实来源)
|
||||
// Set global engine bridge (for render systems to query texture dimensions - single source of truth)
|
||||
setGlobalEngineBridge(this._runtime.bridge);
|
||||
|
||||
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
|
||||
|
||||
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
|
||||
@@ -503,6 +587,58 @@ export class EngineService {
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
// 初始化动态图集服务(用于 UI 合批)
|
||||
// Initialize dynamic atlas service (for UI batching)
|
||||
const bridge = this._runtime.bridge;
|
||||
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (atlasEnabled) {
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
initializeDynamicAtlasService(atlasBridge, {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
|
||||
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
|
||||
maxPageSize: 2048, // 最大页面大小 | Max page size
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
});
|
||||
}
|
||||
|
||||
// 注册纹理加载回调,当纹理加载时自动注册路径映射
|
||||
// Register texture load callback to register path mapping when textures load
|
||||
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
|
||||
registerTexturePathMapping(guid, path);
|
||||
});
|
||||
}
|
||||
|
||||
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
|
||||
@@ -570,6 +706,13 @@ export class EngineService {
|
||||
// 1. Check for explicit loaderType in .meta file (user override)
|
||||
// 1. 检查 .meta 文件中的显式 loaderType(用户覆盖)
|
||||
const meta = metaManager.getMetaByGUID(asset.guid);
|
||||
|
||||
// Debug: log meta for textures with importSettings
|
||||
// 调试:记录有 importSettings 的纹理 meta
|
||||
if (meta?.importSettings?.spriteSettings) {
|
||||
console.log(`[EngineService] Syncing asset with spriteSettings: ${asset.path}`, meta.importSettings.spriteSettings);
|
||||
}
|
||||
|
||||
if (meta?.loaderType) {
|
||||
assetType = meta.loaderType;
|
||||
}
|
||||
@@ -607,10 +750,13 @@ export class EngineService {
|
||||
size: asset.size,
|
||||
hash: asset.hash || '',
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
labels: meta?.labels || [],
|
||||
tags: new Map(),
|
||||
lastModified: asset.lastModified,
|
||||
version: 1
|
||||
version: 1,
|
||||
// 包含 importSettings(包含 spriteSettings 等)用于渲染系统查询
|
||||
// Include importSettings (contains spriteSettings etc.) for render systems to query
|
||||
importSettings: meta?.importSettings as Record<string, unknown> | undefined
|
||||
});
|
||||
}
|
||||
|
||||
@@ -684,10 +830,13 @@ export class EngineService {
|
||||
size: asset.size,
|
||||
hash: asset.hash || '',
|
||||
dependencies: [],
|
||||
labels: [],
|
||||
labels: meta?.labels || [],
|
||||
tags: new Map(),
|
||||
lastModified: asset.lastModified,
|
||||
version: 1
|
||||
version: 1,
|
||||
// 包含 importSettings(包含 spriteSettings 等)用于渲染系统查询
|
||||
// Include importSettings (contains spriteSettings etc.) for render systems to query
|
||||
importSettings: meta?.importSettings as Record<string, unknown> | undefined
|
||||
});
|
||||
|
||||
logger.debug(`Asset synced to runtime: ${asset.path} (${data.guid})`);
|
||||
@@ -1137,11 +1286,29 @@ export class EngineService {
|
||||
|
||||
/**
|
||||
* Set selected entity IDs for gizmo display.
|
||||
* 设置选中的实体 ID 用于 Gizmo 显示。
|
||||
*/
|
||||
setSelectedEntityIds(ids: number[]): void {
|
||||
this._selectedEntityIds = [...ids];
|
||||
this._runtime?.setSelectedEntityIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currently selected entity IDs.
|
||||
* 获取当前选中的实体 IDs。
|
||||
*/
|
||||
getSelectedEntityIds(): number[] {
|
||||
return [...this._selectedEntityIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gizmo interaction service.
|
||||
* 获取 Gizmo 交互服务。
|
||||
*/
|
||||
getGizmoInteractionService(): GizmoInteractionService | null {
|
||||
return this._gizmoInteractionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set transform tool mode.
|
||||
*/
|
||||
@@ -1229,6 +1396,76 @@ export class EngineService {
|
||||
return this._runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize dynamic atlas with current settings.
|
||||
* 使用当前设置重新初始化动态图集。
|
||||
*
|
||||
* Call this when dynamic atlas settings change to apply them.
|
||||
* 当动态图集设置更改时调用此方法以应用更改。
|
||||
*/
|
||||
reinitializeDynamicAtlas(): void {
|
||||
const bridge = this._runtime?.bridge;
|
||||
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
|
||||
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
|
||||
return;
|
||||
}
|
||||
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture!(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (!atlasEnabled) {
|
||||
logger.info('Dynamic atlas is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
logger.info('Dynamic atlas settings read from SettingsService:', {
|
||||
strategyValue,
|
||||
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
|
||||
fixedPageSize,
|
||||
maxPages,
|
||||
maxTextureSize
|
||||
});
|
||||
|
||||
const config: DynamicAtlasConfig = {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256,
|
||||
fixedPageSize,
|
||||
maxPageSize: 2048,
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
};
|
||||
|
||||
reinitializeDynamicAtlasService(atlasBridge, config);
|
||||
logger.info('Dynamic atlas reinitialized with config:', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose engine resources.
|
||||
*/
|
||||
@@ -1242,8 +1479,13 @@ export class EngineService {
|
||||
// 切换项目时清空数据库以释放内存
|
||||
this._assetManager.getDatabase().clear();
|
||||
this._assetManager = null;
|
||||
// 清除全局资产数据库引用 | Clear global asset database reference
|
||||
setGlobalAssetDatabase(null);
|
||||
}
|
||||
|
||||
// 清除全局引擎桥引用 | Clear global engine bridge reference
|
||||
setGlobalEngineBridge(null);
|
||||
|
||||
this._engineIntegration = null;
|
||||
|
||||
if (this._runtime) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
@@ -26,6 +26,15 @@ export interface TextureDebugInfo {
|
||||
state: 'loading' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader uniform 值
|
||||
* Shader uniform value
|
||||
*/
|
||||
export interface UniformDebugValue {
|
||||
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
|
||||
value: number | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite 调试信息
|
||||
* Sprite debug info
|
||||
@@ -47,6 +56,14 @@ export interface SpriteDebugInfo {
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
/** 材质/着色器 ID | Material/Shader ID */
|
||||
materialId: number;
|
||||
/** 着色器名称 | Shader name */
|
||||
shaderName: string;
|
||||
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||
uniforms: Record<string, UniformDebugValue>;
|
||||
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,17 +120,86 @@ export interface UIDebugInfo {
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
|
||||
depth: number;
|
||||
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
|
||||
worldOrderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
/** 材质/着色器 ID | Material/Shader ID */
|
||||
materialId: number;
|
||||
/** 着色器名称 | Shader name */
|
||||
shaderName: string;
|
||||
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||
uniforms: Record<string, UniformDebugValue>;
|
||||
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
/**
|
||||
* 图集条目调试信息
|
||||
* Atlas entry debug info
|
||||
*/
|
||||
export interface AtlasEntryDebugInfo {
|
||||
/** 纹理 GUID | Texture GUID */
|
||||
guid: string;
|
||||
/** 在图集中的位置 | Position in atlas */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** UV 坐标 | UV coordinates */
|
||||
uv: [number, number, number, number];
|
||||
/** 纹理图像 data URL(用于预览)| Texture image data URL (for preview) */
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图集页面调试信息
|
||||
* Atlas page debug info
|
||||
*/
|
||||
export interface AtlasPageDebugInfo {
|
||||
/** 页面索引 | Page index */
|
||||
pageIndex: number;
|
||||
/** 纹理 ID | Texture ID */
|
||||
textureId: number;
|
||||
/** 页面尺寸 | Page size */
|
||||
width: number;
|
||||
height: number;
|
||||
/** 占用率 | Occupancy */
|
||||
occupancy: number;
|
||||
/** 此页面中的条目 | Entries in this page */
|
||||
entries: AtlasEntryDebugInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态图集统计信息
|
||||
* Dynamic atlas statistics
|
||||
*/
|
||||
export interface AtlasStats {
|
||||
/** 是否启用 | Whether enabled */
|
||||
enabled: boolean;
|
||||
/** 图集页数 | Number of atlas pages */
|
||||
pageCount: number;
|
||||
/** 已加入图集的纹理数 | Number of textures in atlas */
|
||||
textureCount: number;
|
||||
/** 平均占用率 | Average occupancy */
|
||||
averageOccupancy: number;
|
||||
/** 正在加载的纹理数 | Number of loading textures */
|
||||
loadingCount: number;
|
||||
/** 加载失败的纹理数 | Number of failed textures */
|
||||
failedCount: number;
|
||||
/** 每个页面的详细信息 | Detailed info for each page */
|
||||
pages: AtlasPageDebugInfo[];
|
||||
}
|
||||
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
@@ -121,15 +207,42 @@ export interface RenderDebugSnapshot {
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
/** UI 合批调试信息 | UI batch debug info */
|
||||
uiBatches: BatchDebugInfo[];
|
||||
/** 动态图集统计 | Dynamic atlas stats */
|
||||
atlasStats?: AtlasStats;
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
/** UI 批次数 | UI batch count */
|
||||
uiBatchCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置着色器 ID 到名称的映射
|
||||
* Built-in shader ID to name mapping
|
||||
*/
|
||||
const SHADER_NAMES: Record<number, string> = {
|
||||
0: 'DefaultSprite',
|
||||
1: 'Grayscale',
|
||||
2: 'Tint',
|
||||
3: 'Flash',
|
||||
4: 'Outline',
|
||||
5: 'Shiny'
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据材质/着色器 ID 获取着色器名称
|
||||
* Get shader name from material/shader ID
|
||||
*/
|
||||
function getShaderName(id: number): string {
|
||||
return SHADER_NAMES[id] ?? `Custom(${id})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试服务
|
||||
* Render Debug Service
|
||||
@@ -187,18 +300,15 @@ export class RenderDebugService {
|
||||
|
||||
// 从缓存获取 | Get from cache
|
||||
if (this._textureCache.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture from cache:', textureGuid);
|
||||
return this._textureCache.get(textureGuid);
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回 undefined | If loading, return undefined
|
||||
if (this._texturePending.has(textureGuid)) {
|
||||
console.log('[RenderDebugService] Texture loading:', textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 异步加载纹理 | Load texture asynchronously
|
||||
console.log('[RenderDebugService] Starting texture load:', textureGuid);
|
||||
this._loadTextureToCache(textureGuid);
|
||||
return undefined;
|
||||
}
|
||||
@@ -260,12 +370,16 @@ export class RenderDebugService {
|
||||
: resolvedPath;
|
||||
|
||||
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
|
||||
console.log('[RenderDebugService] Loading texture:', fullPath);
|
||||
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
|
||||
// 注册 GUID 到 data URL 映射(用于动态图集)
|
||||
// Register GUID to data URL mapping (for dynamic atlas)
|
||||
if (isGuid) {
|
||||
registerTexturePathMapping(textureGuid, dataUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
@@ -285,6 +399,57 @@ export class RenderDebugService {
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
// 收集 UI 合批信息 | Collect UI batch info
|
||||
const uiCollector = getUIRenderCollector();
|
||||
const uiBatches = [...uiCollector.getBatchDebugInfo()];
|
||||
|
||||
// 收集动态图集统计 | Collect dynamic atlas stats
|
||||
const atlasService = getDynamicAtlasService();
|
||||
let atlasStats: AtlasStats | undefined;
|
||||
if (atlasService) {
|
||||
const stats = atlasService.getStats();
|
||||
const pageDetails = atlasService.getPageDetails();
|
||||
|
||||
// 转换页面详细信息 | Convert page details
|
||||
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
|
||||
pageIndex: page.pageIndex,
|
||||
textureId: page.textureId,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
occupancy: page.occupancy,
|
||||
entries: page.entries.map(e => ({
|
||||
guid: e.guid,
|
||||
x: e.entry.region.x,
|
||||
y: e.entry.region.y,
|
||||
width: e.entry.region.width,
|
||||
height: e.entry.region.height,
|
||||
uv: e.entry.uv,
|
||||
// 从纹理缓存获取 data URL | Get data URL from texture cache
|
||||
dataUrl: this._textureCache.get(e.guid)
|
||||
}))
|
||||
}));
|
||||
|
||||
atlasStats = {
|
||||
enabled: true,
|
||||
pageCount: stats.pageCount,
|
||||
textureCount: stats.textureCount,
|
||||
averageOccupancy: stats.averageOccupancy,
|
||||
loadingCount: stats.loadingCount,
|
||||
failedCount: stats.failedCount,
|
||||
pages
|
||||
};
|
||||
} else {
|
||||
atlasStats = {
|
||||
enabled: false,
|
||||
pageCount: 0,
|
||||
textureCount: 0,
|
||||
averageOccupancy: 0,
|
||||
loadingCount: 0,
|
||||
failedCount: 0,
|
||||
pages: []
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
@@ -292,12 +457,15 @@ export class RenderDebugService {
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
uiBatches,
|
||||
atlasStats,
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
uiBatchCount: uiBatches.length,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -306,6 +474,7 @@ export class RenderDebugService {
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
@@ -378,6 +547,24 @@ export class RenderDebugService {
|
||||
: transform.rotation.z;
|
||||
|
||||
const textureGuid = sprite.textureGuid ?? '';
|
||||
const materialId = sprite.getMaterialId?.() ?? 0;
|
||||
|
||||
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||
const uniforms: Record<string, UniformDebugValue> = {};
|
||||
const overrides = sprite.materialOverrides ?? {};
|
||||
for (const [name, override] of Object.entries(overrides)) {
|
||||
uniforms[name] = {
|
||||
type: override.type,
|
||||
value: override.value
|
||||
};
|
||||
}
|
||||
|
||||
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||
// Calculate aspectRatio (same as Rust side: width / height)
|
||||
const width = sprite.width * (transform.scale?.x ?? 1);
|
||||
const height = sprite.height * (transform.scale?.y ?? 1);
|
||||
const aspectRatio = Math.abs(height) > 0.001 ? width / height : 1.0;
|
||||
|
||||
sprites.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
@@ -394,6 +581,10 @@ export class RenderDebugService {
|
||||
alpha: sprite.alpha,
|
||||
sortingLayer: sprite.sortingLayer,
|
||||
orderInLayer: sprite.orderInLayer,
|
||||
materialId,
|
||||
shaderName: getShaderName(materialId),
|
||||
uniforms,
|
||||
aspectRatio,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,6 +710,30 @@ export class RenderDebugService {
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
// 获取材质/着色器 ID | Get material/shader ID
|
||||
const materialId = uiRender?.getMaterialId?.() ?? 0;
|
||||
|
||||
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||
const uniforms: Record<string, UniformDebugValue> = {};
|
||||
const overrides = uiRender?.materialOverrides ?? {};
|
||||
for (const [name, override] of Object.entries(overrides)) {
|
||||
uniforms[name] = {
|
||||
type: override.type,
|
||||
value: override.value
|
||||
};
|
||||
}
|
||||
|
||||
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||
// Calculate aspectRatio (same as Rust side: width / height)
|
||||
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
|
||||
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
|
||||
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
|
||||
|
||||
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
|
||||
// worldOrderInLayer = depth * 1000 + orderInLayer
|
||||
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
|
||||
const depth = Math.floor(worldOrderInLayer / 1000);
|
||||
|
||||
uiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
@@ -534,11 +749,17 @@ export class RenderDebugService {
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
depth,
|
||||
worldOrderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
materialId,
|
||||
shaderName: getShaderName(materialId),
|
||||
uniforms,
|
||||
aspectRatio,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export class TauriAssetReader implements IAssetReader {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
// 允许跨域访问,防止 canvas 被污染
|
||||
// Allow cross-origin access to prevent canvas tainting
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
|
||||
image.src = assetUrl;
|
||||
|
||||
@@ -22,6 +22,9 @@ body {
|
||||
background-color: var(--color-bg-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* 禁用全局文本选择,原生应用风格 | Disable global text selection for native app feel */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -35,6 +38,9 @@ textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
/* 输入框允许文本选择 | Allow text selection in inputs */
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -47,6 +53,18 @@ select {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
/* 允许特定元素文本选择 | Allow text selection for specific elements */
|
||||
.selectable,
|
||||
pre,
|
||||
code,
|
||||
.code-preview-content,
|
||||
.file-preview-content,
|
||||
.output-log-content,
|
||||
.json-viewer {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
Reference in New Issue
Block a user