refactor(ui): UI 系统架构重构 (#309)
* feat(ui): 动态图集系统与渲染调试增强 ## 核心功能 ### 动态图集系统 (Dynamic Atlas) - 新增 DynamicAtlasManager:运行时纹理打包,支持 MaxRects 算法 - 新增 DynamicAtlasService:自动纹理加载与图集管理 - 新增 BinPacker:高效矩形打包算法 - 支持动态/固定两种扩展策略 - 自动 UV 重映射,实现 UI 元素合批渲染 ### Frame Debugger 增强 - 新增合批分析面板,显示批次中断原因 - 新增 UI 元素层级信息(depth, worldOrderInLayer) - 新增实体高亮功能,点击可在场景中定位 - 新增动态图集可视化面板 - 改进渲染原语详情展示 ### 闪光效果 (Shiny Effect) - 新增 UIShinyEffectComponent:UI 闪光参数配置 - 新增 UIShinyEffectSystem:材质覆盖驱动的闪光动画 - 新增 ShinyEffectComponent/System(Sprite 版本) ## 引擎层改进 ### Rust 纹理管理扩展 - create_blank_texture:创建空白 GPU 纹理 - update_texture_region:局部纹理更新 - 支持动态图集的 GPU 端操作 ### 材质系统 - 新增 effects/ 目录:ShinyEffect 等效果实现 - 新增 interfaces/ 目录:IMaterial 等接口定义 - 新增 mixins/ 目录:可组合的材质功能 ### EngineBridge 扩展 - 新增 createBlankTexture/updateTextureRegion 方法 - 改进纹理加载回调机制 ## UI 渲染改进 - UIRenderCollector:支持合批调试信息 - 稳定排序:addIndex 保证渲染顺序一致性 - 九宫格渲染优化 - 材质覆盖支持 ## 其他改进 - 国际化:新增 Frame Debugger 相关翻译 - 编辑器:新增渲染调试入口 - 文档:新增架构设计文档目录 * refactor(ui): 引入新基础组件架构与渲染工具函数 Phase 1 重构 - 组件职责分离与代码复用: 新增基础组件层: - UIGraphicComponent: 所有可视 UI 元素的基类(颜色、透明度、raycast) - UIImageComponent: 纹理显示组件(支持简单、切片、平铺、填充模式) - UISelectableComponent: 可交互元素的基类(状态管理、颜色过渡) 新增渲染工具: - UIRenderUtils: 提取共享的坐标计算、边框渲染、阴影渲染等工具函数 - getUIRenderTransform: 统一的变换数据提取 - renderBorder/renderShadow: 复用的边框和阴影渲染逻辑 新增渲染系统: - UIGraphicRenderSystem: 处理新基础组件的统一渲染器 重构现有系统: - UIRectRenderSystem: 使用新工具函数,移除重复代码 - UIButtonRenderSystem: 使用新工具函数,移除重复代码 这些改动为后续统一渲染系统奠定基础。 * refactor(ui): UIProgressBarRenderSystem 使用渲染工具函数 - 使用 getUIRenderTransform 替代手动变换计算 - 使用 renderBorder 工具函数替代重复的边框渲染 - 使用 lerpColor 工具函数替代重复的颜色插值 - 简化方法签名,使用 UIRenderTransform 类型 - 移除约 135 行重复代码 * refactor(ui): Slider 和 ScrollView 渲染系统使用工具函数 - UISliderRenderSystem: 使用 getUIRenderTransform,简化方法签名 - UIScrollViewRenderSystem: 使用 getUIRenderTransform,简化方法签名 - 统一使用 UIRenderTransform 类型减少参数传递 - 消除重复的变换计算代码 * refactor(ui): 使用 UIWidgetMarker 消除硬编码组件依赖 - 新增 UIWidgetMarker 标记组件 - UIRectRenderSystem 改为检查标记而非硬编码4种组件类型 - 各 Widget 渲染系统自动添加标记组件 - 减少模块间耦合,提高可扩展性 * feat(ui): 实现 Canvas 隔离机制 - 新增 UICanvasComponent 定义 Canvas 渲染组 - UITransformComponent 添加 Canvas 相关字段:canvasEntityId, worldSortingLayer, pixelPerfect - UILayoutSystem 传播 Canvas 设置给子元素 - UIRenderUtils 使用 Canvas 继承的排序层 - 支持嵌套 Canvas 和不同渲染模式 * refactor(ui): 统一纹理管理工具函数 Phase 4: 纹理管理统一 新增: - UITextureUtils.ts: 统一的纹理描述符接口和验证函数 - UITextureDescriptor: 支持 GUID/textureId/path 多种纹理源 - isValidTextureGuid: GUID 验证 - getTextureKey: 获取用于合批的纹理键 - normalizeTextureDescriptor: 规范化各种输入格式 - utils/index.ts: 工具函数导出 修改: - UIGraphicRenderSystem: 使用新的纹理工具函数 - index.ts: 导出纹理工具类型和函数 * refactor(ui): 实现统一的脏标记机制 Phase 5: Dirty 标记机制 新增: - UIDirtyFlags.ts: 位标记枚举和追踪工具 - UIDirtyFlags: Visual/Layout/Transform/Material/Text 标记 - IDirtyTrackable: 脏追踪接口 - DirtyTracker: 辅助工具类 - 帧级别脏状态追踪 (markFrameDirty, isFrameDirty) 修改: - UIGraphicComponent: 实现 IDirtyTrackable - 属性 setter 自动设置脏标记 - 保留 setDirty/clearDirty 向后兼容 - UIImageComponent: 所有属性支持脏追踪 - textureGuid/imageType/fillAmount 等变化自动标记 - UIGraphicRenderSystem: 使用 clearDirtyFlags() 导出: - UIDirtyFlags, IDirtyTrackable, DirtyTracker - markFrameDirty, isFrameDirty, clearFrameDirty * refactor(ui): 移除过时的 dirty flag API 移除 UIGraphicComponent 中的兼容性 API: - 移除 _isDirty getter/setter - 移除 setDirty() 方法 - 移除 clearDirty() 方法 现在统一使用新的 dirty flag 系统: - isDirty() / hasDirtyFlag(flags) - markDirty(flags) / clearDirtyFlags() * fix(ui): 修复两个 TODO 功能 1. 滑块手柄命中测试 (UIInputSystem) - UISliderComponent 添加 getHandleBounds() 计算手柄边界 - UISliderComponent 添加 isPointInHandle() 精确命中测试 - UIInputSystem.handleSlider() 使用精确测试更新悬停状态 2. 径向填充渲染 (UIGraphicRenderSystem) - 实现 renderRadialFill() 方法 - 支持 radial90/radial180/radial360 三种模式 - 支持 fillOrigin (top/right/bottom/left) 和 fillClockwise - 使用多段矩形近似饼形填充效果 * feat(ui): 完善 UI 系统架构和九宫格渲染 * fix(ui): 修复文本渲染层级问题并清理调试代码 - 修复纹理就绪后调用 invalidateUIRenderCaches() 导致的无限循环 - 移除 UITextRenderSystem、UIButtonRenderSystem、UIRectRenderSystem 中的首帧调试输出 - 移除 UILayoutSystem 中的布局调试日志 - 清理所有 __UI_RENDER_DEBUG__ 条件日志 * refactor(ui): 优化渲染批处理和输入框组件 渲染系统: - 修复 RenderBatcher 保持渲染顺序 - 优化 Rust SpriteBatch 避免合并非连续精灵 - 增强 EngineRenderSystem 纹理就绪检测 输入框组件: - 增强 UIInputFieldComponent 功能 - 改进 UIInputSystem 输入处理 - 新增 TextMeasureService 文本测量服务 * fix(ui): 修复九宫格首帧渲染和InputField输入问题 - 修复九宫格首帧 size=0x0 问题: - Viewport.tsx: 预览模式读取图片尺寸存储到 importSettings - AssetDatabase: ISpriteSettings 添加 width/height 字段 - AssetMetadataService: getTextureSpriteInfo 使用元数据尺寸作为后备 - UIRectRenderSystem: 当 atlasEntry 不存在时使用 spriteInfo 尺寸 - WebBuildPipeline: 构建时包含 importSettings - AssetManager: 从 catalog 初始化时复制 importSettings - AssetTypes: IAssetCatalogEntry 添加 importSettings 字段 - 修复 InputField 无法输入问题: - UIRuntimeModule: manifest 添加 pluginExport: 'UIPlugin' - 确保预览模式正确加载 UI 插件并绑定 UIInputSystem - 添加调试日志用于排查纹理加载问题 * fix(sprite): 修复类型导出错误 MaterialPropertyOverride 和 MaterialOverrides 应从 @esengine/material-system 导出 * fix(ui-editor): 补充 AnchorPreset 拉伸预设的映射 添加 StretchTop, StretchMiddle, StretchBottom, StretchLeft, StretchCenter, StretchRight 的位置和锚点值映射
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
280
packages/ui/src/atlas/BinPacker.ts
Normal file
280
packages/ui/src/atlas/BinPacker.ts
Normal file
@@ -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 }];
|
||||
}
|
||||
}
|
||||
669
packages/ui/src/atlas/DynamicAtlasManager.ts
Normal file
669
packages/ui/src/atlas/DynamicAtlasManager.ts
Normal file
@@ -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<string, AtlasEntry> = new Map();
|
||||
|
||||
/** Stored textures for rebuild during expansion (only used in Dynamic mode) */
|
||||
/** 存储的纹理数据,用于扩展时重建(仅在动态模式下使用) */
|
||||
private storedTextures: Map<string, StoredTexture> = 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<string, AtlasEntry>();
|
||||
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;
|
||||
}
|
||||
506
packages/ui/src/atlas/DynamicAtlasService.ts
Normal file
506
packages/ui/src/atlas/DynamicAtlasService.ts
Normal file
@@ -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<string, TextureLoadState>();
|
||||
|
||||
/** Pending load promises | 待处理的加载 Promise */
|
||||
private loadPromises = new Map<string, Promise<AtlasEntry | null>>();
|
||||
|
||||
/** 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<AtlasEntry | null> {
|
||||
// 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<AtlasEntry | null> {
|
||||
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<HTMLImageElement> {
|
||||
// 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<Map<string, AtlasEntry | null>> {
|
||||
const results = new Map<string, AtlasEntry | null>();
|
||||
|
||||
// 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<string, string>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
29
packages/ui/src/atlas/index.ts
Normal file
29
packages/ui/src/atlas/index.ts
Normal file
@@ -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';
|
||||
201
packages/ui/src/components/UICanvasComponent.ts
Normal file
201
packages/ui/src/components/UICanvasComponent.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
174
packages/ui/src/components/UIShinyEffectComponent.ts
Normal file
174
packages/ui/src/components/UIShinyEffectComponent.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
43
packages/ui/src/components/UIWidgetMarker.ts
Normal file
43
packages/ui/src/components/UIWidgetMarker.ts
Normal file
@@ -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
|
||||
// 标记组件 - 不需要数据
|
||||
}
|
||||
173
packages/ui/src/components/base/UIGraphicComponent.ts
Normal file
173
packages/ui/src/components/base/UIGraphicComponent.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
291
packages/ui/src/components/base/UIImageComponent.ts
Normal file
291
packages/ui/src/components/base/UIImageComponent.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
384
packages/ui/src/components/base/UISelectableComponent.ts
Normal file
384
packages/ui/src/components/base/UISelectableComponent.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
30
packages/ui/src/components/base/index.ts
Normal file
30
packages/ui/src/components/base/index.ts
Normal file
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
428
packages/ui/src/components/widgets/UIDropdownComponent.ts
Normal file
428
packages/ui/src/components/widgets/UIDropdownComponent.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
681
packages/ui/src/components/widgets/UIInputFieldComponent.ts
Normal file
681
packages/ui/src/components/widgets/UIInputFieldComponent.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
337
packages/ui/src/components/widgets/UIToggleComponent.ts
Normal file
337
packages/ui/src/components/widgets/UIToggleComponent.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
* 当前屏幕信息
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<number, Entity> = 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;
|
||||
}
|
||||
|
||||
92
packages/ui/src/systems/UISelectableStateSystem.ts
Normal file
92
packages/ui/src/systems/UISelectableStateSystem.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
119
packages/ui/src/systems/UISliderFillSystem.ts
Normal file
119
packages/ui/src/systems/UISliderFillSystem.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getUIRenderCollector>,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
240
packages/ui/src/systems/render/UIDropdownRenderSystem.ts
Normal file
240
packages/ui/src/systems/render/UIDropdownRenderSystem.ts
Normal file
@@ -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<typeof getUIRenderCollector>,
|
||||
rt: NonNullable<ReturnType<typeof getUIRenderTransform>>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
rt: NonNullable<ReturnType<typeof getUIRenderTransform>>,
|
||||
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 渲染
|
||||
}
|
||||
}
|
||||
}
|
||||
387
packages/ui/src/systems/render/UIGraphicRenderSystem.ts
Normal file
387
packages/ui/src/systems/render/UIGraphicRenderSystem.ts
Normal file
@@ -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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
424
packages/ui/src/systems/render/UIInputFieldRenderSystem.ts
Normal file
424
packages/ui/src/systems/render/UIInputFieldRenderSystem.ts
Normal file
@@ -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<number, InputTextCache> = 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<number> = 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<typeof getUIRenderCollector>,
|
||||
input: UIInputFieldComponent,
|
||||
rt: ReturnType<typeof getUIRenderTransform>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
input: UIInputFieldComponent,
|
||||
rt: ReturnType<typeof getUIRenderTransform>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
input: UIInputFieldComponent,
|
||||
rt: ReturnType<typeof getUIRenderTransform>,
|
||||
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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
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<typeof getUIRenderCollector>,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, UIMaterialPropertyOverride>;
|
||||
|
||||
/**
|
||||
* 合批打断原因
|
||||
* 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<string, UIRenderPrimitive[]>();
|
||||
const batchDebugInfos: BatchDebugInfo[] = [];
|
||||
// 每个批次的 entityId 集合 | Entity ID set per batch
|
||||
const batchEntityIds = new Map<string, Set<number>>();
|
||||
|
||||
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<number>());
|
||||
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<string>();
|
||||
|
||||
// 日志节流相关 | Log throttling related
|
||||
// 已警告过的纹理 GUID(避免重复警告)
|
||||
// Warned texture GUIDs (avoid duplicate warnings)
|
||||
const warnedTextureGuids = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
326
packages/ui/src/systems/render/UIRenderUtils.ts
Normal file
326
packages/ui/src/systems/render/UIRenderUtils.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
122
packages/ui/src/systems/render/UIShinyEffectSystem.ts
Normal file
122
packages/ui/src/systems/render/UIShinyEffectSystem.ts
Normal file
@@ -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<number, AutoAnimState> = new Map();
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UIRenderComponent));
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const deltaTime = Time.deltaTime;
|
||||
const usedEntityIds = new Set<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getUIRenderCollector>,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
303
packages/ui/src/systems/render/UIToggleRenderSystem.ts
Normal file
303
packages/ui/src/systems/render/UIToggleRenderSystem.ts
Normal file
@@ -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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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<typeof getUIRenderCollector>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<UIRenderDataProvider>('u
|
||||
* UI text render system token
|
||||
*/
|
||||
export const UITextRenderSystemToken = createServiceToken<UITextRenderSystem>('uiTextRenderSystem');
|
||||
|
||||
/**
|
||||
* UI 动画系统令牌
|
||||
* UI animation system token
|
||||
*/
|
||||
export const UIAnimationSystemToken = createServiceToken<UIAnimationSystem>('uiAnimationSystem');
|
||||
|
||||
/**
|
||||
* UI 可选择状态系统令牌
|
||||
* UI selectable state system token
|
||||
*/
|
||||
export const UISelectableStateSystemToken = createServiceToken<UISelectableStateSystem>('uiSelectableStateSystem');
|
||||
|
||||
308
packages/ui/src/utils/TextMeasureService.ts
Normal file
308
packages/ui/src/utils/TextMeasureService.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
202
packages/ui/src/utils/UIDirtyFlags.ts
Normal file
202
packages/ui/src/utils/UIDirtyFlags.ts
Normal file
@@ -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<string, unknown>) {
|
||||
return this[privateKey];
|
||||
},
|
||||
set(this: IDirtyTrackable & Record<string, unknown>, 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;
|
||||
}
|
||||
162
packages/ui/src/utils/UITextureUtils.ts
Normal file
162
packages/ui/src/utils/UITextureUtils.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
40
packages/ui/src/utils/index.ts
Normal file
40
packages/ui/src/utils/index.ts
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user