Files
esengine/packages/ui/src/components/widgets/UISliderComponent.ts
YHH 536c4c5593 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 的位置和锚点值映射
2025-12-19 15:33:36 +08:00

479 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 滑块方向
* Slider orientation
*/
export enum UISliderOrientation {
Horizontal = 'horizontal',
Vertical = 'vertical'
}
/**
* UI 滑块组件
* UI Slider Component - Value slider with handle
*/
@ECSComponent('UISlider')
@Serializable({ version: 1, typeId: 'UISlider' })
export class UISliderComponent extends Component {
// ===== 数值 Values =====
/**
* 当前值
* Current value
*/
@Serialize()
@Property({ type: 'number', label: 'Value' })
public value: number = 0;
/**
* 最小值
* Minimum value
*/
@Serialize()
@Property({ type: 'number', label: 'Min Value' })
public minValue: number = 0;
/**
* 最大值
* Maximum value
*/
@Serialize()
@Property({ type: 'number', label: 'Max Value' })
public maxValue: number = 100;
/**
* 步进值0 = 连续)
* Step value (0 = continuous)
*/
@Serialize()
@Property({ type: 'number', label: 'Step', min: 0 })
public step: number = 0;
/**
* 目标值(用于动画)
* Target value (for animation)
*/
public targetValue: number = 0;
/**
* 显示值(动画插值后)
* Display value (interpolated)
*/
public displayValue: number = 0;
/**
* 值是否已初始化(用于编辑器预览)
* Whether value has been initialized (for editor preview)
*/
public _valueInitialized: boolean = false;
// ===== 方向 Orientation =====
/**
* 滑块方向
* Slider orientation
*/
@Serialize()
@Property({
type: 'enum',
label: 'Orientation',
options: [
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' }
]
})
public orientation: UISliderOrientation = UISliderOrientation.Horizontal;
// ===== 轨道样式 Track Style =====
/**
* 轨道颜色
* Track color
*/
@Serialize()
@Property({ type: 'color', label: 'Track Color' })
public trackColor: number = 0x444444;
/**
* 轨道透明度
* Track alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Track Alpha', min: 0, max: 1, step: 0.01 })
public trackAlpha: number = 1;
/**
* 轨道高度(水平)或宽度(垂直)
* Track thickness
*/
@Serialize()
@Property({ type: 'number', label: 'Track Thickness', min: 1 })
public trackThickness: number = 4;
/**
* 轨道圆角
* Track corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Track Radius', min: 0 })
public trackRadius: number = 2;
// ===== 填充样式 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)
*/
@Serialize()
@Property({ type: 'color', label: 'Fill Color' })
public fillColor: number = 0x4A90D9;
/**
* 填充透明度
* Fill alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Fill Alpha', min: 0, max: 1, step: 0.01 })
public fillAlpha: number = 1;
// ===== 手柄样式 Handle Style =====
/**
* 手柄宽度
* Handle width
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Width', min: 1 })
public handleWidth: number = 16;
/**
* 手柄高度
* Handle height
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Height', min: 1 })
public handleHeight: number = 16;
/**
* 手柄颜色
* Handle color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Color' })
public handleColor: number = 0xFFFFFF;
/**
* 手柄悬停颜色
* Handle hover color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Hover Color' })
public handleHoverColor: number = 0xE0E0E0;
/**
* 手柄按下颜色
* Handle pressed color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Pressed Color' })
public handlePressedColor: number = 0xCCCCCC;
/**
* 手柄圆角
* Handle corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Radius', min: 0 })
public handleRadius: number = 8;
/**
* 手柄边框宽度
* Handle border width
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Border Width', min: 0 })
public handleBorderWidth: number = 0;
/**
* 手柄边框颜色
* Handle border color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Border Color' })
public handleBorderColor: number = 0x000000;
/**
* 手柄阴影
* Handle shadow enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Handle Shadow' })
public handleShadow: boolean = true;
// ===== 交互状态 Interaction State =====
/**
* 手柄是否被悬停
* Whether handle is hovered
*/
public handleHovered: boolean = false;
/**
* 是否正在拖拽
* Whether currently dragging
*/
public dragging: boolean = false;
/**
* 拖拽起始值
* Drag start value
*/
public dragStartValue: number = 0;
/**
* 拖拽起始位置
* Drag start position
*/
public dragStartPosition: number = 0;
// ===== 动画 Animation =====
/**
* 过渡时长(秒)
* Transition duration in seconds
*/
@Serialize()
@Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 })
public transitionDuration: number = 0.1;
// ===== 刻度 Ticks =====
/**
* 是否显示刻度
* Whether to show ticks
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Ticks' })
public showTicks: boolean = false;
/**
* 刻度数量(不包括首尾)
* Number of ticks (excluding ends)
*/
@Serialize()
@Property({ type: 'integer', label: 'Tick Count', min: 0 })
public tickCount: number = 4;
/**
* 刻度颜色
* Tick color
*/
@Serialize()
@Property({ type: 'color', label: 'Tick Color' })
public tickColor: number = 0x666666;
/**
* 刻度大小
* Tick size
*/
@Serialize()
@Property({ type: 'number', label: 'Tick Size', min: 1 })
public tickSize: number = 4;
// ===== 文本 Text =====
/**
* 是否显示值文本
* Whether to show value text
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Value' })
public showValue: boolean = false;
/**
* 值文本格式
* Value text format
*/
@Serialize()
@Property({ type: 'string', label: 'Value Format' })
public valueFormat: string = '{value}';
/**
* 小数位数
* Decimal places
*/
@Serialize()
@Property({ type: 'integer', label: 'Decimal Places', min: 0 })
public decimalPlaces: number = 0;
// ===== 回调 Callbacks =====
/**
* 值改变回调
* Value change callback
*/
public onChange?: (value: number) => void;
/**
* 拖拽开始回调
* Drag start callback
*/
public onDragStart?: (value: number) => void;
/**
* 拖拽结束回调
* Drag end callback
*/
public onDragEnd?: (value: number) => void;
/**
* 获取进度百分比 (0-1)
* Get progress as percentage (0-1)
*/
public getProgress(): number {
const range = this.maxValue - this.minValue;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (this.displayValue - this.minValue) / range));
}
/**
* 从百分比设置值
* Set value from percentage
*/
public setProgress(progress: number): this {
const range = this.maxValue - this.minValue;
return this.setValue(this.minValue + range * Math.max(0, Math.min(1, progress)));
}
/**
* 设置值
* Set value
*/
public setValue(value: number, animate: boolean = true): this {
let newValue = Math.max(this.minValue, Math.min(this.maxValue, value));
// 应用步进
if (this.step > 0) {
newValue = Math.round((newValue - this.minValue) / this.step) * this.step + this.minValue;
}
this.targetValue = newValue;
if (!animate) {
this.value = newValue;
this.displayValue = newValue;
}
return this;
}
/**
* 获取格式化的值文本
* Get formatted value text
*/
public getFormattedValue(): string {
const formattedValue = this.displayValue.toFixed(this.decimalPlaces);
return this.valueFormat.replace('{value}', formattedValue);
}
/**
* 计算手柄位置(归一化 0-1
* Calculate handle position (normalized 0-1)
*/
public getHandlePosition(): number {
return this.getProgress();
}
/**
* 获取当前手柄颜色
* Get current handle color based on state
*/
public getCurrentHandleColor(): number {
if (this.dragging) return this.handlePressedColor;
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;
}
}