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

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

## 核心功能

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

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

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

## 引擎层改进

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Phase 4: 纹理管理统一

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

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

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

Phase 5: Dirty 标记机制

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,262 @@
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Implements hit testing algorithms for various gizmo types in TypeScript.
* 在 TypeScript 端实现各种 Gizmo 类型的命中测试算法。
*/
import type {
IGizmoRenderData,
IRectGizmoData,
ICircleGizmoData,
ILineGizmoData,
ICapsuleGizmoData
} from './IGizmoProvider';
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Provides static methods for testing if a point intersects with various gizmo shapes.
* 提供静态方法来测试点是否与各种 gizmo 形状相交。
*/
export class GizmoHitTester {
/** Line hit tolerance in world units (adjusted by zoom) | 线条命中容差(世界单位,根据缩放调整) */
private static readonly BASE_LINE_TOLERANCE = 8;
/**
* Test if point is inside a rect gizmo (considers rotation and origin)
* 测试点是否在矩形 gizmo 内(考虑旋转和原点)
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param rect Rect gizmo data | 矩形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestRect(worldX: number, worldY: number, rect: IRectGizmoData): boolean {
const cx = rect.x;
const cy = rect.y;
const halfW = rect.width / 2;
const halfH = rect.height / 2;
const rotation = rect.rotation || 0;
// Transform point to rect's local coordinate system (inverse rotation)
// 将点转换到矩形的本地坐标系(逆旋转)
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Adjust for origin offset
// 根据原点偏移调整
const originOffsetX = (rect.originX - 0.5) * rect.width;
const originOffsetY = (rect.originY - 0.5) * rect.height;
const adjustedX = localX + originOffsetX;
const adjustedY = localY + originOffsetY;
return adjustedX >= -halfW && adjustedX <= halfW &&
adjustedY >= -halfH && adjustedY <= halfH;
}
/**
* Test if point is inside a circle gizmo
* 测试点是否在圆形 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param circle Circle gizmo data | 圆形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCircle(worldX: number, worldY: number, circle: ICircleGizmoData): boolean {
const dx = worldX - circle.x;
const dy = worldY - circle.y;
const distSq = dx * dx + dy * dy;
return distSq <= circle.radius * circle.radius;
}
/**
* Test if point is near a line gizmo
* 测试点是否在线条 gizmo 附近
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param line Line gizmo data | 线条 gizmo 数据
* @param tolerance Hit tolerance in world units | 命中容差(世界单位)
* @returns True if point is within tolerance of line | 如果点在线条容差范围内返回 true
*/
static hitTestLine(worldX: number, worldY: number, line: ILineGizmoData, tolerance: number): boolean {
const points = line.points;
if (points.length < 2) return false;
const count = line.closed ? points.length : points.length - 1;
for (let i = 0; i < count; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
if (this.pointToSegmentDistance(worldX, worldY, p1.x, p1.y, p2.x, p2.y) <= tolerance) {
return true;
}
}
return false;
}
/**
* Test if point is inside a capsule gizmo
* 测试点是否在胶囊 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param capsule Capsule gizmo data | 胶囊 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCapsule(worldX: number, worldY: number, capsule: ICapsuleGizmoData): boolean {
const cx = capsule.x;
const cy = capsule.y;
const rotation = capsule.rotation || 0;
// Transform point to capsule's local coordinate system
// 将点转换到胶囊的本地坐标系
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Capsule = two half-circles + middle rectangle
// 胶囊 = 两个半圆 + 中间矩形
const topCircleY = capsule.halfHeight;
const bottomCircleY = -capsule.halfHeight;
// Check if inside middle rectangle
// 检查是否在中间矩形内
if (Math.abs(localY) <= capsule.halfHeight && Math.abs(localX) <= capsule.radius) {
return true;
}
// Check if inside top half-circle
// 检查是否在上半圆内
const distToTopSq = localX * localX + (localY - topCircleY) * (localY - topCircleY);
if (distToTopSq <= capsule.radius * capsule.radius) {
return true;
}
// Check if inside bottom half-circle
// 检查是否在下半圆内
const distToBottomSq = localX * localX + (localY - bottomCircleY) * (localY - bottomCircleY);
if (distToBottomSq <= capsule.radius * capsule.radius) {
return true;
}
return false;
}
/**
* Generic hit test for any gizmo type
* 通用命中测试,适用于任何 gizmo 类型
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param gizmo Gizmo data | Gizmo 数据
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns True if point hits the gizmo | 如果点命中 gizmo 返回 true
*/
static hitTest(worldX: number, worldY: number, gizmo: IGizmoRenderData, zoom: number = 1): boolean {
// Convert screen pixel tolerance to world units
// 将屏幕像素容差转换为世界单位
const lineTolerance = this.BASE_LINE_TOLERANCE / zoom;
switch (gizmo.type) {
case 'rect':
return this.hitTestRect(worldX, worldY, gizmo);
case 'circle':
return this.hitTestCircle(worldX, worldY, gizmo);
case 'line':
return this.hitTestLine(worldX, worldY, gizmo, lineTolerance);
case 'capsule':
return this.hitTestCapsule(worldX, worldY, gizmo);
case 'grid':
// Grid typically doesn't need hit testing
// 网格通常不需要命中测试
return false;
default:
return false;
}
}
/**
* Calculate distance from point to line segment
* 计算点到线段的距离
*
* @param px Point X | 点 X
* @param py Point Y | 点 Y
* @param x1 Segment start X | 线段起点 X
* @param y1 Segment start Y | 线段起点 Y
* @param x2 Segment end X | 线段终点 X
* @param y2 Segment end Y | 线段终点 Y
* @returns Distance from point to segment | 点到线段的距离
*/
private static pointToSegmentDistance(
px: number, py: number,
x1: number, y1: number,
x2: number, y2: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
// Segment degenerates to a point
// 线段退化为点
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
}
// Calculate projection parameter t
// 计算投影参数 t
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t));
// Nearest point on segment
// 线段上最近的点
const nearestX = x1 + t * dx;
const nearestY = y1 + t * dy;
return Math.sqrt((px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY));
}
/**
* Get the center point of any gizmo
* 获取任意 gizmo 的中心点
*
* @param gizmo Gizmo data | Gizmo 数据
* @returns Center point { x, y } | 中心点 { x, y }
*/
static getGizmoCenter(gizmo: IGizmoRenderData): { x: number; y: number } {
switch (gizmo.type) {
case 'rect':
case 'circle':
case 'capsule':
return { x: gizmo.x, y: gizmo.y };
case 'line':
if (gizmo.points.length === 0) return { x: 0, y: 0 };
const sumX = gizmo.points.reduce((sum, p) => sum + p.x, 0);
const sumY = gizmo.points.reduce((sum, p) => sum + p.y, 0);
return {
x: sumX / gizmo.points.length,
y: sumY / gizmo.points.length
};
case 'grid':
return {
x: gizmo.x + gizmo.width / 2,
y: gizmo.y + gizmo.height / 2
};
default:
return { x: 0, y: 0 };
}
}
}

View File

@@ -10,3 +10,4 @@
export * from './IGizmoProvider';
export * from './GizmoRegistry';
export * from './GizmoHitTester';

View File

@@ -394,12 +394,28 @@ export class AssetRegistryService implements IService {
// 处理文件创建 - 注册新资产并生成 .meta
if (changeType === 'create' || changeType === 'modify') {
for (const absolutePath of paths) {
// Handle .meta file changes - invalidate cache
// 处理 .meta 文件变化 - 使缓存失效
// Handle .meta file changes - invalidate cache and notify listeners
// 处理 .meta 文件变化 - 使缓存失效并通知监听者
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
// Notify listeners that the asset's metadata has changed
// 通知监听者资产的元数据已变化
const relativePath = this.absoluteToRelative(assetPath);
if (relativePath) {
const metadata = this._database.getMetadataByPath(relativePath);
if (metadata) {
this._messageHub?.publish('assets:changed', {
type: 'modify',
path: assetPath,
relativePath,
guid: metadata.guid
});
logger.debug(`Published assets:changed for meta file: ${relativePath}`);
}
}
continue;
}

View File

@@ -868,6 +868,7 @@ ${userScriptImports}
type: string;
size: number;
hash: string;
importSettings?: Record<string, unknown>;
}>
};
@@ -952,7 +953,10 @@ ${userScriptImports}
path: relativePath,
type: assetType,
size,
hash: hashFileInfo(relativePath, size)
hash: hashFileInfo(relativePath, size),
// Include importSettings for sprite slicing info (nine-patch, etc.)
// 包含 importSettings 以支持精灵切片信息(九宫格等)
importSettings: meta.importSettings
};
addedEntries++;
} catch (error) {

View File

@@ -0,0 +1,302 @@
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
import { Core } from '@esengine/ecs-framework';
import type { Entity, ComponentType } from '@esengine/ecs-framework';
import { GizmoHitTester } from '../Gizmos/GizmoHitTester';
import { GizmoRegistry } from '../Gizmos/GizmoRegistry';
import type { IGizmoRenderData, GizmoColor } from '../Gizmos/IGizmoProvider';
/**
* Gizmo hit result
* Gizmo 命中结果
*/
export interface GizmoHitResult {
/** Hit gizmo data | 命中的 Gizmo 数据 */
gizmo: IGizmoRenderData;
/** Associated entity ID | 关联的实体 ID */
entityId: number;
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
distance: number;
}
/**
* Gizmo interaction service interface
* Gizmo 交互服务接口
*/
export interface IGizmoInteractionService {
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null;
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void;
/**
* Get highlight color for entity (applies hover effect if applicable)
* 获取实体的高亮颜色(如果适用则应用悬停效果)
*
* @param entityId Entity ID | 实体 ID
* @param baseColor Base gizmo color | 基础 Gizmo 颜色
* @param isSelected Whether entity is selected | 实体是否被选中
* @returns Adjusted color | 调整后的颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor;
/**
* Handle click at position, return hit entity ID
* 处理位置点击,返回命中的实体 ID
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns Hit entity ID or null | 命中的实体 ID 或 null
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null;
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void;
}
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
export class GizmoInteractionService implements IGizmoInteractionService {
private hoveredEntityId: number | null = null;
private hoveredGizmo: IGizmoRenderData | null = null;
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
/** Hover alpha boost | 悬停时 Alpha 增量 */
private static readonly HOVER_ALPHA_BOOST = 0.3;
// ===== Click cycling state | 点击循环状态 =====
/** Last click position | 上次点击位置 */
private lastClickPos: { x: number; y: number } | null = null;
/** Last click time | 上次点击时间 */
private lastClickTime: number = 0;
/** All hit entities at current click position | 当前点击位置的所有命中实体 */
private hitEntitiesAtClick: number[] = [];
/** Current cycle index | 当前循环索引 */
private cycleIndex: number = 0;
/** Position tolerance for same-position detection | 判断相同位置的容差 */
private static readonly CLICK_POSITION_TOLERANCE = 5;
/** Time tolerance for cycling (ms) | 循环的时间容差(毫秒) */
private static readonly CLICK_TIME_TOLERANCE = 1000;
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null {
return this.hoveredEntityId;
}
/**
* Get currently hovered gizmo data
* 获取当前悬停的 Gizmo 数据
*/
getHoveredGizmo(): IGizmoRenderData | null {
return this.hoveredGizmo;
}
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void {
const scene = Core.scene;
if (!scene) {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
return;
}
let closestHit: GizmoHitResult | null = null;
let closestDistance = Infinity;
// Iterate all entities and collect gizmo data for hit testing
// 遍历所有实体,收集 gizmo 数据进行命中测试
for (const entity of scene.entities.buffer) {
// Skip entities without gizmo providers
// 跳过没有 gizmo 提供者的实体
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
// Calculate distance to gizmo center for sorting
// 计算到 gizmo 中心的距离用于排序
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
if (distance < closestDistance) {
closestDistance = distance;
closestHit = {
gizmo,
entityId: entity.id,
distance
};
}
}
}
}
}
this.hoveredEntityId = closestHit?.entityId ?? null;
this.hoveredGizmo = closestHit?.gizmo ?? null;
}
/**
* Get highlight color for entity
* 获取实体的高亮颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor {
const isHovered = entityId === this.hoveredEntityId;
if (!isHovered) {
return baseColor;
}
// Apply hover highlight: brighten color and increase alpha
// 应用悬停高亮:提亮颜色并增加透明度
return {
r: Math.min(1, baseColor.r * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
g: Math.min(1, baseColor.g * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
b: Math.min(1, baseColor.b * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
a: Math.min(1, baseColor.a + GizmoInteractionService.HOVER_ALPHA_BOOST)
};
}
/**
* Handle click at position, return hit entity ID
* Supports cycling through overlapping entities on repeated clicks
* 处理位置点击,返回命中的实体 ID
* 支持重复点击时循环选择重叠的实体
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null {
const now = Date.now();
const isSamePosition = this.lastClickPos !== null &&
Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom &&
Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom;
const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE;
// If clicking at same position within time window, cycle to next entity
// 如果在时间窗口内点击相同位置,循环到下一个实体
if (isSamePosition && isWithinTimeWindow && this.hitEntitiesAtClick.length > 1) {
this.cycleIndex = (this.cycleIndex + 1) % this.hitEntitiesAtClick.length;
this.lastClickTime = now;
const selectedId = this.hitEntitiesAtClick[this.cycleIndex];
this.hoveredEntityId = selectedId;
return selectedId;
}
// New position or timeout - collect all hit entities
// 新位置或超时 - 收集所有命中的实体
this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom);
this.cycleIndex = 0;
this.lastClickPos = { x: worldX, y: worldY };
this.lastClickTime = now;
if (this.hitEntitiesAtClick.length > 0) {
const selectedId = this.hitEntitiesAtClick[0];
this.hoveredEntityId = selectedId;
return selectedId;
}
return null;
}
/**
* Collect all entities hit at the given position, sorted by distance
* 收集给定位置命中的所有实体,按距离排序
*/
private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] {
const scene = Core.scene;
if (!scene) return [];
const hits: GizmoHitResult[] = [];
for (const entity of scene.entities.buffer) {
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
let entityHit = false;
let minDistance = Infinity;
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
entityHit = true;
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
minDistance = Math.min(minDistance, distance);
}
}
}
if (entityHit) {
hits.push({
gizmo: {} as IGizmoRenderData, // Not needed for sorting
entityId: entity.id,
distance: minDistance
});
}
}
// Sort by distance (closest first)
// 按距离排序(最近的在前)
hits.sort((a, b) => a.distance - b.distance);
return hits.map(hit => hit.entityId);
}
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
}
}

View File

@@ -24,6 +24,7 @@ import type { LocaleService, Locale, TranslationParams, PluginTranslations } fro
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
import type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// ============================================================================
// LocaleService Token
@@ -203,9 +204,28 @@ export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabServ
// 重新导出类型方便使用
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
// ============================================================================
// GizmoInteractionService Token
// Gizmo 交互服务令牌
// ============================================================================
/**
* Gizmo 交互服务令牌
* Gizmo interaction service token
*
* 用于注册和获取 Gizmo 交互服务。
* For registering and getting gizmo interaction service.
*/
export const GizmoInteractionServiceToken = createServiceToken<IGizmoInteractionService>('gizmoInteractionService');
// Re-export interface for convenience
// 重新导出接口方便使用
export type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// Re-export classes for direct use (backwards compatibility)
// 重新导出类以供直接使用(向后兼容)
export { LocaleService } from './Services/LocaleService';
export { MessageHub } from './Services/MessageHub';
export { EntityStoreService } from './Services/EntityStoreService';
export { PrefabService } from './Services/PrefabService';
export { GizmoInteractionService } from './Services/GizmoInteractionService';