From 536c4c5593cb357bf65a8b8d4f4888193adcae1b Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Fri, 19 Dec 2025 15:33:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20UI=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84=20(#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 的位置和锚点值映射 --- docs/architecture/material-system-refactor.md | 663 ++++++++++++++ .../asset-system/src/core/AssetDatabase.ts | 76 ++ .../asset-system/src/core/AssetManager.ts | 5 +- packages/asset-system/src/index.ts | 14 +- .../src/integration/EngineIntegration.ts | 139 ++- .../src/interfaces/IAssetFileLoader.ts | 103 +++ .../src/interfaces/IAssetLoader.ts | 117 ++- .../src/loaders/AssetLoaderFactory.ts | 3 + .../asset-system/src/loaders/TextureLoader.ts | 21 +- .../src/services/AssetMetadataService.ts | 139 +++ packages/asset-system/src/types/AssetTypes.ts | 6 + .../src/ECS/Decorators/PropertyDecorator.ts | 26 +- .../core/src/ECS/Decorators/TypeDecorators.ts | 26 + packages/core/src/ECS/Decorators/index.ts | 1 + packages/core/src/ECS/Scene.ts | 34 +- .../src/core/EngineBridge.ts | 328 ++++++- .../src/core/RenderBatcher.ts | 31 +- .../src/systems/EngineRenderSystem.ts | 306 ++++++- .../src/wasm/es_engine.d.ts | 47 + packages/editor-app/src/App.tsx | 17 + .../src/app/managers/ServiceRegistry.ts | 4 +- .../src/components/PropertyInspector.tsx | 12 + .../src/components/SettingsWindow.tsx | 48 +- .../editor-app/src/components/Viewport.tsx | 133 ++- .../src/components/debug/RenderDebugPanel.css | 184 ++++ .../src/components/debug/RenderDebugPanel.tsx | 763 ++++++++++++++-- .../inspectors/fields/EntityRefField.css | 87 ++ .../inspectors/fields/EntityRefField.tsx | 127 +++ .../material/MaterialPropertiesEditor.tsx | 369 ++++++++ .../components/inspectors/material/index.ts | 6 + .../inspectors/views/AssetFileInspector.tsx | 14 +- .../src/hooks/useStoreSubscriptions.ts | 93 +- packages/editor-app/src/i18n/config.ts | 20 - packages/editor-app/src/i18n/locales/en.json | 102 --- packages/editor-app/src/i18n/locales/zh.json | 102 --- .../field-editors/EntityRefFieldEditor.tsx | 60 ++ .../src/infrastructure/field-editors/index.ts | 1 + packages/editor-app/src/locales/en.ts | 26 + packages/editor-app/src/locales/es.ts | 26 + packages/editor-app/src/locales/zh.ts | 26 + packages/editor-app/src/main.tsx | 1 - .../plugins/builtin/ProjectSettingsPlugin.tsx | 92 ++ .../src/services/EditorAssetFileLoader.ts | 149 +++ .../editor-app/src/services/EngineService.ts | 256 +++++- .../src/services/RenderDebugService.ts | 233 ++++- .../src/services/TauriAssetReader.ts | 3 + packages/editor-app/src/styles/global.css | 18 + .../editor-core/src/Gizmos/GizmoHitTester.ts | 262 ++++++ packages/editor-core/src/Gizmos/index.ts | 1 + .../src/Services/AssetRegistryService.ts | 20 +- .../Build/pipelines/WebBuildPipeline.ts | 6 +- .../src/Services/GizmoInteractionService.ts | 302 +++++++ packages/editor-core/src/tokens.ts | 20 + .../engine-core/src/PluginServiceRegistry.ts | 33 + packages/engine-core/src/TransformSystem.ts | 9 +- packages/engine/src/core/engine.rs | 36 + packages/engine/src/lib.rs | 74 ++ packages/engine/src/math/transform.rs | 28 +- packages/engine/src/math/vec2.rs | 11 +- .../engine/src/renderer/batch/sprite_batch.rs | 100 +- packages/engine/src/renderer/batch/vertex.rs | 21 +- packages/engine/src/renderer/camera.rs | 42 +- packages/engine/src/renderer/renderer2d.rs | 24 +- .../src/renderer/texture/texture_manager.rs | 166 ++++ .../material-system/src/MaterialManager.ts | 61 +- .../src/MaterialSystemPlugin.ts | 12 +- packages/material-system/src/Shader.ts | 110 +++ .../src/effects/BaseShinyEffect.ts | 189 ++++ .../src/effects/ShinyEffectAnimator.ts | 153 ++++ packages/material-system/src/index.ts | 43 +- .../src/interfaces/IMaterialOverridable.ts | 176 ++++ .../src/interfaces/IShaderProperty.ts | 369 ++++++++ .../src/mixins/MaterialOverridableMixin.ts | 268 ++++++ packages/material-system/src/types.ts | 3 +- packages/math/src/Matrix3.ts | 88 +- packages/math/src/Vector2.ts | 30 +- packages/math/tests/Vector2.test.ts | 6 +- .../src/gizmos/ParticleGizmo.ts | 5 +- .../particle/src/systems/ClickFxSystem.ts | 27 + .../particle/src/systems/ParticleSystem.ts | 9 +- .../src/gizmos/Physics2DGizmo.ts | 32 +- .../src/world/Physics2DWorld.ts | 38 +- packages/runtime-core/src/GameRuntime.ts | 8 +- packages/sprite/package.json | 1 + packages/sprite/src/ShinyEffectComponent.ts | 175 ++++ packages/sprite/src/SpriteComponent.ts | 28 +- packages/sprite/src/index.ts | 6 +- .../sprite/src/systems/ShinyEffectSystem.ts | 46 + .../src/systems/TilemapRenderingSystem.ts | 33 +- packages/ui-editor/package.json | 1 + .../ui-editor/src/gizmos/UITransformGizmo.ts | 28 +- packages/ui-editor/src/index.ts | 5 +- .../src/inspectors/UIRenderInspector.tsx | 852 ++++++++++++++++++ .../src/inspectors/UITransformInspector.tsx | 47 +- packages/ui-editor/src/inspectors/index.ts | 1 + packages/ui/package.json | 1 + packages/ui/src/UIBuilder.ts | 175 ++++ packages/ui/src/UIRuntimeModule.ts | 113 ++- packages/ui/src/atlas/BinPacker.ts | 280 ++++++ packages/ui/src/atlas/DynamicAtlasManager.ts | 669 ++++++++++++++ packages/ui/src/atlas/DynamicAtlasService.ts | 506 +++++++++++ packages/ui/src/atlas/index.ts | 29 + .../ui/src/components/UICanvasComponent.ts | 201 +++++ .../ui/src/components/UIRenderComponent.ts | 196 +++- .../src/components/UIShinyEffectComponent.ts | 174 ++++ .../ui/src/components/UITransformComponent.ts | 140 ++- packages/ui/src/components/UIWidgetMarker.ts | 43 + .../src/components/base/UIGraphicComponent.ts | 173 ++++ .../src/components/base/UIImageComponent.ts | 291 ++++++ .../components/base/UISelectableComponent.ts | 384 ++++++++ packages/ui/src/components/base/index.ts | 30 + .../components/widgets/UIButtonComponent.ts | 18 + .../components/widgets/UIDropdownComponent.ts | 428 +++++++++ .../widgets/UIInputFieldComponent.ts | 681 ++++++++++++++ .../components/widgets/UISliderComponent.ts | 88 ++ .../components/widgets/UIToggleComponent.ts | 337 +++++++ packages/ui/src/components/widgets/index.ts | 3 + packages/ui/src/index.ts | 132 ++- packages/ui/src/systems/UIAnimationSystem.ts | 72 +- .../ui/src/systems/UICanvasScalerSystem.ts | 2 +- packages/ui/src/systems/UIInputSystem.ts | 568 +++++++++++- packages/ui/src/systems/UILayoutSystem.ts | 321 +++++-- .../ui/src/systems/UISelectableStateSystem.ts | 92 ++ packages/ui/src/systems/UISliderFillSystem.ts | 119 +++ .../systems/render/UIButtonRenderSystem.ts | 217 +++-- .../systems/render/UIDropdownRenderSystem.ts | 240 +++++ .../systems/render/UIGraphicRenderSystem.ts | 387 ++++++++ .../render/UIInputFieldRenderSystem.ts | 424 +++++++++ .../render/UIProgressBarRenderSystem.ts | 276 ++---- .../src/systems/render/UIRectRenderSystem.ts | 316 +++---- .../src/systems/render/UIRenderBeginSystem.ts | 2 +- .../src/systems/render/UIRenderCollector.ts | 615 +++++++++++-- .../ui/src/systems/render/UIRenderUtils.ts | 326 +++++++ .../render/UIScrollViewRenderSystem.ts | 120 +-- .../src/systems/render/UIShinyEffectSystem.ts | 122 +++ .../systems/render/UISliderRenderSystem.ts | 188 ++-- .../src/systems/render/UITextRenderSystem.ts | 103 ++- .../systems/render/UIToggleRenderSystem.ts | 303 +++++++ packages/ui/src/systems/render/index.ts | 28 +- packages/ui/src/tokens.ts | 14 + packages/ui/src/utils/TextMeasureService.ts | 308 +++++++ packages/ui/src/utils/UIDirtyFlags.ts | 202 +++++ packages/ui/src/utils/UITextureUtils.ts | 162 ++++ packages/ui/src/utils/index.ts | 40 + pnpm-lock.yaml | 9 + 145 files changed, 18187 insertions(+), 1543 deletions(-) create mode 100644 docs/architecture/material-system-refactor.md create mode 100644 packages/asset-system/src/interfaces/IAssetFileLoader.ts create mode 100644 packages/asset-system/src/services/AssetMetadataService.ts create mode 100644 packages/editor-app/src/components/inspectors/fields/EntityRefField.css create mode 100644 packages/editor-app/src/components/inspectors/fields/EntityRefField.tsx create mode 100644 packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx create mode 100644 packages/editor-app/src/components/inspectors/material/index.ts delete mode 100644 packages/editor-app/src/i18n/config.ts delete mode 100644 packages/editor-app/src/i18n/locales/en.json delete mode 100644 packages/editor-app/src/i18n/locales/zh.json create mode 100644 packages/editor-app/src/infrastructure/field-editors/EntityRefFieldEditor.tsx create mode 100644 packages/editor-app/src/services/EditorAssetFileLoader.ts create mode 100644 packages/editor-core/src/Gizmos/GizmoHitTester.ts create mode 100644 packages/editor-core/src/Services/GizmoInteractionService.ts create mode 100644 packages/material-system/src/effects/BaseShinyEffect.ts create mode 100644 packages/material-system/src/effects/ShinyEffectAnimator.ts create mode 100644 packages/material-system/src/interfaces/IMaterialOverridable.ts create mode 100644 packages/material-system/src/interfaces/IShaderProperty.ts create mode 100644 packages/material-system/src/mixins/MaterialOverridableMixin.ts create mode 100644 packages/sprite/src/ShinyEffectComponent.ts create mode 100644 packages/sprite/src/systems/ShinyEffectSystem.ts create mode 100644 packages/ui-editor/src/inspectors/UIRenderInspector.tsx create mode 100644 packages/ui/src/atlas/BinPacker.ts create mode 100644 packages/ui/src/atlas/DynamicAtlasManager.ts create mode 100644 packages/ui/src/atlas/DynamicAtlasService.ts create mode 100644 packages/ui/src/atlas/index.ts create mode 100644 packages/ui/src/components/UICanvasComponent.ts create mode 100644 packages/ui/src/components/UIShinyEffectComponent.ts create mode 100644 packages/ui/src/components/UIWidgetMarker.ts create mode 100644 packages/ui/src/components/base/UIGraphicComponent.ts create mode 100644 packages/ui/src/components/base/UIImageComponent.ts create mode 100644 packages/ui/src/components/base/UISelectableComponent.ts create mode 100644 packages/ui/src/components/base/index.ts create mode 100644 packages/ui/src/components/widgets/UIDropdownComponent.ts create mode 100644 packages/ui/src/components/widgets/UIInputFieldComponent.ts create mode 100644 packages/ui/src/components/widgets/UIToggleComponent.ts create mode 100644 packages/ui/src/systems/UISelectableStateSystem.ts create mode 100644 packages/ui/src/systems/UISliderFillSystem.ts create mode 100644 packages/ui/src/systems/render/UIDropdownRenderSystem.ts create mode 100644 packages/ui/src/systems/render/UIGraphicRenderSystem.ts create mode 100644 packages/ui/src/systems/render/UIInputFieldRenderSystem.ts create mode 100644 packages/ui/src/systems/render/UIRenderUtils.ts create mode 100644 packages/ui/src/systems/render/UIShinyEffectSystem.ts create mode 100644 packages/ui/src/systems/render/UIToggleRenderSystem.ts create mode 100644 packages/ui/src/utils/TextMeasureService.ts create mode 100644 packages/ui/src/utils/UIDirtyFlags.ts create mode 100644 packages/ui/src/utils/UITextureUtils.ts create mode 100644 packages/ui/src/utils/index.ts diff --git a/docs/architecture/material-system-refactor.md b/docs/architecture/material-system-refactor.md new file mode 100644 index 00000000..b43a8383 --- /dev/null +++ b/docs/architecture/material-system-refactor.md @@ -0,0 +1,663 @@ +# ESEngine 材质系统统一架构重构方案 + +## 问题概述 + +当前 UI 和 Scene (Sprite) 两套渲染系统存在大量代码重复: + +| 重复项 | Sprite | UI | 重复度 | +|--------|--------|----|----| +| 材质属性覆盖接口 | `MaterialPropertyOverride` | `UIMaterialPropertyOverride` | 100% | +| 材质方法 (12个) | `SpriteComponent` | `UIRenderComponent` | 100% | +| ShinyEffect 组件 | `ShinyEffectComponent` | `UIShinyEffectComponent` | 99% | +| ShinyEffect 系统 | `ShinyEffectSystem` | `UIShinyEffectSystem` | 98% | + +**根本原因**:缺乏统一的材质覆盖接口抽象层。 + +--- + +## 一、统一材质覆盖接口 + +### 1.1 定义通用接口 + +在 `@esengine/material-system` 包中定义统一接口: + +```typescript +// packages/material-system/src/interfaces/IMaterialOverridable.ts + +/** + * Material property override definition. + * 材质属性覆盖定义。 + */ +export interface MaterialPropertyOverride { + type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int'; + value: number | number[]; +} + +export type MaterialOverrides = Record; + +/** + * Interface for components that support material property overrides. + * 支持材质属性覆盖的组件接口。 + */ +export interface IMaterialOverridable { + /** Material GUID for asset reference | 材质资产引用的 GUID */ + materialGuid: string; + + /** Current material overrides | 当前材质覆盖 */ + readonly materialOverrides: MaterialOverrides; + + /** Get current material ID | 获取当前材质 ID */ + getMaterialId(): number; + + /** Set material ID | 设置材质 ID */ + setMaterialId(id: number): void; + + // Uniform setters + setOverrideFloat(name: string, value: number): this; + setOverrideVec2(name: string, x: number, y: number): this; + setOverrideVec3(name: string, x: number, y: number, z: number): this; + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this; + setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this; + setOverrideInt(name: string, value: number): this; + + // Uniform getters + getOverride(name: string): MaterialPropertyOverride | undefined; + removeOverride(name: string): this; + clearOverrides(): this; + hasOverrides(): boolean; +} +``` + +### 1.2 创建 Mixin 实现 + +使用 Mixin 模式避免代码重复: + +```typescript +// packages/material-system/src/mixins/MaterialOverridableMixin.ts + +import type { MaterialPropertyOverride, MaterialOverrides } from '../interfaces/IMaterialOverridable'; + +/** + * Mixin that provides material override functionality. + * 提供材质覆盖功能的 Mixin。 + */ +export function MaterialOverridableMixin {}>(Base: TBase) { + return class extends Base { + materialGuid: string = ''; + private _materialId: number = 0; + private _materialOverrides: MaterialOverrides = {}; + + get materialOverrides(): MaterialOverrides { + return this._materialOverrides; + } + + getMaterialId(): number { + return this._materialId; + } + + setMaterialId(id: number): void { + this._materialId = id; + } + + setOverrideFloat(name: string, value: number): this { + this._materialOverrides[name] = { type: 'float', value }; + return this; + } + + setOverrideVec2(name: string, x: number, y: number): this { + this._materialOverrides[name] = { type: 'vec2', value: [x, y] }; + return this; + } + + setOverrideVec3(name: string, x: number, y: number, z: number): this { + this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] }; + return this; + } + + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this { + this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] }; + return this; + } + + setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this { + this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] }; + return this; + } + + setOverrideInt(name: string, value: number): this { + this._materialOverrides[name] = { type: 'int', value: Math.floor(value) }; + return this; + } + + getOverride(name: string): MaterialPropertyOverride | undefined { + return this._materialOverrides[name]; + } + + removeOverride(name: string): this { + delete this._materialOverrides[name]; + return this; + } + + clearOverrides(): this { + this._materialOverrides = {}; + return this; + } + + hasOverrides(): boolean { + return Object.keys(this._materialOverrides).length > 0; + } + }; +} +``` + +--- + +## 二、Shader Property 元数据系统 + +### 2.1 定义属性元数据接口 + +```typescript +// packages/material-system/src/interfaces/IShaderProperty.ts + +/** + * Shader property UI metadata. + * 着色器属性 UI 元数据。 + */ +export interface ShaderPropertyMeta { + /** Property type | 属性类型 */ + type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int' | 'texture'; + + /** Display label (supports i18n key) | 显示标签(支持 i18n 键) */ + label: string; + + /** Property group for organization | 属性分组 */ + group?: string; + + /** Default value | 默认值 */ + default?: number | number[] | string; + + // Numeric constraints + min?: number; + max?: number; + step?: number; + + /** UI hints | UI 提示 */ + hint?: 'range' | 'angle' | 'hdr' | 'normal'; + + /** Tooltip description | 工具提示描述 */ + tooltip?: string; + + /** Whether to hide in inspector | 是否在检查器中隐藏 */ + hidden?: boolean; +} + +/** + * Extended shader definition with property metadata. + * 带属性元数据的扩展着色器定义。 + */ +export interface ShaderAssetDefinition { + /** Shader name | 着色器名称 */ + name: string; + + /** Display name for UI | UI 显示名称 */ + displayName?: string; + + /** Shader description | 着色器描述 */ + description?: string; + + /** Vertex shader source (inline or path) | 顶点着色器源(内联或路径)*/ + vertexSource: string; + + /** Fragment shader source (inline or path) | 片段着色器源(内联或路径)*/ + fragmentSource: string; + + /** Property metadata for inspector | 检查器属性元数据 */ + properties?: Record; + + /** Render queue / order | 渲染队列/顺序 */ + renderQueue?: number; + + /** Preset blend mode | 预设混合模式 */ + blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque'; +} +``` + +### 2.2 .shader 资产文件格式 + +```json +{ + "$schema": "esengine://schemas/shader.json", + "version": 1, + "name": "Shiny", + "displayName": "闪光效果 | Shiny Effect", + "description": "扫光高亮动画着色器 | Sweeping highlight animation shader", + + "vertexSource": "./shaders/sprite.vert", + "fragmentSource": "./shaders/shiny.frag", + + "blendMode": "alpha", + "renderQueue": 2000, + + "properties": { + "u_shinyProgress": { + "type": "float", + "label": "进度 | Progress", + "group": "Animation", + "default": 0, + "min": 0, + "max": 1, + "step": 0.01, + "hidden": true + }, + "u_shinyWidth": { + "type": "float", + "label": "宽度 | Width", + "group": "Effect", + "default": 0.25, + "min": 0, + "max": 1, + "step": 0.01, + "tooltip": "闪光带宽度 | Width of the shiny band" + }, + "u_shinyRotation": { + "type": "float", + "label": "角度 | Rotation", + "group": "Effect", + "default": 2.25, + "min": 0, + "max": 6.28, + "step": 0.01, + "hint": "angle" + }, + "u_shinySoftness": { + "type": "float", + "label": "柔和度 | Softness", + "group": "Effect", + "default": 1.0, + "min": 0, + "max": 1, + "step": 0.01 + }, + "u_shinyBrightness": { + "type": "float", + "label": "亮度 | Brightness", + "group": "Effect", + "default": 1.0, + "min": 0, + "max": 2, + "step": 0.01 + }, + "u_shinyGloss": { + "type": "float", + "label": "光泽度 | Gloss", + "group": "Effect", + "default": 1.0, + "min": 0, + "max": 1, + "step": 0.01, + "tooltip": "0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted" + } + } +} +``` + +--- + +## 三、统一效果组件/系统架构 + +### 3.1 抽取通用 ShinyEffect 基类 + +```typescript +// packages/material-system/src/effects/BaseShinyEffect.ts + +import { Component, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * Base shiny effect configuration (shared between UI and Sprite). + * 基础闪光效果配置(UI 和 Sprite 共享)。 + */ +export abstract class BaseShinyEffect extends Component { + // ============= Effect Parameters ============= + @Serialize() + @Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 }) + public width: number = 0.25; + + @Serialize() + @Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 }) + public rotation: number = 129; + + @Serialize() + @Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 }) + public softness: number = 1.0; + + @Serialize() + @Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 }) + public brightness: number = 1.0; + + @Serialize() + @Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 }) + public gloss: number = 1.0; + + // ============= Animation Settings ============= + @Serialize() + @Property({ type: 'boolean', label: 'Play' }) + public play: boolean = true; + + @Serialize() + @Property({ type: 'boolean', label: 'Loop' }) + public loop: boolean = true; + + @Serialize() + @Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 }) + public duration: number = 2.0; + + @Serialize() + @Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 }) + public loopDelay: number = 2.0; + + @Serialize() + @Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 }) + public initialDelay: number = 0; + + // ============= Runtime State ============= + public progress: number = 0; + public elapsedTime: number = 0; + public inDelay: boolean = false; + public delayRemaining: number = 0; + public initialDelayProcessed: boolean = false; + + reset(): void { + this.progress = 0; + this.elapsedTime = 0; + this.inDelay = false; + this.delayRemaining = 0; + this.initialDelayProcessed = false; + } + + start(): void { + this.reset(); + this.play = true; + } + + stop(): void { + this.play = false; + } + + getRotationRadians(): number { + return this.rotation * Math.PI / 180; + } +} +``` + +### 3.2 通用动画更新逻辑 + +```typescript +// packages/material-system/src/effects/ShinyEffectAnimator.ts + +import type { BaseShinyEffect } from './BaseShinyEffect'; +import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable'; +import { BuiltInShaders } from '../types'; + +/** + * Shared animator logic for shiny effect. + * 闪光效果共享的动画逻辑。 + */ +export class ShinyEffectAnimator { + /** + * Update animation state. + * 更新动画状态。 + */ + static updateAnimation(shiny: BaseShinyEffect, deltaTime: number): void { + if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) { + shiny.delayRemaining = shiny.initialDelay; + shiny.inDelay = true; + shiny.initialDelayProcessed = true; + } + + if (shiny.inDelay) { + shiny.delayRemaining -= deltaTime; + if (shiny.delayRemaining <= 0) { + shiny.inDelay = false; + shiny.elapsedTime = 0; + } + return; + } + + shiny.elapsedTime += deltaTime; + shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0); + + if (shiny.progress >= 1.0) { + if (shiny.loop) { + shiny.inDelay = true; + shiny.delayRemaining = shiny.loopDelay; + shiny.progress = 0; + shiny.elapsedTime = 0; + } else { + shiny.play = false; + shiny.progress = 1.0; + } + } + } + + /** + * Apply material overrides. + * 应用材质覆盖。 + */ + static applyMaterialOverrides(shiny: BaseShinyEffect, target: IMaterialOverridable): void { + if (target.getMaterialId() === 0) { + target.setMaterialId(BuiltInShaders.Shiny); + } + + target.setOverrideFloat('u_shinyProgress', shiny.progress); + target.setOverrideFloat('u_shinyWidth', shiny.width); + target.setOverrideFloat('u_shinyRotation', shiny.getRotationRadians()); + target.setOverrideFloat('u_shinySoftness', shiny.softness); + target.setOverrideFloat('u_shinyBrightness', shiny.brightness); + target.setOverrideFloat('u_shinyGloss', shiny.gloss); + } +} +``` + +--- + +## 四、Material Inspector 设计 + +### 4.1 组件架构 + +``` +MaterialPropertiesEditor (容器组件) +├── ShaderSelector (着色器选择器) +├── PropertyGroup (属性分组) +│ ├── FloatProperty (浮点属性) +│ ├── VectorProperty (向量属性) +│ ├── ColorProperty (颜色属性) +│ └── TextureProperty (纹理属性) +└── OverrideIndicator (覆盖指示器) +``` + +### 4.2 核心组件 + +```typescript +// packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx + +interface MaterialPropertiesEditorProps { + /** Target component implementing IMaterialOverridable */ + target: IMaterialOverridable; + /** Current shader definition with property metadata */ + shaderDef?: ShaderAssetDefinition; + /** Callback when property changes */ + onChange?: (name: string, value: MaterialPropertyOverride) => void; +} + +export const MaterialPropertiesEditor: React.FC = ({ + target, + shaderDef, + onChange +}) => { + // Group properties by their group field + const groupedProps = useMemo(() => { + if (!shaderDef?.properties) return {}; + + const groups: Record> = {}; + for (const [name, meta] of Object.entries(shaderDef.properties)) { + if (meta.hidden) continue; + const group = meta.group || 'Default'; + if (!groups[group]) groups[group] = []; + groups[group].push([name, meta]); + } + return groups; + }, [shaderDef]); + + return ( +
+ target.setMaterialId(id)} + /> + + {Object.entries(groupedProps).map(([group, props]) => ( + + {props.map(([name, meta]) => ( + { + applyOverride(target, name, meta.type, value); + onChange?.(name, target.getOverride(name)!); + }} + /> + ))} + + ))} +
+ ); +}; +``` + +--- + +## 五、实施计划 + +### Phase 1: 接口层 (1-2 天) + +1. **创建 IMaterialOverridable 接口** (`packages/material-system/src/interfaces/`) +2. **创建 MaterialOverridableMixin** (`packages/material-system/src/mixins/`) +3. **导出新接口** (`packages/material-system/src/index.ts`) + +### Phase 2: 重构现有组件 (2-3 天) + +1. **修改 SpriteComponent**:实现 `IMaterialOverridable`,使用 Mixin +2. **修改 UIRenderComponent**:实现 `IMaterialOverridable`,使用 Mixin +3. **删除重复代码**:移除各组件中的重复材质方法 + +### Phase 3: 统一效果系统 (2-3 天) + +1. **创建 BaseShinyEffect** (`packages/material-system/src/effects/`) +2. **创建 ShinyEffectAnimator** (`packages/material-system/src/effects/`) +3. **重构 ShinyEffectComponent**:继承 BaseShinyEffect +4. **重构 UIShinyEffectComponent**:继承 BaseShinyEffect +5. **重构系统**:使用 ShinyEffectAnimator + +### Phase 4: Shader Property 系统 (2-3 天) + +1. **定义 ShaderPropertyMeta 接口** +2. **扩展 ShaderDefinition** 添加 properties 字段 +3. **创建 ShaderLoader** 支持 .shader 文件 +4. **注册内置着色器属性元数据** + +### Phase 5: Material Inspector (3-4 天) + +1. **创建 MaterialPropertiesEditor 组件** +2. **创建 PropertyField 组件** (Float, Vector, Color, Texture) +3. **集成到现有 Inspector 系统** +4. **支持实时预览** + +--- + +## 六、文件修改清单 + +| 优先级 | 包 | 文件 | 操作 | +|--------|-----|------|------| +| P0 | material-system | `src/interfaces/IMaterialOverridable.ts` | 新建 | +| P0 | material-system | `src/mixins/MaterialOverridableMixin.ts` | 新建 | +| P0 | material-system | `src/interfaces/IShaderProperty.ts` | 新建 | +| P1 | material-system | `src/effects/BaseShinyEffect.ts` | 新建 | +| P1 | material-system | `src/effects/ShinyEffectAnimator.ts` | 新建 | +| P1 | sprite | `src/SpriteComponent.ts` | 重构 | +| P1 | ui | `src/components/UIRenderComponent.ts` | 重构 | +| P2 | sprite | `src/ShinyEffectComponent.ts` | 重构 | +| P2 | ui | `src/components/UIShinyEffectComponent.ts` | 重构 | +| P2 | sprite | `src/systems/ShinyEffectSystem.ts` | 重构 | +| P2 | ui | `src/systems/render/UIShinyEffectSystem.ts` | 重构 | +| P3 | material-system | `src/loaders/ShaderLoader.ts` | 扩展 | +| P3 | editor-app | `src/components/inspectors/material/*` | 新建 | + +--- + +## 七、Transform 组件统一(可选) + +### 7.1 现状分析 + +| 特性 | TransformComponent | UITransformComponent | +|------|-------------------|---------------------| +| **坐标系** | 绝对坐标 (position.x/y/z) | 相对锚点坐标 (x/y + anchor) | +| **尺寸** | ❌ 无 | ✅ width/height + 约束 | +| **锚点系统** | ❌ 无 | ✅ anchorMin/Max | +| **3D 支持** | ✅ IVector3 | ❌ 纯 2D | +| **可见性** | ❌ 无 | ✅ visible, alpha | + +### 7.2 结论 + +**不建议完全合并**,但可提取公共基类: + +```typescript +// packages/engine-core/src/interfaces/ITransformBase.ts + +export interface ITransformBase { + /** 旋转角度(度) | Rotation in degrees */ + rotation: number; + + /** X 缩放 | Scale X */ + scaleX: number; + + /** Y 缩放 | Scale Y */ + scaleY: number; + + /** 本地到世界矩阵 | Local to world matrix */ + readonly localToWorldMatrix: Matrix2D; + + /** 是否需要更新 | Dirty flag */ + isDirty: boolean; + + /** 世界坐标 X | World position X */ + readonly worldX: number; + + /** 世界坐标 Y | World position Y */ + readonly worldY: number; + + /** 世界旋转 | World rotation */ + readonly worldRotation: number; + + /** 世界缩放 X | World scale X */ + readonly worldScaleX: number; + + /** 世界缩放 Y | World scale Y */ + readonly worldScaleY: number; +} +``` + +### 7.3 收益 + +- 渲染系统可以统一处理 `ITransformBase` +- 减少 SpriteRenderSystem 和 UIRenderSystem 的重复 +- Gizmo 系统可以共享变换操作逻辑 + +--- + +## 八、向后兼容性 + +1. **接口兼容**:现有组件的 API 保持不变 +2. **序列化兼容**:不改变现有序列化格式 +3. **渐进迁移**:可分阶段进行,不影响现有功能 diff --git a/packages/asset-system/src/core/AssetDatabase.ts b/packages/asset-system/src/core/AssetDatabase.ts index a11fd016..b1750516 100644 --- a/packages/asset-system/src/core/AssetDatabase.ts +++ b/packages/asset-system/src/core/AssetDatabase.ts @@ -10,6 +10,47 @@ import { IAssetCatalogEntry } from '../types/AssetTypes'; +/** + * 纹理 Sprite 信息(从 meta 文件的 importSettings 读取) + * Texture sprite info (read from meta file's importSettings) + */ +export interface ITextureSpriteInfo { + /** + * 九宫格切片边距 [top, right, bottom, left] + * Nine-patch slice border + */ + sliceBorder?: [number, number, number, number]; + /** + * Sprite 锚点 [x, y](0-1 归一化) + * Sprite pivot point (0-1 normalized) + */ + pivot?: [number, number]; + /** + * 纹理宽度(可选,需要纹理已加载) + * Texture width (optional, requires texture to be loaded) + */ + width?: number; + /** + * 纹理高度(可选,需要纹理已加载) + * Texture height (optional, requires texture to be loaded) + */ + height?: number; +} + +/** + * Sprite settings in import settings + * 导入设置中的 Sprite 设置 + */ +interface ISpriteSettings { + sliceBorder?: [number, number, number, number]; + pivot?: [number, number]; + pixelsPerUnit?: number; + /** Texture width (from import settings) | 纹理宽度(来自导入设置) */ + width?: number; + /** Texture height (from import settings) | 纹理高度(来自导入设置) */ + height?: number; +} + /** * Asset database implementation * 资产数据库实现 @@ -212,6 +253,41 @@ export class AssetDatabase { return guid ? this._metadata.get(guid) : undefined; } + /** + * Get texture sprite info from metadata + * 从元数据获取纹理 Sprite 信息 + * + * Extracts spriteSettings from importSettings if available. + * 如果可用,从 importSettings 提取 spriteSettings。 + * + * @param guid - Texture asset GUID | 纹理资产 GUID + * @returns Sprite info or undefined if not found/not a texture | Sprite 信息或未找到/非纹理则为 undefined + */ + getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined { + const metadata = this._metadata.get(guid); + if (!metadata) return undefined; + + // Check if it's a texture asset + // 检查是否是纹理资产 + if (metadata.type !== AssetType.Texture) return undefined; + + // Extract spriteSettings from importSettings + // 从 importSettings 提取 spriteSettings + const importSettings = metadata.importSettings as Record | undefined; + const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined; + + if (!spriteSettings) return undefined; + + return { + sliceBorder: spriteSettings.sliceBorder, + pivot: spriteSettings.pivot, + // Include dimensions from import settings if available + // 如果可用,包含来自导入设置的尺寸 + width: spriteSettings.width, + height: spriteSettings.height + }; + } + /** * Find assets by type * 按类型查找资产 diff --git a/packages/asset-system/src/core/AssetManager.ts b/packages/asset-system/src/core/AssetManager.ts index f7c7a7bb..1271cc03 100644 --- a/packages/asset-system/src/core/AssetManager.ts +++ b/packages/asset-system/src/core/AssetManager.ts @@ -132,7 +132,10 @@ export class AssetManager implements IAssetManager { labels: [], tags: new Map(), lastModified: Date.now(), - version: 1 + version: 1, + // Include importSettings for sprite slicing (nine-patch), etc. + // 包含 importSettings 以支持精灵切片(九宫格)等功能 + importSettings: entry.importSettings }; this._database.addAsset(metadata); diff --git a/packages/asset-system/src/index.ts b/packages/asset-system/src/index.ts index 57c3d093..7d81d5af 100644 --- a/packages/asset-system/src/index.ts +++ b/packages/asset-system/src/index.ts @@ -36,6 +36,7 @@ export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog'; export * from './interfaces/IAssetLoader'; export * from './interfaces/IAssetManager'; export * from './interfaces/IAssetReader'; +export * from './interfaces/IAssetFileLoader'; export * from './interfaces/IResourceComponent'; // Core @@ -58,13 +59,24 @@ export { PrefabLoader } from './loaders/PrefabLoader'; // Integration export { EngineIntegration } from './integration/EngineIntegration'; -export type { ITextureEngineBridge } from './integration/EngineIntegration'; +export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration'; // Services export { SceneResourceManager } from './services/SceneResourceManager'; export type { IResourceLoader } from './services/SceneResourceManager'; export { PathResolutionService } from './services/PathResolutionService'; +// Asset Metadata Service (primary API for sprite info) +// 资产元数据服务(sprite 信息的主要 API) +export { + setGlobalAssetDatabase, + getGlobalAssetDatabase, + setGlobalEngineBridge, + getGlobalEngineBridge, + getTextureSpriteInfo +} from './services/AssetMetadataService'; +export type { ITextureSpriteInfo } from './core/AssetDatabase'; + // Utils export { UVHelper } from './utils/UVHelper'; export { diff --git a/packages/asset-system/src/integration/EngineIntegration.ts b/packages/asset-system/src/integration/EngineIntegration.ts index 5309a97c..598f8b6f 100644 --- a/packages/asset-system/src/integration/EngineIntegration.ts +++ b/packages/asset-system/src/integration/EngineIntegration.ts @@ -31,12 +31,6 @@ export interface ITextureEngineBridge { */ unloadTexture(id: number): void; - /** - * Get texture info - * 获取纹理信息 - */ - getTextureInfo(id: number): { width: number; height: number } | null; - /** * Get or load texture by path. * 按路径获取或加载纹理。 @@ -109,6 +103,20 @@ export interface ITextureEngineBridge { * @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise */ loadTextureAsync?(id: number, url: string): Promise; + + /** + * Get texture info by path. + * 通过路径获取纹理信息。 + * + * This is the primary API for getting texture dimensions. + * The Rust engine is the single source of truth for texture dimensions. + * 这是获取纹理尺寸的主要 API。 + * Rust 引擎是纹理尺寸的唯一事实来源。 + * + * @param path Image path/URL | 图片路径/URL + * @returns Texture info or null if not loaded | 纹理信息或未加载则为 null + */ + getTextureInfoByPath?(path: string): { width: number; height: number } | null; } /** @@ -131,10 +139,43 @@ interface DataAssetEntry { path: string; } +/** + * Texture load callback type + * 纹理加载回调类型 + */ +export type TextureLoadCallback = (guid: string, path: string, textureId: number) => void; + /** * Asset system engine integration * 资产系统引擎集成 */ +/** + * Texture sprite info (nine-patch border, pivot, etc.) + * 纹理 Sprite 信息(九宫格边距、锚点等) + */ +export interface ITextureSpriteInfo { + /** + * 九宫格切片边距 [top, right, bottom, left] + * Nine-patch slice border + */ + sliceBorder?: [number, number, number, number]; + /** + * Sprite 锚点 [x, y](0-1 归一化) + * Sprite pivot point (0-1 normalized) + */ + pivot?: [number, number]; + /** + * 纹理宽度 + * Texture width + */ + width: number; + /** + * 纹理高度 + * Texture height + */ + height: number; +} + export class EngineIntegration { private _assetManager: AssetManager; private _engineBridge?: ITextureEngineBridge; @@ -146,6 +187,54 @@ export class EngineIntegration { // Path-stable ID cache (persists across Play/Stop cycles) private static _pathIdCache = new Map(); + // 纹理 Sprite 信息缓存(全局静态,可供渲染系统访问) + // Texture sprite info cache (global static, accessible by render systems) + private static _textureSpriteInfoCache = new Map(); + + // 纹理加载回调(用于动态图集集成等) + // Texture load callback (for dynamic atlas integration, etc.) + private static _textureLoadCallbacks: TextureLoadCallback[] = []; + + /** + * Register a callback to be called when textures are loaded + * 注册纹理加载时调用的回调 + * + * This can be used for dynamic atlas integration. + * 可用于动态图集集成。 + * + * @param callback - Callback function | 回调函数 + */ + static onTextureLoad(callback: TextureLoadCallback): void { + if (!EngineIntegration._textureLoadCallbacks.includes(callback)) { + EngineIntegration._textureLoadCallbacks.push(callback); + } + } + + /** + * Remove a texture load callback + * 移除纹理加载回调 + */ + static removeTextureLoadCallback(callback: TextureLoadCallback): void { + const index = EngineIntegration._textureLoadCallbacks.indexOf(callback); + if (index >= 0) { + EngineIntegration._textureLoadCallbacks.splice(index, 1); + } + } + + /** + * Notify all callbacks of a texture load + * 通知所有回调纹理已加载 + */ + private static notifyTextureLoad(guid: string, path: string, textureId: number): void { + for (const callback of EngineIntegration._textureLoadCallbacks) { + try { + callback(guid, path, textureId); + } catch (e) { + console.error('[EngineIntegration] Error in texture load callback:', e); + } + } + } + // Audio resource mappings | 音频资源映射 private _audioIdMap = new Map(); private _pathToAudioId = new Map(); @@ -279,6 +368,16 @@ export class EngineIntegration { const result = await this._assetManager.loadAsset(guid); const metadata = result.metadata; const assetPath = metadata.path; + const textureAsset = result.asset; + + // 缓存 sprite 信息(九宫格边距等)到静态缓存 + // Cache sprite info (slice border, etc.) to static cache + EngineIntegration._textureSpriteInfoCache.set(guid, { + sliceBorder: textureAsset.sliceBorder, + pivot: textureAsset.pivot, + width: textureAsset.width, + height: textureAsset.height + }); // 生成路径稳定 ID // Generate path-stable ID @@ -309,9 +408,37 @@ export class EngineIntegration { this._textureIdMap.set(guid, stableId); this._pathToTextureId.set(assetPath, stableId); + // 通知回调(用于动态图集等) + // Notify callbacks (for dynamic atlas, etc.) + EngineIntegration.notifyTextureLoad(guid, engineUrl, stableId); + return stableId; } + /** + * Get texture sprite info by GUID (static method for render system access) + * 通过 GUID 获取纹理 Sprite 信息(静态方法,供渲染系统访问) + * + * Returns cached sprite info including nine-patch slice border. + * Must call loadTextureByGuid first to populate the cache. + * 返回缓存的 sprite 信息,包括九宫格边距。 + * 必须先调用 loadTextureByGuid 来填充缓存。 + * + * @param guid - Texture asset GUID | 纹理资产 GUID + * @returns Sprite info or undefined if not loaded | Sprite 信息或未加载则为 undefined + */ + static getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined { + return EngineIntegration._textureSpriteInfoCache.get(guid); + } + + /** + * Clear texture sprite info cache + * 清除纹理 Sprite 信息缓存 + */ + static clearTextureSpriteInfoCache(): void { + EngineIntegration._textureSpriteInfoCache.clear(); + } + /** * Batch load textures * 批量加载纹理 diff --git a/packages/asset-system/src/interfaces/IAssetFileLoader.ts b/packages/asset-system/src/interfaces/IAssetFileLoader.ts new file mode 100644 index 00000000..f8a202a1 --- /dev/null +++ b/packages/asset-system/src/interfaces/IAssetFileLoader.ts @@ -0,0 +1,103 @@ +/** + * Asset File Loader Interface + * 资产文件加载器接口 + * + * High-level file loading abstraction that combines path resolution + * with platform-specific file reading. + * 高级文件加载抽象,结合路径解析和平台特定的文件读取。 + * + * This is the unified entry point for all file loading in the engine. + * Different from IAssetLoader (which parses content), this interface + * handles the actual file fetching from asset paths. + * 这是引擎中所有文件加载的统一入口。 + * 与 IAssetLoader(解析内容)不同,此接口处理从资产路径获取文件。 + */ + +/** + * Asset file loader interface. + * 资产文件加载器接口。 + * + * Provides a unified API for loading files from asset paths (relative to project). + * Different platforms provide their own implementations. + * 提供从资产路径(相对于项目)加载文件的统一 API。 + * 不同平台提供各自的实现。 + * + * @example + * ```typescript + * // Get global loader + * const loader = getGlobalAssetFileLoader(); + * + * // Load image from asset path (relative to project) + * const image = await loader.loadImage('assets/demo/button.png'); + * + * // Load text content + * const json = await loader.loadText('assets/config.json'); + * ``` + */ +export interface IAssetFileLoader { + /** + * Load image from asset path. + * 从资产路径加载图片。 + * + * @param assetPath - Asset path relative to project (e.g., "assets/demo/button.png"). + * 相对于项目的资产路径。 + * @returns Promise resolving to HTMLImageElement. | 返回 HTMLImageElement 的 Promise。 + */ + loadImage(assetPath: string): Promise; + + /** + * Load text content from asset path. + * 从资产路径加载文本内容。 + * + * @param assetPath - Asset path relative to project. | 相对于项目的资产路径。 + * @returns Promise resolving to text content. | 返回文本内容的 Promise。 + */ + loadText(assetPath: string): Promise; + + /** + * Load binary data from asset path. + * 从资产路径加载二进制数据。 + * + * @param assetPath - Asset path relative to project. | 相对于项目的资产路径。 + * @returns Promise resolving to ArrayBuffer. | 返回 ArrayBuffer 的 Promise。 + */ + loadBinary(assetPath: string): Promise; + + /** + * Check if asset file exists. + * 检查资产文件是否存在。 + * + * @param assetPath - Asset path relative to project. | 相对于项目的资产路径。 + * @returns Promise resolving to boolean. | 返回布尔值的 Promise。 + */ + exists(assetPath: string): Promise; +} + +/** + * Global asset file loader instance. + * 全局资产文件加载器实例。 + */ +let globalAssetFileLoader: IAssetFileLoader | null = null; + +/** + * Set the global asset file loader. + * 设置全局资产文件加载器。 + * + * Should be called during engine initialization with platform-specific implementation. + * 应在引擎初始化期间使用平台特定的实现调用。 + * + * @param loader - Asset file loader instance or null. | 资产文件加载器实例或 null。 + */ +export function setGlobalAssetFileLoader(loader: IAssetFileLoader | null): void { + globalAssetFileLoader = loader; +} + +/** + * Get the global asset file loader. + * 获取全局资产文件加载器。 + * + * @returns Asset file loader instance or null. | 资产文件加载器实例或 null。 + */ +export function getGlobalAssetFileLoader(): IAssetFileLoader | null { + return globalAssetFileLoader; +} diff --git a/packages/asset-system/src/interfaces/IAssetLoader.ts b/packages/asset-system/src/interfaces/IAssetLoader.ts index b9beabb9..d774552c 100644 --- a/packages/asset-system/src/interfaces/IAssetLoader.ts +++ b/packages/asset-system/src/interfaces/IAssetLoader.ts @@ -144,6 +144,24 @@ export interface ITextureAsset { hasMipmaps: boolean; /** 原始数据(如果可用) / Raw image data if available */ data?: ImageData | HTMLImageElement; + + // ===== Sprite Settings ===== + // ===== Sprite 设置 ===== + + /** + * 九宫格切片边距 [top, right, bottom, left] + * Nine-patch slice border + * + * Defines the non-stretchable borders for nine-patch rendering. + * 定义九宫格渲染时不可拉伸的边框区域。 + */ + sliceBorder?: [number, number, number, number]; + + /** + * Sprite 锚点 [x, y](0-1 归一化) + * Sprite pivot point (0-1 normalized) + */ + pivot?: [number, number]; } /** @@ -183,24 +201,109 @@ export interface IAudioAsset { channels: number; } +/** + * Shader property type + * 着色器属性类型 + */ +export type ShaderPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'sampler2D' | 'mat3' | 'mat4'; + +/** + * Shader property definition + * 着色器属性定义 + */ +export interface IShaderProperty { + /** 属性名称(uniform 名) / Property name (uniform name) */ + name: string; + /** 属性类型 / Property type */ + type: ShaderPropertyType; + /** 默认值 / Default value */ + default: number | number[]; + /** 显示名称(编辑器用) / Display name for editor */ + displayName?: string; + /** 值范围(用于 float/int) / Value range for float/int */ + range?: [number, number]; + /** 是否隐藏(内部使用) / Hidden from inspector */ + hidden?: boolean; +} + +/** + * Shader asset interface + * 着色器资产接口 + * + * Shader assets contain GLSL source code and property definitions. + * 着色器资产包含 GLSL 源代码和属性定义。 + */ +export interface IShaderAsset { + /** 着色器名称 / Shader name (e.g., "UI/Shiny") */ + name: string; + /** 顶点着色器源代码 / Vertex shader GLSL source */ + vertex: string; + /** 片段着色器源代码 / Fragment shader GLSL source */ + fragment: string; + /** 属性定义列表 / Property definitions */ + properties: IShaderProperty[]; + /** 编译后的着色器 ID(运行时填充) / Compiled shader ID (runtime) */ + shaderId?: number; +} + +/** + * Material property value + * 材质属性值 + */ +export type MaterialPropertyValue = number | number[] | string; + +/** + * Material animator configuration + * 材质动画器配置 + */ +export interface IMaterialAnimator { + /** 要动画的属性名 / Property to animate */ + property: string; + /** 起始值 / Start value */ + from: number; + /** 结束值 / End value */ + to: number; + /** 持续时间(秒) / Duration in seconds */ + duration: number; + /** 是否循环 / Loop animation */ + loop?: boolean; + /** 循环间隔(秒) / Delay between loops */ + loopDelay?: number; + /** 缓动函数 / Easing function */ + easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'; + /** 是否自动播放 / Auto play on start */ + autoPlay?: boolean; +} + /** * Material asset interface * 材质资产接口 + * + * Material assets reference a shader and define property values. + * 材质资产引用着色器并定义属性值。 */ export interface IMaterialAsset { - /** 着色器名称 / Shader name */ + /** 材质名称 / Material name */ + name: string; + /** 着色器 GUID 或内置路径 / Shader GUID or built-in path (e.g., "builtin://shaders/Shiny") */ shader: string; - /** 材质属性 / Material properties */ - properties: Map; - /** 纹理映射 / Texture slot mappings */ - textures: Map; + /** 材质属性值 / Material property values */ + properties: Record; + /** 纹理映射 / Texture slot mappings (property name -> texture GUID) */ + textures?: Record; /** 渲染状态 / Render states */ - renderStates: { + renderStates?: { cullMode?: 'none' | 'front' | 'back'; - blendMode?: 'none' | 'alpha' | 'additive' | 'multiply'; + blendMode?: 'none' | 'alpha' | 'additive' | 'multiply' | 'screen'; depthTest?: boolean; depthWrite?: boolean; }; + /** 动画器配置(可选) / Animator configuration (optional) */ + animator?: IMaterialAnimator; + /** 运行时:编译后的着色器 ID / Runtime: compiled shader ID */ + _shaderId?: number; + /** 运行时:引擎材质 ID / Runtime: engine material ID */ + _materialId?: number; } // 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file diff --git a/packages/asset-system/src/loaders/AssetLoaderFactory.ts b/packages/asset-system/src/loaders/AssetLoaderFactory.ts index 855fbf89..70f75e6f 100644 --- a/packages/asset-system/src/loaders/AssetLoaderFactory.ts +++ b/packages/asset-system/src/loaders/AssetLoaderFactory.ts @@ -46,6 +46,9 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { // 预制体加载器 / Prefab loader this._loaders.set(AssetType.Prefab, new PrefabLoader()); + + // 注:Shader 和 Material 加载器由 material-system 模块注册 + // Note: Shader and Material loaders are registered by material-system module } /** diff --git a/packages/asset-system/src/loaders/TextureLoader.ts b/packages/asset-system/src/loaders/TextureLoader.ts index 2e9f4e23..8ee4b3b9 100644 --- a/packages/asset-system/src/loaders/TextureLoader.ts +++ b/packages/asset-system/src/loaders/TextureLoader.ts @@ -16,6 +16,16 @@ interface IEngineBridgeGlobal { unloadTexture?(textureId: number): void; } +/** + * Sprite settings from texture meta + * 纹理 meta 中的 Sprite 设置 + */ +interface ISpriteSettings { + sliceBorder?: [number, number, number, number]; + pivot?: [number, number]; + pixelsPerUnit?: number; +} + /** * 获取全局引擎桥接 * Get global engine bridge @@ -61,13 +71,22 @@ export class TextureLoader implements IAssetLoader { const image = content.image; + // Read sprite settings from import settings + // 从导入设置读取 sprite 设置 + const importSettings = context.metadata.importSettings as Record | undefined; + const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined; + const textureAsset: ITextureAsset = { textureId: TextureLoader._nextTextureId++, width: image.width, height: image.height, format: 'rgba', hasMipmaps: false, - data: image + data: image, + // Include sprite settings if available + // 如果有则包含 sprite 设置 + sliceBorder: spriteSettings?.sliceBorder, + pivot: spriteSettings?.pivot }; // Upload to GPU if bridge exists. diff --git a/packages/asset-system/src/services/AssetMetadataService.ts b/packages/asset-system/src/services/AssetMetadataService.ts new file mode 100644 index 00000000..57b25fd9 --- /dev/null +++ b/packages/asset-system/src/services/AssetMetadataService.ts @@ -0,0 +1,139 @@ +/** + * Asset Metadata Service + * 资产元数据服务 + * + * Provides global access to asset metadata without requiring asset loading. + * This service is independent of the texture loading path, allowing + * render systems to query sprite info regardless of how textures are loaded. + * + * 提供对资产元数据的全局访问,无需加载资产。 + * 此服务独立于纹理加载路径,允许渲染系统查询 sprite 信息, + * 无论纹理是如何加载的。 + */ + +import { AssetDatabase, ITextureSpriteInfo } from '../core/AssetDatabase'; +import type { AssetGUID } from '../types/AssetTypes'; +import type { ITextureEngineBridge } from '../integration/EngineIntegration'; + +/** + * Global asset database instance + * 全局资产数据库实例 + */ +let globalAssetDatabase: AssetDatabase | null = null; + +/** + * Global engine bridge instance + * 全局引擎桥实例 + * + * Used to query texture dimensions from Rust engine (single source of truth). + * 用于从 Rust 引擎查询纹理尺寸(唯一事实来源)。 + */ +let globalEngineBridge: ITextureEngineBridge | null = null; + +/** + * Set the global asset database + * 设置全局资产数据库 + * + * Should be called during engine initialization. + * 应在引擎初始化期间调用。 + * + * @param database - AssetDatabase instance | AssetDatabase 实例 + */ +export function setGlobalAssetDatabase(database: AssetDatabase | null): void { + globalAssetDatabase = database; +} + +/** + * Get the global asset database + * 获取全局资产数据库 + * + * @returns AssetDatabase instance or null | AssetDatabase 实例或 null + */ +export function getGlobalAssetDatabase(): AssetDatabase | null { + return globalAssetDatabase; +} + +/** + * Set the global engine bridge + * 设置全局引擎桥 + * + * The engine bridge is used to query texture dimensions directly from Rust engine. + * This is the single source of truth for texture dimensions. + * 引擎桥用于直接从 Rust 引擎查询纹理尺寸。 + * 这是纹理尺寸的唯一事实来源。 + * + * @param bridge - ITextureEngineBridge instance | ITextureEngineBridge 实例 + */ +export function setGlobalEngineBridge(bridge: ITextureEngineBridge | null): void { + globalEngineBridge = bridge; +} + +/** + * Get the global engine bridge + * 获取全局引擎桥 + * + * @returns ITextureEngineBridge instance or null | ITextureEngineBridge 实例或 null + */ +export function getGlobalEngineBridge(): ITextureEngineBridge | null { + return globalEngineBridge; +} + +/** + * Get texture sprite info by GUID + * 通过 GUID 获取纹理 Sprite 信息 + * + * This is the primary API for render systems to query nine-patch/sprite info. + * It combines data from: + * - Asset metadata (sliceBorder, pivot) from AssetDatabase + * - Texture dimensions (width, height) from Rust engine (single source of truth) + * + * 这是渲染系统查询九宫格/sprite 信息的主要 API。 + * 它合并来自: + * - AssetDatabase 的资产元数据(sliceBorder, pivot) + * - Rust 引擎的纹理尺寸(width, height)(唯一事实来源) + * + * @param guid - Texture asset GUID | 纹理资产 GUID + * @returns Sprite info or undefined | Sprite 信息或 undefined + */ +export function getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined { + // Get sprite settings from metadata + // 从元数据获取 sprite 设置 + const metadataInfo = globalAssetDatabase?.getTextureSpriteInfo(guid); + + // Get texture dimensions from Rust engine (single source of truth) + // 从 Rust 引擎获取纹理尺寸(唯一事实来源) + let dimensions: { width: number; height: number } | undefined; + + if (globalEngineBridge?.getTextureInfoByPath && globalAssetDatabase) { + // Get asset path from database + // 从数据库获取资产路径 + const metadata = globalAssetDatabase.getMetadata(guid); + if (metadata?.path) { + const engineInfo = globalEngineBridge.getTextureInfoByPath(metadata.path); + if (engineInfo) { + dimensions = engineInfo; + } + } + } + + // If no metadata and no dimensions, return undefined + // 如果没有元数据也没有尺寸,返回 undefined + if (!metadataInfo && !dimensions) { + return undefined; + } + + // Merge the two sources + // 合并两个数据源 + // Prefer engine dimensions (runtime loaded), fallback to metadata dimensions (catalog stored) + // 优先使用引擎尺寸(运行时加载),后备使用元数据尺寸(目录存储) + return { + sliceBorder: metadataInfo?.sliceBorder, + pivot: metadataInfo?.pivot, + width: dimensions?.width ?? metadataInfo?.width, + height: dimensions?.height ?? metadataInfo?.height + }; +} + +// Re-export type for convenience +// 为方便起见重新导出类型 +export type { ITextureSpriteInfo }; diff --git a/packages/asset-system/src/types/AssetTypes.ts b/packages/asset-system/src/types/AssetTypes.ts index f92d1144..614bcf96 100644 --- a/packages/asset-system/src/types/AssetTypes.ts +++ b/packages/asset-system/src/types/AssetTypes.ts @@ -406,6 +406,12 @@ export interface IAssetCatalogEntry { /** 可用变体 / Available variants (platform/quality specific) */ variants?: IAssetVariant[]; + + /** + * Import settings (e.g., sprite slicing for nine-patch) + * 导入设置(如九宫格切片信息) + */ + importSettings?: Record; } /** diff --git a/packages/core/src/ECS/Decorators/PropertyDecorator.ts b/packages/core/src/ECS/Decorators/PropertyDecorator.ts index 1ca60948..199dbe36 100644 --- a/packages/core/src/ECS/Decorators/PropertyDecorator.ts +++ b/packages/core/src/ECS/Decorators/PropertyDecorator.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; -export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask'; +export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask' | 'entityRef'; /** * 属性资源类型 @@ -52,6 +52,16 @@ interface PropertyOptionsBase { label?: string; /** 是否只读 | Read-only flag */ readOnly?: boolean; + /** + * 是否在 Inspector 中隐藏 + * Whether to hide this property in Inspector + * + * Hidden properties are still serialized but not shown in the default PropertyInspector. + * Useful when a custom Inspector handles the property. + * 隐藏的属性仍然会被序列化,但不会在默认的 PropertyInspector 中显示。 + * 适用于自定义 Inspector 处理该属性的情况。 + */ + hidden?: boolean; /** Action buttons | 操作按钮 */ actions?: PropertyAction[]; /** 此属性控制的其他组件属性 | Properties this field controls */ @@ -193,6 +203,17 @@ interface CollisionMaskPropertyOptions extends PropertyOptionsBase { type: 'collisionMask'; } +/** + * 实体引用属性选项 + * Entity reference property options + * + * Used for properties that store entity IDs and support drag-and-drop from SceneHierarchy. + * 用于存储实体 ID 的属性,支持从场景层级面板拖放。 + */ +interface EntityRefPropertyOptions extends PropertyOptionsBase { + type: 'entityRef'; +} + /** * 属性选项联合类型 * Property options union type @@ -208,7 +229,8 @@ export type PropertyOptions = | ArrayPropertyOptions | AnimationClipsPropertyOptions | CollisionLayerPropertyOptions - | CollisionMaskPropertyOptions; + | CollisionMaskPropertyOptions + | EntityRefPropertyOptions; // 使用 Symbol.for 创建全局 Symbol,确保跨包共享元数据 // Use Symbol.for to create a global Symbol to ensure metadata sharing across packages diff --git a/packages/core/src/ECS/Decorators/TypeDecorators.ts b/packages/core/src/ECS/Decorators/TypeDecorators.ts index 20c35deb..c273066e 100644 --- a/packages/core/src/ECS/Decorators/TypeDecorators.ts +++ b/packages/core/src/ECS/Decorators/TypeDecorators.ts @@ -112,6 +112,21 @@ export interface SystemMetadata { * Whether enabled by default (default true) */ enabled?: boolean; + + /** + * 是否在编辑模式下运行(默认 true) + * Whether to run in edit mode (default true) + * + * 默认情况下,所有系统在编辑模式下都会运行。 + * 当设置为 false 时,此系统在编辑模式(非 Play 状态)下不会执行。 + * 适用于物理系统、AI 系统等只应在游戏运行时执行的系统。 + * + * By default, all systems run in edit mode. + * When set to false, this system will NOT execute during edit mode + * (when not playing). Useful for physics, AI, and other systems + * that should only run during gameplay. + */ + runInEditMode?: boolean; } /** @@ -166,6 +181,17 @@ export function getSystemMetadata(systemType: new (...args: any[]) => EntitySyst return (systemType as any).__systemMetadata__; } +/** + * 从系统实例获取元数据 + * Get metadata from system instance + * + * @param system 系统实例 | System instance + * @returns 系统元数据 | System metadata + */ +export function getSystemInstanceMetadata(system: EntitySystem): SystemMetadata | undefined { + return getSystemMetadata(system.constructor as new (...args: any[]) => EntitySystem); +} + /** * 获取系统类型的名称,优先使用装饰器指定的名称 * Get system type name, preferring decorator-specified name diff --git a/packages/core/src/ECS/Decorators/index.ts b/packages/core/src/ECS/Decorators/index.ts index b6f5d010..a6101780 100644 --- a/packages/core/src/ECS/Decorators/index.ts +++ b/packages/core/src/ECS/Decorators/index.ts @@ -28,6 +28,7 @@ export { getSystemTypeName, getSystemInstanceTypeName, getSystemMetadata, + getSystemInstanceMetadata, SYSTEM_TYPE_NAME } from './TypeDecorators'; diff --git a/packages/core/src/ECS/Scene.ts b/packages/core/src/ECS/Scene.ts index 4ed97e72..8cb4a4c0 100644 --- a/packages/core/src/ECS/Scene.ts +++ b/packages/core/src/ECS/Scene.ts @@ -13,7 +13,7 @@ import { QuerySystem } from './Core/QuerySystem'; import { TypeSafeEventSystem } from './Core/EventSystem'; import { ReferenceTracker } from './Core/ReferenceTracker'; import { IScene, ISceneConfig } from './IScene'; -import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata } from './Decorators'; +import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata, getSystemInstanceMetadata } from './Decorators'; import { TypedQueryBuilder } from './Core/Query/TypedQuery'; import { SceneSerializer, @@ -558,7 +558,7 @@ export class Scene implements IScene { const updateHandle = ProfilerSDK.beginSample('Systems.update', ProfileCategory.ECS); try { for (const system of systems) { - if (system.enabled) { + if (this._shouldSystemRun(system)) { const systemHandle = ProfilerSDK.beginSample(system.systemName, ProfileCategory.ECS); try { system.update(); @@ -577,7 +577,7 @@ export class Scene implements IScene { const lateUpdateHandle = ProfilerSDK.beginSample('Systems.lateUpdate', ProfileCategory.ECS); try { for (const system of systems) { - if (system.enabled) { + if (this._shouldSystemRun(system)) { const systemHandle = ProfilerSDK.beginSample(`${system.systemName}.late`, ProfileCategory.ECS); try { system.lateUpdate(); @@ -602,6 +602,34 @@ export class Scene implements IScene { } } + /** + * 检查系统是否应该运行 + * Check if a system should run + * + * @param system 要检查的系统 | System to check + * @returns 是否应该运行 | Whether it should run + */ + private _shouldSystemRun(system: EntitySystem): boolean { + // 系统必须启用 + // System must be enabled + if (!system.enabled) { + return false; + } + + // 非编辑模式下,所有启用的系统都运行 + // In non-edit mode, all enabled systems run + if (!this.isEditorMode) { + return true; + } + + // 编辑模式下,默认所有系统都运行 + // 只有明确标记 runInEditMode: false 的系统不运行 + // In edit mode, all systems run by default + // Only systems explicitly marked runInEditMode: false are skipped + const metadata = getSystemInstanceMetadata(system); + return metadata?.runInEditMode !== false; + } + /** * 执行所有系统的延迟命令 * Flush all systems' deferred commands diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index 4e22aad0..202cbecb 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -384,17 +384,33 @@ export class EngineBridge implements ITextureEngineBridge { } /** - * Get texture information. - * 获取纹理信息。 + * Get texture info by path. + * 通过路径获取纹理信息。 * - * @param id - Texture ID | 纹理ID + * This is the primary API for getting texture dimensions. + * The Rust engine is the single source of truth for texture dimensions. + * 这是获取纹理尺寸的主要 API。 + * Rust 引擎是纹理尺寸的唯一事实来源。 + * + * @param path - Image path/URL | 图片路径/URL + * @returns Texture info or null if not loaded | 纹理信息或未加载则为 null */ - getTextureInfo(id: number): { width: number; height: number } | null { + getTextureInfoByPath(path: string): { width: number; height: number } | null { if (!this.initialized) return null; - // TODO: Implement in Rust engine - // TODO: 在Rust引擎中实现 - // Return default values for now / 暂时返回默认值 - return { width: 64, height: 64 }; + + // Resolve path if resolver is set + // 如果设置了解析器,则解析路径 + const resolvedPath = this.pathResolver ? this.pathResolver(path) : path; + + // Query Rust engine for texture size + // 向 Rust 引擎查询纹理尺寸 + const result = this.getEngine().getTextureSizeByPath(resolvedPath); + if (!result) return null; + + return { + width: result[0], + height: result[1] + }; } /** @@ -1010,6 +1026,302 @@ export class EngineBridge implements ITextureEngineBridge { }); } + // ===== Shader API ===== + // ===== 着色器 API ===== + + /** + * Compile and register a custom shader program. + * 编译并注册自定义着色器程序。 + * + * @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码 + * @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码 + * @returns Promise resolving to shader ID | 解析为着色器 ID 的 Promise + */ + async compileShader(vertexSource: string, fragmentSource: string): Promise { + if (!this.initialized) throw new Error('Engine not initialized'); + return this.getEngine().compileShader(vertexSource, fragmentSource); + } + + /** + * Compile and register a shader with a specific ID. + * 使用特定 ID 编译并注册着色器。 + * + * @param shaderId - Desired shader ID | 期望的着色器 ID + * @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码 + * @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码 + */ + async compileShaderWithId(shaderId: number, vertexSource: string, fragmentSource: string): Promise { + if (!this.initialized) throw new Error('Engine not initialized'); + this.getEngine().compileShaderWithId(shaderId, vertexSource, fragmentSource); + } + + /** + * Check if a shader exists. + * 检查着色器是否存在。 + * + * @param shaderId - Shader ID to check | 要检查的着色器 ID + */ + hasShader(shaderId: number): boolean { + if (!this.initialized) return false; + return this.getEngine().hasShader(shaderId); + } + + /** + * Remove a shader. + * 移除着色器。 + * + * @param shaderId - Shader ID to remove | 要移除的着色器 ID + */ + removeShader(shaderId: number): boolean { + if (!this.initialized) return false; + return this.getEngine().removeShader(shaderId); + } + + // ===== Material Management API ===== + // ===== 材质管理 API ===== + + /** + * Create a new material. + * 创建新材质。 + * + * @param name - Material name | 材质名称 + * @param shaderId - Shader ID to use | 使用的着色器 ID + * @param blendMode - Blend mode | 混合模式 + * @returns Material ID | 材质 ID + */ + createMaterial(name: string, shaderId: number, blendMode: number): number { + if (!this.initialized) return -1; + return this.getEngine().createMaterial(name, shaderId, blendMode); + } + + /** + * Create a material with a specific ID. + * 使用特定 ID 创建材质。 + * + * @param materialId - Desired material ID | 期望的材质 ID + * @param name - Material name | 材质名称 + * @param shaderId - Shader ID to use | 使用的着色器 ID + * @param blendMode - Blend mode | 混合模式 + */ + createMaterialWithId(materialId: number, name: string, shaderId: number, blendMode: number): void { + if (!this.initialized) return; + this.getEngine().createMaterialWithId(materialId, name, shaderId, blendMode); + } + + /** + * Check if a material exists. + * 检查材质是否存在。 + * + * @param materialId - Material ID to check | 要检查的材质 ID + */ + hasMaterial(materialId: number): boolean { + if (!this.initialized) return false; + return this.getEngine().hasMaterial(materialId); + } + + /** + * Remove a material. + * 移除材质。 + * + * @param materialId - Material ID to remove | 要移除的材质 ID + */ + removeMaterial(materialId: number): boolean { + if (!this.initialized) return false; + return this.getEngine().removeMaterial(materialId); + } + + // ===== Material Uniform API ===== + // ===== 材质 Uniform API ===== + + /** + * Set a float uniform on a material. + * 设置材质的浮点 uniform。 + * + * @param materialId - Material ID | 材质 ID + * @param name - Uniform name | Uniform 名称 + * @param value - Float value | 浮点值 + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialFloat(materialId: number, name: string, value: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialFloat(materialId, name, value); + } + + /** + * Set a vec2 uniform on a material. + * 设置材质的 vec2 uniform。 + * + * @param materialId - Material ID | 材质 ID + * @param name - Uniform name | Uniform 名称 + * @param x - X component | X 分量 + * @param y - Y component | Y 分量 + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialVec2(materialId: number, name: string, x: number, y: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialVec2(materialId, name, x, y); + } + + /** + * Set a vec3 uniform on a material. + * 设置材质的 vec3 uniform。 + * + * @param materialId - Material ID | 材质 ID + * @param name - Uniform name | Uniform 名称 + * @param x - X component | X 分量 + * @param y - Y component | Y 分量 + * @param z - Z component | Z 分量 + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialVec3(materialId: number, name: string, x: number, y: number, z: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialVec3(materialId, name, x, y, z); + } + + /** + * Set a vec4 uniform on a material. + * 设置材质的 vec4 uniform。 + * + * @param materialId - Material ID | 材质 ID + * @param name - Uniform name | Uniform 名称 + * @param x - X component | X 分量 + * @param y - Y component | Y 分量 + * @param z - Z component | Z 分量 + * @param w - W component | W 分量 + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialVec4(materialId: number, name: string, x: number, y: number, z: number, w: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialVec4(materialId, name, x, y, z, w); + } + + /** + * Set a color uniform on a material. + * 设置材质的颜色 uniform。 + * + * @param materialId - Material ID | 材质 ID + * @param name - Uniform name | Uniform 名称 + * @param r - Red component (0-1) | 红色分量 (0-1) + * @param g - Green component (0-1) | 绿色分量 (0-1) + * @param b - Blue component (0-1) | 蓝色分量 (0-1) + * @param a - Alpha component (0-1) | Alpha 分量 (0-1) + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialColor(materialId: number, name: string, r: number, g: number, b: number, a: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialColor(materialId, name, r, g, b, a); + } + + /** + * Set a material's blend mode. + * 设置材质的混合模式。 + * + * @param materialId - Material ID | 材质 ID + * @param blendMode - Blend mode (0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha) + * 混合模式 (0=无, 1=Alpha, 2=叠加, 3=正片叠底, 4=滤色, 5=预乘Alpha) + * @returns Whether the operation succeeded | 操作是否成功 + */ + setMaterialBlendMode(materialId: number, blendMode: number): boolean { + if (!this.initialized) return false; + return this.getEngine().setMaterialBlendMode(materialId, blendMode); + } + + // ===== Dynamic Atlas API ===== + // ===== 动态图集 API ===== + + /** + * Create a blank texture for dynamic atlas. + * 为动态图集创建空白纹理。 + * + * This creates a texture that can be filled later using `updateTextureRegion`. + * Used for runtime atlas generation to batch UI elements with different textures. + * 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。 + * 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。 + * + * @param width - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048) + * @param height - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048) + * @returns Texture ID for the created blank texture | 创建的空白纹理ID + */ + createBlankTexture(width: number, height: number): number { + if (!this.initialized) return -1; + return this.getEngine().createBlankTexture(width, height); + } + + /** + * Update a region of an existing texture with pixel data. + * 使用像素数据更新现有纹理的区域。 + * + * This is used for dynamic atlas to copy individual textures into the atlas. + * 用于动态图集将单个纹理复制到图集纹理中。 + * + * @param id - The texture ID to update | 要更新的纹理ID + * @param x - X offset in the texture | 纹理中的X偏移 + * @param y - Y offset in the texture | 纹理中的Y偏移 + * @param width - Width of the region to update | 要更新的区域宽度 + * @param height - Height of the region to update | 要更新的区域高度 + * @param pixels - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节) + */ + updateTextureRegion( + id: number, + x: number, + y: number, + width: number, + height: number, + pixels: Uint8Array + ): void { + if (!this.initialized) return; + this.getEngine().updateTextureRegion(id, x, y, width, height, pixels); + } + + /** + * Apply material overrides to a material. + * 将材质覆盖应用到材质。 + * + * @param materialId - Material ID | 材质 ID + * @param overrides - Material property overrides | 材质属性覆盖 + */ + applyMaterialOverrides(materialId: number, overrides: Record): void { + if (!this.initialized || !overrides) return; + + for (const [name, override] of Object.entries(overrides)) { + const { type, value } = override; + + switch (type) { + case 'float': + this.setMaterialFloat(materialId, name, value as number); + break; + case 'vec2': + { + const v = value as number[]; + this.setMaterialVec2(materialId, name, v[0], v[1]); + } + break; + case 'vec3': + { + const v = value as number[]; + this.setMaterialVec3(materialId, name, v[0], v[1], v[2]); + } + break; + case 'vec4': + { + const v = value as number[]; + this.setMaterialVec4(materialId, name, v[0], v[1], v[2], v[3]); + } + break; + case 'color': + { + const v = value as number[]; + this.setMaterialColor(materialId, name, v[0], v[1], v[2], v[3] ?? 1.0); + } + break; + case 'int': + // Int is passed as float | Int 作为 float 传递 + this.setMaterialFloat(materialId, name, value as number); + break; + } + } + } + /** * Dispose the bridge and release resources. * 销毁桥接并释放资源。 diff --git a/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts b/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts index 7064f1d5..7f722c82 100644 --- a/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts +++ b/packages/ecs-engine-bindgen/src/core/RenderBatcher.ts @@ -35,17 +35,16 @@ import type { SpriteRenderData } from '../types'; */ export class RenderBatcher { private sprites: SpriteRenderData[] = []; - private sortByZ = false; /** * Create a new render batcher. * 创建新的渲染批处理器。 * - * @param sortByZ - Whether to sort sprites by Z order | 是否按Z顺序排序精灵 + * Sprites are stored in insertion order. The caller is responsible + * for adding sprites in the correct render order (back-to-front for 2D). + * 精灵按插入顺序存储。调用者负责以正确的渲染顺序添加精灵(2D 中从后到前)。 */ - constructor(sortByZ = false) { - this.sortByZ = sortByZ; - } + constructor() {} /** * Add a sprite to the batch. @@ -71,18 +70,20 @@ export class RenderBatcher { * Get all sprites in the batch. * 获取批处理中的所有精灵。 * - * @returns Sorted array of sprites | 排序后的精灵数组 + * Sprites are returned in insertion order to preserve z-ordering. + * The rendering system is responsible for sorting sprites before adding them. + * 精灵按插入顺序返回以保持 z 顺序。 + * 渲染系统负责在添加精灵前对其进行排序。 + * + * @returns Array of sprites in insertion order | 按插入顺序排列的精灵数组 */ getSprites(): SpriteRenderData[] { - // Sort by material ID first, then texture ID for better batching - // 先按材质ID排序,再按纹理ID排序以获得更好的批处理效果 - if (!this.sortByZ) { - this.sprites.sort((a, b) => { - const materialDiff = (a.materialId || 0) - (b.materialId || 0); - if (materialDiff !== 0) return materialDiff; - return a.textureId - b.textureId; - }); - } + // NOTE: Previously sorted by materialId/textureId for batching optimization, + // but this broke z-ordering for UI elements where render order is critical. + // Sprites should be added in the correct render order by the caller. + // 注意:之前按 materialId/textureId 排序以优化批处理, + // 但这破坏了 UI 元素的 z 排序,而 UI 的渲染顺序至关重要。 + // 调用者应该以正确的渲染顺序添加精灵。 return this.sprites; } diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index cf288be1..3afca9ef 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -12,7 +12,7 @@ import { SpriteComponent } from '@esengine/sprite'; import type { EngineBridge } from '../core/EngineBridge'; import { RenderBatcher } from '../core/RenderBatcher'; import type { ITransformComponent } from '../core/SpriteRenderHelper'; -import type { SpriteRenderData } from '../types'; +import type { SpriteRenderData, MaterialOverrides } from '../types'; /** * Render data from a provider @@ -47,6 +47,10 @@ export interface ProviderRenderData { * Overrides sortingLayer's bScreenSpace setting, for particles that need dynamic render space. */ bScreenSpace?: boolean; + /** Material IDs for each primitive. | 每个原语的材质 ID。 */ + materialIds?: Uint32Array; + /** Material overrides (per-group). | 材质覆盖(按组)。 */ + materialOverrides?: MaterialOverrides; } /** @@ -132,6 +136,24 @@ export type GizmoDataProviderFn = ( */ export type HasGizmoProviderFn = (component: Component) => boolean; +/** + * Function type for getting highlight color for gizmo. + * Used to inject GizmoInteractionService functionality from editor layer. + * 获取 gizmo 高亮颜色的函数类型。 + * 用于从编辑器层注入 GizmoInteractionService 功能。 + */ +export type GizmoHighlightColorFn = ( + entityId: number, + baseColor: GizmoColorInternal, + isSelected: boolean +) => GizmoColorInternal; + +/** + * Function type for getting hovered entity ID. + * 获取悬停实体 ID 的函数类型。 + */ +export type GetHoveredEntityIdFn = () => number | null; + /** * Type for transform component constructor. * 变换组件构造函数类型。 @@ -198,6 +220,11 @@ export class EngineRenderSystem extends EntitySystem { private gizmoDataProvider: GizmoDataProviderFn | null = null; private hasGizmoProvider: HasGizmoProviderFn | null = null; + // Gizmo interaction functions (injected from editor layer) + // Gizmo 交互函数(从编辑器层注入) + private gizmoHighlightColorFn: GizmoHighlightColorFn | null = null; + private getHoveredEntityIdFn: GetHoveredEntityIdFn | null = null; + // UI Canvas boundary settings // UI 画布边界设置 private uiCanvasWidth: number = 0; @@ -218,6 +245,18 @@ export class EngineRenderSystem extends EntitySystem { // 为 false(编辑器模式)时,UI 在世界空间渲染,跟随编辑器相机 private previewMode: boolean = false; + // ===== Material Instance Management ===== + // ===== 材质实例管理 ===== + // Maps (baseMaterialId, overridesHash) → instanceMaterialId + // 映射 (基础材质ID, 覆盖哈希) → 实例材质ID + private materialInstanceMap: Map = new Map(); + // Next instance ID (starts at 10000 to avoid collision with built-in materials) + // 下一个实例 ID(从 10000 开始以避免与内置材质冲突) + private nextMaterialInstanceId: number = 10000; + // Track instances used this frame for cleanup + // 跟踪本帧使用的实例以便清理 + private usedInstancesThisFrame: Set = new Set(); + /** * Create a new engine render system. * 创建新的引擎渲染系统。 @@ -281,8 +320,10 @@ export class EngineRenderSystem extends EntitySystem { // Collect all render items separated by render space // 按渲染空间分离收集所有渲染项 - const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = []; - const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = []; + // addIndex is used for stable sorting when sortKeys are equal + // addIndex 用于当 sortKey 相等时实现稳定排序 + const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = []; + const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = []; // Collect sprites from entities (all in world space) // 收集实体的 sprites(都在世界空间) @@ -296,6 +337,24 @@ export class EngineRenderSystem extends EntitySystem { // 收集 UI 渲染数据 if (this.uiRenderDataProvider) { const uiRenderData = this.uiRenderDataProvider.getRenderData(); + // Use addIndex to preserve original order for stable sorting + // 使用 addIndex 保持原始顺序以实现稳定排序 + let uiAddIndex = 0; + + // DEBUG: 输出 UI 渲染数据 + // DEBUG: Output UI render data + if ((globalThis as any).__UI_RENDER_DEBUG__) { + console.log('[EngineRenderSystem] UI render batches:', uiRenderData.map((data, i) => ({ + index: i, + orderInLayer: data.orderInLayer, + sortingLayer: data.sortingLayer, + tileCount: data.tileCount, + sortKey: sortingLayerManager.getSortKey(data.sortingLayer, data.orderInLayer), + textureIds: Array.from(data.textureIds).slice(0, 3), // 只显示前3个 | Show first 3 only + textureGuid: data.textureGuid + }))); + } + for (const data of uiRenderData) { const uiSprites = this.convertProviderDataToSprites(data); if (uiSprites.length > 0) { @@ -303,9 +362,9 @@ export class EngineRenderSystem extends EntitySystem { // UI always goes to screen space in preview mode, world space in editor mode // UI 在预览模式下始终在屏幕空间,编辑器模式下在世界空间 if (this.previewMode) { - screenSpaceItems.push({ sortKey, sprites: uiSprites }); + screenSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ }); } else { - worldSpaceItems.push({ sortKey, sprites: uiSprites }); + worldSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ }); } } } @@ -320,6 +379,10 @@ export class EngineRenderSystem extends EntitySystem { if (this.previewMode && screenSpaceItems.length > 0) { this.renderScreenSpacePass(screenSpaceItems); } + + // ===== Cleanup unused material instances ===== + // ===== 清理未使用的材质实例 ===== + this.cleanupUnusedMaterialInstances(); } /** @@ -465,11 +528,29 @@ export class EngineRenderSystem extends EntitySystem { * 渲染世界空间内容。 */ private renderWorldSpacePass( - worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> + worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> ): void { // Sort by sortKey (lower values render first, appear behind) + // Use addIndex as secondary key for stable sorting when sortKeys are equal // 按 sortKey 排序(值越小越先渲染,显示在后面) - worldSpaceItems.sort((a, b) => a.sortKey - b.sortKey); + // 当 sortKey 相等时使用 addIndex 作为次要排序键以实现稳定排序 + worldSpaceItems.sort((a, b) => { + const diff = a.sortKey - b.sortKey; + if (diff !== 0) return diff; + return (a.addIndex ?? 0) - (b.addIndex ?? 0); + }); + + // DEBUG: 输出排序后的世界空间渲染项 + // DEBUG: Output sorted world space items + if ((globalThis as any).__UI_RENDER_DEBUG__) { + console.log('[EngineRenderSystem] World items after sort:', worldSpaceItems.map((item, i) => ({ + index: i, + sortKey: item.sortKey, + addIndex: item.addIndex, + spriteCount: item.sprites.length, + firstTextureId: item.sprites[0]?.textureId + }))); + } // Submit all sprites in sorted order // 按排序顺序提交所有 sprites @@ -481,6 +562,11 @@ export class EngineRenderSystem extends EntitySystem { if (!this.batcher.isEmpty) { const sprites = this.batcher.getSprites(); + + // Apply material overrides before rendering + // 在渲染前应用材质覆盖 + this.applySpriteMaterialOverrides(sprites); + this.bridge.submitSprites(sprites); } @@ -512,11 +598,15 @@ export class EngineRenderSystem extends EntitySystem { * 渲染屏幕空间内容(UI、屏幕覆盖层、模态层)。 */ private renderScreenSpacePass( - screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> + screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> ): void { - // Sort by sortKey - // 按 sortKey 排序 - screenSpaceItems.sort((a, b) => a.sortKey - b.sortKey); + // Sort by sortKey, use addIndex for stable sorting when equal + // 按 sortKey 排序,当相等时使用 addIndex 实现稳定排序 + screenSpaceItems.sort((a, b) => { + const diff = a.sortKey - b.sortKey; + if (diff !== 0) return diff; + return (a.addIndex ?? 0) - (b.addIndex ?? 0); + }); // Switch to screen space projection // 切换到屏幕空间投影 @@ -539,6 +629,11 @@ export class EngineRenderSystem extends EntitySystem { if (!this.batcher.isEmpty) { const sprites = this.batcher.getSprites(); + + // Apply material overrides before rendering + // 在渲染前应用材质覆盖 + this.applySpriteMaterialOverrides(sprites); + this.bridge.submitSprites(sprites); // Render overlay (without clearing screen) // 渲染叠加层(不清屏) @@ -550,6 +645,147 @@ export class EngineRenderSystem extends EntitySystem { this.bridge.popScreenSpaceMode(); } + /** + * Generate a hash key for material overrides. + * 为材质覆盖生成哈希键。 + * + * @param overrides - Material overrides | 材质覆盖 + * @returns Hash string | 哈希字符串 + */ + private hashMaterialOverrides(overrides: MaterialOverrides): string { + // Sort keys for consistent hashing + // 排序键以保持一致的哈希 + const sortedKeys = Object.keys(overrides).sort(); + const parts: string[] = []; + for (const key of sortedKeys) { + const override = overrides[key]; + if (override) { + const valueStr = Array.isArray(override.value) + ? override.value.map(v => v.toFixed(4)).join(',') + : override.value.toFixed(4); + parts.push(`${key}:${valueStr}`); + } + } + return parts.join('|'); + } + + /** + * Get or create a material instance for a specific base material + overrides combination. + * 为特定的基础材质+覆盖组合获取或创建材质实例。 + * + * This ensures each unique (baseMaterial, overrides) combination gets its own + * material instance, preventing shared material state issues. + * 这确保每个唯一的(基础材质,覆盖)组合都有自己的材质实例, + * 防止共享材质状态问题。 + * + * @param baseMaterialId - Base material ID (e.g., 1 for Grayscale) | 基础材质ID + * @param overrides - Material property overrides | 材质属性覆盖 + * @returns Instance material ID | 实例材质ID + */ + private getOrCreateMaterialInstance(baseMaterialId: number, overrides: MaterialOverrides): number { + const overridesHash = this.hashMaterialOverrides(overrides); + const instanceKey = `${baseMaterialId}:${overridesHash}`; + + // Check if instance already exists + // 检查实例是否已存在 + let instanceId = this.materialInstanceMap.get(instanceKey); + if (instanceId !== undefined) { + this.usedInstancesThisFrame.add(instanceId); + return instanceId; + } + + // Create new instance + // 创建新实例 + instanceId = this.nextMaterialInstanceId++; + this.materialInstanceMap.set(instanceKey, instanceId); + this.usedInstancesThisFrame.add(instanceId); + + // Clone the base material with the new ID + // 使用新ID克隆基础材质 + // For built-in materials, shaderId = materialId (1:1 mapping) + // 对于内置材质,shaderId = materialId(1:1 映射) + const shaderId = baseMaterialId; + const blendMode = 1; // Alpha blending + this.bridge.createMaterialWithId(instanceId, `Instance_${baseMaterialId}_${instanceId}`, shaderId, blendMode); + + // Apply overrides to the new instance + // 将覆盖应用到新实例 + this.bridge.applyMaterialOverrides(instanceId, overrides); + + return instanceId; + } + + /** + * Clean up unused material instances. + * 清理未使用的材质实例。 + * + * Called at the end of each frame to remove instances that were not used. + * 在每帧结束时调用,移除未使用的实例。 + */ + private cleanupUnusedMaterialInstances(): void { + const toRemove: string[] = []; + + for (const [key, instanceId] of this.materialInstanceMap.entries()) { + if (!this.usedInstancesThisFrame.has(instanceId)) { + this.bridge.removeMaterial(instanceId); + toRemove.push(key); + } + } + + for (const key of toRemove) { + this.materialInstanceMap.delete(key); + } + + // Clear the used set for next frame + // 清除已用集合以便下一帧 + this.usedInstancesThisFrame.clear(); + } + + /** + * Apply material overrides from sprites to the engine. + * 将 sprites 的材质覆盖应用到引擎。 + * + * For sprites with overrides, this creates unique material instances + * to ensure each sprite's overrides don't affect other sprites. + * 对于有覆盖的精灵,这会创建唯一的材质实例, + * 确保每个精灵的覆盖不会影响其他精灵。 + */ + private applySpriteMaterialOverrides(sprites: SpriteRenderData[]): void { + // Track which instance materials we've already applied overrides to this frame + // 跟踪本帧已应用覆盖的实例材质 + const appliedInstances = new Set(); + + for (const sprite of sprites) { + const baseMaterialId = sprite.materialId; + + // Skip if no material or no overrides + // 如果没有材质或没有覆盖,跳过 + if (!baseMaterialId || baseMaterialId <= 0 || !sprite.materialOverrides) { + continue; + } + + const overrideKeys = Object.keys(sprite.materialOverrides); + if (overrideKeys.length === 0) { + continue; + } + + // Get or create a unique material instance for this sprite's overrides + // 为此精灵的覆盖获取或创建唯一的材质实例 + const instanceId = this.getOrCreateMaterialInstance(baseMaterialId, sprite.materialOverrides); + + // Update the sprite to use the instance material + // 更新精灵以使用实例材质 + sprite.materialId = instanceId; + + // Apply overrides if not already done for this instance + // 如果尚未为此实例应用覆盖,则应用 + if (!appliedInstances.has(instanceId)) { + this.bridge.applyMaterialOverrides(instanceId, sprite.materialOverrides); + appliedInstances.add(instanceId); + } + } + } + /** * Convert provider render data to sprite render data array. * 将提供者渲染数据转换为 Sprite 渲染数据数组。 @@ -562,6 +798,11 @@ export class EngineRenderSystem extends EntitySystem { textureId = this.bridge.getOrLoadTextureByPath(this.resolveAssetPath(data.textureGuid)); } + // Check for material data + // 检查材质数据 + const hasMaterialIds = data.materialIds && data.materialIds.length > 0; + const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0; + const sprites: SpriteRenderData[] = []; for (let i = 0; i < data.tileCount; i++) { const tOffset = i * 7; @@ -587,6 +828,15 @@ export class EngineRenderSystem extends EntitySystem { color: data.colors[i] }; + // Add material data if present + // 如果存在材质数据,添加它 + if (hasMaterialIds) { + renderData.materialId = data.materialIds![i]; + } + if (hasMaterialOverrides) { + renderData.materialOverrides = data.materialOverrides; + } + sprites.push(renderData); } @@ -601,10 +851,15 @@ export class EngineRenderSystem extends EntitySystem { const scene = Core.scene; if (!scene || !this.gizmoDataProvider || !this.hasGizmoProvider) return; + // Get hovered entity ID for highlight + // 获取悬停的实体 ID 用于高亮 + const hoveredEntityId = this.getHoveredEntityIdFn?.() ?? null; + // Iterate all entities in the scene // 遍历场景中的所有实体 for (const entity of scene.entities.buffer) { const isSelected = this.selectedEntityIds.has(entity.id); + const isHovered = entity.id === hoveredEntityId; // Check each component for gizmo provider // 检查每个组件是否有 gizmo 提供者 @@ -613,6 +868,15 @@ export class EngineRenderSystem extends EntitySystem { try { const gizmoDataArray = this.gizmoDataProvider(component, entity, isSelected); for (const gizmoData of gizmoDataArray) { + // Apply hover highlight color if applicable + // 如果适用,应用悬停高亮颜色 + if (isHovered && this.gizmoHighlightColorFn) { + gizmoData.color = this.gizmoHighlightColorFn( + entity.id, + gizmoData.color, + isSelected + ); + } this.renderGizmoData(gizmoData); } } catch (e) { @@ -1037,6 +1301,26 @@ export class EngineRenderSystem extends EntitySystem { this.hasGizmoProvider = hasProvider; } + /** + * Set gizmo interaction functions. + * 设置 gizmo 交互函数。 + * + * This allows the editor layer to inject GizmoInteractionService functionality + * for hover highlighting and click selection. + * 这允许编辑器层注入 GizmoInteractionService 功能, + * 用于悬停高亮和点击选择。 + * + * @param highlightColorFn - Function to get highlight color for gizmo + * @param getHoveredEntityIdFn - Function to get currently hovered entity ID + */ + setGizmoInteraction( + highlightColorFn: GizmoHighlightColorFn, + getHoveredEntityIdFn: GetHoveredEntityIdFn + ): void { + this.gizmoHighlightColorFn = highlightColorFn; + this.getHoveredEntityIdFn = getHoveredEntityIdFn; + } + /** * Set gizmo visibility. * 设置Gizmo可见性。 diff --git a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts index 8896ac1c..db66b539 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -338,6 +338,23 @@ export class GameEngine { * 注销视口。 */ unregisterViewport(id: string): void; + /** + * Create a blank texture for dynamic atlas. + * 为动态图集创建空白纹理。 + * + * This creates a texture that can be filled later using `updateTextureRegion`. + * Used for runtime atlas generation to batch UI elements with different textures. + * 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。 + * 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。 + * + * # Arguments | 参数 + * * `width` - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048) + * * `height` - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048) + * + * # Returns | 返回 + * The texture ID for the created blank texture | 创建的空白纹理ID + */ + createBlankTexture(width: number, height: number): number; /** * Load texture by path, returning texture ID. * 按路径加载纹理,返回纹理ID。 @@ -346,6 +363,22 @@ export class GameEngine { * * `path` - Image path/URL to load | 要加载的图片路径/URL */ loadTextureByPath(path: string): number; + /** + * Update a region of an existing texture with pixel data. + * 使用像素数据更新现有纹理的区域。 + * + * This is used for dynamic atlas to copy individual textures into the atlas. + * 用于动态图集将单个纹理复制到图集纹理中。 + * + * # Arguments | 参数 + * * `id` - The texture ID to update | 要更新的纹理ID + * * `x` - X offset in the texture | 纹理中的X偏移 + * * `y` - Y offset in the texture | 纹理中的Y偏移 + * * `width` - Width of the region to update | 要更新的区域宽度 + * * `height` - Height of the region to update | 要更新的区域高度 + * * `pixels` - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节) + */ + updateTextureRegion(id: number, x: number, y: number, width: number, height: number, pixels: Uint8Array): void; /** * Compile a shader with a specific ID. * 使用特定ID编译着色器。 @@ -381,6 +414,17 @@ export class GameEngine { * 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。 */ clearTexturePathCache(): void; + /** + * Get texture size by path. + * 按路径获取纹理尺寸。 + * + * Returns an array [width, height] or null if not found. + * 返回数组 [width, height],如果未找到则返回 null。 + * + * # Arguments | 参数 + * * `path` - Image path to lookup | 要查找的图片路径 + */ + getTextureSizeByPath(path: string): Float32Array | undefined; /** * 获取正在加载中的纹理数量 * Get the number of textures currently loading @@ -448,6 +492,7 @@ export interface InitOutput { readonly gameengine_clearTexturePathCache: (a: number) => void; readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number]; readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; + readonly gameengine_createBlankTexture: (a: number, b: number, c: number) => [number, number, number]; readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number; readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void; readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number]; @@ -455,6 +500,7 @@ export interface InitOutput { readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number]; readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number; readonly gameengine_getTextureLoadingCount: (a: number) => number; + readonly gameengine_getTextureSizeByPath: (a: number, b: number, c: number) => any; readonly gameengine_getTextureState: (a: number, b: number) => [number, number]; readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number]; readonly gameengine_getViewportIds: (a: number) => [number, number]; @@ -494,6 +540,7 @@ export interface InitOutput { readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number]; readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void; readonly gameengine_updateInput: (a: number) => void; + readonly gameengine_updateTextureRegion: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number]; readonly gameengine_width: (a: number) => number; readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number]; readonly init: () => void; diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index f1a45593..9d022c3e 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -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 diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index 58aab876..63dd75b9 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -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 diff --git a/packages/editor-app/src/components/PropertyInspector.tsx b/packages/editor-app/src/components/PropertyInspector.tsx index 9b3969fa..644b091c 100644 --- a/packages/editor-app/src/components/PropertyInspector.tsx +++ b/packages/editor-app/src/components/PropertyInspector.tsx @@ -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 ( + handleChange(propertyName, newValue)} + /> + ); + case 'array': { const arrayMeta = metadata as { itemType?: { type: string; extensions?: string[]; assetType?: string }; diff --git a/packages/editor-app/src/components/SettingsWindow.tsx b/packages/editor-app/src/components/SettingsWindow.tsx index aa299000..7f0fda37 100644 --- a/packages/editor-app/src/components/SettingsWindow.tsx +++ b/packages/editor-app/src/components/SettingsWindow.tsx @@ -162,28 +162,25 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const initialValues = new Map(); 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 } })); diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 352c4625..eadf7e55 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -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 = {}; + const catalogEntries: Record }> = {}; 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 | 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; + } + } 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; + 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); diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.css b/packages/editor-app/src/components/debug/RenderDebugPanel.css index ff23ada2..d6f8cc7a 100644 --- a/packages/editor-app/src/components/debug/RenderDebugPanel.css +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.css @@ -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; +} diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.tsx b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx index e10b37bb..fac37022 100644 --- a/packages/editor-app/src/components/debug/RenderDebugPanel.tsx +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx @@ -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 = ({ visible, onC const [frameHistory, setFrameHistory] = useState([]); 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 = ({ visible, onC const canvasRef = useRef(null); const windowRef = useRef(null); + // 高亮相关 | Highlight related + const previousSelectedIdsRef = useRef(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 = ({ 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(); + 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 = { + '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 = ({ 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 = ({ 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 = ({ 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 = ({ visible, onC Systems: {snapshot?.particles?.length ?? 0} + {/* 动态图集统计 | Dynamic atlas stats */} + {snapshot?.atlasStats && ( +
snapshot.atlasStats?.enabled && setShowAtlasPreview(true)} + > + + + Atlas: {snapshot.atlasStats.enabled + ? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p` + : 'Off'} + +
+ )} {/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */} {!standalone &&
} + + {/* 图集预览弹窗 | Atlas preview modal */} + {showAtlasPreview && snapshot?.atlasStats?.pages && ( + setShowAtlasPreview(false)} + /> + )}
); }; @@ -788,12 +996,14 @@ interface EventItemProps { const EventItem: React.FC = ({ 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 ; case 'particle': return ; - case 'ui': return ; + case 'ui': return ; + case 'ui-batch': return ; case 'batch': return ; default: return ; } @@ -802,7 +1012,7 @@ const EventItem: React.FC = ({ event, depth, selected, onSelect, return ( <>
onSelect(event)} > @@ -814,8 +1024,8 @@ const EventItem: React.FC = ({ event, depth, selected, onSelect, )} {getTypeIcon()} - {event.name} - {event.drawCalls !== undefined && ( + {event.name} + {event.drawCalls !== undefined && event.drawCalls > 0 && ( {event.drawCalls} )}
@@ -948,6 +1158,8 @@ const EventDetails: React.FC = ({ event }) => { }); }, [event, data]); + const batchInfo = event.batchInfo; + return (
@@ -955,6 +1167,48 @@ const EventDetails: React.FC = ({ event }) => { + {/* UI 批次信息 | UI batch info */} + {event.type === 'ui-batch' && batchInfo && ( + <> +
Batch Break Reason
+ + +
Batch Properties
+ + + + + + + {batchInfo.reason !== 'first' && ( + <> +
How to Fix
+
+ {batchInfo.reason === 'sortingLayer' && ( + 将这些元素放在同一个排序层中 + )} + {batchInfo.reason === 'texture' && ( + 使用相同的纹理,或将纹理合并到图集中 + )} + {batchInfo.reason === 'material' && ( + 使用相同的材质/着色器 + )} +
+ + )} + + )} + {data && ( <>
Properties
@@ -971,6 +1225,17 @@ const EventDetails: React.FC = ({ event }) => { +
Material
+ + + {data.uniforms && Object.keys(data.uniforms).length > 0 && ( + <> +
Uniforms
+ + + )} +
Vertex Attributes
+ )} @@ -1018,6 +1283,19 @@ const EventDetails: React.FC = ({ event }) => { {/* UI 元素数据 | UI element data */} {event.type === 'ui' && data.entityName && ( <> + {/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */} + {data.isBatchBreaker && ( + <> +
⚡ Batch Breaker
+
+ 此元素导致了新的 Draw Call。 + {data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'} + {data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'} + {data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'} + {data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'} +
+ + )} @@ -1026,14 +1304,20 @@ const EventDetails: React.FC = ({ event }) => { - +
Sorting
+ + + {data.backgroundColor && ( )} {data.textureGuid && ( )} + {!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && ( + + )} {data.text && ( <>
Text
@@ -1041,6 +1325,17 @@ const EventDetails: React.FC = ({ event }) => { {data.fontSize && } )} +
Material
+ + + {data.uniforms && Object.keys(data.uniforms).length > 0 && ( + <> +
Uniforms
+ + + )} +
Vertex Attributes
+ )} @@ -1056,4 +1351,350 @@ const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }>
); +/** + * 格式化 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 }> = ({ uniforms }) => { + const entries = Object.entries(uniforms); + if (entries.length === 0) { + return ; + } + return ( + <> + {entries.map(([name, uniform]) => ( + + ))} + + ); +}; + +/** + * 图集预览弹窗组件 + * Atlas Preview Modal Component + */ +interface AtlasPreviewModalProps { + atlasStats: AtlasStats; + selectedPage: number; + onSelectPage: (page: number) => void; + onClose: () => void; +} + +const AtlasPreviewModal: React.FC = ({ + atlasStats, + selectedPage, + onSelectPage, + onClose +}) => { + const canvasRef = useRef(null); + const [hoveredEntry, setHoveredEntry] = useState(null); + const [loadedImages, setLoadedImages] = useState>(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(); + 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) => { + 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) => { + 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) => { + 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 ( +
+
e.stopPropagation()}> +
+ Dynamic Atlas Preview + +
+ + {/* 页面选择器 | Page selector */} + {atlasStats.pages.length > 1 && ( +
+ {atlasStats.pages.map((page, idx) => ( + + ))} +
+ )} + + {/* 图集可视化 | Atlas visualization */} +
+ { setHoveredEntry(null); setIsPanning(false); }} + onWheel={handleWheel} + onDoubleClick={handleDoubleClick} + style={{ cursor: isPanning ? 'grabbing' : 'grab' }} + /> +
+ + {/* 悬停信息 | Hover info */} +
+ {hoveredEntry ? ( + <> +
+ GUID: + {hoveredEntry.guid.slice(0, 8)}... +
+
+ Position: + ({hoveredEntry.x}, {hoveredEntry.y}) +
+
+ Size: + {hoveredEntry.width} x {hoveredEntry.height} +
+
+ UV: + [{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}] +
+ + ) : ( + Scroll to zoom, drag to pan, double-click to reset + )} +
+ + {/* 统计信息 | Statistics */} +
+ Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s) + Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}% + {atlasStats.loadingCount > 0 && Loading: {atlasStats.loadingCount}} + {atlasStats.failedCount > 0 && Failed: {atlasStats.failedCount}} +
+
+
+ ); +}; + export default RenderDebugPanel; diff --git a/packages/editor-app/src/components/inspectors/fields/EntityRefField.css b/packages/editor-app/src/components/inspectors/fields/EntityRefField.css new file mode 100644 index 00000000..2c71760a --- /dev/null +++ b/packages/editor-app/src/components/inspectors/fields/EntityRefField.css @@ -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; +} diff --git a/packages/editor-app/src/components/inspectors/fields/EntityRefField.tsx b/packages/editor-app/src/components/inspectors/fields/EntityRefField.tsx new file mode 100644 index 00000000..61ec65ef --- /dev/null +++ b/packages/editor-app/src/components/inspectors/fields/EntityRefField.tsx @@ -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 = ({ + 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 ( +
+ +
+ {entityName ? ( + <> + + {entityName} + + {!readonly && ( + + )} + + ) : ( + {placeholder} + )} +
+
+ ); +}; diff --git a/packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx b/packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx new file mode 100644 index 00000000..13310570 --- /dev/null +++ b/packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx @@ -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 = { + 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 = ({ + target, + onChange +}) => { + const [expandedGroups, setExpandedGroups] = useState>(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> = {}; + 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 ( +
+ {/* Shader selector */} +
+ + Shader: + +
+ + {/* Property groups */} + {Object.entries(groupedProps).map(([group, props]) => ( +
+ {/* Group header */} +
toggleGroup(group)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '4px 8px', + backgroundColor: '#333', + borderRadius: '3px', + cursor: 'pointer', + userSelect: 'none' + }} + > + {expandedGroups.has(group) ? : } + {group} +
+ + {/* Properties */} + {expandedGroups.has(group) && ( +
+ {props.map(([name, meta]) => ( + handleChange(name, meta, v)} + /> + ))} +
+ )} +
+ ))} + + {!properties && ( +
+ No editable properties for {shaderName} +
+ )} +
+ ); +}; + +interface PropertyEditorProps { + name: string; + meta: ShaderPropertyMeta; + value: number | number[]; + onChange: (value: number | number[]) => void; +} + +/** + * Individual property editor. + * 单个属性编辑器。 + */ +const PropertyEditor: React.FC = ({ 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 ( + 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 ( +
+ onChange([parseFloat(e.target.value) || 0, v2y])} + style={{ ...inputStyle, width: '50px' }} + /> + onChange([v2x, parseFloat(e.target.value) || 0])} + style={{ ...inputStyle, width: '50px' }} + /> +
+ ); + + case 'vec3': + const v3 = Array.isArray(value) ? value : [0, 0, 0]; + return ( +
+ {[0, 1, 2].map(i => ( + { + const newVal = [...v3]; + newVal[i] = parseFloat(e.target.value) || 0; + onChange(newVal); + }} + style={{ ...inputStyle, width: '40px' }} + /> + ))} +
+ ); + + case 'vec4': + const v4 = Array.isArray(value) ? value : [0, 0, 0, 0]; + return ( +
+ {[0, 1, 2, 3].map(i => ( + { + const newVal = [...v4]; + newVal[i] = parseFloat(e.target.value) || 0; + onChange(newVal); + }} + style={{ ...inputStyle, width: '35px' }} + /> + ))} +
+ ); + + 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 ( +
+ { + 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' }} + /> + onChange([cr, cg, cb, parseFloat(e.target.value) || 1])} + style={{ ...inputStyle, width: '40px' }} + title="Alpha" + /> +
+ ); + + default: + return Unsupported type: {meta.type}; + } + }; + + return ( +
+ + {displayName} + + {renderInput()} +
+ ); +}; + +export default MaterialPropertiesEditor; diff --git a/packages/editor-app/src/components/inspectors/material/index.ts b/packages/editor-app/src/components/inspectors/material/index.ts new file mode 100644 index 00000000..a362bc9d --- /dev/null +++ b/packages/editor-app/src/components/inspectors/material/index.ts @@ -0,0 +1,6 @@ +/** + * Material Inspector components. + * 材质 Inspector 组件。 + */ + +export { MaterialPropertiesEditor } from './MaterialPropertiesEditor'; diff --git a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx index 46f905f7..31d62409 100644 --- a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx @@ -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 { diff --git a/packages/editor-app/src/hooks/useStoreSubscriptions.ts b/packages/editor-app/src/hooks/useStoreSubscriptions.ts index 027f298f..6709b5f2 100644 --- a/packages/editor-app/src/hooks/useStoreSubscriptions.ts +++ b/packages/editor-app/src/hooks/useStoreSubscriptions.ts @@ -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]); } diff --git a/packages/editor-app/src/i18n/config.ts b/packages/editor-app/src/i18n/config.ts deleted file mode 100644 index 3425ae27..00000000 --- a/packages/editor-app/src/i18n/config.ts +++ /dev/null @@ -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; diff --git a/packages/editor-app/src/i18n/locales/en.json b/packages/editor-app/src/i18n/locales/en.json deleted file mode 100644 index 5b7822da..00000000 --- a/packages/editor-app/src/i18n/locales/en.json +++ /dev/null @@ -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" - } - } -} diff --git a/packages/editor-app/src/i18n/locales/zh.json b/packages/editor-app/src/i18n/locales/zh.json deleted file mode 100644 index c41ba46a..00000000 --- a/packages/editor-app/src/i18n/locales/zh.json +++ /dev/null @@ -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": "材质实体" - } - } -} diff --git a/packages/editor-app/src/infrastructure/field-editors/EntityRefFieldEditor.tsx b/packages/editor-app/src/infrastructure/field-editors/EntityRefFieldEditor.tsx new file mode 100644 index 00000000..f81c0590 --- /dev/null +++ b/packages/editor-app/src/infrastructure/field-editors/EntityRefFieldEditor.tsx @@ -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 { + 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): React.ReactElement { + const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here'; + + return ( + + ); + } +} diff --git a/packages/editor-app/src/infrastructure/field-editors/index.ts b/packages/editor-app/src/infrastructure/field-editors/index.ts index d54a45b5..9a27afe0 100644 --- a/packages/editor-app/src/infrastructure/field-editors/index.ts +++ b/packages/editor-app/src/infrastructure/field-editors/index.ts @@ -2,3 +2,4 @@ export * from './AssetFieldEditor'; export * from './VectorFieldEditors'; export * from './ColorFieldEditor'; export * from './AnimationClipsFieldEditor'; +export * from './EntityRefFieldEditor'; diff --git a/packages/editor-app/src/locales/en.ts b/packages/editor-app/src/locales/en.ts index ced279ae..fdf88857 100644 --- a/packages/editor-app/src/locales/en.ts +++ b/packages/editor-app/src/locales/en.ts @@ -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' + } } } } diff --git a/packages/editor-app/src/locales/es.ts b/packages/editor-app/src/locales/es.ts index 1256f8ff..6289edc9 100644 --- a/packages/editor-app/src/locales/es.ts +++ b/packages/editor-app/src/locales/es.ts @@ -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' + } } } } diff --git a/packages/editor-app/src/locales/zh.ts b/packages/editor-app/src/locales/zh.ts index 10180d6f..7261c531 100644 --- a/packages/editor-app/src/locales/zh.ts +++ b/packages/editor-app/src/locales/zh.ts @@ -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: '可加入图集的最大单个纹理尺寸,超过此尺寸的纹理将不会被合批' + } } } } diff --git a/packages/editor-app/src/main.tsx b/packages/editor-app/src/main.tsx index 526d2b19..fb282798 100644 --- a/packages/editor-app/src/main.tsx +++ b/packages/editor-app/src/main.tsx @@ -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); diff --git a/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx b/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx index 62e91f14..0b71f521 100644 --- a/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx +++ b/packages/editor-app/src/plugins/builtin/ProjectSettingsPlugin.tsx @@ -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 设计分辨率 diff --git a/packages/editor-app/src/services/EditorAssetFileLoader.ts b/packages/editor-app/src/services/EditorAssetFileLoader.ts new file mode 100644 index 00000000..8e97d376 --- /dev/null +++ b/packages/editor-app/src/services/EditorAssetFileLoader.ts @@ -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 { + const absolutePath = this.resolveToAbsolutePath(assetPath); + return this.assetReader.loadImage(absolutePath); + } + + /** + * Load text content from asset path. + * 从资产路径加载文本内容。 + */ + async loadText(assetPath: string): Promise { + const absolutePath = this.resolveToAbsolutePath(assetPath); + return this.assetReader.readText(absolutePath); + } + + /** + * Load binary data from asset path. + * 从资产路径加载二进制数据。 + */ + async loadBinary(assetPath: string): Promise { + const absolutePath = this.resolveToAbsolutePath(assetPath); + return this.assetReader.readBinary(absolutePath); + } + + /** + * Check if asset file exists. + * 检查资产文件是否存在。 + */ + async exists(assetPath: string): Promise { + 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:\...") + ); + } +} diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index ed17b45d..f6d3e32b 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -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('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); 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 | 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 | 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('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) { diff --git a/packages/editor-app/src/services/RenderDebugService.ts b/packages/editor-app/src/services/RenderDebugService.ts index 04a95f30..0206edd1 100644 --- a/packages/editor-app/src/services/RenderDebugService.ts +++ b/packages/editor-app/src/services/RenderDebugService.ts @@ -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; + /** 顶点属性: 宽高比 (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; + /** 顶点属性: 宽高比 (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 = { + 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('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 = {}; + 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 = {}; + 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, }); } diff --git a/packages/editor-app/src/services/TauriAssetReader.ts b/packages/editor-app/src/services/TauriAssetReader.ts index c6ff3909..106c3c7b 100644 --- a/packages/editor-app/src/services/TauriAssetReader.ts +++ b/packages/editor-app/src/services/TauriAssetReader.ts @@ -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; diff --git a/packages/editor-app/src/styles/global.css b/packages/editor-app/src/styles/global.css index 237448de..9a1ccf7e 100644 --- a/packages/editor-app/src/styles/global.css +++ b/packages/editor-app/src/styles/global.css @@ -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; diff --git a/packages/editor-core/src/Gizmos/GizmoHitTester.ts b/packages/editor-core/src/Gizmos/GizmoHitTester.ts new file mode 100644 index 00000000..3293cc5c --- /dev/null +++ b/packages/editor-core/src/Gizmos/GizmoHitTester.ts @@ -0,0 +1,262 @@ +/** + * Gizmo Hit Tester + * Gizmo 命中测试器 + * + * Implements hit testing algorithms for various gizmo types in TypeScript. + * 在 TypeScript 端实现各种 Gizmo 类型的命中测试算法。 + */ + +import type { + IGizmoRenderData, + IRectGizmoData, + ICircleGizmoData, + ILineGizmoData, + ICapsuleGizmoData +} from './IGizmoProvider'; + +/** + * Gizmo Hit Tester + * Gizmo 命中测试器 + * + * Provides static methods for testing if a point intersects with various gizmo shapes. + * 提供静态方法来测试点是否与各种 gizmo 形状相交。 + */ +export class GizmoHitTester { + /** Line hit tolerance in world units (adjusted by zoom) | 线条命中容差(世界单位,根据缩放调整) */ + private static readonly BASE_LINE_TOLERANCE = 8; + + /** + * Test if point is inside a rect gizmo (considers rotation and origin) + * 测试点是否在矩形 gizmo 内(考虑旋转和原点) + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param rect Rect gizmo data | 矩形 gizmo 数据 + * @returns True if point is inside | 如果点在内部返回 true + */ + static hitTestRect(worldX: number, worldY: number, rect: IRectGizmoData): boolean { + const cx = rect.x; + const cy = rect.y; + const halfW = rect.width / 2; + const halfH = rect.height / 2; + const rotation = rect.rotation || 0; + + // Transform point to rect's local coordinate system (inverse rotation) + // 将点转换到矩形的本地坐标系(逆旋转) + const cos = Math.cos(-rotation); + const sin = Math.sin(-rotation); + const dx = worldX - cx; + const dy = worldY - cy; + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + + // Adjust for origin offset + // 根据原点偏移调整 + const originOffsetX = (rect.originX - 0.5) * rect.width; + const originOffsetY = (rect.originY - 0.5) * rect.height; + const adjustedX = localX + originOffsetX; + const adjustedY = localY + originOffsetY; + + return adjustedX >= -halfW && adjustedX <= halfW && + adjustedY >= -halfH && adjustedY <= halfH; + } + + /** + * Test if point is inside a circle gizmo + * 测试点是否在圆形 gizmo 内 + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param circle Circle gizmo data | 圆形 gizmo 数据 + * @returns True if point is inside | 如果点在内部返回 true + */ + static hitTestCircle(worldX: number, worldY: number, circle: ICircleGizmoData): boolean { + const dx = worldX - circle.x; + const dy = worldY - circle.y; + const distSq = dx * dx + dy * dy; + return distSq <= circle.radius * circle.radius; + } + + /** + * Test if point is near a line gizmo + * 测试点是否在线条 gizmo 附近 + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param line Line gizmo data | 线条 gizmo 数据 + * @param tolerance Hit tolerance in world units | 命中容差(世界单位) + * @returns True if point is within tolerance of line | 如果点在线条容差范围内返回 true + */ + static hitTestLine(worldX: number, worldY: number, line: ILineGizmoData, tolerance: number): boolean { + const points = line.points; + if (points.length < 2) return false; + + const count = line.closed ? points.length : points.length - 1; + + for (let i = 0; i < count; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % points.length]; + + if (this.pointToSegmentDistance(worldX, worldY, p1.x, p1.y, p2.x, p2.y) <= tolerance) { + return true; + } + } + + return false; + } + + /** + * Test if point is inside a capsule gizmo + * 测试点是否在胶囊 gizmo 内 + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param capsule Capsule gizmo data | 胶囊 gizmo 数据 + * @returns True if point is inside | 如果点在内部返回 true + */ + static hitTestCapsule(worldX: number, worldY: number, capsule: ICapsuleGizmoData): boolean { + const cx = capsule.x; + const cy = capsule.y; + const rotation = capsule.rotation || 0; + + // Transform point to capsule's local coordinate system + // 将点转换到胶囊的本地坐标系 + const cos = Math.cos(-rotation); + const sin = Math.sin(-rotation); + const dx = worldX - cx; + const dy = worldY - cy; + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + + // Capsule = two half-circles + middle rectangle + // 胶囊 = 两个半圆 + 中间矩形 + const topCircleY = capsule.halfHeight; + const bottomCircleY = -capsule.halfHeight; + + // Check if inside middle rectangle + // 检查是否在中间矩形内 + if (Math.abs(localY) <= capsule.halfHeight && Math.abs(localX) <= capsule.radius) { + return true; + } + + // Check if inside top half-circle + // 检查是否在上半圆内 + const distToTopSq = localX * localX + (localY - topCircleY) * (localY - topCircleY); + if (distToTopSq <= capsule.radius * capsule.radius) { + return true; + } + + // Check if inside bottom half-circle + // 检查是否在下半圆内 + const distToBottomSq = localX * localX + (localY - bottomCircleY) * (localY - bottomCircleY); + if (distToBottomSq <= capsule.radius * capsule.radius) { + return true; + } + + return false; + } + + /** + * Generic hit test for any gizmo type + * 通用命中测试,适用于任何 gizmo 类型 + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param gizmo Gizmo data | Gizmo 数据 + * @param zoom Current viewport zoom level | 当前视口缩放级别 + * @returns True if point hits the gizmo | 如果点命中 gizmo 返回 true + */ + static hitTest(worldX: number, worldY: number, gizmo: IGizmoRenderData, zoom: number = 1): boolean { + // Convert screen pixel tolerance to world units + // 将屏幕像素容差转换为世界单位 + const lineTolerance = this.BASE_LINE_TOLERANCE / zoom; + + switch (gizmo.type) { + case 'rect': + return this.hitTestRect(worldX, worldY, gizmo); + case 'circle': + return this.hitTestCircle(worldX, worldY, gizmo); + case 'line': + return this.hitTestLine(worldX, worldY, gizmo, lineTolerance); + case 'capsule': + return this.hitTestCapsule(worldX, worldY, gizmo); + case 'grid': + // Grid typically doesn't need hit testing + // 网格通常不需要命中测试 + return false; + default: + return false; + } + } + + /** + * Calculate distance from point to line segment + * 计算点到线段的距离 + * + * @param px Point X | 点 X + * @param py Point Y | 点 Y + * @param x1 Segment start X | 线段起点 X + * @param y1 Segment start Y | 线段起点 Y + * @param x2 Segment end X | 线段终点 X + * @param y2 Segment end Y | 线段终点 Y + * @returns Distance from point to segment | 点到线段的距离 + */ + private static pointToSegmentDistance( + px: number, py: number, + x1: number, y1: number, + x2: number, y2: number + ): number { + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSq = dx * dx + dy * dy; + + if (lengthSq === 0) { + // Segment degenerates to a point + // 线段退化为点 + return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1)); + } + + // Calculate projection parameter t + // 计算投影参数 t + let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq; + t = Math.max(0, Math.min(1, t)); + + // Nearest point on segment + // 线段上最近的点 + const nearestX = x1 + t * dx; + const nearestY = y1 + t * dy; + + return Math.sqrt((px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY)); + } + + /** + * Get the center point of any gizmo + * 获取任意 gizmo 的中心点 + * + * @param gizmo Gizmo data | Gizmo 数据 + * @returns Center point { x, y } | 中心点 { x, y } + */ + static getGizmoCenter(gizmo: IGizmoRenderData): { x: number; y: number } { + switch (gizmo.type) { + case 'rect': + case 'circle': + case 'capsule': + return { x: gizmo.x, y: gizmo.y }; + case 'line': + if (gizmo.points.length === 0) return { x: 0, y: 0 }; + const sumX = gizmo.points.reduce((sum, p) => sum + p.x, 0); + const sumY = gizmo.points.reduce((sum, p) => sum + p.y, 0); + return { + x: sumX / gizmo.points.length, + y: sumY / gizmo.points.length + }; + case 'grid': + return { + x: gizmo.x + gizmo.width / 2, + y: gizmo.y + gizmo.height / 2 + }; + default: + return { x: 0, y: 0 }; + } + } +} diff --git a/packages/editor-core/src/Gizmos/index.ts b/packages/editor-core/src/Gizmos/index.ts index c429ddd6..ffd922bd 100644 --- a/packages/editor-core/src/Gizmos/index.ts +++ b/packages/editor-core/src/Gizmos/index.ts @@ -10,3 +10,4 @@ export * from './IGizmoProvider'; export * from './GizmoRegistry'; +export * from './GizmoHitTester'; diff --git a/packages/editor-core/src/Services/AssetRegistryService.ts b/packages/editor-core/src/Services/AssetRegistryService.ts index 9af15f76..be0e4e9f 100644 --- a/packages/editor-core/src/Services/AssetRegistryService.ts +++ b/packages/editor-core/src/Services/AssetRegistryService.ts @@ -394,12 +394,28 @@ export class AssetRegistryService implements IService { // 处理文件创建 - 注册新资产并生成 .meta if (changeType === 'create' || changeType === 'modify') { for (const absolutePath of paths) { - // Handle .meta file changes - invalidate cache - // 处理 .meta 文件变化 - 使缓存失效 + // Handle .meta file changes - invalidate cache and notify listeners + // 处理 .meta 文件变化 - 使缓存失效并通知监听者 if (absolutePath.endsWith('.meta')) { const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix this._metaManager.invalidateCache(assetPath); logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`); + + // Notify listeners that the asset's metadata has changed + // 通知监听者资产的元数据已变化 + const relativePath = this.absoluteToRelative(assetPath); + if (relativePath) { + const metadata = this._database.getMetadataByPath(relativePath); + if (metadata) { + this._messageHub?.publish('assets:changed', { + type: 'modify', + path: assetPath, + relativePath, + guid: metadata.guid + }); + logger.debug(`Published assets:changed for meta file: ${relativePath}`); + } + } continue; } diff --git a/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts b/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts index c9afe73d..dd080285 100644 --- a/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts +++ b/packages/editor-core/src/Services/Build/pipelines/WebBuildPipeline.ts @@ -868,6 +868,7 @@ ${userScriptImports} type: string; size: number; hash: string; + importSettings?: Record; }> }; @@ -952,7 +953,10 @@ ${userScriptImports} path: relativePath, type: assetType, size, - hash: hashFileInfo(relativePath, size) + hash: hashFileInfo(relativePath, size), + // Include importSettings for sprite slicing info (nine-patch, etc.) + // 包含 importSettings 以支持精灵切片信息(九宫格等) + importSettings: meta.importSettings }; addedEntries++; } catch (error) { diff --git a/packages/editor-core/src/Services/GizmoInteractionService.ts b/packages/editor-core/src/Services/GizmoInteractionService.ts new file mode 100644 index 00000000..f5971e2f --- /dev/null +++ b/packages/editor-core/src/Services/GizmoInteractionService.ts @@ -0,0 +1,302 @@ +/** + * Gizmo Interaction Service + * Gizmo 交互服务 + * + * Manages gizmo hover detection, highlighting, and click selection. + * 管理 Gizmo 的悬停检测、高亮显示和点击选择。 + */ + +import { Core } from '@esengine/ecs-framework'; +import type { Entity, ComponentType } from '@esengine/ecs-framework'; +import { GizmoHitTester } from '../Gizmos/GizmoHitTester'; +import { GizmoRegistry } from '../Gizmos/GizmoRegistry'; +import type { IGizmoRenderData, GizmoColor } from '../Gizmos/IGizmoProvider'; + +/** + * Gizmo hit result + * Gizmo 命中结果 + */ +export interface GizmoHitResult { + /** Hit gizmo data | 命中的 Gizmo 数据 */ + gizmo: IGizmoRenderData; + /** Associated entity ID | 关联的实体 ID */ + entityId: number; + /** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */ + distance: number; +} + +/** + * Gizmo interaction service interface + * Gizmo 交互服务接口 + */ +export interface IGizmoInteractionService { + /** + * Get currently hovered entity ID + * 获取当前悬停的实体 ID + */ + getHoveredEntityId(): number | null; + + /** + * Update mouse position and perform hit test + * 更新鼠标位置并执行命中测试 + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param zoom Current viewport zoom level | 当前视口缩放级别 + */ + updateMousePosition(worldX: number, worldY: number, zoom: number): void; + + /** + * Get highlight color for entity (applies hover effect if applicable) + * 获取实体的高亮颜色(如果适用则应用悬停效果) + * + * @param entityId Entity ID | 实体 ID + * @param baseColor Base gizmo color | 基础 Gizmo 颜色 + * @param isSelected Whether entity is selected | 实体是否被选中 + * @returns Adjusted color | 调整后的颜色 + */ + getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor; + + /** + * Handle click at position, return hit entity ID + * 处理位置点击,返回命中的实体 ID + * + * @param worldX World X coordinate | 世界 X 坐标 + * @param worldY World Y coordinate | 世界 Y 坐标 + * @param zoom Current viewport zoom level | 当前视口缩放级别 + * @returns Hit entity ID or null | 命中的实体 ID 或 null + */ + handleClick(worldX: number, worldY: number, zoom: number): number | null; + + /** + * Clear hover state + * 清除悬停状态 + */ + clearHover(): void; +} + +/** + * Gizmo Interaction Service + * Gizmo 交互服务 + * + * Manages gizmo hover detection, highlighting, and click selection. + * 管理 Gizmo 的悬停检测、高亮显示和点击选择。 + */ +export class GizmoInteractionService implements IGizmoInteractionService { + private hoveredEntityId: number | null = null; + private hoveredGizmo: IGizmoRenderData | null = null; + + /** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */ + private static readonly HOVER_COLOR_MULTIPLIER = 1.3; + /** Hover alpha boost | 悬停时 Alpha 增量 */ + private static readonly HOVER_ALPHA_BOOST = 0.3; + + // ===== Click cycling state | 点击循环状态 ===== + /** Last click position | 上次点击位置 */ + private lastClickPos: { x: number; y: number } | null = null; + /** Last click time | 上次点击时间 */ + private lastClickTime: number = 0; + /** All hit entities at current click position | 当前点击位置的所有命中实体 */ + private hitEntitiesAtClick: number[] = []; + /** Current cycle index | 当前循环索引 */ + private cycleIndex: number = 0; + /** Position tolerance for same-position detection | 判断相同位置的容差 */ + private static readonly CLICK_POSITION_TOLERANCE = 5; + /** Time tolerance for cycling (ms) | 循环的时间容差(毫秒) */ + private static readonly CLICK_TIME_TOLERANCE = 1000; + + /** + * Get currently hovered entity ID + * 获取当前悬停的实体 ID + */ + getHoveredEntityId(): number | null { + return this.hoveredEntityId; + } + + /** + * Get currently hovered gizmo data + * 获取当前悬停的 Gizmo 数据 + */ + getHoveredGizmo(): IGizmoRenderData | null { + return this.hoveredGizmo; + } + + /** + * Update mouse position and perform hit test + * 更新鼠标位置并执行命中测试 + */ + updateMousePosition(worldX: number, worldY: number, zoom: number): void { + const scene = Core.scene; + if (!scene) { + this.hoveredEntityId = null; + this.hoveredGizmo = null; + return; + } + + let closestHit: GizmoHitResult | null = null; + let closestDistance = Infinity; + + // Iterate all entities and collect gizmo data for hit testing + // 遍历所有实体,收集 gizmo 数据进行命中测试 + for (const entity of scene.entities.buffer) { + // Skip entities without gizmo providers + // 跳过没有 gizmo 提供者的实体 + if (!GizmoRegistry.hasAnyGizmoProvider(entity)) { + continue; + } + + for (const component of entity.components) { + const componentType = component.constructor as ComponentType; + if (!GizmoRegistry.hasProvider(componentType)) { + continue; + } + + const gizmos = GizmoRegistry.getGizmoData(component, entity, false); + for (const gizmo of gizmos) { + if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) { + // Calculate distance to gizmo center for sorting + // 计算到 gizmo 中心的距离用于排序 + const center = GizmoHitTester.getGizmoCenter(gizmo); + const distance = Math.sqrt( + (worldX - center.x) ** 2 + (worldY - center.y) ** 2 + ); + + if (distance < closestDistance) { + closestDistance = distance; + closestHit = { + gizmo, + entityId: entity.id, + distance + }; + } + } + } + } + } + + this.hoveredEntityId = closestHit?.entityId ?? null; + this.hoveredGizmo = closestHit?.gizmo ?? null; + } + + /** + * Get highlight color for entity + * 获取实体的高亮颜色 + */ + getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor { + const isHovered = entityId === this.hoveredEntityId; + + if (!isHovered) { + return baseColor; + } + + // Apply hover highlight: brighten color and increase alpha + // 应用悬停高亮:提亮颜色并增加透明度 + return { + r: Math.min(1, baseColor.r * GizmoInteractionService.HOVER_COLOR_MULTIPLIER), + g: Math.min(1, baseColor.g * GizmoInteractionService.HOVER_COLOR_MULTIPLIER), + b: Math.min(1, baseColor.b * GizmoInteractionService.HOVER_COLOR_MULTIPLIER), + a: Math.min(1, baseColor.a + GizmoInteractionService.HOVER_ALPHA_BOOST) + }; + } + + /** + * Handle click at position, return hit entity ID + * Supports cycling through overlapping entities on repeated clicks + * 处理位置点击,返回命中的实体 ID + * 支持重复点击时循环选择重叠的实体 + */ + handleClick(worldX: number, worldY: number, zoom: number): number | null { + const now = Date.now(); + const isSamePosition = this.lastClickPos !== null && + Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom && + Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom; + const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE; + + // If clicking at same position within time window, cycle to next entity + // 如果在时间窗口内点击相同位置,循环到下一个实体 + if (isSamePosition && isWithinTimeWindow && this.hitEntitiesAtClick.length > 1) { + this.cycleIndex = (this.cycleIndex + 1) % this.hitEntitiesAtClick.length; + this.lastClickTime = now; + const selectedId = this.hitEntitiesAtClick[this.cycleIndex]; + this.hoveredEntityId = selectedId; + return selectedId; + } + + // New position or timeout - collect all hit entities + // 新位置或超时 - 收集所有命中的实体 + this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom); + this.cycleIndex = 0; + this.lastClickPos = { x: worldX, y: worldY }; + this.lastClickTime = now; + + if (this.hitEntitiesAtClick.length > 0) { + const selectedId = this.hitEntitiesAtClick[0]; + this.hoveredEntityId = selectedId; + return selectedId; + } + + return null; + } + + /** + * Collect all entities hit at the given position, sorted by distance + * 收集给定位置命中的所有实体,按距离排序 + */ + private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] { + const scene = Core.scene; + if (!scene) return []; + + const hits: GizmoHitResult[] = []; + + for (const entity of scene.entities.buffer) { + if (!GizmoRegistry.hasAnyGizmoProvider(entity)) { + continue; + } + + let entityHit = false; + let minDistance = Infinity; + + for (const component of entity.components) { + const componentType = component.constructor as ComponentType; + if (!GizmoRegistry.hasProvider(componentType)) { + continue; + } + + const gizmos = GizmoRegistry.getGizmoData(component, entity, false); + for (const gizmo of gizmos) { + if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) { + entityHit = true; + const center = GizmoHitTester.getGizmoCenter(gizmo); + const distance = Math.sqrt( + (worldX - center.x) ** 2 + (worldY - center.y) ** 2 + ); + minDistance = Math.min(minDistance, distance); + } + } + } + + if (entityHit) { + hits.push({ + gizmo: {} as IGizmoRenderData, // Not needed for sorting + entityId: entity.id, + distance: minDistance + }); + } + } + + // Sort by distance (closest first) + // 按距离排序(最近的在前) + hits.sort((a, b) => a.distance - b.distance); + + return hits.map(hit => hit.entityId); + } + + /** + * Clear hover state + * 清除悬停状态 + */ + clearHover(): void { + this.hoveredEntityId = null; + this.hoveredGizmo = null; + } +} diff --git a/packages/editor-core/src/tokens.ts b/packages/editor-core/src/tokens.ts index 76b86992..2826d6bb 100644 --- a/packages/editor-core/src/tokens.ts +++ b/packages/editor-core/src/tokens.ts @@ -24,6 +24,7 @@ import type { LocaleService, Locale, TranslationParams, PluginTranslations } fro import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub'; import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService'; import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService'; +import type { IGizmoInteractionService } from './Services/GizmoInteractionService'; // ============================================================================ // LocaleService Token @@ -203,9 +204,28 @@ export const PrefabServiceToken = createServiceToken('prefabServ // 重新导出类型方便使用 export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService'; +// ============================================================================ +// GizmoInteractionService Token +// Gizmo 交互服务令牌 +// ============================================================================ + +/** + * Gizmo 交互服务令牌 + * Gizmo interaction service token + * + * 用于注册和获取 Gizmo 交互服务。 + * For registering and getting gizmo interaction service. + */ +export const GizmoInteractionServiceToken = createServiceToken('gizmoInteractionService'); + +// Re-export interface for convenience +// 重新导出接口方便使用 +export type { IGizmoInteractionService } from './Services/GizmoInteractionService'; + // Re-export classes for direct use (backwards compatibility) // 重新导出类以供直接使用(向后兼容) export { LocaleService } from './Services/LocaleService'; export { MessageHub } from './Services/MessageHub'; export { EntityStoreService } from './Services/EntityStoreService'; export { PrefabService } from './Services/PrefabService'; +export { GizmoInteractionService } from './Services/GizmoInteractionService'; diff --git a/packages/engine-core/src/PluginServiceRegistry.ts b/packages/engine-core/src/PluginServiceRegistry.ts index c1423378..b3482d0d 100644 --- a/packages/engine-core/src/PluginServiceRegistry.ts +++ b/packages/engine-core/src/PluginServiceRegistry.ts @@ -117,6 +117,39 @@ export interface IEngineBridge { * @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded */ waitForAllTextures?(timeout?: number): Promise; + + // ===== Dynamic Atlas API (Optional) ===== + // ===== 动态图集 API(可选)===== + + /** + * 创建空白纹理(用于动态图集) + * Create blank texture (for dynamic atlas) + * + * @param width 宽度 | Width + * @param height 高度 | Height + * @returns 纹理 ID | Texture ID + */ + createBlankTexture?(width: number, height: number): number; + + /** + * 更新纹理区域 + * Update texture region + * + * @param id 纹理 ID | Texture ID + * @param x X 坐标 | X coordinate + * @param y Y 坐标 | Y coordinate + * @param width 宽度 | Width + * @param height 高度 | Height + * @param pixels RGBA 像素数据 | RGBA pixel data + */ + updateTextureRegion?( + id: number, + x: number, + y: number, + width: number, + height: number, + pixels: Uint8Array + ): void; } /** diff --git a/packages/engine-core/src/TransformSystem.ts b/packages/engine-core/src/TransformSystem.ts index fde4f06e..1136378b 100644 --- a/packages/engine-core/src/TransformSystem.ts +++ b/packages/engine-core/src/TransformSystem.ts @@ -81,14 +81,15 @@ export class TransformSystem extends EntitySystem { const sin = Math.sin(rad); // 构建仿射变换矩阵: Scale -> Rotate -> Translate - // [a c tx] [sx 0 0] [cos -sin 0] [1 0 tx] - // [b d ty] = [0 sy 0] * [sin cos 0] * [0 1 ty] + // 顺时针旋转 | Clockwise rotation + // [a c tx] [sx 0 0] [cos sin 0] [1 0 tx] + // [b d ty] = [0 sy 0] * [-sin cos 0] * [0 1 ty] // [0 0 1] [0 0 1] [0 0 1] [0 0 1] return { a: scale.x * cos, - b: scale.x * sin, - c: scale.y * -sin, + b: -scale.x * sin, + c: scale.y * sin, d: scale.y * cos, tx: position.x, ty: position.y diff --git a/packages/engine/src/core/engine.rs b/packages/engine/src/core/engine.rs index 2eedff63..dbaffb06 100644 --- a/packages/engine/src/core/engine.rs +++ b/packages/engine/src/core/engine.rs @@ -349,6 +349,16 @@ impl Engine { self.texture_manager.get_texture_id_by_path(path) } + /// Get texture size by path. + /// 按路径获取纹理尺寸。 + /// + /// Returns None if texture is not loaded or path not found. + /// 如果纹理未加载或路径未找到,返回 None。 + pub fn get_texture_size_by_path(&self, path: &str) -> Option<(f32, f32)> { + let id = self.texture_manager.get_texture_id_by_path(path)?; + self.texture_manager.get_texture_size(id) + } + /// Get or load texture by path. /// 按路径获取或加载纹理。 pub fn get_or_load_by_path(&mut self, path: &str) -> Result { @@ -374,6 +384,32 @@ impl Engine { self.texture_manager.clear_all(); } + /// Create a blank texture for dynamic atlas. + /// 为动态图集创建空白纹理。 + /// + /// This creates a texture that can be filled later using `update_texture_region`. + /// 创建一个可以稍后使用 `update_texture_region` 填充的纹理。 + pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result { + self.texture_manager.create_blank_texture(width, height) + } + + /// Update a region of an existing texture. + /// 更新现有纹理的区域。 + /// + /// Used for dynamic atlas to copy textures into the atlas. + /// 用于动态图集将纹理复制到图集中。 + pub fn update_texture_region( + &self, + id: u32, + x: u32, + y: u32, + width: u32, + height: u32, + pixels: &[u8], + ) -> Result<()> { + self.texture_manager.update_texture_region(id, x, y, width, height, pixels) + } + /// 获取纹理加载状态 /// Get texture loading state pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState { diff --git a/packages/engine/src/lib.rs b/packages/engine/src/lib.rs index ea50d589..94a3af24 100644 --- a/packages/engine/src/lib.rs +++ b/packages/engine/src/lib.rs @@ -212,6 +212,24 @@ impl GameEngine { self.engine.get_texture_id_by_path(path) } + /// Get texture size by path. + /// 按路径获取纹理尺寸。 + /// + /// Returns an array [width, height] or null if not found. + /// 返回数组 [width, height],如果未找到则返回 null。 + /// + /// # Arguments | 参数 + /// * `path` - Image path to lookup | 要查找的图片路径 + #[wasm_bindgen(js_name = getTextureSizeByPath)] + pub fn get_texture_size_by_path(&self, path: &str) -> Option { + self.engine.get_texture_size_by_path(path).map(|(w, h)| { + let arr = js_sys::Float32Array::new_with_length(2); + arr.set_index(0, w); + arr.set_index(1, h); + arr + }) + } + /// Get or load texture by path. /// 按路径获取或加载纹理。 /// @@ -722,4 +740,60 @@ impl GameEngine { pub fn clear_all_textures(&mut self) { self.engine.clear_all_textures(); } + + // ===== Dynamic Atlas API ===== + // ===== 动态图集 API ===== + + /// Create a blank texture for dynamic atlas. + /// 为动态图集创建空白纹理。 + /// + /// This creates a texture that can be filled later using `updateTextureRegion`. + /// Used for runtime atlas generation to batch UI elements with different textures. + /// 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。 + /// 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。 + /// + /// # Arguments | 参数 + /// * `width` - Texture width in pixels (recommended: 2048) | 纹理宽度(推荐:2048) + /// * `height` - Texture height in pixels (recommended: 2048) | 纹理高度(推荐:2048) + /// + /// # Returns | 返回 + /// The texture ID for the created blank texture | 创建的空白纹理ID + #[wasm_bindgen(js_name = createBlankTexture)] + pub fn create_blank_texture( + &mut self, + width: u32, + height: u32, + ) -> std::result::Result { + self.engine + .create_blank_texture(width, height) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Update a region of an existing texture with pixel data. + /// 使用像素数据更新现有纹理的区域。 + /// + /// This is used for dynamic atlas to copy individual textures into the atlas. + /// 用于动态图集将单个纹理复制到图集纹理中。 + /// + /// # Arguments | 参数 + /// * `id` - The texture ID to update | 要更新的纹理ID + /// * `x` - X offset in the texture | 纹理中的X偏移 + /// * `y` - Y offset in the texture | 纹理中的Y偏移 + /// * `width` - Width of the region to update | 要更新的区域宽度 + /// * `height` - Height of the region to update | 要更新的区域高度 + /// * `pixels` - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据(每像素4字节) + #[wasm_bindgen(js_name = updateTextureRegion)] + pub fn update_texture_region( + &self, + id: u32, + x: u32, + y: u32, + width: u32, + height: u32, + pixels: &[u8], + ) -> std::result::Result<(), JsValue> { + self.engine + .update_texture_region(id, x, y, width, height, pixels) + .map_err(|e| JsValue::from_str(&e.to_string())) + } } diff --git a/packages/engine/src/math/transform.rs b/packages/engine/src/math/transform.rs index 37183faf..957ced15 100644 --- a/packages/engine/src/math/transform.rs +++ b/packages/engine/src/math/transform.rs @@ -82,15 +82,22 @@ impl Transform2D { /// /// The matrix is constructed as: T * R * S (translate, rotate, scale). /// 矩阵构造顺序为:T * R * S(平移、旋转、缩放)。 + /// + /// Uses left-hand coordinate system convention: + /// 使用左手坐标系约定: + /// - Positive rotation = clockwise (when viewed from +Z) + /// - 正旋转 = 顺时针(从 +Z 方向观察时) pub fn to_matrix(&self) -> Mat3 { let cos = self.rotation.cos(); let sin = self.rotation.sin(); // Construct TRS matrix directly for performance // 直接构造TRS矩阵以提高性能 + // Clockwise rotation: [cos, -sin; sin, cos] (column-major) + // 顺时针旋转矩阵 Mat3::from_cols( - glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0), - glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0), + glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0), + glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0), glam::Vec3::new(self.position.x, self.position.y, 1.0), ) } @@ -101,6 +108,9 @@ impl Transform2D { /// # Arguments | 参数 /// * `width` - Sprite width | 精灵宽度 /// * `height` - Sprite height | 精灵高度 + /// + /// Uses left-hand coordinate system (clockwise positive rotation). + /// 使用左手坐标系(顺时针正旋转)。 pub fn to_matrix_with_origin(&self, width: f32, height: f32) -> Mat3 { let ox = -self.origin.x * width * self.scale.x; let oy = -self.origin.y * height * self.scale.y; @@ -108,14 +118,16 @@ impl Transform2D { let cos = self.rotation.cos(); let sin = self.rotation.sin(); - // Apply origin offset after rotation - // 在旋转后应用原点偏移 - let tx = self.position.x + ox * cos - oy * sin; - let ty = self.position.y + ox * sin + oy * cos; + // Apply origin offset after rotation (clockwise rotation) + // 在旋转后应用原点偏移(顺时针旋转) + let tx = self.position.x + ox * cos + oy * sin; + let ty = self.position.y - ox * sin + oy * cos; + // Clockwise rotation matrix + // 顺时针旋转矩阵 Mat3::from_cols( - glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0), - glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0), + glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0), + glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0), glam::Vec3::new(tx, ty, 1.0), ) } diff --git a/packages/engine/src/math/vec2.rs b/packages/engine/src/math/vec2.rs index 991af15b..7084cef3 100644 --- a/packages/engine/src/math/vec2.rs +++ b/packages/engine/src/math/vec2.rs @@ -113,13 +113,20 @@ impl Vec2 { /// Rotate the vector by an angle (in radians). /// 按角度旋转向量(弧度)。 + /// + /// Uses left-hand coordinate system convention: + /// 使用左手坐标系约定: + /// - Positive angle = clockwise rotation (when viewed from +Z) + /// - 正角度 = 顺时针旋转(从 +Z 方向观察时) #[inline] pub fn rotate(&self, angle: f32) -> Self { let cos = angle.cos(); let sin = angle.sin(); + // Clockwise rotation matrix: [cos, sin; -sin, cos] + // 顺时针旋转矩阵 Self { - x: self.x * cos - self.y * sin, - y: self.x * sin + self.y * cos, + x: self.x * cos + self.y * sin, + y: -self.x * sin + self.y * cos, } } diff --git a/packages/engine/src/renderer/batch/sprite_batch.rs b/packages/engine/src/renderer/batch/sprite_batch.rs index f091096e..b27f6640 100644 --- a/packages/engine/src/renderer/batch/sprite_batch.rs +++ b/packages/engine/src/renderer/batch/sprite_batch.rs @@ -1,7 +1,6 @@ //! Sprite batch renderer for efficient 2D rendering. //! 用于高效2D渲染的精灵批处理渲染器。 -use indexmap::IndexMap; use web_sys::{ WebGl2RenderingContext, WebGlBuffer, WebGlVertexArrayObject, }; @@ -66,17 +65,23 @@ pub struct SpriteBatch { /// 最大精灵数。 max_sprites: usize, - /// Per-material-texture vertex data buffers (insertion-ordered). - /// 按材质和纹理分组的顶点数据缓冲区(保持插入顺序)。 + /// Batches stored as (key, vertices) pairs in submission order. + /// 按提交顺序存储的批次(键,顶点)对。 /// - /// Uses IndexMap to preserve render order - sprites submitted first - /// are rendered first (appear behind later sprites). - /// 使用 IndexMap 保持渲染顺序 - 先提交的精灵先渲染(显示在后面)。 - batches: IndexMap>, + /// Only consecutive sprites with the same BatchKey are batched together. + /// Sprites with the same key but separated by different keys are kept in separate batches + /// to preserve correct render order. + /// 只有连续的相同 BatchKey 的 sprites 才会合批。 + /// 相同 key 但被其他 key 分隔的 sprites 保持在独立批次中以保证正确的渲染顺序。 + batches: Vec<(BatchKey, Vec)>, /// Total sprite count across all batches. /// 所有批次的总精灵数。 sprite_count: usize, + + /// Last batch key used, for determining if we can merge into the last batch. + /// 上一个使用的批次键,用于判断是否可以合并到最后一个批次。 + last_batch_key: Option, } impl SpriteBatch { @@ -140,8 +145,9 @@ impl SpriteBatch { vbo, ibo, max_sprites, - batches: IndexMap::new(), + batches: Vec::new(), sprite_count: 0, + last_batch_key: None, }) } @@ -168,8 +174,15 @@ impl SpriteBatch { /// Set up vertex attribute pointers. /// 设置顶点属性指针。 + /// + /// Vertex layout (9 floats per vertex): + /// 顶点布局(每顶点 9 个浮点数): + /// - location 0: position (2 floats) - offset 0 + /// - location 1: tex_coord (2 floats) - offset 8 + /// - location 2: color (4 floats) - offset 16 + /// - location 3: aspect_ratio (1 float) - offset 32 fn setup_vertex_attributes(gl: &WebGl2RenderingContext) { - let stride = (FLOATS_PER_VERTEX * 4) as i32; + let stride = (FLOATS_PER_VERTEX * 4) as i32; // 9 * 4 = 36 bytes // Position attribute (location = 0) | 位置属性 gl.enable_vertex_attrib_array(0); @@ -203,15 +216,27 @@ impl SpriteBatch { stride, 16, // 4 floats * 4 bytes ); + + // Aspect ratio attribute (location = 3) | 宽高比属性 + // Used by shaders for aspect-ratio-aware transformations + // 用于着色器中的宽高比感知变换 + gl.enable_vertex_attrib_array(3); + gl.vertex_attrib_pointer_with_i32( + 3, + 1, + WebGl2RenderingContext::FLOAT, + false, + stride, + 32, // (2 + 2 + 4) floats * 4 bytes + ); } /// Clear the batch for a new frame. /// 为新帧清空批处理。 pub fn clear(&mut self) { - for batch in self.batches.values_mut() { - batch.clear(); - } + self.batches.clear(); self.sprite_count = 0; + self.last_batch_key = None; } /// Add sprites from batch data. @@ -302,21 +327,40 @@ impl SpriteBatch { let width = scale_x; let height = scale_y; + // Calculate aspect ratio (width / height), default 1.0 for degenerate cases + // 计算宽高比(宽度/高度),退化情况下默认为 1.0 + let aspect_ratio = if height.abs() > 0.001 { + width / height + } else { + 1.0 + }; + let batch_key = BatchKey { material_id: material_ids[i], texture_id: texture_ids[i], }; - // Get or create batch for this material+texture combination | 获取或创建此材质+纹理组合的批次 - let batch = self.batches - .entry(batch_key) - .or_insert_with(Vec::new); + // Only batch consecutive sprites with the same key to preserve render order + // 只对连续相同 key 的 sprites 合批以保持渲染顺序 + let should_create_new_batch = match self.last_batch_key { + Some(last_key) => batch_key != last_key, + None => true, + }; + + if should_create_new_batch { + // Create a new batch | 创建新批次 + self.batches.push((batch_key, Vec::new())); + self.last_batch_key = Some(batch_key); + } + + // Add to the last batch | 添加到最后一个批次 + let batch = &mut self.batches.last_mut().unwrap().1; // Calculate transformed vertices and add to batch | 计算变换后的顶点并添加到批次 Self::add_sprite_vertices_to_batch( batch, x, y, width, height, rotation, origin_x, origin_y, - u0, v0, u1, v1, color_arr, + u0, v0, u1, v1, color_arr, aspect_ratio, ); } @@ -326,6 +370,9 @@ impl SpriteBatch { /// Add vertices for a single sprite to a batch. /// 为单个精灵添加顶点到批次。 + /// + /// Each vertex contains: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9 floats + /// 每个顶点包含: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9 个浮点数 #[inline] fn add_sprite_vertices_to_batch( batch: &mut Vec, @@ -341,6 +388,7 @@ impl SpriteBatch { u1: f32, v1: f32, color: [f32; 4], + aspect_ratio: f32, ) { let cos = rotation.cos(); let sin = rotation.sin(); @@ -393,6 +441,10 @@ impl SpriteBatch { // Color | 颜色 batch.extend_from_slice(&color); + + // Aspect ratio (same for all 4 vertices of a quad) + // 宽高比(四边形的 4 个顶点相同) + batch.push(aspect_ratio); } } @@ -432,16 +484,16 @@ impl SpriteBatch { gl.bind_vertex_array(None); } - /// Get all batches for rendering (in insertion order). - /// 获取所有批次用于渲染(按插入顺序)。 - pub fn batches(&self) -> &IndexMap> { + /// Get all batches for rendering (in submission order). + /// 获取所有批次用于渲染(按提交顺序)。 + pub fn batches(&self) -> &[(BatchKey, Vec)] { &self.batches } - /// Flush a specific batch by key. - /// 按键刷新特定批次。 - pub fn flush_for_batch(&self, gl: &WebGl2RenderingContext, key: &BatchKey) { - if let Some(vertices) = self.batches.get(key) { + /// Flush a specific batch by index. + /// 按索引刷新特定批次。 + pub fn flush_batch_at(&self, gl: &WebGl2RenderingContext, index: usize) { + if let Some((_, vertices)) = self.batches.get(index) { self.flush_batch(gl, vertices); } } diff --git a/packages/engine/src/renderer/batch/vertex.rs b/packages/engine/src/renderer/batch/vertex.rs index 1cd30d6c..de0bed97 100644 --- a/packages/engine/src/renderer/batch/vertex.rs +++ b/packages/engine/src/renderer/batch/vertex.rs @@ -9,13 +9,16 @@ pub const VERTEX_SIZE: usize = std::mem::size_of::(); /// Number of floats per vertex. /// 每个顶点的浮点数数量。 -pub const FLOATS_PER_VERTEX: usize = 8; +/// +/// Layout: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9 +/// 布局: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9 +pub const FLOATS_PER_VERTEX: usize = 9; /// Sprite vertex data. /// 精灵顶点数据。 /// -/// Each sprite requires 4 vertices (quad), each with position, UV, and color. -/// 每个精灵需要4个顶点(四边形),每个顶点包含位置、UV和颜色。 +/// Each sprite requires 4 vertices (quad), each with position, UV, color, and aspect ratio. +/// 每个精灵需要4个顶点(四边形),每个顶点包含位置、UV、颜色和宽高比。 #[derive(Debug, Clone, Copy, Pod, Zeroable)] #[repr(C)] pub struct SpriteVertex { @@ -30,6 +33,15 @@ pub struct SpriteVertex { /// Color (r, g, b, a). /// 颜色。 pub color: [f32; 4], + + /// Aspect ratio (width / height) for shader effects. + /// 宽高比(宽度/高度),用于着色器效果。 + /// + /// This allows shaders to apply aspect-ratio-aware transformations + /// (e.g., rotation in shiny effects) without per-instance uniforms. + /// 这允许着色器应用宽高比感知的变换(如闪光效果中的旋转), + /// 无需每实例 uniform。 + pub aspect_ratio: f32, } impl SpriteVertex { @@ -40,11 +52,13 @@ impl SpriteVertex { position: [f32; 2], tex_coord: [f32; 2], color: [f32; 4], + aspect_ratio: f32, ) -> Self { Self { position, tex_coord, color, + aspect_ratio, } } } @@ -55,6 +69,7 @@ impl Default for SpriteVertex { position: [0.0, 0.0], tex_coord: [0.0, 0.0], color: [1.0, 1.0, 1.0, 1.0], + aspect_ratio: 1.0, } } } diff --git a/packages/engine/src/renderer/camera.rs b/packages/engine/src/renderer/camera.rs index d939d394..9e51f374 100644 --- a/packages/engine/src/renderer/camera.rs +++ b/packages/engine/src/renderer/camera.rs @@ -1,5 +1,12 @@ //! 2D camera implementation. //! 2D相机实现。 +//! +//! Uses left-hand coordinate system convention: +//! 使用左手坐标系约定: +//! - X axis: positive to the right / X 轴:正方向向右 +//! - Y axis: positive upward (in world space) / Y 轴:正方向向上(世界空间) +//! - Z axis: positive into the screen / Z 轴:正方向指向屏幕内 +//! - Positive rotation: clockwise (when viewed from +Z) / 正旋转:顺时针(从 +Z 观察) use crate::math::Vec2; use glam::Mat3; @@ -67,6 +74,7 @@ impl Camera2D { /// - World: Y-up, origin at camera position | 世界坐标:Y向上,原点在相机位置 /// - Screen: Y-down, origin at top-left | 屏幕坐标:Y向下,原点在左上角 /// - NDC: Y-up, origin at center [-1, 1] | NDC:Y向上,原点在中心 + /// - Rotation: positive = clockwise | 旋转:正 = 顺时针 /// /// When zoom=1, 1 world unit = 1 screen pixel. /// 当zoom=1时,1个世界单位 = 1个屏幕像素。 @@ -81,8 +89,8 @@ impl Camera2D { let sx = 2.0 / self.width * self.zoom; let sy = 2.0 / self.height * self.zoom; - // Handle rotation - // 处理旋转 + // Handle rotation (clockwise positive) + // 处理旋转(顺时针为正) let cos = self.rotation.cos(); let sin = self.rotation.sin(); @@ -97,15 +105,17 @@ impl Camera2D { // 组合缩放、旋转和平移 // Matrix = Scale * Rotation * Translation (applied right to left) // 矩阵 = 缩放 * 旋转 * 平移(从右到左应用) + // Clockwise rotation: [cos, -sin; sin, cos] + // 顺时针旋转矩阵 if self.rotation != 0.0 { - // With rotation: need to rotate the translation as well - // 有旋转时:平移也需要旋转 - let rtx = tx * cos - ty * sin; - let rty = tx * sin + ty * cos; + // With rotation: need to rotate the translation as well (clockwise) + // 有旋转时:平移也需要旋转(顺时针) + let rtx = tx * cos + ty * sin; + let rty = -tx * sin + ty * cos; Mat3::from_cols( - glam::Vec3::new(sx * cos, sx * sin, 0.0), - glam::Vec3::new(-sy * sin, sy * cos, 0.0), + glam::Vec3::new(sx * cos, -sx * sin, 0.0), + glam::Vec3::new(sy * sin, sy * cos, 0.0), glam::Vec3::new(rtx, rty, 1.0), ) } else { @@ -124,6 +134,7 @@ impl Camera2D { /// /// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下 /// World: Y-up, camera at center | 世界:Y向上,相机在中心 + /// Rotation: positive = clockwise | 旋转:正 = 顺时针 pub fn screen_to_world(&self, screen: Vec2) -> Vec2 { // Convert screen to NDC-like coordinates (centered, Y-up) // 将屏幕坐标转换为类NDC坐标(居中,Y向上) @@ -138,11 +149,15 @@ impl Camera2D { if self.rotation != 0.0 { // Apply inverse rotation around camera position // 围绕相机位置应用反向旋转 + // Inverse of clockwise θ is clockwise -θ + // 顺时针 θ 的逆变换是顺时针 -θ let dx = world_x - self.position.x; let dy = world_y - self.position.y; - let cos = (-self.rotation).cos(); - let sin = (-self.rotation).sin(); + let cos = self.rotation.cos(); // cos(-θ) = cos(θ) + let sin = self.rotation.sin(); // for clockwise -θ: use -sin(θ) + // Clockwise rotation with -θ: x' = x*cos + y*(-sin), y' = -x*(-sin) + y*cos + // 用 -θ 做顺时针旋转 Vec2::new( dx * cos - dy * sin + self.position.x, dx * sin + dy * cos + self.position.y, @@ -157,14 +172,19 @@ impl Camera2D { /// /// World: Y-up | 世界:Y向上 /// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角,Y向下 + /// Rotation: positive = clockwise | 旋转:正 = 顺时针 pub fn world_to_screen(&self, world: Vec2) -> Vec2 { let dx = world.x - self.position.x; let dy = world.y - self.position.y; + // Apply clockwise rotation + // 应用顺时针旋转 let (rx, ry) = if self.rotation != 0.0 { let cos = self.rotation.cos(); let sin = self.rotation.sin(); - (dx * cos - dy * sin, dx * sin + dy * cos) + // Clockwise: x' = x*cos + y*sin, y' = -x*sin + y*cos + // 顺时针旋转公式 + (dx * cos + dy * sin, -dx * sin + dy * cos) } else { (dx, dy) }; diff --git a/packages/engine/src/renderer/renderer2d.rs b/packages/engine/src/renderer/renderer2d.rs index 0348865f..e7b8549a 100644 --- a/packages/engine/src/renderer/renderer2d.rs +++ b/packages/engine/src/renderer/renderer2d.rs @@ -116,19 +116,10 @@ impl Renderer2D { /// Render the current frame. /// 渲染当前帧。 pub fn render(&mut self, gl: &WebGl2RenderingContext, texture_manager: &TextureManager) -> Result<()> { - use super::batch::BatchKey; - if self.sprite_batch.sprite_count() == 0 { return Ok(()); } - // Collect non-empty batch keys | 收集非空批次键 - let batch_keys: Vec = self.sprite_batch.batches() - .iter() - .filter(|(_, vertices)| !vertices.is_empty()) - .map(|(key, _)| *key) - .collect(); - // Track current state to minimize state changes | 跟踪当前状态以最小化状态切换 let mut current_material_id: u32 = u32::MAX; let mut current_texture_id: u32 = u32::MAX; @@ -136,7 +127,16 @@ impl Renderer2D { // Get projection matrix once | 一次性获取投影矩阵 let projection = self.camera.projection_matrix(); - for batch_key in batch_keys { + // Iterate through batches in submission order (preserves render order) + // 按提交顺序遍历批次(保持渲染顺序) + for batch_idx in 0..self.sprite_batch.batches().len() { + let (batch_key, vertices) = &self.sprite_batch.batches()[batch_idx]; + + // Skip empty batches | 跳过空批次 + if vertices.is_empty() { + continue; + } + // Switch material if needed | 如需切换材质 if batch_key.material_id != current_material_id { current_material_id = batch_key.material_id; @@ -169,8 +169,8 @@ impl Renderer2D { texture_manager.bind_texture(batch_key.texture_id, 0); } - // Flush this batch | 刷新此批次 - self.sprite_batch.flush_for_batch(gl, &batch_key); + // Flush this batch by index | 按索引刷新此批次 + self.sprite_batch.flush_batch_at(gl, batch_idx); } // Clear batch for next frame | 清空批处理以供下一帧使用 diff --git a/packages/engine/src/renderer/texture/texture_manager.rs b/packages/engine/src/renderer/texture/texture_manager.rs index 60524b49..4891f529 100644 --- a/packages/engine/src/renderer/texture/texture_manager.rs +++ b/packages/engine/src/renderer/texture/texture_manager.rs @@ -52,6 +52,11 @@ pub struct TextureManager { /// 纹理加载状态(使用 Rc> 以便闭包可以修改) /// Texture loading states (using Rc> so closures can modify) texture_states: Rc>>, + + /// 纹理尺寸缓存(使用 Rc> 以便闭包可以修改) + /// Texture dimensions cache (using Rc> so closures can modify) + /// Key: texture ID, Value: (width, height) + texture_dimensions: Rc>>, } impl TextureManager { @@ -65,6 +70,7 @@ impl TextureManager { next_id: 1, // Start from 1, 0 is reserved for default default_texture: None, texture_states: Rc::new(RefCell::new(HashMap::new())), + texture_dimensions: Rc::new(RefCell::new(HashMap::new())), }; // Create default white texture | 创建默认白色纹理 @@ -150,6 +156,9 @@ impl TextureManager { let states_for_onload = Rc::clone(&self.texture_states); let states_for_onerror = Rc::clone(&self.texture_states); + // Clone dimensions map for closure | 克隆尺寸映射用于闭包 + let dimensions_for_onload = Rc::clone(&self.texture_dimensions); + // Load actual image asynchronously | 异步加载实际图片 let gl = self.gl.clone(); @@ -205,6 +214,12 @@ impl TextureManager { WebGl2RenderingContext::LINEAR as i32, ); + // 存储纹理尺寸(从加载的图片获取) + // Store texture dimensions (from loaded image) + let width = image_clone.width(); + let height = image_clone.height(); + dimensions_for_onload.borrow_mut().insert(texture_id, (width, height)); + // 标记为就绪 | Mark as ready states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready); @@ -236,8 +251,21 @@ impl TextureManager { /// Get texture size by ID. /// 按ID获取纹理尺寸。 + /// + /// First checks the dimensions cache (updated when texture loads), + /// then falls back to the Texture struct. + /// 首先检查尺寸缓存(在纹理加载时更新), + /// 然后回退到 Texture 结构体。 #[inline] pub fn get_texture_size(&self, id: u32) -> Option<(f32, f32)> { + // Check dimensions cache first (has actual loaded dimensions) + // 首先检查尺寸缓存(有实际加载的尺寸) + if let Some(&(w, h)) = self.texture_dimensions.borrow().get(&id) { + return Some((w as f32, h as f32)); + } + + // Fall back to texture struct (may have placeholder dimensions) + // 回退到纹理结构体(可能是占位符尺寸) self.textures .get(&id) .map(|t| (t.width as f32, t.height as f32)) @@ -329,6 +357,8 @@ impl TextureManager { self.path_to_id.retain(|_, &mut v| v != id); // Remove state | 移除状态 self.texture_states.borrow_mut().remove(&id); + // Remove dimensions | 移除尺寸 + self.texture_dimensions.borrow_mut().remove(&id); } /// Load texture by path, returning texture ID. @@ -409,8 +439,144 @@ impl TextureManager { // Clear texture states | 清除纹理状态 self.texture_states.borrow_mut().clear(); + // Clear texture dimensions | 清除纹理尺寸 + self.texture_dimensions.borrow_mut().clear(); + // Reset ID counter (1 is reserved for first texture, 0 for default) // 重置ID计数器(1保留给第一个纹理,0给默认纹理) self.next_id = 1; } + + /// Create a blank texture with specified dimensions. + /// 创建具有指定尺寸的空白纹理。 + /// + /// This is used for dynamic atlas creation where textures + /// are later filled with content using `update_texture_region`. + /// 用于动态图集创建,之后使用 `update_texture_region` 填充内容。 + /// + /// # Arguments | 参数 + /// * `width` - Texture width in pixels | 纹理宽度(像素) + /// * `height` - Texture height in pixels | 纹理高度(像素) + /// + /// # Returns | 返回 + /// The texture ID for the created texture | 创建的纹理ID + pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result { + let texture = self.gl + .create_texture() + .ok_or_else(|| EngineError::TextureLoadFailed("Failed to create blank texture".into()))?; + + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture)); + + // Initialize with transparent pixels + // 使用透明像素初始化 + let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + width as i32, + height as i32, + 0, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + None, // NULL data - allocate but don't fill + ); + + // Set texture parameters for atlas use + // 设置图集使用的纹理参数 + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_S, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_T, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MIN_FILTER, + WebGl2RenderingContext::LINEAR as i32, + ); + self.gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MAG_FILTER, + WebGl2RenderingContext::LINEAR as i32, + ); + + // Assign ID and store + // 分配ID并存储 + let id = self.next_id; + self.next_id += 1; + + self.textures.insert(id, Texture::new(texture, width, height)); + self.texture_states.borrow_mut().insert(id, TextureState::Ready); + self.texture_dimensions.borrow_mut().insert(id, (width, height)); + + log::debug!("Created blank texture {} ({}x{}) | 创建空白纹理 {} ({}x{})", id, width, height, id, width, height); + + Ok(id) + } + + /// Update a region of an existing texture with pixel data. + /// 使用像素数据更新现有纹理的区域。 + /// + /// This is used for dynamic atlas to copy individual textures + /// into the atlas texture. + /// 用于动态图集将单个纹理复制到图集纹理中。 + /// + /// # Arguments | 参数 + /// * `id` - The texture ID to update | 要更新的纹理ID + /// * `x` - X offset in the texture | 纹理中的X偏移 + /// * `y` - Y offset in the texture | 纹理中的Y偏移 + /// * `width` - Width of the region to update | 要更新的区域宽度 + /// * `height` - Height of the region to update | 要更新的区域高度 + /// * `pixels` - RGBA pixel data (4 bytes per pixel) | RGBA像素数据(每像素4字节) + /// + /// # Returns | 返回 + /// Ok(()) on success, Err if texture not found or update failed + /// 成功时返回 Ok(()),纹理未找到或更新失败时返回 Err + pub fn update_texture_region( + &self, + id: u32, + x: u32, + y: u32, + width: u32, + height: u32, + pixels: &[u8], + ) -> Result<()> { + let texture = self.textures.get(&id) + .ok_or_else(|| EngineError::TextureLoadFailed(format!("Texture {} not found", id)))?; + + // Validate pixel data size + // 验证像素数据大小 + let expected_size = (width * height * 4) as usize; + if pixels.len() != expected_size { + return Err(EngineError::TextureLoadFailed(format!( + "Pixel data size mismatch: expected {}, got {} | 像素数据大小不匹配:预期 {},实际 {}", + expected_size, pixels.len(), expected_size, pixels.len() + ))); + } + + self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture.handle)); + + // Use texSubImage2D to update a region + // 使用 texSubImage2D 更新区域 + self.gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array( + WebGl2RenderingContext::TEXTURE_2D, + 0, + x as i32, + y as i32, + width as i32, + height as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + Some(pixels), + ).map_err(|e| EngineError::TextureLoadFailed(format!("texSubImage2D failed: {:?}", e)))?; + + log::trace!("Updated texture {} region ({},{}) {}x{} | 更新纹理 {} 区域 ({},{}) {}x{}", + id, x, y, width, height, id, x, y, width, height); + + Ok(()) + } } diff --git a/packages/material-system/src/MaterialManager.ts b/packages/material-system/src/MaterialManager.ts index bb50de30..5ddb93e6 100644 --- a/packages/material-system/src/MaterialManager.ts +++ b/packages/material-system/src/MaterialManager.ts @@ -14,7 +14,8 @@ import { GRAYSCALE_FRAGMENT_SHADER, TINT_FRAGMENT_SHADER, FLASH_FRAGMENT_SHADER, - OUTLINE_FRAGMENT_SHADER + OUTLINE_FRAGMENT_SHADER, + SHINY_FRAGMENT_SHADER } from './Shader'; import { BuiltInMaterials, BuiltInShaders, UniformType } from './types'; import type { IAssetManager } from '@esengine/asset-system'; @@ -103,10 +104,67 @@ export class MaterialManager { * Set the engine bridge for GPU operations. * 设置用于GPU操作的引擎桥接。 * + * When set, uploads all built-in shaders to the GPU. + * 设置后,将所有内置着色器上传到GPU。 + * * @param bridge - Engine bridge instance. | 引擎桥接实例。 */ setEngineBridge(bridge: IEngineBridge): void { this.engineBridge = bridge; + + // Upload all existing shaders to the engine + // 将所有现有着色器上传到引擎 + this.uploadShadersToEngine(); + } + + /** + * Upload all registered shaders to the engine. + * 将所有已注册的着色器上传到引擎。 + * + * Called automatically when engine bridge is set. + * 设置引擎桥接时自动调用。 + */ + private uploadShadersToEngine(): void { + if (!this.engineBridge) return; + + let shadersUploaded = 0; + let materialsCreated = 0; + + for (const [shaderId, shader] of this.shaders) { + // Skip if already compiled + // 跳过已编译的着色器 + if (shader.compiled) continue; + + try { + // Compile shader + // 编译着色器 + this.engineBridge.compileShaderWithId( + shaderId, + shader.vertexSource, + shader.fragmentSource + ); + shader.markCompiled(); + shadersUploaded++; + logger.debug(`Uploaded shader ${shader.name} (ID: ${shaderId}) to engine`); + + // Create a material for this shader if it doesn't exist in the engine + // 为此着色器创建材质(如果引擎中不存在) + // This allows sprites to reference the shader via materialId + // 这允许精灵通过 materialId 引用着色器 + if (!this.engineBridge.hasMaterial(shaderId)) { + // Use shaderId as materialId for built-in shaders (1:1 mapping) + // 对于内置着色器,使用 shaderId 作为 materialId(1:1 映射) + // BlendMode 1 = Alpha blending + this.engineBridge.createMaterialWithId(shaderId, shader.name, shaderId, 1); + materialsCreated++; + logger.debug(`Created material ${shader.name} (ID: ${shaderId}) for shader`); + } + } catch (e) { + logger.error(`Failed to upload shader ${shader.name} (ID: ${shaderId}):`, e); + } + } + + logger.info(`Uploaded ${shadersUploaded} shaders and created ${materialsCreated} materials | 已上传 ${shadersUploaded} 个着色器,创建 ${materialsCreated} 个材质`); } /** @@ -138,6 +196,7 @@ export class MaterialManager { { id: BuiltInShaders.Tint, name: 'Tint', vertex: DEFAULT_VERTEX_SHADER, fragment: TINT_FRAGMENT_SHADER }, { id: BuiltInShaders.Flash, name: 'Flash', vertex: DEFAULT_VERTEX_SHADER, fragment: FLASH_FRAGMENT_SHADER }, { id: BuiltInShaders.Outline, name: 'Outline', vertex: DEFAULT_VERTEX_SHADER, fragment: OUTLINE_FRAGMENT_SHADER }, + { id: BuiltInShaders.Shiny, name: 'Shiny', vertex: DEFAULT_VERTEX_SHADER, fragment: SHINY_FRAGMENT_SHADER }, ]; for (const { id, name, vertex, fragment } of builtInShaders) { diff --git a/packages/material-system/src/MaterialSystemPlugin.ts b/packages/material-system/src/MaterialSystemPlugin.ts index 56e268c1..e79ce9c8 100644 --- a/packages/material-system/src/MaterialSystemPlugin.ts +++ b/packages/material-system/src/MaterialSystemPlugin.ts @@ -2,7 +2,13 @@ * MaterialSystemPlugin for ES Engine. * ES引擎的材质系统插件。 * - * 注意:材质系统不注册独立组件,材质作为渲染组件(如 SpriteComponent)的属性使用 + * Provides: + * - Material and Shader management + * - Built-in shaders (Default, Grayscale, Tint, Flash, Outline, Shiny) + * + * 提供: + * - 材质和着色器管理 + * - 内置着色器 */ import { MaterialManager, getMaterialManager } from './MaterialManager'; @@ -82,7 +88,9 @@ const manifest: ModuleManifest = { defaultEnabled: true, isEngineModule: true, dependencies: ['core', 'asset-system'], - exports: { other: ['Material', 'Shader', 'MaterialManager'] }, + exports: { + other: ['Material', 'Shader', 'MaterialManager'] + }, requiresWasm: false }; diff --git a/packages/material-system/src/Shader.ts b/packages/material-system/src/Shader.ts index d2317138..006a53ee 100644 --- a/packages/material-system/src/Shader.ts +++ b/packages/material-system/src/Shader.ts @@ -120,6 +120,13 @@ export class Shader { /** * Default sprite vertex shader source. * 默认精灵顶点着色器源代码。 + * + * Vertex layout (9 floats per vertex): + * 顶点布局(每顶点 9 个浮点数): + * - location 0: position (2 floats) + * - location 1: tex_coord (2 floats) + * - location 2: color (4 floats) + * - location 3: aspect_ratio (1 float) */ export const DEFAULT_VERTEX_SHADER = `#version 300 es precision highp float; @@ -128,6 +135,7 @@ precision highp float; layout(location = 0) in vec2 a_position; layout(location = 1) in vec2 a_texCoord; layout(location = 2) in vec4 a_color; +layout(location = 3) in float a_aspectRatio; // Uniforms | 统一变量 uniform mat3 u_projection; @@ -135,6 +143,7 @@ uniform mat3 u_projection; // Outputs to fragment shader | 输出到片段着色器 out vec2 v_texCoord; out vec4 v_color; +out float v_aspectRatio; void main() { // Apply projection matrix | 应用投影矩阵 @@ -144,6 +153,7 @@ void main() { // Pass through to fragment shader | 传递到片段着色器 v_texCoord = a_texCoord; v_color = a_color; + v_aspectRatio = a_aspectRatio; } `; @@ -157,6 +167,7 @@ precision highp float; // Inputs from vertex shader | 来自顶点着色器的输入 in vec2 v_texCoord; in vec4 v_color; +in float v_aspectRatio; // Texture sampler | 纹理采样器 uniform sampler2D u_texture; @@ -185,6 +196,7 @@ precision highp float; in vec2 v_texCoord; in vec4 v_color; +in float v_aspectRatio; uniform sampler2D u_texture; uniform float u_grayscale; // 0.0 = full color, 1.0 = full grayscale @@ -215,6 +227,7 @@ precision highp float; in vec2 v_texCoord; in vec4 v_color; +in float v_aspectRatio; uniform sampler2D u_texture; uniform vec4 u_tintColor; // Tint color to apply @@ -243,6 +256,7 @@ precision highp float; in vec2 v_texCoord; in vec4 v_color; +in float v_aspectRatio; uniform sampler2D u_texture; uniform vec4 u_flashColor; // Flash color @@ -273,6 +287,7 @@ precision highp float; in vec2 v_texCoord; in vec4 v_color; +in float v_aspectRatio; uniform sampler2D u_texture; uniform vec4 u_outlineColor; @@ -309,3 +324,98 @@ void main() { } } `; + +/** + * Shiny/Shimmer effect fragment shader. + * 闪光效果片段着色器。 + * + * Uses v_aspectRatio from vertex attribute for aspect-ratio-aware rotation. + * 使用顶点属性中的 v_aspectRatio 进行宽高比感知的旋转。 + */ +export const SHINY_FRAGMENT_SHADER = `#version 300 es +precision highp float; + +in vec2 v_texCoord; +in vec4 v_color; +in float v_aspectRatio; + +uniform sampler2D u_texture; + +// Shiny effect uniforms | 闪光效果 uniform 变量 +uniform float u_shinyProgress; // Animation progress (0-1) | 动画进度 +uniform float u_shinyWidth; // Width of shine band (0-1) | 闪光带宽度 +uniform float u_shinyRotation; // Rotation in radians | 旋转角度(弧度) +uniform float u_shinySoftness; // Edge softness (0-1) | 边缘柔和度 +uniform float u_shinyBrightness; // Brightness multiplier | 亮度倍数 +uniform float u_shinyGloss; // Gloss intensity (0=white, 1=color-tinted) | 光泽度 + +out vec4 fragColor; + +void main() { + vec4 texColor = texture(u_texture, v_texCoord); + float originAlpha = texColor.a; + vec4 color = texColor * v_color; + + // Early discard for transparent pixels + if (color.a < 0.01) { + discard; + } + + // Calculate rotated position for the sweep (0 to 1 range) + // 计算旋转后的扫描位置(0 到 1 范围) + // + // 1. 计算基础方向向量 dir = (cos(θ), sin(θ)) + // 2. 宽高比校正:dir.x *= height/width = 1/aspectRatio + // 3. 归一化方向向量 + // 4. 计算扫描位置(考虑纹理坐标 Y 轴方向) + // + // 1. Calculate base direction vector dir = (cos(θ), sin(θ)) + // 2. Aspect ratio correction: dir.x *= height/width = 1/aspectRatio + // 3. Normalize direction vector + // 4. Calculate sweep position (accounting for texture Y-axis direction) + // + vec2 center = v_texCoord - vec2(0.5); + float cosR = cos(u_shinyRotation); + float sinR = sin(u_shinyRotation); + + // Aspect ratio correction: scale X by 1/aspectRatio (height/width) + // v_aspectRatio is passed from vertex attribute, calculated at render time + // 宽高比校正:X 分量乘以 1/aspectRatio(即 height/width) + // v_aspectRatio 从顶点属性传入,在渲染时计算 + float adjCosR = cosR / max(v_aspectRatio, 0.001); + + // Normalize the direction vector + // 归一化方向向量 + float len = sqrt(adjCosR * adjCosR + sinR * sinR); + float dirX = adjCosR / len; + float dirY = sinR / len; + + // Sweep position: project onto perpendicular direction + // Y-axis flip: texture coords have Y pointing up, but we want top-to-bottom sweep + // 扫描位置:投影到垂直方向 + // Y 轴翻转:纹理坐标 Y 向上,但我们需要从上到下扫描 + float rotatedPos = (center.x * dirY - center.y * dirX) + 0.5; + + // Map progress to location (-0.5 to 1.5 range for smooth entry/exit) + float location = u_shinyProgress * 2.0 - 0.5; + + // Calculate normalized distance (1 at center, 0 at edges) + // 计算归一化距离(中心为1,边缘为0) + float normalized = 1.0 - clamp(abs((rotatedPos - location) / max(u_shinyWidth, 0.001)), 0.0, 1.0); + + // Apply softness with smoothstep + // 使用 smoothstep 应用柔和度 + float shinePower = smoothstep(0.0, u_shinySoftness * 2.0, normalized); + + // Calculate reflect color: lerp between white and bright original color + // 计算反射颜色:在白色和明亮的原色之间插值 + vec3 reflectColor = mix(vec3(1.0), color.rgb * 10.0, u_shinyGloss); + + // Apply shine: additive blend with halved intensity + // 应用高光:半强度加性混合 + vec3 shineAdd = originAlpha * (shinePower * 0.5) * u_shinyBrightness * reflectColor; + vec3 finalColor = color.rgb + shineAdd; + + fragColor = vec4(finalColor, color.a); +} +`; diff --git a/packages/material-system/src/effects/BaseShinyEffect.ts b/packages/material-system/src/effects/BaseShinyEffect.ts new file mode 100644 index 00000000..3a91c99f --- /dev/null +++ b/packages/material-system/src/effects/BaseShinyEffect.ts @@ -0,0 +1,189 @@ +/** + * Base shiny effect component for ES Engine. + * ES引擎基础闪光效果组件。 + * + * This abstract base class provides shared shiny effect properties and methods + * that can be extended by both SpriteShinyEffectComponent and UIShinyEffectComponent. + * 此抽象基类提供可由 SpriteShinyEffectComponent 和 UIShinyEffectComponent 扩展的 + * 共享闪光效果属性和方法。 + * + * @packageDocumentation + */ + +/** + * Base interface for shiny effect configuration. + * 闪光效果配置的基础接口。 + * + * This interface defines all properties needed for the shiny effect animation. + * 此接口定义了闪光效果动画所需的所有属性。 + */ +export interface IShinyEffect { + // ============= Effect Parameters ============= + // ============= 效果参数 ============= + + /** + * Width of the shiny band (0.0 - 1.0). + * 闪光带宽度 (0.0 - 1.0)。 + */ + width: number; + + /** + * Rotation angle in degrees. + * 旋转角度(度)。 + */ + rotation: number; + + /** + * Edge softness (0.0 - 1.0). + * 边缘柔和度 (0.0 - 1.0)。 + */ + softness: number; + + /** + * Brightness multiplier. + * 亮度倍增器。 + */ + brightness: number; + + /** + * Gloss intensity. + * 光泽度。 + */ + gloss: number; + + // ============= Animation Settings ============= + // ============= 动画设置 ============= + + /** + * Whether the animation is playing. + * 动画是否正在播放。 + */ + play: boolean; + + /** + * Whether to loop the animation. + * 是否循环动画。 + */ + loop: boolean; + + /** + * Animation duration in seconds. + * 动画持续时间(秒)。 + */ + duration: number; + + /** + * Delay between loops in seconds. + * 循环之间的延迟(秒)。 + */ + loopDelay: number; + + /** + * Initial delay before first play in seconds. + * 首次播放前的初始延迟(秒)。 + */ + initialDelay: number; + + // ============= Runtime State ============= + // ============= 运行时状态 ============= + + /** Current animation progress (0.0 - 1.0). | 当前动画进度。 */ + progress: number; + + /** Current elapsed time in the animation cycle. | 当前周期已用时间。 */ + elapsedTime: number; + + /** Whether currently in delay phase. | 是否处于延迟阶段。 */ + inDelay: boolean; + + /** Remaining delay time. | 剩余延迟时间。 */ + delayRemaining: number; + + /** Whether the initial delay has been processed. | 初始延迟是否已处理。 */ + initialDelayProcessed: boolean; +} + +/** + * Default values for shiny effect properties. + * 闪光效果属性的默认值。 + */ +export const SHINY_EFFECT_DEFAULTS = { + width: 0.25, + rotation: 129, + softness: 1.0, + brightness: 1.0, + gloss: 1.0, + play: true, + loop: true, + duration: 2.0, + loopDelay: 2.0, + initialDelay: 0, + progress: 0, + elapsedTime: 0, + inDelay: false, + delayRemaining: 0, + initialDelayProcessed: false +} as const; + +/** + * Property metadata for shiny effect Inspector. + * 闪光效果 Inspector 的属性元数据。 + */ +export const SHINY_EFFECT_PROPERTIES = { + width: { type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 }, + rotation: { type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 }, + softness: { type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 }, + brightness: { type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 }, + gloss: { type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 }, + play: { type: 'boolean', label: 'Play' }, + loop: { type: 'boolean', label: 'Loop' }, + duration: { type: 'number', label: 'Duration', min: 0.1, step: 0.1 }, + loopDelay: { type: 'number', label: 'Loop Delay', min: 0, step: 0.1 }, + initialDelay: { type: 'number', label: 'Initial Delay', min: 0, step: 0.1 } +} as const; + +/** + * Reset shiny effect runtime state. + * 重置闪光效果运行时状态。 + * + * @param effect - The shiny effect to reset | 要重置的闪光效果 + */ +export function resetShinyEffect(effect: IShinyEffect): void { + effect.progress = 0; + effect.elapsedTime = 0; + effect.inDelay = false; + effect.delayRemaining = 0; + effect.initialDelayProcessed = false; +} + +/** + * Start playing the shiny effect. + * 开始播放闪光效果。 + * + * @param effect - The shiny effect to start | 要开始的闪光效果 + */ +export function startShinyEffect(effect: IShinyEffect): void { + resetShinyEffect(effect); + effect.play = true; +} + +/** + * Stop the shiny effect. + * 停止闪光效果。 + * + * @param effect - The shiny effect to stop | 要停止的闪光效果 + */ +export function stopShinyEffect(effect: IShinyEffect): void { + effect.play = false; +} + +/** + * Get rotation in radians for shader use. + * 获取弧度制的旋转角度供着色器使用。 + * + * @param effect - The shiny effect | 闪光效果 + * @returns Rotation in radians | 弧度制的旋转角度 + */ +export function getShinyRotationRadians(effect: IShinyEffect): number { + return effect.rotation * Math.PI / 180; +} diff --git a/packages/material-system/src/effects/ShinyEffectAnimator.ts b/packages/material-system/src/effects/ShinyEffectAnimator.ts new file mode 100644 index 00000000..0fe15eae --- /dev/null +++ b/packages/material-system/src/effects/ShinyEffectAnimator.ts @@ -0,0 +1,153 @@ +/** + * Shiny effect animator for ES Engine. + * ES引擎闪光效果动画器。 + * + * This module provides shared animation logic for shiny effects that can be used + * by both SpriteShinyEffectSystem and UIShinyEffectSystem. + * 此模块提供可由 SpriteShinyEffectSystem 和 UIShinyEffectSystem 使用的 + * 共享闪光效果动画逻辑。 + * + * @packageDocumentation + */ + +import type { IShinyEffect } from './BaseShinyEffect'; +import { getShinyRotationRadians } from './BaseShinyEffect'; +import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable'; +import { BuiltInShaders } from '../types'; + +/** + * Shared animator logic for shiny effect. + * 闪光效果共享的动画逻辑。 + * + * This class provides static methods for updating animation state and + * applying material overrides, eliminating code duplication between + * sprite and UI shiny effect systems. + * 此类提供用于更新动画状态和应用材质覆盖的静态方法, + * 消除精灵和 UI 闪光效果系统之间的代码重复。 + */ +export class ShinyEffectAnimator { + /** + * Update animation state. + * 更新动画状态。 + * + * This method handles: + * - Initial delay processing + * - Delay phase countdown + * - Progress calculation + * - Loop handling + * + * 此方法处理: + * - 初始延迟处理 + * - 延迟阶段倒计时 + * - 进度计算 + * - 循环处理 + * + * @param shiny - The shiny effect component | 闪光效果组件 + * @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒) + */ + static updateAnimation(shiny: IShinyEffect, deltaTime: number): void { + // Handle initial delay + // 处理初始延迟 + if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) { + shiny.delayRemaining = shiny.initialDelay; + shiny.inDelay = true; + shiny.initialDelayProcessed = true; + } + + // Handle delay phase + // 处理延迟阶段 + if (shiny.inDelay) { + shiny.delayRemaining -= deltaTime; + if (shiny.delayRemaining <= 0) { + shiny.inDelay = false; + shiny.elapsedTime = 0; + } + return; + } + + // Update elapsed time + // 更新已用时间 + shiny.elapsedTime += deltaTime; + + // Calculate progress (0 to 1) + // 计算进度(0 到 1) + shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0); + + // Check if animation completed + // 检查动画是否完成 + if (shiny.progress >= 1.0) { + if (shiny.loop) { + // Start loop delay + // 开始循环延迟 + shiny.inDelay = true; + shiny.delayRemaining = shiny.loopDelay; + shiny.progress = 0; + shiny.elapsedTime = 0; + } else { + // Stop animation + // 停止动画 + shiny.play = false; + shiny.progress = 1.0; + } + } + } + + /** + * Apply shiny effect material overrides to a renderable component. + * 将闪光效果材质覆盖应用到可渲染组件。 + * + * This method: + * - Sets the Shiny shader if not already set + * - Applies all uniform overrides for the shiny effect + * + * Note: aspectRatio is passed via vertex attribute from the rendering pipeline, + * calculated from sprite's scaleX/scaleY in the Rust engine. + * + * 此方法: + * - 如果尚未设置,则设置 Shiny 着色器 + * - 应用闪光效果的所有 uniform 覆盖 + * + * 注意:宽高比通过渲染管线的顶点属性传递,在 Rust 引擎中从精灵的 scaleX/scaleY 计算。 + * + * @param shiny - The shiny effect component | 闪光效果组件 + * @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件 + */ + static applyMaterialOverrides(shiny: IShinyEffect, target: IMaterialOverridable): void { + // Ensure target uses Shiny shader + // 确保目标使用 Shiny 着色器 + if (target.getMaterialId() === 0) { + target.setMaterialId(BuiltInShaders.Shiny); + } + + // Apply uniform overrides (aspectRatio is from vertex attribute v_aspectRatio) + // 应用 uniform 覆盖(宽高比来自顶点属性 v_aspectRatio) + target.setOverrideFloat('u_shinyProgress', shiny.progress); + target.setOverrideFloat('u_shinyWidth', shiny.width); + target.setOverrideFloat('u_shinyRotation', getShinyRotationRadians(shiny)); + target.setOverrideFloat('u_shinySoftness', shiny.softness); + target.setOverrideFloat('u_shinyBrightness', shiny.brightness); + target.setOverrideFloat('u_shinyGloss', shiny.gloss); + } + + /** + * Process a single entity with shiny effect. + * 处理单个带有闪光效果的实体。 + * + * This is a convenience method that combines updateAnimation and applyMaterialOverrides. + * 这是一个结合了 updateAnimation 和 applyMaterialOverrides 的便捷方法。 + * + * @param shiny - The shiny effect component | 闪光效果组件 + * @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件 + * @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒) + * @returns True if the effect was processed, false if skipped | 如果效果已处理则返回 true,如果跳过则返回 false + */ + static processEffect(shiny: IShinyEffect, target: IMaterialOverridable, deltaTime: number): boolean { + if (!shiny.play) { + return false; + } + + this.updateAnimation(shiny, deltaTime); + this.applyMaterialOverrides(shiny, target); + return true; + } +} diff --git a/packages/material-system/src/index.ts b/packages/material-system/src/index.ts index e93c2a3d..dd15ee30 100644 --- a/packages/material-system/src/index.ts +++ b/packages/material-system/src/index.ts @@ -25,6 +25,46 @@ // 类型。 export * from './types'; +// Interfaces. +// 接口。 +export type { + MaterialPropertyType, + MaterialPropertyOverride, + MaterialOverrides, + IMaterialOverridable +} from './interfaces/IMaterialOverridable'; + +export type { + ShaderPropertyType, + ShaderPropertyHint, + ShaderPropertyMeta, + ShaderAssetDefinition, + ShaderAssetFile +} from './interfaces/IShaderProperty'; + +export { + BUILTIN_SHADER_PROPERTIES, + getShaderProperties, + getShaderPropertiesById +} from './interfaces/IShaderProperty'; + +// Mixins. +// Mixin。 +export { MaterialOverridableMixin, MaterialOverrideHelper } from './mixins/MaterialOverridableMixin'; + +// Effects. +// 效果。 +export type { IShinyEffect } from './effects/BaseShinyEffect'; +export { + SHINY_EFFECT_DEFAULTS, + SHINY_EFFECT_PROPERTIES, + resetShinyEffect, + startShinyEffect, + stopShinyEffect, + getShinyRotationRadians +} from './effects/BaseShinyEffect'; +export { ShinyEffectAnimator } from './effects/ShinyEffectAnimator'; + // Core classes. // 核心类。 export { Material } from './Material'; @@ -35,7 +75,8 @@ export { GRAYSCALE_FRAGMENT_SHADER, TINT_FRAGMENT_SHADER, FLASH_FRAGMENT_SHADER, - OUTLINE_FRAGMENT_SHADER + OUTLINE_FRAGMENT_SHADER, + SHINY_FRAGMENT_SHADER } from './Shader'; // Manager. diff --git a/packages/material-system/src/interfaces/IMaterialOverridable.ts b/packages/material-system/src/interfaces/IMaterialOverridable.ts new file mode 100644 index 00000000..4dc31a54 --- /dev/null +++ b/packages/material-system/src/interfaces/IMaterialOverridable.ts @@ -0,0 +1,176 @@ +/** + * Material override interfaces for ES Engine. + * ES引擎材质覆盖接口。 + * + * This module provides a unified interface for components that support + * material property overrides (SpriteComponent, UIRenderComponent, etc.). + * 此模块为支持材质属性覆盖的组件提供统一接口。 + * + * @packageDocumentation + */ + +/** + * Material property override value types. + * 材质属性覆盖值类型。 + */ +export type MaterialPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int'; + +/** + * Material property override definition. + * 材质属性覆盖定义。 + */ +export interface MaterialPropertyOverride { + /** Property type | 属性类型 */ + type: MaterialPropertyType; + + /** Property value | 属性值 */ + value: number | number[]; +} + +/** + * Material overrides record type. + * 材质覆盖记录类型。 + */ +export type MaterialOverrides = Record; + +/** + * Interface for components that support material property overrides. + * 支持材质属性覆盖的组件接口。 + * + * Both SpriteComponent and UIRenderComponent implement this interface, + * allowing unified handling by material systems and inspectors. + * SpriteComponent 和 UIRenderComponent 都实现此接口, + * 允许材质系统和检查器统一处理。 + * + * @example + * ```typescript + * function applyShinyEffect(target: IMaterialOverridable, progress: number): void { + * target.setMaterialId(BuiltInShaders.Shiny); + * target.setOverrideFloat('u_shinyProgress', progress); + * } + * + * // Works with both SpriteComponent and UIRenderComponent + * applyShinyEffect(spriteComponent, 0.5); + * applyShinyEffect(uiRenderComponent, 0.5); + * ``` + */ +export interface IMaterialOverridable { + /** + * Material GUID for asset reference. + * 材质资产引用的 GUID。 + */ + materialGuid: string; + + /** + * Current material overrides (read-only access). + * 当前材质覆盖(只读访问)。 + */ + readonly materialOverrides: MaterialOverrides; + + /** + * Get current material ID. + * 获取当前材质 ID。 + */ + getMaterialId(): number; + + /** + * Set material ID. + * 设置材质 ID。 + * + * @param id - Material/Shader ID from BuiltInShaders or custom shader + * 来自 BuiltInShaders 或自定义着色器的材质/着色器 ID + */ + setMaterialId(id: number): void; + + /** + * Set a float uniform override. + * 设置浮点 uniform 覆盖。 + * + * @param name - Uniform name (e.g., 'u_shinyProgress') + * @param value - Float value + */ + setOverrideFloat(name: string, value: number): this; + + /** + * Set a vec2 uniform override. + * 设置 vec2 uniform 覆盖。 + * + * @param name - Uniform name + * @param x - X component + * @param y - Y component + */ + setOverrideVec2(name: string, x: number, y: number): this; + + /** + * Set a vec3 uniform override. + * 设置 vec3 uniform 覆盖。 + * + * @param name - Uniform name + * @param x - X component + * @param y - Y component + * @param z - Z component + */ + setOverrideVec3(name: string, x: number, y: number, z: number): this; + + /** + * Set a vec4 uniform override. + * 设置 vec4 uniform 覆盖。 + * + * @param name - Uniform name + * @param x - X component + * @param y - Y component + * @param z - Z component + * @param w - W component + */ + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this; + + /** + * Set a color uniform override (RGBA, 0.0-1.0). + * 设置颜色 uniform 覆盖(RGBA,0.0-1.0)。 + * + * @param name - Uniform name + * @param r - Red component (0-1) + * @param g - Green component (0-1) + * @param b - Blue component (0-1) + * @param a - Alpha component (0-1), defaults to 1.0 + */ + setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this; + + /** + * Set an integer uniform override. + * 设置整数 uniform 覆盖。 + * + * @param name - Uniform name + * @param value - Integer value + */ + setOverrideInt(name: string, value: number): this; + + /** + * Get a specific override value. + * 获取特定覆盖值。 + * + * @param name - Uniform name + * @returns Override value or undefined if not set + */ + getOverride(name: string): MaterialPropertyOverride | undefined; + + /** + * Remove a specific override. + * 移除特定覆盖。 + * + * @param name - Uniform name to remove + */ + removeOverride(name: string): this; + + /** + * Clear all overrides. + * 清除所有覆盖。 + */ + clearOverrides(): this; + + /** + * Check if any overrides are set. + * 检查是否设置了任何覆盖。 + */ + hasOverrides(): boolean; +} diff --git a/packages/material-system/src/interfaces/IShaderProperty.ts b/packages/material-system/src/interfaces/IShaderProperty.ts new file mode 100644 index 00000000..8d1aaa34 --- /dev/null +++ b/packages/material-system/src/interfaces/IShaderProperty.ts @@ -0,0 +1,369 @@ +/** + * Shader property interfaces for ES Engine. + * ES引擎着色器属性接口。 + * + * This module provides interfaces for defining shader property metadata, + * enabling automatic Inspector UI generation for material editing. + * 此模块提供用于定义着色器属性元数据的接口, + * 实现材质编辑的自动 Inspector UI 生成。 + * + * @packageDocumentation + */ + +/** + * Shader property types. + * 着色器属性类型。 + */ +export type ShaderPropertyType = + | 'float' + | 'int' + | 'vec2' + | 'vec3' + | 'vec4' + | 'color' + | 'texture'; + +/** + * UI hint for property display. + * 属性显示的 UI 提示。 + */ +export type ShaderPropertyHint = + | 'range' // Show as slider | 显示为滑块 + | 'angle' // Show as angle picker (degrees) | 显示为角度选择器(度) + | 'hdr' // HDR color picker | HDR 颜色选择器 + | 'normal' // Normal map preview | 法线贴图预览 + | 'default'; // Default input | 默认输入 + +/** + * Shader property UI metadata. + * 着色器属性 UI 元数据。 + * + * This interface defines all metadata needed to generate an Inspector UI + * for editing shader uniform values. + * 此接口定义生成用于编辑着色器 uniform 值的 Inspector UI 所需的所有元数据。 + */ +export interface ShaderPropertyMeta { + /** + * Property type. + * 属性类型。 + */ + type: ShaderPropertyType; + + /** + * Display label (supports i18n key format "中文 | English"). + * 显示标签(支持国际化键格式 "中文 | English")。 + */ + label: string; + + /** + * Property group for organization in Inspector. + * Inspector 中用于组织的属性分组。 + * + * Properties with the same group will be displayed together under a collapsible header. + * 具有相同分组的属性将在可折叠标题下一起显示。 + */ + group?: string; + + /** + * Default value. + * 默认值。 + */ + default?: number | number[] | string; + + /** + * Minimum value (for numeric types). + * 最小值(用于数值类型)。 + */ + min?: number; + + /** + * Maximum value (for numeric types). + * 最大值(用于数值类型)。 + */ + max?: number; + + /** + * Step value for numeric inputs. + * 数值输入的步长值。 + */ + step?: number; + + /** + * UI hint for specialized display. + * 用于特殊显示的 UI 提示。 + */ + hint?: ShaderPropertyHint; + + /** + * Tooltip description (supports i18n). + * 工具提示描述(支持国际化)。 + */ + tooltip?: string; + + /** + * Whether to hide in Inspector. + * 是否在 Inspector 中隐藏。 + * + * Hidden properties are typically controlled by scripts or systems. + * 隐藏的属性通常由脚本或系统控制。 + */ + hidden?: boolean; + + /** + * Texture filter options (for texture type). + * 纹理过滤选项(用于纹理类型)。 + */ + textureFilter?: 'linear' | 'nearest'; + + /** + * Texture wrap options (for texture type). + * 纹理包裹选项(用于纹理类型)。 + */ + textureWrap?: 'repeat' | 'clamp' | 'mirror'; +} + +/** + * Extended shader definition with property metadata. + * 带属性元数据的扩展着色器定义。 + * + * This interface extends the basic shader definition with UI metadata + * for Inspector generation and asset serialization. + * 此接口使用 UI 元数据扩展基本着色器定义, + * 用于 Inspector 生成和资产序列化。 + */ +export interface ShaderAssetDefinition { + /** + * Shader name (unique identifier). + * 着色器名称(唯一标识符)。 + */ + name: string; + + /** + * Display name for UI. + * UI 显示名称。 + */ + displayName?: string; + + /** + * Shader description. + * 着色器描述。 + */ + description?: string; + + /** + * Vertex shader source (inline GLSL or relative path). + * 顶点着色器源(内联 GLSL 或相对路径)。 + */ + vertexSource: string; + + /** + * Fragment shader source (inline GLSL or relative path). + * 片段着色器源(内联 GLSL 或相对路径)。 + */ + fragmentSource: string; + + /** + * Property metadata for Inspector. + * Inspector 属性元数据。 + * + * Key is the uniform name (e.g., 'u_shinyProgress'). + * 键是 uniform 名称(例如 'u_shinyProgress')。 + */ + properties?: Record; + + /** + * Render queue / order. + * 渲染队列/顺序。 + * + * Lower values render first. Default is 2000 (opaque). + * 较低的值先渲染。默认为 2000(不透明)。 + */ + renderQueue?: number; + + /** + * Preset blend mode. + * 预设混合模式。 + */ + blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque'; + + /** + * Whether this shader requires depth testing. + * 此着色器是否需要深度测试。 + */ + depthTest?: boolean; + + /** + * Whether this shader writes to depth buffer. + * 此着色器是否写入深度缓冲区。 + */ + depthWrite?: boolean; +} + +/** + * Shader asset file format (.shader). + * 着色器资产文件格式 (.shader)。 + */ +export interface ShaderAssetFile { + /** + * Schema version for format evolution. + * 用于格式演进的模式版本。 + */ + version: number; + + /** + * Shader definition. + * 着色器定义。 + */ + shader: ShaderAssetDefinition; +} + +/** + * Built-in shader property definitions. + * 内置着色器属性定义。 + * + * These are the property metadata for built-in shaders. + * 这些是内置着色器的属性元数据。 + */ +export const BUILTIN_SHADER_PROPERTIES: Record> = { + Shiny: { + u_shinyProgress: { + type: 'float', + label: '进度 | Progress', + group: 'Animation', + default: 0, + min: 0, + max: 1, + step: 0.01, + hidden: true + }, + u_shinyWidth: { + type: 'float', + label: '宽度 | Width', + group: 'Effect', + default: 0.25, + min: 0, + max: 1, + step: 0.01, + tooltip: '闪光带宽度 | Width of the shiny band' + }, + u_shinyRotation: { + type: 'float', + label: '角度 | Rotation', + group: 'Effect', + default: 0.524, // 30 degrees in radians | 30度的弧度值 + min: 0, + max: 6.28, // 360 degrees | 360度 + step: 0.01, + hint: 'angle', + tooltip: '闪光扫过的角度 | Angle of shine sweep' + }, + u_shinySoftness: { + type: 'float', + label: '柔和度 | Softness', + group: 'Effect', + default: 1.0, + min: 0, + max: 1, + step: 0.01 + }, + u_shinyBrightness: { + type: 'float', + label: '亮度 | Brightness', + group: 'Effect', + default: 1.0, + min: 0, + max: 2, + step: 0.01 + }, + u_shinyGloss: { + type: 'float', + label: '光泽度 | Gloss', + group: 'Effect', + default: 1.0, + min: 0, + max: 1, + step: 0.01, + tooltip: '0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted' + } + }, + Grayscale: { + u_grayscale: { + type: 'float', + label: '灰度 | Grayscale', + default: 1.0, + min: 0, + max: 1, + step: 0.01, + hint: 'range', + tooltip: '0=彩色, 1=完全灰度 | 0=full color, 1=full grayscale' + } + }, + Tint: { + u_tintColor: { + type: 'color', + label: '着色 | Tint Color', + default: [1, 1, 1, 1] + } + }, + Flash: { + u_flashColor: { + type: 'color', + label: '闪光颜色 | Flash Color', + default: [1, 1, 1, 1] + }, + u_flashAmount: { + type: 'float', + label: '闪光强度 | Flash Amount', + default: 0, + min: 0, + max: 1, + step: 0.01, + hint: 'range' + } + }, + Outline: { + u_outlineColor: { + type: 'color', + label: '描边颜色 | Outline Color', + default: [0, 0, 0, 1] + }, + u_outlineWidth: { + type: 'float', + label: '描边宽度 | Outline Width', + default: 1, + min: 0, + max: 10, + step: 0.5 + }, + u_texelSize: { + type: 'vec2', + label: '纹素大小 | Texel Size', + default: [0.01, 0.01], + hidden: true + } + } +}; + +/** + * Get shader property metadata by shader name. + * 通过着色器名称获取着色器属性元数据。 + * + * @param shaderName - Name of the shader | 着色器名称 + * @returns Property metadata or undefined | 属性元数据或 undefined + */ +export function getShaderProperties(shaderName: string): Record | undefined { + return BUILTIN_SHADER_PROPERTIES[shaderName]; +} + +/** + * Get shader property metadata by shader ID. + * 通过着色器 ID 获取着色器属性元数据。 + * + * @param shaderId - ID of the shader (from BuiltInShaders) | 着色器 ID(来自 BuiltInShaders) + * @returns Property metadata or undefined | 属性元数据或 undefined + */ +export function getShaderPropertiesById(shaderId: number): Record | undefined { + const shaderNames = ['DefaultSprite', 'Grayscale', 'Tint', 'Flash', 'Outline', 'Shiny']; + const name = shaderNames[shaderId]; + return name ? BUILTIN_SHADER_PROPERTIES[name] : undefined; +} diff --git a/packages/material-system/src/mixins/MaterialOverridableMixin.ts b/packages/material-system/src/mixins/MaterialOverridableMixin.ts new file mode 100644 index 00000000..65ec3586 --- /dev/null +++ b/packages/material-system/src/mixins/MaterialOverridableMixin.ts @@ -0,0 +1,268 @@ +/** + * Material overridable mixin for ES Engine. + * ES引擎材质覆盖 Mixin。 + * + * This mixin provides material override functionality that can be mixed into + * any component class (SpriteComponent, UIRenderComponent, etc.). + * 此 Mixin 提供材质覆盖功能,可混入任何组件类。 + * + * @packageDocumentation + */ + +import type { + MaterialPropertyOverride, + MaterialOverrides, + IMaterialOverridable +} from '../interfaces/IMaterialOverridable'; + +/** + * Constructor type for mixin base class. + * Mixin 基类的构造函数类型。 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = new (...args: any[]) => T; + +/** + * Mixin that provides material override functionality. + * 提供材质覆盖功能的 Mixin。 + * + * This mixin adds all material override methods to a base class, + * implementing the IMaterialOverridable interface. + * 此 Mixin 将所有材质覆盖方法添加到基类,实现 IMaterialOverridable 接口。 + * + * @example + * ```typescript + * // Apply mixin to a component class + * class MySpriteComponent extends MaterialOverridableMixin(Component) { + * // ... other properties + * } + * + * // The class now has all material override methods + * const sprite = new MySpriteComponent(); + * sprite.setMaterialId(BuiltInShaders.Shiny); + * sprite.setOverrideFloat('u_shinyProgress', 0.5); + * ``` + * + * @param Base - Base class to extend + * @returns Class with material override functionality + */ +export function MaterialOverridableMixin(Base: TBase) { + return class MaterialOverridableClass extends Base implements IMaterialOverridable { + /** + * Material GUID for asset reference. + * 材质资产引用的 GUID。 + */ + materialGuid: string = ''; + + /** + * Current material ID. + * 当前材质 ID。 + * @internal - Use getMaterialId() and setMaterialId() instead + */ + __materialId: number = 0; + + /** + * Material property overrides. + * 材质属性覆盖。 + * @internal - Use materialOverrides getter instead + */ + __materialOverrides: MaterialOverrides = {}; + + /** + * Get current material overrides. + * 获取当前材质覆盖。 + */ + get materialOverrides(): MaterialOverrides { + return this.__materialOverrides; + } + + /** + * Get current material ID. + * 获取当前材质 ID。 + */ + getMaterialId(): number { + return this.__materialId; + } + + /** + * Set material ID. + * 设置材质 ID。 + */ + setMaterialId(id: number): void { + this.__materialId = id; + } + + /** + * Set a float uniform override. + * 设置浮点 uniform 覆盖。 + */ + setOverrideFloat(name: string, value: number): this { + this.__materialOverrides[name] = { type: 'float', value }; + return this; + } + + /** + * Set a vec2 uniform override. + * 设置 vec2 uniform 覆盖。 + */ + setOverrideVec2(name: string, x: number, y: number): this { + this.__materialOverrides[name] = { type: 'vec2', value: [x, y] }; + return this; + } + + /** + * Set a vec3 uniform override. + * 设置 vec3 uniform 覆盖。 + */ + setOverrideVec3(name: string, x: number, y: number, z: number): this { + this.__materialOverrides[name] = { type: 'vec3', value: [x, y, z] }; + return this; + } + + /** + * Set a vec4 uniform override. + * 设置 vec4 uniform 覆盖。 + */ + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this { + this.__materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] }; + return this; + } + + /** + * Set a color uniform override (RGBA, 0.0-1.0). + * 设置颜色 uniform 覆盖(RGBA,0.0-1.0)。 + */ + setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this { + this.__materialOverrides[name] = { type: 'color', value: [r, g, b, a] }; + return this; + } + + /** + * Set an integer uniform override. + * 设置整数 uniform 覆盖。 + */ + setOverrideInt(name: string, value: number): this { + this.__materialOverrides[name] = { type: 'int', value: Math.floor(value) }; + return this; + } + + /** + * Get a specific override value. + * 获取特定覆盖值。 + */ + getOverride(name: string): MaterialPropertyOverride | undefined { + return this.__materialOverrides[name]; + } + + /** + * Remove a specific override. + * 移除特定覆盖。 + */ + removeOverride(name: string): this { + delete this.__materialOverrides[name]; + return this; + } + + /** + * Clear all overrides. + * 清除所有覆盖。 + */ + clearOverrides(): this { + this.__materialOverrides = {}; + return this; + } + + /** + * Check if any overrides are set. + * 检查是否设置了任何覆盖。 + */ + hasOverrides(): boolean { + return Object.keys(this.__materialOverrides).length > 0; + } + }; +} + +/** + * Helper class that can be used for composition instead of mixin. + * 可用于组合而非 Mixin 的辅助类。 + * + * Use this when you cannot use mixins (e.g., class already extends another class). + * 当无法使用 Mixin 时使用此类(例如,类已继承其他类)。 + * + * @example + * ```typescript + * class MyComponent extends Component { + * private _materialHelper = new MaterialOverrideHelper(); + * + * get materialOverrides() { return this._materialHelper.materialOverrides; } + * getMaterialId() { return this._materialHelper.getMaterialId(); } + * setMaterialId(id: number) { this._materialHelper.setMaterialId(id); } + * // ... delegate other methods + * } + * ``` + */ +export class MaterialOverrideHelper implements IMaterialOverridable { + materialGuid: string = ''; + private _materialId: number = 0; + private _materialOverrides: MaterialOverrides = {}; + + get materialOverrides(): MaterialOverrides { + return this._materialOverrides; + } + + getMaterialId(): number { + return this._materialId; + } + + setMaterialId(id: number): void { + this._materialId = id; + } + + setOverrideFloat(name: string, value: number): this { + this._materialOverrides[name] = { type: 'float', value }; + return this; + } + + setOverrideVec2(name: string, x: number, y: number): this { + this._materialOverrides[name] = { type: 'vec2', value: [x, y] }; + return this; + } + + setOverrideVec3(name: string, x: number, y: number, z: number): this { + this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] }; + return this; + } + + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this { + this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] }; + return this; + } + + setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this { + this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] }; + return this; + } + + setOverrideInt(name: string, value: number): this { + this._materialOverrides[name] = { type: 'int', value: Math.floor(value) }; + return this; + } + + getOverride(name: string): MaterialPropertyOverride | undefined { + return this._materialOverrides[name]; + } + + removeOverride(name: string): this { + delete this._materialOverrides[name]; + return this; + } + + clearOverrides(): this { + this._materialOverrides = {}; + return this; + } + + hasOverrides(): boolean { + return Object.keys(this._materialOverrides).length > 0; + } +} diff --git a/packages/material-system/src/types.ts b/packages/material-system/src/types.ts index 0ca5e2c3..e889e7ba 100644 --- a/packages/material-system/src/types.ts +++ b/packages/material-system/src/types.ts @@ -125,7 +125,8 @@ export const BuiltInShaders = { Grayscale: 1, Tint: 2, Flash: 3, - Outline: 4 + Outline: 4, + Shiny: 5 } as const; /** diff --git a/packages/math/src/Matrix3.ts b/packages/math/src/Matrix3.ts index 6374da15..0691a4e4 100644 --- a/packages/math/src/Matrix3.ts +++ b/packages/math/src/Matrix3.ts @@ -2,12 +2,15 @@ import { Vector2 } from './Vector2'; /** * 3x3变换矩阵类 + * 3x3 Transform Matrix Class * * 用于2D变换(平移、旋转、缩放)的3x3矩阵 - * 矩阵布局: - * [m00, m01, m02] [scaleX * cos, -scaleY * sin, translateX] - * [m10, m11, m12] = [scaleX * sin, scaleY * cos, translateY] - * [m20, m21, m22] [0, 0, 1] + * 使用左手坐标系(顺时针正旋转) + * + * 矩阵布局(顺时针旋转): + * [m00, m01, m02] [scaleX * cos, scaleY * sin, translateX] + * [m10, m11, m12] = [-scaleX * sin, scaleY * cos, translateY] + * [m20, m21, m22] [0, 0, 1] */ export class Matrix3 { /** 矩阵元素,按行优先存储 */ @@ -243,17 +246,24 @@ export class Matrix3 { } /** - * 设置为旋转矩阵 - * @param angle 旋转角度(弧度) - * @returns 当前矩阵实例(链式调用) - */ + * 设置为旋转矩阵(顺时针为正) + * Set as rotation matrix (clockwise positive) + * + * 使用左手坐标系约定:正角度 = 顺时针旋转 + * Uses left-hand coordinate system: positive angle = clockwise + * + * @param angle 旋转角度(弧度) + * @returns 当前矩阵实例(链式调用) + */ makeRotation(angle: number): this { const cos = Math.cos(angle); const sin = Math.sin(angle); + // Clockwise rotation matrix + // 顺时针旋转矩阵 this.elements.set([ - cos, -sin, 0, - sin, cos, 0, + cos, sin, 0, + -sin, cos, 0, 0, 0, 1 ]); return this; @@ -287,18 +297,25 @@ export class Matrix3 { } /** - * 复合旋转 - * @param angle 旋转角度(弧度) - * @returns 当前矩阵实例(链式调用) - */ + * 复合旋转(顺时针为正) + * Composite rotation (clockwise positive) + * + * 使用左手坐标系约定:正角度 = 顺时针旋转 + * Uses left-hand coordinate system: positive angle = clockwise + * + * @param angle 旋转角度(弧度) + * @returns 当前矩阵实例(链式调用) + */ rotate(angle: number): this { const cos = Math.cos(angle); const sin = Math.sin(angle); - const m00 = this.m00 * cos + this.m01 * sin; - const m01 = this.m00 * -sin + this.m01 * cos; - const m10 = this.m10 * cos + this.m11 * sin; - const m11 = this.m10 * -sin + this.m11 * cos; + // Clockwise rotation: multiply by [cos, sin; -sin, cos] + // 顺时针旋转 + const m00 = this.m00 * cos - this.m01 * sin; + const m01 = this.m00 * sin + this.m01 * cos; + const m10 = this.m10 * cos - this.m11 * sin; + const m11 = this.m10 * sin + this.m11 * cos; this.m00 = m00; this.m01 = m01; @@ -433,11 +450,15 @@ export class Matrix3 { } /** - * 获取旋转角度 - * @returns 旋转角度(弧度) - */ + * 获取旋转角度(顺时针为正) + * Get rotation angle (clockwise positive) + * @returns 旋转角度(弧度) + */ getRotation(): number { - return Math.atan2(this.m10, this.m00); + // For clockwise rotation matrix [cos, sin; -sin, cos] + // m00 = cos, m01 = sin, so atan2(m01, m00) = θ + // 顺时针旋转矩阵:从 m01 和 m00 提取角度 + return Math.atan2(this.m01, this.m00); } /** @@ -551,19 +572,26 @@ export class Matrix3 { } /** - * 创建TRS(平移-旋转-缩放)变换矩阵 - * @param translation 平移向量 - * @param rotation 旋转角度(弧度) - * @param scale 缩放向量 - * @returns 新的TRS矩阵 - */ + * 创建TRS(平移-旋转-缩放)变换矩阵(顺时针为正) + * Create TRS (Translate-Rotate-Scale) matrix (clockwise positive) + * + * 使用左手坐标系约定:正角度 = 顺时针旋转 + * Uses left-hand coordinate system: positive angle = clockwise + * + * @param translation 平移向量 + * @param rotation 旋转角度(弧度) + * @param scale 缩放向量 + * @returns 新的TRS矩阵 + */ static TRS(translation: Vector2, rotation: number, scale: Vector2): Matrix3 { const cos = Math.cos(rotation); const sin = Math.sin(rotation); + // Clockwise rotation matrix with scale + // 带缩放的顺时针旋转矩阵 return new Matrix3([ - scale.x * cos, -scale.y * sin, translation.x, - scale.x * sin, scale.y * cos, translation.y, + scale.x * cos, scale.y * sin, translation.x, + -scale.x * sin, scale.y * cos, translation.y, 0, 0, 1 ]); } diff --git a/packages/math/src/Vector2.ts b/packages/math/src/Vector2.ts index 2d0f857f..2d27dd5b 100644 --- a/packages/math/src/Vector2.ts +++ b/packages/math/src/Vector2.ts @@ -282,25 +282,35 @@ export class Vector2 implements IVector2 { } /** - * 获取垂直向量(逆时针旋转90度) - * @returns 新的垂直向量 - */ + * 获取垂直向量(顺时针旋转90度) + * Get perpendicular vector (clockwise 90 degrees) + * @returns 新的垂直向量 + */ perpendicular(): Vector2 { - return new Vector2(-this.y, this.x); + // Clockwise 90° rotation: (x, y) -> (y, -x) + // 顺时针旋转 90° + return new Vector2(this.y, -this.x); } // 变换操作 /** - * 向量旋转 - * @param angle 旋转角度(弧度) - * @returns 当前向量实例(链式调用) - */ + * 向量旋转(顺时针为正) + * Rotate vector (clockwise positive) + * + * 使用左手坐标系约定:正角度 = 顺时针旋转 + * Uses left-hand coordinate system: positive angle = clockwise + * + * @param angle 旋转角度(弧度) + * @returns 当前向量实例(链式调用) + */ rotate(angle: number): this { const cos = Math.cos(angle); const sin = Math.sin(angle); - const x = this.x * cos - this.y * sin; - const y = this.x * sin + this.y * cos; + // Clockwise rotation: x' = x*cos + y*sin, y' = -x*sin + y*cos + // 顺时针旋转公式 + const x = this.x * cos + this.y * sin; + const y = -this.x * sin + this.y * cos; this.x = x; this.y = y; return this; diff --git a/packages/math/tests/Vector2.test.ts b/packages/math/tests/Vector2.test.ts index 0b7ea134..39a43141 100644 --- a/packages/math/tests/Vector2.test.ts +++ b/packages/math/tests/Vector2.test.ts @@ -149,11 +149,13 @@ describe('Vector2', () => { }); describe('变换操作', () => { - test('rotate方法应正确旋转向量', () => { + test('rotate方法应正确旋转向量(顺时针)', () => { + // Clockwise rotation: (1, 0) rotated 90° clockwise = (0, -1) + // 顺时针旋转:(1, 0) 顺时针旋转 90° = (0, -1) const v = new Vector2(1, 0); v.rotate(Math.PI / 2); expectFloatsEqual(v.x, 0, 1e-10); - expectFloatsEqual(v.y, 1, 1e-10); + expectFloatsEqual(v.y, -1, 1e-10); }); test('reflect方法应正确反射向量', () => { diff --git a/packages/particle-editor/src/gizmos/ParticleGizmo.ts b/packages/particle-editor/src/gizmos/ParticleGizmo.ts index 6ce4d8d7..0ed7e8e2 100644 --- a/packages/particle-editor/src/gizmos/ParticleGizmo.ts +++ b/packages/particle-editor/src/gizmos/ParticleGizmo.ts @@ -148,7 +148,10 @@ function particleSystemGizmoProvider( const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX; const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY; const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度 - const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转 + // 转换为弧度并应用世界旋转 | Convert to radians and apply world rotation + // worldRotation 是度(顺时针),转为弧度(逆时针)用于数学计算 + // worldRotation is degrees(clockwise), convert to radians(counter-clockwise) for math + const direction = ((asset?.direction ?? 90) * Math.PI / 180) - (worldRotation * Math.PI / 180); // 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape switch (emissionShape) { diff --git a/packages/particle/src/systems/ClickFxSystem.ts b/packages/particle/src/systems/ClickFxSystem.ts index 795f9bc0..072f46c6 100644 --- a/packages/particle/src/systems/ClickFxSystem.ts +++ b/packages/particle/src/systems/ClickFxSystem.ts @@ -227,6 +227,12 @@ export class ClickFxSystem extends EntitySystem { private _checkTrigger(clickFx: ClickFxComponent): boolean { const mode = clickFx.triggerMode; + // 首先检查鼠标是否在 Canvas 内 + // First check if mouse is within canvas bounds + if (!this._isMouseInCanvas()) { + return false; + } + switch (mode) { case ClickFxTriggerMode.LeftClick: return Input.isMouseButtonJustPressed(MouseButton.Left); @@ -253,6 +259,27 @@ export class ClickFxSystem extends EntitySystem { } } + /** + * 检查鼠标是否在 Canvas 内 + * Check if mouse is within canvas bounds + */ + private _isMouseInCanvas(): boolean { + if (!this._canvas) { + return true; // 没有 canvas 引用时,默认允许(兼容旧行为) + } + + const rect = this._canvas.getBoundingClientRect(); + const mouseX = Input.mousePosition.x; + const mouseY = Input.mousePosition.y; + + // 检查鼠标是否在 canvas 边界内 + // Check if mouse is within canvas bounds + return mouseX >= rect.left && + mouseX <= rect.right && + mouseY >= rect.top && + mouseY <= rect.bottom; + } + /** * 检查是否有新的触摸开始 * Check if there's a new touch start diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts index 0cf2aa97..2e00995d 100644 --- a/packages/particle/src/systems/ParticleSystem.ts +++ b/packages/particle/src/systems/ParticleSystem.ts @@ -12,6 +12,12 @@ import type { IParticleAsset } from '../loaders/ParticleLoader'; */ const DEFAULT_PARTICLE_TEXTURE_ID = 99999; +/** + * 角度转换常量 + * Angle conversion constants + */ +const DEG_TO_RAD = Math.PI / 180; + /** * 生成默认粒子纹理的 Data URL(渐变圆形) * Generate default particle texture Data URL (gradient circle) @@ -171,9 +177,10 @@ export class ParticleUpdateSystem extends EntitySystem { worldY = pos.y; // 获取旋转(2D 使用 z 分量)| Get rotation (2D uses z component) + // 转换:度(顺时针) → 弧度(逆时针) | Convert: degrees(clockwise) → radians(counter-clockwise) const rot = transform.worldRotation ?? transform.rotation; if (rot) { - worldRotation = rot.z; + worldRotation = -rot.z * DEG_TO_RAD; } // 获取缩放 | Get scale diff --git a/packages/physics-rapier2d-editor/src/gizmos/Physics2DGizmo.ts b/packages/physics-rapier2d-editor/src/gizmos/Physics2DGizmo.ts index 370d00fc..6d0d9dc2 100644 --- a/packages/physics-rapier2d-editor/src/gizmos/Physics2DGizmo.ts +++ b/packages/physics-rapier2d-editor/src/gizmos/Physics2DGizmo.ts @@ -27,6 +27,9 @@ import { RigidbodyType2D } from '@esengine/physics-rapier2d'; +/** 度转弧度常量 | Degrees to radians constant */ +const DEG_TO_RAD = Math.PI / 180; + /** * 物理 Gizmo 颜色配置 */ @@ -185,17 +188,21 @@ function circleCollider2DGizmoProvider( gizmos.push(...createCenterMarkGizmo(worldX, worldY, centerMarkSize, PhysicsGizmoColors.centerMark)); // 半径指示线 (从中心到右边缘) - const rotation = typeof transform.rotation === 'number' + // Radius indicator line (from center to right edge) + const rotationDeg = typeof transform.rotation === 'number' ? transform.rotation : transform.rotation.z; - const cos = Math.cos(rotation); - const sin = Math.sin(rotation); + const rotationRad = rotationDeg * DEG_TO_RAD; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + // Clockwise rotation: use (cos, -sin) for direction + // 顺时针旋转:使用 (cos, -sin) 表示方向 gizmos.push({ type: 'line', points: [ { x: worldX, y: worldY }, - { x: worldX + scaledRadius * cos, y: worldY + scaledRadius * sin } + { x: worldX + scaledRadius * cos, y: worldY - scaledRadius * sin } ], color: PhysicsGizmoColors.selected, closed: false @@ -205,7 +212,7 @@ function circleCollider2DGizmoProvider( gizmos.push({ type: 'circle', x: worldX + scaledRadius * cos, - y: worldY + scaledRadius * sin, + y: worldY - scaledRadius * sin, radius: scaledRadius * 0.08, color: PhysicsGizmoColors.selected } as ICircleGizmoData); @@ -276,21 +283,24 @@ function polygonCollider2DGizmoProvider( const gizmos: IGizmoRenderData[] = []; const color = getColliderColor(isSelected, collider.isTrigger); - const rotation = typeof transform.rotation === 'number' + const rotationDeg = typeof transform.rotation === 'number' ? transform.rotation : transform.rotation.z; - const totalRotation = rotation + collider.rotationOffset; - const cos = Math.cos(totalRotation); - const sin = Math.sin(totalRotation); + // 转换为弧度 | Convert to radians + const totalRotationRad = (rotationDeg + collider.rotationOffset) * DEG_TO_RAD; + const cos = Math.cos(totalRotationRad); + const sin = Math.sin(totalRotationRad); const worldX = transform.position.x + collider.offset.x * transform.scale.x; const worldY = transform.position.y + collider.offset.y * transform.scale.y; + // Clockwise rotation for polygon vertices + // 多边形顶点的顺时针旋转 const worldPoints = collider.vertices.map(v => { const scaledX = v.x * transform.scale.x; const scaledY = v.y * transform.scale.y; - const rotatedX = scaledX * cos - scaledY * sin; - const rotatedY = scaledX * sin + scaledY * cos; + const rotatedX = scaledX * cos + scaledY * sin; + const rotatedY = -scaledX * sin + scaledY * cos; return { x: worldX + rotatedX, y: worldY + rotatedY diff --git a/packages/physics-rapier2d/src/world/Physics2DWorld.ts b/packages/physics-rapier2d/src/world/Physics2DWorld.ts index 4e8425a1..68dc885c 100644 --- a/packages/physics-rapier2d/src/world/Physics2DWorld.ts +++ b/packages/physics-rapier2d/src/world/Physics2DWorld.ts @@ -3,10 +3,35 @@ * 2D 物理世界封装 * * 封装 Rapier2D 物理世界,提供确定性物理模拟 + * + * 坐标转换说明: + * - ESEngine: 左手坐标系,顺时针正旋转,角度单位为度 + * - Rapier2D: 数学坐标系,逆时针正旋转,角度单位为弧度 */ import type RAPIER from '@esengine/rapier2d'; import type { IVector2 } from '@esengine/ecs-framework-math'; + +// 角度单位转换常量 | Angle unit conversion constants +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +/** + * 将引擎旋转(度,顺时针)转换为 Rapier 旋转(弧度,逆时针) + * Convert engine rotation (degrees, clockwise) to Rapier rotation (radians, counter-clockwise) + */ +function toRapierRotation(degrees: number): number { + return -degrees * DEG_TO_RAD; +} + +/** + * 将 Rapier 旋转(弧度,逆时针)转换为引擎旋转(度,顺时针) + * Convert Rapier rotation (radians, counter-clockwise) to engine rotation (degrees, clockwise) + */ +function fromRapierRotation(radians: number): number { + return -radians * RAD_TO_DEG; +} + import type { Physics2DConfig, RaycastHit2D, @@ -223,9 +248,10 @@ export class Physics2DWorld { } // 设置刚体属性 + // 转换旋转:引擎(度,顺时针)→ Rapier(弧度,逆时针) bodyDesc .setTranslation(position.x, position.y) - .setRotation(rotation) + .setRotation(toRapierRotation(rotation)) .setLinearDamping(rigidbody.linearDamping) .setAngularDamping(rigidbody.angularDamping) .setGravityScale(rigidbody.gravityScale) @@ -306,7 +332,8 @@ export class Physics2DWorld { if (!body) return; body.setTranslation(new this._rapier.Vector2(position.x, position.y), true); - body.setRotation(rotation, true); + // 转换旋转:引擎(度,顺时针)→ Rapier(弧度,逆时针) + body.setRotation(toRapierRotation(rotation), true); } /** @@ -333,7 +360,8 @@ export class Physics2DWorld { const body = this._world.getRigidBody(handle); if (!body) return null; - return body.rotation(); + // 转换旋转:Rapier(弧度,逆时针)→ 引擎(度,顺时针) + return fromRapierRotation(body.rotation()); } /** @@ -803,7 +831,7 @@ export class Physics2DWorld { const shape = new this._rapier.Cuboid(halfExtents.x, halfExtents.y); const shapePos = new this._rapier.Vector2(center.x, center.y); - this._world.intersectionsWithShape(shapePos, rotation, shape, (collider) => { + this._world.intersectionsWithShape(shapePos, toRapierRotation(rotation), shape, (collider) => { const mapping = this._colliderMap.get(collider.handle); if (mapping && (collider.collisionGroups() & collisionMask) !== 0) { entityIds.push(mapping.entityId); @@ -1016,7 +1044,7 @@ export class Physics2DWorld { // 配置碰撞体属性 colliderDesc .setTranslation(collider.offset.x * sx, collider.offset.y * sy) - .setRotation(collider.rotationOffset) + .setRotation(toRapierRotation(collider.rotationOffset)) .setFriction(collider.friction) .setRestitution(collider.restitution) .setDensity(collider.density) diff --git a/packages/runtime-core/src/GameRuntime.ts b/packages/runtime-core/src/GameRuntime.ts index b67cb525..a92dc77a 100644 --- a/packages/runtime-core/src/GameRuntime.ts +++ b/packages/runtime-core/src/GameRuntime.ts @@ -23,7 +23,7 @@ import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core'; -import { AssetManager, EngineIntegration, AssetManagerToken } from '@esengine/asset-system'; +import { AssetManager, EngineIntegration, AssetManagerToken, setGlobalAssetDatabase } from '@esengine/asset-system'; // ============================================================================ // 本地服务令牌定义 | Local Service Token Definitions @@ -347,6 +347,10 @@ export class GameRuntime { this._assetManager = new AssetManager(); this._engineIntegration = new EngineIntegration(this._assetManager, this._bridge); + // 设置全局资产数据库(供渲染系统查询 sprite 元数据) + // Set global asset database (for render systems to query sprite metadata) + setGlobalAssetDatabase(this._assetManager.getDatabase()); + // 9. 加载并初始化插件(编辑器模式下跳过,由 editor-core 的 PluginManager 处理) if (!this._config.skipPluginLoading) { await this._initializePlugins(); @@ -1034,6 +1038,8 @@ export class GameRuntime { if (this._assetManager) { this._assetManager.dispose(); this._assetManager = null; + // 清除全局资产数据库引用 | Clear global asset database reference + setGlobalAssetDatabase(null); } this._engineIntegration = null; diff --git a/packages/sprite/package.json b/packages/sprite/package.json index 9375d9c9..90ce07bd 100644 --- a/packages/sprite/package.json +++ b/packages/sprite/package.json @@ -31,6 +31,7 @@ "@esengine/ecs-framework": "workspace:*", "@esengine/asset-system": "workspace:*", "@esengine/engine-core": "workspace:*", + "@esengine/material-system": "workspace:*", "@esengine/build-config": "workspace:*", "rimraf": "^5.0.5", "tsup": "^8.0.0", diff --git a/packages/sprite/src/ShinyEffectComponent.ts b/packages/sprite/src/ShinyEffectComponent.ts new file mode 100644 index 00000000..08f263e2 --- /dev/null +++ b/packages/sprite/src/ShinyEffectComponent.ts @@ -0,0 +1,175 @@ +/** + * Shiny effect component for sprite elements. + * 精灵元素的闪光效果组件。 + * + * This component configures a sweeping highlight animation that moves across + * the sprite's texture. + * 此组件配置一个扫过精灵纹理的高光动画。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import type { IShinyEffect } from '@esengine/material-system'; +import { + SHINY_EFFECT_DEFAULTS, + resetShinyEffect, + startShinyEffect, + stopShinyEffect, + getShinyRotationRadians +} from '@esengine/material-system'; + +/** + * Shiny effect component. + * 闪光效果组件。 + * + * Adds a sweeping highlight animation to sprites. + * 为精灵添加扫光动画效果。 + * + * @example + * ```typescript + * // Add shiny effect to an entity with SpriteComponent + * const shiny = entity.addComponent(ShinyEffectComponent); + * shiny.play = true; + * shiny.loop = true; + * shiny.duration = 2.0; + * shiny.loopDelay = 2.0; + * ``` + */ +@ECSComponent('ShinyEffect', { requires: ['Sprite'] }) +@Serializable({ version: 1, typeId: 'ShinyEffect' }) +export class ShinyEffectComponent extends Component implements IShinyEffect { + // ============= Effect Parameters ============= + // ============= 效果参数 ============= + + /** + * Width of the shiny band (0.0 - 1.0). + * 闪光带宽度 (0.0 - 1.0)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 }) + public width: number = 0.25; + + /** + * Rotation angle in degrees. + * 旋转角度(度)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 }) + public rotation: number = 129; + + /** + * Edge softness (0.0 - 1.0). + * 边缘柔和度 (0.0 - 1.0)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 }) + public softness: number = 1.0; + + /** + * Brightness multiplier. + * 亮度倍增器。 + */ + @Serialize() + @Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 }) + public brightness: number = 1.0; + + /** + * Gloss intensity. + * 光泽度。 + */ + @Serialize() + @Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 }) + public gloss: number = 1.0; + + // ============= Animation Settings ============= + // ============= 动画设置 ============= + + /** + * Whether the animation is playing. + * 动画是否正在播放。 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Play' }) + public play: boolean = true; + + /** + * Whether to loop the animation. + * 是否循环动画。 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Loop' }) + public loop: boolean = true; + + /** + * Animation duration in seconds. + * 动画持续时间(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 }) + public duration: number = 2.0; + + /** + * Delay between loops in seconds. + * 循环之间的延迟(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 }) + public loopDelay: number = 2.0; + + /** + * Initial delay before first play in seconds. + * 首次播放前的初始延迟(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 }) + public initialDelay: number = 0; + + // ============= Runtime State (not serialized) ============= + // ============= 运行时状态(不序列化)============= + + /** Current animation progress (0.0 - 1.0). | 当前动画进度。 */ + public progress: number = 0; + + /** Current elapsed time in the animation cycle. | 当前周期已用时间。 */ + public elapsedTime: number = 0; + + /** Whether currently in delay phase. | 是否处于延迟阶段。 */ + public inDelay: boolean = false; + + /** Remaining delay time. | 剩余延迟时间。 */ + public delayRemaining: number = 0; + + /** Whether the initial delay has been processed. | 初始延迟是否已处理。 */ + public initialDelayProcessed: boolean = false; + + /** + * Reset the animation to the beginning. + * 重置动画到开始状态。 + */ + reset(): void { + resetShinyEffect(this); + } + + /** + * Start playing the animation. + * 开始播放动画。 + */ + start(): void { + startShinyEffect(this); + } + + /** + * Stop the animation. + * 停止动画。 + */ + stop(): void { + stopShinyEffect(this); + } + + /** + * Get rotation in radians for shader use. + * 获取弧度制的旋转角度供着色器使用。 + */ + getRotationRadians(): number { + return getShinyRotationRadians(this); + } +} diff --git a/packages/sprite/src/SpriteComponent.ts b/packages/sprite/src/SpriteComponent.ts index accc6cec..40f63834 100644 --- a/packages/sprite/src/SpriteComponent.ts +++ b/packages/sprite/src/SpriteComponent.ts @@ -1,27 +1,11 @@ import type { AssetReference } from '@esengine/asset-system'; import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; import { SortingLayers, type ISortable } from '@esengine/engine-core'; - -/** - * Material property override value. - * 材质属性覆盖值。 - * - * Used to override specific uniform parameters on a per-instance basis - * without creating a new material instance. - * 用于在每个实例上覆盖特定的 uniform 参数,而无需创建新的材质实例。 - */ -export interface MaterialPropertyOverride { - /** Uniform type. | Uniform 类型。 */ - type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int'; - /** Uniform value. | Uniform 值。 */ - value: number | number[]; -} - -/** - * Material property overrides map. - * 材质属性覆盖映射。 - */ -export type MaterialOverrides = Record; +import type { + IMaterialOverridable, + MaterialPropertyOverride, + MaterialOverrides +} from '@esengine/material-system'; /** * 精灵组件 - 管理2D图像渲染 @@ -32,7 +16,7 @@ export type MaterialOverrides = Record; */ @ECSComponent('Sprite', { requires: ['Transform'] }) @Serializable({ version: 5, typeId: 'Sprite' }) -export class SpriteComponent extends Component implements ISortable { +export class SpriteComponent extends Component implements ISortable, IMaterialOverridable { /** * 纹理资产 GUID * Texture asset GUID diff --git a/packages/sprite/src/index.ts b/packages/sprite/src/index.ts index c964d6cb..af15be7f 100644 --- a/packages/sprite/src/index.ts +++ b/packages/sprite/src/index.ts @@ -1,8 +1,12 @@ export { SpriteComponent } from './SpriteComponent'; -export type { MaterialPropertyOverride, MaterialOverrides } from './SpriteComponent'; +// Re-export material types from material-system for convenience +// 从 material-system 重新导出材质类型以方便使用 +export type { MaterialPropertyOverride, MaterialOverrides } from '@esengine/material-system'; export { SpriteAnimatorComponent } from './SpriteAnimatorComponent'; export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent'; +export { ShinyEffectComponent } from './ShinyEffectComponent'; export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem'; +export { ShinyEffectSystem } from './systems/ShinyEffectSystem'; export { SpriteRuntimeModule, SpritePlugin } from './SpriteRuntimeModule'; // Service tokens | 服务令牌 diff --git a/packages/sprite/src/systems/ShinyEffectSystem.ts b/packages/sprite/src/systems/ShinyEffectSystem.ts new file mode 100644 index 00000000..7235e33e --- /dev/null +++ b/packages/sprite/src/systems/ShinyEffectSystem.ts @@ -0,0 +1,46 @@ +/** + * Shiny effect animation system. + * 闪光效果动画系统。 + * + * Updates ShinyEffectComponent animations and applies material overrides + * to the associated SpriteComponent. + * 更新 ShinyEffectComponent 动画并将材质覆盖应用到关联的 SpriteComponent。 + */ + +import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework'; +import { ShinyEffectAnimator } from '@esengine/material-system'; +import { ShinyEffectComponent } from '../ShinyEffectComponent'; +import { SpriteComponent } from '../SpriteComponent'; + +/** + * System that animates shiny effects on sprites. + * 为精灵动画闪光效果的系统。 + */ +@ECSSystem('ShinyEffect', { updateOrder: 100 }) +export class ShinyEffectSystem extends EntitySystem { + constructor() { + super(Matcher.empty().all(ShinyEffectComponent)); + } + + /** + * Process all entities with ShinyEffectComponent. + * 处理所有带有 ShinyEffectComponent 的实体。 + */ + protected override process(entities: readonly Entity[]): void { + const deltaTime = Time.deltaTime; + + for (const entity of entities) { + if (!entity.enabled) continue; + + const shiny = entity.getComponent(ShinyEffectComponent); + if (!shiny || !shiny.play) continue; + + const sprite = entity.getComponent(SpriteComponent); + if (!sprite) continue; + + // Use shared animator logic + // 使用共享的动画器逻辑 + ShinyEffectAnimator.processEffect(shiny, sprite, deltaTime); + } + } +} diff --git a/packages/tilemap/src/systems/TilemapRenderingSystem.ts b/packages/tilemap/src/systems/TilemapRenderingSystem.ts index ef8735fc..1cf511c0 100644 --- a/packages/tilemap/src/systems/TilemapRenderingSystem.ts +++ b/packages/tilemap/src/systems/TilemapRenderingSystem.ts @@ -4,6 +4,9 @@ import { Color } from '@esengine/ecs-framework-math'; import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen'; import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent'; +/** 度转弧度常量 | Degrees to radians constant */ +const DEG_TO_RAD = Math.PI / 180; + /** * Tilemap render data for a single tilemap layer * 单个瓦片地图图层的渲染数据 @@ -186,9 +189,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha); // Calculate rotation parameters - // 计算旋转参数 - const cos = Math.cos(transform.rotation.z); - const sin = Math.sin(transform.rotation.z); + // 计算旋转参数(度转弧度) + const rotationRad = transform.rotation.z * DEG_TO_RAD; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); // Tilemap rotation pivot // Tilemap 旋转中心点 @@ -221,10 +225,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX; const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY; - // Apply rotation transform - // 应用旋转变换 - const rotatedX = localX * cos - localY * sin + pivotX; - const rotatedY = localX * sin + localY * cos + pivotY; + // Apply rotation transform (clockwise positive) + // 应用旋转变换(顺时针为正) + const rotatedX = localX * cos + localY * sin + pivotX; + const rotatedY = -localX * sin + localY * cos + pivotY; // Transform: [x, y, rotation, scaleX, scaleY, originX, originY] const tOffset = idx * 7; @@ -301,9 +305,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP ); // Calculate rotation parameters - // 计算旋转参数 - const cos = Math.cos(transform.rotation.z); - const sin = Math.sin(transform.rotation.z); + // 计算旋转参数(度转弧度) + const rotationRad = transform.rotation.z * DEG_TO_RAD; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); // Tilemap rotation pivot // Tilemap 旋转中心点 @@ -320,10 +325,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX; const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY; - // Apply rotation transform - // 应用旋转变换 - const rotatedX = localX * cos - localY * sin + pivotX; - const rotatedY = localX * sin + localY * cos + pivotY; + // Apply rotation transform (clockwise positive) + // 应用旋转变换(顺时针为正) + const rotatedX = localX * cos + localY * sin + pivotX; + const rotatedY = -localX * sin + localY * cos + pivotY; const tOffset = idx * 7; renderData.transforms[tOffset] = rotatedX; diff --git a/packages/ui-editor/package.json b/packages/ui-editor/package.json index d280fe1f..0536cb54 100644 --- a/packages/ui-editor/package.json +++ b/packages/ui-editor/package.json @@ -30,6 +30,7 @@ "@esengine/ecs-framework": "workspace:*", "@esengine/editor-core": "workspace:*", "@esengine/editor-runtime": "workspace:*", + "@esengine/material-system": "workspace:*", "@esengine/build-config": "workspace:*", "lucide-react": "^0.545.0", "react": "^18.3.1", diff --git a/packages/ui-editor/src/gizmos/UITransformGizmo.ts b/packages/ui-editor/src/gizmos/UITransformGizmo.ts index 73a66643..30b67674 100644 --- a/packages/ui-editor/src/gizmos/UITransformGizmo.ts +++ b/packages/ui-editor/src/gizmos/UITransformGizmo.ts @@ -15,21 +15,23 @@ function uiTransformGizmoProvider( return []; } - // Use world coordinates (computed by UILayoutSystem) if available - // Otherwise fallback to local coordinates - // 使用世界坐标(由 UILayoutSystem 计算),如果可用 - // 否则回退到本地坐标 - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // Use world scale for proper hierarchical transform inheritance - // 使用世界缩放以正确继承层级变换 + // 使用 UILayoutSystem 计算的世界坐标 + // Use world coordinates computed by UILayoutSystem + // 如果 layoutComputed = false,说明 UILayoutSystem 还没运行,回退到本地坐标 + // If layoutComputed = false, UILayoutSystem hasn't run yet, fallback to local coordinates + const x = transform.layoutComputed ? transform.worldX : transform.x; + const y = transform.layoutComputed ? transform.worldY : transform.y; const scaleX = transform.worldScaleX ?? transform.scaleX; const scaleY = transform.worldScaleY ?? transform.scaleY; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - // Use world rotation for proper hierarchical transform inheritance - // 使用世界旋转以正确继承层级变换 - const rotation = transform.worldRotation ?? transform.rotation; + const width = (transform.layoutComputed && transform.computedWidth > 0 + ? transform.computedWidth + : transform.width) * scaleX; + const height = (transform.layoutComputed && transform.computedHeight > 0 + ? transform.computedHeight + : transform.height) * scaleY; + // 角度转弧度 | Convert degrees to radians + const rotationDegrees = transform.worldRotation ?? transform.rotation; + const rotation = (rotationDegrees * Math.PI) / 180; // 使用 transform 的 pivot 作为旋转/缩放中心 const pivotX = transform.pivotX; const pivotY = transform.pivotY; diff --git a/packages/ui-editor/src/index.ts b/packages/ui-editor/src/index.ts index 4d08231f..ddabe220 100644 --- a/packages/ui-editor/src/index.ts +++ b/packages/ui-editor/src/index.ts @@ -33,11 +33,11 @@ import { UISliderComponent, UIScrollViewComponent } from '@esengine/ui'; -import { UITransformInspector } from './inspectors'; +import { UITransformInspector, UIRenderInspector } from './inspectors'; import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos'; // Re-exports -export { UITransformInspector } from './inspectors'; +export { UITransformInspector, UIRenderInspector } from './inspectors'; export { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos'; /** @@ -76,6 +76,7 @@ export class UIEditorModule implements IEditorModuleLoader { const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry); if (componentInspectorRegistry) { componentInspectorRegistry.register(new UITransformInspector()); + componentInspectorRegistry.register(new UIRenderInspector()); } // 注册 Gizmo | Register gizmo diff --git a/packages/ui-editor/src/inspectors/UIRenderInspector.tsx b/packages/ui-editor/src/inspectors/UIRenderInspector.tsx new file mode 100644 index 00000000..69c34c3b --- /dev/null +++ b/packages/ui-editor/src/inspectors/UIRenderInspector.tsx @@ -0,0 +1,852 @@ +/** + * UI Render Component Inspector. + * UI 渲染组件检查器。 + * + * Provides unified material editing for UIRenderComponent. + * 为 UIRenderComponent 提供统一的材质编辑。 + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { Component, Core } from '@esengine/ecs-framework'; +import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core'; +import { MessageHub } from '@esengine/editor-core'; +import { UIRenderComponent } from '@esengine/ui'; +import type { ShaderPropertyMeta } from '@esengine/material-system'; +import { getShaderPropertiesById } from '@esengine/material-system'; +import { ChevronDown, ChevronRight, Palette, X, Plus, FileBox } from 'lucide-react'; + +/** + * Material source type. + * 材质来源类型。 + */ +type MaterialSource = 'none' | 'builtin' | 'asset'; + +/** + * Built-in effect options. + * 内置效果选项。 + */ +const BUILTIN_EFFECTS = [ + { id: 1, name: 'Grayscale', description: 'Convert to grayscale' }, + { id: 2, name: 'Tint', description: 'Apply color tint' }, + { id: 3, name: 'Flash', description: 'Flash effect for hit feedback' }, + { id: 4, name: 'Outline', description: 'Add outline border' }, + { id: 5, name: 'Shiny', description: 'Animated shine sweep' }, +]; + +// Uniform type display names +const UNIFORM_TYPE_LABELS: Record = { + 'float': 'Float', + 'int': 'Int', + 'vec2': 'Vec2', + 'vec3': 'Vec3', + 'vec4': 'Vec4', + 'color': 'Color', +}; + +/** + * Single number input with local state to prevent focus loss. + * 带本地状态的单数字输入框,防止失焦。 + */ +function NumberInput({ value, onChange, min, max, step, style }: { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + style?: React.CSSProperties; +}) { + const [localValue, setLocalValue] = useState(String(value)); + const [isFocused, setIsFocused] = useState(false); + + // Sync from prop when not focused + // 未聚焦时从 prop 同步 + React.useEffect(() => { + if (!isFocused) { + setLocalValue(String(value)); + } + }, [value, isFocused]); + + const handleBlur = () => { + setIsFocused(false); + const parsed = parseFloat(localValue); + if (!isNaN(parsed)) { + onChange(parsed); + } else { + setLocalValue(String(value)); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }; + + return ( + setLocalValue(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + style={style} + /> + ); +} + +/** + * Convert radians to degrees. + * 弧度转角度。 + */ +function radToDeg(rad: number): number { + return rad * 180 / Math.PI; +} + +/** + * Convert degrees to radians. + * 角度转弧度。 + */ +function degToRad(deg: number): number { + return deg * Math.PI / 180; +} + +/** + * Property value editor component. + * 属性值编辑器组件。 + */ +function PropertyValueEditor({ meta, value, onChange }: { + meta: ShaderPropertyMeta; + value: number | number[]; + onChange: (value: number | number[]) => void; +}) { + const inputStyle: React.CSSProperties = { + backgroundColor: 'var(--color-bg-inset)', + color: 'var(--color-text-primary)', + border: '1px solid var(--color-border-default)', + borderRadius: 'var(--radius-sm)', + padding: '2px 6px', + fontSize: '11px', + width: '60px' + }; + + switch (meta.type) { + case 'float': + case 'int': { + // Handle 'angle' hint: display degrees, store radians + // 处理 'angle' 提示:显示角度,存储弧度 + const isAngle = meta.hint === 'angle'; + const numValue = typeof value === 'number' ? value : 0; + const displayValue = isAngle ? radToDeg(numValue) : numValue; + const displayMin = isAngle && meta.min !== undefined ? radToDeg(meta.min) : meta.min; + const displayMax = isAngle && meta.max !== undefined ? radToDeg(meta.max) : meta.max; + const displayStep = isAngle ? 1 : (meta.step ?? (meta.type === 'int' ? 1 : 0.01)); + + return ( +
+ { + const storeValue = isAngle ? degToRad(v) : v; + (onChange as (v: number) => void)(storeValue); + }} + style={inputStyle} + /> + {isAngle && ( + ° + )} +
+ ); + } + + case 'vec2': { + const v2 = Array.isArray(value) ? value : [0, 0]; + return ( +
+ onChange([v, v2[1] ?? 0])} + style={{ ...inputStyle, width: '50px' }} + /> + onChange([v2[0] ?? 0, v])} + style={{ ...inputStyle, width: '50px' }} + /> +
+ ); + } + + case 'vec3': { + const v3 = Array.isArray(value) ? value : [0, 0, 0]; + return ( +
+ {[0, 1, 2].map(i => ( + { + const newVal = [...v3]; + newVal[i] = v; + onChange(newVal); + }} + style={{ ...inputStyle, width: '40px' }} + /> + ))} +
+ ); + } + + case 'vec4': { + const v4 = Array.isArray(value) ? value : [0, 0, 0, 0]; + return ( +
+ {[0, 1, 2, 3].map(i => ( + { + const newVal = [...v4]; + newVal[i] = v; + onChange(newVal); + }} + style={{ ...inputStyle, width: '35px' }} + /> + ))} +
+ ); + } + + 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 ( +
+ { + 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' }} + /> + onChange([cr, cg, cb, v])} + style={{ ...inputStyle, width: '40px' }} + /> +
+ ); + } + + default: + return Unsupported; + } +} + +/** + * Determine material source from component state. + * 从组件状态确定材质来源。 + */ +function getMaterialSource(render: UIRenderComponent): MaterialSource { + if (render.materialGuid && render.materialGuid.length > 0) { + return 'asset'; + } + if (render.getMaterialId() !== 0) { + return 'builtin'; + } + return 'none'; +} + +/** + * UI Render Inspector content component. + * UI 渲染检查器内容组件。 + */ +function UIRenderInspectorContent({ context }: { context: ComponentInspectorContext }) { + const render = context.component as UIRenderComponent; + const [expandedGroups, setExpandedGroups] = useState>(new Set(['Effect', 'Default'])); + const [showAddMenu, setShowAddMenu] = useState(false); + const [, forceUpdate] = useState({}); + + // Determine current state + const materialSource = getMaterialSource(render); + const materialId = render.getMaterialId(); + const properties = getShaderPropertiesById(materialId); + + // Get effect name for display + const effectName = BUILTIN_EFFECTS.find(e => e.id === materialId)?.name || ''; + + // Group properties + const groupedProps = useMemo(() => { + if (!properties) return {}; + + const groups: Record> = {}; + for (const [name, meta] of Object.entries(properties) as [string, ShaderPropertyMeta][]) { + if (meta.hidden) continue; + const group = meta.group || 'Default'; + if (!groups[group]) groups[group] = []; + groups[group].push([name, meta]); + } + return groups; + }, [properties]); + + // Get available properties for override + const availableProperties = useMemo((): Array<{ name: string; meta: ShaderPropertyMeta }> => { + if (!properties) return []; + const currentOverrides = render.materialOverrides || {}; + return (Object.entries(properties) as [string, ShaderPropertyMeta][]) + .filter(([name, meta]) => !meta.hidden && !currentOverrides[name]) + .map(([name, meta]) => ({ name, meta })); + }, [properties, render.materialOverrides]); + + 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 notifyChange = useCallback(() => { + forceUpdate({}); + const messageHub = Core.services.tryResolve(MessageHub); + if (messageHub) { + messageHub.publish('scene:modified', {}); + } + }, []); + + // Handle source change + const handleSourceChange = useCallback((newSource: MaterialSource) => { + if (newSource === 'none') { + render.materialGuid = ''; + render.setMaterialId(0); + render.clearOverrides(); + } else if (newSource === 'builtin') { + render.materialGuid = ''; + // Set to first effect if currently none + if (render.getMaterialId() === 0) { + render.setMaterialId(1); // Grayscale + } + render.clearOverrides(); + } else if (newSource === 'asset') { + render.setMaterialId(0); + render.clearOverrides(); + // materialGuid will be set by asset picker + } + context.onChange?.('materialGuid', render.materialGuid); + notifyChange(); + }, [render, context, notifyChange]); + + // Handle effect change + const handleEffectChange = useCallback((effectId: number) => { + render.setMaterialId(effectId); + render.clearOverrides(); + context.onChange?.('_materialId', effectId); + notifyChange(); + }, [render, context, notifyChange]); + + // Handle asset change + const handleAssetChange = useCallback((assetGuid: string) => { + render.materialGuid = assetGuid; + context.onChange?.('materialGuid', assetGuid); + notifyChange(); + }, [render, context, notifyChange]); + + // Handle property change + const handlePropertyChange = useCallback((name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => { + switch (meta.type) { + case 'float': + render.setOverrideFloat(name, newValue as number); + break; + case 'int': + render.setOverrideInt(name, newValue as number); + break; + case 'vec2': { + const v2 = newValue as number[]; + render.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0); + break; + } + case 'vec3': { + const v3 = newValue as number[]; + render.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0); + break; + } + case 'vec4': { + const v4 = newValue as number[]; + render.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0); + break; + } + case 'color': { + const c = newValue as number[]; + render.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1); + break; + } + } + context.onChange?.('materialOverrides', render.materialOverrides); + notifyChange(); + }, [render, context, notifyChange]); + + const handleRemoveOverride = useCallback((name: string) => { + render.removeOverride(name); + context.onChange?.('materialOverrides', render.materialOverrides); + notifyChange(); + }, [render, context, notifyChange]); + + const handleAddOverride = useCallback((name: string, meta: ShaderPropertyMeta) => { + const defaultValue = meta.default ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0); + handlePropertyChange(name, meta, defaultValue as number | number[]); + setShowAddMenu(false); + }, [handlePropertyChange]); + + const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => { + const override = render.getOverride(name); + if (override) { + return override.value as number | number[]; + } + return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0); + }; + + const currentOverrides = render.materialOverrides || {}; + const overrideKeys = Object.keys(currentOverrides); + + // Styles + const selectStyle: React.CSSProperties = { + flex: 1, + backgroundColor: 'var(--color-bg-inset)', + color: 'var(--color-text-primary)', + border: '1px solid var(--color-border-default)', + borderRadius: 'var(--radius-sm)', + padding: '4px 8px', + fontSize: '12px' + }; + + const rowStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + padding: '4px 8px', + marginBottom: '4px' + }; + + const labelStyle: React.CSSProperties = { + color: 'var(--color-text-secondary)', + marginRight: '8px', + minWidth: '60px', + fontSize: '12px' + }; + + return ( +
+ {/* Section header */} +
+
+ + Material +
+ {materialSource === 'builtin' && effectName && ( + + {effectName} + + )} + {materialSource === 'asset' && ( + + Asset + + )} +
+ + {/* Source selector */} +
+ Source + +
+ + {/* None selected hint */} + {materialSource === 'none' && ( +
+ No material effect applied.
+ Select a source above to add visual effects. +
+ )} + + {/* Built-in effect selector */} + {materialSource === 'builtin' && ( + <> +
+ Effect + +
+ + {/* Effect description */} + {effectName && ( +
+ {BUILTIN_EFFECTS.find(e => e.id === materialId)?.description} +
+ )} + + {/* Overrides section */} + {overrideKeys.length > 0 && ( +
+
+ + Overrides ({overrideKeys.length}) + +
+ {overrideKeys.map(key => { + const override = currentOverrides[key]; + if (!override) return null; + const meta = properties?.[key]; + if (!meta) return null; + + return ( +
+ + {key.replace(/^u_/, '')} + +
+ handlePropertyChange(key, meta, v)} + /> + +
+
+ ); + })} +
+ )} + + {/* Property groups */} + {Object.entries(groupedProps).map(([group, props]) => ( +
+
toggleGroup(group)} + style={{ + display: 'flex', + alignItems: 'center', + padding: '4px 8px', + backgroundColor: 'var(--color-bg-subtle)', + borderRadius: 'var(--radius-sm)', + cursor: 'pointer', + userSelect: 'none' + }} + > + {expandedGroups.has(group) ? : } + {group} +
+ + {expandedGroups.has(group) && ( +
+ {props.map(([name, meta]) => { + const hasOverride = !!currentOverrides[name]; + return ( +
+ !hasOverride && handleAddOverride(name, meta)} + > + {name.replace(/^u_/, '')} + {!hasOverride && } + + {hasOverride ? ( + handlePropertyChange(name, meta, v)} + /> + ) : ( + + {typeof meta.default === 'number' ? meta.default.toFixed(2) : 'default'} + + )} +
+ ); + })} +
+ )} +
+ ))} + + {/* Add override button */} + {availableProperties.length > 0 && ( +
+ + {showAddMenu && ( +
+ {availableProperties.map(({ name, meta }) => ( + + ))} +
+ )} +
+ )} + + {/* Empty state for effect without properties */} + {!properties && ( +
+ No editable properties for this effect +
+ )} + + )} + + {/* Material Asset selector */} + {materialSource === 'asset' && ( +
+
+ +
+ handleAssetChange(e.target.value)} + placeholder="Drag .mat file here or enter GUID" + style={{ + width: '100%', + backgroundColor: 'var(--color-bg-inset)', + color: 'var(--color-text-primary)', + border: '1px solid var(--color-border-default)', + borderRadius: 'var(--radius-sm)', + padding: '4px 8px', + fontSize: '11px' + }} + /> +
+ {render.materialGuid && ( + + )} +
+
+ Material assets (.mat) define shared shader configurations +
+
+ )} +
+ ); +} + +/** + * UI Render component inspector implementation. + * UI 渲染组件检查器实现。 + * + * Uses 'append' mode to add unified material UI after default properties. + * 使用 'append' 模式在默认属性后添加统一的材质 UI。 + */ +export class UIRenderInspector implements IComponentInspector { + readonly id = 'uirender-inspector'; + readonly name = 'UIRender Inspector'; + readonly priority = 100; + readonly targetComponents = ['UIRender', 'UIRenderComponent']; + readonly renderMode = 'append' as const; + + canHandle(component: Component): component is UIRenderComponent { + return component instanceof UIRenderComponent || + component.constructor.name === 'UIRenderComponent'; + } + + render(context: ComponentInspectorContext): React.ReactElement { + return React.createElement(UIRenderInspectorContent, { + context, + key: `uirender-${context.version}` + }); + } +} diff --git a/packages/ui-editor/src/inspectors/UITransformInspector.tsx b/packages/ui-editor/src/inspectors/UITransformInspector.tsx index 97789a55..d69c215b 100644 --- a/packages/ui-editor/src/inspectors/UITransformInspector.tsx +++ b/packages/ui-editor/src/inspectors/UITransformInspector.tsx @@ -202,6 +202,15 @@ const AnchorPresetGrid: React.FC<{ [AnchorPreset.BottomLeft]: { x: 3, y: 17 }, [AnchorPreset.BottomCenter]: { x: 10, y: 17 }, [AnchorPreset.BottomRight]: { x: 17, y: 17 }, + // Stretch presets (horizontal) | 拉伸预设(水平) + [AnchorPreset.StretchTop]: { x: 10, y: 3 }, + [AnchorPreset.StretchMiddle]: { x: 10, y: 10 }, + [AnchorPreset.StretchBottom]: { x: 10, y: 17 }, + // Stretch presets (vertical) | 拉伸预设(垂直) + [AnchorPreset.StretchLeft]: { x: 3, y: 10 }, + [AnchorPreset.StretchCenter]: { x: 10, y: 10 }, + [AnchorPreset.StretchRight]: { x: 17, y: 10 }, + // Full stretch | 完全拉伸 [AnchorPreset.StretchAll]: { x: 10, y: 10 }, }; return positions[preset]; @@ -320,30 +329,44 @@ export class UITransformInspector implements IComponentInspector { + // [anchorMinX, anchorMinY, anchorMaxX, anchorMaxY] + // Y-up 坐标系:Y=1 是顶部,Y=0 是底部 + // Y-up coordinate system: Y=1 is top, Y=0 is bottom const presetValues: Record = { - [AnchorPreset.TopLeft]: [0, 0, 0, 0], - [AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0], - [AnchorPreset.TopRight]: [1, 0, 1, 0], + [AnchorPreset.TopLeft]: [0, 1, 0, 1], + [AnchorPreset.TopCenter]: [0.5, 1, 0.5, 1], + [AnchorPreset.TopRight]: [1, 1, 1, 1], [AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5], [AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5], [AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5], - [AnchorPreset.BottomLeft]: [0, 1, 0, 1], - [AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1], - [AnchorPreset.BottomRight]: [1, 1, 1, 1], + [AnchorPreset.BottomLeft]: [0, 0, 0, 0], + [AnchorPreset.BottomCenter]: [0.5, 0, 0.5, 0], + [AnchorPreset.BottomRight]: [1, 0, 1, 0], + // Horizontal stretch | 水平拉伸 + [AnchorPreset.StretchTop]: [0, 1, 1, 1], + [AnchorPreset.StretchMiddle]: [0, 0.5, 1, 0.5], + [AnchorPreset.StretchBottom]: [0, 0, 1, 0], + // Vertical stretch | 垂直拉伸 + [AnchorPreset.StretchLeft]: [0, 0, 0, 1], + [AnchorPreset.StretchCenter]: [0.5, 0, 0.5, 1], + [AnchorPreset.StretchRight]: [1, 0, 1, 1], + // Full stretch | 完全拉伸 [AnchorPreset.StretchAll]: [0, 0, 1, 1], }; diff --git a/packages/ui-editor/src/inspectors/index.ts b/packages/ui-editor/src/inspectors/index.ts index 65b39e5d..35caa33e 100644 --- a/packages/ui-editor/src/inspectors/index.ts +++ b/packages/ui-editor/src/inspectors/index.ts @@ -1 +1,2 @@ export * from './UITransformInspector'; +export * from './UIRenderInspector'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 09a55dba..be575632 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,6 +33,7 @@ "@esengine/ecs-framework": "workspace:*", "@esengine/engine-core": "workspace:*", "@esengine/ecs-engine-bindgen": "workspace:*", + "@esengine/material-system": "workspace:*", "@esengine/build-config": "workspace:*", "rimraf": "^5.0.5", "tsup": "^8.0.0", diff --git a/packages/ui/src/UIBuilder.ts b/packages/ui/src/UIBuilder.ts index 47293f43..a549e72a 100644 --- a/packages/ui/src/UIBuilder.ts +++ b/packages/ui/src/UIBuilder.ts @@ -8,6 +8,9 @@ import { UIButtonComponent } from './components/widgets/UIButtonComponent'; import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent'; import { UISliderComponent } from './components/widgets/UISliderComponent'; import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent'; +import { UIToggleComponent, type UIToggleStyle } from './components/widgets/UIToggleComponent'; +import { UIInputFieldComponent, type UIInputContentType, type UIInputLineType } from './components/widgets/UIInputFieldComponent'; +import { UIDropdownComponent, type UIDropdownOption } from './components/widgets/UIDropdownComponent'; /** * 基础 UI 配置 @@ -125,6 +128,55 @@ export interface UIScrollViewConfig extends UIBaseConfig { backgroundColor?: number; } +/** + * 开关配置 + * Toggle configuration + */ +export interface UIToggleConfig extends UIBaseConfig { + isOn?: boolean; + style?: UIToggleStyle; + onColor?: number; + offColor?: number; + onChange?: (isOn: boolean) => void; +} + +/** + * 输入框配置 + * Input field configuration + */ +export interface UIInputFieldConfig extends UIBaseConfig { + placeholder?: string; + text?: string; + contentType?: UIInputContentType; + lineType?: UIInputLineType; + characterLimit?: number; + textColor?: number; + placeholderColor?: number; + backgroundColor?: number; + borderColor?: number; + borderWidth?: number; + padding?: number; + onValueChanged?: (value: string) => void; + onSubmit?: (value: string) => void; +} + +/** + * 下拉菜单配置 + * Dropdown configuration + */ +export interface UIDropdownConfig extends UIBaseConfig { + options?: UIDropdownOption[]; + selectedIndex?: number; + placeholder?: string; + buttonColor?: number; + textColor?: number; + borderColor?: number; + listBackgroundColor?: number; + optionHeight?: number; + maxVisibleOptions?: number; + onValueChanged?: (value: string | number, index: number) => void; +} + /** * UI 构建器 * UI Builder - Simplified API for creating UI elements @@ -390,6 +442,129 @@ export class UIBuilder { return entity; } + /** + * 创建开关 + * Create toggle (checkbox/switch) + */ + public toggle(config: UIToggleConfig): Entity { + const entity = this.createBase({ + ...config, + width: config.width ?? (config.style === 'switch' ? 50 : 24), + height: config.height ?? 24 + }, 'Toggle'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Rect; + + // 交互组件 + const interactable = entity.addComponent(new UIInteractableComponent()); + interactable.cursor = 'pointer'; + + // 开关组件 + const toggle = entity.addComponent(new UIToggleComponent()); + toggle.isOn = config.isOn ?? false; + toggle.style = config.style ?? 'checkbox'; + toggle.onChange = config.onChange; + + if (config.onColor !== undefined) toggle.onColor = config.onColor; + if (config.offColor !== undefined) toggle.offColor = config.offColor; + + // 初始化显示状态 + toggle.displayProgress = toggle.isOn ? 1 : 0; + toggle.targetProgress = toggle.displayProgress; + + return entity; + } + + /** + * 创建文本输入框 + * Create input field + */ + public inputField(config: UIInputFieldConfig): Entity { + const entity = this.createBase({ + ...config, + width: config.width ?? 200, + height: config.height ?? 36 + }, 'InputField'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Rect; + render.backgroundColor = config.backgroundColor ?? 0xFFFFFF; + + // 交互组件 + const interactable = entity.addComponent(new UIInteractableComponent()); + interactable.cursor = 'text'; + interactable.focusable = true; + + // 输入框组件 + const inputField = entity.addComponent(new UIInputFieldComponent()); + inputField.placeholder = config.placeholder ?? ''; + inputField.text = config.text ?? ''; + inputField.contentType = config.contentType ?? 'standard'; + inputField.lineType = config.lineType ?? 'singleLine'; + inputField.characterLimit = config.characterLimit ?? 0; + inputField.onValueChanged = config.onValueChanged; + inputField.onSubmit = config.onSubmit; + + if (config.textColor !== undefined) inputField.textColor = config.textColor; + if (config.placeholderColor !== undefined) inputField.placeholderColor = config.placeholderColor; + if (config.padding !== undefined) inputField.padding = config.padding; + + // 背景和边框通过 UIRenderComponent 设置 + // Background and border are set via UIRenderComponent + if (config.backgroundColor !== undefined) render.backgroundColor = config.backgroundColor; + if (config.borderColor !== undefined) render.borderColor = config.borderColor; + if (config.borderWidth !== undefined) render.borderWidth = config.borderWidth; + + return entity; + } + + /** + * 创建下拉菜单 + * Create dropdown + */ + public dropdown(config: UIDropdownConfig): Entity { + const entity = this.createBase({ + ...config, + width: config.width ?? 200, + height: config.height ?? 36 + }, 'Dropdown'); + + // 渲染组件 + const render = entity.addComponent(new UIRenderComponent()); + render.type = UIRenderType.Rect; + render.backgroundColor = config.buttonColor ?? 0xFFFFFF; + + // 交互组件 + const interactable = entity.addComponent(new UIInteractableComponent()); + interactable.cursor = 'pointer'; + + // 下拉菜单组件 + const dropdown = entity.addComponent(new UIDropdownComponent()); + dropdown.placeholder = config.placeholder ?? 'Select...'; + dropdown.selectedIndex = config.selectedIndex ?? -1; + dropdown.onValueChanged = config.onValueChanged; + + if (config.options) { + dropdown.options = config.options; + } + + if (config.buttonColor !== undefined) dropdown.buttonColor = config.buttonColor; + if (config.textColor !== undefined) dropdown.textColor = config.textColor; + if (config.borderColor !== undefined) dropdown.borderColor = config.borderColor; + if (config.listBackgroundColor !== undefined) dropdown.listBackgroundColor = config.listBackgroundColor; + if (config.optionHeight !== undefined) dropdown.optionHeight = config.optionHeight; + if (config.maxVisibleOptions !== undefined) dropdown.maxVisibleOptions = config.maxVisibleOptions; + + // 初始化颜色 + dropdown.currentColor = dropdown.buttonColor; + dropdown.targetColor = dropdown.buttonColor; + + return entity; + } + /** * 创建分隔线 * Create divider/separator diff --git a/packages/ui/src/UIRuntimeModule.ts b/packages/ui/src/UIRuntimeModule.ts index 17fd4b9f..c4a9e176 100644 --- a/packages/ui/src/UIRuntimeModule.ts +++ b/packages/ui/src/UIRuntimeModule.ts @@ -1,7 +1,9 @@ import type { IScene, IComponentRegistry } from '@esengine/ecs-framework'; import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen'; +import { EngineIntegration } from '@esengine/asset-system'; +import { initializeDynamicAtlasService, registerTexturePathMapping, AtlasExpansionStrategy, type IAtlasEngineBridge } from './atlas'; import { UITransformComponent, UIRenderComponent, @@ -11,13 +13,18 @@ import { UIButtonComponent, UIProgressBarComponent, UISliderComponent, - UIScrollViewComponent + UIScrollViewComponent, + UIToggleComponent, + UIInputFieldComponent, + UIDropdownComponent } from './components'; import { TextBlinkComponent } from './components/TextBlinkComponent'; import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent'; +import { UIShinyEffectComponent } from './components/UIShinyEffectComponent'; import { UILayoutSystem } from './systems/UILayoutSystem'; import { UIInputSystem } from './systems/UIInputSystem'; import { UIAnimationSystem } from './systems/UIAnimationSystem'; +import { UISliderFillSystem } from './systems/UISliderFillSystem'; import { UIRenderDataProvider } from './systems/UIRenderDataProvider'; import { TextBlinkSystem } from './systems/TextBlinkSystem'; import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem'; @@ -28,7 +35,11 @@ import { UIButtonRenderSystem, UIProgressBarRenderSystem, UISliderRenderSystem, - UIScrollViewRenderSystem + UIScrollViewRenderSystem, + UIToggleRenderSystem, + UIInputFieldRenderSystem, + UIDropdownRenderSystem, + UIShinyEffectSystem } from './systems/render'; import { UILayoutSystemToken, @@ -56,14 +67,23 @@ class UIRuntimeModule implements IRuntimeModule { registry.register(UIProgressBarComponent); registry.register(UISliderComponent); registry.register(UIScrollViewComponent); + registry.register(UIToggleComponent); + registry.register(UIInputFieldComponent); + registry.register(UIDropdownComponent); registry.register(TextBlinkComponent); registry.register(SceneLoadTriggerComponent); + registry.register(UIShinyEffectComponent); } createSystems(scene: IScene, context: SystemContext): void { // 从服务注册表获取依赖 | Get dependencies from service registry const engineBridge = context.services.get(EngineBridgeToken); + // Slider fill control system (runs before layout to modify anchors) + // 滑块填充控制系统(在布局之前运行以修改锚点) + const sliderFillSystem = new UISliderFillSystem(); + scene.addSystem(sliderFillSystem); + const layoutSystem = new UILayoutSystem(); scene.addSystem(layoutSystem); @@ -81,6 +101,11 @@ class UIRuntimeModule implements IRuntimeModule { const renderBeginSystem = new UIRenderBeginSystem(); scene.addSystem(renderBeginSystem); + // Shiny effect system (runs before render systems to apply material overrides) + // 闪光效果系统(在渲染系统之前运行以应用材质覆盖) + const shinyEffectSystem = new UIShinyEffectSystem(); + scene.addSystem(shinyEffectSystem); + const rectRenderSystem = new UIRectRenderSystem(); scene.addSystem(rectRenderSystem); @@ -96,13 +121,46 @@ class UIRuntimeModule implements IRuntimeModule { const buttonRenderSystem = new UIButtonRenderSystem(); scene.addSystem(buttonRenderSystem); + const toggleRenderSystem = new UIToggleRenderSystem(); + scene.addSystem(toggleRenderSystem); + + const inputFieldRenderSystem = new UIInputFieldRenderSystem(); + scene.addSystem(inputFieldRenderSystem); + + const dropdownRenderSystem = new UIDropdownRenderSystem(); + scene.addSystem(dropdownRenderSystem); + const textRenderSystem = new UITextRenderSystem(); scene.addSystem(textRenderSystem); if (engineBridge) { + // 设置文本渲染系统的纹理回调 + // Set texture callback for text render system textRenderSystem.setTextureCallback((id: number, dataUrl: string) => { engineBridge.loadTexture(id, dataUrl); }); + + // 设置纹理就绪检查回调,用于检测异步加载的纹理是否已就绪 + // Set texture ready checker callback to detect if async-loaded texture is ready + if (engineBridge.isTextureReady) { + textRenderSystem.setTextureReadyChecker((id: number) => { + return engineBridge.isTextureReady!(id); + }); + } + + // 设置输入框渲染系统的纹理回调 + // Set texture callback for input field render system + inputFieldRenderSystem.setTextureCallback((id: number, dataUrl: string) => { + engineBridge.loadTexture(id, dataUrl); + }); + + // 设置输入框渲染系统的纹理就绪检查回调 + // Set texture ready checker callback for input field render system + if (engineBridge.isTextureReady) { + inputFieldRenderSystem.setTextureReadyChecker((id: number) => { + return engineBridge.isTextureReady!(id); + }); + } } const uiRenderProvider = new UIRenderDataProvider(); @@ -115,6 +173,53 @@ class UIRuntimeModule implements IRuntimeModule { context.services.register(UIRenderProviderToken, uiRenderProvider); context.services.register(UIInputSystemToken, inputSystem); context.services.register(UITextRenderSystemToken, textRenderSystem); + + // 初始化动态图集服务 | Initialize dynamic atlas service + // 需要 engineBridge 支持 createBlankTexture 和 updateTextureRegion + // Requires engineBridge to support createBlankTexture and updateTextureRegion + console.log('[UIRuntimeModule] engineBridge available:', !!engineBridge); + console.log('[UIRuntimeModule] createBlankTexture:', !!engineBridge?.createBlankTexture); + console.log('[UIRuntimeModule] updateTextureRegion:', !!engineBridge?.updateTextureRegion); + if (engineBridge?.createBlankTexture && engineBridge?.updateTextureRegion) { + // 创建适配器将 EngineBridge 适配为 IAtlasEngineBridge + // Create adapter to adapt EngineBridge to IAtlasEngineBridge + const atlasBridge: IAtlasEngineBridge = { + createBlankTexture: (width: number, height: number) => { + return engineBridge.createBlankTexture!(width, height); + }, + updateTextureRegion: ( + id: number, + x: number, + y: number, + width: number, + height: number, + pixels: Uint8Array + ) => { + engineBridge.updateTextureRegion!(id, x, y, width, height, pixels); + } + }; + + console.log('[UIRuntimeModule] Initializing dynamic atlas service...'); + initializeDynamicAtlasService(atlasBridge, { + expansionStrategy: AtlasExpansionStrategy.Fixed, // 运行时默认使用固定模式 | Runtime defaults to fixed mode + initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size + fixedPageSize: 1024, // 固定模式页面大小 | Fixed mode page size + maxPageSize: 2048, // 最大页面大小 | Max page size + maxPages: 4, + maxTextureSize: 512, + padding: 1 + }); + console.log('[UIRuntimeModule] Dynamic atlas service initialized'); + + // 注册纹理加载回调,当纹理通过 EngineIntegration 加载时自动注册路径映射 + // Register texture load callback to automatically register path mapping + // when textures are loaded through EngineIntegration + EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => { + registerTexturePathMapping(guid, path); + }); + } else { + console.warn('[UIRuntimeModule] Cannot initialize dynamic atlas service: engineBridge missing createBlankTexture or updateTextureRegion'); + } } } @@ -132,7 +237,9 @@ const manifest: ModuleManifest = { canContainContent: true, dependencies: ['core', 'math'], exports: { components: ['UICanvasComponent'] }, - editorPackage: '@esengine/ui-editor' + editorPackage: '@esengine/ui-editor', + // Plugin export for runtime loading | 运行时加载的插件导出 + pluginExport: 'UIPlugin' }; export const UIPlugin: IRuntimePlugin = { diff --git a/packages/ui/src/atlas/BinPacker.ts b/packages/ui/src/atlas/BinPacker.ts new file mode 100644 index 00000000..08c892b7 --- /dev/null +++ b/packages/ui/src/atlas/BinPacker.ts @@ -0,0 +1,280 @@ +/** + * Bin Packing Algorithm for Dynamic Atlas + * 动态图集的矩形打包算法 + * + * Implements the MaxRects algorithm for efficiently packing rectangles + * into a larger texture atlas. + * 实现 MaxRects 算法,高效地将矩形打包到更大的纹理图集中。 + */ + +/** + * A rectangle region within the atlas + * 图集内的矩形区域 + */ +export interface PackedRect { + /** X position in atlas | 图集中的X位置 */ + x: number; + /** Y position in atlas | 图集中的Y位置 */ + y: number; + /** Width of the packed rectangle | 打包矩形的宽度 */ + width: number; + /** Height of the packed rectangle | 打包矩形的高度 */ + height: number; +} + +/** + * MaxRects Bin Packer + * MaxRects 矩形打包器 + * + * Uses the MaxRects algorithm with Best Short Side Fit heuristic + * to pack rectangles into a fixed-size bin (atlas texture). + * 使用带有最佳短边适配启发式的 MaxRects 算法 + * 将矩形打包到固定大小的容器(图集纹理)中。 + */ +export class BinPacker { + /** Atlas width | 图集宽度 */ + private readonly binWidth: number; + /** Atlas height | 图集高度 */ + private readonly binHeight: number; + /** Padding between packed rectangles | 打包矩形之间的间距 */ + private readonly padding: number; + + /** + * List of free rectangles available for packing + * 可用于打包的空闲矩形列表 + */ + private freeRects: PackedRect[]; + + /** + * Create a new bin packer + * 创建新的矩形打包器 + * + * @param width - Bin width (atlas texture width) | 容器宽度(图集纹理宽度) + * @param height - Bin height (atlas texture height) | 容器高度(图集纹理高度) + * @param padding - Padding between packed rectangles (default: 1) | 矩形之间的间距(默认:1) + */ + constructor(width: number, height: number, padding: number = 1) { + this.binWidth = width; + this.binHeight = height; + this.padding = padding; + + // Start with one free rectangle covering the entire bin + // 从覆盖整个容器的一个空闲矩形开始 + this.freeRects = [{ x: 0, y: 0, width, height }]; + } + + /** + * Pack a rectangle into the atlas + * 将矩形打包到图集中 + * + * @param width - Rectangle width | 矩形宽度 + * @param height - Rectangle height | 矩形高度 + * @returns Packed position, or null if no space available | 打包位置,如果没有可用空间则返回 null + */ + pack(width: number, height: number): PackedRect | null { + // Add padding | 添加间距 + const paddedWidth = width + this.padding; + const paddedHeight = height + this.padding; + + // Find best position using Best Short Side Fit + // 使用最佳短边适配查找最佳位置 + const bestNode = this.findBestPosition(paddedWidth, paddedHeight); + + if (!bestNode) { + return null; // No space available | 没有可用空间 + } + + // Place the rectangle | 放置矩形 + const packedRect: PackedRect = { + x: bestNode.x, + y: bestNode.y, + width, + height + }; + + // Split free rectangles | 分割空闲矩形 + this.splitFreeRects(bestNode.x, bestNode.y, paddedWidth, paddedHeight); + + // Remove redundant free rectangles | 移除冗余的空闲矩形 + this.pruneFreeRects(); + + return packedRect; + } + + /** + * Find the best position for a rectangle using Best Short Side Fit + * 使用最佳短边适配查找矩形的最佳位置 + */ + private findBestPosition(width: number, height: number): PackedRect | null { + let bestNode: PackedRect | null = null; + let bestShortSideFit = Infinity; + let bestLongSideFit = Infinity; + + for (const freeRect of this.freeRects) { + // Check if rectangle fits | 检查矩形是否适合 + if (width <= freeRect.width && height <= freeRect.height) { + const leftoverHoriz = Math.abs(freeRect.width - width); + const leftoverVert = Math.abs(freeRect.height - height); + const shortSideFit = Math.min(leftoverHoriz, leftoverVert); + const longSideFit = Math.max(leftoverHoriz, leftoverVert); + + if (shortSideFit < bestShortSideFit || + (shortSideFit === bestShortSideFit && longSideFit < bestLongSideFit)) { + bestNode = { + x: freeRect.x, + y: freeRect.y, + width, + height + }; + bestShortSideFit = shortSideFit; + bestLongSideFit = longSideFit; + } + } + } + + return bestNode; + } + + /** + * Split free rectangles after placing a new rectangle + * 放置新矩形后分割空闲矩形 + */ + private splitFreeRects(x: number, y: number, width: number, height: number): void { + const newFreeRects: PackedRect[] = []; + const usedRect: PackedRect = { x, y, width, height }; + + for (const freeRect of this.freeRects) { + // Check if the used rectangle intersects with this free rectangle + // 检查已使用矩形是否与此空闲矩形相交 + if (!this.intersects(usedRect, freeRect)) { + newFreeRects.push(freeRect); + continue; + } + + // Split the free rectangle into up to 4 new rectangles + // 将空闲矩形分割成最多4个新矩形 + + // Left piece | 左侧部分 + if (usedRect.x > freeRect.x) { + newFreeRects.push({ + x: freeRect.x, + y: freeRect.y, + width: usedRect.x - freeRect.x, + height: freeRect.height + }); + } + + // Right piece | 右侧部分 + if (usedRect.x + usedRect.width < freeRect.x + freeRect.width) { + newFreeRects.push({ + x: usedRect.x + usedRect.width, + y: freeRect.y, + width: freeRect.x + freeRect.width - usedRect.x - usedRect.width, + height: freeRect.height + }); + } + + // Bottom piece | 底部部分 + if (usedRect.y > freeRect.y) { + newFreeRects.push({ + x: freeRect.x, + y: freeRect.y, + width: freeRect.width, + height: usedRect.y - freeRect.y + }); + } + + // Top piece | 顶部部分 + if (usedRect.y + usedRect.height < freeRect.y + freeRect.height) { + newFreeRects.push({ + x: freeRect.x, + y: usedRect.y + usedRect.height, + width: freeRect.width, + height: freeRect.y + freeRect.height - usedRect.y - usedRect.height + }); + } + } + + this.freeRects = newFreeRects; + } + + /** + * Remove redundant free rectangles (those contained within others) + * 移除冗余的空闲矩形(被其他矩形包含的) + */ + private pruneFreeRects(): void { + const pruned: PackedRect[] = []; + + for (let i = 0; i < this.freeRects.length; i++) { + let isContained = false; + + for (let j = 0; j < this.freeRects.length; j++) { + if (i !== j && this.contains(this.freeRects[j], this.freeRects[i])) { + isContained = true; + break; + } + } + + if (!isContained) { + pruned.push(this.freeRects[i]); + } + } + + this.freeRects = pruned; + } + + /** + * Check if two rectangles intersect + * 检查两个矩形是否相交 + */ + private intersects(a: PackedRect, b: PackedRect): boolean { + return a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y; + } + + /** + * Check if rectangle a contains rectangle b + * 检查矩形 a 是否包含矩形 b + */ + private contains(a: PackedRect, b: PackedRect): boolean { + return a.x <= b.x && + a.y <= b.y && + a.x + a.width >= b.x + b.width && + a.y + a.height >= b.y + b.height; + } + + /** + * Get the current occupancy ratio of the bin + * 获取容器的当前占用率 + */ + getOccupancy(): number { + let usedArea = this.binWidth * this.binHeight; + + for (const freeRect of this.freeRects) { + usedArea -= freeRect.width * freeRect.height; + } + + return usedArea / (this.binWidth * this.binHeight); + } + + /** + * Check if the bin is full (no more space for small allocations) + * 检查容器是否已满(没有更多空间用于小分配) + */ + isFull(): boolean { + // Consider full if we can't fit a 16x16 texture + // 如果无法容纳 16x16 纹理,则认为已满 + return this.freeRects.length === 0 || + this.freeRects.every(r => r.width < 16 || r.height < 16); + } + + /** + * Reset the packer to initial state + * 将打包器重置为初始状态 + */ + reset(): void { + this.freeRects = [{ x: 0, y: 0, width: this.binWidth, height: this.binHeight }]; + } +} diff --git a/packages/ui/src/atlas/DynamicAtlasManager.ts b/packages/ui/src/atlas/DynamicAtlasManager.ts new file mode 100644 index 00000000..95034c2f --- /dev/null +++ b/packages/ui/src/atlas/DynamicAtlasManager.ts @@ -0,0 +1,669 @@ +/** + * Dynamic Atlas Manager + * 动态图集管理器 + * + * Manages runtime texture atlasing to enable batching of UI elements + * that use different source textures. + * 管理运行时纹理图集,以启用使用不同源纹理的 UI 元素的合批。 + */ + +import { BinPacker, PackedRect } from './BinPacker'; + +/** + * Atlas expansion strategy + * 图集扩展策略 + */ +export enum AtlasExpansionStrategy { + /** + * Dynamic expansion: Start small, expand pages when full (has rebuild cost) + * 动态扩展:从小尺寸开始,页面满时扩展(有重建开销) + */ + Dynamic = 'dynamic', + /** + * Fixed size: Use fixed page size, create new pages when full (no rebuild) + * 固定大小:使用固定页面大小,满时创建新页面(无重建) + */ + Fixed = 'fixed' +} + +/** + * Stored texture data for rebuild during expansion + * 存储的纹理数据,用于扩展时重建 + */ +interface StoredTexture { + guid: string; + pixels: Uint8Array; + width: number; + height: number; +} + +/** + * Atlas entry storing the mapping from original texture to atlas region + * 图集条目,存储从原始纹理到图集区域的映射 + */ +export interface AtlasEntry { + /** Atlas texture ID | 图集纹理ID */ + atlasId: number; + /** Position in atlas | 图集中的位置 */ + region: PackedRect; + /** Original texture width | 原始纹理宽度 */ + originalWidth: number; + /** Original texture height | 原始纹理高度 */ + originalHeight: number; + /** UV coordinates in atlas [u0, v0, u1, v1] | 图集中的UV坐标 */ + uv: [number, number, number, number]; +} + +/** + * A single atlas texture with its packer + * 单个图集纹理及其打包器 + */ +interface AtlasPage { + /** GPU texture ID | GPU纹理ID */ + textureId: number; + /** Bin packer for this page | 此页面的矩形打包器 */ + packer: BinPacker; + /** Atlas width | 图集宽度 */ + width: number; + /** Atlas height | 图集高度 */ + height: number; +} + +/** + * Engine bridge interface for texture operations + * 纹理操作的引擎桥接接口 + */ +export interface IAtlasEngineBridge { + /** Create a blank texture | 创建空白纹理 */ + createBlankTexture(width: number, height: number): number; + /** Update a region of a texture | 更新纹理区域 */ + updateTextureRegion( + id: number, + x: number, + y: number, + width: number, + height: number, + pixels: Uint8Array + ): void; +} + +/** + * Configuration for the dynamic atlas manager + * 动态图集管理器配置 + */ +export interface DynamicAtlasConfig { + /** + * Expansion strategy (default: Fixed) + * 扩展策略(默认:固定) + * + * - Dynamic: Start small (initialPageSize), expand when full. Better memory efficiency but has rebuild cost. + * - Fixed: Use fixedPageSize directly, create new pages when full. No rebuild cost but uses more memory initially. + * + * - 动态:从小尺寸开始(initialPageSize),满时扩展。内存效率更高但有重建开销。 + * - 固定:直接使用 fixedPageSize,满时创建新页面。无重建开销但初始内存占用更大。 + */ + expansionStrategy?: AtlasExpansionStrategy; + /** Initial atlas page size for dynamic mode (default: 256) | 动态模式的初始页面大小(默认:256) */ + initialPageSize?: number; + /** Fixed atlas page size for fixed mode (default: 1024) | 固定模式的页面大小(默认:1024) */ + fixedPageSize?: number; + /** Maximum atlas page size (default: 2048) | 最大图集页面大小(默认:2048) */ + maxPageSize?: number; + /** Maximum number of atlas pages (default: 4) | 最大图集页数(默认:4) */ + maxPages?: number; + /** Maximum individual texture size to atlas (default: 512) | 可加入图集的最大单个纹理尺寸(默认:512) */ + maxTextureSize?: number; + /** Padding between textures (default: 1) | 纹理之间的间距(默认:1) */ + padding?: number; +} + +/** + * Dynamic Atlas Manager + * 动态图集管理器 + * + * Automatically packs individual textures into larger atlas textures + * at runtime to enable draw call batching. + * 在运行时自动将单个纹理打包到更大的图集纹理中,以启用绘制调用合批。 + * + * @example + * ```typescript + * const manager = new DynamicAtlasManager(bridge); + * + * // Add texture to atlas + * const entry = await manager.addTexture('texture-guid', imageData, 64, 64); + * + * // Use atlas texture ID and remapped UV for rendering + * const atlasTextureId = entry.atlasId; + * const atlasUV = entry.uv; + * ``` + */ +export class DynamicAtlasManager { + /** Engine bridge for texture operations | 纹理操作的引擎桥接 */ + private bridge: IAtlasEngineBridge; + + /** Atlas configuration | 图集配置 */ + private config: { + expansionStrategy: AtlasExpansionStrategy; + initialPageSize: number; + fixedPageSize: number; + maxPageSize: number; + maxPages: number; + maxTextureSize: number; + padding: number; + }; + + /** Atlas pages | 图集页面 */ + private pages: AtlasPage[] = []; + + /** Mapping from texture GUID to atlas entry | 纹理GUID到图集条目的映射 */ + private entries: Map = new Map(); + + /** Stored textures for rebuild during expansion (only used in Dynamic mode) */ + /** 存储的纹理数据,用于扩展时重建(仅在动态模式下使用) */ + private storedTextures: Map = new Map(); + + /** Whether the manager has been initialized | 管理器是否已初始化 */ + private initialized = false; + + /** + * Create a new dynamic atlas manager + * 创建新的动态图集管理器 + * + * @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接 + * @param config - Configuration options | 配置选项 + */ + constructor(bridge: IAtlasEngineBridge, config: DynamicAtlasConfig = {}) { + this.bridge = bridge; + this.config = { + expansionStrategy: config.expansionStrategy ?? AtlasExpansionStrategy.Fixed, + initialPageSize: config.initialPageSize ?? 256, + fixedPageSize: config.fixedPageSize ?? 1024, + maxPageSize: config.maxPageSize ?? 2048, + maxPages: config.maxPages ?? 4, + maxTextureSize: config.maxTextureSize ?? 512, + padding: config.padding ?? 1 + }; + } + + /** + * Initialize the atlas manager (creates first atlas page) + * 初始化图集管理器(创建第一个图集页面) + */ + initialize(): void { + if (this.initialized) return; + + // Choose initial page size based on strategy + // 根据策略选择初始页面大小 + const initialSize = this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic + ? this.config.initialPageSize + : this.config.fixedPageSize; + + console.log('[DynamicAtlasManager] Initializing with:', { + strategy: this.config.expansionStrategy, + initialPageSize: this.config.initialPageSize, + fixedPageSize: this.config.fixedPageSize, + selectedSize: initialSize + }); + + this.createNewPage(initialSize); + this.initialized = true; + } + + /** + * Check if a texture is already in the atlas + * 检查纹理是否已在图集中 + * + * @param textureGuid - Texture GUID | 纹理GUID + */ + hasTexture(textureGuid: string): boolean { + return this.entries.has(textureGuid); + } + + /** + * Get atlas entry for a texture + * 获取纹理的图集条目 + * + * @param textureGuid - Texture GUID | 纹理GUID + */ + getEntry(textureGuid: string): AtlasEntry | undefined { + return this.entries.get(textureGuid); + } + + /** + * Add a texture to the atlas + * 将纹理添加到图集 + * + * @param textureGuid - Unique identifier for this texture | 此纹理的唯一标识符 + * @param pixels - RGBA pixel data | RGBA像素数据 + * @param width - Texture width | 纹理宽度 + * @param height - Texture height | 纹理高度 + * @returns Atlas entry with UV mapping, or null if texture too large | 带UV映射的图集条目,如果纹理太大则返回null + */ + addTexture( + textureGuid: string, + pixels: Uint8Array, + width: number, + height: number + ): AtlasEntry | null { + // Check if already added | 检查是否已添加 + const existing = this.entries.get(textureGuid); + if (existing) { + return existing; + } + + // Check if texture is too large for atlasing + // 检查纹理是否太大无法加入图集 + if (width > this.config.maxTextureSize || height > this.config.maxTextureSize) { + return null; // Too large, should use original texture | 太大,应使用原始纹理 + } + + // Ensure initialized | 确保已初始化 + if (!this.initialized) { + this.initialize(); + } + + // Store texture data for potential rebuild (only in Dynamic mode) + // 存储纹理数据用于可能的重建(仅在动态模式下) + if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) { + this.storedTextures.set(textureGuid, { + guid: textureGuid, + pixels: new Uint8Array(pixels), // Clone to avoid external mutation + width, + height + }); + } + + // Try to pack into existing pages + // 尝试打包到现有页面 + for (const page of this.pages) { + const region = page.packer.pack(width, height); + if (region) { + // Upload to atlas texture | 上传到图集纹理 + this.bridge.updateTextureRegion( + page.textureId, + region.x, + region.y, + width, + height, + pixels + ); + + // Calculate UV coordinates | 计算UV坐标 + const entry = this.createEntry(page, region, width, height); + this.entries.set(textureGuid, entry); + return entry; + } + } + + // No space in existing pages + // 现有页面没有空间 + if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) { + // Dynamic mode: Try to expand existing page first + // 动态模式:先尝试扩展现有页面 + const expanded = this.tryExpandPage(0); // Try to expand first page + if (expanded) { + // Page expanded, try to pack again + // 页面已扩展,再次尝试打包 + const page = this.pages[0]; + const region = page.packer.pack(width, height); + if (region) { + this.bridge.updateTextureRegion( + page.textureId, + region.x, + region.y, + width, + height, + pixels + ); + const entry = this.createEntry(page, region, width, height); + this.entries.set(textureGuid, entry); + return entry; + } + } + } + + // Create new page if allowed + // 如果允许则创建新页面 + if (this.pages.length < this.config.maxPages) { + // Calculate page size based on strategy + // 根据策略计算页面大小 + let newPageSize: number; + if (this.config.expansionStrategy === AtlasExpansionStrategy.Fixed) { + newPageSize = this.config.fixedPageSize; + } else { + // Dynamic mode: start with initial size for new page + // 动态模式:新页面从初始大小开始 + newPageSize = this.config.initialPageSize; + while (newPageSize < Math.max(width, height) + this.config.padding * 2) { + newPageSize *= 2; + if (newPageSize > this.config.maxPageSize) { + newPageSize = this.config.maxPageSize; + break; + } + } + } + + const page = this.createNewPage(newPageSize); + const region = page.packer.pack(width, height); + + if (region) { + this.bridge.updateTextureRegion( + page.textureId, + region.x, + region.y, + width, + height, + pixels + ); + + const entry = this.createEntry(page, region, width, height); + this.entries.set(textureGuid, entry); + return entry; + } + } + + // Could not fit texture (all pages full or texture too large) + // 无法容纳纹理(所有页面已满或纹理太大) + return null; + } + + /** + * Try to expand a page to a larger size (Dynamic mode only) + * 尝试将页面扩展到更大尺寸(仅动态模式) + * + * @param pageIndex - Index of the page to expand | 要扩展的页面索引 + * @returns True if expansion succeeded | 如果扩展成功返回true + */ + private tryExpandPage(pageIndex: number): boolean { + const page = this.pages[pageIndex]; + if (!page) return false; + + // Check if already at max size + // 检查是否已达到最大尺寸 + if (page.width >= this.config.maxPageSize) { + return false; + } + + // Calculate new size (double the current size) + // 计算新尺寸(当前尺寸的两倍) + const newSize = Math.min(page.width * 2, this.config.maxPageSize); + + // Create new texture + // 创建新纹理 + const newTextureId = this.bridge.createBlankTexture(newSize, newSize); + + // Create new packer + // 创建新打包器 + const newPacker = new BinPacker(newSize, newSize, this.config.padding); + + // Collect all textures from this page + // 收集此页面的所有纹理 + const texturesInPage: StoredTexture[] = []; + for (const [guid, entry] of this.entries) { + if (entry.atlasId === page.textureId) { + const stored = this.storedTextures.get(guid); + if (stored) { + texturesInPage.push(stored); + } + } + } + + // Sort by size (larger first for better packing) + // 按大小排序(大的优先以获得更好的打包效果) + texturesInPage.sort((a, b) => (b.width * b.height) - (a.width * a.height)); + + // Repack all textures into the new larger page + // 将所有纹理重新打包到新的更大页面 + const newEntries = new Map(); + for (const tex of texturesInPage) { + const region = newPacker.pack(tex.width, tex.height); + if (!region) { + // Failed to repack (shouldn't happen if new size is larger) + // 重新打包失败(如果新尺寸更大则不应发生) + return false; + } + + // Upload texture to new atlas + // 将纹理上传到新图集 + this.bridge.updateTextureRegion( + newTextureId, + region.x, + region.y, + tex.width, + tex.height, + tex.pixels + ); + + // Calculate new UV coordinates + // 计算新的UV坐标 + const u0 = region.x / newSize; + const v0 = region.y / newSize; + const u1 = (region.x + region.width) / newSize; + const v1 = (region.y + region.height) / newSize; + + newEntries.set(tex.guid, { + atlasId: newTextureId, + region, + originalWidth: tex.width, + originalHeight: tex.height, + uv: [u0, v0, u1, v1] + }); + } + + // Update page + // 更新页面 + page.textureId = newTextureId; + page.packer = newPacker; + page.width = newSize; + page.height = newSize; + + // Update entries + // 更新条目 + for (const [guid, entry] of newEntries) { + this.entries.set(guid, entry); + } + + return true; + } + + /** + * Create a new atlas page + * 创建新的图集页面 + * + * @param size - Page size (default: initialPageSize) | 页面大小(默认:initialPageSize) + */ + private createNewPage(size?: number): AtlasPage { + const pageSize = size ?? this.config.initialPageSize; + const textureId = this.bridge.createBlankTexture(pageSize, pageSize); + + const page: AtlasPage = { + textureId, + packer: new BinPacker(pageSize, pageSize, this.config.padding), + width: pageSize, + height: pageSize + }; + + this.pages.push(page); + return page; + } + + /** + * Create an atlas entry with UV coordinates + * 创建带UV坐标的图集条目 + */ + private createEntry( + page: AtlasPage, + region: PackedRect, + originalWidth: number, + originalHeight: number + ): AtlasEntry { + // Calculate normalized UV coordinates | 计算归一化UV坐标 + const u0 = region.x / page.width; + const v0 = region.y / page.height; + const u1 = (region.x + region.width) / page.width; + const v1 = (region.y + region.height) / page.height; + + return { + atlasId: page.textureId, + region, + originalWidth, + originalHeight, + uv: [u0, v0, u1, v1] + }; + } + + /** + * Remap UV coordinates from original texture space to atlas space + * 将UV坐标从原始纹理空间重映射到图集空间 + * + * @param entry - Atlas entry | 图集条目 + * @param originalU0 - Original U0 | 原始U0 + * @param originalV0 - Original V0 | 原始V0 + * @param originalU1 - Original U1 | 原始U1 + * @param originalV1 - Original V1 | 原始V1 + * @returns Remapped UV coordinates [u0, v0, u1, v1] | 重映射的UV坐标 + */ + remapUV( + entry: AtlasEntry, + originalU0: number, + originalV0: number, + originalU1: number, + originalV1: number + ): [number, number, number, number] { + const [atlasU0, atlasV0, atlasU1, atlasV1] = entry.uv; + + // Calculate the UV range in atlas space | 计算图集空间中的UV范围 + const atlasURange = atlasU1 - atlasU0; + const atlasVRange = atlasV1 - atlasV0; + + // Remap original UVs to atlas space | 将原始UV重映射到图集空间 + const u0 = atlasU0 + originalU0 * atlasURange; + const v0 = atlasV0 + originalV0 * atlasVRange; + const u1 = atlasU0 + originalU1 * atlasURange; + const v1 = atlasV0 + originalV1 * atlasVRange; + + return [u0, v0, u1, v1]; + } + + /** + * Get all atlas texture IDs + * 获取所有图集纹理ID + */ + getAtlasTextureIds(): number[] { + return this.pages.map(p => p.textureId); + } + + /** + * Get statistics about atlas usage + * 获取图集使用统计信息 + */ + getStats(): { + pageCount: number; + textureCount: number; + averageOccupancy: number; + } { + const occupancies = this.pages.map(p => p.packer.getOccupancy()); + const avgOccupancy = occupancies.length > 0 + ? occupancies.reduce((a, b) => a + b, 0) / occupancies.length + : 0; + + return { + pageCount: this.pages.length, + textureCount: this.entries.size, + averageOccupancy: avgOccupancy + }; + } + + /** + * Get all atlas entries with their GUID + * 获取所有图集条目及其 GUID + */ + getAllEntries(): Array<{ guid: string; entry: AtlasEntry }> { + const result: Array<{ guid: string; entry: AtlasEntry }> = []; + for (const [guid, entry] of this.entries) { + result.push({ guid, entry }); + } + return result; + } + + /** + * Get detailed info for each atlas page + * 获取每个图集页面的详细信息 + */ + getPageDetails(): Array<{ + pageIndex: number; + textureId: number; + width: number; + height: number; + occupancy: number; + entries: Array<{ guid: string; entry: AtlasEntry }>; + }> { + return this.pages.map((page, index) => { + // Find all entries in this page + // 查找此页面中的所有条目 + const pageEntries: Array<{ guid: string; entry: AtlasEntry }> = []; + for (const [guid, entry] of this.entries) { + if (entry.atlasId === page.textureId) { + pageEntries.push({ guid, entry }); + } + } + + return { + pageIndex: index, + textureId: page.textureId, + width: page.width, + height: page.height, + occupancy: page.packer.getOccupancy(), + entries: pageEntries + }; + }); + } + + /** + * Clear all atlas data and reset + * 清除所有图集数据并重置 + * + * Note: This does NOT delete GPU textures. Call this when switching scenes + * or when textures are no longer needed. + * 注意:这不会删除GPU纹理。在切换场景或不再需要纹理时调用此方法。 + */ + clear(): void { + this.entries.clear(); + this.storedTextures.clear(); + this.pages = []; + this.initialized = false; + } + + /** + * Get current expansion strategy + * 获取当前扩展策略 + */ + getExpansionStrategy(): AtlasExpansionStrategy { + return this.config.expansionStrategy; + } +} + +// Singleton instance for global access +// 单例实例用于全局访问 +let globalAtlasManager: DynamicAtlasManager | null = null; + +/** + * Get the global dynamic atlas manager instance + * 获取全局动态图集管理器实例 + * + * @param bridge - Engine bridge (required on first call) | 引擎桥接(首次调用时必需) + */ +export function getDynamicAtlasManager(bridge?: IAtlasEngineBridge): DynamicAtlasManager | null { + if (!globalAtlasManager && bridge) { + globalAtlasManager = new DynamicAtlasManager(bridge); + } + return globalAtlasManager; +} + +/** + * Set the global dynamic atlas manager instance + * 设置全局动态图集管理器实例 + */ +export function setDynamicAtlasManager(manager: DynamicAtlasManager | null): void { + globalAtlasManager = manager; +} diff --git a/packages/ui/src/atlas/DynamicAtlasService.ts b/packages/ui/src/atlas/DynamicAtlasService.ts new file mode 100644 index 00000000..700de531 --- /dev/null +++ b/packages/ui/src/atlas/DynamicAtlasService.ts @@ -0,0 +1,506 @@ +/** + * Dynamic Atlas Service + * 动态图集服务 + * + * Provides automatic texture atlasing for UI elements. + * 为 UI 元素提供自动纹理图集功能。 + */ + +import { + DynamicAtlasManager, + getDynamicAtlasManager, + setDynamicAtlasManager, + type IAtlasEngineBridge, + type AtlasEntry, + type DynamicAtlasConfig +} from './DynamicAtlasManager'; +import { getGlobalAssetFileLoader } from '@esengine/asset-system'; + +/** + * Texture info for atlas + * 图集纹理信息 + */ +export interface TextureInfo { + /** Texture GUID | 纹理 GUID */ + guid: string; + /** Texture URL/path | 纹理 URL/路径 */ + url: string; + /** Texture width | 纹理宽度 */ + width: number; + /** Texture height | 纹理高度 */ + height: number; +} + +/** + * Loading state for a texture + * 纹理加载状态 + */ +type TextureLoadState = 'pending' | 'loading' | 'ready' | 'failed' | 'too-large'; + +/** + * Dynamic Atlas Service + * 动态图集服务 + * + * Manages automatic texture loading and atlasing for UI. + * 管理 UI 的自动纹理加载和图集化。 + * + * @example + * ```typescript + * // Initialize with engine bridge + * const service = new DynamicAtlasService(bridge); + * service.initialize(); + * + * // Add texture to atlas (async) + * await service.addTextureFromUrl('texture-guid', 'assets/button.png'); + * + * // Check if texture is in atlas + * const entry = service.getAtlasEntry('texture-guid'); + * if (entry) { + * // Use atlas texture ID and remapped UV + * } + * ``` + */ +export class DynamicAtlasService { + /** Engine bridge for texture operations | 纹理操作的引擎桥接 */ + private bridge: IAtlasEngineBridge; + + /** Atlas manager instance | 图集管理器实例 */ + private atlasManager: DynamicAtlasManager; + + /** Loading states for textures | 纹理加载状态 */ + private loadStates = new Map(); + + /** Pending load promises | 待处理的加载 Promise */ + private loadPromises = new Map>(); + + /** Maximum texture size for atlasing (default: 512) | 可加入图集的最大纹理尺寸 */ + private maxTextureSize: number; + + /** Whether the service has been initialized | 服务是否已初始化 */ + private initialized = false; + + /** Canvas for pixel extraction | 用于提取像素的 Canvas */ + private canvas: HTMLCanvasElement | null = null; + private ctx: CanvasRenderingContext2D | null = null; + + /** + * Create a new dynamic atlas service + * 创建新的动态图集服务 + * + * @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接 + * @param config - Configuration options | 配置选项 + */ + constructor(bridge: IAtlasEngineBridge, config: DynamicAtlasConfig = {}) { + this.bridge = bridge; + this.maxTextureSize = config.maxTextureSize ?? 512; + this.atlasManager = new DynamicAtlasManager(bridge, config); + } + + /** + * Initialize the service + * 初始化服务 + */ + initialize(): void { + if (this.initialized) return; + + // Set as global atlas manager + // 设置为全局图集管理器 + setDynamicAtlasManager(this.atlasManager); + + // Create canvas for pixel extraction + // 创建用于提取像素的 canvas + if (typeof document !== 'undefined') { + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true }); + } + + this.initialized = true; + } + + /** + * Add texture to atlas from URL + * 从 URL 将纹理添加到图集 + * + * @param textureGuid - Unique identifier for the texture | 纹理的唯一标识符 + * @param url - URL to load the texture from | 加载纹理的 URL + * @returns Atlas entry if added, null if too large or failed | 如果添加成功返回图集条目,太大或失败返回 null + */ + async addTextureFromUrl(textureGuid: string, url: string): Promise { + // Check if already processed | 检查是否已处理 + const existingEntry = this.atlasManager.getEntry(textureGuid); + if (existingEntry) { + return existingEntry; + } + + // Check if already loading | 检查是否正在加载 + const existingPromise = this.loadPromises.get(textureGuid); + if (existingPromise) { + return existingPromise; + } + + // Check state | 检查状态 + const state = this.loadStates.get(textureGuid); + if (state === 'failed' || state === 'too-large') { + return null; + } + + // Start loading | 开始加载 + const promise = this.loadAndAddTexture(textureGuid, url); + this.loadPromises.set(textureGuid, promise); + + try { + const result = await promise; + return result; + } finally { + this.loadPromises.delete(textureGuid); + } + } + + /** + * Load texture and add to atlas + * 加载纹理并添加到图集 + */ + private async loadAndAddTexture(textureGuid: string, url: string): Promise { + this.loadStates.set(textureGuid, 'loading'); + console.log(`[DynamicAtlasService] Loading texture: guid=${textureGuid}, url=${url}`); + + try { + // Load image | 加载图像 + const image = await this.loadImage(url); + console.log(`[DynamicAtlasService] Loaded image: ${url}, size=${image.width}x${image.height}`); + + // Check if too large | 检查是否太大 + if (image.width > this.maxTextureSize || image.height > this.maxTextureSize) { + this.loadStates.set(textureGuid, 'too-large'); + return null; + } + + // Extract pixel data | 提取像素数据 + const pixels = this.extractPixels(image); + if (!pixels) { + this.loadStates.set(textureGuid, 'failed'); + return null; + } + + // Add to atlas | 添加到图集 + const entry = this.atlasManager.addTexture(textureGuid, pixels, image.width, image.height); + + if (entry) { + this.loadStates.set(textureGuid, 'ready'); + } else { + // Atlas might be full | 图集可能已满 + this.loadStates.set(textureGuid, 'failed'); + } + + return entry; + } catch (error) { + console.error(`[DynamicAtlasService] Failed to load texture: ${url}`, error); + this.loadStates.set(textureGuid, 'failed'); + return null; + } + } + + /** + * Load image from asset path + * 从资产路径加载图像 + */ + private loadImage(assetPath: string): Promise { + // Use global asset file loader if available (recommended) + // 如果可用则使用全局资产文件加载器(推荐) + const loader = getGlobalAssetFileLoader(); + console.log(`[DynamicAtlasService] loadImage: path=${assetPath}, hasLoader=${!!loader}`); + if (loader) { + return loader.loadImage(assetPath); + } + + // Fallback: direct HTMLImageElement loading (for H5/web runtime) + // 回退:直接 HTMLImageElement 加载(用于 H5/web 运行时) + console.log(`[DynamicAtlasService] Using fallback image loading: ${assetPath}`); + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = (e) => { + console.error(`[DynamicAtlasService] Image load error: ${assetPath}`, e); + reject(new Error(`Failed to load image: ${assetPath}`)); + }; + image.src = assetPath; + }); + } + + /** + * Extract RGBA pixel data from image + * 从图像提取 RGBA 像素数据 + */ + private extractPixels(image: HTMLImageElement): Uint8Array | null { + if (!this.canvas || !this.ctx) { + console.error('[DynamicAtlasService] Canvas not available'); + return null; + } + + const width = image.width; + const height = image.height; + + // Resize canvas | 调整 canvas 大小 + this.canvas.width = width; + this.canvas.height = height; + + // Draw image | 绘制图像 + this.ctx.clearRect(0, 0, width, height); + this.ctx.drawImage(image, 0, 0); + + // Get pixel data | 获取像素数据 + try { + const imageData = this.ctx.getImageData(0, 0, width, height); + return new Uint8Array(imageData.data); + } catch (e) { + console.error('[DynamicAtlasService] Failed to get image data (CORS?)', e); + return null; + } + } + + /** + * Add multiple textures to atlas + * 批量添加纹理到图集 + * + * @param textures - Array of texture info | 纹理信息数组 + * @returns Map of GUID to atlas entry | GUID 到图集条目的映射 + */ + async addTexturesBatch(textures: TextureInfo[]): Promise> { + const results = new Map(); + + // Load all textures in parallel | 并行加载所有纹理 + const promises = textures.map(async (tex) => { + const entry = await this.addTextureFromUrl(tex.guid, tex.url); + results.set(tex.guid, entry); + }); + + await Promise.all(promises); + return results; + } + + /** + * Check if a texture is in the atlas + * 检查纹理是否在图集中 + */ + hasTexture(textureGuid: string): boolean { + return this.atlasManager.hasTexture(textureGuid); + } + + /** + * Get atlas entry for a texture + * 获取纹理的图集条目 + */ + getAtlasEntry(textureGuid: string): AtlasEntry | undefined { + return this.atlasManager.getEntry(textureGuid); + } + + /** + * Get loading state for a texture + * 获取纹理的加载状态 + */ + getLoadState(textureGuid: string): TextureLoadState | undefined { + return this.loadStates.get(textureGuid); + } + + /** + * Get atlas statistics + * 获取图集统计信息 + */ + getStats(): { + pageCount: number; + textureCount: number; + averageOccupancy: number; + loadingCount: number; + failedCount: number; + } { + const atlasStats = this.atlasManager.getStats(); + let loadingCount = 0; + let failedCount = 0; + + for (const state of this.loadStates.values()) { + if (state === 'loading' || state === 'pending') { + loadingCount++; + } else if (state === 'failed') { + failedCount++; + } + } + + return { + ...atlasStats, + loadingCount, + failedCount + }; + } + + /** + * Get detailed info for each atlas page (for debugging/visualization) + * 获取每个图集页面的详细信息(用于调试/可视化) + */ + getPageDetails(): Array<{ + pageIndex: number; + textureId: number; + width: number; + height: number; + occupancy: number; + entries: Array<{ + guid: string; + entry: { + atlasId: number; + region: { x: number; y: number; width: number; height: number }; + originalWidth: number; + originalHeight: number; + uv: [number, number, number, number]; + }; + }>; + }> { + return this.atlasManager.getPageDetails(); + } + + /** + * Clear all atlas data + * 清除所有图集数据 + */ + clear(): void { + this.atlasManager.clear(); + this.loadStates.clear(); + this.loadPromises.clear(); + } + + /** + * Dispose the service + * 释放服务资源 + */ + dispose(): void { + this.clear(); + this.canvas = null; + this.ctx = null; + this.initialized = false; + + // Clear global reference if it's us | 如果是我们则清除全局引用 + if (getDynamicAtlasManager() === this.atlasManager) { + setDynamicAtlasManager(null); + } + } +} + +// Global service instance | 全局服务实例 +let globalAtlasService: DynamicAtlasService | null = null; + +// GUID to path mapping for texture resolution +// 用于纹理解析的 GUID 到路径映射 +const guidToPathMap = new Map(); + +/** + * Register a texture GUID to path mapping + * 注册纹理 GUID 到路径的映射 + * + * Call this when loading textures to enable automatic atlas integration. + * 在加载纹理时调用此函数以启用自动图集集成。 + * + * @param textureGuid - Texture GUID | 纹理 GUID + * @param path - Texture URL/path | 纹理 URL/路径 + */ +export function registerTexturePathMapping(textureGuid: string, path: string): void { + guidToPathMap.set(textureGuid, path); +} + +/** + * Get the path for a texture GUID + * 获取纹理 GUID 的路径 + * + * @param textureGuid - Texture GUID | 纹理 GUID + * @returns Texture path or undefined | 纹理路径或 undefined + */ +export function getTexturePathByGuid(textureGuid: string): string | undefined { + return guidToPathMap.get(textureGuid); +} + +/** + * Clear all texture path mappings + * 清除所有纹理路径映射 + */ +export function clearTexturePathMappings(): void { + guidToPathMap.clear(); +} + +/** + * Get the global dynamic atlas service + * 获取全局动态图集服务 + */ +export function getDynamicAtlasService(): DynamicAtlasService | null { + return globalAtlasService; +} + +/** + * Set the global dynamic atlas service + * 设置全局动态图集服务 + */ +export function setDynamicAtlasService(service: DynamicAtlasService | null): void { + globalAtlasService = service; +} + +/** + * Initialize the global dynamic atlas service + * 初始化全局动态图集服务 + * + * If the service is already initialized, returns the existing instance. + * 如果服务已初始化,则返回现有实例。 + * + * Note: Image loading is handled through the global IAssetFileLoader service. + * Make sure to call setGlobalAssetFileLoader() before using atlas service. + * 注意:图片加载通过全局 IAssetFileLoader 服务处理。 + * 确保在使用图集服务之前调用 setGlobalAssetFileLoader()。 + * + * @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接 + * @param config - Configuration options | 配置选项 + * @returns The initialized service | 初始化的服务 + */ +export function initializeDynamicAtlasService( + bridge: IAtlasEngineBridge, + config?: DynamicAtlasConfig +): DynamicAtlasService { + // If already initialized, return existing service + // 如果已初始化,返回现有服务 + if (globalAtlasService) { + return globalAtlasService; + } + + // Create and initialize new service | 创建并初始化新服务 + globalAtlasService = new DynamicAtlasService(bridge, config); + globalAtlasService.initialize(); + + return globalAtlasService; +} + +/** + * Reinitialize the global dynamic atlas service with new config + * 使用新配置重新初始化全局动态图集服务 + * + * This will dispose the existing service and create a new one. + * Warning: All existing atlas data will be cleared! + * 这将释放现有服务并创建新服务。 + * 警告:所有现有图集数据将被清除! + * + * @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接 + * @param config - New configuration options | 新的配置选项 + * @returns The reinitialized service | 重新初始化的服务 + */ +export function reinitializeDynamicAtlasService( + bridge: IAtlasEngineBridge, + config?: DynamicAtlasConfig +): DynamicAtlasService { + // Dispose existing service if any + // 如果存在则释放现有服务 + if (globalAtlasService) { + globalAtlasService.dispose(); + globalAtlasService = null; + } + + // Create and initialize new service with new config + // 使用新配置创建并初始化新服务 + globalAtlasService = new DynamicAtlasService(bridge, config); + globalAtlasService.initialize(); + + return globalAtlasService; +} diff --git a/packages/ui/src/atlas/index.ts b/packages/ui/src/atlas/index.ts new file mode 100644 index 00000000..ec3bb1e5 --- /dev/null +++ b/packages/ui/src/atlas/index.ts @@ -0,0 +1,29 @@ +/** + * Dynamic Atlas Module + * 动态图集模块 + * + * Provides runtime texture atlasing for UI batching optimization. + * 提供运行时纹理图集,用于 UI 合批优化。 + */ + +export { BinPacker, type PackedRect } from './BinPacker'; +export { + DynamicAtlasManager, + getDynamicAtlasManager, + setDynamicAtlasManager, + AtlasExpansionStrategy, + type AtlasEntry, + type IAtlasEngineBridge, + type DynamicAtlasConfig +} from './DynamicAtlasManager'; +export { + DynamicAtlasService, + getDynamicAtlasService, + setDynamicAtlasService, + initializeDynamicAtlasService, + reinitializeDynamicAtlasService, + registerTexturePathMapping, + getTexturePathByGuid, + clearTexturePathMappings, + type TextureInfo +} from './DynamicAtlasService'; diff --git a/packages/ui/src/components/UICanvasComponent.ts b/packages/ui/src/components/UICanvasComponent.ts new file mode 100644 index 00000000..4914fc86 --- /dev/null +++ b/packages/ui/src/components/UICanvasComponent.ts @@ -0,0 +1,201 @@ +/** + * UI Canvas Component + * UI 画布组件 + * + * Defines a UI Canvas root that groups UI elements for rendering. + * All child UI elements inherit the Canvas's rendering settings. + * 定义一个 UI 画布根节点,用于分组渲染 UI 元素。 + * 所有子 UI 元素继承画布的渲染设置。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * Canvas render mode + * 画布渲染模式 + */ +export enum UICanvasRenderMode { + /** + * Screen Space - Overlay: UI renders on top of everything + * 屏幕空间 - 覆盖:UI 渲染在所有内容之上 + */ + ScreenSpaceOverlay = 'screen-space-overlay', + + /** + * Screen Space - Camera: UI rendered by a specific camera + * 屏幕空间 - 相机:UI 由特定相机渲染 + */ + ScreenSpaceCamera = 'screen-space-camera', + + /** + * World Space: UI exists in 3D/2D world space + * 世界空间:UI 存在于 3D/2D 世界空间中 + */ + WorldSpace = 'world-space' +} + +/** + * UI Canvas Component + * UI 画布组件 + * + * A Canvas groups UI elements and defines rendering properties. + * UI elements look up their nearest ancestor Canvas to determine render settings. + * 画布将 UI 元素分组并定义渲染属性。 + * UI 元素查找最近的祖先画布来确定渲染设置。 + * + * @example + * ```typescript + * // Create a Canvas root + * const canvasEntity = scene.createEntity('UICanvas'); + * canvasEntity.addComponent(new UICanvasComponent()); + * canvasEntity.addComponent(new UITransformComponent()); + * + * // Create child UI element - inherits Canvas settings + * const button = scene.createEntity('Button'); + * button.addComponent(new UITransformComponent()); + * button.addComponent(new UIButtonComponent()); + * button.getComponent(UITransformComponent).setParent(canvasEntity); + * ``` + */ +@ECSComponent('UICanvas') +@Serializable({ version: 1, typeId: 'UICanvas' }) +export class UICanvasComponent extends Component { + // ===== Render Mode | 渲染模式 ===== + + /** + * Canvas render mode + * 画布渲染模式 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Render Mode', + options: [ + { value: 'screen-space-overlay', label: 'Screen Space - Overlay' }, + { value: 'screen-space-camera', label: 'Screen Space - Camera' }, + { value: 'world-space', label: 'World Space' } + ] + }) + public renderMode: UICanvasRenderMode = UICanvasRenderMode.ScreenSpaceOverlay; + + // ===== Sorting | 排序 ===== + + /** + * Sorting layer name + * 排序层名称 + */ + @Serialize() + @Property({ type: 'string', label: 'Sorting Layer' }) + public sortingLayerName: string = 'UI'; + + /** + * Base order in layer (children add their own orderInLayer to this) + * 层内基础顺序(子元素在此基础上添加自己的 orderInLayer) + */ + @Serialize() + @Property({ type: 'number', label: 'Sort Order' }) + public sortOrder: number = 0; + + // ===== Pixel Perfect | 像素完美 ===== + + /** + * Enable pixel-perfect rendering (snaps to integer pixels) + * 启用像素完美渲染(对齐到整数像素) + */ + @Serialize() + @Property({ type: 'boolean', label: 'Pixel Perfect' }) + public pixelPerfect: boolean = false; + + // ===== Clipping | 裁剪 ===== + + /** + * Enable clipping to Canvas bounds + * 启用画布边界裁剪 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Enable Clipping' }) + public enableClipping: boolean = false; + + // ===== Runtime State | 运行时状态 ===== + + /** + * Cached Canvas ID (for quick lookup) + * 缓存的画布 ID(用于快速查找) + */ + public canvasId: number = 0; + + /** + * Flag indicating Canvas settings changed + * 标记画布设置已更改 + */ + public dirty: boolean = true; + + /** + * Set render mode + * 设置渲染模式 + */ + public setRenderMode(mode: UICanvasRenderMode): this { + if (this.renderMode !== mode) { + this.renderMode = mode; + this.dirty = true; + } + return this; + } + + /** + * Set sorting layer + * 设置排序层 + */ + public setSortingLayer(layerName: string, order?: number): this { + if (this.sortingLayerName !== layerName) { + this.sortingLayerName = layerName; + this.dirty = true; + } + if (order !== undefined && this.sortOrder !== order) { + this.sortOrder = order; + this.dirty = true; + } + return this; + } + + /** + * Set pixel perfect mode + * 设置像素完美模式 + */ + public setPixelPerfect(enabled: boolean): this { + if (this.pixelPerfect !== enabled) { + this.pixelPerfect = enabled; + this.dirty = true; + } + return this; + } + + /** + * Set clipping enabled + * 设置裁剪启用 + */ + public setClipping(enabled: boolean): this { + if (this.enableClipping !== enabled) { + this.enableClipping = enabled; + this.dirty = true; + } + return this; + } + + /** + * Check if this Canvas uses screen space rendering + * 检查此画布是否使用屏幕空间渲染 + */ + public isScreenSpace(): boolean { + return this.renderMode === UICanvasRenderMode.ScreenSpaceOverlay || + this.renderMode === UICanvasRenderMode.ScreenSpaceCamera; + } + + /** + * Check if this Canvas uses world space rendering + * 检查此画布是否使用世界空间渲染 + */ + public isWorldSpace(): boolean { + return this.renderMode === UICanvasRenderMode.WorldSpace; + } +} diff --git a/packages/ui/src/components/UIRenderComponent.ts b/packages/ui/src/components/UIRenderComponent.ts index 3f3e1a2c..93021b2e 100644 --- a/packages/ui/src/components/UIRenderComponent.ts +++ b/packages/ui/src/components/UIRenderComponent.ts @@ -1,4 +1,9 @@ import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import type { + IMaterialOverridable, + MaterialPropertyOverride, + MaterialOverrides +} from '@esengine/material-system'; /** * 渲染类型 @@ -48,7 +53,7 @@ export interface UIShadowStyle { */ @ECSComponent('UIRender') @Serializable({ version: 1, typeId: 'UIRender' }) -export class UIRenderComponent extends Component { +export class UIRenderComponent extends Component implements IMaterialOverridable { /** * 渲染类型 * Type of rendering @@ -115,41 +120,6 @@ export class UIRenderComponent extends Component { */ public textureTint: number = 0xFFFFFF; - // ===== 九宫格 Nine-Patch ===== - - /** - * 九宫格边距 [top, right, bottom, left] - * Nine-patch margins - * - * Defines the non-stretchable borders for nine-patch rendering. - * 定义九宫格渲染时不可拉伸的边框区域。 - */ - @Serialize() - @Property({ type: 'vector4', label: 'Nine-Patch Margins' }) - public ninePatchMargins: [number, number, number, number] = [0, 0, 0, 0]; - - /** - * 源纹理宽度(像素) - * Source texture width in pixels - * - * Required for nine-patch UV calculations. - * 九宫格 UV 计算所需。 - */ - @Serialize() - @Property({ type: 'number', label: 'Texture Width', min: 1 }) - public textureWidth: number = 0; - - /** - * 源纹理高度(像素) - * Source texture height in pixels - * - * Required for nine-patch UV calculations. - * 九宫格 UV 计算所需。 - */ - @Serialize() - @Property({ type: 'number', label: 'Texture Height', min: 1 }) - public textureHeight: number = 0; - // ===== 边框 Border ===== /** @@ -266,20 +236,6 @@ export class UIRenderComponent extends Component { return this; } - /** - * 设置九宫格 - * Set nine-patch image - * - * @param textureGuid - 纹理资产 GUID | Texture asset GUID - * @param margins - 九宫格边距 | Nine-patch margins - */ - public setNinePatch(textureGuid: string | number, margins: [number, number, number, number]): this { - this.type = UIRenderType.NinePatch; - this.textureGuid = textureGuid; - this.ninePatchMargins = margins; - return this; - } - /** * 设置边框 * Set border style @@ -332,4 +288,144 @@ export class UIRenderComponent extends Component { this.gradientStops = stops; return this; } + + // ===== 材质 Material ===== + + /** + * 材质资产 GUID(共享材质) + * Material asset GUID (shared material) + * + * Note: This field is hidden from default PropertyInspector. + * Material editing is handled by UIRenderInspector. + * 注意:此字段在默认 PropertyInspector 中隐藏。 + * 材质编辑由 UIRenderInspector 处理。 + */ + @Serialize() + @Property({ type: 'asset', label: 'Material', extensions: ['.mat'], hidden: true }) + public materialGuid: string = ''; + + /** + * 材质属性覆盖(实例级别) + * Material property overrides (instance level) + */ + @Serialize() + public materialOverrides: MaterialOverrides = {}; + + /** + * 运行时材质ID(缓存) + * Runtime material ID (cached) + */ + private _materialId: number = 0; + + // ============= Material Override Methods ============= + // ============= 材质覆盖方法 ============= + + /** + * 获取材质ID + * Get material ID + */ + getMaterialId(): number { + return this._materialId; + } + + /** + * 设置材质ID + * Set material ID + * + * @param id - Material ID from MaterialManager. | 来自 MaterialManager 的材质ID。 + */ + setMaterialId(id: number): void { + this._materialId = id; + } + + /** + * 设置浮点覆盖值 + * Set float override value + * + * @param name - Uniform name. | Uniform 名称。 + * @param value - Float value. | 浮点值。 + */ + setOverrideFloat(name: string, value: number): this { + this.materialOverrides[name] = { type: 'float', value }; + return this; + } + + /** + * 设置 vec2 覆盖值 + * Set vec2 override value + */ + setOverrideVec2(name: string, x: number, y: number): this { + this.materialOverrides[name] = { type: 'vec2', value: [x, y] }; + return this; + } + + /** + * 设置 vec3 覆盖值 + * Set vec3 override value + */ + setOverrideVec3(name: string, x: number, y: number, z: number): this { + this.materialOverrides[name] = { type: 'vec3', value: [x, y, z] }; + return this; + } + + /** + * 设置 vec4 覆盖值 + * Set vec4 override value + */ + setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this { + this.materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] }; + return this; + } + + /** + * 设置颜色覆盖值 + * Set color override value + */ + setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this { + this.materialOverrides[name] = { type: 'color', value: [r, g, b, a] }; + return this; + } + + /** + * 设置整数覆盖值 + * Set integer override value + */ + setOverrideInt(name: string, value: number): this { + this.materialOverrides[name] = { type: 'int', value: Math.floor(value) }; + return this; + } + + /** + * 获取覆盖值 + * Get override value + */ + getOverride(name: string): MaterialPropertyOverride | undefined { + return this.materialOverrides[name]; + } + + /** + * 移除覆盖值 + * Remove override value + */ + removeOverride(name: string): this { + delete this.materialOverrides[name]; + return this; + } + + /** + * 清除所有覆盖值 + * Clear all override values + */ + clearOverrides(): this { + this.materialOverrides = {}; + return this; + } + + /** + * 检查是否有覆盖值 + * Check if there are any overrides + */ + hasOverrides(): boolean { + return Object.keys(this.materialOverrides).length > 0; + } } diff --git a/packages/ui/src/components/UIShinyEffectComponent.ts b/packages/ui/src/components/UIShinyEffectComponent.ts new file mode 100644 index 00000000..0fa64629 --- /dev/null +++ b/packages/ui/src/components/UIShinyEffectComponent.ts @@ -0,0 +1,174 @@ +/** + * Shiny effect component for UI elements. + * UI 元素的闪光效果组件。 + * + * This component configures a sweeping highlight animation that moves across + * the UI element's texture. + * 此组件配置一个扫过 UI 元素纹理的高光动画。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import type { IShinyEffect } from '@esengine/material-system'; +import { + resetShinyEffect, + startShinyEffect, + stopShinyEffect, + getShinyRotationRadians +} from '@esengine/material-system'; + +/** + * UI Shiny effect component. + * UI 闪光效果组件。 + * + * Adds a sweeping highlight animation to UI elements with UIRenderComponent. + * 为带有 UIRenderComponent 的 UI 元素添加扫光动画效果。 + * + * @example + * ```typescript + * // Add shiny effect to an entity with UIRenderComponent + * const shiny = entity.addComponent(UIShinyEffectComponent); + * shiny.play = true; + * shiny.loop = true; + * shiny.duration = 2.0; + * shiny.loopDelay = 2.0; + * ``` + */ +@ECSComponent('UIShinyEffect', { requires: ['UIRender'] }) +@Serializable({ version: 1, typeId: 'UIShinyEffect' }) +export class UIShinyEffectComponent extends Component implements IShinyEffect { + // ============= Effect Parameters ============= + // ============= 效果参数 ============= + + /** + * Width of the shiny band (0.0 - 1.0). + * 闪光带宽度 (0.0 - 1.0)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 }) + public width: number = 0.25; + + /** + * Rotation angle in degrees. + * 旋转角度(度)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 }) + public rotation: number = 129; + + /** + * Edge softness (0.0 - 1.0). + * 边缘柔和度 (0.0 - 1.0)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 }) + public softness: number = 1.0; + + /** + * Brightness multiplier. + * 亮度倍增器。 + */ + @Serialize() + @Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 }) + public brightness: number = 1.0; + + /** + * Gloss intensity (0=white shine, 1=color-tinted shine). + * 光泽度 (0=白色高光, 1=带颜色的高光)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Gloss', min: 0, max: 1, step: 0.01 }) + public gloss: number = 0; + + // ============= Animation Settings ============= + // ============= 动画设置 ============= + + /** + * Whether the animation is playing. + * 动画是否正在播放。 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Play' }) + public play: boolean = true; + + /** + * Whether to loop the animation. + * 是否循环动画。 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Loop' }) + public loop: boolean = true; + + /** + * Animation duration in seconds. + * 动画持续时间(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 }) + public duration: number = 2.0; + + /** + * Delay between loops in seconds. + * 循环之间的延迟(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 }) + public loopDelay: number = 2.0; + + /** + * Initial delay before first play in seconds. + * 首次播放前的初始延迟(秒)。 + */ + @Serialize() + @Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 }) + public initialDelay: number = 0; + + // ============= Runtime State (not serialized) ============= + // ============= 运行时状态(不序列化)============= + + /** Current animation progress (0.0 - 1.0). | 当前动画进度。 */ + public progress: number = 0; + + /** Current elapsed time in the animation cycle. | 当前周期已用时间。 */ + public elapsedTime: number = 0; + + /** Whether currently in delay phase. | 是否处于延迟阶段。 */ + public inDelay: boolean = false; + + /** Remaining delay time. | 剩余延迟时间。 */ + public delayRemaining: number = 0; + + /** Whether the initial delay has been processed. | 初始延迟是否已处理。 */ + public initialDelayProcessed: boolean = false; + + /** + * Reset the animation to the beginning. + * 重置动画到开始状态。 + */ + reset(): void { + resetShinyEffect(this); + } + + /** + * Start playing the animation. + * 开始播放动画。 + */ + start(): void { + startShinyEffect(this); + } + + /** + * Stop the animation. + * 停止动画。 + */ + stop(): void { + stopShinyEffect(this); + } + + /** + * Get rotation in radians for shader use. + * 获取弧度制的旋转角度供着色器使用。 + */ + getRotationRadians(): number { + return getShinyRotationRadians(this); + } +} diff --git a/packages/ui/src/components/UITransformComponent.ts b/packages/ui/src/components/UITransformComponent.ts index 31d30fd3..ae2045f1 100644 --- a/packages/ui/src/components/UITransformComponent.ts +++ b/packages/ui/src/components/UITransformComponent.ts @@ -4,8 +4,15 @@ import { SortingLayers, type ISortable } from '@esengine/engine-core'; /** * 锚点预设 * Anchor presets for common positioning scenarios + * + * Available presets: + * - Point anchors (9): TopLeft, TopCenter, TopRight, MiddleLeft, MiddleCenter, MiddleRight, BottomLeft, BottomCenter, BottomRight + * - Horizontal stretch (3): StretchTop, StretchMiddle, StretchBottom + * - Vertical stretch (3): StretchLeft, StretchCenter, StretchRight + * - Full stretch (1): StretchAll */ export enum AnchorPreset { + // Point anchors | 点锚点 TopLeft = 'top-left', TopCenter = 'top-center', TopRight = 'top-right', @@ -15,6 +22,18 @@ export enum AnchorPreset { BottomLeft = 'bottom-left', BottomCenter = 'bottom-center', BottomRight = 'bottom-right', + + // Horizontal stretch | 水平拉伸 + StretchTop = 'stretch-top', + StretchMiddle = 'stretch-middle', + StretchBottom = 'stretch-bottom', + + // Vertical stretch | 垂直拉伸 + StretchLeft = 'stretch-left', + StretchCenter = 'stretch-center', + StretchRight = 'stretch-right', + + // Full stretch | 全拉伸 StretchAll = 'stretch-all' } @@ -151,11 +170,11 @@ export class UITransformComponent extends Component implements ISortable { // ===== 变换 Transform ===== /** - * 旋转角度(弧度) - * Rotation angle in radians + * 旋转角度(度,顺时针为正) + * Rotation angle in degrees (clockwise positive) */ @Serialize() - @Property({ type: 'number', label: 'Rotation', step: 0.01 }) + @Property({ type: 'number', label: 'Rotation', step: 1 }) public rotation: number = 0; /** @@ -258,22 +277,31 @@ export class UITransformComponent extends Component implements ISortable { public worldVisible: boolean = true; /** - * 计算后的世界旋转(弧度,考虑父元素旋转) - * Computed world rotation in radians (considering parent rotation) + * 计算后的世界旋转(度,顺时针为正,考虑父元素旋转) + * Computed world rotation in degrees (clockwise positive, considering parent rotation) + * + * undefined 表示尚未由 UILayoutSystem 计算,此时应使用本地 rotation。 + * undefined means not yet computed by UILayoutSystem, should use local rotation in that case. */ - public worldRotation: number = 0; + public worldRotation: number | undefined = undefined; /** * 计算后的世界 X 缩放(考虑父元素缩放) * Computed world X scale (considering parent scale) + * + * undefined 表示尚未由 UILayoutSystem 计算,此时应使用本地 scaleX。 + * undefined means not yet computed by UILayoutSystem, should use local scaleX in that case. */ - public worldScaleX: number = 1; + public worldScaleX: number | undefined = undefined; /** * 计算后的世界 Y 缩放(考虑父元素缩放) * Computed world Y scale (considering parent scale) + * + * undefined 表示尚未由 UILayoutSystem 计算,此时应使用本地 scaleY。 + * undefined means not yet computed by UILayoutSystem, should use local scaleY in that case. */ - public worldScaleY: number = 1; + public worldScaleY: number | undefined = undefined; /** * 计算后的世界层内顺序(考虑父元素和层级深度) @@ -284,6 +312,27 @@ export class UITransformComponent extends Component implements ISortable { */ public worldOrderInLayer: number = 0; + /** + * 所属 Canvas 实体 ID(由 UILayoutSystem 计算) + * Owning Canvas entity ID (computed by UILayoutSystem) + * + * 如果为 null,表示没有父 Canvas(使用默认设置)。 + * If null, indicates no parent Canvas (use default settings). + */ + public canvasEntityId: number | null = null; + + /** + * 计算后的世界排序层(从 Canvas 继承) + * Computed world sorting layer (inherited from Canvas) + */ + public worldSortingLayer: string = SortingLayers.UI; + + /** + * 是否启用像素完美渲染(从 Canvas 继承) + * Whether pixel-perfect rendering is enabled (inherited from Canvas) + */ + public pixelPerfect: boolean = false; + /** * 本地到世界的 2D 变换矩阵(只读,由 UILayoutSystem 计算) * Local to world 2D transformation matrix (readonly, computed by UILayoutSystem) @@ -298,23 +347,34 @@ export class UITransformComponent extends Component implements ISortable { */ public layoutDirty: boolean = true; + /** + * 布局是否已由 UILayoutSystem 计算 + * Flag indicating layout has been computed by UILayoutSystem + * + * 当 UILayoutSystem 运行后设为 true,此时 worldX/worldY 等值有效。 + * Set to true after UILayoutSystem runs, at which point worldX/worldY etc. are valid. + */ + public layoutComputed: boolean = false; + /** * 设置锚点预设 * Set anchor preset for quick positioning */ public setAnchorPreset(preset: AnchorPreset): this { + // anchorMinY=0 是底部,anchorMinY=1 是顶部 + // anchorMinY=0 is bottom, anchorMinY=1 is top switch (preset) { case AnchorPreset.TopLeft: - this.anchorMinX = 0; this.anchorMinY = 0; - this.anchorMaxX = 0; this.anchorMaxY = 0; + this.anchorMinX = 0; this.anchorMinY = 1; + this.anchorMaxX = 0; this.anchorMaxY = 1; break; case AnchorPreset.TopCenter: - this.anchorMinX = 0.5; this.anchorMinY = 0; - this.anchorMaxX = 0.5; this.anchorMaxY = 0; + this.anchorMinX = 0.5; this.anchorMinY = 1; + this.anchorMaxX = 0.5; this.anchorMaxY = 1; break; case AnchorPreset.TopRight: - this.anchorMinX = 1; this.anchorMinY = 0; - this.anchorMaxX = 1; this.anchorMaxY = 0; + this.anchorMinX = 1; this.anchorMinY = 1; + this.anchorMaxX = 1; this.anchorMaxY = 1; break; case AnchorPreset.MiddleLeft: this.anchorMinX = 0; this.anchorMinY = 0.5; @@ -329,17 +389,47 @@ export class UITransformComponent extends Component implements ISortable { this.anchorMaxX = 1; this.anchorMaxY = 0.5; break; case AnchorPreset.BottomLeft: - this.anchorMinX = 0; this.anchorMinY = 1; - this.anchorMaxX = 0; this.anchorMaxY = 1; + this.anchorMinX = 0; this.anchorMinY = 0; + this.anchorMaxX = 0; this.anchorMaxY = 0; break; case AnchorPreset.BottomCenter: - this.anchorMinX = 0.5; this.anchorMinY = 1; - this.anchorMaxX = 0.5; this.anchorMaxY = 1; + this.anchorMinX = 0.5; this.anchorMinY = 0; + this.anchorMaxX = 0.5; this.anchorMaxY = 0; break; case AnchorPreset.BottomRight: - this.anchorMinX = 1; this.anchorMinY = 1; + this.anchorMinX = 1; this.anchorMinY = 0; + this.anchorMaxX = 1; this.anchorMaxY = 0; + break; + + // Horizontal stretch | 水平拉伸 + case AnchorPreset.StretchTop: + this.anchorMinX = 0; this.anchorMinY = 1; this.anchorMaxX = 1; this.anchorMaxY = 1; break; + case AnchorPreset.StretchMiddle: + this.anchorMinX = 0; this.anchorMinY = 0.5; + this.anchorMaxX = 1; this.anchorMaxY = 0.5; + break; + case AnchorPreset.StretchBottom: + this.anchorMinX = 0; this.anchorMinY = 0; + this.anchorMaxX = 1; this.anchorMaxY = 0; + break; + + // Vertical stretch | 垂直拉伸 + case AnchorPreset.StretchLeft: + this.anchorMinX = 0; this.anchorMinY = 0; + this.anchorMaxX = 0; this.anchorMaxY = 1; + break; + case AnchorPreset.StretchCenter: + this.anchorMinX = 0.5; this.anchorMinY = 0; + this.anchorMaxX = 0.5; this.anchorMaxY = 1; + break; + case AnchorPreset.StretchRight: + this.anchorMinX = 1; this.anchorMinY = 0; + this.anchorMaxX = 1; this.anchorMaxY = 1; + break; + + // Full stretch | 全拉伸 case AnchorPreset.StretchAll: this.anchorMinX = 0; this.anchorMinY = 0; this.anchorMaxX = 1; this.anchorMaxY = 1; @@ -387,9 +477,13 @@ export class UITransformComponent extends Component implements ISortable { * Test if a point is inside this element */ public containsPoint(worldX: number, worldY: number): boolean { - return worldX >= this.worldX && - worldX <= this.worldX + this.computedWidth && - worldY >= this.worldY && - worldY <= this.worldY + this.computedHeight; + const x = this.worldX ?? this.x; + const y = this.worldY ?? this.y; + const width = this.computedWidth ?? this.width; + const height = this.computedHeight ?? this.height; + return worldX >= x && + worldX <= x + width && + worldY >= y && + worldY <= y + height; } } diff --git a/packages/ui/src/components/UIWidgetMarker.ts b/packages/ui/src/components/UIWidgetMarker.ts new file mode 100644 index 00000000..b599f190 --- /dev/null +++ b/packages/ui/src/components/UIWidgetMarker.ts @@ -0,0 +1,43 @@ +/** + * UI Widget Marker Component + * UI 控件标记组件 + * + * A marker component that indicates an entity has a specialized widget component + * (Button, ProgressBar, Slider, ScrollView, etc.) with its own dedicated render system. + * + * This allows UIRectRenderSystem to skip entities without hardcoding widget component checks. + * + * 标记组件,表示实体有专门的 widget 组件(按钮、进度条、滑块、滚动视图等), + * 并有自己的专用渲染系统。 + * + * 这使得 UIRectRenderSystem 可以跳过这些实体,而无需硬编码 widget 组件检查。 + * + * @example + * ```typescript + * // Widget components should add this marker when added to entity + * // Widget 组件在添加到实体时应该添加此标记 + * class UIButtonComponent extends Component { + * onEnable(): void { + * if (!this.entity.hasComponent(UIWidgetMarker)) { + * this.entity.addComponent(UIWidgetMarker); + * } + * } + * } + * ``` + */ + +import { Component, ECSComponent, Serializable } from '@esengine/ecs-framework'; + +/** + * UI Widget Marker - Empty marker component + * UI 控件标记 - 空标记组件 + * + * This component has no data, it's purely used for entity tagging. + * 此组件没有数据,纯粹用于实体标记。 + */ +@ECSComponent('UIWidgetMarker') +@Serializable({ version: 1, typeId: 'UIWidgetMarker' }) +export class UIWidgetMarker extends Component { + // Marker component - no data needed + // 标记组件 - 不需要数据 +} diff --git a/packages/ui/src/components/base/UIGraphicComponent.ts b/packages/ui/src/components/base/UIGraphicComponent.ts new file mode 100644 index 00000000..97a5c8f9 --- /dev/null +++ b/packages/ui/src/components/base/UIGraphicComponent.ts @@ -0,0 +1,173 @@ +/** + * UI Graphic Component - Base for all visual UI elements + * UI 图形组件 - 所有可视 UI 元素的基类 + * + * This is the foundation for any UI element that can be rendered. + * 这是所有可渲染 UI 元素的基础。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { UIDirtyFlags, type IDirtyTrackable, markFrameDirty } from '../../utils/UIDirtyFlags'; + +/** + * UI Graphic Component + * UI 图形组件 + * + * Base component for all visual UI elements. Provides: + * - Color tinting + * - Raycast target flag (for input detection) + * - Material reference + * - Dirty tracking for render optimization + * + * 所有可视 UI 元素的基础组件。提供: + * - 颜色着色 + * - 射线检测目标标志(用于输入检测) + * - 材质引用 + * - 渲染优化的脏追踪 + * + * @example + * ```typescript + * const graphic = entity.addComponent(UIGraphicComponent); + * graphic.color = 0xFF0000; // Red tint (marks dirty automatically) + * graphic.raycastTarget = true; + * ``` + */ +@ECSComponent('UIGraphic') +@Serializable({ version: 1, typeId: 'UIGraphic' }) +export class UIGraphicComponent extends Component implements IDirtyTrackable { + // ===== Private backing fields ===== + private _color: number = 0xFFFFFF; + private _alpha: number = 1; + private _raycastTarget: boolean = true; + private _materialId: number = 0; + + /** + * Dirty flags for change tracking + * 变更追踪的脏标记 + */ + _dirtyFlags: UIDirtyFlags = UIDirtyFlags.Visual; + + /** + * Tint color (0xRRGGBB format) + * 着色颜色(0xRRGGBB 格式) + * + * This color is multiplied with the texture/content color. + * White (0xFFFFFF) means no tinting. + * 此颜色与纹理/内容颜色相乘。 + * 白色 (0xFFFFFF) 表示不着色。 + */ + @Serialize() + @Property({ type: 'color', label: 'Color / 颜色' }) + get color(): number { + return this._color; + } + set color(value: number) { + if (this._color !== value) { + this._color = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Alpha transparency (0-1) + * 透明度 (0-1) + */ + @Serialize() + @Property({ type: 'number', label: 'Alpha / 透明度', min: 0, max: 1, step: 0.1 }) + get alpha(): number { + return this._alpha; + } + set alpha(value: number) { + if (this._alpha !== value) { + this._alpha = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Whether this graphic should be considered for raycasting (input detection) + * 此图形是否应参与射线检测(输入检测) + * + * Set to false for decorative elements that shouldn't block input. + * 对于不应阻挡输入的装饰性元素,设置为 false。 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Raycast Target / 射线目标' }) + get raycastTarget(): boolean { + return this._raycastTarget; + } + set raycastTarget(value: boolean) { + this._raycastTarget = value; + } + + /** + * Material ID for custom rendering + * 自定义渲染的材质 ID + * + * 0 = default UI material + * 0 = 默认 UI 材质 + */ + @Serialize() + @Property({ type: 'number', label: 'Material ID / 材质 ID' }) + get materialId(): number { + return this._materialId; + } + set materialId(value: number) { + if (this._materialId !== value) { + this._materialId = value; + this.markDirty(UIDirtyFlags.Material); + } + } + + // ===== IDirtyTrackable implementation ===== + + /** + * Check if component has any dirty flags + * 检查组件是否有任何脏标记 + */ + isDirty(): boolean { + return this._dirtyFlags !== UIDirtyFlags.None; + } + + /** + * Check if specific dirty flags are set + * 检查是否设置了特定的脏标记 + */ + hasDirtyFlag(flags: UIDirtyFlags): boolean { + return (this._dirtyFlags & flags) !== 0; + } + + /** + * Mark component as dirty with specific flags + * 使用特定标记将组件标记为脏 + */ + markDirty(flags: UIDirtyFlags): void { + this._dirtyFlags |= flags; + markFrameDirty(); + } + + /** + * Clear all dirty flags + * 清除所有脏标记 + */ + clearDirtyFlags(): void { + this._dirtyFlags = UIDirtyFlags.None; + } + + /** + * Clear specific dirty flags + * 清除特定的脏标记 + */ + clearDirtyFlag(flags: UIDirtyFlags): void { + this._dirtyFlags &= ~flags; + } + + /** + * Get the packed color with alpha (0xAARRGGBB) + * 获取带透明度的打包颜色 (0xAARRGGBB) + */ + getPackedColor(): number { + const a = Math.round(this._alpha * 255) & 0xFF; + return (a << 24) | (this._color & 0xFFFFFF); + } +} diff --git a/packages/ui/src/components/base/UIImageComponent.ts b/packages/ui/src/components/base/UIImageComponent.ts new file mode 100644 index 00000000..41971998 --- /dev/null +++ b/packages/ui/src/components/base/UIImageComponent.ts @@ -0,0 +1,291 @@ +/** + * UI Image Component - Displays textures/sprites + * UI 图像组件 - 显示纹理/精灵 + * + * Extends UIGraphicComponent to add texture display capabilities. + * Supports multiple image types: simple, sliced (9-patch), tiled, filled. + * + * 扩展 UIGraphicComponent 添加纹理显示功能。 + * 支持多种图像类型:简单、切片(九宫格)、平铺、填充。 + */ + +import { ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { UIGraphicComponent } from './UIGraphicComponent'; +import { UIDirtyFlags } from '../../utils/UIDirtyFlags'; + +/** + * Image display type + * 图像显示类型 + */ +export type UIImageType = 'simple' | 'sliced' | 'tiled' | 'filled'; + +/** + * Fill method for filled images + * 填充图像的填充方法 + */ +export type UIFillMethod = 'horizontal' | 'vertical' | 'radial90' | 'radial180' | 'radial360'; + +/** + * Fill origin for horizontal/vertical fill + * 水平/垂直填充的填充起点 + */ +export type UIFillOrigin = 'left' | 'right' | 'top' | 'bottom' | 'center'; + +/** + * UI Image Component + * UI 图像组件 + * + * @example + * ```typescript + * // Simple image + * const image = entity.addComponent(UIImageComponent); + * image.textureGuid = 'asset-guid-here'; // marks dirty automatically + * + * // 9-slice image for buttons/panels + * image.imageType = 'sliced'; + * image.sliceBorder = [10, 10, 10, 10]; // top, right, bottom, left + * + * // Progress bar fill + * image.imageType = 'filled'; + * image.fillMethod = 'horizontal'; + * image.fillAmount = 0.75; // 75% filled + * ``` + */ +@ECSComponent('UIImage') +@Serializable({ version: 1, typeId: 'UIImage' }) +export class UIImageComponent extends UIGraphicComponent { + // ===== Private backing fields ===== + private _textureGuid?: string; + private _textureId?: number; + private _imageType: UIImageType = 'simple'; + private _sliceBorder: [number, number, number, number] = [0, 0, 0, 0]; + private _preserveAspect: boolean = false; + private _fillMethod: UIFillMethod = 'horizontal'; + private _fillOrigin: UIFillOrigin = 'left'; + private _fillAmount: number = 1; + private _fillClockwise: boolean = true; + private _textureWidth: number = 0; + private _textureHeight: number = 0; + + /** + * Texture GUID from asset system + * 来自资产系统的纹理 GUID + */ + @Serialize() + @Property({ type: 'asset', assetType: 'texture', label: 'Texture / 纹理' }) + get textureGuid(): string | undefined { + return this._textureGuid; + } + set textureGuid(value: string | undefined) { + if (this._textureGuid !== value) { + this._textureGuid = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Direct texture ID (for generated textures) + * 直接纹理 ID(用于生成的纹理) + */ + get textureId(): number | undefined { + return this._textureId; + } + set textureId(value: number | undefined) { + if (this._textureId !== value) { + this._textureId = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Image display type + * 图像显示类型 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Image Type / 图像类型', + options: ['simple', 'sliced', 'tiled', 'filled'] + }) + get imageType(): UIImageType { + return this._imageType; + } + set imageType(value: UIImageType) { + if (this._imageType !== value) { + this._imageType = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Border for sliced (9-patch) images [top, right, bottom, left] + * 切片(九宫格)图像的边框 [上, 右, 下, 左] + */ + @Serialize() + @Property({ type: 'vector4', label: 'Slice Border / 九宫格边距' }) + get sliceBorder(): [number, number, number, number] { + return this._sliceBorder; + } + set sliceBorder(value: [number, number, number, number]) { + if (this._sliceBorder[0] !== value[0] || + this._sliceBorder[1] !== value[1] || + this._sliceBorder[2] !== value[2] || + this._sliceBorder[3] !== value[3]) { + this._sliceBorder = [...value] as [number, number, number, number]; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Whether to preserve aspect ratio + * 是否保持纵横比 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Preserve Aspect / 保持比例' }) + get preserveAspect(): boolean { + return this._preserveAspect; + } + set preserveAspect(value: boolean) { + if (this._preserveAspect !== value) { + this._preserveAspect = value; + this.markDirty(UIDirtyFlags.Layout); + } + } + + // ===== Fill mode properties (imageType = 'filled') ===== + + /** + * Fill method for filled images + * 填充图像的填充方法 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Fill Method / 填充方法', + options: ['horizontal', 'vertical', 'radial90', 'radial180', 'radial360'] + }) + get fillMethod(): UIFillMethod { + return this._fillMethod; + } + set fillMethod(value: UIFillMethod) { + if (this._fillMethod !== value) { + this._fillMethod = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Fill origin + * 填充起点 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Fill Origin / 填充起点', + options: ['left', 'right', 'top', 'bottom', 'center'] + }) + get fillOrigin(): UIFillOrigin { + return this._fillOrigin; + } + set fillOrigin(value: UIFillOrigin) { + if (this._fillOrigin !== value) { + this._fillOrigin = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Fill amount (0-1) + * 填充量 (0-1) + */ + @Serialize() + @Property({ type: 'number', label: 'Fill Amount / 填充量', min: 0, max: 1, step: 0.01 }) + get fillAmount(): number { + return this._fillAmount; + } + set fillAmount(value: number) { + if (this._fillAmount !== value) { + this._fillAmount = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Whether fill is clockwise (for radial fill) + * 填充是否顺时针(用于径向填充) + */ + @Serialize() + @Property({ type: 'boolean', label: 'Clockwise / 顺时针' }) + get fillClockwise(): boolean { + return this._fillClockwise; + } + set fillClockwise(value: boolean) { + if (this._fillClockwise !== value) { + this._fillClockwise = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + // ===== UV mapping ===== + + /** + * Custom UV coordinates [u0, v0, u1, v1] + * 自定义 UV 坐标 [u0, v0, u1, v1] + */ + uv?: [number, number, number, number]; + + /** + * Source texture width (for 9-patch calculations) + * 源纹理宽度(用于九宫格计算) + */ + get textureWidth(): number { + return this._textureWidth; + } + set textureWidth(value: number) { + if (this._textureWidth !== value) { + this._textureWidth = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Source texture height (for 9-patch calculations) + * 源纹理高度(用于九宫格计算) + */ + get textureHeight(): number { + return this._textureHeight; + } + set textureHeight(value: number) { + if (this._textureHeight !== value) { + this._textureHeight = value; + this.markDirty(UIDirtyFlags.Visual); + } + } + + /** + * Check if this image uses sliced (9-patch) rendering + * 检查此图像是否使用切片(九宫格)渲染 + */ + isSliced(): boolean { + return this._imageType === 'sliced' && + this._textureWidth > 0 && + this._textureHeight > 0 && + this._sliceBorder.some(v => v > 0); + } + + /** + * Check if this image uses filled rendering + * 检查此图像是否使用填充渲染 + */ + isFilled(): boolean { + return this._imageType === 'filled'; + } + + /** + * Check if this image has a valid texture + * 检查此图像是否有有效的纹理 + */ + hasTexture(): boolean { + return !!(this._textureGuid || this._textureId); + } +} diff --git a/packages/ui/src/components/base/UISelectableComponent.ts b/packages/ui/src/components/base/UISelectableComponent.ts new file mode 100644 index 00000000..a87613de --- /dev/null +++ b/packages/ui/src/components/base/UISelectableComponent.ts @@ -0,0 +1,384 @@ +/** + * UI Selectable Component - Base for all interactive UI elements + * UI 可选择组件 - 所有可交互 UI 元素的基类 + * + * Provides common interaction handling for buttons, sliders, toggles, etc. + * 为按钮、滑块、开关等提供通用交互处理。 + */ + +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { lerpColor } from '../../systems/render/UIRenderUtils'; + +/** + * Interaction state + * 交互状态 + */ +export type UISelectableState = 'normal' | 'highlighted' | 'pressed' | 'selected' | 'disabled'; + +/** + * Transition type for state changes + * 状态变化的过渡类型 + */ +export type UITransitionType = 'none' | 'colorTint' | 'spriteSwap' | 'animation'; + +/** + * Color block for state colors + * 状态颜色块 + */ +export interface UIColorBlock { + normalColor: number; + highlightedColor: number; + pressedColor: number; + selectedColor: number; + disabledColor: number; + colorMultiplier: number; + fadeDuration: number; +} + +/** + * Sprite state for sprite swap transition + * 精灵切换过渡的精灵状态 + */ +export interface UISpriteState { + highlightedSprite?: string; + pressedSprite?: string; + selectedSprite?: string; + disabledSprite?: string; +} + +/** + * Default color block + * 默认颜色块 + */ +export const DEFAULT_COLOR_BLOCK: UIColorBlock = { + normalColor: 0xFFFFFF, + highlightedColor: 0xF5F5F5, + pressedColor: 0xC8C8C8, + selectedColor: 0xF5F5F5, + disabledColor: 0x787878, + colorMultiplier: 1, + fadeDuration: 0.1 +}; + +/** + * UI Selectable Component + * UI 可选择组件 + * + * Base component for interactive UI elements. Handles: + * - Interaction state management (normal, highlighted, pressed, disabled) + * - Visual transitions (color tint, sprite swap, animation) + * - Navigation between selectables + * + * 可交互 UI 元素的基础组件。处理: + * - 交互状态管理(正常、高亮、按下、禁用) + * - 视觉过渡(颜色着色、精灵切换、动画) + * - 可选择元素之间的导航 + * + * @example + * ```typescript + * const selectable = entity.addComponent(UISelectableComponent); + * selectable.interactable = true; + * selectable.transition = 'colorTint'; + * selectable.colors.highlightedColor = 0xFFFF00; + * ``` + */ +@ECSComponent('UISelectable') +@Serializable({ version: 1, typeId: 'UISelectable' }) +export class UISelectableComponent extends Component { + /** + * Whether the selectable is interactable + * 可选择元素是否可交互 + */ + @Serialize() + @Property({ type: 'boolean', label: 'Interactable / 可交互' }) + interactable: boolean = true; + + /** + * Transition type for visual feedback + * 视觉反馈的过渡类型 + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Transition / 过渡', + options: ['none', 'colorTint', 'spriteSwap', 'animation'] + }) + transition: UITransitionType = 'colorTint'; + + /** + * Normal state color + * 正常状态颜色 + */ + @Serialize() + @Property({ type: 'color', label: 'Normal Color / 正常颜色' }) + normalColor: number = 0xFFFFFF; + + /** + * Highlighted state color + * 高亮状态颜色 + */ + @Serialize() + @Property({ type: 'color', label: 'Highlighted Color / 高亮颜色' }) + highlightedColor: number = 0xF5F5F5; + + /** + * Pressed state color + * 按下状态颜色 + */ + @Serialize() + @Property({ type: 'color', label: 'Pressed Color / 按下颜色' }) + pressedColor: number = 0xC8C8C8; + + /** + * Selected state color + * 选中状态颜色 + */ + @Serialize() + @Property({ type: 'color', label: 'Selected Color / 选中颜色' }) + selectedColor: number = 0xF5F5F5; + + /** + * Disabled state color + * 禁用状态颜色 + */ + @Serialize() + @Property({ type: 'color', label: 'Disabled Color / 禁用颜色' }) + disabledColor: number = 0x787878; + + /** + * Color multiplier + * 颜色乘数 + */ + @Serialize() + @Property({ type: 'number', label: 'Color Multiplier / 颜色乘数', min: 0, max: 2, step: 0.1 }) + colorMultiplier: number = 1; + + /** + * Fade duration in seconds + * 淡入淡出持续时间(秒) + */ + @Serialize() + @Property({ type: 'number', label: 'Fade Duration / 过渡时长', min: 0, max: 2, step: 0.05 }) + fadeDuration: number = 0.1; + + /** + * Sprite swap settings + * 精灵切换设置 + */ + sprites: UISpriteState = {}; + + /** + * Animation trigger name + * 动画触发器名称 + */ + @Serialize() + @Property({ type: 'string', label: 'Animation Trigger / 动画触发器' }) + animationTrigger: string = ''; + + // ===== Runtime state (not serialized) ===== + + /** + * Current interaction state + * 当前交互状态 + */ + private _currentState: UISelectableState = 'normal'; + + /** + * Current interpolated color (for smooth transitions) + * 当前插值颜色(用于平滑过渡) + */ + private _currentColor: number = 0xFFFFFF; + + /** + * Target color for transition + * 过渡的目标颜色 + */ + private _targetColor: number = 0xFFFFFF; + + /** + * Transition progress (0-1) + * 过渡进度 (0-1) + */ + private _transitionProgress: number = 1; + + /** + * Whether the pointer is over this element + * 指针是否在此元素上 + */ + private _isPointerOver: boolean = false; + + /** + * Whether this element is being pressed + * 此元素是否正在被按下 + */ + private _isPressed: boolean = false; + + /** + * Whether this element is selected (for navigation) + * 此元素是否被选中(用于导航) + */ + private _isSelected: boolean = false; + + /** + * Get current interaction state + * 获取当前交互状态 + */ + get currentState(): UISelectableState { + return this._currentState; + } + + /** + * Get current display color (interpolated) + * 获取当前显示颜色(插值后) + */ + get currentColor(): number { + return this._currentColor; + } + + /** + * Get the color for a specific state + * 获取特定状态的颜色 + */ + getStateColor(state: UISelectableState): number { + switch (state) { + case 'normal': return this.normalColor; + case 'highlighted': return this.highlightedColor; + case 'pressed': return this.pressedColor; + case 'selected': return this.selectedColor; + case 'disabled': return this.disabledColor; + default: return this.normalColor; + } + } + + /** + * Get the sprite for a specific state + * 获取特定状态的精灵 + */ + getStateSprite(state: UISelectableState): string | undefined { + switch (state) { + case 'highlighted': return this.sprites.highlightedSprite; + case 'pressed': return this.sprites.pressedSprite; + case 'selected': return this.sprites.selectedSprite; + case 'disabled': return this.sprites.disabledSprite; + default: return undefined; + } + } + + /** + * Update interaction state based on input + * 根据输入更新交互状态 + */ + updateState(): void { + let newState: UISelectableState; + + if (!this.interactable) { + newState = 'disabled'; + } else if (this._isPressed) { + newState = 'pressed'; + } else if (this._isSelected) { + newState = 'selected'; + } else if (this._isPointerOver) { + newState = 'highlighted'; + } else { + newState = 'normal'; + } + + if (newState !== this._currentState) { + this._currentState = newState; + this._targetColor = this.getStateColor(newState); + this._transitionProgress = 0; + } + } + + /** + * Update color transition + * 更新颜色过渡 + * + * @param deltaTime - Time since last update in seconds + */ + updateTransition(deltaTime: number): void { + if (this.transition !== 'colorTint' || this._transitionProgress >= 1) { + this._currentColor = this._targetColor; + return; + } + + const duration = this.fadeDuration; + if (duration <= 0) { + this._currentColor = this._targetColor; + this._transitionProgress = 1; + return; + } + + this._transitionProgress = Math.min(1, this._transitionProgress + deltaTime / duration); + this._currentColor = lerpColor( + this._currentColor, + this._targetColor, + this._transitionProgress + ); + } + + /** + * Set pointer over state + * 设置指针悬停状态 + */ + setPointerOver(isOver: boolean): void { + this._isPointerOver = isOver; + this.updateState(); + } + + /** + * Set pressed state + * 设置按下状态 + */ + setPressed(isPressed: boolean): void { + this._isPressed = isPressed; + this.updateState(); + } + + /** + * Set selected state + * 设置选中状态 + */ + setSelected(isSelected: boolean): void { + this._isSelected = isSelected; + this.updateState(); + } + + /** + * Check if pointer is over this element + * 检查指针是否在此元素上 + */ + get isPointerOver(): boolean { + return this._isPointerOver; + } + + /** + * Check if this element is pressed + * 检查此元素是否被按下 + */ + get isPressed(): boolean { + return this._isPressed; + } + + /** + * Check if this element is selected + * 检查此元素是否被选中 + */ + get isSelected(): boolean { + return this._isSelected; + } + + /** + * Reset to initial state + * 重置到初始状态 + */ + reset(): void { + this._currentState = 'normal'; + this._currentColor = this.normalColor; + this._targetColor = this.normalColor; + this._transitionProgress = 1; + this._isPointerOver = false; + this._isPressed = false; + this._isSelected = false; + } +} diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts new file mode 100644 index 00000000..e9c16202 --- /dev/null +++ b/packages/ui/src/components/base/index.ts @@ -0,0 +1,30 @@ +/** + * UI Base Components + * UI 基础组件 + * + * These are the foundation components for the UI system: + * - UIGraphicComponent: Base for all visual elements + * - UIImageComponent: Texture/sprite display + * - UISelectableComponent: Base for interactive elements + * + * 这些是 UI 系统的基础组件: + * - UIGraphicComponent: 所有可视元素的基础 + * - UIImageComponent: 纹理/精灵显示 + * - UISelectableComponent: 可交互元素的基础 + */ + +export { UIGraphicComponent } from './UIGraphicComponent'; +export { + UIImageComponent, + type UIImageType, + type UIFillMethod, + type UIFillOrigin +} from './UIImageComponent'; +export { + UISelectableComponent, + type UISelectableState, + type UITransitionType, + type UIColorBlock, + type UISpriteState, + DEFAULT_COLOR_BLOCK +} from './UISelectableComponent'; diff --git a/packages/ui/src/components/widgets/UIButtonComponent.ts b/packages/ui/src/components/widgets/UIButtonComponent.ts index 3784bbd1..7807855b 100644 --- a/packages/ui/src/components/widgets/UIButtonComponent.ts +++ b/packages/ui/src/components/widgets/UIButtonComponent.ts @@ -163,6 +163,12 @@ export class UIButtonComponent extends Component { */ public targetColor: number = 0x4A90D9; + /** + * 颜色是否已初始化(用于编辑器预览) + * Whether color has been initialized (for editor preview) + */ + public _colorInitialized: boolean = false; + // ===== 回调 Callbacks ===== /** @@ -309,4 +315,16 @@ export class UIButtonComponent extends Component { this.displayMode = 'texture'; return this; } + + /** + * 组件添加到实体时初始化颜色 + * Initialize colors when component is added to entity + */ + public override onAddedToEntity(): void { + super.onAddedToEntity(); + // 初始化 currentColor 和 targetColor 为 normalColor + // Initialize currentColor and targetColor to normalColor + this.currentColor = this.normalColor; + this.targetColor = this.normalColor; + } } diff --git a/packages/ui/src/components/widgets/UIDropdownComponent.ts b/packages/ui/src/components/widgets/UIDropdownComponent.ts new file mode 100644 index 00000000..f63f9a1b --- /dev/null +++ b/packages/ui/src/components/widgets/UIDropdownComponent.ts @@ -0,0 +1,428 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; + +/** + * 下拉选项 + * Dropdown option item + */ +export interface UIDropdownOption { + /** 显示文本 | Display text */ + label: string; + /** 选项值 | Option value */ + value: string | number; + /** 是否禁用 | Whether disabled */ + disabled?: boolean; + /** 图标 GUID(可选)| Icon GUID (optional) */ + iconGuid?: string; +} + +/** + * UI 下拉菜单组件 + * UI Dropdown Component - Selection from a list of options + * + * @example + * ```typescript + * const dropdown = entity.addComponent(new UIDropdownComponent()); + * dropdown.options = [ + * { label: 'Option 1', value: 1 }, + * { label: 'Option 2', value: 2 }, + * { label: 'Option 3', value: 3 } + * ]; + * dropdown.selectedIndex = 0; + * dropdown.onValueChanged = (value, index) => { + * console.log('Selected:', value, 'at index:', index); + * }; + * ``` + */ +@ECSComponent('UIDropdown') +@Serializable({ version: 1, typeId: 'UIDropdown' }) +export class UIDropdownComponent extends Component { + // ===== 选项配置 Options Configuration ===== + + /** + * 下拉选项列表 + * List of dropdown options + */ + public options: UIDropdownOption[] = []; + + /** + * 当前选中的索引 + * Currently selected index + */ + @Serialize() + @Property({ type: 'integer', label: 'Selected Index / 选中索引', min: -1 }) + public selectedIndex: number = -1; + + /** + * 占位符文本(未选中时显示) + * Placeholder text shown when nothing is selected + */ + @Serialize() + @Property({ type: 'string', label: 'Placeholder / 占位符' }) + public placeholder: string = 'Select...'; + + // ===== 外观配置 Appearance Configuration ===== + + /** + * 按钮背景颜色 + * Button background color + */ + @Serialize() + @Property({ type: 'color', label: 'Button Color / 按钮颜色' }) + public buttonColor: number = 0xFFFFFF; + + /** + * 按钮悬停颜色 + * Button hover color + */ + @Serialize() + @Property({ type: 'color', label: 'Hover Color / 悬停颜色' }) + public hoverColor: number = 0xF0F0F0; + + /** + * 按钮按下颜色 + * Button pressed color + */ + @Serialize() + @Property({ type: 'color', label: 'Pressed Color / 按下颜色' }) + public pressedColor: number = 0xE0E0E0; + + /** + * 禁用时的颜色 + * Disabled color + */ + @Serialize() + @Property({ type: 'color', label: 'Disabled Color / 禁用颜色' }) + public disabledColor: number = 0xCCCCCC; + + /** + * 文本颜色 + * Text color + */ + @Serialize() + @Property({ type: 'color', label: 'Text Color / 文本颜色' }) + public textColor: number = 0x333333; + + /** + * 占位符文本颜色 + * Placeholder text color + */ + @Serialize() + @Property({ type: 'color', label: 'Placeholder Color / 占位符颜色' }) + public placeholderColor: number = 0x999999; + + /** + * 边框颜色 + * Border color + */ + @Serialize() + @Property({ type: 'color', label: 'Border Color / 边框颜色' }) + public borderColor: number = 0xCCCCCC; + + /** + * 边框宽度 + * Border width in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Border Width / 边框宽度', min: 0, max: 10, step: 1 }) + public borderWidth: number = 1; + + /** + * 下拉箭头颜色 + * Arrow color + */ + @Serialize() + @Property({ type: 'color', label: 'Arrow Color / 箭头颜色' }) + public arrowColor: number = 0x666666; + + /** + * 下拉列表背景颜色 + * Dropdown list background color + */ + @Serialize() + @Property({ type: 'color', label: 'List Background / 列表背景' }) + public listBackgroundColor: number = 0xFFFFFF; + + /** + * 选项悬停颜色 + * Option hover color + */ + @Serialize() + @Property({ type: 'color', label: 'Option Hover / 选项悬停' }) + public optionHoverColor: number = 0xE8F0FE; + + /** + * 选中选项颜色 + * Selected option color + */ + @Serialize() + @Property({ type: 'color', label: 'Selected Option / 选中选项' }) + public selectedOptionColor: number = 0xD0E0FF; + + /** + * 选项高度 + * Option item height + */ + @Serialize() + @Property({ type: 'number', label: 'Option Height / 选项高度', min: 20, max: 100 }) + public optionHeight: number = 32; + + /** + * 最大显示选项数(超出时显示滚动条) + * Max visible options (scrollbar shown if exceeded) + */ + @Serialize() + @Property({ type: 'integer', label: 'Max Visible Options / 最大可见选项', min: 1, max: 20 }) + public maxVisibleOptions: number = 5; + + /** + * 内边距 + * Padding in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Padding / 内边距', min: 0 }) + public padding: number = 8; + + // ===== 状态 State ===== + + /** + * 是否禁用 + * Whether the dropdown is disabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Disabled / 禁用' }) + public disabled: boolean = false; + + // ===== 运行时状态 Runtime State (not serialized) ===== + + /** + * 下拉列表是否展开 + * Whether dropdown list is expanded + */ + public isOpen: boolean = false; + + /** + * 是否悬停在按钮上 + * Whether mouse is hovering over button + */ + public hovered: boolean = false; + + /** + * 是否按下 + * Whether button is pressed + */ + public pressed: boolean = false; + + /** + * 当前悬停的选项索引 + * Currently hovered option index + */ + public hoveredOptionIndex: number = -1; + + /** + * 列表滚动偏移 + * List scroll offset + */ + public scrollOffset: number = 0; + + /** + * 当前显示颜色(用于动画) + * Current display color (for animation) + */ + public currentColor: number = 0xFFFFFF; + + /** + * 目标颜色 + * Target color + */ + public targetColor: number = 0xFFFFFF; + + /** + * 颜色过渡时长 + * Color transition duration + */ + @Serialize() + @Property({ type: 'number', label: 'Transition Duration / 过渡时长', min: 0, step: 0.01 }) + public transitionDuration: number = 0.1; + + // ===== 回调 Callbacks ===== + + /** + * 值变化回调 + * Value changed callback + */ + public onValueChanged?: (value: string | number, index: number) => void; + + /** + * 下拉列表打开回调 + * Dropdown opened callback + */ + public onOpen?: () => void; + + /** + * 下拉列表关闭回调 + * Dropdown closed callback + */ + public onClose?: () => void; + + // ===== 方法 Methods ===== + + /** + * 获取当前选中的选项 + * Get currently selected option + */ + public getSelectedOption(): UIDropdownOption | undefined { + if (this.selectedIndex >= 0 && this.selectedIndex < this.options.length) { + return this.options[this.selectedIndex]; + } + return undefined; + } + + /** + * 获取当前选中的值 + * Get currently selected value + */ + public getSelectedValue(): string | number | undefined { + return this.getSelectedOption()?.value; + } + + /** + * 获取当前显示文本 + * Get current display text + */ + public getDisplayText(): string { + const option = this.getSelectedOption(); + return option?.label ?? this.placeholder; + } + + /** + * 设置选中索引 + * Set selected index + */ + public setSelectedIndex(index: number): void { + if (index === this.selectedIndex) return; + if (index < -1 || index >= this.options.length) return; + + const option = this.options[index]; + if (option?.disabled) return; + + this.selectedIndex = index; + this.onValueChanged?.(option?.value ?? '', index); + } + + /** + * 根据值设置选中项 + * Set selected by value + */ + public setSelectedValue(value: string | number): void { + const index = this.options.findIndex(opt => opt.value === value); + if (index >= 0) { + this.setSelectedIndex(index); + } + } + + /** + * 添加选项 + * Add option + */ + public addOption(label: string, value: string | number, disabled: boolean = false): void { + this.options.push({ label, value, disabled }); + } + + /** + * 移除选项 + * Remove option by index + */ + public removeOption(index: number): void { + if (index < 0 || index >= this.options.length) return; + this.options.splice(index, 1); + + // 调整选中索引 + // Adjust selected index + if (this.selectedIndex === index) { + this.selectedIndex = -1; + } else if (this.selectedIndex > index) { + this.selectedIndex--; + } + } + + /** + * 清除所有选项 + * Clear all options + */ + public clearOptions(): void { + this.options = []; + this.selectedIndex = -1; + } + + /** + * 打开下拉列表 + * Open dropdown list + */ + public open(): void { + if (this.disabled || this.isOpen) return; + this.isOpen = true; + this.hoveredOptionIndex = this.selectedIndex; + this.onOpen?.(); + } + + /** + * 关闭下拉列表 + * Close dropdown list + */ + public close(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.hoveredOptionIndex = -1; + this.onClose?.(); + } + + /** + * 切换下拉列表 + * Toggle dropdown list + */ + public toggle(): void { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + /** + * 获取当前背景颜色 + * Get current background color based on state + */ + public getCurrentBackgroundColor(): number { + if (this.disabled) return this.disabledColor; + if (this.pressed || this.isOpen) return this.pressedColor; + if (this.hovered) return this.hoverColor; + return this.buttonColor; + } + + /** + * 获取下拉列表高度 + * Get dropdown list height + */ + public getListHeight(): number { + const visibleCount = Math.min(this.options.length, this.maxVisibleOptions); + return visibleCount * this.optionHeight; + } + + /** + * 是否需要滚动条 + * Whether scrollbar is needed + */ + public needsScrollbar(): boolean { + return this.options.length > this.maxVisibleOptions; + } + + /** + * 获取最大滚动偏移 + * Get maximum scroll offset + */ + public getMaxScrollOffset(): number { + const totalHeight = this.options.length * this.optionHeight; + const visibleHeight = this.getListHeight(); + return Math.max(0, totalHeight - visibleHeight); + } +} diff --git a/packages/ui/src/components/widgets/UIInputFieldComponent.ts b/packages/ui/src/components/widgets/UIInputFieldComponent.ts new file mode 100644 index 00000000..eca78d1f --- /dev/null +++ b/packages/ui/src/components/widgets/UIInputFieldComponent.ts @@ -0,0 +1,681 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { getTextMeasureService, type TextMeasureFont } from '../../utils/TextMeasureService'; + +/** + * 输入框类型 + * Input field content type + */ +export type UIInputContentType = + | 'standard' // 标准文本 | Standard text + | 'integer' // 整数 | Integer numbers only + | 'decimal' // 小数 | Decimal numbers + | 'alphanumeric' // 字母数字 | Letters and numbers only + | 'name' // 姓名 | Name (capitalized) + | 'email' // 邮箱 | Email address + | 'password'; // 密码 | Password (hidden) + +/** + * 输入框行类型 + * Input field line type + */ +export type UIInputLineType = + | 'singleLine' // 单行,回车提交 | Single line, Enter submits + | 'multiLine' // 多行,回车换行 | Multi-line, Enter adds newline + | 'multiLineSubmit'; // 多行,Shift+回车换行,回车提交 | Multi-line, Shift+Enter adds newline, Enter submits + +/** + * UI 输入框组件 + * UI Input Field Component - Text input for user entry + * + * @example + * ```typescript + * // Single-line text input + * const input = entity.addComponent(new UIInputFieldComponent()); + * input.placeholder = 'Enter your name...'; + * input.onValueChanged = (value) => console.log('Value:', value); + * + * // Password input + * const password = entity.addComponent(new UIInputFieldComponent()); + * password.contentType = 'password'; + * password.placeholder = 'Enter password...'; + * + * // Multi-line input + * const textarea = entity.addComponent(new UIInputFieldComponent()); + * textarea.lineType = 'multiLine'; + * textarea.maxLines = 5; + * ``` + */ +@ECSComponent('UIInputField') +@Serializable({ version: 1, typeId: 'UIInputField' }) +export class UIInputFieldComponent extends Component { + // ===== 内容配置 Content Configuration ===== + + /** + * 当前文本值 + * Current text value + */ + @Serialize() + @Property({ type: 'string', label: 'Text / 文本' }) + public text: string = ''; + + /** + * 占位符文本 + * Placeholder text shown when empty + */ + @Serialize() + @Property({ type: 'string', label: 'Placeholder / 占位符' }) + public placeholder: string = ''; + + /** + * 内容类型(影响输入验证和键盘类型) + * Content type (affects input validation and keyboard type) + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Content Type / 内容类型', + options: ['standard', 'integer', 'decimal', 'alphanumeric', 'name', 'email', 'password'] + }) + public contentType: UIInputContentType = 'standard'; + + /** + * 行类型(单行或多行) + * Line type (single or multi-line) + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Line Type / 行类型', + options: ['singleLine', 'multiLine', 'multiLineSubmit'] + }) + public lineType: UIInputLineType = 'singleLine'; + + /** + * 最大字符数(0 = 无限制) + * Maximum character count (0 = unlimited) + */ + @Serialize() + @Property({ type: 'integer', label: 'Character Limit / 字符限制', min: 0 }) + public characterLimit: number = 0; + + /** + * 多行模式下的最大行数 + * Maximum lines for multi-line mode + */ + @Serialize() + @Property({ type: 'integer', label: 'Max Lines / 最大行数', min: 1 }) + public maxLines: number = 1; + + // ===== 字体配置 Font Configuration ===== + + /** + * 字体大小 + * Font size in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Font Size / 字体大小', min: 8, max: 72 }) + public fontSize: number = 14; + + /** + * 字体系列 + * Font family + */ + @Serialize() + @Property({ type: 'string', label: 'Font Family / 字体系列' }) + public fontFamily: string = 'Arial, sans-serif'; + + /** + * 字体粗细 + * Font weight + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Font Weight / 字体粗细', + options: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'] + }) + public fontWeight: string = 'normal'; + + // ===== 外观配置 Appearance Configuration ===== + // 注意:背景和边框由 UIRender/UIGraphic 组件配置 + // Note: Background and border are configured via UIRender/UIGraphic component + + /** + * 文本颜色 + * Text color + */ + @Serialize() + @Property({ type: 'color', label: 'Text Color / 文本颜色' }) + public textColor: number = 0x000000; + + /** + * 占位符文本颜色 + * Placeholder text color + */ + @Serialize() + @Property({ type: 'color', label: 'Placeholder Color / 占位符颜色' }) + public placeholderColor: number = 0x808080; + + /** + * 选中文本背景颜色 + * Selection highlight color + */ + @Serialize() + @Property({ type: 'color', label: 'Selection Color / 选中颜色' }) + public selectionColor: number = 0x3399FF; + + /** + * 光标颜色 + * Caret (cursor) color + */ + @Serialize() + @Property({ type: 'color', label: 'Caret Color / 光标颜色' }) + public caretColor: number = 0x000000; + + /** + * 光标宽度 + * Caret width in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Caret Width / 光标宽度', min: 1, max: 10 }) + public caretWidth: number = 2; + + /** + * 内边距 + * Padding in pixels + */ + @Serialize() + @Property({ type: 'number', label: 'Padding / 内边距', min: 0 }) + public padding: number = 8; + + // ===== 状态 State ===== + + /** + * 是否禁用 + * Whether the input is disabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Disabled / 禁用' }) + public disabled: boolean = false; + + /** + * 是否只读 + * Whether the input is read-only + */ + @Serialize() + @Property({ type: 'boolean', label: 'Read Only / 只读' }) + public readOnly: boolean = false; + + // ===== 运行时状态 Runtime State (not serialized) ===== + + /** + * 是否获得焦点 + * Whether the input has focus + */ + public focused: boolean = false; + + /** + * 是否悬停 + * Whether mouse is hovering + */ + public hovered: boolean = false; + + /** + * 光标位置(字符索引) + * Caret position (character index) + */ + public caretPosition: number = 0; + + /** + * 选择起始位置 + * Selection start position + */ + public selectionStart: number = 0; + + /** + * 选择结束位置 + * Selection end position + */ + public selectionEnd: number = 0; + + /** + * 光标闪烁计时器 + * Caret blink timer + */ + public caretBlinkTimer: number = 0; + + /** + * 光标是否可见(闪烁状态) + * Whether caret is visible (blink state) + */ + public caretVisible: boolean = true; + + /** + * 光标闪烁间隔(秒) + * Caret blink interval in seconds + */ + public caretBlinkRate: number = 0.53; + + /** + * 滚动偏移(用于长文本) + * Scroll offset for long text + */ + public scrollOffset: number = 0; + + // ===== 回调 Callbacks ===== + + /** + * 值变化回调 + * Value changed callback + */ + public onValueChanged?: (value: string) => void; + + /** + * 提交回调(按回车键) + * Submit callback (on Enter key) + */ + public onSubmit?: (value: string) => void; + + /** + * 获得焦点回调 + * Focus callback + */ + public onFocus?: () => void; + + /** + * 失去焦点回调 + * Blur callback + */ + public onBlur?: () => void; + + /** + * 选择变化回调 + * Selection changed callback + */ + public onSelectionChanged?: (start: number, end: number) => void; + + // ===== 方法 Methods ===== + + /** + * 设置文本值并触发回调 + * Set text value and trigger callback + */ + public setValue(value: string): void { + // 应用字符限制 + // Apply character limit + if (this.characterLimit > 0 && value.length > this.characterLimit) { + value = value.substring(0, this.characterLimit); + } + + // 应用内容类型验证 + // Apply content type validation + value = this.validateContent(value); + + if (this.text !== value) { + this.text = value; + this.onValueChanged?.(value); + } + + // 确保光标位置有效 + // Ensure caret position is valid + this.caretPosition = Math.min(this.caretPosition, this.text.length); + this.selectionStart = Math.min(this.selectionStart, this.text.length); + this.selectionEnd = Math.min(this.selectionEnd, this.text.length); + } + + /** + * 插入文本到光标位置 + * Insert text at caret position + */ + public insertText(text: string): void { + if (this.readOnly || this.disabled) return; + + // 删除选中文本 + // Delete selected text + this.deleteSelection(); + + // 插入新文本 + // Insert new text + const before = this.text.substring(0, this.caretPosition); + const after = this.text.substring(this.caretPosition); + const newText = before + text + after; + + this.setValue(newText); + this.caretPosition += text.length; + this.selectionStart = this.caretPosition; + this.selectionEnd = this.caretPosition; + this.resetCaretBlink(); + } + + /** + * 删除选中的文本 + * Delete selected text + */ + public deleteSelection(): void { + if (this.selectionStart === this.selectionEnd) return; + + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + + const before = this.text.substring(0, start); + const after = this.text.substring(end); + + this.setValue(before + after); + this.caretPosition = start; + this.selectionStart = start; + this.selectionEnd = start; + } + + /** + * 删除光标前的字符 + * Delete character before caret (backspace) + */ + public deleteBackward(): void { + if (this.readOnly || this.disabled) return; + + if (this.hasSelection()) { + this.deleteSelection(); + } else if (this.caretPosition > 0) { + const before = this.text.substring(0, this.caretPosition - 1); + const after = this.text.substring(this.caretPosition); + this.setValue(before + after); + this.caretPosition--; + this.selectionStart = this.caretPosition; + this.selectionEnd = this.caretPosition; + } + this.resetCaretBlink(); + } + + /** + * 删除光标后的字符 + * Delete character after caret (delete) + */ + public deleteForward(): void { + if (this.readOnly || this.disabled) return; + + if (this.hasSelection()) { + this.deleteSelection(); + } else if (this.caretPosition < this.text.length) { + const before = this.text.substring(0, this.caretPosition); + const after = this.text.substring(this.caretPosition + 1); + this.setValue(before + after); + } + this.resetCaretBlink(); + } + + /** + * 移动光标 + * Move caret + */ + public moveCaret(position: number, extendSelection: boolean = false): void { + position = Math.max(0, Math.min(position, this.text.length)); + + if (extendSelection) { + this.selectionEnd = position; + } else { + this.selectionStart = position; + this.selectionEnd = position; + } + + this.caretPosition = position; + this.resetCaretBlink(); + this.onSelectionChanged?.(this.selectionStart, this.selectionEnd); + } + + /** + * 选择全部文本 + * Select all text + */ + public selectAll(): void { + this.selectionStart = 0; + this.selectionEnd = this.text.length; + this.caretPosition = this.text.length; + this.onSelectionChanged?.(this.selectionStart, this.selectionEnd); + } + + /** + * 清除选择 + * Clear selection + */ + public clearSelection(): void { + this.selectionStart = this.caretPosition; + this.selectionEnd = this.caretPosition; + } + + /** + * 是否有选中文本 + * Whether there is selected text + */ + public hasSelection(): boolean { + return this.selectionStart !== this.selectionEnd; + } + + /** + * 获取选中的文本 + * Get selected text + */ + public getSelectedText(): string { + if (!this.hasSelection()) return ''; + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + return this.text.substring(start, end); + } + + /** + * 重置光标闪烁 + * Reset caret blink + */ + public resetCaretBlink(): void { + this.caretBlinkTimer = 0; + this.caretVisible = true; + } + + /** + * 更新光标闪烁 + * Update caret blink + */ + public updateCaretBlink(deltaTime: number): void { + if (!this.focused) return; + + this.caretBlinkTimer += deltaTime; + if (this.caretBlinkTimer >= this.caretBlinkRate) { + this.caretBlinkTimer = 0; + this.caretVisible = !this.caretVisible; + } + } + + /** + * 获取显示文本(密码模式显示圆点) + * Get display text (dots for password mode) + */ + public getDisplayText(): string { + if (this.text.length === 0) return ''; + if (this.contentType === 'password') { + return '•'.repeat(this.text.length); + } + return this.text; + } + + /** + * 验证单个字符是否可以输入 + * Validate if a single character can be input + */ + public validateInput(char: string): boolean { + switch (this.contentType) { + case 'integer': + return /^[0-9-]$/.test(char); + case 'decimal': + return /^[0-9.-]$/.test(char); + case 'alphanumeric': + return /^[a-zA-Z0-9]$/.test(char); + default: + return true; + } + } + + /** + * 验证内容类型 + * Validate content based on content type + */ + private validateContent(value: string): string { + switch (this.contentType) { + case 'integer': + return value.replace(/[^0-9-]/g, ''); + case 'decimal': + return value.replace(/[^0-9.-]/g, ''); + case 'alphanumeric': + return value.replace(/[^a-zA-Z0-9]/g, ''); + case 'name': + // 首字母大写 + // Capitalize first letter of each word + return value.replace(/\b\w/g, char => char.toUpperCase()); + case 'email': + return value.toLowerCase(); + default: + return value; + } + } + + // ===== 文本测量方法 Text Measurement Methods ===== + + /** + * 获取字体配置 + * Get font configuration for text measurement + */ + public getFontConfig(): TextMeasureFont { + return { + fontSize: this.fontSize, + fontFamily: this.fontFamily, + fontWeight: this.fontWeight + }; + } + + /** + * 获取 CSS 字体字符串 + * Get CSS font string + */ + public getCSSFont(): string { + return `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; + } + + /** + * 测量显示文本的宽度 + * Measure display text width + */ + public measureDisplayTextWidth(): number { + const service = getTextMeasureService(); + return service.measureText(this.getDisplayText(), this.getFontConfig()); + } + + /** + * 获取光标的 X 位置 + * Get caret X position + */ + public getCaretX(): number { + const service = getTextMeasureService(); + const displayText = this.getDisplayText(); + return service.getXForCharIndex(displayText, this.getFontConfig(), this.caretPosition); + } + + /** + * 获取选择区域的 X 范围 + * Get selection X range + */ + public getSelectionXRange(): { startX: number; endX: number; width: number } { + const service = getTextMeasureService(); + const font = this.getFontConfig(); + const displayText = this.getDisplayText(); + + const start = Math.min(this.selectionStart, this.selectionEnd); + const end = Math.max(this.selectionStart, this.selectionEnd); + + const startX = service.getXForCharIndex(displayText, font, start); + const endX = service.getXForCharIndex(displayText, font, end); + + return { + startX, + endX, + width: endX - startX + }; + } + + /** + * 根据 X 位置获取字符索引 + * Get character index at X position + * + * @param x - X position relative to text area start | 相对于文本区域开始的 X 位置 + */ + public getCharIndexAtX(x: number): number { + const service = getTextMeasureService(); + const displayText = this.getDisplayText(); + return service.getCharIndexAtX(displayText, this.getFontConfig(), x + this.scrollOffset); + } + + /** + * 获取当前行的高度 + * Get line height + */ + public getLineHeight(): number { + return this.fontSize * 1.2; // 默认行高系数 | Default line height factor + } + + /** + * 获取文本行信息(多行模式) + * Get text line info (multi-line mode) + */ + public getLineInfo() { + const service = getTextMeasureService(); + return service.getLineInfo(this.getDisplayText(), this.getFontConfig()); + } + + /** + * 获取光标所在行索引 + * Get line index for caret + */ + public getCaretLineIndex(): number { + const service = getTextMeasureService(); + return service.getLineIndexForChar(this.text, this.caretPosition); + } + + /** + * 获取光标在当前行的列位置 + * Get column position of caret in current line + */ + public getCaretColumn(): number { + const service = getTextMeasureService(); + return service.getColumnForChar(this.text, this.caretPosition); + } + + /** + * 移动光标到指定行和列 + * Move caret to specified line and column + */ + public moveCaretToLineColumn(lineIndex: number, column: number): void { + const service = getTextMeasureService(); + const newPosition = service.getCharIndexForLineColumn(this.text, lineIndex, column); + this.caretPosition = newPosition; + this.resetCaretBlink(); + } + + /** + * 更新滚动偏移以确保光标可见 + * Update scroll offset to ensure caret is visible + * + * @param visibleWidth - Visible text area width | 可见文本区域宽度 + */ + public ensureCaretVisible(visibleWidth: number): void { + const caretX = this.getCaretX(); + + // 如果光标在可见区域左边 + // If caret is to the left of visible area + if (caretX < this.scrollOffset) { + this.scrollOffset = Math.max(0, caretX - 10); + } + + // 如果光标在可见区域右边 + // If caret is to the right of visible area + if (caretX > this.scrollOffset + visibleWidth) { + this.scrollOffset = caretX - visibleWidth + 10; + } + } +} diff --git a/packages/ui/src/components/widgets/UISliderComponent.ts b/packages/ui/src/components/widgets/UISliderComponent.ts index eb429933..a136ceeb 100644 --- a/packages/ui/src/components/widgets/UISliderComponent.ts +++ b/packages/ui/src/components/widgets/UISliderComponent.ts @@ -62,6 +62,12 @@ export class UISliderComponent extends Component { */ public displayValue: number = 0; + /** + * 值是否已初始化(用于编辑器预览) + * Whether value has been initialized (for editor preview) + */ + public _valueInitialized: boolean = false; + // ===== 方向 Orientation ===== /** @@ -115,6 +121,14 @@ export class UISliderComponent extends Component { // ===== 填充样式 Fill Style ===== + /** + * 外部 Fill 实体 ID(如果设置,将控制该实体的宽度而不是内置渲染) + * External Fill entity ID (if set, controls that entity's width instead of built-in rendering) + */ + @Serialize() + @Property({ type: 'entityRef', label: 'Fill Rect' }) + public fillRectEntityId: number = 0; + /** * 填充颜色(已滑过的部分) * Fill color (passed portion) @@ -387,4 +401,78 @@ export class UISliderComponent extends Component { if (this.handleHovered) return this.handleHoverColor; return this.handleColor; } + + /** + * 计算手柄边界(世界坐标) + * Calculate handle bounds in world coordinates + * + * @param worldX - Slider world X position | 滑块世界 X 坐标 + * @param worldY - Slider world Y position | 滑块世界 Y 坐标 + * @param sliderWidth - Slider computed width | 滑块计算宽度 + * @param sliderHeight - Slider computed height | 滑块计算高度 + * @returns Handle bounds { x, y, width, height } | 手柄边界 + */ + public getHandleBounds( + worldX: number, + worldY: number, + sliderWidth: number, + sliderHeight: number + ): { x: number; y: number; width: number; height: number } { + const progress = this.getProgress(); + + if (this.orientation === UISliderOrientation.Horizontal) { + // 水平滑块:手柄沿 X 轴移动 + // Horizontal slider: handle moves along X axis + const trackWidth = sliderWidth - this.handleWidth; + const handleX = worldX + trackWidth * progress; + const handleY = worldY + (sliderHeight - this.handleHeight) / 2; + + return { + x: handleX, + y: handleY, + width: this.handleWidth, + height: this.handleHeight + }; + } else { + // 垂直滑块:手柄沿 Y 轴移动 + // Vertical slider: handle moves along Y axis + const trackHeight = sliderHeight - this.handleHeight; + const handleX = worldX + (sliderWidth - this.handleWidth) / 2; + const handleY = worldY + trackHeight * progress; + + return { + x: handleX, + y: handleY, + width: this.handleWidth, + height: this.handleHeight + }; + } + } + + /** + * 检测点是否在手柄内 + * Test if a point is inside the handle + * + * @param pointX - Point X in world coordinates | 世界坐标点 X + * @param pointY - Point Y in world coordinates | 世界坐标点 Y + * @param worldX - Slider world X position | 滑块世界 X 坐标 + * @param worldY - Slider world Y position | 滑块世界 Y 坐标 + * @param sliderWidth - Slider computed width | 滑块计算宽度 + * @param sliderHeight - Slider computed height | 滑块计算高度 + * @returns Whether point is inside handle | 点是否在手柄内 + */ + public isPointInHandle( + pointX: number, + pointY: number, + worldX: number, + worldY: number, + sliderWidth: number, + sliderHeight: number + ): boolean { + const bounds = this.getHandleBounds(worldX, worldY, sliderWidth, sliderHeight); + return pointX >= bounds.x && + pointX <= bounds.x + bounds.width && + pointY >= bounds.y && + pointY <= bounds.y + bounds.height; + } } diff --git a/packages/ui/src/components/widgets/UIToggleComponent.ts b/packages/ui/src/components/widgets/UIToggleComponent.ts new file mode 100644 index 00000000..c8c572b9 --- /dev/null +++ b/packages/ui/src/components/widgets/UIToggleComponent.ts @@ -0,0 +1,337 @@ +import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework'; +import { lerpColor } from '../../systems/render/UIRenderUtils'; + +/** + * Toggle 显示样式 + * Toggle display style + */ +export type UIToggleStyle = 'checkbox' | 'switch' | 'custom'; + +/** + * UI Toggle 组件 + * UI Toggle Component - Checkbox/Switch for boolean values + * + * @example + * ```typescript + * // Checkbox style + * const toggle = entity.addComponent(UIToggleComponent); + * toggle.isOn = true; + * toggle.onChange = (value) => console.log('Toggle:', value); + * + * // Switch style + * toggle.style = 'switch'; + * toggle.switchWidth = 50; + * toggle.switchHeight = 26; + * ``` + */ +@ECSComponent('UIToggle') +@Serializable({ version: 1, typeId: 'UIToggle' }) +export class UIToggleComponent extends Component { + // ===== 状态 State ===== + + /** + * 当前开关状态 + * Current toggle state + */ + @Serialize() + @Property({ type: 'boolean', label: 'Is On' }) + public isOn: boolean = false; + + /** + * 是否禁用 + * Whether toggle is disabled + */ + @Serialize() + @Property({ type: 'boolean', label: 'Disabled' }) + public disabled: boolean = false; + + // ===== 显示样式 Display Style ===== + + /** + * 显示样式:复选框、开关、自定义 + * Display style: checkbox, switch, or custom + */ + @Serialize() + @Property({ + type: 'enum', + label: 'Style', + options: ['checkbox', 'switch', 'custom'] + }) + public style: UIToggleStyle = 'checkbox'; + + // ===== Checkbox 样式配置 ===== + + /** + * 复选框大小 + * Checkbox size + */ + @Serialize() + @Property({ type: 'number', label: 'Checkbox Size', min: 8 }) + public checkboxSize: number = 20; + + /** + * 复选框边框宽度 + * Checkbox border width + */ + @Serialize() + @Property({ type: 'number', label: 'Border Width', min: 0 }) + public borderWidth: number = 2; + + /** + * 复选框圆角 + * Checkbox corner radius + */ + @Serialize() + @Property({ type: 'number', label: 'Corner Radius', min: 0 }) + public cornerRadius: number = 4; + + /** + * 勾选标记大小比例(相对于复选框) + * Checkmark size ratio (relative to checkbox) + */ + @Serialize() + @Property({ type: 'number', label: 'Checkmark Ratio', min: 0.3, max: 1, step: 0.1 }) + public checkmarkRatio: number = 0.6; + + // ===== Switch 样式配置 ===== + + /** + * 开关宽度 + * Switch width + */ + @Serialize() + @Property({ type: 'number', label: 'Switch Width', min: 20 }) + public switchWidth: number = 44; + + /** + * 开关高度 + * Switch height + */ + @Serialize() + @Property({ type: 'number', label: 'Switch Height', min: 12 }) + public switchHeight: number = 24; + + /** + * 开关滑块边距 + * Switch knob padding + */ + @Serialize() + @Property({ type: 'number', label: 'Knob Padding', min: 0 }) + public knobPadding: number = 2; + + // ===== 颜色配置 Color Configuration ===== + + /** + * 关闭状态背景颜色 + * Off state background color + */ + @Serialize() + @Property({ type: 'color', label: 'Off Color' }) + public offColor: number = 0xCCCCCC; + + /** + * 开启状态背景颜色 + * On state background color + */ + @Serialize() + @Property({ type: 'color', label: 'On Color' }) + public onColor: number = 0x4CD964; + + /** + * 悬停颜色偏移(叠加) + * Hover color tint + */ + @Serialize() + @Property({ type: 'color', label: 'Hover Tint' }) + public hoverTint: number = 0xFFFFFF; + + /** + * 按下颜色偏移 + * Pressed color tint + */ + @Serialize() + @Property({ type: 'color', label: 'Pressed Tint' }) + public pressedTint: number = 0xDDDDDD; + + /** + * 禁用状态颜色 + * Disabled state color + */ + @Serialize() + @Property({ type: 'color', label: 'Disabled Color' }) + public disabledColor: number = 0xEEEEEE; + + /** + * 边框颜色 + * Border color + */ + @Serialize() + @Property({ type: 'color', label: 'Border Color' }) + public borderColor: number = 0x999999; + + /** + * 勾选标记/滑块颜色 + * Checkmark/Knob color + */ + @Serialize() + @Property({ type: 'color', label: 'Mark Color' }) + public markColor: number = 0xFFFFFF; + + /** + * 背景透明度 + * Background alpha + */ + @Serialize() + @Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.1 }) + public alpha: number = 1; + + // ===== 纹理配置 Texture Configuration ===== + + /** + * 关闭状态纹理 GUID + * Off state texture GUID + */ + @Serialize() + @Property({ type: 'asset', label: 'Off Texture', assetType: 'texture' }) + public offTextureGuid: string = ''; + + /** + * 开启状态纹理 GUID + * On state texture GUID + */ + @Serialize() + @Property({ type: 'asset', label: 'On Texture', assetType: 'texture' }) + public onTextureGuid: string = ''; + + /** + * 勾选标记纹理 GUID + * Checkmark texture GUID + */ + @Serialize() + @Property({ type: 'asset', label: 'Checkmark Texture', assetType: 'texture' }) + public checkmarkTextureGuid: string = ''; + + // ===== 动画配置 Animation Configuration ===== + + /** + * 过渡时长(秒) + * Transition duration in seconds + */ + @Serialize() + @Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 }) + public transitionDuration: number = 0.15; + + // ===== 运行时状态 Runtime State ===== + + /** + * 当前显示进度(0=关闭,1=开启,用于动画) + * Current display progress (0=off, 1=on, for animation) + */ + public displayProgress: number = 0; + + /** + * 目标进度 + * Target progress + */ + public targetProgress: number = 0; + + /** + * 是否悬停 + * Whether hovered + */ + public hovered: boolean = false; + + /** + * 是否按下 + * Whether pressed + */ + public pressed: boolean = false; + + // ===== 回调 Callbacks ===== + + /** + * 值改变回调 + * Value change callback + */ + public onChange?: (isOn: boolean) => void; + + // ===== 方法 Methods ===== + + /** + * 切换状态 + * Toggle state + */ + public toggle(): void { + if (this.disabled) return; + this.setOn(!this.isOn); + } + + /** + * 设置开关状态 + * Set toggle state + * + * @param value - New state | 新状态 + * @param animate - Whether to animate transition | 是否动画过渡 + */ + public setOn(value: boolean, animate: boolean = true): void { + if (this.isOn === value) return; + this.isOn = value; + this.targetProgress = value ? 1 : 0; + if (!animate) { + this.displayProgress = this.targetProgress; + } + this.onChange?.(value); + } + + /** + * 获取当前背景颜色 + * Get current background color based on state + */ + public getCurrentBackgroundColor(): number { + if (this.disabled) return this.disabledColor; + + // 基础颜色:根据开关状态插值 + const baseColor = this.isOn ? this.onColor : this.offColor; + + // 如果有悬停或按下状态,可以应用色调 + // 这里简化处理,直接返回基础颜色 + if (this.pressed) return lerpColor(baseColor, this.pressedTint, 0.2); + if (this.hovered) return lerpColor(baseColor, this.hoverTint, 0.1); + + return baseColor; + } + + /** + * 获取当前纹理 GUID + * Get current texture GUID based on state + */ + public getCurrentTextureGuid(): string { + return this.isOn ? this.onTextureGuid : this.offTextureGuid; + } + + /** + * 是否使用纹理渲染 + * Whether to use texture rendering + */ + public useTexture(): boolean { + return !!(this.offTextureGuid || this.onTextureGuid); + } + + /** + * 计算滑块位置(Switch 样式) + * Calculate knob position for switch style + * + * @returns Normalized position 0-1 | 归一化位置 + */ + public getKnobPosition(): number { + return this.displayProgress; + } + + /** + * 计算滑块尺寸(Switch 样式) + * Calculate knob size for switch style + */ + public getKnobSize(): number { + return this.switchHeight - this.knobPadding * 2; + } +} diff --git a/packages/ui/src/components/widgets/index.ts b/packages/ui/src/components/widgets/index.ts index 952d610f..ef532c39 100644 --- a/packages/ui/src/components/widgets/index.ts +++ b/packages/ui/src/components/widgets/index.ts @@ -2,3 +2,6 @@ export * from './UIButtonComponent'; export * from './UIProgressBarComponent'; export * from './UISliderComponent'; export * from './UIScrollViewComponent'; +export * from './UIToggleComponent'; +export * from './UIInputFieldComponent'; +export * from './UIDropdownComponent'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 54cbb4c5..161fcf89 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -75,6 +75,13 @@ export { type UIShadowStyle } from './components/UIRenderComponent'; +export { + type UIMaterialPropertyOverride, + type UIMaterialOverrides +} from './systems/render/UIRenderCollector'; + +export { UIShinyEffectComponent } from './components/UIShinyEffectComponent'; + export { UIInteractableComponent, type UICursorType @@ -90,6 +97,8 @@ export { export { TextBlinkComponent } from './components/TextBlinkComponent'; export { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent'; +export { UIWidgetMarker } from './components/UIWidgetMarker'; +export { UICanvasComponent, UICanvasRenderMode } from './components/UICanvasComponent'; export { UILayoutComponent, @@ -99,6 +108,22 @@ export { type UIPadding } from './components/UILayoutComponent'; +// Components - Base (new architecture) +// 基础组件(新架构) +export { + UIGraphicComponent, + UIImageComponent, + UISelectableComponent, + DEFAULT_COLOR_BLOCK, + type UIImageType, + type UIFillMethod, + type UIFillOrigin, + type UISelectableState, + type UITransitionType, + type UIColorBlock, + type UISpriteState +} from './components/base'; + // Components - Widgets export { UIButtonComponent, @@ -122,10 +147,28 @@ export { UIScrollbarVisibility } from './components/widgets/UIScrollViewComponent'; +export { + UIToggleComponent, + type UIToggleStyle +} from './components/widgets/UIToggleComponent'; + +export { + UIInputFieldComponent, + type UIInputContentType, + type UIInputLineType +} from './components/widgets/UIInputFieldComponent'; + +export { + UIDropdownComponent, + type UIDropdownOption +} from './components/widgets/UIDropdownComponent'; + // Systems - Core export { UILayoutSystem } from './systems/UILayoutSystem'; export { UIInputSystem, type UIInputEvent } from './systems/UIInputSystem'; export { UIAnimationSystem, UIEasing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem'; +export { UISelectableStateSystem } from './systems/UISelectableStateSystem'; +export { UISliderFillSystem } from './systems/UISliderFillSystem'; export { UIRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider'; export { TextBlinkSystem } from './systems/TextBlinkSystem'; export { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem'; @@ -137,16 +180,36 @@ export { getUIRenderCollector, resetUIRenderCollector, invalidateUIRenderCaches, + requestTextureForAtlas, + clearTextureRequestCache, type UIRenderPrimitive, type ProviderRenderData, + type BatchBreakReason, + type BatchDebugInfo, // Render systems UIRenderBeginSystem, + UIGraphicRenderSystem, UIRectRenderSystem, UITextRenderSystem, UIButtonRenderSystem, UIProgressBarRenderSystem, UISliderRenderSystem, - UIScrollViewRenderSystem + UIScrollViewRenderSystem, + UIToggleRenderSystem, + UIInputFieldRenderSystem, + UIDropdownRenderSystem, + UIShinyEffectSystem, + // Render utilities + ensureUIWidgetMarker, + getUIRenderTransform, + renderBorder, + renderShadow, + lerpColor, + packColorWithAlpha, + getNinePatchPosition, + type UIRenderTransform, + type BorderRenderOptions, + type ShadowRenderOptions } from './systems/render'; // Rendering @@ -163,7 +226,10 @@ export { type UIProgressBarConfig, type UISliderConfig, type UIPanelConfig, - type UIScrollViewConfig + type UIScrollViewConfig, + type UIToggleConfig, + type UIInputFieldConfig, + type UIDropdownConfig } from './UIBuilder'; // Runtime module and plugin @@ -174,5 +240,65 @@ export { UILayoutSystemToken, UIInputSystemToken, UIRenderProviderToken, - UITextRenderSystemToken + UITextRenderSystemToken, + UIAnimationSystemToken, + UISelectableStateSystemToken } from './tokens'; + +// Dynamic Atlas | 动态图集 +export { + BinPacker, + DynamicAtlasManager, + getDynamicAtlasManager, + setDynamicAtlasManager, + AtlasExpansionStrategy, + DynamicAtlasService, + getDynamicAtlasService, + setDynamicAtlasService, + initializeDynamicAtlasService, + reinitializeDynamicAtlasService, + registerTexturePathMapping, + getTexturePathByGuid, + clearTexturePathMappings, + type PackedRect, + type AtlasEntry, + type IAtlasEngineBridge, + type DynamicAtlasConfig, + type TextureInfo +} from './atlas'; + +// Texture Utilities | 纹理工具 +export { + type UITextureDescriptor, + type UINinePatchDescriptor, + isValidTexture, + isValidTextureGuid, + getTextureKey, + defaultUV, + normalizeTextureDescriptor, + extractTextureGuid, + mergeTextureDescriptors, + isValidNinePatchMargins, + getNinePatchMinSize +} from './utils'; + +// Dirty Flag Utilities | 脏标记工具 +export { + UIDirtyFlags, + type IDirtyTrackable, + DirtyOnChange, + DirtyTracker, + markFrameDirty, + isFrameDirty, + getDirtyComponentCount, + clearFrameDirty +} from './utils'; + +// Text Measure Utilities | 文本测量工具 +export { + getTextMeasureService, + disposeTextMeasureService, + type TextMeasureFont, + type CharacterPosition, + type LineInfo +} from './utils'; diff --git a/packages/ui/src/systems/UIAnimationSystem.ts b/packages/ui/src/systems/UIAnimationSystem.ts index 9a6217c5..f5bb2338 100644 --- a/packages/ui/src/systems/UIAnimationSystem.ts +++ b/packages/ui/src/systems/UIAnimationSystem.ts @@ -2,6 +2,9 @@ import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-fr import { UIProgressBarComponent } from '../components/widgets/UIProgressBarComponent'; import { UISliderComponent } from '../components/widgets/UISliderComponent'; import { UIButtonComponent } from '../components/widgets/UIButtonComponent'; +import { UIToggleComponent } from '../components/widgets/UIToggleComponent'; +import { UIDropdownComponent } from '../components/widgets/UIDropdownComponent'; +import { lerpColor } from './render/UIRenderUtils'; /** * 缓动函数类型 @@ -139,7 +142,7 @@ export const Easing = UIEasing; export class UIAnimationSystem extends EntitySystem { constructor() { // 匹配有任何动画组件的实体 - super(Matcher.empty().any(UIButtonComponent, UIProgressBarComponent, UISliderComponent)); + super(Matcher.empty().any(UIButtonComponent, UIProgressBarComponent, UISliderComponent, UIToggleComponent, UIDropdownComponent)); } /** @@ -162,6 +165,12 @@ export class UIAnimationSystem extends EntitySystem { // 处理按钮颜色动画 this.updateButtonColor(entity, dt); + + // 处理 Toggle 动画 + this.updateToggle(entity, dt); + + // 处理 Dropdown 颜色动画 + this.updateDropdownColor(entity, dt); } } @@ -238,7 +247,7 @@ export class UIAnimationSystem extends EntitySystem { if (button.currentColor !== button.targetColor) { // 颜色插值 - button.currentColor = this.lerpColor( + button.currentColor = lerpColor( button.currentColor, button.targetColor, Math.min(1, dt / button.transitionDuration) @@ -247,23 +256,56 @@ export class UIAnimationSystem extends EntitySystem { } /** - * 颜色线性插值 - * Linear interpolate between two colors + * 更新 Toggle 动画 + * Update toggle animation */ - private lerpColor(from: number, to: number, t: number): number { - const fromR = (from >> 16) & 0xFF; - const fromG = (from >> 8) & 0xFF; - const fromB = from & 0xFF; + private updateToggle(entity: Entity, dt: number): void { + const toggle = entity.getComponent(UIToggleComponent); + if (!toggle) return; - const toR = (to >> 16) & 0xFF; - const toG = (to >> 8) & 0xFF; - const toB = to & 0xFF; + // 同步目标进度和开关状态 + // Sync target progress with on state + toggle.targetProgress = toggle.isOn ? 1 : 0; - const r = Math.round(fromR + (toR - fromR) * t); - const g = Math.round(fromG + (toG - fromG) * t); - const b = Math.round(fromB + (toB - fromB) * t); + // 如果显示进度和目标进度不同,进行插值 + // If display progress differs from target, interpolate + if (toggle.displayProgress !== toggle.targetProgress) { + const speed = 1 / Math.max(0.01, toggle.transitionDuration); + const diff = toggle.targetProgress - toggle.displayProgress; + const direction = Math.sign(diff); + const step = Math.min(Math.abs(diff), speed * dt); - return (r << 16) | (g << 8) | b; + toggle.displayProgress += direction * step; + + // 接近目标时直接设置 + // Snap to target when close enough + if (Math.abs(toggle.displayProgress - toggle.targetProgress) < 0.01) { + toggle.displayProgress = toggle.targetProgress; + } + } + } + + /** + * 更新 Dropdown 颜色动画 + * Update dropdown color animation + */ + private updateDropdownColor(entity: Entity, dt: number): void { + const dropdown = entity.getComponent(UIDropdownComponent); + if (!dropdown) return; + + // 更新目标颜色基于当前状态 + // Update target color based on current state + dropdown.targetColor = dropdown.getCurrentBackgroundColor(); + + if (dropdown.currentColor !== dropdown.targetColor) { + // 颜色插值 + // Color interpolation + dropdown.currentColor = lerpColor( + dropdown.currentColor, + dropdown.targetColor, + Math.min(1, dt / dropdown.transitionDuration) + ); + } } /** diff --git a/packages/ui/src/systems/UICanvasScalerSystem.ts b/packages/ui/src/systems/UICanvasScalerSystem.ts index 55738edc..5c9acf60 100644 --- a/packages/ui/src/systems/UICanvasScalerSystem.ts +++ b/packages/ui/src/systems/UICanvasScalerSystem.ts @@ -21,7 +21,7 @@ export interface ScreenInfo { * 此系统应该在 UILayoutSystem 之前执行,以便在布局计算前更新画布尺寸 * This system should execute before UILayoutSystem to update canvas size before layout calculation */ -@ECSSystem('UICanvasScaler') +@ECSSystem('UICanvasScaler', { runInEditMode: true }) export class UICanvasScalerSystem extends EntitySystem { /** * 当前屏幕信息 diff --git a/packages/ui/src/systems/UIInputSystem.ts b/packages/ui/src/systems/UIInputSystem.ts index 4b1573c1..1079219a 100644 --- a/packages/ui/src/systems/UIInputSystem.ts +++ b/packages/ui/src/systems/UIInputSystem.ts @@ -5,6 +5,9 @@ import { UIInteractableComponent } from '../components/UIInteractableComponent'; import { UIButtonComponent } from '../components/widgets/UIButtonComponent'; import { UISliderComponent } from '../components/widgets/UISliderComponent'; import { UIScrollViewComponent } from '../components/widgets/UIScrollViewComponent'; +import { UIToggleComponent } from '../components/widgets/UIToggleComponent'; +import { UIInputFieldComponent } from '../components/widgets/UIInputFieldComponent'; +import { UIDropdownComponent } from '../components/widgets/UIDropdownComponent'; import type { UILayoutSystem } from './UILayoutSystem'; // Re-export MouseButton for backward compatibility @@ -79,6 +82,16 @@ export class UIInputSystem extends EntitySystem { private boundMouseDown: (e: MouseEvent) => void; private boundMouseUp: (e: MouseEvent) => void; private boundWheel: (e: WheelEvent) => void; + private boundKeyDown: (e: KeyboardEvent) => void; + private boundKeyUp: (e: KeyboardEvent) => void; + private boundKeyPress: (e: KeyboardEvent) => void; + + // ===== 打开的 Dropdown Open Dropdown ===== + private openDropdown: Entity | null = null; + + // ===== InputField 拖选状态 InputField Selection Drag State ===== + private inputFieldDragTarget: Entity | null = null; + private inputFieldDragStartIndex: number = 0; // ===== UI 布局系统引用 UI Layout System Reference ===== // 用于获取 UI 画布尺寸以进行坐标转换 @@ -92,6 +105,9 @@ export class UIInputSystem extends EntitySystem { this.boundMouseDown = this.onMouseDown.bind(this); this.boundMouseUp = this.onMouseUp.bind(this); this.boundWheel = this.onWheel.bind(this); + this.boundKeyDown = this.onKeyDown.bind(this); + this.boundKeyUp = this.onKeyUp.bind(this); + this.boundKeyPress = this.onKeyPress.bind(this); } /** @@ -118,6 +134,12 @@ export class UIInputSystem extends EntitySystem { canvas.addEventListener('mouseup', this.boundMouseUp); canvas.addEventListener('wheel', this.boundWheel); + // 键盘事件绑定到 document 以便在焦点状态下捕获 + // Bind keyboard events to document to capture when focused + document.addEventListener('keydown', this.boundKeyDown); + document.addEventListener('keyup', this.boundKeyUp); + document.addEventListener('keypress', this.boundKeyPress); + // 阻止右键菜单 canvas.addEventListener('contextmenu', (e) => e.preventDefault()); } @@ -134,6 +156,12 @@ export class UIInputSystem extends EntitySystem { this.canvas.removeEventListener('wheel', this.boundWheel); this.canvas = null; } + + // 移除键盘事件监听 + // Remove keyboard event listeners + document.removeEventListener('keydown', this.boundKeyDown); + document.removeEventListener('keyup', this.boundKeyUp); + document.removeEventListener('keypress', this.boundKeyPress); } /** @@ -277,7 +305,10 @@ export class UIInputSystem extends EntitySystem { // 处理特殊控件 this.handleSlider(entity); this.handleButton(entity, interactable); + this.handleToggle(entity, interactable); this.handleScrollView(entity, transform); + this.handleInputField(entity, interactable); + this.handleDropdown(entity, interactable, transform); // 阻止事件传递到下层 if (interactable.blockEvents) { @@ -387,11 +418,27 @@ export class UIInputSystem extends EntitySystem { const transform = entity.getComponent(UITransformComponent)!; - // 更新手柄悬停状态 - // TODO: 更精确的手柄命中测试 + // 更新手柄悬停状态(精确命中测试) + // Update handle hover state (precise hit testing) + const worldX = transform.worldX ?? transform.x; + const worldY = transform.worldY ?? transform.y; + const width = transform.computedWidth ?? transform.width; + const height = transform.computedHeight ?? transform.height; - // 处理拖拽 - if (this.mouseButtons[MouseButton.Left] && transform.containsPoint(this.mouseX, this.mouseY)) { + const isInSlider = transform.containsPoint(this.mouseX, this.mouseY); + const isInHandle = slider.isPointInHandle( + this.mouseX, + this.mouseY, + worldX, + worldY, + width, + height + ); + slider.handleHovered = isInHandle; + + // 处理拖拽:点击手柄或轨道都可以开始拖拽 + // Handle drag: clicking handle or track can start dragging + if (this.mouseButtons[MouseButton.Left] && isInSlider) { if (!slider.dragging) { slider.dragging = true; slider.dragStartValue = slider.value; @@ -400,8 +447,8 @@ export class UIInputSystem extends EntitySystem { } // 计算新值 - const relativeX = this.mouseX - transform.worldX; - const progress = Math.max(0, Math.min(1, relativeX / transform.computedWidth)); + const relativeX = this.mouseX - worldX; + const progress = Math.max(0, Math.min(1, relativeX / width)); const newValue = slider.minValue + progress * (slider.maxValue - slider.minValue); if (newValue !== slider.targetValue) { @@ -418,11 +465,12 @@ export class UIInputSystem extends EntitySystem { const button = entity.getComponent(UIButtonComponent); if (!button || button.disabled) return; - // 更新目标颜色和当前颜色 + // 更新目标颜色,让 UIAnimationSystem 处理平滑过渡 + // Update target color, let UIAnimationSystem handle smooth transition const stateColor = button.getStateColor(interactable.getState()); button.targetColor = stateColor; - // 直接设置 currentColor 以便立即看到效果(动画系统会平滑过渡) - button.currentColor = stateColor; + // 注意:不要直接设置 currentColor,由 UIAnimationSystem.updateButtonColor() 进行插值 + // Note: Don't set currentColor directly, let UIAnimationSystem.updateButtonColor() interpolate // 处理长按 if (interactable.pressed) { @@ -442,12 +490,33 @@ export class UIInputSystem extends EntitySystem { } } + private handleToggle(entity: Entity, interactable: UIInteractableComponent): void { + const toggle = entity.getComponent(UIToggleComponent); + if (!toggle || toggle.disabled) return; + + // 更新悬停和按下状态 + // Update hover and pressed state + toggle.hovered = interactable.hovered; + toggle.pressed = interactable.pressed; + + // 处理点击切换 + // Handle click toggle + const wasPressed = this.prevMouseButtons[MouseButton.Left]; + const isPressed = this.mouseButtons[MouseButton.Left]; + + if (wasPressed && !isPressed && interactable.hovered) { + // 鼠标在元素上释放 - 切换状态 + // Mouse released over element - toggle state + toggle.toggle(); + } + } + private handleScrollView(entity: Entity, transform: UITransformComponent): void { const scrollView = entity.getComponent(UIScrollViewComponent); if (!scrollView) return; - const viewportWidth = transform.computedWidth; - const viewportHeight = transform.computedHeight; + const viewportWidth = transform.computedWidth ?? transform.width; + const viewportHeight = transform.computedHeight ?? transform.height; const maxScrollX = scrollView.getMaxScrollX(viewportWidth); const maxScrollY = scrollView.getMaxScrollY(viewportHeight); @@ -720,6 +789,483 @@ export class UIInputSystem extends EntitySystem { return this.mouseButtons[button] ?? false; } + // ===== 键盘事件处理 Keyboard Event Handlers ===== + + private onKeyDown(e: KeyboardEvent): void { + // 处理 InputField 键盘输入 + // Handle InputField keyboard input + if (this.focusedEntity) { + const inputField = this.focusedEntity.getComponent(UIInputFieldComponent); + if (inputField && !inputField.readOnly) { + this.handleInputFieldKeyDown(inputField, e); + + // 确保光标可见 + // Ensure caret is visible after keyboard operation + const transform = this.focusedEntity.getComponent(UITransformComponent); + if (transform) { + const width = transform.computedWidth ?? transform.width; + const textAreaWidth = width - inputField.padding * 2; + inputField.ensureCaretVisible(textAreaWidth); + } + return; + } + } + + // 处理打开的 Dropdown 键盘导航 + // Handle open Dropdown keyboard navigation + if (this.openDropdown) { + const dropdown = this.openDropdown.getComponent(UIDropdownComponent); + if (dropdown && dropdown.isOpen) { + this.handleDropdownKeyDown(dropdown, e); + } + } + } + + private onKeyUp(_e: KeyboardEvent): void { + // 可扩展的按键释放处理 + // Extensible key release handling + } + + private onKeyPress(e: KeyboardEvent): void { + // 处理 InputField 字符输入 + // Handle InputField character input + if (this.focusedEntity) { + const inputField = this.focusedEntity.getComponent(UIInputFieldComponent); + if (inputField && !inputField.readOnly) { + // keypress 用于可打印字符 + // keypress is for printable characters + if (e.key.length === 1) { + this.handleInputFieldCharacter(inputField, e.key, e); + + // 确保光标可见 + // Ensure caret is visible after character input + const transform = this.focusedEntity.getComponent(UITransformComponent); + if (transform) { + const width = transform.computedWidth ?? transform.width; + const textAreaWidth = width - inputField.padding * 2; + inputField.ensureCaretVisible(textAreaWidth); + } + } + } + } + } + + private handleInputFieldKeyDown(inputField: UIInputFieldComponent, e: KeyboardEvent): void { + const key = e.key; + const ctrlOrCmd = e.ctrlKey || e.metaKey; + + switch (key) { + case 'Backspace': + e.preventDefault(); + if (inputField.hasSelection()) { + inputField.deleteSelection(); + } else if (inputField.caretPosition > 0) { + // 删除光标前的字符 + // Delete character before caret + const text = inputField.text; + inputField.text = text.slice(0, inputField.caretPosition - 1) + text.slice(inputField.caretPosition); + inputField.caretPosition--; + inputField.onValueChanged?.(inputField.text); + } + break; + + case 'Delete': + e.preventDefault(); + if (inputField.hasSelection()) { + inputField.deleteSelection(); + } else if (inputField.caretPosition < inputField.text.length) { + // 删除光标后的字符 + // Delete character after caret + const text = inputField.text; + inputField.text = text.slice(0, inputField.caretPosition) + text.slice(inputField.caretPosition + 1); + inputField.onValueChanged?.(inputField.text); + } + break; + + case 'ArrowLeft': + e.preventDefault(); + if (e.shiftKey) { + // 扩展选择 + // Extend selection + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.caretPosition = Math.max(0, inputField.caretPosition - 1); + inputField.selectionEnd = inputField.caretPosition; + } else { + inputField.moveCaret(-1, ctrlOrCmd); + inputField.clearSelection(); + } + break; + + case 'ArrowRight': + e.preventDefault(); + if (e.shiftKey) { + // 扩展选择 + // Extend selection + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.caretPosition = Math.min(inputField.text.length, inputField.caretPosition + 1); + inputField.selectionEnd = inputField.caretPosition; + } else { + inputField.moveCaret(1, ctrlOrCmd); + inputField.clearSelection(); + } + break; + + case 'ArrowUp': + // 多行模式:移动到上一行 + // Multi-line mode: move to previous line + if (inputField.lineType !== 'singleLine') { + e.preventDefault(); + const currentLine = inputField.getCaretLineIndex(); + if (currentLine > 0) { + const column = inputField.getCaretColumn(); + const targetLine = currentLine - 1; + + if (e.shiftKey) { + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.moveCaretToLineColumn(targetLine, column); + inputField.selectionEnd = inputField.caretPosition; + } else { + inputField.moveCaretToLineColumn(targetLine, column); + inputField.clearSelection(); + } + } + } + break; + + case 'ArrowDown': + // 多行模式:移动到下一行 + // Multi-line mode: move to next line + if (inputField.lineType !== 'singleLine') { + e.preventDefault(); + const lines = inputField.text.split('\n'); + const currentLine = inputField.getCaretLineIndex(); + if (currentLine < lines.length - 1) { + const column = inputField.getCaretColumn(); + const targetLine = currentLine + 1; + + if (e.shiftKey) { + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.moveCaretToLineColumn(targetLine, column); + inputField.selectionEnd = inputField.caretPosition; + } else { + inputField.moveCaretToLineColumn(targetLine, column); + inputField.clearSelection(); + } + } + } + break; + + case 'Home': + e.preventDefault(); + if (e.shiftKey) { + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.caretPosition = 0; + inputField.selectionEnd = 0; + } else { + inputField.caretPosition = 0; + inputField.clearSelection(); + } + break; + + case 'End': + e.preventDefault(); + if (e.shiftKey) { + if (!inputField.hasSelection()) { + inputField.selectionStart = inputField.caretPosition; + } + inputField.caretPosition = inputField.text.length; + inputField.selectionEnd = inputField.text.length; + } else { + inputField.caretPosition = inputField.text.length; + inputField.clearSelection(); + } + break; + + case 'a': + if (ctrlOrCmd) { + e.preventDefault(); + inputField.selectAll(); + } + break; + + case 'c': + if (ctrlOrCmd && inputField.hasSelection()) { + e.preventDefault(); + const selectedText = inputField.getSelectedText(); + navigator.clipboard?.writeText(selectedText); + } + break; + + case 'x': + if (ctrlOrCmd && inputField.hasSelection()) { + e.preventDefault(); + const selectedText = inputField.getSelectedText(); + navigator.clipboard?.writeText(selectedText); + inputField.deleteSelection(); + } + break; + + case 'v': + if (ctrlOrCmd) { + e.preventDefault(); + navigator.clipboard?.readText().then(text => { + if (text) { + inputField.insertText(text); + } + }); + } + break; + + case 'Enter': + if (inputField.lineType === 'multiLine' || inputField.lineType === 'multiLineSubmit') { + if (inputField.lineType === 'multiLineSubmit' && !e.shiftKey) { + inputField.onSubmit?.(inputField.text); + } else { + e.preventDefault(); + inputField.insertText('\n'); + } + } else { + inputField.onSubmit?.(inputField.text); + } + break; + + case 'Escape': + // 取消焦点 + // Blur focus + this.setFocus(null); + break; + } + } + + private handleInputFieldCharacter(inputField: UIInputFieldComponent, char: string, e: KeyboardEvent): void { + // 验证字符是否符合内容类型 + // Validate character against content type + if (!inputField.validateInput(char)) { + e.preventDefault(); + return; + } + + // 检查字符限制 + // Check character limit + if (inputField.characterLimit > 0 && inputField.text.length >= inputField.characterLimit) { + if (!inputField.hasSelection()) { + e.preventDefault(); + return; + } + } + + e.preventDefault(); + inputField.insertText(char); + } + + private handleDropdownKeyDown(dropdown: UIDropdownComponent, e: KeyboardEvent): void { + const key = e.key; + + switch (key) { + case 'ArrowUp': + e.preventDefault(); + if (dropdown.hoveredOptionIndex > 0) { + dropdown.hoveredOptionIndex--; + } else { + dropdown.hoveredOptionIndex = dropdown.options.length - 1; + } + break; + + case 'ArrowDown': + e.preventDefault(); + if (dropdown.hoveredOptionIndex < dropdown.options.length - 1) { + dropdown.hoveredOptionIndex++; + } else { + dropdown.hoveredOptionIndex = 0; + } + break; + + case 'Enter': + case ' ': + e.preventDefault(); + if (dropdown.hoveredOptionIndex >= 0) { + dropdown.setSelectedIndex(dropdown.hoveredOptionIndex); + dropdown.close(); + this.openDropdown = null; + } + break; + + case 'Escape': + e.preventDefault(); + dropdown.close(); + this.openDropdown = null; + break; + } + } + + // ===== InputField 交互 InputField Interaction ===== + + private handleInputField(entity: Entity, interactable: UIInteractableComponent): void { + const inputField = entity.getComponent(UIInputFieldComponent); + if (!inputField) return; + + const transform = entity.getComponent(UITransformComponent); + if (!transform) return; + + // 更新悬停和聚焦状态 + // Update hover and focused state + inputField.hovered = interactable.hovered; + inputField.focused = this.focusedEntity === entity; + + // 处理点击聚焦和光标定位 + // Handle click to focus and caret positioning + const wasPressed = this.prevMouseButtons[MouseButton.Left]; + const isPressed = this.mouseButtons[MouseButton.Left]; + + // 计算文本区域的起始 X 坐标 + // Calculate text area start X coordinate + const worldX = transform.worldX ?? transform.x; + const worldY = transform.worldY ?? transform.y; + const width = transform.computedWidth ?? transform.width; + const height = transform.computedHeight ?? transform.height; + const pivotX = transform.pivotX; + const pivotY = transform.pivotY; + const textAreaStartX = worldX - width * pivotX + inputField.padding; + const textAreaWidth = width - inputField.padding * 2; + + // 鼠标按下:聚焦并定位光标 | Mouse down: focus and position caret + if (!wasPressed && isPressed && interactable.hovered) { + this.setFocus(entity); + inputField.focused = true; + + const clickX = this.mouseX - textAreaStartX; + const charIndex = inputField.getCharIndexAtX(clickX); + + inputField.caretPosition = charIndex; + inputField.selectionStart = charIndex; + inputField.selectionEnd = charIndex; + inputField.resetCaretBlink(); + + this.inputFieldDragTarget = entity; + this.inputFieldDragStartIndex = charIndex; + } + + // 拖选 | Drag selection + if (isPressed && this.inputFieldDragTarget === entity && inputField.focused) { + const currentX = this.mouseX - textAreaStartX; + const currentIndex = inputField.getCharIndexAtX(currentX); + + inputField.selectionStart = this.inputFieldDragStartIndex; + inputField.selectionEnd = currentIndex; + inputField.caretPosition = currentIndex; + inputField.resetCaretBlink(); + inputField.ensureCaretVisible(textAreaWidth); + } + + // 结束拖选 | End drag + if (!isPressed && this.inputFieldDragTarget === entity) { + this.inputFieldDragTarget = null; + } + + // 光标闪烁 | Caret blink + if (inputField.focused) { + inputField.caretBlinkTimer += Time.deltaTime; + if (inputField.caretBlinkTimer >= inputField.caretBlinkRate) { + inputField.caretBlinkTimer = 0; + inputField.caretVisible = !inputField.caretVisible; + } + } else { + inputField.caretVisible = false; + inputField.caretBlinkTimer = 0; + } + } + + // ===== Dropdown 交互 Dropdown Interaction ===== + + private handleDropdown(entity: Entity, interactable: UIInteractableComponent, transform: UITransformComponent): void { + const dropdown = entity.getComponent(UIDropdownComponent); + if (!dropdown) return; + + // 更新悬停和按下状态 + // Update hover and pressed state + dropdown.hovered = interactable.hovered; + dropdown.pressed = interactable.pressed; + + // 处理点击切换 + // Handle click toggle + const wasPressed = this.prevMouseButtons[MouseButton.Left]; + const isPressed = this.mouseButtons[MouseButton.Left]; + + if (wasPressed && !isPressed) { + if (dropdown.isOpen) { + // 检查是否点击了选项 + // Check if clicked on an option + const optionIndex = this.getDropdownOptionAtPoint(dropdown, transform); + if (optionIndex >= 0) { + dropdown.setSelectedIndex(optionIndex); + dropdown.close(); + this.openDropdown = null; + } else if (!interactable.hovered) { + // 点击外部关闭 + // Click outside to close + dropdown.close(); + this.openDropdown = null; + } + } else if (interactable.hovered && !dropdown.disabled) { + // 点击按钮打开 + // Click button to open + dropdown.open(); + this.openDropdown = entity; + } + } + + // 更新选项悬停 + // Update option hover + if (dropdown.isOpen) { + dropdown.hoveredOptionIndex = this.getDropdownOptionAtPoint(dropdown, transform); + } + } + + private getDropdownOptionAtPoint(dropdown: UIDropdownComponent, transform: UITransformComponent): number { + if (!dropdown.isOpen) return -1; + + const worldX = transform.worldX ?? transform.x; + const worldY = transform.worldY ?? transform.y; + const width = transform.computedWidth ?? transform.width; + const buttonHeight = transform.computedHeight ?? transform.height; + const listTop = worldY - buttonHeight; + const listHeight = dropdown.getListHeight(); + + // 检查是否在列表区域内 + // Check if within list area + if (this.mouseY > listTop || this.mouseY < listTop - listHeight) { + return -1; + } + + if (this.mouseX < worldX || this.mouseX > worldX + width) { + return -1; + } + + // 计算点击的选项索引 + // Calculate clicked option index + const relativeY = listTop - this.mouseY - dropdown.scrollOffset; + const optionIndex = Math.floor(relativeY / dropdown.optionHeight); + + if (optionIndex >= 0 && optionIndex < dropdown.options.length) { + const option = dropdown.options[optionIndex]; + if (!option?.disabled) { + return optionIndex; + } + } + + return -1; + } + protected onDestroy(): void { this.unbind(); } diff --git a/packages/ui/src/systems/UILayoutSystem.ts b/packages/ui/src/systems/UILayoutSystem.ts index 6af85862..dcac2646 100644 --- a/packages/ui/src/systems/UILayoutSystem.ts +++ b/packages/ui/src/systems/UILayoutSystem.ts @@ -1,6 +1,14 @@ -import { EntitySystem, Matcher, Entity, ECSSystem, HierarchyComponent } from '@esengine/ecs-framework'; +import { ECSSystem, Entity, EntitySystem, HierarchyComponent, Matcher } from '@esengine/ecs-framework'; +import { SortingLayers } from '@esengine/engine-core'; +import { UICanvasComponent } from '../components/UICanvasComponent'; +import { UIAlignItems, UIJustifyContent, UILayoutComponent, UILayoutType } from '../components/UILayoutComponent'; import { UITransformComponent } from '../components/UITransformComponent'; -import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from '../components/UILayoutComponent'; +import { getUIRenderCollector } from './render/UIRenderCollector'; + +/** 度转弧度常量 | Degrees to radians constant */ +const DEG_TO_RAD = Math.PI / 180; +/** 弧度转度常量 | Radians to degrees constant */ +const RAD_TO_DEG = 180 / Math.PI; /** * 2D 变换矩阵类型 @@ -15,6 +23,21 @@ interface Matrix2D { ty: number; // translateY } +/** + * Canvas 上下文(用于传播设置给子元素) + * Canvas context (for propagating settings to children) + */ +interface CanvasContext { + /** Canvas 实体 ID | Canvas entity ID */ + entityId: number | null; + /** 排序层 | Sorting layer */ + sortingLayer: string; + /** 基础层内顺序 | Base order in layer */ + baseSortOrder: number; + /** 像素完美 | Pixel perfect */ + pixelPerfect: boolean; +} + /** * UI 布局系统 * UI Layout System - Computes layout for UI elements @@ -28,7 +51,7 @@ interface Matrix2D { * 注意:canvasWidth/canvasHeight 是 UI 设计的参考尺寸,不是实际渲染视口大小 * Note: canvasWidth/canvasHeight is the UI design reference size, not the actual render viewport size */ -@ECSSystem('UILayout') +@ECSSystem('UILayout', { updateOrder: 50, runInEditMode: true }) export class UILayoutSystem extends EntitySystem { /** * UI 画布宽度(设计尺寸) @@ -42,10 +65,28 @@ export class UILayoutSystem extends EntitySystem { */ public canvasHeight: number = 1080; + /** + * 当前帧的实体映射(用于快速查找) + * Entity map for current frame (for fast lookup) + */ + private currentFrameEntityMap: Map = new Map(); + constructor() { super(Matcher.empty().all(UITransformComponent)); } + /** + * 帧开始时调用 + * Called at the start of each frame + * + * 清除 UI 渲染收集器,为本帧的渲染数据做准备 + * Clear the UI render collector to prepare for this frame's render data + */ + protected override onBegin(): void { + const collector = getUIRenderCollector(); + collector.clear(); + } + /** * 设置 UI 画布尺寸(设计尺寸) * Set UI canvas size (design size) @@ -75,12 +116,30 @@ export class UILayoutSystem extends EntitySystem { } protected process(entities: readonly Entity[]): void { + // 构建当前帧的实体映射(用于快速查找,解决第一帧 findEntityById 返回 null 的问题) + // Build entity map for current frame (for fast lookup, fixes findEntityById returning null on first frame) + this.currentFrameEntityMap.clear(); + for (const e of entities) { + this.currentFrameEntityMap.set(e.id, e); + } + // 首先处理根元素(没有父元素的) + // 修复:如果父实体在当前处理的实体集合中,则不是根实体 + // 这解决了第一帧时 findEntityById 可能返回 null 的问题 + // Fix: If parent entity is in current entity set, this is not a root + // This fixes the issue where findEntityById may return null on first frame const rootEntities = entities.filter(e => { const hierarchy = e.getComponent(HierarchyComponent); if (!hierarchy || hierarchy.parentId === null) { return true; } + // 如果父实体在我们的实体集合中,这不是根实体(父实体会递归处理它) + // If parent is in our entity set, this is NOT a root (parent will recursively process this child) + if (this.currentFrameEntityMap.has(hierarchy.parentId)) { + return false; + } + // 如果父实体不在我们的集合中,检查它是否存在于场景中 + // If parent is not in our set, check if it exists in scene const parent = this.scene?.findEntityById(hierarchy.parentId); return !parent || !parent.hasComponent(UITransformComponent); }); @@ -95,8 +154,17 @@ export class UILayoutSystem extends EntitySystem { // 根元素使用单位矩阵作为父矩阵 const identityMatrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }; + // 默认 Canvas 上下文 + // Default Canvas context + const defaultCanvasContext: CanvasContext = { + entityId: null, + sortingLayer: SortingLayers.UI, + baseSortOrder: 0, + pixelPerfect: false + }; + for (const entity of rootEntities) { - this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix, true, 0); + this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix, true, 0, defaultCanvasContext); } } @@ -113,41 +181,70 @@ export class UILayoutSystem extends EntitySystem { parentAlpha: number, parentMatrix: Matrix2D, parentVisible: boolean = true, - depth: number = 0 + depth: number = 0, + canvasContext: CanvasContext ): void { const transform = entity.getComponent(UITransformComponent); if (!transform) return; + // 检查此实体是否有 UICanvasComponent + // Check if this entity has UICanvasComponent + const canvas = entity.getComponent(UICanvasComponent); + let currentCanvasContext = canvasContext; + + if (canvas) { + // 此实体是一个 Canvas,创建新的 Canvas 上下文 + // This entity is a Canvas, create new Canvas context + currentCanvasContext = { + entityId: entity.id, + sortingLayer: canvas.sortingLayerName, + baseSortOrder: canvas.sortOrder, + pixelPerfect: canvas.pixelPerfect + }; + canvas.canvasId = entity.id; + canvas.dirty = false; + } + + // 应用 Canvas 设置到 transform + // Apply Canvas settings to transform + transform.canvasEntityId = currentCanvasContext.entityId; + transform.worldSortingLayer = currentCanvasContext.sortingLayer; + transform.pixelPerfect = currentCanvasContext.pixelPerfect; + // 计算锚点位置 // X 轴:向右为正,anchorMinX=0 是左边,anchorMinX=1 是右边 - // Y 轴:向上为正,anchorMinY=0 是顶部,anchorMinY=1 是底部 + // Y 轴:向上为正,anchorMinY=0 是底部,anchorMinY=1 是顶部 // X axis: right is positive, anchorMinX=0 is left, anchorMinX=1 is right - // Y axis: up is positive, anchorMinY=0 is top, anchorMinY=1 is bottom + // Y axis: up is positive, anchorMinY=0 is bottom, anchorMinY=1 is top const anchorMinX = parentX + parentWidth * transform.anchorMinX; const anchorMaxX = parentX + parentWidth * transform.anchorMaxX; - // Y 轴反转:parentY 是顶部(正值),向下减少 - // Y axis inverted: parentY is top (positive), decreases downward - const anchorMinY = parentY - parentHeight * transform.anchorMinY; - const anchorMaxY = parentY - parentHeight * transform.anchorMaxY; + // parentY 是顶部,anchorMinY=0 对应底部,anchorMinY=1 对应顶部 + // parentY is top, anchorMinY=0 maps to bottom, anchorMinY=1 maps to top + const anchorMinY = parentY - parentHeight * (1 - transform.anchorMinY); + const anchorMaxY = parentY - parentHeight * (1 - transform.anchorMaxY); // 计算元素尺寸 let width: number; let height: number; // 如果锚点 min 和 max 相同,使用固定尺寸 + // If anchor min and max are the same, use fixed size if (transform.anchorMinX === transform.anchorMaxX) { width = transform.width; } else { - // 拉伸模式:尺寸由锚点决定 - width = anchorMaxX - anchorMinX - transform.x; + // 拉伸模式:尺寸 = 锚点区域 + sizeDelta(width 字段存储 sizeDelta) + // Stretch mode: size = anchor area + sizeDelta (width field stores sizeDelta) + const anchorWidth = anchorMaxX - anchorMinX; + width = anchorWidth + transform.width; } if (transform.anchorMinY === transform.anchorMaxY) { height = transform.height; } else { - // 拉伸模式:Y 轴反转,anchorMinY > anchorMaxY - // Stretch mode: Y axis inverted, anchorMinY > anchorMaxY - height = anchorMinY - anchorMaxY - transform.y; + // 拉伸模式:尺寸 = 锚点区域 + sizeDelta(height 字段存储 sizeDelta) + // Stretch mode: size = anchor area + sizeDelta (height field stores sizeDelta) + const anchorHeight = anchorMaxY - anchorMinY; + height = anchorHeight + transform.height; } // 应用尺寸约束 @@ -162,31 +259,28 @@ export class UILayoutSystem extends EntitySystem { let worldY: number; if (transform.anchorMinX === transform.anchorMaxX) { - // 固定锚点模式 - // anchor 位置 + position 偏移 - pivot 偏移 - // 结果是矩形左边缘的 X 坐标 + // 固定锚点模式:anchor 位置 + position 偏移 - pivot 偏移 + // Fixed anchor mode: anchor position + offset - pivot offset worldX = anchorMinX + transform.x - width * transform.pivotX; } else { - // 拉伸模式 - worldX = anchorMinX + transform.x; + // 拉伸模式:anchoredPosition 是相对于锚点中心的偏移 + // Stretch mode: anchoredPosition is offset from anchor center + // pivot 位置 = 锚点中心 + anchoredPosition + // Pivot position = anchor center + anchoredPosition + const anchorCenterX = (anchorMinX + anchorMaxX) / 2; + worldX = anchorCenterX + transform.x - width * transform.pivotX; } if (transform.anchorMinY === transform.anchorMaxY) { - // 固定锚点模式:Y 轴向上 - // Fixed anchor mode: Y axis up - // anchorMinY 是锚点 Y 位置(anchor=0 在顶部,Y=+540) - // position.y 是从锚点的偏移(正值向上) - // pivot 决定元素哪个点对齐到 (anchor + position) - // worldY 是元素底部的 Y 坐标(与 Gizmo origin=(0,0) 对应) - // pivotY=0 意味着元素顶部对齐,pivotY=1 意味着元素底部对齐 - const anchorPosY = anchorMinY + transform.y; // anchor 位置 + 偏移 - // pivotY=0: 顶部对齐,底部 = anchorPos - height - // pivotY=0.5: 中心对齐,底部 = anchorPos - height/2 - // pivotY=1: 底部对齐,底部 = anchorPos - worldY = anchorPosY - height * (1 - transform.pivotY); + // 固定锚点模式:pivotY=0 是底部,pivotY=1 是顶部 + // Fixed anchor mode: pivotY=0 is bottom, pivotY=1 is top + const anchorPosY = anchorMinY + transform.y; + worldY = anchorPosY - height * transform.pivotY; } else { - // 拉伸模式:worldY 是底部 - worldY = anchorMaxY - transform.y; + // 拉伸模式:anchoredPosition 是相对于锚点中心的偏移 + // Stretch mode: anchoredPosition is offset from anchor center + const anchorCenterY = (anchorMinY + anchorMaxY) / 2; + worldY = anchorCenterY + transform.y - height * transform.pivotY; } // 更新布局计算的值 @@ -202,16 +296,19 @@ export class UILayoutSystem extends EntitySystem { // 计算世界层内顺序(子元素总是渲染在父元素之上) // Calculate world order in layer (children always render on top of parents) - // 公式:depth * 1000 + localOrderInLayer - // Formula: depth * 1000 + localOrderInLayer - transform.worldOrderInLayer = depth * 1000 + transform.orderInLayer; + // 公式:canvasBaseSortOrder + depth * 1000 + localOrderInLayer + // Formula: canvasBaseSortOrder + depth * 1000 + localOrderInLayer + transform.worldOrderInLayer = currentCanvasContext.baseSortOrder + depth * 1000 + transform.orderInLayer; + + // 标记布局已计算 | Mark layout as computed + transform.layoutComputed = true; // 使用矩阵乘法计算世界变换 this.updateWorldMatrix(transform, parentMatrix); transform.layoutDirty = false; - // 处理子元素布局 + // 处理子元素布局 | Process child element layout const children = this.getUIChildren(entity); if (children.length === 0) return; @@ -222,7 +319,7 @@ export class UILayoutSystem extends EntitySystem { // 检查是否有布局组件 const layout = entity.getComponent(UILayoutComponent); if (layout && layout.type !== UILayoutType.None) { - this.layoutChildren(layout, transform, children, depth + 1); + this.layoutChildren(layout, transform, children, depth + 1, currentCanvasContext); } else { // 无布局组件,直接递归处理子元素 for (const child of children) { @@ -235,7 +332,8 @@ export class UILayoutSystem extends EntitySystem { transform.worldAlpha, transform.localToWorldMatrix, transform.worldVisible, - depth + 1 + depth + 1, + currentCanvasContext ); } } @@ -249,39 +347,47 @@ export class UILayoutSystem extends EntitySystem { layout: UILayoutComponent, parentTransform: UITransformComponent, children: Entity[], - depth: number + depth: number, + canvasContext: CanvasContext ): void { - const contentStartX = parentTransform.worldX + layout.paddingLeft; + // 父元素的世界坐标在此调用前应已计算,使用 ?? 回退以防万一 + // Parent's world coords should be computed before this call, use ?? fallback just in case + const parentWorldX = parentTransform.worldX ?? parentTransform.x; + const parentWorldY = parentTransform.worldY ?? parentTransform.y; + const parentWidth = parentTransform.computedWidth ?? parentTransform.width; + const parentHeight = parentTransform.computedHeight ?? parentTransform.height; + + const contentStartX = parentWorldX + layout.paddingLeft; // Y-up 系统:worldY 是底部,顶部 = worldY + height // contentStartY 是内容区域的顶部 Y(从顶部减去 paddingTop) - const parentTopY = parentTransform.worldY + parentTransform.computedHeight; + const parentTopY = parentWorldY + parentHeight; const contentStartY = parentTopY - layout.paddingTop; - const contentWidth = parentTransform.computedWidth - layout.getHorizontalPadding(); - const contentHeight = parentTransform.computedHeight - layout.getVerticalPadding(); + const contentWidth = parentWidth - layout.getHorizontalPadding(); + const contentHeight = parentHeight - layout.getVerticalPadding(); switch (layout.type) { case UILayoutType.Horizontal: - this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth); + this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth, canvasContext); break; case UILayoutType.Vertical: - this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth); + this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth, canvasContext); break; case UILayoutType.Grid: - this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth); + this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth, canvasContext); break; default: - // 默认按正常方式递归(传递顶部 Y) for (const child of children) { this.layoutEntity( child, - parentTransform.worldX, + parentWorldX, parentTopY, - parentTransform.computedWidth, - parentTransform.computedHeight, + parentWidth, + parentHeight, parentTransform.worldAlpha, parentTransform.localToWorldMatrix, parentTransform.worldVisible, - depth + depth, + canvasContext ); } } @@ -299,7 +405,8 @@ export class UILayoutSystem extends EntitySystem { startY: number, contentWidth: number, contentHeight: number, - depth: number + depth: number, + canvasContext: CanvasContext ): void { // 计算总子元素宽度 const childSizes = children.map(child => { @@ -378,14 +485,19 @@ export class UILayoutSystem extends EntitySystem { childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; // 传播世界可见性 | Propagate world visibility childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible; - // 计算世界层内顺序 | Calculate world order in layer - childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer; + // 计算世界层内顺序(包含 Canvas 基础排序)| Calculate world order in layer (with Canvas base sort) + childTransform.worldOrderInLayer = canvasContext.baseSortOrder + depth * 1000 + childTransform.orderInLayer; + // 传播 Canvas 设置 | Propagate Canvas settings + childTransform.canvasEntityId = canvasContext.entityId; + childTransform.worldSortingLayer = canvasContext.sortingLayer; + childTransform.pixelPerfect = canvasContext.pixelPerfect; // 使用矩阵乘法计算世界旋转和缩放 this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix); + childTransform.layoutComputed = true; childTransform.layoutDirty = false; // 递归处理子元素的子元素 - this.processChildrenRecursive(child, childTransform, depth); + this.processChildrenRecursive(child, childTransform, depth, canvasContext); offsetX += size.width + gap; } @@ -404,7 +516,8 @@ export class UILayoutSystem extends EntitySystem { startY: number, contentWidth: number, contentHeight: number, - depth: number + depth: number, + canvasContext: CanvasContext ): void { // 计算总子元素高度 const childSizes = children.map(child => { @@ -481,13 +594,18 @@ export class UILayoutSystem extends EntitySystem { childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; // 传播世界可见性 | Propagate world visibility childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible; - // 计算世界层内顺序 | Calculate world order in layer - childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer; + // 计算世界层内顺序(包含 Canvas 基础排序)| Calculate world order in layer (with Canvas base sort) + childTransform.worldOrderInLayer = canvasContext.baseSortOrder + depth * 1000 + childTransform.orderInLayer; + // 传播 Canvas 设置 | Propagate Canvas settings + childTransform.canvasEntityId = canvasContext.entityId; + childTransform.worldSortingLayer = canvasContext.sortingLayer; + childTransform.pixelPerfect = canvasContext.pixelPerfect; // 使用矩阵乘法计算世界旋转和缩放 this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix); + childTransform.layoutComputed = true; childTransform.layoutDirty = false; - this.processChildrenRecursive(child, childTransform, depth); + this.processChildrenRecursive(child, childTransform, depth, canvasContext); // 移动到下一个元素的顶部位置(向下 = Y 减小) currentTopY -= size.height + gap; @@ -507,7 +625,8 @@ export class UILayoutSystem extends EntitySystem { startY: number, contentWidth: number, _contentHeight: number, - depth: number + depth: number, + canvasContext: CanvasContext ): void { const columns = layout.columns; const gapX = layout.getHorizontalGap(); @@ -542,13 +661,18 @@ export class UILayoutSystem extends EntitySystem { childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha; // 传播世界可见性 | Propagate world visibility childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible; - // 计算世界层内顺序 | Calculate world order in layer - childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer; + // 计算世界层内顺序(包含 Canvas 基础排序)| Calculate world order in layer (with Canvas base sort) + childTransform.worldOrderInLayer = canvasContext.baseSortOrder + depth * 1000 + childTransform.orderInLayer; + // 传播 Canvas 设置 | Propagate Canvas settings + childTransform.canvasEntityId = canvasContext.entityId; + childTransform.worldSortingLayer = canvasContext.sortingLayer; + childTransform.pixelPerfect = canvasContext.pixelPerfect; // 使用矩阵乘法计算世界旋转和缩放 this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix); + childTransform.layoutComputed = true; childTransform.layoutDirty = false; - this.processChildrenRecursive(child, childTransform, depth); + this.processChildrenRecursive(child, childTransform, depth, canvasContext); } } @@ -557,6 +681,7 @@ export class UILayoutSystem extends EntitySystem { * Get child entities that have UITransformComponent * * 优先使用 HierarchyComponent,如果没有则返回空数组 + * 优先从当前帧实体映射查找,解决第一帧 findEntityById 返回 null 的问题 */ private getUIChildren(entity: Entity): Entity[] { const hierarchy = entity.getComponent(HierarchyComponent); @@ -573,7 +698,15 @@ export class UILayoutSystem extends EntitySystem { const children: Entity[] = []; for (const childId of hierarchy.childIds) { - const child = this.scene?.findEntityById(childId); + // 优先从当前帧实体映射查找(解决第一帧问题) + // Prefer looking up from current frame entity map (fixes first frame issue) + let child = this.currentFrameEntityMap.get(childId); + const fromMap = !!child; + if (!child) { + // 回退到场景查找 + // Fallback to scene lookup + child = this.scene?.findEntityById(childId) ?? undefined; + } if (child && child.hasComponent(UITransformComponent)) { children.push(child); } @@ -585,28 +718,36 @@ export class UILayoutSystem extends EntitySystem { * 递归处理子元素 * Recursively process children */ - private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent, depth: number): void { + private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent, depth: number, canvasContext: CanvasContext): void { const children = this.getUIChildren(entity); if (children.length === 0) return; + // 父元素的世界坐标在此调用前应已计算,使用 ?? 回退以防万一 + // Parent's world coords should be computed before this call, use ?? fallback just in case + const parentWorldX = parentTransform.worldX ?? parentTransform.x; + const parentWorldY = parentTransform.worldY ?? parentTransform.y; + const parentWidth = parentTransform.computedWidth ?? parentTransform.width; + const parentHeight = parentTransform.computedHeight ?? parentTransform.height; + // 计算子元素的父容器顶部 Y(worldY 是底部,顶部 = 底部 + 高度) - const parentTopY = parentTransform.worldY + parentTransform.computedHeight; + const parentTopY = parentWorldY + parentHeight; const layout = entity.getComponent(UILayoutComponent); if (layout && layout.type !== UILayoutType.None) { - this.layoutChildren(layout, parentTransform, children, depth + 1); + this.layoutChildren(layout, parentTransform, children, depth + 1, canvasContext); } else { for (const child of children) { this.layoutEntity( child, - parentTransform.worldX, + parentWorldX, parentTopY, - parentTransform.computedWidth, - parentTransform.computedHeight, + parentWidth, + parentHeight, parentTransform.worldAlpha, parentTransform.localToWorldMatrix, parentTransform.worldVisible, - depth + 1 + depth + 1, + canvasContext ); } } @@ -648,13 +789,14 @@ export class UILayoutSystem extends EntitySystem { // 构建变换矩阵: Translate(-pivot) -> Scale -> Rotate -> Translate(position + pivot) // 最终矩阵将轴心点作为旋转/缩放中心 + // 顺时针旋转矩阵 | Clockwise rotation matrix return { a: scaleX * cos, - b: scaleX * sin, - c: scaleY * -sin, + b: -scaleX * sin, + c: scaleY * sin, d: scaleY * cos, - tx: x + px - (scaleX * cos * px - scaleY * sin * py), - ty: y + py - (scaleX * sin * px + scaleY * cos * py) + tx: x + px - (scaleX * cos * px + scaleY * sin * py), + ty: y + py - (-scaleX * sin * px + scaleY * cos * py) }; } @@ -704,17 +846,24 @@ export class UILayoutSystem extends EntitySystem { * Update element's world transformation matrix */ private updateWorldMatrix(transform: UITransformComponent, parentMatrix: Matrix2D | null): void { - // 计算本地矩阵 + // 此方法在布局计算后调用,worldX/worldY/computedWidth/Height 应已计算 + // This method is called after layout calculation, worldX/Y/computed values should be ready + const worldX = transform.worldX ?? transform.x; + const worldY = transform.worldY ?? transform.y; + const width = transform.computedWidth ?? transform.width; + const height = transform.computedHeight ?? transform.height; + + // 计算本地矩阵(度转弧度) const localMatrix = this.calculateLocalMatrix( transform.pivotX, transform.pivotY, - transform.computedWidth, - transform.computedHeight, - transform.rotation, + width, + height, + transform.rotation * DEG_TO_RAD, transform.scaleX, transform.scaleY, - transform.worldX, - transform.worldY + worldX, + worldY ); // 计算世界矩阵 @@ -724,9 +873,9 @@ export class UILayoutSystem extends EntitySystem { transform.localToWorldMatrix = localMatrix; } - // 从世界矩阵分解出世界旋转和缩放 + // 从世界矩阵分解出世界旋转和缩放(弧度转度) const decomposed = this.decomposeMatrix(transform.localToWorldMatrix); - transform.worldRotation = decomposed.rotation; + transform.worldRotation = decomposed.rotation * RAD_TO_DEG; transform.worldScaleX = decomposed.scaleX; transform.worldScaleY = decomposed.scaleY; } diff --git a/packages/ui/src/systems/UISelectableStateSystem.ts b/packages/ui/src/systems/UISelectableStateSystem.ts new file mode 100644 index 00000000..cc4c6785 --- /dev/null +++ b/packages/ui/src/systems/UISelectableStateSystem.ts @@ -0,0 +1,92 @@ +/** + * UI Selectable State System + * UI 可选择状态系统 + * + * Manages interaction states and color transitions for entities with UISelectableComponent. + * Provides unified state management for all interactive UI elements. + * + * 管理带有 UISelectableComponent 实体的交互状态和颜色过渡。 + * 为所有交互式 UI 元素提供统一的状态管理。 + * + * @example + * ```typescript + * // Add system to scene + * scene.addSystem(new UISelectableStateSystem()); + * + * // Create selectable element + * const entity = scene.createEntity('myButton'); + * entity.addComponent(new UITransformComponent()); + * entity.addComponent(new UIInteractableComponent()); + * const selectable = entity.addComponent(new UISelectableComponent()); + * selectable.transition = 'colorTint'; + * selectable.highlightedColor = 0xFFFF00; + * + * // In render system, use selectable.currentColor for rendering + * ``` + */ + +import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-framework'; +import { UISelectableComponent } from '../components/base'; +import { UIInteractableComponent } from '../components/UIInteractableComponent'; + +/** + * UI Selectable State System + * UI 可选择状态系统 + * + * Handles: + * - Syncing UISelectableComponent state with UIInteractableComponent + * - Color transitions for colorTint mode + * - State change detection and callbacks + * + * 处理: + * - 将 UISelectableComponent 状态与 UIInteractableComponent 同步 + * - colorTint 模式的颜色过渡 + * - 状态变化检测和回调 + */ +@ECSSystem('UISelectableState', { updateOrder: 45 }) +export class UISelectableStateSystem extends EntitySystem { + constructor() { + super(Matcher.empty().all(UISelectableComponent, UIInteractableComponent)); + } + + protected process(entities: readonly Entity[]): void { + const dt = Time.deltaTime; + + for (const entity of entities) { + const selectable = entity.getComponent(UISelectableComponent); + const interactable = entity.getComponent(UIInteractableComponent); + + if (!selectable || !interactable) continue; + + // Sync state from UIInteractableComponent + // 从 UIInteractableComponent 同步状态 + this.syncState(selectable, interactable); + + // Update color transition + // 更新颜色过渡 + selectable.updateTransition(dt); + } + } + + /** + * Sync selectable state with interactable state + * 将可选择状态与可交互状态同步 + */ + private syncState(selectable: UISelectableComponent, interactable: UIInteractableComponent): void { + // Update interactable (disabled) state based on enabled flag + // 根据 enabled 标志更新可交互(禁用)状态 + selectable.interactable = interactable.enabled; + + // Update pointer over state + // 更新指针悬停状态 + selectable.setPointerOver(interactable.hovered); + + // Update pressed state + // 更新按下状态 + selectable.setPressed(interactable.pressed); + + // Update selected state (keyboard navigation / focus) + // 更新选中状态(键盘导航/焦点) + selectable.setSelected(interactable.focused); + } +} diff --git a/packages/ui/src/systems/UISliderFillSystem.ts b/packages/ui/src/systems/UISliderFillSystem.ts new file mode 100644 index 00000000..b38c0671 --- /dev/null +++ b/packages/ui/src/systems/UISliderFillSystem.ts @@ -0,0 +1,119 @@ +/** + * UI Slider Fill Control System + * UI 滑块填充控制系统 + * + * Runs BEFORE UILayoutSystem to modify Fill entity's anchors based on slider progress. + * This allows UILayoutSystem to correctly compute Fill's position and size. + * + * 在 UILayoutSystem 之前运行,根据滑块进度修改 Fill 实体的锚点。 + * 这样 UILayoutSystem 可以正确计算 Fill 的位置和尺寸。 + */ + +import { EntitySystem, Matcher, Entity, ECSSystem, Core } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../components/UITransformComponent'; +import { UISliderComponent, UISliderOrientation } from '../components/widgets/UISliderComponent'; + +/** + * UI Slider Fill Control System + * UI 滑块填充控制系统 + * + * Controls the Fill entity's anchor to reflect slider progress. + * Must run before UILayoutSystem (updateOrder < 0). + * + * 控制 Fill 实体的锚点以反映滑块进度。 + * 必须在 UILayoutSystem 之前运行(updateOrder < 0)。 + */ +@ECSSystem('UISliderFill', { updateOrder: -10, runInEditMode: true }) +export class UISliderFillSystem extends EntitySystem { + constructor() { + super(Matcher.empty().all(UITransformComponent, UISliderComponent)); + } + + protected process(entities: readonly Entity[]): void { + const scene = Core.scene; + if (!scene) return; + + for (const entity of entities) { + const slider = entity.getComponent(UISliderComponent); + if (!slider || slider.fillRectEntityId <= 0) continue; + + const fillEntity = scene.entities.findEntityById(slider.fillRectEntityId); + if (!fillEntity) continue; + + const fillTransform = fillEntity.getComponent(UITransformComponent); + if (!fillTransform) continue; + + const progress = slider.getProgress(); + const isHorizontal = slider.orientation === UISliderOrientation.Horizontal; + + if (isHorizontal) { + // For horizontal slider: + // - X: anchorMinX=0, anchorMaxX=progress (stretch by progress) + // - Y: anchorMinY=0, anchorMaxY=1 (full vertical stretch in parent) + // 水平滑块: + // - X:anchorMinX=0, anchorMaxX=progress(按进度拉伸) + // - Y:anchorMinY=0, anchorMaxY=1(在父容器内垂直完全拉伸) + const targetAnchorMaxX = progress; + + // Check if any anchor needs update + // 检查是否有锚点需要更新 + const needsUpdate = + Math.abs(fillTransform.anchorMaxX - targetAnchorMaxX) > 0.0001 || + fillTransform.anchorMinX !== 0 || + fillTransform.anchorMinY !== 0 || + fillTransform.anchorMaxY !== 1; + + if (needsUpdate) { + // X axis: stretch from left to progress + // X 轴:从左边拉伸到进度位置 + fillTransform.anchorMinX = 0; + fillTransform.anchorMaxX = targetAnchorMaxX; + + // Y axis: full stretch within parent (Fill Area) + // Y 轴:在父容器(Fill Area)内完全拉伸 + fillTransform.anchorMinY = 0; + fillTransform.anchorMaxY = 1; + + // For stretch mode, size stores sizeDelta (usually 0) + // 拉伸模式下,尺寸存储 sizeDelta(通常为 0) + fillTransform.width = 0; + fillTransform.height = 0; + + // Mark as dirty for UILayoutSystem + // 标记为脏,让 UILayoutSystem 重新计算 + fillTransform.layoutDirty = true; + } + } else { + // For vertical slider: + // - Y: anchorMinY=0, anchorMaxY=progress (stretch by progress) + // - X: anchorMinX=0, anchorMaxX=1 (full horizontal stretch in parent) + // 垂直滑块: + // - Y:anchorMinY=0, anchorMaxY=progress(按进度拉伸) + // - X:anchorMinX=0, anchorMaxX=1(在父容器内水平完全拉伸) + const targetAnchorMaxY = progress; + + const needsUpdate = + Math.abs(fillTransform.anchorMaxY - targetAnchorMaxY) > 0.0001 || + fillTransform.anchorMinY !== 0 || + fillTransform.anchorMinX !== 0 || + fillTransform.anchorMaxX !== 1; + + if (needsUpdate) { + // Y axis: stretch from bottom to progress + // Y 轴:从底部拉伸到进度位置 + fillTransform.anchorMinY = 0; + fillTransform.anchorMaxY = targetAnchorMaxY; + + // X axis: full stretch within parent (Fill Area) + // X 轴:在父容器(Fill Area)内完全拉伸 + fillTransform.anchorMinX = 0; + fillTransform.anchorMaxX = 1; + + fillTransform.width = 0; + fillTransform.height = 0; + fillTransform.layoutDirty = true; + } + } + } + } +} diff --git a/packages/ui/src/systems/render/UIButtonRenderSystem.ts b/packages/ui/src/systems/render/UIButtonRenderSystem.ts index 05b511f9..c29f2d1f 100644 --- a/packages/ui/src/systems/render/UIButtonRenderSystem.ts +++ b/packages/ui/src/systems/render/UIButtonRenderSystem.ts @@ -8,10 +8,13 @@ */ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { getTextureSpriteInfo } from '@esengine/asset-system'; import { UITransformComponent } from '../../components/UITransformComponent'; import { UIButtonComponent } from '../../components/widgets/UIButtonComponent'; import { UIRenderComponent } from '../../components/UIRenderComponent'; +import { UIInteractableComponent } from '../../components/UIInteractableComponent'; import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, renderBorder, getNinePatchPosition } from './UIRenderUtils'; /** * UI Button Render System @@ -30,7 +33,7 @@ import { getUIRenderCollector } from './UIRenderCollector'; * Note: Button text is rendered by UITextRenderSystem if UITextComponent is present. * 注意:如果存在 UITextComponent,按钮文本由 UITextRenderSystem 渲染。 */ -@ECSSystem('UIButtonRender', { updateOrder: 113 }) +@ECSSystem('UIButtonRender', { updateOrder: 113, runInEditMode: true }) export class UIButtonRenderSystem extends EntitySystem { constructor() { super(Matcher.empty().all(UITransformComponent, UIButtonComponent)); @@ -47,46 +50,97 @@ export class UIButtonRenderSystem extends EntitySystem { // 空值检查 | Null check if (!transform || !button) continue; - if (!transform.worldVisible) continue; + // 确保添加 UIWidgetMarker 以便 UIRectRenderSystem 跳过此实体 + // Ensure UIWidgetMarker is added so UIRectRenderSystem skips this entity + ensureUIWidgetMarker(entity); - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放和旋转 - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const rotation = transform.worldRotation ?? transform.rotation; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 作为旋转/缩放中心 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; + // 初始化 currentColor 和 targetColor(编辑器预览模式需要) + // Initialize currentColor and targetColor (needed for editor preview mode) + if (!button._colorInitialized) { + button.currentColor = button.normalColor; + button.targetColor = button.normalColor; + button._colorInitialized = true; + } + + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; // Render texture if in texture or both mode // 如果在纹理或两者模式下,渲染纹理 if (button.useTexture()) { - const textureGuid = button.getStateTextureGuid('normal'); + // 根据交互状态获取正确的纹理 + // Get correct texture based on interaction state + const interactable = entity.getComponent(UIInteractableComponent); + const state = interactable?.getState() ?? 'normal'; + const textureGuid = button.getStateTextureGuid(state); + if (textureGuid) { - collector.addRect( - renderX, renderY, - width, height, - 0xFFFFFF, // White tint for texture - alpha, - sortingLayer, - orderInLayer, - { - rotation, - pivotX, - pivotY, - textureGuid + // 使用按钮的当前颜色作为纹理着色(Color Tint Transition) + // Use button's current color as texture tint (Color Tint Transition) + const textureTint = button.currentColor; + + // Try to get nine-patch info from texture's sprite settings + // 尝试从纹理的 sprite 设置获取九宫格信息 + let isNinePatch = false; + let ninePatchMargins: [number, number, number, number] | undefined; + let textureWidth = 0; + let textureHeight = 0; + + const spriteInfo = getTextureSpriteInfo(textureGuid); + if (spriteInfo) { + if (spriteInfo.width !== undefined && spriteInfo.height !== undefined) { + textureWidth = spriteInfo.width; + textureHeight = spriteInfo.height; } - ); + if (spriteInfo.sliceBorder) { + ninePatchMargins = spriteInfo.sliceBorder; + isNinePatch = true; + } + } + + if (isNinePatch && ninePatchMargins && textureWidth > 0 && textureHeight > 0) { + // Nine-patch rendering for buttons (using utility) + // 按钮的九宫格渲染(使用工具函数) + const pos = getNinePatchPosition(rt); + collector.addNinePatch( + pos.x, pos.y, + rt.width, rt.height, + ninePatchMargins, + textureWidth, + textureHeight, + textureTint, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: pos.pivotX, + pivotY: pos.pivotY, + textureGuid, + entityId: entity.id + } + ); + } else { + // Standard texture rendering + // 标准纹理渲染 + collector.addRect( + rt.renderX, rt.renderY, + rt.width, rt.height, + textureTint, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureGuid, + entityId: entity.id + } + ); + } } } @@ -96,94 +150,31 @@ export class UIButtonRenderSystem extends EntitySystem { const bgAlpha = render?.backgroundAlpha ?? 1; if (bgAlpha > 0) { collector.addRect( - renderX, renderY, - width, height, + rt.renderX, rt.renderY, + rt.width, rt.height, button.currentColor, - bgAlpha * alpha, - sortingLayer, - orderInLayer + (button.useTexture() ? 1 : 0), + bgAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + (button.useTexture() ? 1 : 0), { - rotation, - pivotX, - pivotY + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId: entity.id } ); } } - // Render border if UIRenderComponent has border - // 如果 UIRenderComponent 有边框,渲染边框 + // Render border if UIRenderComponent has border (using utility) + // 如果 UIRenderComponent 有边框,渲染边框(使用工具函数) if (render && render.borderWidth > 0 && render.borderAlpha > 0) { - this.renderBorder( - collector, - renderX, renderY, width, height, - render.borderWidth, - render.borderColor, - render.borderAlpha * alpha, - sortingLayer, - orderInLayer + 2, - rotation, - pivotX, - pivotY - ); + renderBorder(collector, rt, { + borderWidth: render.borderWidth, + borderColor: render.borderColor, + borderAlpha: render.borderAlpha + }, entity.id, 2); } } } - - /** - * Render border using pivot-based coordinates - * 使用基于 pivot 的坐标渲染边框 - */ - private renderBorder( - collector: ReturnType, - centerX: number, centerY: number, - width: number, height: number, - borderWidth: number, - borderColor: number, - alpha: number, - sortingLayer: string, - orderInLayer: number, - rotation: number, - pivotX: number, - pivotY: number - ): void { - // 计算矩形的边界(相对于 pivot 中心) - const left = centerX - width * pivotX; - const bottom = centerY - height * pivotY; - const right = left + width; - const top = bottom + height; - - // Top border - collector.addRect( - (left + right) / 2, top - borderWidth / 2, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Bottom border - collector.addRect( - (left + right) / 2, bottom + borderWidth / 2, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Left border (excluding corners) - const sideBorderHeight = height - borderWidth * 2; - collector.addRect( - left + borderWidth / 2, (top + bottom) / 2, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Right border (excluding corners) - collector.addRect( - right - borderWidth / 2, (top + bottom) / 2, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - } } diff --git a/packages/ui/src/systems/render/UIDropdownRenderSystem.ts b/packages/ui/src/systems/render/UIDropdownRenderSystem.ts new file mode 100644 index 00000000..82bf8bb0 --- /dev/null +++ b/packages/ui/src/systems/render/UIDropdownRenderSystem.ts @@ -0,0 +1,240 @@ +/** + * UI Dropdown Render System + * UI 下拉菜单渲染系统 + * + * Renders UIDropdownComponent entities by submitting render primitives + * to the shared UIRenderCollector. + * 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIDropdownComponent 实体。 + */ + +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../../components/UITransformComponent'; +import { UIDropdownComponent } from '../../components/widgets/UIDropdownComponent'; +import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, renderBorder } from './UIRenderUtils'; + +/** + * UI Dropdown Render System + * UI 下拉菜单渲染系统 + * + * Handles rendering of dropdown components including: + * - Button background with current selection + * - Dropdown arrow indicator + * - Expanded option list (when open) + * - Option hover states + * + * 处理下拉菜单组件的渲染,包括: + * - 带当前选择的按钮背景 + * - 下拉箭头指示器 + * - 展开的选项列表(打开时) + * - 选项悬停状态 + */ +@ECSSystem('UIDropdownRender', { updateOrder: 116, runInEditMode: true }) +export class UIDropdownRenderSystem extends EntitySystem { + constructor() { + super(Matcher.empty().all(UITransformComponent, UIDropdownComponent)); + } + + protected process(entities: readonly Entity[]): void { + const collector = getUIRenderCollector(); + + for (const entity of entities) { + const transform = entity.getComponent(UITransformComponent); + const dropdown = entity.getComponent(UIDropdownComponent); + + // 空值检查 | Null check + if (!transform || !dropdown) continue; + + // 确保添加 UIWidgetMarker + // Ensure UIWidgetMarker is added + ensureUIWidgetMarker(entity); + + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; + + const entityId = entity.id; + + // 1. 渲染按钮背景 + // 1. Render button background + collector.addRect( + rt.renderX, rt.renderY, + rt.width, rt.height, + dropdown.currentColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); + + // 2. 渲染边框 + // 2. Render border + if (dropdown.borderWidth > 0) { + renderBorder(collector, rt, { + borderWidth: dropdown.borderWidth, + borderColor: dropdown.borderColor, + borderAlpha: rt.alpha + }, entityId, 1); + } + + // 3. 渲染下拉箭头 + // 3. Render dropdown arrow + this.renderArrow(collector, rt, dropdown, entityId); + + // 4. 如果打开,渲染下拉列表 + // 4. If open, render dropdown list + if (dropdown.isOpen) { + this.renderDropdownList(collector, rt, dropdown, entityId); + } + + // Note: Text rendering is handled by UITextRenderSystem + // 注意:文本渲染由 UITextRenderSystem 处理 + } + } + + /** + * 渲染下拉箭头 + * Render dropdown arrow + */ + private renderArrow( + collector: ReturnType, + rt: NonNullable>, + dropdown: UIDropdownComponent, + entityId: number + ): void { + const arrowSize = 8; + const arrowX = rt.renderX + rt.width * (1 - rt.pivotX) - dropdown.padding - arrowSize / 2; + const arrowY = rt.renderY + rt.height * (0.5 - rt.pivotY); + + // 简化的箭头渲染(使用小矩形模拟) + // Simplified arrow rendering (using small rectangles) + // 向下箭头由两条斜线组成 + // Down arrow made of two lines + + // 左斜线 | Left line + collector.addRect( + arrowX - 2, arrowY, + arrowSize * 0.7, 2, + dropdown.arrowColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + rotation: dropdown.isOpen ? -0.785 : 0.785, // 45 degrees + pivotX: 0, + pivotY: 0.5, + entityId + } + ); + + // 右斜线 | Right line + collector.addRect( + arrowX + 2, arrowY, + arrowSize * 0.7, 2, + dropdown.arrowColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + rotation: dropdown.isOpen ? 0.785 : -0.785, // -45 degrees + pivotX: 1, + pivotY: 0.5, + entityId + } + ); + } + + /** + * 渲染下拉列表 + * Render dropdown list + */ + private renderDropdownList( + collector: ReturnType, + rt: NonNullable>, + dropdown: UIDropdownComponent, + entityId: number + ): void { + const listHeight = dropdown.getListHeight(); + const listY = rt.renderY - rt.height * rt.pivotY - listHeight; + + // 列表背景 + // List background + collector.addRect( + rt.renderX, listY + listHeight / 2, + rt.width, listHeight, + dropdown.listBackgroundColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 10, + { + pivotX: rt.pivotX, + pivotY: 0.5, + entityId + } + ); + + // 列表边框 + // List border + if (dropdown.borderWidth > 0) { + const listRt = { + ...rt, + renderX: rt.renderX, + renderY: listY + listHeight / 2, + height: listHeight, + pivotY: 0.5 + }; + renderBorder(collector, listRt as typeof rt, { + borderWidth: dropdown.borderWidth, + borderColor: dropdown.borderColor, + borderAlpha: rt.alpha + }, entityId, 11); + } + + // 渲染可见选项 + // Render visible options + const visibleCount = Math.min(dropdown.options.length, dropdown.maxVisibleOptions); + const startIndex = Math.floor(dropdown.scrollOffset / dropdown.optionHeight); + + for (let i = 0; i < visibleCount; i++) { + const optionIndex = startIndex + i; + if (optionIndex >= dropdown.options.length) break; + + const option = dropdown.options[optionIndex]; + const optionY = listY + listHeight - (i + 0.5) * dropdown.optionHeight; + + // 选项背景色 + // Option background color + let bgColor = dropdown.listBackgroundColor; + if (optionIndex === dropdown.selectedIndex) { + bgColor = dropdown.selectedOptionColor; + } else if (optionIndex === dropdown.hoveredOptionIndex) { + bgColor = dropdown.optionHoverColor; + } + + if (bgColor !== dropdown.listBackgroundColor) { + collector.addRect( + rt.renderX, optionY, + rt.width - dropdown.borderWidth * 2, dropdown.optionHeight, + bgColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 12, + { + pivotX: rt.pivotX, + pivotY: 0.5, + entityId + } + ); + } + + // Note: Option text is rendered by UITextRenderSystem + // 注意:选项文本由 UITextRenderSystem 渲染 + } + } +} diff --git a/packages/ui/src/systems/render/UIGraphicRenderSystem.ts b/packages/ui/src/systems/render/UIGraphicRenderSystem.ts new file mode 100644 index 00000000..2b474368 --- /dev/null +++ b/packages/ui/src/systems/render/UIGraphicRenderSystem.ts @@ -0,0 +1,387 @@ +/** + * UI Graphic Render System + * UI 图形渲染系统 + * + * Renders entities with the new base components (UIGraphicComponent, UIImageComponent). + * This system follows the new architecture pattern with clearer component separation. + * + * 渲染使用新基础组件(UIGraphicComponent、UIImageComponent)的实体。 + * 此系统遵循新架构模式,组件职责分离更清晰。 + */ + +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../../components/UITransformComponent'; +import { UIGraphicComponent } from '../../components/base/UIGraphicComponent'; +import { UIImageComponent } from '../../components/base/UIImageComponent'; +import { getUIRenderCollector } from './UIRenderCollector'; +import { getUIRenderTransform, getNinePatchPosition, type UIRenderTransform } from './UIRenderUtils'; +import { isValidTextureGuid, defaultUV } from '../../utils/UITextureUtils'; + +/** + * UI Graphic Render System + * UI 图形渲染系统 + * + * Handles rendering of the new base graphic components: + * - UIGraphicComponent: Base visual element (color rectangle) + * - UIImageComponent: Texture display (simple, sliced, tiled, filled) + * + * 处理新基础图形组件的渲染: + * - UIGraphicComponent:基础可视元素(颜色矩形) + * - UIImageComponent:纹理显示(简单、切片、平铺、填充) + */ +@ECSSystem('UIGraphicRender', { updateOrder: 102, runInEditMode: true }) +export class UIGraphicRenderSystem extends EntitySystem { + constructor() { + // Match entities with UITransformComponent and UIGraphicComponent + // 匹配具有 UITransformComponent 和 UIGraphicComponent 的实体 + super(Matcher.empty().all(UITransformComponent, UIGraphicComponent)); + } + + protected process(entities: readonly Entity[]): void { + const collector = getUIRenderCollector(); + + for (const entity of entities) { + const transform = entity.getComponent(UITransformComponent); + const graphic = entity.getComponent(UIGraphicComponent); + + if (!transform || !graphic) continue; + + // Get render transform data + // 获取渲染变换数据 + const rt = getUIRenderTransform(transform); + if (!rt) continue; + + // Check if entity has UIImageComponent for texture rendering + // 检查实体是否有 UIImageComponent 用于纹理渲染 + const image = entity.getComponent(UIImageComponent); + + if (image && image.hasTexture()) { + this.renderImage(collector, rt, graphic, image, entity.id); + } else { + this.renderColorRect(collector, rt, graphic, entity.id); + } + + // Mark graphic as rendered (clear dirty flag) + // 标记图形已渲染(清除脏标记) + graphic.clearDirtyFlags(); + } + } + + /** + * Render a color rectangle + * 渲染颜色矩形 + */ + private renderColorRect( + collector: ReturnType, + rt: UIRenderTransform, + graphic: UIGraphicComponent, + entityId: number + ): void { + collector.addRect( + rt.renderX, rt.renderY, + rt.width, rt.height, + graphic.color, + graphic.alpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + materialId: graphic.materialId > 0 ? graphic.materialId : undefined, + entityId + } + ); + } + + /** + * Render an image with various modes + * 渲染各种模式的图像 + */ + private renderImage( + collector: ReturnType, + rt: UIRenderTransform, + graphic: UIGraphicComponent, + image: UIImageComponent, + entityId: number + ): void { + const alpha = graphic.alpha * rt.alpha; + const color = graphic.color; + const materialId = graphic.materialId > 0 ? graphic.materialId : undefined; + + // Get validated texture GUID + // 获取验证后的纹理 GUID + const textureGuid = isValidTextureGuid(image.textureGuid) ? image.textureGuid : undefined; + + // Handle different image types + // 处理不同的图像类型 + if (image.isSliced()) { + // Nine-patch (sliced) rendering + // 九宫格(切片)渲染 + const pos = getNinePatchPosition(rt); + collector.addNinePatch( + pos.x, pos.y, + rt.width, rt.height, + image.sliceBorder, + image.textureWidth, + image.textureHeight, + color, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: pos.pivotX, + pivotY: pos.pivotY, + textureGuid, + textureId: image.textureId, + materialId, + entityId + } + ); + } else if (image.isFilled()) { + // Filled rendering (for progress bars, etc.) + // 填充渲染(用于进度条等) + this.renderFilledImage(collector, rt, graphic, image, entityId); + } else { + // Simple image rendering + // 简单图像渲染 + collector.addRect( + rt.renderX, rt.renderY, + rt.width, rt.height, + color, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureGuid, + textureId: image.textureId, + uv: image.uv ?? defaultUV(), + materialId, + entityId + } + ); + } + } + + /** + * Render a filled image (horizontal/vertical fill) + * 渲染填充图像(水平/垂直填充) + */ + private renderFilledImage( + collector: ReturnType, + rt: UIRenderTransform, + graphic: UIGraphicComponent, + image: UIImageComponent, + entityId: number + ): void { + const alpha = graphic.alpha * rt.alpha; + const color = graphic.color; + const materialId = graphic.materialId > 0 ? graphic.materialId : undefined; + const textureGuid = isValidTextureGuid(image.textureGuid) ? image.textureGuid : undefined; + + // Calculate filled dimensions based on fillAmount and fillMethod + // 根据 fillAmount 和 fillMethod 计算填充尺寸 + let fillWidth = rt.width; + let fillHeight = rt.height; + let fillX = rt.renderX; + let fillY = rt.renderY; + let fillU0 = 0, fillV0 = 0, fillU1 = 1, fillV1 = 1; + + const fillAmount = Math.max(0, Math.min(1, image.fillAmount)); + + switch (image.fillMethod) { + case 'horizontal': + if (image.fillOrigin === 'left' || image.fillOrigin === 'center') { + fillWidth = rt.width * fillAmount; + fillU1 = fillAmount; + } else { + // Right origin + fillWidth = rt.width * fillAmount; + fillX = rt.renderX + rt.width * (1 - fillAmount) * (1 - rt.pivotX); + fillU0 = 1 - fillAmount; + } + break; + + case 'vertical': + if (image.fillOrigin === 'bottom' || image.fillOrigin === 'center') { + fillHeight = rt.height * fillAmount; + fillV1 = fillAmount; + } else { + // Top origin + fillHeight = rt.height * fillAmount; + fillY = rt.renderY + rt.height * (1 - fillAmount) * rt.pivotY; + fillV0 = 1 - fillAmount; + } + break; + + // Radial fill modes - approximate with multiple segments + // 径向填充模式 - 使用多个分段近似 + case 'radial90': + case 'radial180': + case 'radial360': + this.renderRadialFill(collector, rt, graphic, image, entityId); + return; // Early return - radial fill handles its own rendering + } + + // Apply original UV mapping if present + // 如果存在原始 UV 映射,应用它 + if (image.uv) { + const [u0, v0, u1, v1] = image.uv; + const uvWidth = u1 - u0; + const uvHeight = v1 - v0; + fillU0 = u0 + fillU0 * uvWidth; + fillV0 = v0 + fillV0 * uvHeight; + fillU1 = u0 + fillU1 * uvWidth; + fillV1 = v0 + fillV1 * uvHeight; + } + + collector.addRect( + fillX, fillY, + fillWidth, fillHeight, + color, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureGuid, + textureId: image.textureId, + uv: [fillU0, fillV0, fillU1, fillV1], + materialId, + entityId + } + ); + } + + /** + * Render radial fill using multiple quad segments + * 使用多个矩形分段渲染径向填充 + * + * This approximates a pie-shaped fill by rendering multiple narrow quads + * that fan out from the center. + * 通过渲染多个从中心扇形展开的窄矩形来近似饼形填充。 + */ + private renderRadialFill( + collector: ReturnType, + rt: UIRenderTransform, + graphic: UIGraphicComponent, + image: UIImageComponent, + entityId: number + ): void { + const alpha = graphic.alpha * rt.alpha; + const color = graphic.color; + const materialId = graphic.materialId > 0 ? graphic.materialId : undefined; + const textureGuid = isValidTextureGuid(image.textureGuid) ? image.textureGuid : undefined; + const fillAmount = Math.max(0, Math.min(1, image.fillAmount)); + + if (fillAmount <= 0) return; + + // Determine the total angle range based on fill method + // 根据填充方法确定总角度范围 + let totalAngle: number; + switch (image.fillMethod) { + case 'radial90': totalAngle = Math.PI / 2; break; + case 'radial180': totalAngle = Math.PI; break; + case 'radial360': totalAngle = Math.PI * 2; break; + default: return; + } + + // Calculate fill angle + // 计算填充角度 + const fillAngle = totalAngle * fillAmount; + + // Determine start angle based on origin + // 根据起点确定起始角度 + let startAngle: number; + switch (image.fillOrigin) { + case 'top': startAngle = -Math.PI / 2; break; + case 'right': startAngle = 0; break; + case 'bottom': startAngle = Math.PI / 2; break; + case 'left': startAngle = Math.PI; break; + default: startAngle = -Math.PI / 2; break; // Default: top + } + + // Direction: clockwise or counter-clockwise + // 方向:顺时针或逆时针 + const direction = image.fillClockwise ? 1 : -1; + + // Calculate center and radius + // 计算中心和半径 + const centerX = rt.x + rt.width / 2; + const centerY = rt.y + rt.height / 2; + const radiusX = rt.width / 2; + const radiusY = rt.height / 2; + + // Number of segments for smooth appearance (more segments = smoother) + // 分段数量(更多分段 = 更平滑) + const numSegments = Math.max(4, Math.ceil(fillAngle * 16 / Math.PI)); + + // Render segments as quads from center + // 从中心渲染分段为矩形 + const angleStep = fillAngle / numSegments; + + for (let i = 0; i < numSegments; i++) { + const angle1 = startAngle + direction * angleStep * i; + const angle2 = startAngle + direction * angleStep * (i + 1); + + // Calculate quad corners + // 计算矩形角点 + const cos1 = Math.cos(angle1); + const sin1 = Math.sin(angle1); + const cos2 = Math.cos(angle2); + const sin2 = Math.sin(angle2); + + // For each segment, render a triangle-like quad + // 对于每个分段,渲染一个类似三角形的矩形 + // We approximate by rendering a small rect at the outer edge + // 我们通过在外边缘渲染一个小矩形来近似 + + // Calculate midpoint of the arc segment + // 计算弧段的中点 + const midAngle = (angle1 + angle2) / 2; + const midCos = Math.cos(midAngle); + const midSin = Math.sin(midAngle); + + // Segment width and position + // 分段宽度和位置 + const segmentWidth = Math.abs(radiusX * (cos2 - cos1)) + Math.abs(radiusY * (sin2 - sin1)); + const segmentHeight = Math.sqrt(radiusX * radiusX + radiusY * radiusY); + + // Position at the midpoint direction from center + // 从中心沿中点方向定位 + const segX = centerX + midCos * radiusX * 0.5; + const segY = centerY + midSin * radiusY * 0.5; + + // Calculate UV for this segment + // 计算此分段的 UV + const u0 = 0.5 + midCos * 0.5 * fillAmount; + const v0 = 0.5 + midSin * 0.5 * fillAmount; + + collector.addRect( + segX, segY, + Math.max(2, segmentWidth + 2), // Ensure minimum width with overlap + segmentHeight * 0.55, // Slightly more than half to ensure coverage + color, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: midAngle + Math.PI / 2, // Rotate to face outward + pivotX: 0.5, + pivotY: 0, + textureGuid, + textureId: image.textureId, + uv: [u0 - 0.1, v0 - 0.1, u0 + 0.1, v0 + 0.1], + materialId, + entityId + } + ); + } + } +} diff --git a/packages/ui/src/systems/render/UIInputFieldRenderSystem.ts b/packages/ui/src/systems/render/UIInputFieldRenderSystem.ts new file mode 100644 index 00000000..344ea442 --- /dev/null +++ b/packages/ui/src/systems/render/UIInputFieldRenderSystem.ts @@ -0,0 +1,424 @@ +/** + * UI InputField Render System + * UI 输入框渲染系统 + * + * Renders UIInputFieldComponent entities by submitting render primitives + * to the shared UIRenderCollector. + * 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIInputFieldComponent 实体。 + */ + +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../../components/UITransformComponent'; +import { UIInputFieldComponent } from '../../components/widgets/UIInputFieldComponent'; +import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector'; +import { getUIRenderTransform } from './UIRenderUtils'; + +/** + * Text texture cache entry + * 文本纹理缓存条目 + */ +interface InputTextCache { + textureId: number; + text: string; + isPlaceholder: boolean; + fontSize: number; + fontFamily: string; + fontWeight: string; + color: number; + width: number; + height: number; + dataUrl: string; +} + +/** + * UI InputField Render System + * UI 输入框渲染系统 + * + * Handles rendering of input field components: + * - Text / Placeholder display + * - Selection highlight + * - Caret (blinking cursor) + * + * Note: Background and border are rendered by UIRender/UIGraphic component. + * + * 处理输入框组件的渲染: + * - 文本/占位符显示 + * - 选中高亮 + * - 光标(闪烁) + * + * 注意:背景和边框由 UIRender/UIGraphic 组件渲染。 + */ +@ECSSystem('UIInputFieldRender', { updateOrder: 115, runInEditMode: true }) +export class UIInputFieldRenderSystem extends EntitySystem { + private textCanvas: HTMLCanvasElement | null = null; + private textCtx: CanvasRenderingContext2D | null = null; + private textTextureCache: Map = new Map(); + private nextTextureId = 91000; // Start from 91000 to avoid conflicts with UITextRenderSystem + private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null; + private cacheInvalidationBound: () => void; + /** 检查纹理是否已就绪的回调 | Callback to check if texture is ready */ + private textureReadyChecker: ((id: number) => boolean) | null = null; + /** 待确认就绪的纹理 ID 集合 | Set of texture IDs pending ready confirmation */ + private pendingTextures: Set = new Set(); + + constructor() { + super(Matcher.empty().all(UITransformComponent, UIInputFieldComponent)); + this.cacheInvalidationBound = this.clearTextCache.bind(this); + } + + /** + * Called when system is added to scene + * 系统添加到场景时调用 + */ + public override initialize(): void { + super.initialize(); + registerCacheInvalidationCallback(this.cacheInvalidationBound); + } + + /** + * Called when system is destroyed + * 系统销毁时调用 + */ + protected override onDestroy(): void { + super.onDestroy(); + unregisterCacheInvalidationCallback(this.cacheInvalidationBound); + } + + /** + * Set callback for when a new text texture is created + * 设置创建新文本纹理时的回调 + */ + setTextureCallback(callback: (id: number, dataUrl: string) => void): void { + this.onTextureCreated = callback; + } + + /** + * Set callback to check if texture is ready + * 设置检查纹理是否就绪的回调 + */ + setTextureReadyChecker(checker: (id: number) => boolean): void { + this.textureReadyChecker = checker; + } + + protected process(entities: readonly Entity[]): void { + const collector = getUIRenderCollector(); + + // 检查待确认的纹理是否已就绪 + // Check if pending textures are ready + if (this.pendingTextures.size > 0 && this.textureReadyChecker) { + const nowReady: number[] = []; + for (const textureId of this.pendingTextures) { + if (this.textureReadyChecker(textureId)) { + nowReady.push(textureId); + } + } + if (nowReady.length > 0) { + for (const id of nowReady) { + this.pendingTextures.delete(id); + } + // 纹理就绪后不需要做任何特殊处理! + // Rust 端的纹理已经从 1x1 占位符更新为真实内容。 + // 注意:不要调用 invalidateUIRenderCaches(),那会清除缓存导致无限循环。 + // No special action needed - Rust texture is already updated. + // Note: Do NOT call invalidateUIRenderCaches(), it would cause infinite loop. + } + } + + for (const entity of entities) { + const transform = entity.getComponent(UITransformComponent); + const input = entity.getComponent(UIInputFieldComponent); + + // 空值检查 | Null check + if (!transform || !input) continue; + + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; + + const entityId = entity.id; + + // 注意:背景和边框由 UIRender/UIGraphic 组件渲染 + // Note: Background and border are rendered by UIRender/UIGraphic component + + // 1. 计算文本区域 + // 1. Calculate text area + const textX = rt.renderX - rt.width * rt.pivotX + input.padding; + const textY = rt.renderY - rt.height * rt.pivotY + input.padding; + const textWidth = rt.width - input.padding * 2; + const textHeight = rt.height - input.padding * 2; + + // 2. 渲染文本或占位符(在背景之上) + // 2. Render text or placeholder (above background) + this.renderText(collector, input, rt, textX, textY, textWidth, textHeight, entityId); + + // 3. 渲染选中高亮 + // 3. Render selection highlight + if (input.focused && input.hasSelection()) { + this.renderSelection(collector, input, rt, textX, textY, textHeight, entityId); + } + + // 4. 渲染光标 + // 4. Render caret + if (input.focused && input.caretVisible && !input.hasSelection()) { + this.renderCaret(collector, input, rt, textX, textY, textHeight, entityId); + } + } + } + + /** + * 渲染文本或占位符 + * Render text or placeholder + */ + private renderText( + collector: ReturnType, + input: UIInputFieldComponent, + rt: ReturnType, + textX: number, + textY: number, + textWidth: number, + textHeight: number, + entityId: number + ): void { + if (!rt) return; + + // 确定要显示的文本和颜色 + // Determine text to display and color + const isPlaceholder = input.text.length === 0; + const displayText = isPlaceholder ? input.placeholder : input.getDisplayText(); + + // 如果没有文本可显示,跳过渲染 + // Skip rendering if no text to display + if (!displayText) return; + + const color = isPlaceholder ? input.placeholderColor : input.textColor; + + // 生成或获取缓存的文本纹理 + // Generate or retrieve cached text texture + const textureId = this.getOrCreateInputTextTexture( + entityId, + displayText, + isPlaceholder, + input, + Math.ceil(textWidth), + Math.ceil(textHeight), + color + ); + + if (textureId === null) return; + + // 提交文本渲染原语(在背景之上) + // Submit text render primitive (above background) + collector.addRect( + textX + textWidth / 2, // 中心点 | Center point + textY + textHeight / 2, + textWidth, + textHeight, + 0xFFFFFF, // 白色着色(颜色已烘焙到纹理中) | White tint (color is baked into texture) + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 1, // 在背景之上 | Above background + { + pivotX: 0.5, + pivotY: 0.5, + textureId, + entityId + } + ); + } + + /** + * 获取或创建输入框文本纹理 + * Get or create input text texture + */ + private getOrCreateInputTextTexture( + entityId: number, + text: string, + isPlaceholder: boolean, + input: UIInputFieldComponent, + width: number, + height: number, + color: number + ): number | null { + const canvasData = this.getTextCanvas(); + if (!canvasData) return null; + + const { canvas, ctx } = canvasData; + + const cached = this.textTextureCache.get(entityId); + + // 检查是否需要重新生成纹理 + // Check if we need to regenerate the texture + const needsUpdate = !cached || + cached.text !== text || + cached.isPlaceholder !== isPlaceholder || + cached.fontSize !== input.fontSize || + cached.fontFamily !== input.fontFamily || + cached.fontWeight !== input.fontWeight || + cached.color !== color || + cached.width !== width || + cached.height !== height; + + if (needsUpdate) { + const canvasWidth = Math.max(1, width); + const canvasHeight = Math.max(1, height); + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // 设置字体 + // Set font + ctx.font = input.getCSSFont(); + + // 转换颜色为 CSS 格式 + // Convert color to CSS format + const r = (color >> 16) & 0xFF; + const g = (color >> 8) & 0xFF; + const b = color & 0xFF; + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + + // 计算绘制位置(考虑滚动偏移) + // Calculate draw position (considering scroll offset) + const drawX = -input.scrollOffset; + const drawY = canvasHeight / 2; + + // 绘制文本 + // Draw text + ctx.fillText(text, drawX, drawY); + + // 获取或创建纹理 ID + // Get or create texture ID + const textureId = cached?.textureId ?? this.nextTextureId++; + + const dataUrl = canvas.toDataURL('image/png'); + + // 通知回调新纹理 + // Notify callback of new texture + if (this.onTextureCreated) { + this.onTextureCreated(textureId, dataUrl); + // 如果有就绪检查器,将新纹理添加到待确认列表 + // If ready checker is available, add new texture to pending list + if (this.textureReadyChecker) { + this.pendingTextures.add(textureId); + } + } + + // 更新缓存 + // Update cache + this.textTextureCache.set(entityId, { + textureId, + text, + isPlaceholder, + fontSize: input.fontSize, + fontFamily: input.fontFamily, + fontWeight: input.fontWeight, + color, + width, + height, + dataUrl + }); + } + + return this.textTextureCache.get(entityId)?.textureId ?? null; + } + + /** + * 获取或创建文本画布 + * Get or create text canvas + */ + private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null { + if (!this.textCanvas) { + this.textCanvas = document.createElement('canvas'); + this.textCtx = this.textCanvas.getContext('2d'); + } + if (!this.textCtx) return null; + return { canvas: this.textCanvas, ctx: this.textCtx }; + } + + /** + * 清除文本纹理缓存 + * Clear text texture cache + */ + private clearTextCache(): void { + this.textTextureCache.clear(); + this.pendingTextures.clear(); + } + + /** + * 渲染选中高亮 + * Render selection highlight + */ + private renderSelection( + collector: ReturnType, + input: UIInputFieldComponent, + rt: ReturnType, + textX: number, + textY: number, + textHeight: number, + entityId: number + ): void { + if (!rt) return; + + const selRange = input.getSelectionXRange(); + const selX = textX + selRange.startX - input.scrollOffset; + const selWidth = selRange.width; + + if (selWidth <= 0) return; + + collector.addRect( + selX + selWidth / 2, // 中心点 | Center point + textY + textHeight / 2, + selWidth, + textHeight, + input.selectionColor, + 0.3 * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + pivotX: 0.5, + pivotY: 0.5, + entityId + } + ); + } + + /** + * 渲染光标 + * Render caret + */ + private renderCaret( + collector: ReturnType, + input: UIInputFieldComponent, + rt: ReturnType, + textX: number, + textY: number, + textHeight: number, + entityId: number + ): void { + if (!rt) return; + + const caretXOffset = input.getCaretX(); + const caretX = textX + caretXOffset - input.scrollOffset; + + collector.addRect( + caretX + input.caretWidth / 2, + textY + textHeight / 2, + input.caretWidth, + textHeight, + input.caretColor, + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 3, + { + pivotX: 0.5, + pivotY: 0.5, + entityId + } + ); + } +} diff --git a/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts b/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts index 45ee5b2f..8ff4d15e 100644 --- a/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts +++ b/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts @@ -11,6 +11,7 @@ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framewor import { UITransformComponent } from '../../components/UITransformComponent'; import { UIProgressBarComponent, UIProgressDirection } from '../../components/widgets/UIProgressBarComponent'; import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, renderBorder, lerpColor, type UIRenderTransform } from './UIRenderUtils'; /** * UI ProgressBar Render System @@ -28,7 +29,7 @@ import { getUIRenderCollector } from './UIRenderCollector'; * - 支持不同方向(左到右、右到左、上到下、下到上) * - 分段显示 */ -@ECSSystem('UIProgressBarRender', { updateOrder: 110 }) +@ECSSystem('UIProgressBarRender', { updateOrder: 110, runInEditMode: true }) export class UIProgressBarRenderSystem extends EntitySystem { constructor() { super(Matcher.empty().all(UITransformComponent, UIProgressBarComponent)); @@ -44,58 +45,41 @@ export class UIProgressBarRenderSystem extends EntitySystem { // 空值检查 | Null check if (!transform || !progressBar) continue; - if (!transform.worldVisible) continue; + // 确保添加 UIWidgetMarker + // Ensure UIWidgetMarker is added + ensureUIWidgetMarker(entity); - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放和旋转 - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const rotation = transform.worldRotation ?? transform.rotation; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 作为旋转/缩放中心 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; // Render background // 渲染背景 if (progressBar.backgroundAlpha > 0) { collector.addRect( - renderX, renderY, width, height, + rt.renderX, rt.renderY, rt.width, rt.height, progressBar.backgroundColor, - progressBar.backgroundAlpha * alpha, - sortingLayer, - orderInLayer, + progressBar.backgroundAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer, { - rotation, - pivotX, - pivotY + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId: entity.id } ); } - // Render border - // 渲染边框 + // Render border (using utility) + // 渲染边框(使用工具函数) if (progressBar.borderWidth > 0) { - this.renderBorder( - collector, renderX, renderY, width, height, - progressBar.borderWidth, - progressBar.borderColor, - alpha, - sortingLayer, - orderInLayer + 2, - transform, - pivotX, - pivotY - ); + renderBorder(collector, rt, { + borderWidth: progressBar.borderWidth, + borderColor: progressBar.borderColor, + borderAlpha: 1 + }, entity.id, 2); } // Render fill @@ -103,17 +87,9 @@ export class UIProgressBarRenderSystem extends EntitySystem { const progress = progressBar.getProgress(); if (progress > 0 && progressBar.fillAlpha > 0) { if (progressBar.showSegments) { - this.renderSegmentedFill( - collector, renderX, renderY, width, height, - progress, progressBar, alpha, sortingLayer, orderInLayer + 1, transform, - pivotX, pivotY - ); + this.renderSegmentedFill(collector, rt, progress, progressBar, entity.id); } else { - this.renderSolidFill( - collector, renderX, renderY, width, height, - progress, progressBar, alpha, sortingLayer, orderInLayer + 1, transform, - pivotX, pivotY - ); + this.renderSolidFill(collector, rt, progress, progressBar, entity.id); } } } @@ -122,70 +98,60 @@ export class UIProgressBarRenderSystem extends EntitySystem { /** * Render solid fill rectangle * 渲染实心填充矩形 - * - * Note: centerX, centerY is the pivot position of the progress bar - * 注意:centerX, centerY 是进度条的 pivot 位置 */ private renderSolidFill( collector: ReturnType, - centerX: number, centerY: number, width: number, height: number, + rt: UIRenderTransform, progress: number, progressBar: UIProgressBarComponent, - alpha: number, - sortingLayer: string, - orderInLayer: number, - transform: UITransformComponent, - pivotX: number, - pivotY: number + entityId: number ): void { - const rotation = transform.worldRotation ?? transform.rotation; - // 计算进度条的边界(相对于 pivot 中心) - const left = centerX - width * pivotX; - const bottom = centerY - height * pivotY; + const left = rt.renderX - rt.width * rt.pivotX; + const bottom = rt.renderY - rt.height * rt.pivotY; let fillX: number; let fillY: number; - let fillWidth = width; - let fillHeight = height; + let fillWidth = rt.width; + let fillHeight = rt.height; // Calculate fill dimensions based on direction // 根据方向计算填充尺寸 switch (progressBar.direction) { case UIProgressDirection.LeftToRight: - fillWidth = width * progress; + fillWidth = rt.width * progress; fillX = left + fillWidth / 2; - fillY = bottom + height / 2; + fillY = bottom + rt.height / 2; break; case UIProgressDirection.RightToLeft: - fillWidth = width * progress; - fillX = left + width - fillWidth / 2; - fillY = bottom + height / 2; + fillWidth = rt.width * progress; + fillX = left + rt.width - fillWidth / 2; + fillY = bottom + rt.height / 2; break; case UIProgressDirection.BottomToTop: - fillHeight = height * progress; - fillX = left + width / 2; + fillHeight = rt.height * progress; + fillX = left + rt.width / 2; fillY = bottom + fillHeight / 2; break; case UIProgressDirection.TopToBottom: - fillHeight = height * progress; - fillX = left + width / 2; - fillY = bottom + height - fillHeight / 2; + fillHeight = rt.height * progress; + fillX = left + rt.width / 2; + fillY = bottom + rt.height - fillHeight / 2; break; default: fillX = left + fillWidth / 2; - fillY = bottom + height / 2; + fillY = bottom + rt.height / 2; } - // Determine fill color (gradient or solid) - // 确定填充颜色(渐变或实心) + // Determine fill color (gradient or solid, using utility) + // 确定填充颜色(渐变或实心,使用工具函数) let fillColor = progressBar.fillColor; if (progressBar.useGradient) { - fillColor = this.lerpColor( + fillColor = lerpColor( progressBar.gradientStartColor, progressBar.gradientEndColor, progress @@ -195,13 +161,14 @@ export class UIProgressBarRenderSystem extends EntitySystem { collector.addRect( fillX, fillY, fillWidth, fillHeight, fillColor, - progressBar.fillAlpha * alpha, - sortingLayer, - orderInLayer, + progressBar.fillAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 1, { - rotation, + rotation: rt.rotation, pivotX: 0.5, - pivotY: 0.5 + pivotY: 0.5, + entityId } ); } @@ -209,23 +176,14 @@ export class UIProgressBarRenderSystem extends EntitySystem { /** * Render segmented fill * 渲染分段填充 - * - * Note: centerX, centerY is the pivot position of the progress bar - * 注意:centerX, centerY 是进度条的 pivot 位置 */ private renderSegmentedFill( collector: ReturnType, - centerX: number, centerY: number, width: number, height: number, + rt: UIRenderTransform, progress: number, progressBar: UIProgressBarComponent, - alpha: number, - sortingLayer: string, - orderInLayer: number, - transform: UITransformComponent, - pivotX: number, - pivotY: number + entityId: number ): void { - const rotation = transform.worldRotation ?? transform.rotation; const segments = progressBar.segments; const gap = progressBar.segmentGap; const filledSegments = Math.ceil(progress * segments); @@ -234,8 +192,8 @@ export class UIProgressBarRenderSystem extends EntitySystem { progressBar.direction === UIProgressDirection.RightToLeft; // 计算进度条的边界(相对于 pivot 中心) - const left = centerX - width * pivotX; - const bottom = centerY - height * pivotY; + const left = rt.renderX - rt.width * rt.pivotX; + const bottom = rt.renderY - rt.height * rt.pivotY; // Calculate segment dimensions // 计算段尺寸 @@ -243,11 +201,11 @@ export class UIProgressBarRenderSystem extends EntitySystem { let segmentHeight: number; if (isHorizontal) { - segmentWidth = (width - gap * (segments - 1)) / segments; - segmentHeight = height; + segmentWidth = (rt.width - gap * (segments - 1)) / segments; + segmentHeight = rt.height; } else { - segmentWidth = width; - segmentHeight = (height - gap * (segments - 1)) / segments; + segmentWidth = rt.width; + segmentHeight = (rt.height - gap * (segments - 1)) / segments; } for (let i = 0; i < filledSegments && i < segments; i++) { @@ -259,138 +217,56 @@ export class UIProgressBarRenderSystem extends EntitySystem { switch (progressBar.direction) { case UIProgressDirection.LeftToRight: segCenterX = left + i * (segmentWidth + gap) + segmentWidth / 2; - segCenterY = bottom + height / 2; + segCenterY = bottom + rt.height / 2; break; case UIProgressDirection.RightToLeft: - segCenterX = left + width - i * (segmentWidth + gap) - segmentWidth / 2; - segCenterY = bottom + height / 2; + segCenterX = left + rt.width - i * (segmentWidth + gap) - segmentWidth / 2; + segCenterY = bottom + rt.height / 2; break; case UIProgressDirection.TopToBottom: - segCenterX = left + width / 2; - segCenterY = bottom + height - i * (segmentHeight + gap) - segmentHeight / 2; + segCenterX = left + rt.width / 2; + segCenterY = bottom + rt.height - i * (segmentHeight + gap) - segmentHeight / 2; break; case UIProgressDirection.BottomToTop: - segCenterX = left + width / 2; + segCenterX = left + rt.width / 2; segCenterY = bottom + i * (segmentHeight + gap) + segmentHeight / 2; break; default: segCenterX = left + i * (segmentWidth + gap) + segmentWidth / 2; - segCenterY = bottom + height / 2; + segCenterY = bottom + rt.height / 2; } - // Determine segment color - // 确定段颜色 + // Determine segment color (using utility) + // 确定段颜色(使用工具函数) let segmentColor = progressBar.fillColor; if (progressBar.useGradient) { const t = segments > 1 ? i / (segments - 1) : 0; - segmentColor = this.lerpColor( + segmentColor = lerpColor( progressBar.gradientStartColor, progressBar.gradientEndColor, t ); } - // Use center position with pivot 0.5, 0.5 - // 使用中心位置,pivot 0.5, 0.5 collector.addRect( segCenterX, segCenterY, segmentWidth, segmentHeight, segmentColor, - progressBar.fillAlpha * alpha, - sortingLayer, - orderInLayer, + progressBar.fillAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 1, { - rotation, + rotation: rt.rotation, pivotX: 0.5, - pivotY: 0.5 + pivotY: 0.5, + entityId } ); } } - - /** - * Render border - * 渲染边框 - * - * Note: centerX, centerY is the pivot position of the progress bar - * 注意:centerX, centerY 是进度条的 pivot 位置 - */ - private renderBorder( - collector: ReturnType, - centerX: number, centerY: number, width: number, height: number, - borderWidth: number, - borderColor: number, - alpha: number, - sortingLayer: string, - orderInLayer: number, - transform: UITransformComponent, - pivotX: number, - pivotY: number - ): void { - const rotation = transform.worldRotation ?? transform.rotation; - - // 计算边界(相对于 pivot 中心) - const left = centerX - width * pivotX; - const bottom = centerY - height * pivotY; - const right = left + width; - const top = bottom + height; - - // Top border - collector.addRect( - (left + right) / 2, top - borderWidth / 2, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Bottom border - collector.addRect( - (left + right) / 2, bottom + borderWidth / 2, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Left border (excluding corners) - const sideBorderHeight = height - borderWidth * 2; - collector.addRect( - left + borderWidth / 2, (top + bottom) / 2, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Right border (excluding corners) - collector.addRect( - right - borderWidth / 2, (top + bottom) / 2, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - } - - /** - * Linear interpolation between two colors - * 两种颜色之间的线性插值 - */ - private lerpColor(color1: number, color2: number, t: number): number { - const r1 = (color1 >> 16) & 0xFF; - const g1 = (color1 >> 8) & 0xFF; - const b1 = color1 & 0xFF; - - const r2 = (color2 >> 16) & 0xFF; - const g2 = (color2 >> 8) & 0xFF; - const b2 = color2 & 0xFF; - - const r = Math.round(r1 + (r2 - r1) * t); - const g = Math.round(g1 + (g2 - g1) * t); - const b = Math.round(b1 + (b2 - b1) * t); - - return (r << 16) | (g << 8) | b; - } } diff --git a/packages/ui/src/systems/render/UIRectRenderSystem.ts b/packages/ui/src/systems/render/UIRectRenderSystem.ts index e52c30c6..1882a0e9 100644 --- a/packages/ui/src/systems/render/UIRectRenderSystem.ts +++ b/packages/ui/src/systems/render/UIRectRenderSystem.ts @@ -9,13 +9,13 @@ */ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { getTextureSpriteInfo, getGlobalAssetDatabase } from '@esengine/asset-system'; import { UITransformComponent } from '../../components/UITransformComponent'; -import { UIRenderComponent, UIRenderType } from '../../components/UIRenderComponent'; -import { UIButtonComponent } from '../../components/widgets/UIButtonComponent'; -import { UIProgressBarComponent } from '../../components/widgets/UIProgressBarComponent'; -import { UISliderComponent } from '../../components/widgets/UISliderComponent'; -import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent'; +import { UIRenderComponent } from '../../components/UIRenderComponent'; +import { UIWidgetMarker } from '../../components/UIWidgetMarker'; +import { getDynamicAtlasService } from '../../atlas/DynamicAtlasService'; import { getUIRenderCollector } from './UIRenderCollector'; +import { getUIRenderTransform, renderBorder, renderShadow, getNinePatchPosition } from './UIRenderUtils'; /** * UI Rect Render System @@ -29,8 +29,11 @@ import { getUIRenderCollector } from './UIRenderCollector'; * 处理具有 UIRenderComponent 但没有专门 widget 组件(如按钮、进度条等)的基础 UI 元素的渲染。 * 这是简单矩形、图像和面板的"兜底"渲染器。 */ -@ECSSystem('UIRectRender', { updateOrder: 100 }) +@ECSSystem('UIRectRender', { updateOrder: 100, runInEditMode: true }) export class UIRectRenderSystem extends EntitySystem { + // Debug: Track logged GUIDs to avoid spam | 调试:跟踪已记录的 GUID 以避免刷屏 + private _loggedGuids?: Set; + constructor() { super(Matcher.empty().all(UITransformComponent, UIRenderComponent)); } @@ -39,13 +42,9 @@ export class UIRectRenderSystem extends EntitySystem { const collector = getUIRenderCollector(); for (const entity of entities) { - // Skip if entity has specialized widget components - // (they have their own render systems) - // 如果实体有专门的 widget 组件,跳过(它们有自己的渲染系统) - if (entity.hasComponent(UIButtonComponent) || - entity.hasComponent(UIProgressBarComponent) || - entity.hasComponent(UISliderComponent) || - entity.hasComponent(UIScrollViewComponent)) { + // Skip if entity has UIWidgetMarker (has specialized render system) + // 如果实体有 UIWidgetMarker 标记(有专门的渲染系统),跳过 + if (entity.hasComponent(UIWidgetMarker)) { continue; } @@ -56,106 +55,152 @@ export class UIRectRenderSystem extends EntitySystem { // Null check - component may not be ready during deserialization or initialization if (!transform || !render) continue; - if (!transform.worldVisible) continue; + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放(考虑父级缩放) - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用世界旋转(考虑父级旋转) - const rotation = transform.worldRotation ?? transform.rotation; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - // worldOrderInLayer 考虑了父子层级关系,确保子元素渲染在父元素之上 - // worldOrderInLayer considers parent-child hierarchy, ensuring children render on top of parents - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 作为旋转/缩放中心 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - - // worldX/worldY 是元素左下角位置,需要转换为以 pivot 为中心的位置 - // pivot 相对于元素的偏移:(width * pivotX, height * pivotY) - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; - - // Render shadow if enabled - // 如果启用,渲染阴影 + // Render shadow if enabled (using utility) + // 如果启用,渲染阴影(使用工具函数) if (render.shadowEnabled && render.shadowAlpha > 0) { - collector.addRect( - renderX + render.shadowOffsetX, - renderY + render.shadowOffsetY, - width + render.shadowBlur * 2, - height + render.shadowBlur * 2, - render.shadowColor, - render.shadowAlpha * alpha, - sortingLayer, - orderInLayer - 1, // Shadow renders below main content - { - rotation, - pivotX, - pivotY - } - ); + renderShadow(collector, rt, { + offsetX: render.shadowOffsetX, + offsetY: render.shadowOffsetY, + blur: render.shadowBlur, + color: render.shadowColor, + alpha: render.shadowAlpha + }, entity.id); } + // Get material data from UIRenderComponent + // 从 UIRenderComponent 获取材质数据 + const materialId = render.getMaterialId(); + const materialOverrides = render.hasOverrides() ? render.materialOverrides : undefined; + // Render texture if present // 如果有纹理,渲染纹理 if (render.textureGuid) { const textureGuid = typeof render.textureGuid === 'string' ? render.textureGuid : undefined; const textureId = typeof render.textureGuid === 'number' ? render.textureGuid : undefined; + // Calculate effective alpha (backgroundAlpha affects texture too) + // 计算有效透明度(backgroundAlpha 也影响纹理) + const effectiveAlpha = render.backgroundAlpha * rt.alpha; + + // Try to get nine-patch info from texture's sprite settings + // 尝试从纹理的 sprite 设置获取九宫格信息 + let ninePatchMargins: [number, number, number, number] | undefined; + let textureWidth = 0; + let textureHeight = 0; + let isNinePatch = false; + + // Get texture path from AssetDatabase for atlas loading + // 从 AssetDatabase 获取纹理路径用于图集加载 + let texturePath: string | undefined; + if (textureGuid) { + const assetDb = getGlobalAssetDatabase(); + const metadata = assetDb?.getMetadata(textureGuid); + texturePath = metadata?.path; + + // Get sliceBorder from asset metadata + // 从资产元数据获取九宫格边距 + const spriteInfo = getTextureSpriteInfo(textureGuid); + if (spriteInfo?.sliceBorder) { + ninePatchMargins = spriteInfo.sliceBorder; + isNinePatch = true; + } + + // Get dimensions from DynamicAtlasService (primary source for UI textures) + // 从动态图集服务获取尺寸(UI 纹理的主要来源) + const atlasService = getDynamicAtlasService(); + const atlasEntry = atlasService?.getAtlasEntry(textureGuid); + if (atlasEntry) { + textureWidth = atlasEntry.originalWidth; + textureHeight = atlasEntry.originalHeight; + } else if (spriteInfo?.width && spriteInfo?.height) { + // Fallback to dimensions from metadata (from asset catalog) + // 从元数据获取尺寸作为后备(来自资产目录) + textureWidth = spriteInfo.width; + textureHeight = spriteInfo.height; + } + } // Handle nine-patch rendering + // Skip if texture is placeholder (1x1) - means texture not yet loaded // 处理九宫格渲染 - if (render.type === UIRenderType.NinePatch && - render.textureWidth > 0 && - render.textureHeight > 0) { - // addNinePatch expects top-left corner coordinates - // Y-up coordinate system: top = bottom + height - // addNinePatch 期望左上角坐标 - // Y轴向上坐标系:顶部 = 底部 + 高度 - const topLeftX = x; - const topLeftY = y + height; + // 如果纹理是占位符 (1x1) 则跳过 - 表示纹理尚未加载 + + // Debug: Log nine-patch info (throttled) + // 调试:记录九宫格信息(节流) + // When dimensions are 0, don't add to logged set - we want to see when they become available + // 当尺寸为0时,不添加到已记录集合 - 我们想看到它们何时可用 + if (textureGuid) { + const hasValidDimensions = textureWidth > 1 && textureHeight > 1; + const alreadyLogged = this._loggedGuids?.has(textureGuid); + + // Log when: (1) first time with valid dimensions, or (2) first time with invalid dimensions (but don't mark as logged) + // 记录条件:(1) 首次有有效尺寸,或 (2) 首次无效尺寸(但不标记为已记录) + if (!alreadyLogged) { + console.log(`[UIRect] textureGuid=${textureGuid}, isNinePatch=${isNinePatch}, margins=${JSON.stringify(ninePatchMargins)}, size=${textureWidth}x${textureHeight}, path=${texturePath}`); + + // Only mark as logged when we have valid dimensions + // 只有当我们有有效尺寸时才标记为已记录 + if (hasValidDimensions) { + if (!this._loggedGuids) this._loggedGuids = new Set(); + this._loggedGuids.add(textureGuid); + } + } + } + + if (isNinePatch && ninePatchMargins && textureWidth > 1 && textureHeight > 1) { + // Use utility to get position and pivot for consistent rendering + // 使用工具函数获取位置和 pivot 以实现一致的渲染 + const pos = getNinePatchPosition(rt); collector.addNinePatch( - topLeftX, topLeftY, - width, height, - render.ninePatchMargins, - render.textureWidth, - render.textureHeight, + pos.x, pos.y, + rt.width, rt.height, + ninePatchMargins, + textureWidth, + textureHeight, render.textureTint, - alpha, - sortingLayer, - orderInLayer, + effectiveAlpha, + rt.sortingLayer, + rt.orderInLayer, { - rotation, + rotation: rt.rotation, + pivotX: pos.pivotX, + pivotY: pos.pivotY, textureId, - textureGuid + textureGuid, + texturePath, + materialId, + materialOverrides, + entityId: entity.id } ); } else { // Standard image rendering // 标准图像渲染 collector.addRect( - renderX, renderY, - width, height, + rt.renderX, rt.renderY, + rt.width, rt.height, render.textureTint, - alpha, - sortingLayer, - orderInLayer, + effectiveAlpha, + rt.sortingLayer, + rt.orderInLayer, { - rotation, - pivotX, - pivotY, + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, textureId, textureGuid, + texturePath, uv: render.textureUV ? [render.textureUV.u0, render.textureUV.v0, render.textureUV.u1, render.textureUV.v1] - : undefined + : undefined, + materialId, + materialOverrides, + entityId: entity.id } ); } @@ -164,99 +209,32 @@ export class UIRectRenderSystem extends EntitySystem { // 如果启用填充,渲染背景颜色 else if (render.fillBackground && render.backgroundAlpha > 0) { collector.addRect( - renderX, renderY, - width, height, + rt.renderX, rt.renderY, + rt.width, rt.height, render.backgroundColor, - render.backgroundAlpha * alpha, - sortingLayer, - orderInLayer, + render.backgroundAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer, { - rotation, - pivotX, - pivotY + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + materialId, + materialOverrides, + entityId: entity.id } ); } - // Render border if present - // 如果有边框,渲染边框 + // Render border if present (using utility) + // 如果有边框,渲染边框(使用工具函数) if (render.borderWidth > 0 && render.borderAlpha > 0) { - this.renderBorder( - collector, - renderX, renderY, width, height, - render.borderWidth, - render.borderColor, - render.borderAlpha * alpha, - sortingLayer, - orderInLayer + 1, // Border renders above main content - rotation, - pivotX, - pivotY - ); + renderBorder(collector, rt, { + borderWidth: render.borderWidth, + borderColor: render.borderColor, + borderAlpha: render.borderAlpha + }, entity.id, 1); // Border renders above main content } } } - - /** - * Render border using pivot-based coordinates - * 使用基于 pivot 的坐标渲染边框 - */ - private renderBorder( - collector: ReturnType, - centerX: number, centerY: number, - width: number, height: number, - borderWidth: number, - borderColor: number, - alpha: number, - sortingLayer: string, - orderInLayer: number, - rotation: number, - pivotX: number, - pivotY: number - ): void { - // 计算矩形的左下角位置(相对于 pivot 中心) - const left = centerX - width * pivotX; - const bottom = centerY - height * pivotY; - const right = left + width; - const top = bottom + height; - - // Top border - const topBorderCenterX = (left + right) / 2; - const topBorderCenterY = top - borderWidth / 2; - collector.addRect( - topBorderCenterX, topBorderCenterY, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Bottom border - const bottomBorderCenterY = bottom + borderWidth / 2; - collector.addRect( - topBorderCenterX, bottomBorderCenterY, - width, borderWidth, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Left border (excluding corners) - const sideBorderHeight = height - borderWidth * 2; - const leftBorderCenterX = left + borderWidth / 2; - const sideBorderCenterY = (top + bottom) / 2; - collector.addRect( - leftBorderCenterX, sideBorderCenterY, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - - // Right border (excluding corners) - const rightBorderCenterX = right - borderWidth / 2; - collector.addRect( - rightBorderCenterX, sideBorderCenterY, - borderWidth, sideBorderHeight, - borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - } } diff --git a/packages/ui/src/systems/render/UIRenderBeginSystem.ts b/packages/ui/src/systems/render/UIRenderBeginSystem.ts index cb6b559c..59e29a8c 100644 --- a/packages/ui/src/systems/render/UIRenderBeginSystem.ts +++ b/packages/ui/src/systems/render/UIRenderBeginSystem.ts @@ -22,7 +22,7 @@ import { getUIRenderCollector } from './UIRenderCollector'; * * Update order: 99 (runs before UIRectRenderSystem at 100) */ -@ECSSystem('UIRenderBegin', { updateOrder: 99 }) +@ECSSystem('UIRenderBegin', { updateOrder: 99, runInEditMode: true }) export class UIRenderBeginSystem extends EntitySystem { constructor() { // Use Matcher.nothing() to indicate this system doesn't process any entities diff --git a/packages/ui/src/systems/render/UIRenderCollector.ts b/packages/ui/src/systems/render/UIRenderCollector.ts index bad23ce5..1287028d 100644 --- a/packages/ui/src/systems/render/UIRenderCollector.ts +++ b/packages/ui/src/systems/render/UIRenderCollector.ts @@ -16,6 +16,65 @@ import { isValidGUID } from '@esengine/asset-system'; import { sortingLayerManager, SortingLayers } from '@esengine/engine-core'; +import { getDynamicAtlasManager } from '../../atlas/DynamicAtlasManager'; +import { getDynamicAtlasService, getTexturePathByGuid } from '../../atlas/DynamicAtlasService'; + +/** + * Material property override for UI rendering. + * UI 渲染的材质属性覆盖。 + */ +export interface UIMaterialPropertyOverride { + /** Uniform type. | Uniform 类型。 */ + type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int'; + /** Uniform value. | Uniform 值。 */ + value: number | number[]; +} + +/** + * Material overrides map for UI. + * UI 的材质覆盖映射。 + */ +export type UIMaterialOverrides = Record; + +/** + * 合批打断原因 + * Batch break reason + * + * 注意:orderInLayer 不会打断合批,它只决定渲染顺序 + * Note: orderInLayer doesn't break batching, it only determines render order + */ +export type BatchBreakReason = + | 'first' // 第一个批次 | First batch + | 'sortingLayer' // 排序层不同 | Different sorting layer + | 'texture' // 纹理不同 | Different texture + | 'material'; // 材质不同 | Different material + +/** + * 合批调试信息 + * Batch debug info + */ +export interface BatchDebugInfo { + /** 批次索引 | Batch index */ + batchIndex: number; + /** 打断原因 | Break reason */ + reason: BatchBreakReason; + /** 详细信息 | Detail message */ + detail: string; + /** 批次内原语数量 | Primitive count in batch */ + primitiveCount: number; + /** 排序层 | Sorting layer */ + sortingLayer: string; + /** 层内顺序 | Order in layer */ + orderInLayer: number; + /** 纹理标识 | Texture key */ + textureKey: string; + /** 材质 ID | Material ID */ + materialId: number; + /** 批次包含的实体 ID 列表(去重)| Entity IDs in this batch (deduplicated) */ + entityIds: number[]; + /** 第一个实体 ID(打断合批的元素)| First entity ID (the batch breaker) */ + firstEntityId?: number; +} /** * A single render primitive (rectangle with optional texture) @@ -64,12 +123,33 @@ export interface UIRenderPrimitive { sortingLayer: string; /** 层内排序顺序 | Order within layer */ orderInLayer: number; + /** + * 添加顺序索引,用于稳定排序 + * Addition order index for stable sorting + * + * 当 sortKey 相同时,后添加的原语渲染在先添加的之上。 + * 这确保了系统执行顺序(如 UIButtonRenderSystem → UITextRenderSystem) + * 自然决定渲染顺序,而不需要硬编码偏移量。 + * + * When sortKey is equal, later-added primitives render on top of earlier ones. + * This ensures system execution order (e.g., UIButtonRenderSystem → UITextRenderSystem) + * naturally determines render order without hardcoded offsets. + */ + addIndex: number; /** Optional texture ID | 可选纹理 ID */ textureId?: number; /** Optional texture GUID | 可选纹理 GUID */ textureGuid?: string; + /** Optional texture URL/path (for dynamic atlas) | 可选纹理 URL/路径(用于动态图集) */ + texturePath?: string; /** UV coordinates [u0, v0, u1, v1] | UV 坐标 */ uv?: [number, number, number, number]; + /** Material ID (0 = default). | 材质 ID (0 = 默认)。 */ + materialId?: number; + /** Material property overrides. | 材质属性覆盖。 */ + materialOverrides?: UIMaterialOverrides; + /** Source entity ID (for debugging). | 来源实体 ID(用于调试)。 */ + entityId?: number; } /** @@ -88,6 +168,10 @@ export interface ProviderRenderData { orderInLayer: number; /** 纹理 GUID(如果 textureId 为 0 则使用)| Texture GUID (used if textureId is 0) */ textureGuid?: string; + /** Material IDs for each primitive. | 每个原语的材质 ID。 */ + materialIds?: Uint32Array; + /** Material overrides (per-group). | 材质覆盖(按组)。 */ + materialOverrides?: UIMaterialOverrides; } /** @@ -104,6 +188,15 @@ export class UIRenderCollector { private cache: ProviderRenderData[] | null = null; + /** 合批调试信息缓存 | Batch debug info cache */ + private batchDebugCache: BatchDebugInfo[] | null = null; + + /** + * 原语添加计数器,用于稳定排序 + * Primitive addition counter for stable sorting + */ + private addIndexCounter: number = 0; + /** * Clear all collected primitives (call at start of frame) * 清除所有收集的原语(在帧开始时调用) @@ -111,6 +204,8 @@ export class UIRenderCollector { clear(): void { this.primitives.length = 0; this.cache = null; + this.batchDebugCache = null; + this.addIndexCounter = 0; } /** @@ -132,7 +227,13 @@ export class UIRenderCollector { pivotY?: number; textureId?: number; textureGuid?: string; + /** 纹理路径(用于动态图集加载)| Texture path (for dynamic atlas loading) */ + texturePath?: string; uv?: [number, number, number, number]; + materialId?: number; + materialOverrides?: UIMaterialOverrides; + /** 来源实体 ID(用于调试)| Source entity ID (for debugging) */ + entityId?: number; } ): void { // Pack color with alpha: 0xAABBGGRR @@ -153,13 +254,32 @@ export class UIRenderCollector { color: packedColor, sortingLayer, orderInLayer, + addIndex: this.addIndexCounter++, textureId: options?.textureId, textureGuid: options?.textureGuid, - uv: options?.uv + texturePath: options?.texturePath, + uv: options?.uv, + materialId: options?.materialId, + materialOverrides: options?.materialOverrides, + entityId: options?.entityId }; this.primitives.push(primitive); + + // 如果有 GUID,请求加载到动态图集 + // If GUID provided, request loading to dynamic atlas + if (options?.textureGuid) { + // 优先使用提供的路径,否则从映射中查找 + // Prefer provided path, otherwise lookup from mapping + const texturePath = options.texturePath ?? getTexturePathByGuid(options.textureGuid); + if (texturePath) { + requestTextureForAtlas(options.textureGuid, texturePath); + } + // 不再输出警告 - 路径可能稍后注册 + // No warning - path may be registered later + } this.cache = null; + this.batchDebugCache = null; } /** @@ -167,8 +287,12 @@ export class UIRenderCollector { * 添加带预计算世界变换的原语 */ addPrimitive(primitive: UIRenderPrimitive): void { + // 分配添加索引用于稳定排序 + // Assign add index for stable sorting + primitive.addIndex = this.addIndexCounter++; this.primitives.push(primitive); this.cache = null; + this.batchDebugCache = null; } /** @@ -185,8 +309,8 @@ export class UIRenderCollector { * - 边缘:单向拉伸 * - 中心:双向拉伸 * - * @param x - X position | X 坐标 - * @param y - Y position | Y 坐标 + * @param x - Pivot X position (same as regular rect) | Pivot X 坐标(与普通矩形相同) + * @param y - Pivot Y position (same as regular rect) | Pivot Y 坐标(与普通矩形相同) * @param width - Target width | 目标宽度 * @param height - Target height | 目标高度 * @param margins - Nine-patch margins [top, right, bottom, left] | 九宫格边距 @@ -212,18 +336,44 @@ export class UIRenderCollector { orderInLayer: number, options?: { rotation?: number; + /** Pivot X (0-1), default 0.5 | X 轴锚点 (0-1),默认 0.5 */ + pivotX?: number; + /** Pivot Y (0-1), default 0.5 | Y 轴锚点 (0-1),默认 0.5 */ + pivotY?: number; textureId?: number; textureGuid?: string; + /** 纹理路径(用于动态图集加载)| Texture path (for dynamic atlas loading) */ + texturePath?: string; + materialId?: number; + materialOverrides?: UIMaterialOverrides; + /** 来源实体 ID(用于调试)| Source entity ID (for debugging) */ + entityId?: number; } ): void { - const [marginTop, marginRight, marginBottom, marginLeft] = margins; + let [marginTop, marginRight, marginBottom, marginLeft] = margins; + const rotation = options?.rotation ?? 0; + const pivotX = options?.pivotX ?? 0.5; + const pivotY = options?.pivotY ?? 0.5; - // Ensure minimum size to avoid negative dimensions - // 确保最小尺寸以避免负尺寸 + // Proportionally scale margins if target size is smaller than minimum + // 如果目标尺寸小于最小值,按比例缩小边距 const minWidth = marginLeft + marginRight; const minHeight = marginTop + marginBottom; - const targetWidth = Math.max(width, minWidth); - const targetHeight = Math.max(height, minHeight); + + if (width < minWidth && minWidth > 0) { + const scale = width / minWidth; + marginLeft *= scale; + marginRight *= scale; + } + + if (height < minHeight && minHeight > 0) { + const scale = height / minHeight; + marginTop *= scale; + marginBottom *= scale; + } + + const targetWidth = width; + const targetHeight = height; // Calculate center dimensions // 计算中心区域尺寸 @@ -237,23 +387,49 @@ export class UIRenderCollector { const uvTop = marginTop / textureHeight; const uvBottom = (textureHeight - marginBottom) / textureHeight; - // Common options for all patches - // 所有 patch 的公共选项 - // Note: pivotY=1 means position is top-left corner (Y-up coordinate system) - // 注意:pivotY=1 表示位置是左上角(Y轴向上坐标系) + // Pre-calculate sin/cos for rotation + // 预计算旋转的 sin/cos + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + + // Calculate top-left corner position (unrotated) relative to pivot point + // 计算相对于 pivot 点的左上角位置(未旋转) + const topLeftOffsetX = -targetWidth * pivotX; + const topLeftOffsetY = targetHeight * (1 - pivotY); + + // Common options for all patches (no rotation per-patch, we handle it via position) + // 所有 patch 的公共选项(每个 patch 不单独旋转,我们通过位置处理) const baseOptions = { - rotation: options?.rotation ?? 0, - pivotX: 0, - pivotY: 1, + rotation: rotation, + pivotX: 0.5, + pivotY: 0.5, textureId: options?.textureId, - textureGuid: options?.textureGuid + textureGuid: options?.textureGuid, + texturePath: options?.texturePath, + materialId: options?.materialId, + materialOverrides: options?.materialOverrides, + entityId: options?.entityId + }; + + // Helper to rotate a point around the pivot + // 辅助函数:围绕 pivot 旋转一个点 + const rotatePoint = (offsetX: number, offsetY: number): { x: number; y: number } => { + // Offset is relative to pivot (x, y) + // 偏移是相对于 pivot (x, y) 的 + const rotatedX = offsetX * cos - offsetY * sin; + const rotatedY = offsetX * sin + offsetY * cos; + return { x: x + rotatedX, y: y + rotatedY }; }; // Helper to add a patch with specific UVs + // The patch position is specified by its top-left corner offset from the nine-patch's top-left // 辅助函数:添加具有特定 UV 的 patch + // patch 位置由相对于九宫格左上角的偏移指定 const addPatch = ( - px: number, - py: number, + // Local offset from top-left corner of nine-patch (unrotated) + // 相对于九宫格左上角的本地偏移(未旋转) + localX: number, + localY: number, pw: number, ph: number, u0: number, @@ -262,43 +438,53 @@ export class UIRenderCollector { v1: number ) => { if (pw <= 0 || ph <= 0) return; - this.addRect(px, py, pw, ph, color, alpha, sortingLayer, orderInLayer, { + + // Calculate the center of this patch (relative to pivot) + // 计算此 patch 的中心(相对于 pivot) + // localX, localY is top-left corner offset from nine-patch's top-left + // Add topLeftOffset to get offset from pivot + const offsetX = topLeftOffsetX + localX + pw / 2; + const offsetY = topLeftOffsetY - localY - ph / 2; + + // Rotate around pivot point + // 围绕 pivot 点旋转 + const rotated = rotatePoint(offsetX, offsetY); + + this.addRect(rotated.x, rotated.y, pw, ph, color, alpha, sortingLayer, orderInLayer, { ...baseOptions, uv: [u0, v0, u1, v1] }); }; - // Y-up coordinate system: y decreases as we go down - // Y轴向上坐标系:向下移动时 y 减小 - // (x, y) is top-left corner, patches extend downward (negative y direction) - // (x, y) 是左上角,patch 向下延伸(y 减小方向) + // Add all 9 patches (localX, localY relative to top-left of nine-patch) + // 添加所有 9 个 patch(localX, localY 相对于九宫格的左上角) // Top-left corner | 左上角 - addPatch(x, y, marginLeft, marginTop, 0, 0, uvLeft, uvTop); + addPatch(0, 0, marginLeft, marginTop, 0, 0, uvLeft, uvTop); // Top edge | 顶边 - addPatch(x + marginLeft, y, centerWidth, marginTop, uvLeft, 0, uvRight, uvTop); + addPatch(marginLeft, 0, centerWidth, marginTop, uvLeft, 0, uvRight, uvTop); // Top-right corner | 右上角 - addPatch(x + marginLeft + centerWidth, y, marginRight, marginTop, uvRight, 0, 1, uvTop); + addPatch(marginLeft + centerWidth, 0, marginRight, marginTop, uvRight, 0, 1, uvTop); - // Left edge | 左边 (move down = subtract y) - addPatch(x, y - marginTop, marginLeft, centerHeight, 0, uvTop, uvLeft, uvBottom); + // Left edge | 左边 + addPatch(0, marginTop, marginLeft, centerHeight, 0, uvTop, uvLeft, uvBottom); // Center | 中心 - addPatch(x + marginLeft, y - marginTop, centerWidth, centerHeight, uvLeft, uvTop, uvRight, uvBottom); + addPatch(marginLeft, marginTop, centerWidth, centerHeight, uvLeft, uvTop, uvRight, uvBottom); // Right edge | 右边 - addPatch(x + marginLeft + centerWidth, y - marginTop, marginRight, centerHeight, uvRight, uvTop, 1, uvBottom); + addPatch(marginLeft + centerWidth, marginTop, marginRight, centerHeight, uvRight, uvTop, 1, uvBottom); // Bottom-left corner | 左下角 - addPatch(x, y - marginTop - centerHeight, marginLeft, marginBottom, 0, uvBottom, uvLeft, 1); + addPatch(0, marginTop + centerHeight, marginLeft, marginBottom, 0, uvBottom, uvLeft, 1); // Bottom edge | 底边 - addPatch(x + marginLeft, y - marginTop - centerHeight, centerWidth, marginBottom, uvLeft, uvBottom, uvRight, 1); + addPatch(marginLeft, marginTop + centerHeight, centerWidth, marginBottom, uvLeft, uvBottom, uvRight, 1); // Bottom-right corner | 右下角 - addPatch(x + marginLeft + centerWidth, y - marginTop - centerHeight, marginRight, marginBottom, uvRight, uvBottom, 1, 1); + addPatch(marginLeft + centerWidth, marginTop + centerHeight, marginRight, marginBottom, uvRight, uvBottom, 1, 1); } /** @@ -311,6 +497,7 @@ export class UIRenderCollector { } this.cache = this.buildRenderData(this.primitives); + return this.cache; } @@ -320,37 +507,153 @@ export class UIRenderCollector { */ private buildRenderData(primitives: UIRenderPrimitive[]): ProviderRenderData[] { if (primitives.length === 0) { + this.batchDebugCache = []; return []; } - // Sort by sortKey (layer order * 10000 + orderInLayer) - // 按 sortKey 排序(层顺序 * 10000 + 层内顺序) - primitives.sort((a, b) => { + // 创建副本进行排序,避免修改原数组 + // Create a copy for sorting to avoid modifying the original array + const sortedPrimitives = [...primitives]; + + // Sort by sortKey (layer order * 10000 + orderInLayer), then by addIndex for stability + // 按 sortKey 排序(层顺序 * 10000 + 层内顺序),然后按 addIndex 保持稳定性 + // 当 sortKey 相同时,后添加的原语渲染在先添加的之上 + // When sortKey is equal, later-added primitives render on top of earlier ones + sortedPrimitives.sort((a, b) => { const sortKeyA = sortingLayerManager.getSortKey(a.sortingLayer, a.orderInLayer); const sortKeyB = sortingLayerManager.getSortKey(b.sortingLayer, b.orderInLayer); - return sortKeyA - sortKeyB; + if (sortKeyA !== sortKeyB) { + return sortKeyA - sortKeyB; + } + // 稳定排序:addIndex 大的在后面(渲染在上层) + // Stable sort: larger addIndex comes later (renders on top) + return a.addIndex - b.addIndex; }); - // Group by texture + sortingLayer (primitives with same texture and layer can be batched) - // 按纹理 + 排序层分组(相同纹理和层的原语可以批处理) + // Group by texture + sortingLayer + material (primitives with same texture/layer/material can be batched) + // 按纹理 + 排序层 + 材质分组(相同纹理/层/材质的原语可以批处理) const groups = new Map(); + const batchDebugInfos: BatchDebugInfo[] = []; + // 每个批次的 entityId 集合 | Entity ID set per batch + const batchEntityIds = new Map>(); - for (const prim of primitives) { - // Use texture GUID or 'solid' for solid color rects, combined with sorting layer - // 使用纹理 GUID 或 'solid' 表示纯色矩形,与排序层组合 - const textureKey = prim.textureGuid ?? (prim.textureId?.toString() ?? 'solid'); - const key = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}`; - let group = groups.get(key); - if (!group) { - group = []; - groups.set(key, group); + // 追踪上一个原语的属性以检测打断原因 | Track previous primitive's properties to detect break reason + // 合批条件:连续的原语如果有相同的 sortingLayer + texture + material 就可以合批 + // orderInLayer 只决定渲染顺序,不影响能否合批 + // Batching condition: consecutive primitives with same sortingLayer + texture + material can be batched + // orderInLayer only determines render order, doesn't affect batching + let prevSortingLayer: string | null = null; + let prevTextureKey: string | null = null; + let prevMaterialKey: number | null = null; + let batchIndex = 0; + let currentGroup: UIRenderPrimitive[] | null = null; + let currentBatchKey: string | null = null; + + // Get dynamic atlas manager for batch key optimization + // 获取动态图集管理器用于优化合批 key + const atlasManager = getDynamicAtlasManager(); + + for (const prim of sortedPrimitives) { + // Check if texture is in dynamic atlas + // 检查纹理是否在动态图集中 + let textureKey: string; + const atlasEntry = prim.textureGuid && atlasManager + ? atlasManager.getEntry(prim.textureGuid) + : undefined; + + if (atlasEntry) { + // Use atlas texture ID as key - all textures in same atlas can batch! + // 使用图集纹理 ID 作为 key - 同一图集中的所有纹理可以合批! + textureKey = `atlas:${atlasEntry.atlasId}`; + } else { + // Use original texture key + // 使用原始纹理 key + textureKey = prim.textureGuid ?? (prim.textureId?.toString() ?? 'solid'); } - group.push(prim); + + const materialKey = prim.materialId ?? 0; + // 合批 key 必须包含 orderInLayer,否则不同深度的元素会被错误合并 + // Batch key must include orderInLayer, otherwise elements at different depths will be incorrectly merged + const batchKey = `${prim.sortingLayer}:${prim.orderInLayer}:${textureKey}:${materialKey}`; + + // 检查是否需要新批次:sortingLayer、orderInLayer、texture 或 material 变化 + // Check if new batch needed: sortingLayer, orderInLayer, texture or material changed + const needNewBatch = currentBatchKey !== batchKey; + + if (needNewBatch) { + // 新批次 - 记录打断原因 | New batch - record break reason + let reason: BatchBreakReason = 'first'; + let detail = 'First batch'; + + if (prevSortingLayer !== null) { + if (prim.sortingLayer !== prevSortingLayer) { + reason = 'sortingLayer'; + detail = `Layer changed: ${prevSortingLayer} → ${prim.sortingLayer}`; + } else if (textureKey !== prevTextureKey) { + reason = 'texture'; + detail = `Texture changed: ${prevTextureKey} → ${textureKey}`; + } else if (materialKey !== prevMaterialKey) { + reason = 'material'; + detail = `Material changed: ${prevMaterialKey} → ${materialKey}`; + } + } + + // 使用带索引的唯一 key 来存储每个批次(因为相同 batchKey 可能出现多次) + // Use indexed unique key to store each batch (same batchKey may appear multiple times) + const uniqueKey = `${batchIndex}:${batchKey}`; + + batchDebugInfos.push({ + batchIndex, + reason, + detail, + primitiveCount: 0, // 稍后更新 | Update later + sortingLayer: prim.sortingLayer, + orderInLayer: prim.orderInLayer, + textureKey, + materialId: materialKey, + entityIds: [], // 稍后填充 | Fill later + firstEntityId: prim.entityId // 第一个实体 ID | First entity ID + }); + + batchIndex++; + + currentGroup = []; + groups.set(uniqueKey, currentGroup); + batchEntityIds.set(uniqueKey, new Set()); + currentBatchKey = batchKey; + } + + currentGroup!.push(prim); + + // 收集 entityId | Collect entityId + if (prim.entityId !== undefined) { + const uniqueKey = `${batchIndex - 1}:${currentBatchKey}`; + batchEntityIds.get(uniqueKey)?.add(prim.entityId); + } + + prevSortingLayer = prim.sortingLayer; + prevTextureKey = textureKey; + prevMaterialKey = materialKey; } - // Convert groups to ProviderRenderData - // 将分组转换为 ProviderRenderData - const result: ProviderRenderData[] = []; + // 更新每个批次的原语数量和 entityIds | Update primitive count and entityIds for each batch + let debugIdx = 0; + for (const [key, prims] of groups) { + if (debugIdx < batchDebugInfos.length) { + batchDebugInfos[debugIdx].primitiveCount = prims.length; + const entityIdSet = batchEntityIds.get(key); + if (entityIdSet) { + batchDebugInfos[debugIdx].entityIds = [...entityIdSet]; + } + debugIdx++; + } + } + + this.batchDebugCache = batchDebugInfos; + + // Convert groups to ProviderRenderData with addIndex for stable sorting + // 将分组转换为带 addIndex 的 ProviderRenderData 以实现稳定排序 + const result: Array<{ data: ProviderRenderData; addIndex: number }> = []; for (const [key, prims] of groups) { const count = prims.length; @@ -359,9 +662,18 @@ export class UIRenderCollector { const uvs = new Float32Array(count * 4); const colors = new Uint32Array(count); - // Use the first primitive's sorting info (all in group have same layer/order) - // 使用第一个原语的排序信息(组内所有原语层/顺序相同) + // Use the first primitive's sorting info (all in group have same layer/order/material) + // 使用第一个原语的排序信息(组内所有原语层/顺序/材质相同) const firstPrim = prims[0]; + const hasMaterial = (firstPrim.materialId ?? 0) !== 0; + let materialIds: Uint32Array | undefined; + if (hasMaterial) { + materialIds = new Uint32Array(count); + } + + // Get dynamic atlas manager for UV remapping + // 获取动态图集管理器用于 UV 重映射 + const atlasManager = getDynamicAtlasManager(); for (let i = 0; i < count; i++) { const p = prims[i]; @@ -379,22 +691,56 @@ export class UIRenderCollector { transforms[tOffset + 5] = p.pivotX; transforms[tOffset + 6] = p.pivotY; - textureIds[i] = p.textureId ?? 0; + // Check for dynamic atlas entry + // 检查动态图集条目 + let atlasEntry = p.textureGuid && atlasManager + ? atlasManager.getEntry(p.textureGuid) + : undefined; - // UV - if (p.uv) { - uvs[uvOffset] = p.uv[0]; - uvs[uvOffset + 1] = p.uv[1]; - uvs[uvOffset + 2] = p.uv[2]; - uvs[uvOffset + 3] = p.uv[3]; + if (atlasEntry) { + // Use atlas texture ID + // 使用图集纹理 ID + textureIds[i] = atlasEntry.atlasId; + + // Remap UV to atlas space + // 将 UV 重映射到图集空间 + const originalUV = p.uv ?? [0, 0, 1, 1]; + const remappedUV = atlasManager!.remapUV( + atlasEntry, + originalUV[0], + originalUV[1], + originalUV[2], + originalUV[3] + ); + uvs[uvOffset] = remappedUV[0]; + uvs[uvOffset + 1] = remappedUV[1]; + uvs[uvOffset + 2] = remappedUV[2]; + uvs[uvOffset + 3] = remappedUV[3]; } else { - uvs[uvOffset] = 0; - uvs[uvOffset + 1] = 0; - uvs[uvOffset + 2] = 1; - uvs[uvOffset + 3] = 1; + // Use original texture ID and UV + // 使用原始纹理 ID 和 UV + textureIds[i] = p.textureId ?? 0; + + // UV + if (p.uv) { + uvs[uvOffset] = p.uv[0]; + uvs[uvOffset + 1] = p.uv[1]; + uvs[uvOffset + 2] = p.uv[2]; + uvs[uvOffset + 3] = p.uv[3]; + } else { + uvs[uvOffset] = 0; + uvs[uvOffset + 1] = 0; + uvs[uvOffset + 2] = 1; + uvs[uvOffset + 3] = 1; + } } colors[i] = p.color; + + // Material ID + if (materialIds) { + materialIds[i] = p.materialId ?? 0; + } } const renderData: ProviderRenderData = { @@ -413,18 +759,36 @@ export class UIRenderCollector { renderData.textureGuid = firstPrim.textureGuid; } - result.push(renderData); + // Add material data if present + // 如果存在材质数据,添加它 + if (materialIds) { + renderData.materialIds = materialIds; + } + // Use the first primitive's material overrides (all in group share same material) + // 使用第一个原语的材质覆盖(组内所有原语共享相同材质) + if (firstPrim.materialOverrides && Object.keys(firstPrim.materialOverrides).length > 0) { + renderData.materialOverrides = firstPrim.materialOverrides; + } + + result.push({ data: renderData, addIndex: firstPrim.addIndex }); } - // Sort result by sortKey - // 按 sortKey 排序结果 + // Sort result by sortKey, then by addIndex for stability + // 按 sortKey 排序,然后按 addIndex 保持稳定性 + // 当 sortKey 相同时,后添加的 batch 渲染在先添加的之上 + // When sortKey is equal, later-added batches render on top of earlier ones result.sort((a, b) => { - const sortKeyA = sortingLayerManager.getSortKey(a.sortingLayer, a.orderInLayer); - const sortKeyB = sortingLayerManager.getSortKey(b.sortingLayer, b.orderInLayer); - return sortKeyA - sortKeyB; + const sortKeyA = sortingLayerManager.getSortKey(a.data.sortingLayer, a.data.orderInLayer); + const sortKeyB = sortingLayerManager.getSortKey(b.data.sortingLayer, b.data.orderInLayer); + if (sortKeyA !== sortKeyB) { + return sortKeyA - sortKeyB; + } + // 稳定排序:addIndex 大的在后面(渲染在上层) + // Stable sort: larger addIndex comes later (renders on top) + return a.addIndex - b.addIndex; }); - return result; + return result.map(r => r.data); } /** @@ -442,26 +806,46 @@ export class UIRenderCollector { get isEmpty(): boolean { return this.primitives.length === 0; } -} -// Global singleton instance -// 全局单例实例 -let globalCollector: UIRenderCollector | null = null; + /** + * 获取合批调试信息 + * Get batch debug info + * + * 注意:此方法只返回已构建的缓存,不会触发构建。 + * 这是为了避免在渲染过程中被 Frame Debugger 调用时提前构建缓存, + * 导致后续添加的原语(如 Text)不被包含。 + * + * Note: This method only returns the already-built cache, without triggering a build. + * This prevents Frame Debugger from prematurely building the cache during rendering, + * which would cause subsequently added primitives (like Text) to be excluded. + */ + getBatchDebugInfo(): readonly BatchDebugInfo[] { + // 不再触发构建,只返回已有缓存 + // No longer trigger build, only return existing cache + return this.batchDebugCache ?? []; + } +} // Cache invalidation callbacks // 缓存失效回调 type CacheInvalidationCallback = () => void; const cacheInvalidationCallbacks: CacheInvalidationCallback[] = []; +// 使用 globalThis 确保跨模块单例 +// Use globalThis to ensure cross-module singleton +const COLLECTOR_KEY = '__esengine_ui_render_collector__'; + /** * Get the global UI render collector instance * 获取全局 UI 渲染收集器实例 */ export function getUIRenderCollector(): UIRenderCollector { - if (!globalCollector) { - globalCollector = new UIRenderCollector(); + // 使用 globalThis 确保即使模块被重复打包也只有一个实例 + // Use globalThis to ensure single instance even if module is bundled multiple times + if (!(globalThis as any)[COLLECTOR_KEY]) { + (globalThis as any)[COLLECTOR_KEY] = new UIRenderCollector(); } - return globalCollector; + return (globalThis as any)[COLLECTOR_KEY]; } /** @@ -469,7 +853,7 @@ export function getUIRenderCollector(): UIRenderCollector { * 重置全局收集器(用于测试或清理) */ export function resetUIRenderCollector(): void { - globalCollector = null; + (globalThis as any)[COLLECTOR_KEY] = null; } /** @@ -515,3 +899,74 @@ export function invalidateUIRenderCaches(): void { } } } + +// 已请求加载的纹理集合(避免重复请求) +// Set of requested textures (avoid duplicate requests) +const requestedTextures = new Set(); + +// 日志节流相关 | Log throttling related +// 已警告过的纹理 GUID(避免重复警告) +// Warned texture GUIDs (avoid duplicate warnings) +const warnedTextureGuids = new Set(); +let atlasServiceWarningShown = false; + +/** + * Request a texture to be loaded into the dynamic atlas + * 请求将纹理加载到动态图集 + * + * This function is called automatically when primitives with textureGuid and texturePath are added. + * The texture will be loaded asynchronously and added to the atlas for future batching. + * 当添加带有 textureGuid 和 texturePath 的原语时会自动调用此函数。 + * 纹理将被异步加载并添加到图集以供将来合批使用。 + * + * @param textureGuid - Texture GUID | 纹理 GUID + * @param texturePath - Texture URL/path | 纹理 URL/路径 + */ +export function requestTextureForAtlas(textureGuid: string, texturePath: string): void { + // 检查是否已请求或已在图集中 + // Check if already requested or in atlas + if (requestedTextures.has(textureGuid)) { + return; + } + + const atlasManager = getDynamicAtlasManager(); + if (atlasManager?.hasTexture(textureGuid)) { + requestedTextures.add(textureGuid); // Mark as known + return; + } + + const atlasService = getDynamicAtlasService(); + if (!atlasService) { + // 只警告一次 | Warn only once + if (!atlasServiceWarningShown) { + console.warn('[UIRenderCollector] Atlas service not initialized'); + atlasServiceWarningShown = true; + } + return; // Service not initialized + } + + // Mark as requested to avoid duplicate loads + // 标记为已请求以避免重复加载 + requestedTextures.add(textureGuid); + + // Load async - don't await, let it complete in background + // 异步加载 - 不等待,让它在后台完成 + atlasService.addTextureFromUrl(textureGuid, texturePath).catch((_err) => { + // Remove from requested set so it can be retried + // 从请求集合中移除以便可以重试 + requestedTextures.delete(textureGuid); + }); +} + +/** + * Clear the texture request cache + * 清除纹理请求缓存 + * + * Call this when switching scenes or when textures need to be reloaded. + * 在切换场景或需要重新加载纹理时调用此函数。 + */ +export function clearTextureRequestCache(): void { + requestedTextures.clear(); + warnedTextureGuids.clear(); + atlasServiceWarningShown = false; +} diff --git a/packages/ui/src/systems/render/UIRenderUtils.ts b/packages/ui/src/systems/render/UIRenderUtils.ts new file mode 100644 index 00000000..41719f8c --- /dev/null +++ b/packages/ui/src/systems/render/UIRenderUtils.ts @@ -0,0 +1,326 @@ +/** + * UI Render Utilities + * UI 渲染工具 + * + * Shared utility functions for UI render systems to reduce code duplication. + * 渲染系统共享的工具函数,减少代码重复。 + */ + +import type { Entity } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../../components/UITransformComponent'; +import { UIWidgetMarker } from '../../components/UIWidgetMarker'; +import type { UIRenderCollector } from './UIRenderCollector'; + +/** + * Ensure entity has UIWidgetMarker component + * 确保实体具有 UIWidgetMarker 组件 + * + * Widget components add this marker to prevent UIRectRenderSystem from + * rendering them, as they have their own specialized render systems. + * + * Widget 组件添加此标记以防止 UIRectRenderSystem 渲染它们, + * 因为它们有自己专门的渲染系统。 + * + * @param entity - Entity to check/mark + */ +export function ensureUIWidgetMarker(entity: Entity): void { + if (!entity.hasComponent(UIWidgetMarker)) { + entity.addComponent(new UIWidgetMarker()); + } +} + +/** + * Computed transform data for rendering + * 用于渲染的计算后变换数据 + */ +export interface UIRenderTransform { + /** World X position (bottom-left corner) / 世界 X 坐标(左下角) */ + x: number; + /** World Y position (bottom-left corner) / 世界 Y 坐标(左下角) */ + y: number; + /** Computed width with scale / 计算后的宽度(含缩放) */ + width: number; + /** Computed height with scale / 计算后的高度(含缩放) */ + height: number; + /** World alpha / 世界透明度 */ + alpha: number; + /** World rotation in radians / 世界旋转(弧度) */ + rotation: number; + /** Pivot X (0-1) / X 轴锚点 (0-1) */ + pivotX: number; + /** Pivot Y (0-1) / Y 轴锚点 (0-1) */ + pivotY: number; + /** Sorting layer name / 排序层名称 */ + sortingLayer: string; + /** Order within layer / 层内顺序 */ + orderInLayer: number; + /** Render X position (pivot-adjusted) / 渲染 X 坐标(锚点调整后) */ + renderX: number; + /** Render Y position (pivot-adjusted) / 渲染 Y 坐标(锚点调整后) */ + renderY: number; +} + +/** + * Extract render transform data from UITransformComponent + * 从 UITransformComponent 提取渲染变换数据 + * + * 使用 UILayoutSystem 计算的世界坐标。如果 layoutComputed = false,回退到本地坐标。 + * Uses world coordinates computed by UILayoutSystem. If layoutComputed = false, falls back to local coordinates. + * + * @param transform - UITransformComponent instance + * @param _entity - Optional entity (unused, for API compatibility) + * @returns Computed render transform, or null if not visible + */ +export function getUIRenderTransform(transform: UITransformComponent, _entity?: Entity): UIRenderTransform | null { + // 如果布局还没计算,跳过渲染(等待 UILayoutSystem 计算 worldOrderInLayer) + // Skip if layout not computed yet (wait for UILayoutSystem to calculate worldOrderInLayer) + if (!transform.layoutComputed) return null; + + if (!transform.worldVisible) return null; + + // 使用 layoutComputed 判断是否使用世界坐标 + // Use layoutComputed to determine whether to use world coordinates + const x = transform.layoutComputed ? transform.worldX : transform.x; + const y = transform.layoutComputed ? transform.worldY : transform.y; + const scaleX = transform.worldScaleX ?? transform.scaleX; + const scaleY = transform.worldScaleY ?? transform.scaleY; + const width = (transform.layoutComputed && transform.computedWidth > 0 + ? transform.computedWidth + : transform.width) * scaleX; + const height = (transform.layoutComputed && transform.computedHeight > 0 + ? transform.computedHeight + : transform.height) * scaleY; + const alpha = transform.worldAlpha ?? transform.alpha; + // 角度转弧度 | Convert degrees to radians + const rotationDegrees = transform.worldRotation ?? transform.rotation; + const rotation = (rotationDegrees * Math.PI) / 180; + const pivotX = transform.pivotX; + const pivotY = transform.pivotY; + // 使用继承自 Canvas 的排序层,如果没有则回退到组件本身的排序层 + // Use Canvas-inherited sorting layer, fallback to component's own sortingLayer + const sortingLayer = transform.worldSortingLayer ?? transform.sortingLayer; + const orderInLayer = transform.worldOrderInLayer; + + // Render position = bottom-left corner + pivot offset + // 渲染位置 = 左下角 + 锚点偏移 + const renderX = x + width * pivotX; + const renderY = y + height * pivotY; + + return { + x, + y, + width, + height, + alpha, + rotation, + pivotX, + pivotY, + sortingLayer, + orderInLayer, + renderX, + renderY + }; +} + +/** + * Border rendering options + * 边框渲染选项 + */ +export interface BorderRenderOptions { + /** Border width in pixels / 边框宽度(像素) */ + borderWidth: number; + /** Border color (0xRRGGBB) / 边框颜色 */ + borderColor: number; + /** Border alpha (0-1) / 边框透明度 */ + borderAlpha: number; +} + +/** + * Render a rectangular border + * 渲染矩形边框 + * + * @param collector - UIRenderCollector instance + * @param rt - Render transform data + * @param options - Border options + * @param entityId - Entity ID for debugging + * @param orderOffset - Order in layer offset (default: 0) + */ +export function renderBorder( + collector: UIRenderCollector, + rt: UIRenderTransform, + options: BorderRenderOptions, + entityId: number, + orderOffset: number = 0 +): void { + const { borderWidth, borderColor, borderAlpha } = options; + if (borderWidth <= 0 || borderAlpha <= 0) return; + + const alpha = borderAlpha * rt.alpha; + const orderInLayer = rt.orderInLayer + orderOffset; + + // Calculate rect boundaries relative to pivot center + // 计算矩形边界(相对于 pivot 中心) + const left = rt.renderX - rt.width * rt.pivotX; + const bottom = rt.renderY - rt.height * rt.pivotY; + const right = left + rt.width; + const top = bottom + rt.height; + const centerX = (left + right) / 2; + const centerY = (top + bottom) / 2; + + // Top border + collector.addRect( + centerX, top - borderWidth / 2, + rt.width, borderWidth, + borderColor, alpha, rt.sortingLayer, orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } + ); + + // Bottom border + collector.addRect( + centerX, bottom + borderWidth / 2, + rt.width, borderWidth, + borderColor, alpha, rt.sortingLayer, orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } + ); + + // Side borders (excluding corners) + const sideBorderHeight = rt.height - borderWidth * 2; + + // Left border + collector.addRect( + left + borderWidth / 2, centerY, + borderWidth, sideBorderHeight, + borderColor, alpha, rt.sortingLayer, orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } + ); + + // Right border + collector.addRect( + right - borderWidth / 2, centerY, + borderWidth, sideBorderHeight, + borderColor, alpha, rt.sortingLayer, orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } + ); +} + +/** + * Shadow rendering options + * 阴影渲染选项 + */ +export interface ShadowRenderOptions { + /** Shadow offset X / 阴影 X 偏移 */ + offsetX: number; + /** Shadow offset Y / 阴影 Y 偏移 */ + offsetY: number; + /** Shadow blur radius / 阴影模糊半径 */ + blur: number; + /** Shadow color (0xRRGGBB) / 阴影颜色 */ + color: number; + /** Shadow alpha (0-1) / 阴影透明度 */ + alpha: number; +} + +/** + * Render a shadow behind an element + * 渲染元素后的阴影 + * + * @param collector - UIRenderCollector instance + * @param rt - Render transform data + * @param options - Shadow options + * @param entityId - Entity ID for debugging + * @param orderOffset - Order in layer offset (default: -1 to render below) + */ +export function renderShadow( + collector: UIRenderCollector, + rt: UIRenderTransform, + options: ShadowRenderOptions, + entityId: number, + orderOffset: number = -1 +): void { + if (options.alpha <= 0) return; + + collector.addRect( + rt.renderX + options.offsetX, + rt.renderY + options.offsetY, + rt.width + options.blur * 2, + rt.height + options.blur * 2, + options.color, + options.alpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + orderOffset, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); +} + +/** + * Color interpolation (linear) + * 颜色线性插值 + * + * @param from - Start color (0xRRGGBB) + * @param to - End color (0xRRGGBB) + * @param t - Interpolation factor (0-1) + * @returns Interpolated color + */ +export function lerpColor(from: number, to: number, t: number): number { + const fromR = (from >> 16) & 0xFF; + const fromG = (from >> 8) & 0xFF; + const fromB = from & 0xFF; + + const toR = (to >> 16) & 0xFF; + const toG = (to >> 8) & 0xFF; + const toB = to & 0xFF; + + const r = Math.round(fromR + (toR - fromR) * t); + const g = Math.round(fromG + (toG - fromG) * t); + const b = Math.round(fromB + (toB - fromB) * t); + + return (r << 16) | (g << 8) | b; +} + +/** + * Pack color with alpha into ARGB format + * 将颜色和透明度打包为 ARGB 格式 + * + * @param color - Color (0xRRGGBB) + * @param alpha - Alpha (0-1) + * @returns Packed color (0xAARRGGBB) + */ +export function packColorWithAlpha(color: number, alpha: number): number { + const a = Math.round(alpha * 255) & 0xFF; + return (a << 24) | (color & 0xFFFFFF); +} + +/** + * Get nine-patch position and pivot for consistent rendering + * 获取九宫格位置和 pivot 以实现一致的渲染 + * + * NinePatch now uses the same coordinate system as regular rects: + * - Position is the pivot point (same as renderX/renderY) + * - Pivot values determine rotation center + * + * 九宫格现在使用与普通矩形相同的坐标系: + * - 位置是 pivot 点(与 renderX/renderY 相同) + * - pivot 值决定旋转中心 + * + * @param rt - Render transform data + * @returns Position and pivot for nine-patch rendering + */ +export function getNinePatchPosition(rt: UIRenderTransform): { + x: number; + y: number; + pivotX: number; + pivotY: number; +} { + return { + x: rt.renderX, + y: rt.renderY, + pivotX: rt.pivotX, + pivotY: rt.pivotY + }; +} + diff --git a/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts b/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts index b28085dc..bed3281f 100644 --- a/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts +++ b/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts @@ -11,6 +11,7 @@ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framewor import { UITransformComponent } from '../../components/UITransformComponent'; import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent'; import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, type UIRenderTransform } from './UIRenderUtils'; /** * UI ScrollView Render System @@ -29,7 +30,7 @@ import { getUIRenderCollector } from './UIRenderCollector'; * Note: The scrollview content area and clipping is handled by the layout system. * 注意:滚动视图内容区域和裁剪由布局系统处理。 */ -@ECSSystem('UIScrollViewRender', { updateOrder: 112 }) +@ECSSystem('UIScrollViewRender', { updateOrder: 112, runInEditMode: true }) export class UIScrollViewRenderSystem extends EntitySystem { constructor() { super(Matcher.empty().all(UITransformComponent, UIScrollViewComponent)); @@ -45,49 +46,30 @@ export class UIScrollViewRenderSystem extends EntitySystem { // 空值检查 | Null check if (!transform || !scrollView) continue; - if (!transform.worldVisible) continue; + // 确保添加 UIWidgetMarker + // Ensure UIWidgetMarker is added + ensureUIWidgetMarker(entity); - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放 - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const rotation = transform.worldRotation ?? transform.rotation; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 计算位置 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; - // 计算边界 - const baseX = renderX - width * pivotX; - const baseY = renderY - height * pivotY; + // 计算边界(左下角) + // Calculate bounds (bottom-left corner) + const baseX = rt.renderX - rt.width * rt.pivotX; + const baseY = rt.renderY - rt.height * rt.pivotY; // Render vertical scrollbar // 渲染垂直滚动条 - if (scrollView.needsVerticalScrollbar(height)) { - this.renderVerticalScrollbar( - collector, - baseX, baseY, width, height, - scrollView, alpha, sortingLayer, orderInLayer, rotation - ); + if (scrollView.needsVerticalScrollbar(rt.height)) { + this.renderVerticalScrollbar(collector, rt, baseX, baseY, scrollView, entity.id); } // Render horizontal scrollbar // 渲染水平滚动条 - if (scrollView.needsHorizontalScrollbar(width)) { - this.renderHorizontalScrollbar( - collector, - baseX, baseY, width, height, - scrollView, alpha, sortingLayer, orderInLayer, rotation - ); + if (scrollView.needsHorizontalScrollbar(rt.width)) { + this.renderHorizontalScrollbar(collector, rt, baseX, baseY, scrollView, entity.id); } } } @@ -98,21 +80,19 @@ export class UIScrollViewRenderSystem extends EntitySystem { */ private renderVerticalScrollbar( collector: ReturnType, - baseX: number, baseY: number, - viewWidth: number, viewHeight: number, + rt: UIRenderTransform, + baseX: number, + baseY: number, scrollView: UIScrollViewComponent, - alpha: number, - sortingLayer: string, - orderInLayer: number, - rotation: number + entityId: number ): void { const scrollbarWidth = scrollView.scrollbarWidth; - const hasHorizontal = scrollView.needsHorizontalScrollbar(viewWidth); - const trackHeight = hasHorizontal ? viewHeight - scrollbarWidth : viewHeight; + const hasHorizontal = scrollView.needsHorizontalScrollbar(rt.width); + const trackHeight = hasHorizontal ? rt.height - scrollbarWidth : rt.height; // Track position (right side of viewport) // 轨道位置(视口右侧) - const trackX = baseX + viewWidth - scrollbarWidth / 2; + const trackX = baseX + rt.width - scrollbarWidth / 2; const trackY = baseY + trackHeight / 2; // Render track @@ -122,16 +102,16 @@ export class UIScrollViewRenderSystem extends EntitySystem { trackX, trackY, scrollbarWidth, trackHeight, scrollView.scrollbarTrackColor, - scrollView.scrollbarTrackAlpha * alpha, - sortingLayer, - orderInLayer + 5, - { rotation, pivotX: 0.5, pivotY: 0.5 } + scrollView.scrollbarTrackAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 5, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } // Calculate handle metrics // 计算手柄尺寸 - const metrics = scrollView.getVerticalScrollbarMetrics(viewHeight); + const metrics = scrollView.getVerticalScrollbarMetrics(rt.height); const handleY = baseY + metrics.position + metrics.size / 2; // Handle alpha (different when hovered) @@ -146,10 +126,10 @@ export class UIScrollViewRenderSystem extends EntitySystem { trackX, handleY, scrollbarWidth - 2, metrics.size, scrollView.scrollbarColor, - handleAlpha * alpha, - sortingLayer, - orderInLayer + 6, - { rotation, pivotX: 0.5, pivotY: 0.5 } + handleAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 6, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } @@ -159,22 +139,20 @@ export class UIScrollViewRenderSystem extends EntitySystem { */ private renderHorizontalScrollbar( collector: ReturnType, - baseX: number, baseY: number, - viewWidth: number, viewHeight: number, + rt: UIRenderTransform, + baseX: number, + baseY: number, scrollView: UIScrollViewComponent, - alpha: number, - sortingLayer: string, - orderInLayer: number, - rotation: number + entityId: number ): void { const scrollbarWidth = scrollView.scrollbarWidth; - const hasVertical = scrollView.needsVerticalScrollbar(viewHeight); - const trackWidth = hasVertical ? viewWidth - scrollbarWidth : viewWidth; + const hasVertical = scrollView.needsVerticalScrollbar(rt.height); + const trackWidth = hasVertical ? rt.width - scrollbarWidth : rt.width; // Track position (bottom of viewport) // 轨道位置(视口底部) const trackX = baseX + trackWidth / 2; - const trackY = baseY + viewHeight - scrollbarWidth / 2; + const trackY = baseY + rt.height - scrollbarWidth / 2; // Render track // 渲染轨道 @@ -183,16 +161,16 @@ export class UIScrollViewRenderSystem extends EntitySystem { trackX, trackY, trackWidth, scrollbarWidth, scrollView.scrollbarTrackColor, - scrollView.scrollbarTrackAlpha * alpha, - sortingLayer, - orderInLayer + 5, - { rotation, pivotX: 0.5, pivotY: 0.5 } + scrollView.scrollbarTrackAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 5, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } // Calculate handle metrics // 计算手柄尺寸 - const metrics = scrollView.getHorizontalScrollbarMetrics(viewWidth); + const metrics = scrollView.getHorizontalScrollbarMetrics(rt.width); const handleX = baseX + metrics.position + metrics.size / 2; // Handle alpha (different when hovered) @@ -207,10 +185,10 @@ export class UIScrollViewRenderSystem extends EntitySystem { handleX, trackY, metrics.size, scrollbarWidth - 2, scrollView.scrollbarColor, - handleAlpha * alpha, - sortingLayer, - orderInLayer + 6, - { rotation, pivotX: 0.5, pivotY: 0.5 } + handleAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 6, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } } diff --git a/packages/ui/src/systems/render/UIShinyEffectSystem.ts b/packages/ui/src/systems/render/UIShinyEffectSystem.ts new file mode 100644 index 00000000..9e4be979 --- /dev/null +++ b/packages/ui/src/systems/render/UIShinyEffectSystem.ts @@ -0,0 +1,122 @@ +/** + * UI 元素闪光效果动画系统 + * Shiny effect animation system for UI elements + * + * 两种模式 | Two modes: + * 1. 组件控制 - UIShinyEffectComponent | Component-controlled + * 2. 自动动画 - Shiny 材质自动播放 | Auto-animation for Shiny material + */ + +import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework'; +import { ShinyEffectAnimator, BuiltInShaders } from '@esengine/material-system'; +import { UIShinyEffectComponent } from '../../components/UIShinyEffectComponent'; +import { UIRenderComponent } from '../../components/UIRenderComponent'; + +// 默认动画参数 | Default animation settings +const AUTO_ANIMATION_DEFAULTS = { + duration: 1.5, + delay: 2.0, + width: 0.15, + rotation: 30, + softness: 0.3, + brightness: 1.2, + gloss: 0.3 +}; + +interface AutoAnimState { + progress: number; + waiting: boolean; + waitTime: number; +} + +/** + * 闪光效果动画系统 + * Shiny effect animation system + */ +@ECSSystem('UIShinyEffect', { updateOrder: 98, runInEditMode: true }) +export class UIShinyEffectSystem extends EntitySystem { + private autoAnimStates: Map = new Map(); + + constructor() { + super(Matcher.empty().all(UIRenderComponent)); + } + + protected override process(entities: readonly Entity[]): void { + const deltaTime = Time.deltaTime; + const usedEntityIds = new Set(); + + for (const entity of entities) { + if (!entity.enabled) continue; + + const render = entity.getComponent(UIRenderComponent); + if (!render) continue; + + const shinyComponent = entity.getComponent(UIShinyEffectComponent); + + // 模式1: 组件控制 | Mode 1: Component-controlled + if (shinyComponent) { + if (shinyComponent.play) { + ShinyEffectAnimator.processEffect(shinyComponent, render, deltaTime); + } + continue; + } + + // 模式2: 自动动画 | Mode 2: Auto-animation + if (render.getMaterialId() !== BuiltInShaders.Shiny) { + continue; + } + + usedEntityIds.add(entity.id); + this.processAutoAnimation(entity.id, render, deltaTime); + } + + // 清理已移除实体 | Cleanup removed entities + for (const entityId of this.autoAnimStates.keys()) { + if (!usedEntityIds.has(entityId)) { + this.autoAnimStates.delete(entityId); + } + } + } + + /** + * 处理自动动画:系统控制 progress,其他属性用户可覆盖 + * Process auto-animation: system controls progress, other properties user-overridable + */ + private processAutoAnimation(entityId: number, render: UIRenderComponent, deltaTime: number): void { + let state = this.autoAnimStates.get(entityId); + if (!state) { + state = { progress: 0, waiting: false, waitTime: 0 }; + this.autoAnimStates.set(entityId, state); + } + + const { duration, delay, width, rotation, softness, brightness, gloss } = AUTO_ANIMATION_DEFAULTS; + + // 更新动画进度 | Update progress + if (state.waiting) { + state.waitTime += deltaTime; + if (state.waitTime >= delay) { + state.waiting = false; + state.waitTime = 0; + state.progress = 0; + } + } else { + state.progress += deltaTime / duration; + if (state.progress >= 1) { + state.progress = 0; + state.waiting = true; + state.waitTime = 0; + } + } + + // 系统控制 progress | System controls progress + render.setOverrideFloat('u_shinyProgress', state.progress); + + // 其他属性:用户值优先,否则用默认值 | Other props: user value or default + const overrides = render.materialOverrides; + if (!overrides['u_shinyWidth']) render.setOverrideFloat('u_shinyWidth', width); + if (!overrides['u_shinyRotation']) render.setOverrideFloat('u_shinyRotation', rotation * Math.PI / 180); + if (!overrides['u_shinySoftness']) render.setOverrideFloat('u_shinySoftness', softness); + if (!overrides['u_shinyBrightness']) render.setOverrideFloat('u_shinyBrightness', brightness); + if (!overrides['u_shinyGloss']) render.setOverrideFloat('u_shinyGloss', gloss); + } +} diff --git a/packages/ui/src/systems/render/UISliderRenderSystem.ts b/packages/ui/src/systems/render/UISliderRenderSystem.ts index 8dd2704d..bf3b5e06 100644 --- a/packages/ui/src/systems/render/UISliderRenderSystem.ts +++ b/packages/ui/src/systems/render/UISliderRenderSystem.ts @@ -11,6 +11,7 @@ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framewor import { UITransformComponent } from '../../components/UITransformComponent'; import { UISliderComponent, UISliderOrientation } from '../../components/widgets/UISliderComponent'; import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, type UIRenderTransform } from './UIRenderUtils'; /** * UI Slider Render System @@ -28,7 +29,7 @@ import { getUIRenderCollector } from './UIRenderCollector'; * - 手柄(可拖动的旋钮) * - 可选刻度 */ -@ECSSystem('UISliderRender', { updateOrder: 111 }) +@ECSSystem('UISliderRender', { updateOrder: 105, runInEditMode: true }) export class UISliderRenderSystem extends EntitySystem { constructor() { super(Matcher.empty().all(UITransformComponent, UISliderComponent)); @@ -44,94 +45,80 @@ export class UISliderRenderSystem extends EntitySystem { // 空值检查 | Null check if (!transform || !slider) continue; - if (!transform.worldVisible) continue; + // 确保添加 UIWidgetMarker + // Ensure UIWidgetMarker is added + ensureUIWidgetMarker(entity); - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放 - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const rotation = transform.worldRotation ?? transform.rotation; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 计算中心位置 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; + // 初始化 displayValue 和 targetValue(编辑器预览模式需要) + // Initialize displayValue and targetValue (needed for editor preview mode) + if (!slider._valueInitialized) { + slider.displayValue = slider.value; + slider.targetValue = slider.value; + slider._valueInitialized = true; + } + + // 使用工具函数获取渲染变换数据 + // Use utility function to get render transform data + const rt = getUIRenderTransform(transform); + if (!rt) continue; const isHorizontal = slider.orientation === UISliderOrientation.Horizontal; const progress = slider.getProgress(); - // Calculate track dimensions and position - // 计算轨道尺寸和位置 - const trackLength = isHorizontal ? width : height; + // Calculate track dimensions + // 计算轨道尺寸 + const trackLength = isHorizontal ? rt.width : rt.height; const trackThickness = slider.trackThickness; - // Calculate center position based on pivot - // 基于 pivot 计算中心位置 - const centerX = renderX; - const centerY = renderY; - // Render track (using center position with pivot 0.5) // 渲染轨道(使用中心位置,pivot 0.5) if (slider.trackAlpha > 0) { - if (isHorizontal) { - collector.addRect( - centerX, centerY, - trackLength, trackThickness, - slider.trackColor, - slider.trackAlpha * alpha, - sortingLayer, - orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - } else { - collector.addRect( - centerX, centerY, - trackThickness, trackLength, - slider.trackColor, - slider.trackAlpha * alpha, - sortingLayer, - orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } - ); - } + collector.addRect( + rt.renderX, rt.renderY, + isHorizontal ? trackLength : trackThickness, + isHorizontal ? trackThickness : trackLength, + slider.trackColor, + slider.trackAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId: entity.id } + ); } // Render fill // 渲染填充 - if (progress > 0 && slider.fillAlpha > 0) { + // Note: External Fill entity's size/position is controlled by UISliderFillSystem + // which modifies its anchors. UILayoutSystem then computes the correct layout. + // 注意:外部 Fill 实体的尺寸/位置由 UISliderFillSystem 通过修改锚点来控制。 + // UILayoutSystem 然后计算正确的布局。 + if (slider.fillRectEntityId <= 0 && progress > 0 && slider.fillAlpha > 0) { + // Built-in fill rendering + // 内置填充渲染 const fillLength = trackLength * progress; if (isHorizontal) { // Fill from left - const fillX = centerX - trackLength / 2 + fillLength / 2; + const fillX = rt.renderX - trackLength / 2 + fillLength / 2; collector.addRect( - fillX, centerY, + fillX, rt.renderY, fillLength, trackThickness, slider.fillColor, - slider.fillAlpha * alpha, - sortingLayer, - orderInLayer + 1, - { rotation, pivotX: 0.5, pivotY: 0.5 } + slider.fillAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 1, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId: entity.id } ); } else { // Fill from bottom - const fillY = centerY + trackLength / 2 - fillLength / 2; + const fillY = rt.renderY + trackLength / 2 - fillLength / 2; collector.addRect( - centerX, fillY, + rt.renderX, fillY, trackThickness, fillLength, slider.fillColor, - slider.fillAlpha * alpha, - sortingLayer, - orderInLayer + 1, - { rotation, pivotX: 0.5, pivotY: 0.5 } + slider.fillAlpha * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 1, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId: entity.id } ); } } @@ -139,23 +126,18 @@ export class UISliderRenderSystem extends EntitySystem { // Render ticks // 渲染刻度 if (slider.showTicks && slider.tickCount > 0) { - this.renderTicks( - collector, centerX, centerY, - trackLength, trackThickness, - slider, alpha, sortingLayer, orderInLayer, - isHorizontal, rotation - ); + this.renderTicks(collector, rt, trackLength, trackThickness, slider, isHorizontal, entity.id); } // Render handle // 渲染手柄 const handleColor = slider.getCurrentHandleColor(); const handleX = isHorizontal - ? centerX - trackLength / 2 + trackLength * progress - : centerX; + ? rt.renderX - trackLength / 2 + trackLength * progress + : rt.renderX; const handleY = isHorizontal - ? centerY - : centerY + trackLength / 2 - trackLength * progress; + ? rt.renderY + : rt.renderY + trackLength / 2 - trackLength * progress; // Handle shadow (if enabled) // 手柄阴影(如果启用) @@ -164,10 +146,10 @@ export class UISliderRenderSystem extends EntitySystem { handleX + 1, handleY + 2, slider.handleWidth, slider.handleHeight, 0x000000, - 0.3 * alpha, - sortingLayer, - orderInLayer + 2, - { rotation, pivotX: 0.5, pivotY: 0.5 } + 0.3 * rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId: entity.id } ); } @@ -177,10 +159,10 @@ export class UISliderRenderSystem extends EntitySystem { handleX, handleY, slider.handleWidth, slider.handleHeight, handleColor, - alpha, - sortingLayer, - orderInLayer + 3, - { rotation, pivotX: 0.5, pivotY: 0.5 } + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 3, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId: entity.id } ); // Handle border (if any) @@ -192,10 +174,11 @@ export class UISliderRenderSystem extends EntitySystem { slider.handleWidth, slider.handleHeight, slider.handleBorderWidth, slider.handleBorderColor, - alpha, - sortingLayer, - orderInLayer + 4, - rotation + rt.alpha, + rt.sortingLayer, + rt.orderInLayer + 4, + rt.rotation, + entity.id ); } } @@ -207,14 +190,12 @@ export class UISliderRenderSystem extends EntitySystem { */ private renderTicks( collector: ReturnType, - centerX: number, centerY: number, - trackLength: number, trackThickness: number, + rt: UIRenderTransform, + trackLength: number, + trackThickness: number, slider: UISliderComponent, - alpha: number, - sortingLayer: string, - orderInLayer: number, isHorizontal: boolean, - rotation: number + entityId: number ): void { const tickCount = slider.tickCount + 2; // Include start and end ticks const tickSize = slider.tickSize; @@ -228,13 +209,13 @@ export class UISliderRenderSystem extends EntitySystem { let tickHeight: number; if (isHorizontal) { - tickX = centerX - trackLength / 2 + trackLength * t; - tickY = centerY + trackThickness / 2 + tickSize / 2 + 2; + tickX = rt.renderX - trackLength / 2 + trackLength * t; + tickY = rt.renderY + trackThickness / 2 + tickSize / 2 + 2; tickWidth = 2; tickHeight = tickSize; } else { - tickX = centerX + trackThickness / 2 + tickSize / 2 + 2; - tickY = centerY + trackLength / 2 - trackLength * t; + tickX = rt.renderX + trackThickness / 2 + tickSize / 2 + 2; + tickY = rt.renderY + trackLength / 2 - trackLength * t; tickWidth = tickSize; tickHeight = 2; } @@ -243,10 +224,10 @@ export class UISliderRenderSystem extends EntitySystem { tickX, tickY, tickWidth, tickHeight, slider.tickColor, - alpha, - sortingLayer, - orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } + rt.alpha, + rt.sortingLayer, + rt.orderInLayer, + { rotation: rt.rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } } @@ -264,7 +245,8 @@ export class UISliderRenderSystem extends EntitySystem { alpha: number, sortingLayer: string, orderInLayer: number, - rotation: number + rotation: number, + entityId: number ): void { const halfW = width / 2; const halfH = height / 2; @@ -275,7 +257,7 @@ export class UISliderRenderSystem extends EntitySystem { x, y - halfH + halfB, width, borderWidth, borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } + { rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); // Bottom @@ -283,7 +265,7 @@ export class UISliderRenderSystem extends EntitySystem { x, y + halfH - halfB, width, borderWidth, borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } + { rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); // Left @@ -291,7 +273,7 @@ export class UISliderRenderSystem extends EntitySystem { x - halfW + halfB, y, borderWidth, height - borderWidth * 2, borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } + { rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); // Right @@ -299,7 +281,7 @@ export class UISliderRenderSystem extends EntitySystem { x + halfW - halfB, y, borderWidth, height - borderWidth * 2, borderColor, alpha, sortingLayer, orderInLayer, - { rotation, pivotX: 0.5, pivotY: 0.5 } + { rotation, pivotX: 0.5, pivotY: 0.5, entityId } ); } } diff --git a/packages/ui/src/systems/render/UITextRenderSystem.ts b/packages/ui/src/systems/render/UITextRenderSystem.ts index 77171b31..48bd7674 100644 --- a/packages/ui/src/systems/render/UITextRenderSystem.ts +++ b/packages/ui/src/systems/render/UITextRenderSystem.ts @@ -11,6 +11,7 @@ import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framewor import { UITransformComponent } from '../../components/UITransformComponent'; import { UITextComponent } from '../../components/UITextComponent'; import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector'; +import { getUIRenderTransform } from './UIRenderUtils'; /** * Text texture cache entry @@ -47,7 +48,7 @@ interface TextTextureCache { * 2. 缓存纹理以避免每帧重新生成 * 3. 向收集器提交纹理渲染原语 */ -@ECSSystem('UITextRender', { updateOrder: 120 }) +@ECSSystem('UITextRender', { updateOrder: 120, runInEditMode: true }) export class UITextRenderSystem extends EntitySystem { private textCanvas: HTMLCanvasElement | null = null; private textCtx: CanvasRenderingContext2D | null = null; @@ -55,6 +56,10 @@ export class UITextRenderSystem extends EntitySystem { private nextTextureId = 90000; private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null; private cacheInvalidationBound: () => void; + /** 检查纹理是否已就绪的回调 | Callback to check if texture is ready */ + private textureReadyChecker: ((id: number) => boolean) | null = null; + /** 待确认就绪的纹理 ID 集合 | Set of texture IDs pending ready confirmation */ + private pendingTextures: Set = new Set(); constructor() { super(Matcher.empty().all(UITransformComponent, UITextComponent)); @@ -90,9 +95,42 @@ export class UITextRenderSystem extends EntitySystem { this.onTextureCreated = callback; } + /** + * Set callback to check if texture is ready + * 设置检查纹理是否就绪的回调 + * + * This is used to verify that dynamically created textures + * have finished loading before caching them. + * 用于验证动态创建的纹理在缓存前已加载完成。 + */ + setTextureReadyChecker(checker: (id: number) => boolean): void { + this.textureReadyChecker = checker; + } + protected process(entities: readonly Entity[]): void { const collector = getUIRenderCollector(); + // 检查待确认的纹理是否已就绪 + // Check if pending textures are ready + if (this.pendingTextures.size > 0 && this.textureReadyChecker) { + const nowReady: number[] = []; + for (const textureId of this.pendingTextures) { + if (this.textureReadyChecker(textureId)) { + nowReady.push(textureId); + } + } + if (nowReady.length > 0) { + for (const id of nowReady) { + this.pendingTextures.delete(id); + } + // 纹理就绪后不需要做任何特殊处理! + // Rust 端的纹理已经从 1x1 占位符更新为真实内容。 + // 注意:不要调用 invalidateUIRenderCaches(),那会清除缓存导致无限循环。 + // No special action needed - Rust texture is already updated. + // Note: Do NOT call invalidateUIRenderCaches(), it would cause infinite loop. + } + } + for (const entity of entities) { const transform = entity.getComponent(UITransformComponent); const text = entity.getComponent(UITextComponent); @@ -101,49 +139,40 @@ export class UITextRenderSystem extends EntitySystem { // Null check - component may not be ready during deserialization or initialization if (!transform || !text) continue; - if (!transform.worldVisible || !text.text) continue; + // 使用工具函数获取渲染变换数据(包含 layoutComputed 检查) + // Use utility function to get render transform data (includes layoutComputed check) + const rt = getUIRenderTransform(transform); + if (!rt) continue; - const x = transform.worldX ?? transform.x; - const y = transform.worldY ?? transform.y; - // 使用世界缩放和旋转 - const scaleX = transform.worldScaleX ?? transform.scaleX; - const scaleY = transform.worldScaleY ?? transform.scaleY; - const rotation = transform.worldRotation ?? transform.rotation; - const width = (transform.computedWidth ?? transform.width) * scaleX; - const height = (transform.computedHeight ?? transform.height) * scaleY; - const alpha = transform.worldAlpha ?? transform.alpha; - // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer - const sortingLayer = transform.sortingLayer; - const orderInLayer = transform.worldOrderInLayer; - // 使用 transform 的 pivot 作为旋转/缩放中心 - const pivotX = transform.pivotX; - const pivotY = transform.pivotY; - // 渲染位置 = 左下角 + pivot 偏移 - const renderX = x + width * pivotX; - const renderY = y + height * pivotY; + // 跳过空文本 | Skip empty text + if (!text.text) continue; // Generate or retrieve cached texture // 生成或获取缓存的纹理 const textureId = this.getOrCreateTextTexture( - entity.id, text, Math.ceil(width), Math.ceil(height) + entity.id, text, Math.ceil(rt.width), Math.ceil(rt.height) ); if (textureId === null) continue; - // Use pivot position with transform's pivot values + // 文本渲染在背景之上 | Text renders above background + const textOrderInLayer = rt.orderInLayer + 1; + // 使用 transform 的 pivot 值作为旋转中心 + // Use pivot position with transform's pivot values collector.addRect( - renderX, renderY, - width, height, + rt.renderX, rt.renderY, + rt.width, rt.height, 0xFFFFFF, // White tint (color is baked into texture) - alpha, - sortingLayer, - orderInLayer + 1, // Text renders above background + rt.alpha, + rt.sortingLayer, + textOrderInLayer, // 使用调整后的 orderInLayer { - rotation, - pivotX, - pivotY, - textureId + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureId, + entityId: entity.id } ); } @@ -238,6 +267,15 @@ export class UITextRenderSystem extends EntitySystem { // 通知回调新纹理 if (this.onTextureCreated) { this.onTextureCreated(textureId, dataUrl); + // 如果有就绪检查器,将新纹理添加到待确认列表 + // If ready checker is available, add new texture to pending list + if (this.textureReadyChecker) { + this.pendingTextures.add(textureId); + } + } else { + // 警告:回调未设置(只输出一次) + // Warning: callback not set (output once only) + console.warn('[UITextRenderSystem] onTextureCreated callback not set! Text will not render.'); } // Update cache @@ -316,6 +354,7 @@ export class UITextRenderSystem extends EntitySystem { */ clearTextCache(): void { this.textTextureCache.clear(); + this.pendingTextures.clear(); } /** @@ -334,6 +373,8 @@ export class UITextRenderSystem extends EntitySystem { this.textCanvas = null; this.textCtx = null; this.textTextureCache.clear(); + this.pendingTextures.clear(); this.onTextureCreated = null; + this.textureReadyChecker = null; } } diff --git a/packages/ui/src/systems/render/UIToggleRenderSystem.ts b/packages/ui/src/systems/render/UIToggleRenderSystem.ts new file mode 100644 index 00000000..2aa858ea --- /dev/null +++ b/packages/ui/src/systems/render/UIToggleRenderSystem.ts @@ -0,0 +1,303 @@ +/** + * UI Toggle Render System + * UI Toggle 渲染系统 + * + * Renders UIToggleComponent as checkbox or switch. + * 将 UIToggleComponent 渲染为复选框或开关。 + */ + +import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework'; +import { UITransformComponent } from '../../components/UITransformComponent'; +import { UIToggleComponent } from '../../components/widgets/UIToggleComponent'; +import { getUIRenderCollector } from './UIRenderCollector'; +import { ensureUIWidgetMarker, getUIRenderTransform, lerpColor, type UIRenderTransform } from './UIRenderUtils'; + +/** + * UI Toggle Render System + * UI Toggle 渲染系统 + * + * Handles rendering of toggle/checkbox/switch components. + * 处理开关/复选框/切换组件的渲染。 + */ +@ECSSystem('UIToggleRender', { updateOrder: 114, runInEditMode: true }) +export class UIToggleRenderSystem extends EntitySystem { + constructor() { + // Match entities with both UITransformComponent and UIToggleComponent + // 匹配具有 UITransformComponent 和 UIToggleComponent 的实体 + super(Matcher.empty().all(UITransformComponent, UIToggleComponent)); + } + + protected process(entities: readonly Entity[]): void { + const collector = getUIRenderCollector(); + + for (const entity of entities) { + // Ensure entity has UIWidgetMarker for proper render system handling + // 确保实体有 UIWidgetMarker 以便正确处理渲染系统 + ensureUIWidgetMarker(entity); + + const transform = entity.getComponent(UITransformComponent); + const toggle = entity.getComponent(UIToggleComponent); + + if (!transform || !toggle) continue; + + // Get render transform data + // 获取渲染变换数据 + const rt = getUIRenderTransform(transform); + if (!rt) continue; + + // Render based on style + // 根据样式渲染 + switch (toggle.style) { + case 'checkbox': + this.renderCheckbox(collector, rt, toggle, entity.id); + break; + case 'switch': + this.renderSwitch(collector, rt, toggle, entity.id); + break; + case 'custom': + this.renderCustom(collector, rt, toggle, entity.id); + break; + } + } + } + + /** + * Render checkbox style toggle + * 渲染复选框样式的开关 + */ + private renderCheckbox( + collector: ReturnType, + rt: UIRenderTransform, + toggle: UIToggleComponent, + entityId: number + ): void { + const size = toggle.checkboxSize; + const bgColor = toggle.getCurrentBackgroundColor(); + const alpha = toggle.alpha * rt.alpha; + + // Calculate checkbox position (centered vertically in transform area) + // 计算复选框位置(在变换区域内垂直居中) + const boxX = rt.renderX; + const boxY = rt.renderY + (rt.height - size) / 2 * (rt.pivotY * 2 - 1); + + // Render checkbox background/border + // 渲染复选框背景/边框 + if (toggle.borderWidth > 0) { + // Border (slightly larger) + collector.addRect( + boxX, boxY, + size, size, + toggle.borderColor, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); + + // Inner background + const innerSize = size - toggle.borderWidth * 2; + collector.addRect( + boxX, boxY, + innerSize, innerSize, + toggle.isOn ? toggle.onColor : toggle.offColor, + alpha, + rt.sortingLayer, + rt.orderInLayer + 1, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); + } else { + // Just background + collector.addRect( + boxX, boxY, + size, size, + bgColor, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); + } + + // Render checkmark if on + // 如果开启则渲染勾选标记 + if (toggle.isOn || toggle.displayProgress > 0) { + const checkAlpha = alpha * toggle.displayProgress; + const checkSize = size * toggle.checkmarkRatio; + + if (toggle.checkmarkTextureGuid) { + // Use texture for checkmark + collector.addRect( + boxX, boxY, + checkSize, checkSize, + toggle.markColor, + checkAlpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureGuid: toggle.checkmarkTextureGuid, + entityId + } + ); + } else { + // Simple checkmark using two rotated rectangles + // 使用两个旋转的矩形简单勾选标记 + const strokeWidth = Math.max(2, size * 0.15); + const shortArm = checkSize * 0.4; + const longArm = checkSize * 0.7; + + // Short arm (bottom-left to center) + collector.addRect( + boxX - checkSize * 0.15, boxY - checkSize * 0.05, + shortArm, strokeWidth, + toggle.markColor, + checkAlpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + rotation: rt.rotation + Math.PI / 4, // 45 degrees + pivotX: 0, + pivotY: 0.5, + entityId + } + ); + + // Long arm (center to top-right) + collector.addRect( + boxX + checkSize * 0.05, boxY + checkSize * 0.05, + longArm, strokeWidth, + toggle.markColor, + checkAlpha, + rt.sortingLayer, + rt.orderInLayer + 2, + { + rotation: rt.rotation - Math.PI / 4, // -45 degrees + pivotX: 0, + pivotY: 0.5, + entityId + } + ); + } + } + } + + /** + * Render switch style toggle + * 渲染开关样式的开关 + */ + private renderSwitch( + collector: ReturnType, + rt: UIRenderTransform, + toggle: UIToggleComponent, + entityId: number + ): void { + const width = toggle.switchWidth; + const height = toggle.switchHeight; + const alpha = toggle.alpha * rt.alpha; + + // Calculate switch position (centered in transform area) + // 计算开关位置(在变换区域内居中) + const switchX = rt.renderX; + const switchY = rt.renderY + (rt.height - height) / 2 * (rt.pivotY * 2 - 1); + + // Background color interpolation based on progress + // 根据进度插值背景颜色 + const bgColor = lerpColor(toggle.offColor, toggle.onColor, toggle.displayProgress); + + // Render switch track (background) + // 渲染开关轨道(背景) + collector.addRect( + switchX, switchY, + width, height, + toggle.disabled ? toggle.disabledColor : bgColor, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + entityId + } + ); + + // Calculate knob position + // 计算滑块位置 + const knobSize = toggle.getKnobSize(); + const knobTravel = width - knobSize - toggle.knobPadding * 2; + const knobOffset = toggle.knobPadding + knobTravel * toggle.displayProgress; + + // Knob position relative to switch + const knobX = switchX - width * rt.pivotX + knobOffset + knobSize / 2; + const knobY = switchY; + + // Render knob + // 渲染滑块 + collector.addRect( + knobX, knobY, + knobSize, knobSize, + toggle.markColor, + alpha, + rt.sortingLayer, + rt.orderInLayer + 1, + { + rotation: rt.rotation, + pivotX: 0.5, + pivotY: rt.pivotY, + entityId + } + ); + } + + /** + * Render custom style toggle (texture-based) + * 渲染自定义样式的开关(基于纹理) + */ + private renderCustom( + collector: ReturnType, + rt: UIRenderTransform, + toggle: UIToggleComponent, + entityId: number + ): void { + const alpha = toggle.alpha * rt.alpha; + const textureGuid = toggle.getCurrentTextureGuid(); + + if (textureGuid) { + collector.addRect( + rt.renderX, rt.renderY, + rt.width, rt.height, + toggle.disabled ? toggle.disabledColor : 0xFFFFFF, + alpha, + rt.sortingLayer, + rt.orderInLayer, + { + rotation: rt.rotation, + pivotX: rt.pivotX, + pivotY: rt.pivotY, + textureGuid, + entityId + } + ); + } else { + // Fallback to checkbox style + this.renderCheckbox(collector, rt, toggle, entityId); + } + } +} diff --git a/packages/ui/src/systems/render/index.ts b/packages/ui/src/systems/render/index.ts index 32d6e837..4d1086d6 100644 --- a/packages/ui/src/systems/render/index.ts +++ b/packages/ui/src/systems/render/index.ts @@ -18,16 +18,42 @@ export { registerCacheInvalidationCallback, unregisterCacheInvalidationCallback, invalidateUIRenderCaches, + requestTextureForAtlas, + clearTextureRequestCache, type UIRenderPrimitive, - type ProviderRenderData + type ProviderRenderData, + type UIMaterialPropertyOverride, + type UIMaterialOverrides, + type BatchBreakReason, + type BatchDebugInfo } from './UIRenderCollector'; // Render systems // 渲染系统 export { UIRenderBeginSystem } from './UIRenderBeginSystem'; +export { UIGraphicRenderSystem } from './UIGraphicRenderSystem'; export { UIRectRenderSystem } from './UIRectRenderSystem'; export { UITextRenderSystem } from './UITextRenderSystem'; export { UIButtonRenderSystem } from './UIButtonRenderSystem'; export { UIProgressBarRenderSystem } from './UIProgressBarRenderSystem'; export { UISliderRenderSystem } from './UISliderRenderSystem'; export { UIScrollViewRenderSystem } from './UIScrollViewRenderSystem'; +export { UIToggleRenderSystem } from './UIToggleRenderSystem'; +export { UIInputFieldRenderSystem } from './UIInputFieldRenderSystem'; +export { UIDropdownRenderSystem } from './UIDropdownRenderSystem'; +export { UIShinyEffectSystem } from './UIShinyEffectSystem'; + +// Render utilities +// 渲染工具 +export { + ensureUIWidgetMarker, + getUIRenderTransform, + renderBorder, + renderShadow, + lerpColor, + packColorWithAlpha, + getNinePatchPosition, + type UIRenderTransform, + type BorderRenderOptions, + type ShadowRenderOptions +} from './UIRenderUtils'; diff --git a/packages/ui/src/tokens.ts b/packages/ui/src/tokens.ts index c562528b..bfa682ec 100644 --- a/packages/ui/src/tokens.ts +++ b/packages/ui/src/tokens.ts @@ -6,6 +6,8 @@ import { createServiceToken } from '@esengine/ecs-framework'; import type { UILayoutSystem } from './systems/UILayoutSystem'; import type { UIInputSystem } from './systems/UIInputSystem'; +import type { UIAnimationSystem } from './systems/UIAnimationSystem'; +import type { UISelectableStateSystem } from './systems/UISelectableStateSystem'; import type { UIRenderDataProvider } from './systems/UIRenderDataProvider'; import type { UITextRenderSystem } from './systems/render'; @@ -36,3 +38,15 @@ export const UIRenderProviderToken = createServiceToken('u * UI text render system token */ export const UITextRenderSystemToken = createServiceToken('uiTextRenderSystem'); + +/** + * UI 动画系统令牌 + * UI animation system token + */ +export const UIAnimationSystemToken = createServiceToken('uiAnimationSystem'); + +/** + * UI 可选择状态系统令牌 + * UI selectable state system token + */ +export const UISelectableStateSystemToken = createServiceToken('uiSelectableStateSystem'); diff --git a/packages/ui/src/utils/TextMeasureService.ts b/packages/ui/src/utils/TextMeasureService.ts new file mode 100644 index 00000000..80636ef7 --- /dev/null +++ b/packages/ui/src/utils/TextMeasureService.ts @@ -0,0 +1,308 @@ +/** + * Text Measure Service + * 文本测量服务 + * + * Provides text measurement utilities for UI components. + * 为 UI 组件提供文本测量工具。 + */ + +/** + * Font configuration for text measurement + * 文本测量的字体配置 + */ +export interface TextMeasureFont { + fontSize: number; + fontFamily: string; + fontWeight: string | number; +} + +/** + * Character position info + * 字符位置信息 + */ +export interface CharacterPosition { + /** Character index | 字符索引 */ + index: number; + /** X position from text start | 从文本开始的 X 位置 */ + x: number; + /** Character width | 字符宽度 */ + width: number; +} + +/** + * Line info for multi-line text + * 多行文本的行信息 + */ +export interface LineInfo { + /** Line index | 行索引 */ + lineIndex: number; + /** Start character index | 起始字符索引 */ + startIndex: number; + /** End character index (exclusive) | 结束字符索引(不包含) */ + endIndex: number; + /** Line text content | 行文本内容 */ + text: string; + /** Line width in pixels | 行宽度(像素) */ + width: number; +} + +/** + * Text Measure Service + * 文本测量服务 + * + * Uses Canvas 2D API for accurate text measurement. + * 使用 Canvas 2D API 进行精确的文本测量。 + */ +class TextMeasureServiceImpl { + private canvas: HTMLCanvasElement | null = null; + private ctx: CanvasRenderingContext2D | null = null; + private currentFont: string = ''; + + /** + * Get or create canvas context + * 获取或创建 canvas 上下文 + */ + private getContext(): CanvasRenderingContext2D | null { + if (!this.canvas) { + if (typeof document === 'undefined') return null; + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d'); + } + return this.ctx; + } + + /** + * Set font for measurement + * 设置测量用的字体 + */ + private setFont(font: TextMeasureFont): void { + const ctx = this.getContext(); + if (!ctx) return; + + const fontString = `${font.fontWeight} ${font.fontSize}px ${font.fontFamily}`; + if (this.currentFont !== fontString) { + this.currentFont = fontString; + ctx.font = fontString; + } + } + + /** + * Measure text width + * 测量文本宽度 + */ + public measureText(text: string, font: TextMeasureFont): number { + const ctx = this.getContext(); + if (!ctx) return text.length * font.fontSize * 0.6; // Fallback estimate + + this.setFont(font); + return ctx.measureText(text).width; + } + + /** + * Measure single character width + * 测量单个字符宽度 + */ + public measureChar(char: string, font: TextMeasureFont): number { + return this.measureText(char, font); + } + + /** + * Get character positions for a text string + * 获取文本字符串中每个字符的位置 + */ + public getCharacterPositions(text: string, font: TextMeasureFont): CharacterPosition[] { + const ctx = this.getContext(); + if (!ctx) { + // Fallback: estimate with average character width + const avgWidth = font.fontSize * 0.6; + return text.split('').map((_, i) => ({ + index: i, + x: i * avgWidth, + width: avgWidth + })); + } + + this.setFont(font); + const positions: CharacterPosition[] = []; + let currentX = 0; + + for (let i = 0; i < text.length; i++) { + const char = text[i]!; + const charWidth = ctx.measureText(char).width; + positions.push({ + index: i, + x: currentX, + width: charWidth + }); + currentX += charWidth; + } + + return positions; + } + + /** + * Get character index at x position + * 获取 x 位置处的字符索引 + * + * @param text - Text string | 文本字符串 + * @param font - Font configuration | 字体配置 + * @param x - X position relative to text start | 相对于文本开始的 X 位置 + * @returns Character index (0 to text.length) | 字符索引(0 到 text.length) + */ + public getCharIndexAtX(text: string, font: TextMeasureFont, x: number): number { + if (text.length === 0 || x <= 0) return 0; + + const positions = this.getCharacterPositions(text, font); + const totalWidth = positions.length > 0 + ? positions[positions.length - 1]!.x + positions[positions.length - 1]!.width + : 0; + + if (x >= totalWidth) return text.length; + + // Find the character at position x + // 找到位置 x 处的字符 + for (let i = 0; i < positions.length; i++) { + const pos = positions[i]!; + const charCenter = pos.x + pos.width / 2; + + if (x < charCenter) { + return i; + } + } + + return text.length; + } + + /** + * Get x position for character index + * 获取字符索引的 x 位置 + */ + public getXForCharIndex(text: string, font: TextMeasureFont, index: number): number { + if (index <= 0) return 0; + if (index >= text.length) { + return this.measureText(text, font); + } + + const substring = text.substring(0, index); + return this.measureText(substring, font); + } + + /** + * Get line info for multi-line text + * 获取多行文本的行信息 + */ + public getLineInfo(text: string, font: TextMeasureFont): LineInfo[] { + const lines: LineInfo[] = []; + const textLines = text.split('\n'); + let charIndex = 0; + + for (let i = 0; i < textLines.length; i++) { + const lineText = textLines[i]!; + const width = this.measureText(lineText, font); + + lines.push({ + lineIndex: i, + startIndex: charIndex, + endIndex: charIndex + lineText.length, + text: lineText, + width + }); + + // +1 for the newline character (except last line) + charIndex += lineText.length + (i < textLines.length - 1 ? 1 : 0); + } + + return lines; + } + + /** + * Get line index for character position + * 获取字符位置所在的行索引 + */ + public getLineIndexForChar(text: string, charIndex: number): number { + const lines = text.split('\n'); + let currentIndex = 0; + + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i]!.length; + if (charIndex <= currentIndex + lineLength) { + return i; + } + currentIndex += lineLength + 1; // +1 for newline + } + + return lines.length - 1; + } + + /** + * Get character index for line and column + * 获取行和列对应的字符索引 + */ + public getCharIndexForLineColumn(text: string, lineIndex: number, column: number): number { + const lines = text.split('\n'); + let charIndex = 0; + + for (let i = 0; i < lineIndex && i < lines.length; i++) { + charIndex += lines[i]!.length + 1; // +1 for newline + } + + if (lineIndex < lines.length) { + const line = lines[lineIndex]!; + charIndex += Math.min(column, line.length); + } + + return Math.min(charIndex, text.length); + } + + /** + * Get column (x offset) for character in its line + * 获取字符在其所在行的列位置(x 偏移) + */ + public getColumnForChar(text: string, charIndex: number): number { + const lineIndex = this.getLineIndexForChar(text, charIndex); + const lines = text.split('\n'); + let lineStartIndex = 0; + + for (let i = 0; i < lineIndex; i++) { + lineStartIndex += lines[i]!.length + 1; + } + + return charIndex - lineStartIndex; + } + + /** + * Dispose resources + * 释放资源 + */ + public dispose(): void { + this.canvas = null; + this.ctx = null; + this.currentFont = ''; + } +} + +// Global singleton instance +// 全局单例实例 +let globalTextMeasureService: TextMeasureServiceImpl | null = null; + +/** + * Get the global text measure service + * 获取全局文本测量服务 + */ +export function getTextMeasureService(): TextMeasureServiceImpl { + if (!globalTextMeasureService) { + globalTextMeasureService = new TextMeasureServiceImpl(); + } + return globalTextMeasureService; +} + +/** + * Dispose the global text measure service + * 释放全局文本测量服务 + */ +export function disposeTextMeasureService(): void { + if (globalTextMeasureService) { + globalTextMeasureService.dispose(); + globalTextMeasureService = null; + } +} diff --git a/packages/ui/src/utils/UIDirtyFlags.ts b/packages/ui/src/utils/UIDirtyFlags.ts new file mode 100644 index 00000000..ab7f7767 --- /dev/null +++ b/packages/ui/src/utils/UIDirtyFlags.ts @@ -0,0 +1,202 @@ +/** + * UI Dirty Flags - Unified change tracking for UI components + * UI 脏标记 - UI 组件的统一变更追踪 + * + * This module provides a standardized way to track component changes + * and optimize rendering by skipping unchanged elements. + * 此模块提供标准化的组件变更追踪方式,通过跳过未变化的元素来优化渲染。 + */ + +/** + * Dirty flag types for different aspects of UI components + * UI 组件不同方面的脏标记类型 + * + * Using bit flags allows combining multiple dirty states efficiently. + * 使用位标志可以高效地组合多个脏状态。 + */ +export const enum UIDirtyFlags { + /** No changes | 无变化 */ + None = 0, + + /** Visual properties changed (color, alpha, texture) | 视觉属性变化 */ + Visual = 1 << 0, + + /** Layout properties changed (position, size, anchor) | 布局属性变化 */ + Layout = 1 << 1, + + /** Transform properties changed (rotation, scale) | 变换属性变化 */ + Transform = 1 << 2, + + /** Material properties changed | 材质属性变化 */ + Material = 1 << 3, + + /** Text content changed | 文本内容变化 */ + Text = 1 << 4, + + /** All flags | 所有标记 */ + All = Visual | Layout | Transform | Material | Text +} + +/** + * Dirty tracking mixin interface + * 脏追踪混入接口 + * + * Components implementing this interface can be checked for changes. + * 实现此接口的组件可以被检查变化。 + */ +export interface IDirtyTrackable { + /** Current dirty flags | 当前脏标记 */ + _dirtyFlags: UIDirtyFlags; + + /** + * Check if any dirty flags are set + * 检查是否有任何脏标记 + */ + isDirty(): boolean; + + /** + * Check if specific dirty flags are set + * 检查是否设置了特定的脏标记 + */ + hasDirtyFlag(flags: UIDirtyFlags): boolean; + + /** + * Set dirty flags + * 设置脏标记 + */ + markDirty(flags: UIDirtyFlags): void; + + /** + * Clear all dirty flags + * 清除所有脏标记 + */ + clearDirtyFlags(): void; + + /** + * Clear specific dirty flags + * 清除特定的脏标记 + */ + clearDirtyFlag(flags: UIDirtyFlags): void; +} + +/** + * Create a property descriptor that marks the component as dirty on change + * 创建在变化时标记组件为脏的属性描述符 + * + * @param dirtyFlag - Which flag to set on change | 变化时设置哪个标记 + * @returns Property decorator | 属性装饰器 + * + * @example + * ```typescript + * class MyComponent implements IDirtyTrackable { + * _dirtyFlags = UIDirtyFlags.None; + * + * private _color = 0xFFFFFF; + * + * @DirtyOnChange(UIDirtyFlags.Visual) + * get color() { return this._color; } + * set color(value: number) { this._color = value; } + * } + * ``` + */ +export function DirtyOnChange(dirtyFlag: UIDirtyFlags): PropertyDecorator { + return function (target: object, propertyKey: string | symbol) { + const privateKey = `_${String(propertyKey)}`; + + Object.defineProperty(target, propertyKey, { + get(this: IDirtyTrackable & Record) { + return this[privateKey]; + }, + set(this: IDirtyTrackable & Record, value: unknown) { + if (this[privateKey] !== value) { + this[privateKey] = value; + this.markDirty(dirtyFlag); + } + }, + enumerable: true, + configurable: true + }); + }; +} + +/** + * Helper class to implement dirty tracking + * 实现脏追踪的辅助类 + * + * @example + * ```typescript + * class MyComponent extends Component implements IDirtyTrackable { + * _dirtyFlags = UIDirtyFlags.None; + * isDirty = () => DirtyTracker.isDirty(this); + * hasDirtyFlag = (flags: UIDirtyFlags) => DirtyTracker.hasDirtyFlag(this, flags); + * markDirty = (flags: UIDirtyFlags) => DirtyTracker.markDirty(this, flags); + * clearDirtyFlags = () => DirtyTracker.clearDirtyFlags(this); + * clearDirtyFlag = (flags: UIDirtyFlags) => DirtyTracker.clearDirtyFlag(this, flags); + * } + * ``` + */ +export const DirtyTracker = { + isDirty(component: IDirtyTrackable): boolean { + return component._dirtyFlags !== UIDirtyFlags.None; + }, + + hasDirtyFlag(component: IDirtyTrackable, flags: UIDirtyFlags): boolean { + return (component._dirtyFlags & flags) !== 0; + }, + + markDirty(component: IDirtyTrackable, flags: UIDirtyFlags): void { + component._dirtyFlags |= flags; + }, + + clearDirtyFlags(component: IDirtyTrackable): void { + component._dirtyFlags = UIDirtyFlags.None; + }, + + clearDirtyFlag(component: IDirtyTrackable, flags: UIDirtyFlags): void { + component._dirtyFlags &= ~flags; + } +}; + +/** + * Frame-level dirty tracking for global state + * 帧级别的全局状态脏追踪 + * + * Tracks whether any UI component changed this frame. + * 追踪本帧是否有任何 UI 组件发生变化。 + */ +let frameDirty = false; +let dirtyComponentCount = 0; + +/** + * Mark the frame as dirty (at least one component changed) + * 标记帧为脏(至少有一个组件变化) + */ +export function markFrameDirty(): void { + frameDirty = true; + dirtyComponentCount++; +} + +/** + * Check if any UI component is dirty this frame + * 检查本帧是否有任何 UI 组件为脏 + */ +export function isFrameDirty(): boolean { + return frameDirty; +} + +/** + * Get the number of dirty components this frame + * 获取本帧脏组件的数量 + */ +export function getDirtyComponentCount(): number { + return dirtyComponentCount; +} + +/** + * Clear frame dirty state (call at frame end) + * 清除帧脏状态(在帧结束时调用) + */ +export function clearFrameDirty(): void { + frameDirty = false; + dirtyComponentCount = 0; +} diff --git a/packages/ui/src/utils/UITextureUtils.ts b/packages/ui/src/utils/UITextureUtils.ts new file mode 100644 index 00000000..349efb3c --- /dev/null +++ b/packages/ui/src/utils/UITextureUtils.ts @@ -0,0 +1,162 @@ +/** + * UI Texture Utilities + * UI 纹理工具 + * + * Unified texture handling for UI components. + * 统一的 UI 组件纹理处理。 + */ + +import { isValidGUID } from '@esengine/asset-system'; + +/** + * Texture descriptor for UI components + * UI 组件的纹理描述符 + * + * Provides a unified way to describe texture resources across UI components. + * 为 UI 组件提供统一的纹理资源描述方式。 + */ +export interface UITextureDescriptor { + /** Asset GUID (from asset system) | 资产 GUID(来自资产系统) */ + guid?: string; + + /** Runtime texture ID | 运行时纹理 ID */ + textureId?: number; + + /** Texture file path (for dynamic atlas loading) | 纹理文件路径(用于动态图集加载) */ + path?: string; + + /** Source texture width | 源纹理宽度 */ + width?: number; + + /** Source texture height | 源纹理高度 */ + height?: number; + + /** UV coordinates [u0, v0, u1, v1] | UV 坐标 */ + uv?: [number, number, number, number]; +} + +/** + * Nine-patch texture descriptor + * 九宫格纹理描述符 + */ +export interface UINinePatchDescriptor extends UITextureDescriptor { + /** Nine-patch margins [top, right, bottom, left] | 九宫格边距 */ + margins: [number, number, number, number]; +} + +/** + * Check if a texture descriptor is valid + * 检查纹理描述符是否有效 + */ +export function isValidTexture(texture: UITextureDescriptor | undefined | null): boolean { + if (!texture) return false; + return !!(texture.guid || texture.textureId || texture.path); +} + +/** + * Check if a GUID string is a valid asset GUID + * 检查 GUID 字符串是否是有效的资产 GUID + */ +export function isValidTextureGuid(guid: string | undefined | null): boolean { + if (!guid) return false; + return isValidGUID(guid); +} + +/** + * Get texture key for batching (atlas or direct texture) + * 获取用于合批的纹理键(图集或直接纹理) + */ +export function getTextureKey(texture: UITextureDescriptor | undefined): string { + if (!texture) return 'solid'; + if (texture.guid) return texture.guid; + if (texture.textureId) return `id:${texture.textureId}`; + if (texture.path) return `path:${texture.path}`; + return 'solid'; +} + +/** + * Create default UV coordinates + * 创建默认 UV 坐标 + */ +export function defaultUV(): [number, number, number, number] { + return [0, 0, 1, 1]; +} + +/** + * Normalize texture descriptor from various input formats + * 从各种输入格式规范化纹理描述符 + * + * @param input - String (GUID), number (textureId), or descriptor + * @returns Normalized texture descriptor + */ +export function normalizeTextureDescriptor( + input: string | number | UITextureDescriptor | undefined | null +): UITextureDescriptor | undefined { + if (input === undefined || input === null) return undefined; + + if (typeof input === 'string') { + if (!input) return undefined; + return { guid: input }; + } + + if (typeof input === 'number') { + if (input <= 0) return undefined; + return { textureId: input }; + } + + return input; +} + +/** + * Extract texture GUID from various sources + * 从各种来源提取纹理 GUID + */ +export function extractTextureGuid( + source: string | number | UITextureDescriptor | undefined | null +): string | undefined { + const descriptor = normalizeTextureDescriptor(source); + return descriptor?.guid; +} + +/** + * Merge texture descriptors (later values override earlier) + * 合并纹理描述符(后面的值覆盖前面的) + */ +export function mergeTextureDescriptors( + ...descriptors: (UITextureDescriptor | undefined | null)[] +): UITextureDescriptor { + const result: UITextureDescriptor = {}; + + for (const d of descriptors) { + if (!d) continue; + if (d.guid !== undefined) result.guid = d.guid; + if (d.textureId !== undefined) result.textureId = d.textureId; + if (d.path !== undefined) result.path = d.path; + if (d.width !== undefined) result.width = d.width; + if (d.height !== undefined) result.height = d.height; + if (d.uv !== undefined) result.uv = [...d.uv]; + } + + return result; +} + +/** + * Check if nine-patch margins are valid + * 检查九宫格边距是否有效 + */ +export function isValidNinePatchMargins(margins: [number, number, number, number] | undefined): boolean { + if (!margins) return false; + return margins.some(m => m > 0); +} + +/** + * Calculate nine-patch minimum size based on margins + * 根据边距计算九宫格最小尺寸 + */ +export function getNinePatchMinSize(margins: [number, number, number, number]): { width: number; height: number } { + const [top, right, bottom, left] = margins; + return { + width: left + right, + height: top + bottom + }; +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts new file mode 100644 index 00000000..578695ea --- /dev/null +++ b/packages/ui/src/utils/index.ts @@ -0,0 +1,40 @@ +/** + * UI Utilities + * UI 工具函数 + */ + +export { + // Texture utilities | 纹理工具 + type UITextureDescriptor, + type UINinePatchDescriptor, + isValidTexture, + isValidTextureGuid, + getTextureKey, + defaultUV, + normalizeTextureDescriptor, + extractTextureGuid, + mergeTextureDescriptors, + isValidNinePatchMargins, + getNinePatchMinSize +} from './UITextureUtils'; + +export { + // Dirty flag utilities | 脏标记工具 + UIDirtyFlags, + type IDirtyTrackable, + DirtyOnChange, + DirtyTracker, + markFrameDirty, + isFrameDirty, + getDirtyComponentCount, + clearFrameDirty +} from './UIDirtyFlags'; + +export { + // Text measure utilities | 文本测量工具 + getTextMeasureService, + disposeTextMeasureService, + type TextMeasureFont, + type CharacterPosition, + type LineInfo +} from './TextMeasureService'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5985a2b9..66a8d85e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1500,6 +1500,9 @@ importers: '@esengine/engine-core': specifier: workspace:* version: link:../engine-core + '@esengine/material-system': + specifier: workspace:* + version: link:../material-system rimraf: specifier: ^5.0.5 version: 5.0.10 @@ -1647,6 +1650,9 @@ importers: '@esengine/engine-core': specifier: workspace:* version: link:../engine-core + '@esengine/material-system': + specifier: workspace:* + version: link:../material-system rimraf: specifier: ^5.0.5 version: 5.0.10 @@ -1675,6 +1681,9 @@ importers: '@esengine/editor-runtime': specifier: workspace:* version: link:../editor-runtime + '@esengine/material-system': + specifier: workspace:* + version: link:../material-system '@types/react': specifier: ^18.3.12 version: 18.3.27