feat(fairygui): FairyGUI 完整集成 (#314)

* feat(fairygui): FairyGUI ECS 集成核心架构

实现 FairyGUI 的 ECS 原生集成,完全替代旧 UI 系统:

核心类:
- GObject: UI 对象基类,支持变换、可见性、关联、齿轮
- GComponent: 容器组件,管理子对象和控制器
- GRoot: 根容器,管理焦点、弹窗、输入分发
- GGroup: 组容器,支持水平/垂直布局

抽象层:
- DisplayObject: 显示对象基类
- EventDispatcher: 事件分发
- Timer: 计时器
- Stage: 舞台,管理输入和缩放

布局系统:
- Relations: 约束关联管理
- RelationItem: 24 种关联类型

基础设施:
- Controller: 状态控制器
- Transition: 过渡动画
- ScrollPane: 滚动面板
- UIPackage: 包管理
- ByteBuffer: 二进制解析

* refactor(ui): 删除旧 UI 系统,使用 FairyGUI 替代

* feat(fairygui): 实现 UI 控件

- 添加显示类:Image、TextField、Graph
- 添加基础控件:GImage、GTextField、GGraph
- 添加交互控件:GButton、GProgressBar、GSlider
- 更新 IRenderCollector 支持 Graph 渲染
- 扩展 Controller 添加 selectedPageId
- 添加 STATE_CHANGED 事件类型

* feat(fairygui): 现代化架构重构

- 增强 EventDispatcher 支持类型安全、优先级和传播控制
- 添加 PropertyBinding 响应式属性绑定系统
- 添加 ServiceContainer 依赖注入容器
- 添加 UIConfig 全局配置系统
- 添加 UIObjectFactory 对象工厂
- 实现 RenderBridge 渲染桥接层
- 实现 Canvas2DBackend 作为默认渲染后端
- 扩展 IRenderCollector 支持更多图元类型

* feat(fairygui): 九宫格渲染和资源加载修复

- 修复 FGUIUpdateSystem 支持路径和 GUID 两种加载方式
- 修复 GTextInput 同时设置 _displayObject 和 _textField
- 实现九宫格渲染展开为 9 个子图元
- 添加 sourceWidth/sourceHeight 用于九宫格计算
- 添加 DOMTextRenderer 文本渲染层(临时方案)

* fix(fairygui): 修复 GGraph 颜色读取

* feat(fairygui): 虚拟节点 Inspector 和文本渲染支持

* fix(fairygui): 编辑器状态刷新和遗留引用修复

- 修复切换 FGUI 包后组件列表未刷新问题
- 修复切换组件后 viewport 未清理旧内容问题
- 修复虚拟节点在包加载后未刷新问题
- 重构为事件驱动架构,移除轮询机制
- 修复 @esengine/ui 遗留引用,统一使用 @esengine/fairygui

* fix: 移除 tsconfig 中的 @esengine/ui 引用
This commit is contained in:
YHH
2025-12-22 10:52:54 +08:00
committed by GitHub
parent 96b5403d14
commit a1e1189f9d
237 changed files with 30983 additions and 23563 deletions

View File

@@ -27,11 +27,27 @@ export interface GizmoColor {
a: number;
}
/**
* Base gizmo data with optional virtual node reference
* 带有可选虚拟节点引用的基础 Gizmo 数据
*/
export interface IGizmoDataBase {
/**
* Optional virtual node ID for component internal nodes
* 可选的虚拟节点 ID用于组件内部节点
*
* When set, clicking this gizmo will select the virtual node
* instead of just the entity.
* 设置后,点击此 gizmo 将选中虚拟节点而不只是实体。
*/
virtualNodeId?: string;
}
/**
* Rectangle gizmo data (rendered via Rust WebGL)
* 矩形 gizmo 数据(通过 Rust WebGL 渲染)
*/
export interface IRectGizmoData {
export interface IRectGizmoData extends IGizmoDataBase {
type: 'rect';
/** Center X position in world space | 世界空间中心 X 位置 */
x: number;
@@ -57,7 +73,7 @@ export interface IRectGizmoData {
* Circle gizmo data
* 圆形 gizmo 数据
*/
export interface ICircleGizmoData {
export interface ICircleGizmoData extends IGizmoDataBase {
type: 'circle';
/** Center X position | 中心 X 位置 */
x: number;
@@ -73,7 +89,7 @@ export interface ICircleGizmoData {
* Line gizmo data
* 线条 gizmo 数据
*/
export interface ILineGizmoData {
export interface ILineGizmoData extends IGizmoDataBase {
type: 'line';
/** Line points | 线段点 */
points: Array<{ x: number; y: number }>;
@@ -87,7 +103,7 @@ export interface ILineGizmoData {
* Grid gizmo data
* 网格 gizmo 数据
*/
export interface IGridGizmoData {
export interface IGridGizmoData extends IGizmoDataBase {
type: 'grid';
/** Top-left X position | 左上角 X 位置 */
x: number;
@@ -109,7 +125,7 @@ export interface IGridGizmoData {
* Capsule gizmo data
* 胶囊 gizmo 数据
*/
export interface ICapsuleGizmoData {
export interface ICapsuleGizmoData extends IGizmoDataBase {
type: 'capsule';
/** Center X position | 中心 X 位置 */
x: number;

View File

@@ -127,6 +127,8 @@ const EXTENSION_TYPE_MAP: Record<string, AssetRegistryType> = {
'.tsx': 'tileset',
// Particle system
'.particle': 'particle',
// FairyGUI
'.fui': 'fui',
};
/**

View File

@@ -23,6 +23,19 @@ export interface GizmoHitResult {
entityId: number;
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
distance: number;
/** Virtual node ID if this gizmo represents a virtual node | 虚拟节点 ID如果此 gizmo 代表虚拟节点) */
virtualNodeId?: string;
}
/**
* Click result with entity and optional virtual node
* 点击结果,包含实体和可选的虚拟节点
*/
export interface GizmoClickResult {
/** Entity ID | 实体 ID */
entityId: number;
/** Virtual node ID if clicked on a virtual node gizmo | 虚拟节点 ID如果点击了虚拟节点 gizmo */
virtualNodeId?: string;
}
/**
@@ -73,6 +86,23 @@ export interface IGizmoInteractionService {
* 清除悬停状态
*/
clearHover(): void;
/**
* Handle click at position with virtual node support
* 处理位置点击,支持虚拟节点
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns Click result with entity and optional virtual node | 点击结果
*/
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | null;
/**
* Get currently hovered virtual node ID
* 获取当前悬停的虚拟节点 ID
*/
getHoveredVirtualNodeId(): string | null;
}
/**
@@ -85,6 +115,7 @@ export interface IGizmoInteractionService {
export class GizmoInteractionService implements IGizmoInteractionService {
private hoveredEntityId: number | null = null;
private hoveredGizmo: IGizmoRenderData | null = null;
private hoveredVirtualNodeId: string | null = null;
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
@@ -96,8 +127,8 @@ export class GizmoInteractionService implements IGizmoInteractionService {
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[] = [];
/** All hit results at current click position | 当前点击位置的所有命中结果 */
private hitResultsAtClick: GizmoClickResult[] = [];
/** Current cycle index | 当前循环索引 */
private cycleIndex: number = 0;
/** Position tolerance for same-position detection | 判断相同位置的容差 */
@@ -121,6 +152,14 @@ export class GizmoInteractionService implements IGizmoInteractionService {
return this.hoveredGizmo;
}
/**
* Get currently hovered virtual node ID
* 获取当前悬停的虚拟节点 ID
*/
getHoveredVirtualNodeId(): string | null {
return this.hoveredVirtualNodeId;
}
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
@@ -130,6 +169,7 @@ export class GizmoInteractionService implements IGizmoInteractionService {
if (!scene) {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
this.hoveredVirtualNodeId = null;
return;
}
@@ -166,7 +206,8 @@ export class GizmoInteractionService implements IGizmoInteractionService {
closestHit = {
gizmo,
entityId: entity.id,
distance
distance,
virtualNodeId: gizmo.virtualNodeId
};
}
}
@@ -176,6 +217,7 @@ export class GizmoInteractionService implements IGizmoInteractionService {
this.hoveredEntityId = closestHit?.entityId ?? null;
this.hoveredGizmo = closestHit?.gizmo ?? null;
this.hoveredVirtualNodeId = closestHit?.virtualNodeId ?? null;
}
/**
@@ -206,56 +248,66 @@ export class GizmoInteractionService implements IGizmoInteractionService {
* 支持重复点击时循环选择重叠的实体
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null {
const result = this.handleClickEx(worldX, worldY, zoom);
return result?.entityId ?? null;
}
/**
* Handle click at position with virtual node support
* Supports cycling through overlapping gizmos on repeated clicks
* 处理位置点击,支持虚拟节点
* 支持重复点击时循环选择重叠的 gizmos
*/
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | 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;
// If clicking at same position within time window, cycle to next result
// 如果在时间窗口内点击相同位置,循环到下一个结果
if (isSamePosition && isWithinTimeWindow && this.hitResultsAtClick.length > 1) {
this.cycleIndex = (this.cycleIndex + 1) % this.hitResultsAtClick.length;
this.lastClickTime = now;
const selectedId = this.hitEntitiesAtClick[this.cycleIndex];
this.hoveredEntityId = selectedId;
return selectedId;
const result = this.hitResultsAtClick[this.cycleIndex];
this.hoveredEntityId = result.entityId;
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
return result;
}
// New position or timeout - collect all hit entities
// 新位置或超时 - 收集所有命中的实体
this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom);
// New position or timeout - collect all hit results
// 新位置或超时 - 收集所有命中结果
this.hitResultsAtClick = this.collectAllHitResults(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;
if (this.hitResultsAtClick.length > 0) {
const result = this.hitResultsAtClick[0];
this.hoveredEntityId = result.entityId;
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
return result;
}
return null;
}
/**
* Collect all entities hit at the given position, sorted by distance
* 收集给定位置命中的所有实体,按距离排序
* Collect all hit results at the given position, sorted by distance
* 收集给定位置的所有命中结果,按距离排序
*/
private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] {
private collectAllHitResults(worldX: number, worldY: number, zoom: number): GizmoClickResult[] {
const scene = Core.scene;
if (!scene) return [];
const hits: GizmoHitResult[] = [];
const hits: Array<GizmoClickResult & { distance: number }> = [];
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)) {
@@ -265,30 +317,37 @@ export class GizmoInteractionService implements IGizmoInteractionService {
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);
hits.push({
entityId: entity.id,
virtualNodeId: gizmo.virtualNodeId,
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);
// Remove duplicates (same entity + virtualNodeId), keeping closest
// 去重(相同实体 + virtualNodeId保留最近的
const seen = new Set<string>();
const uniqueHits: GizmoClickResult[] = [];
for (const hit of hits) {
const key = `${hit.entityId}:${hit.virtualNodeId ?? ''}`;
if (!seen.has(key)) {
seen.add(key);
uniqueHits.push({ entityId: hit.entityId, virtualNodeId: hit.virtualNodeId });
}
}
return uniqueHits;
}
/**
@@ -298,5 +357,6 @@ export class GizmoInteractionService implements IGizmoInteractionService {
clearHover(): void {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
this.hoveredVirtualNodeId = null;
}
}

View File

@@ -0,0 +1,323 @@
/**
* VirtualNodeRegistry
*
* Registry for virtual child nodes in the scene hierarchy.
* Allows components to expose internal structure as read-only nodes
* in the hierarchy panel.
*
* Uses event-driven architecture for efficient change notification.
*
* 场景层级中虚拟子节点的注册表。
* 允许组件将内部结构作为只读节点暴露在层级面板中。
* 使用事件驱动架构实现高效的变化通知。
*/
import type { Component, ComponentType, Entity } from '@esengine/ecs-framework';
/**
* Virtual node data
* 虚拟节点数据
*/
export interface IVirtualNode {
/** Unique ID within the parent component | 父组件内的唯一 ID */
id: string;
/** Display name | 显示名称 */
name: string;
/** Node type for icon selection | 节点类型(用于图标选择) */
type: string;
/** Child nodes | 子节点 */
children: IVirtualNode[];
/** Whether this node is visible | 此节点是否可见 */
visible: boolean;
/** Node-specific data for Inspector | Inspector 使用的节点数据 */
data: Record<string, unknown>;
/** World X position (for Gizmo) | 世界 X 坐标(用于 Gizmo */
x: number;
/** World Y position (for Gizmo) | 世界 Y 坐标(用于 Gizmo */
y: number;
/** Width (for Gizmo) | 宽度(用于 Gizmo */
width: number;
/** Height (for Gizmo) | 高度(用于 Gizmo */
height: number;
}
/**
* Virtual node provider function
* 虚拟节点提供者函数
*
* Returns an array of virtual nodes for a component instance.
* 为组件实例返回虚拟节点数组。
*/
export type VirtualNodeProviderFn<T extends Component = Component> = (
component: T,
entity: Entity
) => IVirtualNode[];
/**
* Change event types for virtual nodes
* 虚拟节点的变化事件类型
*/
export type VirtualNodeChangeType = 'loaded' | 'updated' | 'disposed';
/**
* Virtual node change event payload
* 虚拟节点变化事件载荷
*/
export interface VirtualNodeChangeEvent {
/** Entity ID that changed | 发生变化的实体 ID */
entityId: number;
/** Type of change | 变化类型 */
type: VirtualNodeChangeType;
/** Component that triggered the change (optional) | 触发变化的组件(可选) */
component?: Component;
}
/**
* Change listener function type
* 变化监听器函数类型
*/
export type VirtualNodeChangeListener = (event: VirtualNodeChangeEvent) => void;
/**
* VirtualNodeRegistry
*
* Manages virtual node providers for different component types.
* Provides event-driven change notifications for efficient UI updates.
*
* 管理不同组件类型的虚拟节点提供者。
* 提供事件驱动的变化通知以实现高效的 UI 更新。
*/
export class VirtualNodeRegistry {
private static providers = new Map<ComponentType, VirtualNodeProviderFn>();
/** Currently selected virtual node info | 当前选中的虚拟节点信息 */
private static selectedVirtualNodeInfo: {
entityId: number;
virtualNodeId: string;
} | null = null;
/** Change listeners | 变化监听器 */
private static changeListeners = new Set<VirtualNodeChangeListener>();
// ============= Provider Registration | 提供者注册 =============
/**
* Register a virtual node provider for a component type
* 为组件类型注册虚拟节点提供者
*/
static register<T extends Component>(
componentType: ComponentType<T>,
provider: VirtualNodeProviderFn<T>
): void {
this.providers.set(componentType, provider as VirtualNodeProviderFn);
}
/**
* Unregister a virtual node provider
* 取消注册虚拟节点提供者
*/
static unregister(componentType: ComponentType): void {
this.providers.delete(componentType);
}
/**
* Check if a component type has a virtual node provider
* 检查组件类型是否有虚拟节点提供者
*/
static hasProvider(componentType: ComponentType): boolean {
return this.providers.has(componentType);
}
// ============= Virtual Node Collection | 虚拟节点收集 =============
/**
* Get virtual nodes for a component
* 获取组件的虚拟节点
*/
static getVirtualNodes(
component: Component,
entity: Entity
): IVirtualNode[] {
const componentType = component.constructor as ComponentType;
const provider = this.providers.get(componentType);
if (provider) {
try {
return provider(component, entity);
} catch (e) {
console.warn(`[VirtualNodeRegistry] Error in provider for ${componentType.name}:`, e);
return [];
}
}
return [];
}
/**
* Get all virtual nodes for an entity
* 获取实体的所有虚拟节点
*/
static getAllVirtualNodesForEntity(entity: Entity): IVirtualNode[] {
const allNodes: IVirtualNode[] = [];
for (const component of entity.components) {
const nodes = this.getVirtualNodes(component, entity);
allNodes.push(...nodes);
}
return allNodes;
}
/**
* Check if an entity has any components with virtual node providers
* 检查实体是否有任何带有虚拟节点提供者的组件
*/
static hasAnyVirtualNodeProvider(entity: Entity): boolean {
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (this.providers.has(componentType)) {
return true;
}
}
return false;
}
// ============= Event System | 事件系统 =============
/**
* Subscribe to virtual node changes
* 订阅虚拟节点变化
*
* @param listener Callback function for change events
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = VirtualNodeRegistry.onChange((event) => {
* if (event.entityId === selectedEntityId) {
* refreshVirtualNodes();
* }
* });
*
* // Later, cleanup
* unsubscribe();
* ```
*/
static onChange(listener: VirtualNodeChangeListener): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
/**
* Notify that an entity's virtual nodes have changed
* Components should call this when their internal structure changes
*
* 通知实体的虚拟节点已更改
* 组件在内部结构变化时应调用此方法
*
* @param entityId The entity ID that changed
* @param type Type of change ('loaded', 'updated', 'disposed')
* @param component Optional component reference
*
* @example
* ```typescript
* // In FGUIComponent after loading completes:
* VirtualNodeRegistry.notifyChange(this.entity.id, 'loaded', this);
*
* // In FGUIComponent when switching component:
* VirtualNodeRegistry.notifyChange(this.entity.id, 'updated', this);
* ```
*/
static notifyChange(
entityId: number,
type: VirtualNodeChangeType = 'updated',
component?: Component
): void {
const event: VirtualNodeChangeEvent = { entityId, type, component };
for (const listener of this.changeListeners) {
try {
listener(event);
} catch (e) {
console.warn('[VirtualNodeRegistry] Error in change listener:', e);
}
}
}
/**
* Create a React hook-friendly subscription
* 创建对 React Hook 友好的订阅
*
* @param entityIds Set of entity IDs to watch
* @param callback Callback when any watched entity changes
* @returns Unsubscribe function
*/
static watchEntities(
entityIds: Set<number>,
callback: () => void
): () => void {
return this.onChange((event) => {
if (entityIds.has(event.entityId)) {
callback();
}
});
}
// ============= Selection State | 选择状态 =============
/**
* Set the currently selected virtual node
* 设置当前选中的虚拟节点
*/
static setSelectedVirtualNode(entityId: number, virtualNodeId: string): void {
this.selectedVirtualNodeInfo = { entityId, virtualNodeId };
}
/**
* Clear the virtual node selection
* 清除虚拟节点选择
*/
static clearSelectedVirtualNode(): void {
this.selectedVirtualNodeInfo = null;
}
/**
* Get the currently selected virtual node info
* 获取当前选中的虚拟节点信息
*/
static getSelectedVirtualNode(): { entityId: number; virtualNodeId: string } | null {
return this.selectedVirtualNodeInfo;
}
/**
* Check if a specific virtual node is selected
* 检查特定虚拟节点是否被选中
*/
static isVirtualNodeSelected(entityId: number, virtualNodeId: string): boolean {
return this.selectedVirtualNodeInfo !== null &&
this.selectedVirtualNodeInfo.entityId === entityId &&
this.selectedVirtualNodeInfo.virtualNodeId === virtualNodeId;
}
// ============= Cleanup | 清理 =============
/**
* Clear all registered providers and listeners
* 清除所有已注册的提供者和监听器
*/
static clear(): void {
this.providers.clear();
this.changeListeners.clear();
this.selectedVirtualNodeInfo = null;
}
}

View File

@@ -50,6 +50,8 @@ export * from './Services/IViewportService';
export * from './Services/PreviewSceneService';
export * from './Services/EditorViewportService';
export * from './Services/PrefabService';
export * from './Services/VirtualNodeRegistry';
export * from './Services/GizmoInteractionService';
// Build System | 构建系统
export * from './Services/Build';