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:
262
packages/editor-core/src/Gizmos/GizmoHitTester.ts
Normal file
262
packages/editor-core/src/Gizmos/GizmoHitTester.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@
|
||||
|
||||
export * from './IGizmoProvider';
|
||||
export * from './GizmoRegistry';
|
||||
export * from './GizmoHitTester';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
302
packages/editor-core/src/Services/GizmoInteractionService.ts
Normal file
302
packages/editor-core/src/Services/GizmoInteractionService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user