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
@@ -165,7 +165,10 @@ export function inferAssetType(path: string): AssetType {
btree: 'behavior-tree',
bp: 'blueprint',
mat: 'material',
particle: 'particle'
particle: 'particle',
// FairyGUI
fui: 'fui'
};
return typeMap[ext] || 'binary';
-1
View File
@@ -32,7 +32,6 @@
{ "path": "../core" },
{ "path": "../engine-core" },
{ "path": "../editor-core" },
{ "path": "../ui" },
{ "path": "../editor-runtime" }
]
}
@@ -215,6 +215,31 @@ export class EngineBridge implements ITextureEngineBridge, ITextureService, IDyn
this.stats.spriteCount = count;
}
/**
* Submit mesh batch for rendering arbitrary 2D geometry.
* 提交网格批次进行任意 2D 几何体渲染。
*
* Used for rendering ellipses, polygons, and other complex shapes.
* 用于渲染椭圆、多边形和其他复杂形状。
*
* @param positions - Vertex positions [x, y, ...]
* @param uvs - Texture coordinates [u, v, ...]
* @param colors - Packed RGBA colors (one per vertex)
* @param indices - Triangle indices
* @param textureId - Texture ID (0 = white pixel)
*/
submitMeshBatch(
positions: Float32Array,
uvs: Float32Array,
colors: Uint32Array,
indices: Uint16Array,
textureId: number
): void {
if (!this.initialized || positions.length === 0) return;
this.getEngine().submitMeshBatch(positions, uvs, colors, indices, textureId);
}
/**
* Render the current frame.
* 渲染当前帧。
@@ -68,6 +68,23 @@ export interface IRenderDataProvider {
getRenderData(): readonly ProviderRenderData[];
}
/**
* Mesh render data for arbitrary 2D geometry
* 任意 2D 几何体的网格渲染数据
*/
export interface MeshRenderData {
/** Vertex positions [x, y, ...] | 顶点位置 */
positions: Float32Array;
/** Texture coordinates [u, v, ...] | 纹理坐标 */
uvs: Float32Array;
/** Vertex colors (packed RGBA) | 顶点颜色 */
colors: Uint32Array;
/** Triangle indices | 三角形索引 */
indices: Uint16Array;
/** Texture ID (0 = white pixel) | 纹理 ID */
textureId: number;
}
/**
* Interface for UI render data providers
* UI 渲染数据提供者接口
@@ -78,6 +95,8 @@ export interface IRenderDataProvider {
export interface IUIRenderDataProvider extends IRenderDataProvider {
/** Get UI render data | 获取 UI 渲染数据 */
getRenderData(): readonly ProviderRenderData[];
/** Get mesh render data for complex shapes | 获取复杂形状的网格渲染数据 */
getMeshRenderData?(): readonly MeshRenderData[];
/** @deprecated Use getRenderData() instead */
getScreenSpaceRenderData?(): readonly ProviderRenderData[];
/** @deprecated World space UI is no longer supported */
@@ -538,6 +557,34 @@ export class EngineRenderSystem extends EntitySystem {
);
}
}
// Collect mesh render data for complex shapes (ellipses, polygons, etc.)
// 收集复杂形状(椭圆、多边形等)的网格渲染数据
if (this.uiRenderDataProvider.getMeshRenderData) {
const meshRenderData = this.uiRenderDataProvider.getMeshRenderData();
if (meshRenderData.length > 0) {
console.log(`[EngineRenderSystem] Submitting ${meshRenderData.length} mesh batches`);
}
for (const mesh of meshRenderData) {
if (mesh.positions.length === 0) continue;
console.log('[EngineRenderSystem] Mesh batch:', {
vertices: mesh.positions.length / 2,
indices: mesh.indices.length,
textureId: mesh.textureId
});
// Submit mesh data directly to the engine
// 直接将网格数据提交到引擎
this.bridge.submitMeshBatch(
mesh.positions,
mesh.uvs,
mesh.colors,
mesh.indices,
mesh.textureId
);
}
}
}
/**
+51 -2
View File
@@ -204,6 +204,11 @@ export class GameEngine {
* 添加圆形Gizmo边框。
*/
addGizmoCircle(x: number, y: number, radius: number, r: number, g: number, b: number, a: number): void;
/**
* Get the graphics backend name (e.g., "WebGL2").
* 获取图形后端名称(如 "WebGL2")。
*/
getBackendName(): string;
/**
* Get all registered viewport IDs.
* 获取所有已注册的视口ID。
@@ -272,6 +277,35 @@ export class GameEngine {
* 设置材质的vec4 uniform(也用于颜色)。
*/
setMaterialVec4(material_id: number, name: string, x: number, y: number, z: number, w: number): boolean;
/**
* Submit mesh batch for rendering arbitrary 2D geometry.
* 提交网格批次进行任意 2D 几何体渲染。
*
* Used for rendering ellipses, polygons, and other complex shapes.
* 用于渲染椭圆、多边形和其他复杂形状。
*
* # Arguments | 参数
* * `positions` - Float32Array [x, y, ...] for each vertex
* * `uvs` - Float32Array [u, v, ...] for each vertex
* * `colors` - Uint32Array of packed RGBA colors (one per vertex)
* * `indices` - Uint16Array of triangle indices
* * `texture_id` - Texture ID to use (0 for white pixel)
*/
submitMeshBatch(positions: Float32Array, uvs: Float32Array, colors: Uint32Array, indices: Uint16Array, texture_id: number): void;
/**
* Submit MSDF text batch for rendering.
* 提交 MSDF 文本批次进行渲染。
*
* # Arguments | 参数
* * `positions` - Float32Array [x, y, ...] for each vertex (4 per glyph)
* * `tex_coords` - Float32Array [u, v, ...] for each vertex
* * `colors` - Float32Array [r, g, b, a, ...] for each vertex
* * `outline_colors` - Float32Array [r, g, b, a, ...] for each vertex
* * `outline_widths` - Float32Array [width, ...] for each vertex
* * `texture_id` - Font atlas texture ID
* * `px_range` - Pixel range for MSDF shader
*/
submitTextBatch(positions: Float32Array, tex_coords: Float32Array, colors: Float32Array, outline_colors: Float32Array, outline_widths: Float32Array, texture_id: number, px_range: number): void;
/**
* Clear all textures and reset state.
* 清除所有纹理并重置状态。
@@ -311,6 +345,11 @@ export class GameEngine {
* * `mode` - 0=Select, 1=Move, 2=Rotate, 3=Scale
*/
setTransformMode(mode: number): void;
/**
* Get the graphics backend version string.
* 获取图形后端版本字符串。
*/
getBackendVersion(): string;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
@@ -374,6 +413,11 @@ export class GameEngine {
* The texture ID for the created blank texture | 创建的空白纹理ID
*/
createBlankTexture(width: number, height: number): number;
/**
* Get maximum texture size supported by the backend.
* 获取后端支持的最大纹理尺寸。
*/
getMaxTextureSize(): number;
/**
* Load texture by path, returning texture ID.
* 按路径加载纹理,返回纹理ID。
@@ -516,7 +560,10 @@ export interface InitOutput {
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
readonly gameengine_getBackendName: (a: number) => [number, number];
readonly gameengine_getBackendVersion: (a: number) => [number, number];
readonly gameengine_getCamera: (a: number) => [number, number];
readonly gameengine_getMaxTextureSize: (a: number) => number;
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
readonly gameengine_getTextureLoadingCount: (a: number) => number;
@@ -558,15 +605,17 @@ export interface InitOutput {
readonly gameengine_setTransformMode: (a: number, b: number) => void;
readonly gameengine_setViewportCamera: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
readonly gameengine_setViewportConfig: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_submitMeshBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => [number, number];
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
readonly gameengine_submitTextBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => [number, number];
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
readonly gameengine_updateInput: (a: number) => void;
readonly gameengine_updateTextureRegion: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number];
readonly gameengine_width: (a: number) => number;
readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number];
readonly init: () => void;
readonly wasm_bindgen__convert__closures_____invoke__hc746ced83e8f2609: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__hebcd2828f83f27ed: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0cae3d4947da04cb: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h0c01365f59f73f28: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void;
+2 -2
View File
@@ -44,8 +44,8 @@
"@esengine/sprite-editor": "workspace:*",
"@esengine/tilemap": "workspace:*",
"@esengine/tilemap-editor": "workspace:*",
"@esengine/ui": "workspace:*",
"@esengine/ui-editor": "workspace:*",
"@esengine/fairygui": "workspace:*",
"@esengine/fairygui-editor": "workspace:*",
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-cli": "^2.4.1",
@@ -525,3 +525,11 @@ pub async fn read_binary_file_as_base64(path: String) -> Result<String, String>
Ok(STANDARD.encode(&bytes))
}
/// Read binary file and return as raw bytes.
/// 读取二进制文件并返回原始字节。
#[tauri::command]
pub async fn read_binary_file(file_path: String) -> Result<Vec<u8>, String> {
fs::read(&file_path)
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))
}
@@ -109,6 +109,7 @@ fn main() {
commands::write_json_file,
commands::list_files_by_extension,
commands::read_binary_file_as_base64,
commands::read_binary_file,
// Engine modules | 引擎模块
commands::read_engine_modules_index,
commands::read_module_manifest,
@@ -22,7 +22,7 @@ import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
import { ParticlePlugin } from '@esengine/particle-editor';
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
import { TilemapPlugin } from '@esengine/tilemap-editor';
import { UIPlugin } from '@esengine/ui-editor';
import { FGUIPlugin } from '@esengine/fairygui-editor';
import { BlueprintPlugin } from '@esengine/blueprint-editor';
import { MaterialPlugin } from '@esengine/material-editor';
import { SpritePlugin } from '@esengine/sprite-editor';
@@ -63,7 +63,7 @@ export class PluginInstaller {
{ name: 'CameraPlugin', plugin: CameraPlugin },
{ name: 'SpritePlugin', plugin: SpritePlugin },
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
{ name: 'UIPlugin', plugin: UIPlugin },
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
@@ -48,7 +48,7 @@ import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
import { CameraComponent } from '@esengine/camera';
import { AudioSourceComponent } from '@esengine/audio';
import { UITextComponent } from '@esengine/ui';
import { FGUIComponent } from '@esengine/fairygui';
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
import { DIContainer } from '../../core/di/DIContainer';
@@ -129,7 +129,7 @@ export class ServiceRegistry {
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
{ name: 'UITextComponent', type: UITextComponent, editorName: 'UIText', category: 'components.category.ui', description: 'components.text.description', icon: 'Type' },
{ name: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
@@ -1,7 +1,6 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { UITransformComponent } from '@esengine/ui';
import { BaseCommand } from '../BaseCommand';
import { ICommand } from '../ICommand';
@@ -10,7 +9,6 @@ import { ICommand } from '../ICommand';
* Transform state snapshot
*/
export interface TransformState {
// TransformComponent
positionX?: number;
positionY?: number;
positionZ?: number;
@@ -20,14 +18,6 @@ export interface TransformState {
scaleX?: number;
scaleY?: number;
scaleZ?: number;
// UITransformComponent
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
uiScaleX?: number;
uiScaleY?: number;
}
/**
@@ -41,19 +31,17 @@ export type TransformOperationType = 'move' | 'rotate' | 'scale';
* Transform command for undo/redo support
*/
export class TransformCommand extends BaseCommand {
private readonly componentType: 'transform' | 'uiTransform';
private readonly timestamp: number;
constructor(
private readonly messageHub: MessageHub,
private readonly entity: Entity,
private readonly component: Component,
private readonly component: TransformComponent,
private readonly operationType: TransformOperationType,
private readonly oldState: TransformState,
private newState: TransformState
) {
super();
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
this.timestamp = Date.now();
}
@@ -114,25 +102,16 @@ export class TransformCommand extends BaseCommand {
* Apply transform state
*/
private applyState(state: TransformState): void {
if (this.componentType === 'transform') {
const transform = this.component as TransformComponent;
if (state.positionX !== undefined) transform.position.x = state.positionX;
if (state.positionY !== undefined) transform.position.y = state.positionY;
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
} else {
const uiTransform = this.component as UITransformComponent;
if (state.x !== undefined) uiTransform.x = state.x;
if (state.y !== undefined) uiTransform.y = state.y;
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
}
const transform = this.component;
if (state.positionX !== undefined) transform.position.x = state.positionX;
if (state.positionY !== undefined) transform.position.y = state.positionY;
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
}
/**
@@ -141,18 +120,16 @@ export class TransformCommand extends BaseCommand {
*/
private notifyChange(): void {
const propertyName = this.operationType === 'move'
? (this.componentType === 'transform' ? 'position' : 'x')
? 'position'
: this.operationType === 'rotate'
? 'rotation'
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
: 'scale';
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName,
value: this.componentType === 'transform'
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
value: this.component[propertyName as keyof TransformComponent]
});
// 通知 Inspector 刷新 | Notify Inspector to refresh
@@ -176,18 +153,4 @@ export class TransformCommand extends BaseCommand {
scaleZ: transform.scale.z
};
}
/**
* 从 UITransformComponent 捕获状态
* Capture state from UITransformComponent
*/
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
return {
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation,
uiScaleX: uiTransform.scaleX,
uiScaleY: uiTransform.scaleY
};
}
}
@@ -9,7 +9,8 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder, PrefabSerializer, ComponentRegistry, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService } from '@esengine/editor-core';
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService, VirtualNodeRegistry } from '@esengine/editor-core';
import type { IVirtualNode } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { useHierarchyStore } from '../stores';
import * as LucideIcons from 'lucide-react';
@@ -48,6 +49,36 @@ const categoryIconMap: Record<string, string> = {
'other': 'MoreHorizontal',
};
/**
* Map virtual node types to Lucide icon names
* 将虚拟节点类型映射到 Lucide 图标名称
*/
const virtualNodeIconMap: Record<string, string> = {
'Component': 'LayoutGrid',
'Image': 'Image',
'Graph': 'Square',
'TextField': 'Type',
'RichTextField': 'FileText',
'Button': 'MousePointer',
'List': 'List',
'Loader': 'Loader',
'ProgressBar': 'BarChart',
'Slider': 'Sliders',
'ComboBox': 'ChevronDown',
'ScrollPane': 'Scroll',
'Group': 'FolderOpen',
'MovieClip': 'Film',
'TextInput': 'FormInput',
};
/**
* Get icon name for a virtual node type
* 获取虚拟节点类型的图标名称
*/
function getVirtualNodeIcon(nodeType: string): string {
return virtualNodeIconMap[nodeType] || 'Circle';
}
// 实体类型到图标的映射
const entityTypeIcons: Record<string, React.ReactNode> = {
'World': <Mountain size={14} className="entity-type-icon world" />,
@@ -78,6 +109,21 @@ interface EntityNode {
depth: number;
bIsFolder: boolean;
hasChildren: boolean;
/** Virtual nodes from components (e.g., FGUI internal nodes) | 组件的虚拟节点(如 FGUI 内部节点) */
virtualNodes?: IVirtualNode[];
}
/**
* Flattened list item - can be either an entity node or a virtual node
* 扁平化列表项 - 可以是实体节点或虚拟节点
*/
interface FlattenedItem {
type: 'entity' | 'virtual';
entityNode?: EntityNode;
virtualNode?: IVirtualNode;
depth: number;
parentEntityId: number;
hasChildren: boolean;
}
/**
@@ -140,6 +186,15 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [editingEntityId, setEditingEntityId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
// Expanded virtual node IDs (format: "entityId:virtualNodeId")
// 展开的虚拟节点 ID(格式:"entityId:virtualNodeId"
const [expandedVirtualIds, setExpandedVirtualIds] = useState<Set<string>>(new Set());
// Selected virtual node (format: "entityId:virtualNodeId")
// 选中的虚拟节点(格式:"entityId:virtualNodeId"
const [selectedVirtualId, setSelectedVirtualId] = useState<string | null>(null);
// Refresh counter to force virtual nodes recollection
// 刷新计数器,用于强制重新收集虚拟节点
const [virtualNodeRefreshKey, setVirtualNodeRefreshKey] = useState(0);
const { t, locale } = useLocale();
// Ref for auto-scrolling to selected item | 选中项自动滚动 ref
@@ -173,6 +228,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 构建层级树结构
* Build hierarchical tree structure
*
* Also collects virtual nodes from components using VirtualNodeRegistry.
* 同时使用 VirtualNodeRegistry 收集组件的虚拟节点。
*/
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
const scene = Core.scene;
@@ -191,12 +250,17 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
}
// Collect virtual nodes from components
// 从组件收集虚拟节点
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(entity);
return {
entity,
children,
depth,
bIsFolder: bIsEntityFolder,
hasChildren: children.length > 0
hasChildren: children.length > 0 || virtualNodes.length > 0,
virtualNodes: virtualNodes.length > 0 ? virtualNodes : undefined
};
};
@@ -205,17 +269,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 扁平化树为带深度信息的列表(用于渲染)
* Flatten tree to list with depth info (for rendering)
*
* Also includes virtual nodes when their parent entity is expanded.
* 当父实体展开时,也包含虚拟节点。
*/
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
const result: EntityNode[] = [];
const flattenTree = useCallback((
nodes: EntityNode[],
expandedSet: Set<number>,
expandedVirtualSet: Set<string>
): FlattenedItem[] => {
const result: FlattenedItem[] = [];
// Flatten virtual nodes recursively
// 递归扁平化虚拟节点
const flattenVirtualNodes = (
virtualNodes: IVirtualNode[],
parentEntityId: number,
baseDepth: number
) => {
for (const vnode of virtualNodes) {
const vnodeKey = `${parentEntityId}:${vnode.id}`;
const hasVChildren = vnode.children && vnode.children.length > 0;
result.push({
type: 'virtual',
virtualNode: vnode,
depth: baseDepth,
parentEntityId,
hasChildren: hasVChildren
});
// If virtual node is expanded, add its children
// 如果虚拟节点已展开,添加其子节点
if (hasVChildren && expandedVirtualSet.has(vnodeKey)) {
flattenVirtualNodes(vnode.children, parentEntityId, baseDepth + 1);
}
}
};
const traverse = (nodeList: EntityNode[]) => {
for (const node of nodeList) {
result.push(node);
// Add entity node
result.push({
type: 'entity',
entityNode: node,
depth: node.depth,
parentEntityId: node.entity.id,
hasChildren: node.hasChildren
});
const bIsExpanded = expandedSet.has(node.entity.id);
if (bIsExpanded && node.children.length > 0) {
traverse(node.children);
if (bIsExpanded) {
// Add child entities
if (node.children.length > 0) {
traverse(node.children);
}
// Add virtual nodes after entity children
// 在实体子节点后添加虚拟节点
if (node.virtualNodes && node.virtualNodes.length > 0) {
flattenVirtualNodes(node.virtualNodes, node.entity.id, node.depth + 1);
}
}
}
};
@@ -226,13 +341,92 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
/**
* 层级树和扁平化列表
* Hierarchy tree and flattened list
*
* virtualNodeRefreshKey is used to force rebuild when components change.
* virtualNodeRefreshKey 用于在组件变化时强制重建。
*/
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
const flattenedEntities = useMemo(
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
[entityTree, expandedIds, flattenTree]
// eslint-disable-next-line react-hooks/exhaustive-deps
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree, virtualNodeRefreshKey]);
const flattenedItems = useMemo(
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds, expandedVirtualIds) : [],
[entityTree, expandedIds, expandedVirtualIds, flattenTree]
);
/**
* Toggle virtual node expansion
* 切换虚拟节点展开状态
*/
const toggleVirtualExpand = useCallback((parentEntityId: number, virtualNodeId: string) => {
const key = `${parentEntityId}:${virtualNodeId}`;
setExpandedVirtualIds(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
/**
* Handle virtual node click
* 处理虚拟节点点击
*/
const handleVirtualNodeClick = useCallback((parentEntityId: number, virtualNode: IVirtualNode) => {
const key = `${parentEntityId}:${virtualNode.id}`;
setSelectedVirtualId(key);
// Clear entity selection when selecting virtual node
// 选择虚拟节点时清除实体选择
setSelectedIds(new Set());
// Publish event for Inspector to display virtual node properties
// 发布事件以便 Inspector 显示虚拟节点属性
messageHub.publish('virtual-node:selected', {
parentEntityId,
virtualNodeId: virtualNode.id,
virtualNode
});
}, [messageHub, setSelectedIds]);
// Subscribe to scene:modified to refresh virtual nodes when components change
// 订阅 scene:modified 事件,当组件变化时刷新虚拟节点
useEffect(() => {
const unsubModified = messageHub.subscribe('scene:modified', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
// Also subscribe to component-specific events
// 同时订阅组件相关事件
const unsubComponentAdded = messageHub.subscribe('component:added', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
const unsubComponentRemoved = messageHub.subscribe('component:removed', () => {
setVirtualNodeRefreshKey(prev => prev + 1);
});
return () => {
unsubModified();
unsubComponentAdded();
unsubComponentRemoved();
};
}, [messageHub]);
// Subscribe to VirtualNodeRegistry changes (event-driven, no polling needed)
// 订阅 VirtualNodeRegistry 变化(事件驱动,无需轮询)
useEffect(() => {
const unsubscribe = VirtualNodeRegistry.onChange((event) => {
// Refresh if the changed entity is expanded
// 如果变化的实体是展开的,则刷新
if (expandedIds.has(event.entityId)) {
setVirtualNodeRefreshKey(prev => prev + 1);
}
});
return unsubscribe;
}, [expandedIds]);
// 获取插件实体创建模板 | Get entity creation templates from plugins
useEffect(() => {
const updateTemplates = () => {
@@ -257,6 +451,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
// Note: Scene/entity/remote subscriptions moved to useStoreSubscriptions
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
// Clear virtual node selection when selecting an entity
// 选择实体时清除虚拟节点选择
setSelectedVirtualId(null);
// Force refresh virtual nodes to pick up any newly loaded components
// 强制刷新虚拟节点以获取新加载的组件
setVirtualNodeRefreshKey(prev => prev + 1);
if (e.ctrlKey || e.metaKey) {
setSelectedIds(prev => {
const next = new Set(prev);
@@ -927,22 +1129,26 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
}
// 方向键导航 | Arrow key navigation
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && flattenedEntities.length > 0) {
// Only navigate entity nodes, skip virtual nodes
// 只导航实体节点,跳过虚拟节点
const entityItems = flattenedItems.filter(item => item.type === 'entity');
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && entityItems.length > 0) {
e.preventDefault();
const currentIndex = selectedId
? flattenedEntities.findIndex(n => n.entity.id === selectedId)
? entityItems.findIndex(item => item.entityNode?.entity.id === selectedId)
: -1;
let newIndex: number;
if (e.key === 'ArrowUp') {
newIndex = currentIndex <= 0 ? flattenedEntities.length - 1 : currentIndex - 1;
newIndex = currentIndex <= 0 ? entityItems.length - 1 : currentIndex - 1;
} else {
newIndex = currentIndex >= flattenedEntities.length - 1 ? 0 : currentIndex + 1;
newIndex = currentIndex >= entityItems.length - 1 ? 0 : currentIndex + 1;
}
const newEntity = flattenedEntities[newIndex]?.entity;
const newEntity = entityItems[newIndex]?.entityNode?.entity;
if (newEntity) {
setSelectedIds(new Set([newEntity.id]));
setSelectedVirtualId(null); // Clear virtual selection
entityStore.selectEntity(newEntity);
messageHub.publish('entity:selected', { entity: newEntity });
}
@@ -952,7 +1158,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, isShowingRemote, editingEntityId, flattenedEntities, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
}, [selectedId, isShowingRemote, editingEntityId, flattenedItems, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
/**
* 创建文件夹实体
@@ -1303,107 +1509,164 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
</div>
</div>
{/* Hierarchical Entity Items */}
{flattenedEntities.map((node) => {
const { entity, depth, hasChildren, bIsFolder } = node;
const bIsExpanded = expandedIds.has(entity.id);
const bIsSelected = selectedIds.has(entity.id);
const bIsDragging = draggedEntityId === entity.id;
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
const bIsPrefabInstance = isEntityPrefabInstance(entity);
{/* Hierarchical Entity and Virtual Node Items */}
{flattenedItems.map((item, index) => {
// Render entity node
if (item.type === 'entity' && item.entityNode) {
const node = item.entityNode;
const { entity, bIsFolder } = node;
const bIsExpanded = expandedIds.has(entity.id);
const bIsSelected = selectedIds.has(entity.id);
const bIsDragging = draggedEntityId === entity.id;
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
const bIsPrefabInstance = isEntityPrefabInstance(entity);
// 计算缩进 (每层 16px,加上基础 8px)
const indent = 8 + depth * 16;
// 计算缩进 (每层 16px,加上基础 8px)
const indent = 8 + item.depth * 16;
// 构建 drop indicator 类名
let dropIndicatorClass = '';
if (currentDropTarget) {
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
// 构建 drop indicator 类名
let dropIndicatorClass = '';
if (currentDropTarget) {
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
}
return (
<div
key={`entity-${entity.id}`}
ref={bIsSelected ? selectedItemRef : undefined}
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
style={{ paddingLeft: `${indent}px` }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
{isEntityVisible(entity) ? (
<Eye
size={12}
className="item-icon visibility"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
) : (
<EyeOff
size={12}
className="item-icon visibility hidden"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
)}
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{item.hasChildren || bIsFolder ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
>
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{getEntityIcon(entity)}
{editingEntityId === entity.id ? (
<input
className="outliner-item-name-input"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleConfirmRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') handleCancelRename();
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<span
className="outliner-item-name"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingEntityId(entity.id);
setEditingName(entity.name || '');
}}
>
{entity.name || `Entity ${entity.id}`}
</span>
)}
{/* 预制体实例徽章 | Prefab instance badge */}
{bIsPrefabInstance && (
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
P
</span>
)}
</div>
<div className="outliner-item-type">{getEntityType(entity)}</div>
</div>
);
}
return (
<div
key={entity.id}
ref={bIsSelected ? selectedItemRef : undefined}
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
style={{ paddingLeft: `${indent}px` }}
draggable
onClick={(e) => handleEntityClick(entity, e)}
onDragStart={(e) => handleDragStart(e, entity.id)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
handleEntityClick(entity, e);
handleContextMenu(e, entity.id);
}}
>
<div className="outliner-item-icons">
{isEntityVisible(entity) ? (
<Eye
size={12}
className="item-icon visibility"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
) : (
<EyeOff
size={12}
className="item-icon visibility hidden"
onClick={(e) => handleToggleVisibility(entity, e)}
/>
)}
// Render virtual node (read-only)
// 渲染虚拟节点(只读)
if (item.type === 'virtual' && item.virtualNode) {
const vnode = item.virtualNode;
const vnodeKey = `${item.parentEntityId}:${vnode.id}`;
const bIsVExpanded = expandedVirtualIds.has(vnodeKey);
const bIsVSelected = selectedVirtualId === vnodeKey;
// 计算缩进
const indent = 8 + item.depth * 16;
return (
<div
key={`virtual-${vnodeKey}-${index}`}
className={`outliner-item virtual-node ${bIsVSelected ? 'selected' : ''} ${!vnode.visible ? 'hidden-node' : ''}`}
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleVirtualNodeClick(item.parentEntityId, vnode)}
>
<div className="outliner-item-icons">
{vnode.visible ? (
<Eye size={12} className="item-icon visibility" />
) : (
<EyeOff size={12} className="item-icon visibility hidden" />
)}
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{item.hasChildren ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => {
e.stopPropagation();
toggleVirtualExpand(item.parentEntityId, vnode.id);
}}
>
{bIsVExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{/* 虚拟节点类型图标 */}
{getIconComponent(getVirtualNodeIcon(vnode.type), 14)}
<span className="outliner-item-name virtual-name">
{vnode.name}
</span>
</div>
<div className="outliner-item-type virtual-type">{vnode.type}</div>
</div>
<div className="outliner-item-content">
{/* 展开/折叠按钮 */}
{hasChildren || bIsFolder ? (
<span
className="outliner-item-expand clickable"
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
>
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
) : (
<span className="outliner-item-expand" />
)}
{getEntityIcon(entity)}
{editingEntityId === entity.id ? (
<input
className="outliner-item-name-input"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleConfirmRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmRename();
if (e.key === 'Escape') handleCancelRename();
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<span
className="outliner-item-name"
onDoubleClick={(e) => {
e.stopPropagation();
setEditingEntityId(entity.id);
setEditingName(entity.name || '');
}}
>
{entity.name || `Entity ${entity.id}`}
</span>
)}
{/* 预制体实例徽章 | Prefab instance badge */}
{bIsPrefabInstance && (
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
P
</span>
)}
</div>
<div className="outliner-item-type">{getEntityType(entity)}</div>
</div>
);
);
}
return null;
})}
</div>
)
@@ -184,14 +184,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
}
console.log('[SettingsWindow] Initial values for profiler:',
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
setValues(initialValues);
}, [settingsRegistry, initialCategoryId]);
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
const newValues = new Map(values);
newValues.set(key, value);
// When preset is selected, also update width and height values
// 当选择预设时,同时更新宽度和高度值
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
const [w, h] = value.split('x').map(Number);
if (w && h) {
newValues.set('project.uiDesignResolution.width', w);
newValues.set('project.uiDesignResolution.height', h);
}
}
setValues(newValues);
const newErrors = new Map(errors);
@@ -218,7 +227,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
if (!shouldDeferSave) {
settings.set(key, value);
console.log(`[SettingsWindow] Saved ${key}:`, value);
// 触发设置变更事件
// Trigger settings changed event
@@ -237,28 +245,27 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
const changedSettings: Record<string, any> = {};
let uiResolutionChanged = false;
let newWidth = 1920;
let newHeight = 1080;
// Get width and height directly from values - these are the actual UI input values
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
const widthFromValues = values.get('project.uiDesignResolution.width');
const heightFromValues = values.get('project.uiDesignResolution.height');
// Use the width/height values directly (they are always set from either user input or initial load)
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
// Check if resolution differs from saved config
// 检查分辨率是否与保存的配置不同
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
let disabledModulesChanged = false;
let newDisabledModules: string[] = [];
for (const [key, value] of values.entries()) {
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
newWidth = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.height') {
newHeight = value;
uiResolutionChanged = true;
} else if (key === 'project.uiDesignResolution.preset') {
const [w, h] = value.split('x').map(Number);
if (w && h) {
newWidth = w;
newHeight = h;
uiResolutionChanged = true;
}
} else if (key === 'project.disabledModules') {
if (key === 'project.disabledModules') {
newDisabledModules = value as string[];
disabledModulesChanged = true;
}
@@ -270,7 +277,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
}
if (uiResolutionChanged && projectService) {
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
}
if (disabledModulesChanged && projectService) {
@@ -570,14 +579,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
return (
<div className="settings-overlay" onClick={handleCancel}>
<div className="settings-overlay" onClick={handleSave}>
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
{/* Left Sidebar */}
<div className="settings-sidebar-new">
<div className="settings-sidebar-header">
<SettingsIcon size={16} />
<span>{t('settingsWindow.editorPreferences')}</span>
<button className="settings-sidebar-close" onClick={handleCancel}>
<button className="settings-sidebar-close" onClick={handleSave}>
<X size={14} />
</button>
</div>
+138 -130
View File
@@ -10,12 +10,12 @@ import { useLocale } from '../hooks/useLocale';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget, VirtualNodeRegistry } from '@esengine/editor-core';
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { UITransformComponent } from '@esengine/ui';
import { FGUIComponent } from '@esengine/fairygui';
import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
@@ -302,7 +302,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const transformModeRef = useRef<TransformMode>('select');
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
const initialTransformStateRef = useRef<TransformState | null>(null);
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
const transformComponentRef = useRef<TransformComponent | null>(null);
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
@@ -454,18 +454,48 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
const clickResult = gizmoService.handleClickEx(worldPos.x, worldPos.y, zoom);
if (hitEntityId !== null) {
if (clickResult !== null) {
// Find and select the hit entity
// 找到并选中命中的实体
const scene = Core.scene;
if (scene) {
const hitEntity = scene.entities.findEntityById(hitEntityId);
const hitEntity = scene.entities.findEntityById(clickResult.entityId);
if (hitEntity && messageHubRef.current) {
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.selectEntity(hitEntity);
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
// Check if clicked on a virtual node
// 检查是否点击了虚拟节点
if (clickResult.virtualNodeId) {
// Get the virtual node data from VirtualNodeRegistry
// 从 VirtualNodeRegistry 获取虚拟节点数据
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(hitEntity);
const findVirtualNode = (nodes: typeof virtualNodes, targetId: string): typeof virtualNodes[0] | null => {
for (const node of nodes) {
if (node.id === targetId) return node;
const found = findVirtualNode(node.children, targetId);
if (found) return found;
}
return null;
};
const virtualNode = findVirtualNode(virtualNodes, clickResult.virtualNodeId);
if (virtualNode) {
// Publish virtual-node:selected event (will trigger Inspector update)
// 发布 virtual-node:selected 事件(将触发 Inspector 更新)
messageHubRef.current.publish('virtual-node:selected', {
parentEntityId: clickResult.entityId,
virtualNodeId: clickResult.virtualNodeId,
virtualNode
});
}
} else {
// Normal entity selection
// 普通实体选择
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
}
e.preventDefault();
return; // Don't start camera pan
}
@@ -487,13 +517,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const entity = selectedEntityRef.current;
if (entity) {
const transform = entity.getComponent(TransformComponent);
const uiTransform = entity.getComponent(UITransformComponent);
if (transform) {
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
transformComponentRef.current = transform;
} else if (uiTransform) {
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
transformComponentRef.current = uiTransform;
}
}
}
@@ -573,63 +599,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}
// Try UITransformComponent
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x += worldDelta.x;
uiTransform.y += worldDelta.y;
} else if (mode === 'rotate') {
const rotationSpeed = 0.01;
uiTransform.rotation += deltaX * rotationSpeed;
} else if (mode === 'scale') {
const oldWidth = uiTransform.width * uiTransform.scaleX;
const oldHeight = uiTransform.height * uiTransform.scaleY;
// pivot点的世界坐标(缩放前)
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
const newScaleX = uiTransform.scaleX * scaleFactor;
const newScaleY = uiTransform.scaleY * scaleFactor;
const newWidth = uiTransform.width * newScaleX;
const newHeight = uiTransform.height * newScaleY;
// 调整位置使pivot点保持不动
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
uiTransform.scaleX = newScaleX;
uiTransform.scaleY = newScaleY;
}
}
// Update live transform display for UI | 更新 UI 的实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation * 180 / Math.PI,
scaleX: uiTransform.scaleX,
scaleY: uiTransform.scaleY
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
messageHubRef.current.publish('component:property:changed', {
entity,
component: uiTransform,
propertyName,
value: uiTransform[propertyName]
});
}
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else {
// Not dragging - update gizmo hover state
@@ -683,18 +652,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x = snapToGrid(uiTransform.x);
uiTransform.y = snapToGrid(uiTransform.y);
} else if (mode === 'rotate') {
uiTransform.rotation = snapRotation(uiTransform.rotation);
} else if (mode === 'scale') {
uiTransform.scaleX = snapScale(uiTransform.scaleX);
uiTransform.scaleY = snapScale(uiTransform.scaleY);
}
}
}
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
@@ -705,13 +662,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
if (entity && initialState && component && hub && cmdManager) {
const mode = transformModeRef.current as TransformOperationType;
let newState: TransformState;
if (component instanceof TransformComponent) {
newState = TransformCommand.captureTransformState(component);
} else {
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
}
const newState = TransformCommand.captureTransformState(component);
// Only create command if state actually changed | 只有状态实际改变时才创建命令
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
@@ -1715,58 +1666,115 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
const assetPath = e.dataTransfer.getData('asset-path');
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
const assetGuid = e.dataTransfer.getData('asset-guid');
const lowerPath = assetPath?.toLowerCase() || '';
if (!assetPath) {
return;
}
// Check for supported asset types | 检查支持的资产类型
const isPrefab = lowerPath.endsWith('.prefab');
const isFui = lowerPath.endsWith('.fui');
if (!isPrefab && !isFui) {
return;
}
e.preventDefault();
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
const scene = Core.scene;
if (!entityStore || !scene || !messageHub) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
try {
// 读取预制体文件 | Read prefab file
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
if (!entityStore || !messageHub || !commandManager) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
// 创建实例化命令 | Create instantiate command
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
if (isPrefab) {
// 处理预制体 | Handle prefab
if (!commandManager) {
console.error('[Viewport] CommandManager not available');
return;
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
} else if (isFui) {
// 处理 FUI 文件 | Handle FUI file
const filename = assetPath.split(/[/\\]/).pop() || 'FGUI View';
const entityName = filename.replace('.fui', '');
// 生成唯一名称 | Generate unique name
const existingCount = entityStore.getAllEntities()
.filter((ent: Entity) => ent.name.startsWith(entityName)).length;
const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName;
// 创建实体 | Create entity
const entity = scene.createEntity(finalName);
// 添加 TransformComponent | Add TransformComponent
const transform = new TransformComponent();
transform.position.x = worldPos.x;
transform.position.y = worldPos.y;
entity.addComponent(transform);
// 添加 FGUIComponent | Add FGUIComponent
const fguiComponent = new FGUIComponent();
// 优先使用 GUID,如果没有则使用路径(编辑器会通过 AssetRegistry 解析)
// Prefer GUID, fallback to path (editor resolves via AssetRegistry)
fguiComponent.packageGuid = assetGuid || assetPath;
fguiComponent.width = 1920;
fguiComponent.height = 1080;
entity.addComponent(fguiComponent);
// 注册并选中实体 | Register and select entity
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
}
} catch (error) {
console.error('[Viewport] Failed to instantiate prefab:', error);
console.error('[Viewport] Failed to handle drop:', error);
messageHub?.publish('notification:error', {
title: 'Drop Failed',
message: error instanceof Error ? error.message : String(error)
});
}
}, [messageHub, commandManager]);
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,8 @@ import {
ExtensionInspector,
AssetFileInspector,
RemoteEntityInspector,
PrefabInspector
PrefabInspector,
VirtualNodeInspector
} from './views';
import { EntityInspectorPanel } from '../inspector';
@@ -112,5 +113,14 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
);
}
if (target.type === 'virtual-node') {
return (
<VirtualNodeInspector
parentEntityId={target.data.parentEntityId}
virtualNode={target.data.virtualNode}
/>
);
}
return null;
}
@@ -1,5 +1,6 @@
import { Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core';
import type { IVirtualNode } from '@esengine/editor-core';
export interface InspectorProps {
entityStore: EntityStoreService;
@@ -20,11 +21,22 @@ export interface AssetFileInfo {
type ExtensionData = Record<string, any>;
/**
* Virtual node target data
* 虚拟节点目标数据
*/
export interface VirtualNodeTargetData {
parentEntityId: number;
virtualNodeId: string;
virtualNode: IVirtualNode;
}
export type InspectorTarget =
| { type: 'entity'; data: Entity }
| { type: 'remote-entity'; data: RemoteEntity; details?: EntityDetails }
| { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean }
| { type: 'extension'; data: ExtensionData }
| { type: 'virtual-node'; data: VirtualNodeTargetData }
| null;
export interface RemoteEntity {
@@ -0,0 +1,324 @@
/**
* 虚拟节点检查器
* Virtual Node Inspector
*
* 显示 FGUI 等组件内部虚拟节点的只读属性
* Displays read-only properties of virtual nodes from components like FGUI
*/
import type { IVirtualNode } from '@esengine/editor-core';
import { Box, Eye, EyeOff, Move, Maximize2, RotateCw, Palette, Type, Image, Square, Layers, MousePointer, Sliders } from 'lucide-react';
import '../../../styles/VirtualNodeInspector.css';
interface VirtualNodeInspectorProps {
parentEntityId: number;
virtualNode: IVirtualNode;
}
/**
* Format number to fixed decimal places
* 格式化数字到固定小数位
*/
function formatNumber(value: number | undefined, decimals: number = 2): string {
if (value === undefined || value === null) return '-';
return value.toFixed(decimals);
}
/**
* Property row component
* 属性行组件
*/
function PropertyRow({ label, value, icon }: { label: string; value: React.ReactNode; icon?: React.ReactNode }) {
return (
<div className="virtual-node-property-row">
<span className="property-label">
{icon && <span className="property-icon">{icon}</span>}
{label}
</span>
<span className="property-value">{value}</span>
</div>
);
}
/**
* Section component
* 分组组件
*/
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="virtual-node-section">
<div className="section-header">{title}</div>
<div className="section-content">{children}</div>
</div>
);
}
/**
* Color swatch component for displaying colors
* 颜色色块组件
*/
function ColorSwatch({ color }: { color: string }) {
return (
<span className="color-swatch-wrapper">
<span
className="color-swatch"
style={{ backgroundColor: color }}
/>
<span className="color-value">{color}</span>
</span>
);
}
/**
* Check if a property key is for common/transform properties
* 检查属性键是否为公共/变换属性
*/
const COMMON_PROPS = new Set([
'className', 'x', 'y', 'width', 'height', 'alpha', 'visible',
'touchable', 'rotation', 'scaleX', 'scaleY', 'pivotX', 'pivotY', 'grayed'
]);
/**
* Property categories for type-specific display
* 类型特定显示的属性分类
*/
const TYPE_SPECIFIC_SECTIONS: Record<string, { title: string; icon: React.ReactNode; props: string[] }> = {
Graph: {
title: '图形属性 | Graph',
icon: <Square size={12} />,
props: ['graphType', 'lineSize', 'lineColor', 'fillColor', 'cornerRadius', 'sides', 'startAngle']
},
Image: {
title: '图像属性 | Image',
icon: <Image size={12} />,
props: ['color', 'flip', 'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
},
TextField: {
title: '文本属性 | Text',
icon: <Type size={12} />,
props: ['text', 'font', 'fontSize', 'color', 'align', 'valign', 'leading', 'letterSpacing',
'bold', 'italic', 'underline', 'singleLine', 'autoSize', 'stroke', 'strokeColor']
},
Loader: {
title: '加载器属性 | Loader',
icon: <Image size={12} />,
props: ['url', 'align', 'verticalAlign', 'fill', 'shrinkOnly', 'autoSize', 'color',
'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
},
Button: {
title: '按钮属性 | Button',
icon: <MousePointer size={12} />,
props: ['title', 'icon', 'mode', 'selected', 'titleColor', 'titleFontSize',
'selectedTitle', 'selectedIcon']
},
List: {
title: '列表属性 | List',
icon: <Layers size={12} />,
props: ['defaultItem', 'itemCount', 'selectedIndex', 'scrollPane']
},
ProgressBar: {
title: '进度条属性 | Progress',
icon: <Sliders size={12} />,
props: ['value', 'max']
},
Slider: {
title: '滑块属性 | Slider',
icon: <Sliders size={12} />,
props: ['value', 'max']
},
Component: {
title: '组件属性 | Component',
icon: <Layers size={12} />,
props: ['numChildren', 'numControllers', 'numTransitions']
}
};
/**
* Format a property value for display
* 格式化属性值以供显示
*/
function formatPropertyValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) {
return '-';
}
// Color properties - show color swatch
if (typeof value === 'string' && (
key.toLowerCase().includes('color') ||
key === 'fillColor' ||
key === 'lineColor' ||
key === 'strokeColor' ||
key === 'titleColor'
)) {
if (value.startsWith('#') || value.startsWith('rgb')) {
return <ColorSwatch color={value} />;
}
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (typeof value === 'number') {
return formatNumber(value);
}
if (typeof value === 'string') {
// Truncate long strings
if (value.length > 50) {
return value.substring(0, 47) + '...';
}
return value || '-';
}
return JSON.stringify(value);
}
export function VirtualNodeInspector({ parentEntityId, virtualNode }: VirtualNodeInspectorProps) {
const { name, type, visible, x, y, width, height, data } = virtualNode;
// Extract additional properties from data
// 从 data 中提取额外属性
const alpha = data.alpha as number | undefined;
const rotation = data.rotation as number | undefined;
const scaleX = data.scaleX as number | undefined;
const scaleY = data.scaleY as number | undefined;
const touchable = data.touchable as boolean | undefined;
const grayed = data.grayed as boolean | undefined;
const pivotX = data.pivotX as number | undefined;
const pivotY = data.pivotY as number | undefined;
// Get type-specific section config
const typeSection = TYPE_SPECIFIC_SECTIONS[type];
// Collect type-specific properties
const typeSpecificProps: Array<{ key: string; value: unknown }> = [];
const otherProps: Array<{ key: string; value: unknown }> = [];
Object.entries(data).forEach(([key, value]) => {
if (COMMON_PROPS.has(key)) {
return; // Skip common props
}
if (typeSection?.props.includes(key)) {
typeSpecificProps.push({ key, value });
} else {
otherProps.push({ key, value });
}
});
return (
<div className="entity-inspector virtual-node-inspector">
{/* Header */}
<div className="virtual-node-header">
<Box size={16} className="header-icon" />
<div className="header-info">
<div className="header-name">{name}</div>
<div className="header-type">{type}</div>
</div>
<div className="header-badge">
Virtual Node
</div>
</div>
{/* Read-only notice */}
<div className="virtual-node-notice">
</div>
{/* Basic Properties */}
<Section title="基本属性 | Basic">
<PropertyRow
label="Visible"
value={visible ? <Eye size={14} /> : <EyeOff size={14} className="disabled" />}
/>
{touchable !== undefined && (
<PropertyRow
label="Touchable"
value={touchable ? 'Yes' : 'No'}
/>
)}
{grayed !== undefined && (
<PropertyRow
label="Grayed"
value={grayed ? 'Yes' : 'No'}
/>
)}
{alpha !== undefined && (
<PropertyRow
label="Alpha"
value={formatNumber(alpha)}
icon={<Palette size={12} />}
/>
)}
</Section>
{/* Transform */}
<Section title="变换 | Transform">
<PropertyRow
label="Position"
value={`(${formatNumber(x)}, ${formatNumber(y)})`}
icon={<Move size={12} />}
/>
<PropertyRow
label="Size"
value={`${formatNumber(width)} × ${formatNumber(height)}`}
icon={<Maximize2 size={12} />}
/>
{(rotation !== undefined && rotation !== 0) && (
<PropertyRow
label="Rotation"
value={`${formatNumber(rotation)}°`}
icon={<RotateCw size={12} />}
/>
)}
{(scaleX !== undefined || scaleY !== undefined) && (
<PropertyRow
label="Scale"
value={`(${formatNumber(scaleX ?? 1)}, ${formatNumber(scaleY ?? 1)})`}
/>
)}
{(pivotX !== undefined || pivotY !== undefined) && (
<PropertyRow
label="Pivot"
value={`(${formatNumber(pivotX ?? 0)}, ${formatNumber(pivotY ?? 0)})`}
/>
)}
</Section>
{/* Type-Specific Properties */}
{typeSection && typeSpecificProps.length > 0 && (
<Section title={typeSection.title}>
{typeSpecificProps.map(({ key, value }) => (
<PropertyRow
key={key}
label={key}
value={formatPropertyValue(key, value)}
icon={key === typeSection.props[0] ? typeSection.icon : undefined}
/>
))}
</Section>
)}
{/* Other Properties */}
{otherProps.length > 0 && (
<Section title="其他属性 | Other">
{otherProps.map(({ key, value }) => (
<PropertyRow
key={key}
label={key}
value={formatPropertyValue(key, value)}
/>
))}
</Section>
)}
{/* Debug Info */}
<Section title="调试信息 | Debug">
<PropertyRow label="Parent Entity ID" value={parentEntityId} />
<PropertyRow label="Virtual Node ID" value={virtualNode.id} />
<PropertyRow label="Child Count" value={virtualNode.children?.length ?? 0} />
</Section>
</div>
);
}
@@ -4,3 +4,4 @@ export { AssetFileInspector } from './AssetFileInspector';
export { RemoteEntityInspector } from './RemoteEntityInspector';
export { EntityInspector } from './EntityInspector';
export { PrefabInspector } from './PrefabInspector';
export { VirtualNodeInspector } from './VirtualNodeInspector';
@@ -9,6 +9,7 @@
import { useEffect, useRef } from 'react';
import { Core, HierarchyComponent, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
import { VirtualNodeRegistry } from '@esengine/editor-core';
import { useHierarchyStore } from '../stores/HierarchyStore';
import { useInspectorStore } from '../stores/InspectorStore';
import { getProfilerService } from '../services/getService';
@@ -285,6 +286,7 @@ export function useStoreSubscriptions({
setRemoteEntityTarget,
setAssetFileTarget,
setExtensionTarget,
setVirtualNodeTarget,
clearTarget,
updateRemoteEntityDetails,
incrementComponentVersion,
@@ -306,6 +308,9 @@ export function useStoreSubscriptions({
// 实体选择处理 | Handle entity selection
const handleEntitySelection = (data: { entity: any | null }) => {
// Clear virtual node selection when selecting an entity
// 选择实体时清除虚拟节点选择
VirtualNodeRegistry.clearSelectedVirtualNode();
if (data.entity) {
setEntityTarget(data.entity);
} else {
@@ -336,6 +341,18 @@ export function useStoreSubscriptions({
setExtensionTarget(data.data as Record<string, unknown>);
};
// 虚拟节点选择处理 | Handle virtual node selection
const handleVirtualNodeSelection = (data: {
parentEntityId: number;
virtualNodeId: string;
virtualNode: any;
}) => {
// Update VirtualNodeRegistry for Gizmo highlighting
// 更新 VirtualNodeRegistry 用于 Gizmo 高亮
VirtualNodeRegistry.setSelectedVirtualNode(data.parentEntityId, data.virtualNodeId);
setVirtualNodeTarget(data.parentEntityId, data.virtualNodeId, data.virtualNode);
};
// 资产文件选择处理 | Handle asset file selection
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
const fileInfo = data.fileInfo;
@@ -382,6 +399,7 @@ export function useStoreSubscriptions({
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
const unsubVirtualNodeSelect = messageHub.subscribe('virtual-node:selected', handleVirtualNodeSelection);
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', incrementComponentVersion);
const unsubComponentRemoved = messageHub.subscribe('component:removed', incrementComponentVersion);
@@ -394,6 +412,7 @@ export function useStoreSubscriptions({
unsubSceneRestored();
unsubRemoteSelect();
unsubNodeSelect();
unsubVirtualNodeSelect();
unsubAssetFileSelect();
unsubComponentAdded();
unsubComponentRemoved();
@@ -10,7 +10,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
import { createLogger, Core } from '@esengine/ecs-framework';
import type { IEditorPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
import EngineService from '../../services/EngineService';
import { EngineService } from '../../services/EngineService';
/**
* Get engine modules from ModuleRegistry.
@@ -37,6 +37,7 @@ export const UI_RESOLUTION_PRESETS = [
{ label: '1920 x 1080 (Full HD)', value: { width: 1920, height: 1080 } },
{ label: '1280 x 720 (HD)', value: { width: 1280, height: 720 } },
{ label: '1366 x 768 (HD+)', value: { width: 1366, height: 768 } },
{ label: '1136 x 640 (iPhone 5)', value: { width: 1136, height: 640 } },
{ label: '2560 x 1440 (2K)', value: { width: 2560, height: 1440 } },
{ label: '3840 x 2160 (4K)', value: { width: 3840, height: 2160 } },
{ label: '750 x 1334 (iPhone 6/7/8)', value: { width: 750, height: 1334 } },
@@ -137,74 +138,6 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
} as any // Cast to any to allow custom props
]
},
{
id: 'dynamic-atlas',
title: '$pluginSettings.project.dynamicAtlas.title',
description: '$pluginSettings.project.dynamicAtlas.description',
settings: [
{
key: 'project.dynamicAtlas.enabled',
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
type: 'boolean',
defaultValue: true,
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
},
{
key: 'project.dynamicAtlas.expansionStrategy',
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
type: 'select',
defaultValue: 'fixed',
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
options: [
{
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
value: 'fixed'
},
{
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
value: 'dynamic'
}
]
},
{
key: 'project.dynamicAtlas.fixedPageSize',
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
type: 'select',
defaultValue: 1024,
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
options: [
{ label: '512 x 512', value: 512 },
{ label: '1024 x 1024', value: 1024 },
{ label: '2048 x 2048', value: 2048 }
]
},
{
key: 'project.dynamicAtlas.maxPages',
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
type: 'select',
defaultValue: 4,
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
options: [
{ label: '1', value: 1 },
{ label: '2', value: 2 },
{ label: '4', value: 4 },
{ label: '8', value: 8 }
]
},
{
key: 'project.dynamicAtlas.maxTextureSize',
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
type: 'select',
defaultValue: 512,
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
options: [
{ label: '256 x 256', value: 256 },
{ label: '512 x 512', value: 512 },
{ label: '1024 x 1024', value: 1024 }
]
}
]
}
]
});
@@ -241,34 +174,11 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
this.applyUIDesignResolution();
}
// Check if dynamic atlas settings changed
// 检查动态图集设置是否更改
if ('project.dynamicAtlas.enabled' in changedSettings ||
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
'project.dynamicAtlas.maxPages' in changedSettings ||
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
logger.info('Dynamic atlas settings changed, reinitializing...');
this.applyDynamicAtlasSettings();
}
}) as EventListener;
window.addEventListener('settings:changed', this.settingsListener);
}
/**
* Apply dynamic atlas settings
*
*/
private applyDynamicAtlasSettings(): void {
const engineService = EngineService.getInstance();
if (engineService.isInitialized()) {
engineService.reinitializeDynamicAtlas();
logger.info('Dynamic atlas settings applied');
}
}
/**
* Apply UI design resolution from ProjectService
* ProjectService UI
+129 -158
View File
@@ -21,16 +21,14 @@ import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@ese
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
import { ParticleSystemComponent } from '@esengine/particle';
import {
invalidateUIRenderCaches,
UIRenderProviderToken,
UIInputSystemToken,
initializeDynamicAtlasService,
reinitializeDynamicAtlasService,
registerTexturePathMapping,
AtlasExpansionStrategy,
type IAtlasEngineBridge,
type DynamicAtlasConfig
} from '@esengine/ui';
FGUIRenderSystemToken,
getFGUIRenderSystem,
FGUIRenderDataProvider,
setGlobalTextureService,
createTextureResolver,
Stage,
getDOMTextRenderer
} from '@esengine/fairygui';
import { SettingsService } from './SettingsService';
import * as esEngine from '@esengine/engine';
import {
@@ -60,6 +58,7 @@ import { WebInputSubsystem } from '@esengine/platform-web';
import { resetEngineState } from '../hooks/useEngine';
import { convertFileSrc } from '@tauri-apps/api/core';
import { IdGenerator } from '../utils/idGenerator';
import { TauriAssetReader } from './TauriAssetReader';
const logger = createLogger('EngineService');
@@ -303,20 +302,106 @@ export class EngineService {
const animatorSystem = services.get(SpriteAnimatorSystemToken);
const behaviorTreeSystem = services.get(BehaviorTreeSystemToken);
const physicsSystem = services.get(Physics2DSystemToken);
const uiInputSystem = services.get(UIInputSystemToken);
const uiRenderProvider = services.get(UIRenderProviderToken);
const fguiRenderSystem = getFGUIRenderSystem();
if (animatorSystem) runtimeServices.register(SpriteAnimatorSystemToken, animatorSystem);
if (behaviorTreeSystem) runtimeServices.register(BehaviorTreeSystemToken, behaviorTreeSystem);
if (physicsSystem) runtimeServices.register(Physics2DSystemToken, physicsSystem);
if (uiInputSystem) runtimeServices.register(UIInputSystemToken, uiInputSystem);
if (uiRenderProvider) runtimeServices.register(UIRenderProviderToken, uiRenderProvider);
if (fguiRenderSystem) runtimeServices.register(FGUIRenderSystemToken, fguiRenderSystem);
}
// 设置 UI 渲染数据提供者
const uiRenderProvider = services.get(UIRenderProviderToken);
if (uiRenderProvider && this._runtime.renderSystem) {
this._runtime.renderSystem.setUIRenderDataProvider(uiRenderProvider);
// 设置 FairyGUI 渲染系统 | Set FairyGUI render system
const fguiRenderSystem = getFGUIRenderSystem();
const renderSystem = this._runtime?.renderSystem;
if (fguiRenderSystem && this._runtime?.bridge && renderSystem) {
const bridge = this._runtime.bridge;
// Set global texture service for FGUI
// 设置 FGUI 的全局纹理服务
setGlobalTextureService({
loadTextureByPath: (url: string) => bridge.loadTextureByPath(url),
getTextureIdByPath: (url: string) => bridge.getTextureIdByPath(url)
});
// Create render data provider to convert FGUI primitives to engine format
// 创建渲染数据提供者,将 FGUI 图元转换为引擎格式
const fguiRenderDataProvider = new FGUIRenderDataProvider();
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
fguiRenderDataProvider.setSorting('UI', 1000);
// Use the centralized texture resolver from FGUITextureManager
// 使用 FGUITextureManager 的集中式纹理解析器
fguiRenderDataProvider.setTextureResolver(createTextureResolver());
// Initialize DOM text renderer for text fallback
// 初始化 DOM 文本渲染器作为文本回退
const domTextRenderer = getDOMTextRenderer();
const canvas = document.getElementById('viewport-canvas') as HTMLCanvasElement;
if (canvas) {
domTextRenderer.initialize(canvas);
}
// Create UI render data provider adapter for EngineRenderSystem
// 为 EngineRenderSystem 创建 UI 渲染数据提供者适配器
// This adapter updates FGUI and returns render data in the format expected by the engine
// 此适配器更新 FGUI 并以引擎期望的格式返回渲染数据
const runtime = this._runtime;
const uiRenderProvider = {
getRenderData: () => {
// Update canvas size for coordinate conversion
// FGUI uses top-left origin, engine uses center origin
// 更新画布尺寸用于坐标转换(FGUI 使用左上角原点,引擎使用中心原点)
const canvasSize = renderSystem.getUICanvasSize();
const canvasWidth = canvasSize.width > 0 ? canvasSize.width : 1920;
const canvasHeight = canvasSize.height > 0 ? canvasSize.height : 1080;
fguiRenderDataProvider.setCanvasSize(canvasWidth, canvasHeight);
// Update DOM text renderer settings
// 更新 DOM 文本渲染器设置
domTextRenderer.setDesignSize(canvasWidth, canvasHeight);
domTextRenderer.setPreviewMode(renderSystem.isPreviewMode());
// In editor mode, sync camera state for world-space text rendering
// 在编辑器模式下,同步相机状态以进行世界空间文本渲染
if (!renderSystem.isPreviewMode() && runtime?.bridge) {
const camera = runtime.bridge.getCamera();
domTextRenderer.setCamera({
x: camera.x,
y: camera.y,
zoom: camera.zoom,
rotation: camera.rotation
});
}
// Update FGUI system to collect render primitives
// 更新 FGUI 系统以收集渲染图元
fguiRenderSystem.update();
// Render text using DOM (fallback until MSDF text is fully integrated)
// 使用 DOM 渲染文本(作为回退,直到 MSDF 文本完全集成)
domTextRenderer.beginFrame();
domTextRenderer.renderPrimitives(fguiRenderSystem.collector.getPrimitives());
domTextRenderer.endFrame();
// Get render data from provider
// 从提供者获取渲染数据
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
return fguiRenderDataProvider.getRenderData();
},
getMeshRenderData: () => {
// Get mesh render data for complex shapes (ellipses, polygons, etc.)
// 获取复杂形状(椭圆、多边形等)的网格渲染数据
return fguiRenderDataProvider.getMeshRenderData();
}
};
// Register with EngineRenderSystem
// 注册到 EngineRenderSystem
renderSystem.setUIRenderDataProvider(uiRenderProvider);
fguiRenderSystem.enabled = true;
logger.info('FairyGUI render system connected to engine via UI render provider');
}
// 在编辑器模式下,禁用游戏逻辑系统
@@ -350,14 +435,10 @@ export class EngineService {
pluginManager.clearSceneSystems();
}
// 使用服务注册表获取 UI 输入系统
// Use service registry to get UI input system
const runtimeServices = this._runtime?.getServiceRegistry();
if (runtimeServices) {
const uiInputSystem = runtimeServices.get(UIInputSystemToken);
if (uiInputSystem && uiInputSystem.unbind) {
uiInputSystem.unbind();
}
// 清理 FairyGUI 渲染系统 | Clean up FairyGUI render system
const fguiRenderSystem = getFGUIRenderSystem();
if (fguiRenderSystem) {
fguiRenderSystem.enabled = false;
}
// 清理 viewport | Clear viewport
@@ -509,17 +590,26 @@ export class EngineService {
const pathTransformerFn = (path: string) => {
if (!path.startsWith('http://') && !path.startsWith('https://') &&
!path.startsWith('data:') && !path.startsWith('asset://')) {
// Normalize path separators to forward slashes first
// 首先将路径分隔符规范化为正斜杠
path = path.replace(/\\/g, '/');
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
if (projectService && projectService.isProjectOpen()) {
const projectInfo = projectService.getCurrentProject();
if (projectInfo) {
const projectPath = projectInfo.path;
const separator = projectPath.includes('\\') ? '\\' : '/';
path = `${projectPath}${separator}${path.replace(/\//g, separator)}`;
// Normalize project path to forward slashes
// 将项目路径规范化为正斜杠
const projectPath = projectInfo.path.replace(/\\/g, '/');
path = `${projectPath}/${path}`;
}
}
}
return convertFileSrc(path);
// Use convertFileSrc which handles the asset protocol correctly
// 使用 convertFileSrc 正确处理 asset 协议
const result = convertFileSrc(path);
console.log(`[pathTransformer] ${path} -> ${result}`);
return result;
}
return path;
};
@@ -599,58 +689,6 @@ export class EngineService {
this._sceneResourceManager = new SceneResourceManager();
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
// 初始化动态图集服务(用于 UI 合批)
// Initialize dynamic atlas service (for UI batching)
const bridge = this._runtime.bridge;
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
const atlasBridge: IAtlasEngineBridge = {
createBlankTexture: (width: number, height: number) => {
return bridge.createBlankTexture(width, height);
},
updateTextureRegion: (
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
) => {
bridge.updateTextureRegion(id, x, y, width, height, pixels);
}
};
// 从设置中获取动态图集配置
// Get dynamic atlas config from settings
const settingsService = SettingsService.getInstance();
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
if (atlasEnabled) {
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
const expansionStrategy = strategyValue === 'dynamic'
? AtlasExpansionStrategy.Dynamic
: AtlasExpansionStrategy.Fixed;
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
initializeDynamicAtlasService(atlasBridge, {
expansionStrategy,
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
maxPageSize: 2048, // 最大页面大小 | Max page size
maxPages,
maxTextureSize,
padding: 1
});
}
// 注册纹理加载回调,当纹理加载时自动注册路径映射
// Register texture load callback to register path mapping when textures load
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
registerTexturePathMapping(guid, path);
});
}
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
if (sceneManagerService) {
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
@@ -1178,9 +1216,16 @@ export class EngineService {
/**
* Set UI canvas size for boundary display.
* Also syncs with FGUI Stage design size for coordinate conversion.
*
* UI FGUI Stage
*/
setUICanvasSize(width: number, height: number): void {
this._runtime?.setUICanvasSize(width, height);
// Sync to FGUI Stage design size for coordinate conversion
// 同步到 FGUI Stage 设计尺寸用于坐标转换
Stage.inst.setDesignSize(width, height);
}
/**
@@ -1213,9 +1258,8 @@ export class EngineService {
const success = this._runtime?.saveSceneSnapshot() ?? false;
if (success) {
// 清除 UI 渲染缓存(因为纹理已被清除)
// Clear UI render caches (since textures have been cleared)
invalidateUIRenderCaches();
// 场景快照保存成功
// Scene snapshot saved successfully
}
return success;
@@ -1230,9 +1274,6 @@ export class EngineService {
const success = await this._runtime.restoreSceneSnapshot();
if (success) {
// 清除 UI 渲染缓存
invalidateUIRenderCaches();
// Reset particle component textureIds before loading resources
// 在加载资源前重置粒子组件的 textureId
// This ensures ParticleUpdateSystem will reload textures
@@ -1408,76 +1449,6 @@ export class EngineService {
return this._runtime;
}
/**
* Reinitialize dynamic atlas with current settings.
* 使
*
* Call this when dynamic atlas settings change to apply them.
*
*/
reinitializeDynamicAtlas(): void {
const bridge = this._runtime?.bridge;
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
return;
}
const atlasBridge: IAtlasEngineBridge = {
createBlankTexture: (width: number, height: number) => {
return bridge.createBlankTexture!(width, height);
},
updateTextureRegion: (
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
) => {
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
}
};
// 从设置中获取动态图集配置
// Get dynamic atlas config from settings
const settingsService = SettingsService.getInstance();
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
if (!atlasEnabled) {
logger.info('Dynamic atlas is disabled');
return;
}
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
const expansionStrategy = strategyValue === 'dynamic'
? AtlasExpansionStrategy.Dynamic
: AtlasExpansionStrategy.Fixed;
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
logger.info('Dynamic atlas settings read from SettingsService:', {
strategyValue,
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
fixedPageSize,
maxPages,
maxTextureSize
});
const config: DynamicAtlasConfig = {
expansionStrategy,
initialPageSize: 256,
fixedPageSize,
maxPageSize: 2048,
maxPages,
maxTextureSize,
padding: 1
};
reinitializeDynamicAtlasService(atlasBridge, config);
logger.info('Dynamic atlas reinitialized with config:', config);
}
/**
* Dispose engine resources.
*/
@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent } from '@esengine/sprite';
import { ParticleSystemComponent } from '@esengine/particle';
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
import { FGUIComponent, GRoot, GComponent } from '@esengine/fairygui';
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
import { invoke } from '@tauri-apps/api/core';
@@ -102,123 +102,41 @@ export interface ParticleDebugInfo {
}
/**
* UI
* UI element debug info
* FairyGUI
* FairyGUI element debug info
*/
export interface UIDebugInfo {
export interface FGUIDebugInfo {
entityId: number;
entityName: string;
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
packageName: string;
componentName: string;
x: number;
y: number;
width: number;
height: number;
worldX: number;
worldY: number;
rotation: number;
visible: boolean;
alpha: number;
sortingLayer: string;
orderInLayer: number;
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
depth: number;
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
worldOrderInLayer: number;
textureGuid?: string;
textureUrl?: string;
backgroundColor?: string;
text?: string;
fontSize?: number;
/** 材质/着色器 ID | Material/Shader ID */
materialId: number;
/** 着色器名称 | Shader name */
shaderName: string;
/** Shader uniform 覆盖值 | Shader uniform override values */
uniforms: Record<string, UniformDebugValue>;
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
aspectRatio: number;
/** 子对象数量 | Child count */
childCount: number;
}
/**
*
* Render debug snapshot
*/
/**
*
* Atlas entry debug info
*/
export interface AtlasEntryDebugInfo {
/** 纹理 GUID | Texture GUID */
guid: string;
/** 在图集中的位置 | Position in atlas */
x: number;
y: number;
width: number;
height: number;
/** UV 坐标 | UV coordinates */
uv: [number, number, number, number];
/** 纹理图像 data URL(用于预览)| Texture image data URL (for preview) */
dataUrl?: string;
}
/**
*
* Atlas page debug info
*/
export interface AtlasPageDebugInfo {
/** 页面索引 | Page index */
pageIndex: number;
/** 纹理 ID | Texture ID */
textureId: number;
/** 页面尺寸 | Page size */
width: number;
height: number;
/** 占用率 | Occupancy */
occupancy: number;
/** 此页面中的条目 | Entries in this page */
entries: AtlasEntryDebugInfo[];
}
/**
*
* Dynamic atlas statistics
*/
export interface AtlasStats {
/** 是否启用 | Whether enabled */
enabled: boolean;
/** 图集页数 | Number of atlas pages */
pageCount: number;
/** 已加入图集的纹理数 | Number of textures in atlas */
textureCount: number;
/** 平均占用率 | Average occupancy */
averageOccupancy: number;
/** 正在加载的纹理数 | Number of loading textures */
loadingCount: number;
/** 加载失败的纹理数 | Number of failed textures */
failedCount: number;
/** 每个页面的详细信息 | Detailed info for each page */
pages: AtlasPageDebugInfo[];
}
export interface RenderDebugSnapshot {
timestamp: number;
frameNumber: number;
textures: TextureDebugInfo[];
sprites: SpriteDebugInfo[];
particles: ParticleDebugInfo[];
uiElements: UIDebugInfo[];
/** UI 合批调试信息 | UI batch debug info */
uiBatches: BatchDebugInfo[];
/** 动态图集统计 | Dynamic atlas stats */
atlasStats?: AtlasStats;
fguiElements: FGUIDebugInfo[];
stats: {
totalSprites: number;
totalParticles: number;
totalUIElements: number;
totalFGUIElements: number;
totalTextures: number;
drawCalls: number;
/** UI 批次数 | UI batch count */
uiBatchCount: number;
};
}
@@ -374,12 +292,6 @@ export class RenderDebugService {
const dataUrl = `data:${mimeType};base64,${base64}`;
this._textureCache.set(textureGuid, dataUrl);
// 注册 GUID 到 data URL 映射(用于动态图集)
// Register GUID to data URL mapping (for dynamic atlas)
if (isGuid) {
registerTexturePathMapping(textureGuid, dataUrl);
}
} catch (err) {
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
} finally {
@@ -399,82 +311,28 @@ export class RenderDebugService {
this._frameNumber++;
// 收集 UI 合批信息 | Collect UI batch info
const uiCollector = getUIRenderCollector();
const uiBatches = [...uiCollector.getBatchDebugInfo()];
// 收集动态图集统计 | Collect dynamic atlas stats
const atlasService = getDynamicAtlasService();
let atlasStats: AtlasStats | undefined;
if (atlasService) {
const stats = atlasService.getStats();
const pageDetails = atlasService.getPageDetails();
// 转换页面详细信息 | Convert page details
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
pageIndex: page.pageIndex,
textureId: page.textureId,
width: page.width,
height: page.height,
occupancy: page.occupancy,
entries: page.entries.map(e => ({
guid: e.guid,
x: e.entry.region.x,
y: e.entry.region.y,
width: e.entry.region.width,
height: e.entry.region.height,
uv: e.entry.uv,
// 从纹理缓存获取 data URL | Get data URL from texture cache
dataUrl: this._textureCache.get(e.guid)
}))
}));
atlasStats = {
enabled: true,
pageCount: stats.pageCount,
textureCount: stats.textureCount,
averageOccupancy: stats.averageOccupancy,
loadingCount: stats.loadingCount,
failedCount: stats.failedCount,
pages
};
} else {
atlasStats = {
enabled: false,
pageCount: 0,
textureCount: 0,
averageOccupancy: 0,
loadingCount: 0,
failedCount: 0,
pages: []
};
}
const snapshot: RenderDebugSnapshot = {
timestamp: Date.now(),
frameNumber: this._frameNumber,
textures: this._collectTextures(),
sprites: this._collectSprites(scene.entities.buffer),
particles: this._collectParticles(scene.entities.buffer),
uiElements: this._collectUI(scene.entities.buffer),
uiBatches,
atlasStats,
fguiElements: this._collectFGUI(scene.entities.buffer),
stats: {
totalSprites: 0,
totalParticles: 0,
totalUIElements: 0,
totalFGUIElements: 0,
totalTextures: 0,
drawCalls: 0,
uiBatchCount: uiBatches.length,
},
};
// 计算统计 | Calculate stats
snapshot.stats.totalSprites = snapshot.sprites.length;
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
snapshot.stats.totalUIElements = snapshot.uiElements.length;
snapshot.stats.totalFGUIElements = snapshot.fguiElements.length;
snapshot.stats.totalTextures = snapshot.textures.length;
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
snapshot.stats.drawCalls = snapshot.sprites.length + snapshot.particles.length + snapshot.fguiElements.length;
// 保存快照 | Save snapshot
this._snapshots.push(snapshot);
@@ -673,97 +531,36 @@ export class RenderDebugService {
}
/**
* UI
* Collect UI element info
* FairyGUI
* Collect FairyGUI element info
*/
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
const uiElements: UIDebugInfo[] = [];
private _collectFGUI(entities: readonly Entity[]): FGUIDebugInfo[] {
const fguiElements: FGUIDebugInfo[] = [];
for (const entity of entities) {
const uiTransform = entity.getComponent(UITransformComponent);
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
if (!uiTransform) continue;
if (!fguiComp) continue;
const uiRender = entity.getComponent(UIRenderComponent);
const uiText = entity.getComponent(UITextComponent);
const root = fguiComp.root;
const displayObject = root as GComponent | null;
// 确定类型 | Determine type
let type: UIDebugInfo['type'] = 'unknown';
if (uiText) {
type = 'text';
} else if (uiRender) {
switch (uiRender.type) {
case 'rect': type = 'rect'; break;
case 'image': type = 'image'; break;
case 'ninepatch': type = 'ninepatch'; break;
case 'circle': type = 'circle'; break;
case 'rounded-rect': type = 'rounded-rect'; break;
default: type = 'rect';
}
}
// 获取纹理 GUID | Get texture GUID
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
// 转换颜色为十六进制字符串 | Convert color to hex string
const backgroundColor = uiRender?.backgroundColor !== undefined
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
: undefined;
// 获取材质/着色器 ID | Get material/shader ID
const materialId = uiRender?.getMaterialId?.() ?? 0;
// 收集 uniform 覆盖值 | Collect uniform override values
const uniforms: Record<string, UniformDebugValue> = {};
const overrides = uiRender?.materialOverrides ?? {};
for (const [name, override] of Object.entries(overrides)) {
uniforms[name] = {
type: override.type,
value: override.value
};
}
// 计算 aspectRatio (与 Rust 端一致: width / height)
// Calculate aspectRatio (same as Rust side: width / height)
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
// worldOrderInLayer = depth * 1000 + orderInLayer
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
const depth = Math.floor(worldOrderInLayer / 1000);
uiElements.push({
fguiElements.push({
entityId: entity.id,
entityName: entity.name,
type,
x: uiTransform.x,
y: uiTransform.y,
width: uiTransform.width,
height: uiTransform.height,
worldX: uiTransform.worldX,
worldY: uiTransform.worldY,
rotation: uiTransform.rotation,
visible: uiTransform.visible && uiTransform.worldVisible,
alpha: uiTransform.worldAlpha,
sortingLayer: uiTransform.sortingLayer,
orderInLayer: uiTransform.orderInLayer,
depth,
worldOrderInLayer,
textureGuid: textureGuid || undefined,
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
backgroundColor,
text: uiText?.text,
fontSize: uiText?.fontSize,
materialId,
shaderName: getShaderName(materialId),
uniforms,
aspectRatio,
packageName: fguiComp.packageGuid ?? '',
componentName: fguiComp.componentName ?? '',
x: displayObject?.x ?? 0,
y: displayObject?.y ?? 0,
width: displayObject?.width ?? 0,
height: displayObject?.height ?? 0,
visible: displayObject?.visible ?? true,
alpha: displayObject?.alpha ?? 1,
childCount: displayObject?.numChildren ?? 0,
});
}
return uiElements;
return fguiElements;
}
/**
@@ -805,8 +602,3 @@ export class RenderDebugService {
// 全局实例 | Global instance
export const renderDebugService = RenderDebugService.getInstance();
// 导出到全局以便控制台使用 | Export to global for console usage
if (typeof window !== 'undefined') {
(window as any).renderDebugService = renderDebugService;
}
@@ -7,12 +7,20 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { convertFileSrc } from '@tauri-apps/api/core';
import type { IAssetReader } from '@esengine/asset-system';
/** Blob URL cache to avoid re-reading files | Blob URL 缓存避免重复读取文件 */
const blobUrlCache = new Map<string, string>();
/**
* Asset reader implementation for Tauri.
* Tauri
*
* Uses Tauri backend commands to read files and creates Blob URLs for images.
* This approach works reliably with WebGL/Canvas without protocol restrictions.
*
* 使 Tauri Blob URL
* WebGL/Canvas
*/
export class TauriAssetReader implements IAssetReader {
/**
@@ -33,31 +41,71 @@ export class TauriAssetReader implements IAssetReader {
}
/**
* Load image from file.
*
* Load image from file via backend.
*
*
* Reads binary data via Tauri backend and creates a Blob URL.
* This bypasses browser protocol restrictions (asset://, file://).
*
* Tauri Blob URL
*
*/
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
// Only convert if not already a URL.
// 仅当不是 URL 时才转换。
let assetUrl = absolutePath;
if (!absolutePath.startsWith('http://') &&
!absolutePath.startsWith('https://') &&
!absolutePath.startsWith('data:') &&
!absolutePath.startsWith('asset://')) {
assetUrl = convertFileSrc(absolutePath);
// Return cached if available
let blobUrl = blobUrlCache.get(absolutePath);
if (!blobUrl) {
// Read binary via backend
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
const data = new Uint8Array(bytes);
// Determine MIME type from extension
const ext = absolutePath.toLowerCase().split('.').pop();
let mimeType = 'image/png';
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
else if (ext === 'gif') mimeType = 'image/gif';
else if (ext === 'webp') mimeType = 'image/webp';
// Create Blob URL
const blob = new Blob([data], { type: mimeType });
blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(absolutePath, blobUrl);
}
// Load image from Blob URL
return new Promise((resolve, reject) => {
const image = new Image();
// 允许跨域访问,防止 canvas 被污染
// Allow cross-origin access to prevent canvas tainting
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
image.src = assetUrl;
image.src = blobUrl!;
});
}
/**
* Get Blob URL for a file (for engine texture loading).
* Blob URL
*/
async getBlobUrl(absolutePath: string): Promise<string> {
let blobUrl = blobUrlCache.get(absolutePath);
if (!blobUrl) {
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
const data = new Uint8Array(bytes);
const ext = absolutePath.toLowerCase().split('.').pop();
let mimeType = 'image/png';
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
else if (ext === 'gif') mimeType = 'image/gif';
else if (ext === 'webp') mimeType = 'image/webp';
const blob = new Blob([data], { type: mimeType });
blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(absolutePath, blobUrl);
}
return blobUrl;
}
/**
* Load audio from file.
*
@@ -9,7 +9,8 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import type { Entity } from '@esengine/ecs-framework';
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails } from '../components/inspectors/types';
import type { IVirtualNode } from '@esengine/editor-core';
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails, VirtualNodeTargetData } from '../components/inspectors/types';
// ============= Types =============
@@ -45,6 +46,8 @@ export interface InspectorActions {
setAssetFileTarget: (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => void;
/** 设置扩展目标 | Set extension target */
setExtensionTarget: (data: Record<string, unknown>) => void;
/** 设置虚拟节点目标 | Set virtual node target */
setVirtualNodeTarget: (parentEntityId: number, virtualNodeId: string, virtualNode: IVirtualNode) => void;
/** 清除目标 | Clear target */
clearTarget: () => void;
/** 更新远程实体详情 | Update remote entity details */
@@ -118,6 +121,17 @@ export const useInspectorStore = create<InspectorStore>()(
});
},
setVirtualNodeTarget: (parentEntityId, virtualNodeId, virtualNode) => {
// 锁定时忽略 | Ignore when locked
if (get().isLocked) return;
set({
target: {
type: 'virtual-node',
data: { parentEntityId, virtualNodeId, virtualNode }
}
});
},
clearTarget: () => {
// 锁定时忽略 | Ignore when locked
if (get().isLocked) return;
@@ -795,3 +795,49 @@
display: none;
}
}
/* ==================== Virtual Nodes | 虚拟节点 ==================== */
/* Virtual nodes are read-only internal nodes from components like FGUI */
/* 虚拟节点是来自组件(如 FGUI)的只读内部节点 */
.outliner-item.virtual-node {
background: rgba(245, 158, 11, 0.05);
border-left: 2px solid rgba(245, 158, 11, 0.4);
}
.outliner-item.virtual-node:hover {
background: rgba(245, 158, 11, 0.1);
}
.outliner-item.virtual-node.selected {
background: rgba(245, 158, 11, 0.2);
border-left-color: #f59e0b;
}
.outliner-item.virtual-node .outliner-item-name.virtual-name {
color: #fbbf24;
font-style: italic;
}
.outliner-item.virtual-node .outliner-item-type.virtual-type {
color: #d97706;
font-size: 10px;
}
/* Hidden virtual node (invisible in UI) */
/* 隐藏的虚拟节点(在 UI 中不可见) */
.outliner-item.virtual-node.hidden-node {
opacity: 0.5;
}
.outliner-item.virtual-node.hidden-node .outliner-item-name {
text-decoration: line-through;
}
/* Virtual node icon colors */
/* 虚拟节点图标颜色 */
.outliner-item.virtual-node svg {
color: #f59e0b;
flex-shrink: 0;
margin-right: 4px;
}
@@ -0,0 +1,165 @@
/**
* 虚拟节点检查器样式
* Virtual Node Inspector styles
*/
.virtual-node-inspector {
display: flex;
flex-direction: column;
height: 100%;
background-color: #262626;
color: #ccc;
font-size: 11px;
overflow-y: auto;
}
/* Header */
.virtual-node-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: linear-gradient(to right, rgba(245, 158, 11, 0.1), transparent);
border-bottom: 1px solid #3a3a3a;
border-left: 3px solid #f59e0b;
}
.virtual-node-header .header-icon {
color: #f59e0b;
flex-shrink: 0;
}
.virtual-node-header .header-info {
flex: 1;
min-width: 0;
}
.virtual-node-header .header-name {
font-size: 13px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.virtual-node-header .header-type {
font-size: 10px;
color: #888;
margin-top: 2px;
}
.virtual-node-header .header-badge {
font-size: 9px;
padding: 2px 6px;
background: rgba(245, 158, 11, 0.2);
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 3px;
color: #f59e0b;
white-space: nowrap;
}
/* Read-only notice */
.virtual-node-notice {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(59, 130, 246, 0.1);
border-bottom: 1px solid #3a3a3a;
color: #60a5fa;
font-size: 10px;
}
/* Section */
.virtual-node-section {
margin: 8px 0;
}
.virtual-node-section .section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #2d2d2d;
border-top: 1px solid #1a1a1a;
border-bottom: 1px solid #1a1a1a;
font-size: 10px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.virtual-node-section .section-content {
padding: 4px 0;
}
/* Property Row */
.virtual-node-property-row {
display: flex;
align-items: center;
padding: 4px 12px;
min-height: 24px;
}
.virtual-node-property-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.virtual-node-property-row .property-label {
display: flex;
align-items: center;
gap: 6px;
width: 100px;
flex-shrink: 0;
color: #999;
font-size: 11px;
}
.virtual-node-property-row .property-icon {
display: flex;
align-items: center;
color: #666;
}
.virtual-node-property-row .property-value {
flex: 1;
color: #ccc;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.virtual-node-property-row .property-value svg {
color: #4ade80;
}
.virtual-node-property-row .property-value svg.disabled {
color: #666;
}
/* Color swatch */
.color-swatch-wrapper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.color-swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
.color-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 10px;
color: #888;
}
@@ -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;
@@ -127,6 +127,8 @@ const EXTENSION_TYPE_MAP: Record<string, AssetRegistryType> = {
'.tsx': 'tileset',
// Particle system
'.particle': 'particle',
// FairyGUI
'.fui': 'fui',
};
/**
@@ -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;
}
}
@@ -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;
}
}
+2
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';
+1 -1
View File
@@ -32,7 +32,7 @@
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/ui": "workspace:*",
"@esengine/fairygui": "workspace:*",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"react": "^18.3.1",
+107 -76
View File
@@ -272,86 +272,117 @@ export type { LucideIcon } from 'lucide-react';
export { PluginAPI } from './PluginAPI';
// =============================================================================
// UI System
// FairyGUI System
// =============================================================================
export {
// Components - Core
UITransformComponent,
AnchorPreset,
UIRenderComponent,
UIRenderType,
UIInteractableComponent,
UITextComponent,
UILayoutComponent,
UILayoutType,
UIJustifyContent,
UIAlignItems,
// Components - Widgets
UIButtonComponent,
UIProgressBarComponent,
UIProgressDirection,
UIProgressFillMode,
UISliderComponent,
UISliderOrientation,
UIScrollViewComponent,
UIScrollbarVisibility,
// Systems - Core
UILayoutSystem,
UIInputSystem,
UIAnimationSystem,
UIEasing,
UIRenderDataProvider,
// Systems - Render
UIRenderCollector,
getUIRenderCollector,
resetUIRenderCollector,
invalidateUIRenderCaches,
UIRenderBeginSystem,
UIRectRenderSystem,
UITextRenderSystem,
UIButtonRenderSystem,
UIProgressBarRenderSystem,
UISliderRenderSystem,
UIScrollViewRenderSystem,
// Rendering
WebGLUIRenderer,
TextRenderer,
// Builder API
UIBuilder,
// Plugin
UIPlugin,
UIRuntimeModule,
} from '@esengine/ui';
// ECS Integration
FGUIComponent,
FGUIRenderSystem,
getFGUIRenderSystem,
setFGUIRenderSystem,
FGUIRuntimeModule,
FGUIPlugin,
// Core
GObject,
GComponent,
GRoot,
GGroup,
Controller,
Transition,
Timer,
Stage,
EScaleMode,
EAlignMode,
UIConfig,
getUIConfig,
setUIConfig,
UIObjectFactory,
GObjectPool,
DragDropManager,
// Widgets
GImage,
GTextField,
GGraph,
GButton,
GProgressBar,
GSlider,
GLoader,
GList,
GTextInput,
EKeyboardType,
PopupMenu,
Window,
// Package
UIPackage,
PackageItem,
// Events
EventDispatcher,
FGUIEvents,
// Render
RenderCollector,
RenderBridge,
Canvas2DBackend,
FGUIRenderDataProvider,
createFGUIRenderDataProvider,
// Tween
GTween,
GTweener,
TweenManager,
TweenValue,
evaluateEase,
// Asset
FUIAssetLoader,
fuiAssetLoader,
// Field Types
EButtonMode,
EAutoSizeType,
EAlignType,
EVertAlignType,
ELoaderFillType,
EListLayoutType,
EListSelectionMode,
EOverflowType,
EPackageItemType,
EObjectType,
EProgressTitleType,
EScrollBarDisplayType,
EScrollType,
EFlipType,
EChildrenRenderOrder,
EGroupLayoutType,
EPopupDirection,
ERelationType,
EFillMethod,
EFillOrigin,
EObjectPropID,
EGearType,
EEaseType,
EBlendMode,
ETransitionActionType,
EGraphType,
} from '@esengine/fairygui';
export type {
// Types from UI
UIBorderStyle,
UIShadowStyle,
UICursorType,
UITextAlign,
UITextVerticalAlign,
UITextOverflow,
UIFontWeight,
UIPadding,
UIButtonStyle,
UIButtonDisplayMode,
UIInputEvent,
EasingFunction,
EasingName,
UIRenderPrimitive,
ProviderRenderData as UIProviderRenderData,
IUIRenderDataProvider,
TextMeasurement,
TextRenderOptions,
UIBaseConfig,
UIButtonConfig,
UITextConfig,
UIImageConfig,
UIProgressBarConfig,
UISliderConfig,
UIPanelConfig,
UIScrollViewConfig,
} from '@esengine/ui';
// FairyGUI types
IFGUIComponentData,
RenderSubmitCallback,
ItemRenderer,
ItemProvider,
IUISource,
TypedEventListener,
EventListener,
FGUIEventType,
IEventContext,
IInputEventData,
IFUIAsset,
IAssetLoader,
IAssetContent,
IAssetParseContext,
IEngineRenderData,
IFGUIRenderDataProvider,
TextureResolverFn,
TweenCallback,
} from '@esengine/fairygui';
// =============================================================================
// Plugin i18n Infrastructure
+1 -2
View File
@@ -22,7 +22,6 @@
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../editor-core" },
{ "path": "../ui" }
{ "path": "../editor-core" }
]
}
+1 -1
View File
@@ -35,7 +35,7 @@ export default defineConfig({
'@esengine/engine-core', // TransformComponent 等核心组件
'@esengine/ecs-components',
'@esengine/tilemap',
'@esengine/ui',
'@esengine/fairygui', // FairyGUI system
'@esengine/behavior-tree',
'@esengine/platform-web',
'@esengine/ecs-engine-bindgen',
+106 -1
View File
@@ -7,7 +7,7 @@ use super::context::WebGLContext;
use super::error::Result;
use crate::backend::WebGL2Backend;
use crate::input::InputManager;
use crate::renderer::{Renderer2D, GridRenderer, GizmoRenderer, TransformMode, ViewportManager};
use crate::renderer::{Renderer2D, GridRenderer, GizmoRenderer, TransformMode, ViewportManager, TextBatch, MeshBatch};
use crate::resource::TextureManager;
use es_engine_shared::traits::backend::GraphicsBackend;
@@ -96,6 +96,14 @@ pub struct Engine {
/// and axis indicator are automatically hidden.
/// 当为 false(运行时模式)时,编辑器专用 UI(如网格、gizmos、坐标轴指示器)会自动隐藏。
is_editor: bool,
/// Text batch renderer for MSDF text.
/// MSDF 文本批处理渲染器。
text_batch: TextBatch,
/// Mesh batch renderer for arbitrary 2D geometry.
/// 任意 2D 几何体的网格批处理渲染器。
mesh_batch: MeshBatch,
}
impl Engine {
@@ -137,6 +145,10 @@ impl Engine {
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let gizmo_renderer = GizmoRenderer::new(&mut backend)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let text_batch = TextBatch::new(&mut backend, 10000)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let mesh_batch = MeshBatch::new(&mut backend, 10000, 30000)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
log::info!("Engine created successfully | 引擎创建成功");
@@ -153,6 +165,8 @@ impl Engine {
viewport_manager: ViewportManager::new(),
show_gizmos: true,
is_editor: true,
text_batch,
mesh_batch,
})
}
@@ -194,6 +208,10 @@ impl Engine {
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let gizmo_renderer = GizmoRenderer::new(&mut backend)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let text_batch = TextBatch::new(&mut backend, 10000)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
let mesh_batch = MeshBatch::new(&mut backend, 10000, 30000)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
log::info!("Engine created from external context | 从外部上下文创建引擎");
@@ -210,6 +228,8 @@ impl Engine {
viewport_manager: ViewportManager::new(),
show_gizmos: true,
is_editor: true,
text_batch,
mesh_batch,
})
}
@@ -291,6 +311,91 @@ impl Engine {
.map_err(|e| crate::core::error::EngineError::WebGLError(e))
}
/// Submit MSDF text batch for rendering.
/// 提交 MSDF 文本批次进行渲染。
///
/// # Arguments | 参数
/// * `positions` - Float32Array [x, y, ...] for each vertex (4 per glyph)
/// * `tex_coords` - Float32Array [u, v, ...] for each vertex
/// * `colors` - Float32Array [r, g, b, a, ...] for each vertex
/// * `outline_colors` - Float32Array [r, g, b, a, ...] for each vertex
/// * `outline_widths` - Float32Array [width, ...] for each vertex
/// * `texture_id` - Font atlas texture ID
/// * `px_range` - Pixel range for MSDF shader
pub fn submit_text_batch(
&mut self,
positions: &[f32],
tex_coords: &[f32],
colors: &[f32],
outline_colors: &[f32],
outline_widths: &[f32],
texture_id: u32,
px_range: f32,
) -> Result<()> {
self.text_batch.add_glyphs(positions, tex_coords, colors, outline_colors, outline_widths)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
// Render text immediately with proper setup
let projection = self.renderer.camera().projection_matrix();
let shader = self.text_batch.shader();
self.backend.bind_shader(shader).ok();
self.backend.set_blend_mode(es_engine_shared::types::blend::BlendMode::Alpha);
self.backend.set_uniform_mat3("u_projection", &projection).ok();
self.backend.set_uniform_i32("u_msdfTexture", 0).ok();
self.backend.set_uniform_f32("u_pxRange", px_range).ok();
// Bind font atlas texture
self.texture_manager.bind_texture_via_backend(&mut self.backend, texture_id, 0);
// Flush and render
self.text_batch.flush(&mut self.backend);
self.text_batch.clear();
Ok(())
}
/// Submit mesh batch for rendering arbitrary 2D geometry.
/// 提交网格批次进行任意 2D 几何体渲染。
///
/// # Arguments | 参数
/// * `positions` - Float array [x, y, ...] for each vertex
/// * `uvs` - Float array [u, v, ...] for each vertex
/// * `colors` - Packed RGBA colors (one per vertex)
/// * `indices` - Triangle indices
/// * `texture_id` - Texture ID to use
pub fn submit_mesh_batch(
&mut self,
positions: &[f32],
uvs: &[f32],
colors: &[u32],
indices: &[u16],
texture_id: u32,
) -> Result<()> {
self.mesh_batch.add_mesh(positions, uvs, colors, indices, 0.0, 0.0)
.map_err(|e| crate::core::error::EngineError::WebGLError(e))?;
// Render mesh immediately with proper setup
let projection = self.renderer.camera().projection_matrix();
let shader_id = crate::renderer::shader::SHADER_ID_DEFAULT_SPRITE;
if let Some(shader) = self.renderer.get_shader_handle(shader_id) {
self.backend.bind_shader(shader).ok();
self.backend.set_blend_mode(es_engine_shared::types::blend::BlendMode::Alpha);
self.backend.set_uniform_mat3("u_projection", &projection).ok();
// Bind texture
self.texture_manager.bind_texture_via_backend(&mut self.backend, texture_id, 0);
// Flush and render
self.mesh_batch.flush(&mut self.backend);
}
self.mesh_batch.clear();
Ok(())
}
pub fn render(&mut self) -> Result<()> {
let [r, g, b, a] = self.renderer.get_clear_color();
self.context.clear(r, g, b, a);
+53
View File
@@ -170,6 +170,59 @@ impl GameEngine {
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Submit MSDF text batch for rendering.
/// 提交 MSDF 文本批次进行渲染。
///
/// # Arguments | 参数
/// * `positions` - Float32Array [x, y, ...] for each vertex (4 per glyph)
/// * `tex_coords` - Float32Array [u, v, ...] for each vertex
/// * `colors` - Float32Array [r, g, b, a, ...] for each vertex
/// * `outline_colors` - Float32Array [r, g, b, a, ...] for each vertex
/// * `outline_widths` - Float32Array [width, ...] for each vertex
/// * `texture_id` - Font atlas texture ID
/// * `px_range` - Pixel range for MSDF shader
#[wasm_bindgen(js_name = submitTextBatch)]
pub fn submit_text_batch(
&mut self,
positions: &[f32],
tex_coords: &[f32],
colors: &[f32],
outline_colors: &[f32],
outline_widths: &[f32],
texture_id: u32,
px_range: f32,
) -> std::result::Result<(), JsValue> {
self.engine
.submit_text_batch(positions, tex_coords, colors, outline_colors, outline_widths, texture_id, px_range)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Submit mesh batch for rendering arbitrary 2D geometry.
/// 提交网格批次进行任意 2D 几何体渲染。
///
/// Used for rendering ellipses, polygons, and other complex shapes.
/// 用于渲染椭圆、多边形和其他复杂形状。
///
/// # Arguments | 参数
/// * `positions` - Float32Array [x, y, ...] for each vertex
/// * `uvs` - Float32Array [u, v, ...] for each vertex
/// * `colors` - Uint32Array of packed RGBA colors (one per vertex)
/// * `indices` - Uint16Array of triangle indices
/// * `texture_id` - Texture ID to use (0 for white pixel)
#[wasm_bindgen(js_name = submitMeshBatch)]
pub fn submit_mesh_batch(
&mut self,
positions: &[f32],
uvs: &[f32],
colors: &[u32],
indices: &[u16],
texture_id: u32,
) -> std::result::Result<(), JsValue> {
self.engine
.submit_mesh_batch(positions, uvs, colors, indices, texture_id)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Render the current frame.
/// 渲染当前帧。
pub fn render(&mut self) -> std::result::Result<(), JsValue> {
+20 -8
View File
@@ -108,8 +108,8 @@ impl Color {
}
}
/// Convert to packed u32 (ABGR format for WebGL).
/// 转换为打包的u32WebGL的ABGR格式)。
/// Convert to packed u32 (0xRRGGBBAA format, industry standard).
/// 转换为打包的 u320xRRGGBBAA 格式,行业标准)。
#[inline]
pub fn to_packed(&self) -> u32 {
let r = (self.r.clamp(0.0, 1.0) * 255.0) as u32;
@@ -117,21 +117,33 @@ impl Color {
let b = (self.b.clamp(0.0, 1.0) * 255.0) as u32;
let a = (self.a.clamp(0.0, 1.0) * 255.0) as u32;
(a << 24) | (b << 16) | (g << 8) | r
(r << 24) | (g << 16) | (b << 8) | a
}
/// Create from packed u32 (ABGR format).
/// 从打包的u32创建(ABGR格式)。
/// Create from packed u32 (0xRRGGBBAA format, industry standard).
/// 从打包的 u32 创建(0xRRGGBBAA 格式,行业标准)。
#[inline]
pub fn from_packed(packed: u32) -> Self {
Self::from_rgba8(
(packed & 0xFF) as u8,
((packed >> 8) & 0xFF) as u8,
((packed >> 16) & 0xFF) as u8,
((packed >> 24) & 0xFF) as u8,
((packed >> 16) & 0xFF) as u8,
((packed >> 8) & 0xFF) as u8,
(packed & 0xFF) as u8,
)
}
/// Convert to GPU vertex format (ABGR for WebGL little-endian).
/// 转换为 GPU 顶点格式(WebGL 小端序 ABGR)。
#[inline]
pub fn to_vertex_u32(&self) -> u32 {
let r = (self.r.clamp(0.0, 1.0) * 255.0) as u32;
let g = (self.g.clamp(0.0, 1.0) * 255.0) as u32;
let b = (self.b.clamp(0.0, 1.0) * 255.0) as u32;
let a = (self.a.clamp(0.0, 1.0) * 255.0) as u32;
(a << 24) | (b << 16) | (g << 8) | r
}
/// Linear interpolation between two colors.
/// 两个颜色之间的线性插值。
#[inline]
@@ -0,0 +1,243 @@
//! Mesh batch renderer for arbitrary 2D geometry.
//! 用于任意 2D 几何体的网格批处理渲染器。
//!
//! Unlike SpriteBatch which only supports quads, MeshBatch can render
//! arbitrary triangulated meshes (ellipses, polygons, rounded rectangles, etc.).
//!
//! 与仅支持四边形的 SpriteBatch 不同,MeshBatch 可以渲染
//! 任意三角化的网格(椭圆、多边形、圆角矩形等)。
use es_engine_shared::{
traits::backend::{GraphicsBackend, BufferUsage},
types::{
handle::{BufferHandle, VertexArrayHandle},
vertex::{VertexLayout, VertexAttribute, VertexAttributeType},
},
};
/// Floats per mesh vertex: position(2) + texCoord(2) + color(4) = 8
/// 每个网格顶点的浮点数:位置(2) + 纹理坐标(2) + 颜色(4) = 8
const FLOATS_PER_VERTEX: usize = 8;
/// Mesh batch for rendering arbitrary 2D geometry.
/// 用于渲染任意 2D 几何体的网格批处理。
pub struct MeshBatch {
vbo: BufferHandle,
ibo: BufferHandle,
vao: VertexArrayHandle,
max_vertices: usize,
max_indices: usize,
vertex_data: Vec<f32>,
index_data: Vec<u16>,
vertex_count: usize,
index_count: usize,
}
impl MeshBatch {
/// Create a new mesh batch.
/// 创建新的网格批处理。
///
/// # Arguments | 参数
/// * `backend` - Graphics backend
/// * `max_vertices` - Maximum number of vertices
/// * `max_indices` - Maximum number of indices
pub fn new(
backend: &mut impl GraphicsBackend,
max_vertices: usize,
max_indices: usize,
) -> Result<Self, String> {
let vertex_buffer_size = max_vertices * FLOATS_PER_VERTEX * 4;
let vbo = backend.create_vertex_buffer(
&vec![0u8; vertex_buffer_size],
BufferUsage::Dynamic,
).map_err(|e| format!("Mesh VBO: {:?}", e))?;
let ibo = backend.create_index_buffer(
bytemuck::cast_slice(&vec![0u16; max_indices]),
BufferUsage::Dynamic,
).map_err(|e| format!("Mesh IBO: {:?}", e))?;
// Mesh vertex layout:
// a_position: vec2 (location 0)
// a_texCoord: vec2 (location 1)
// a_color: vec4 (location 2)
let layout = VertexLayout {
attributes: vec![
VertexAttribute {
name: "a_position".into(),
attr_type: VertexAttributeType::Float2,
offset: 0,
normalized: false,
},
VertexAttribute {
name: "a_texcoord".into(),
attr_type: VertexAttributeType::Float2,
offset: 8,
normalized: false,
},
VertexAttribute {
name: "a_color".into(),
attr_type: VertexAttributeType::Float4,
offset: 16,
normalized: false,
},
],
stride: FLOATS_PER_VERTEX * 4,
};
let vao = backend.create_vertex_array(vbo, Some(ibo), &layout)
.map_err(|e| format!("Mesh VAO: {:?}", e))?;
Ok(Self {
vbo,
ibo,
vao,
max_vertices,
max_indices,
vertex_data: Vec::with_capacity(max_vertices * FLOATS_PER_VERTEX),
index_data: Vec::with_capacity(max_indices),
vertex_count: 0,
index_count: 0,
})
}
/// Clear the batch.
/// 清除批处理。
pub fn clear(&mut self) {
self.vertex_data.clear();
self.index_data.clear();
self.vertex_count = 0;
self.index_count = 0;
}
/// Add a mesh to the batch.
/// 将网格添加到批处理。
///
/// # Arguments | 参数
/// * `positions` - Float array [x, y, ...] for each vertex
/// * `uvs` - Float array [u, v, ...] for each vertex
/// * `colors` - Packed RGBA colors (one per vertex)
/// * `indices` - Triangle indices
/// * `offset_x` - X offset to apply to all positions
/// * `offset_y` - Y offset to apply to all positions
pub fn add_mesh(
&mut self,
positions: &[f32],
uvs: &[f32],
colors: &[u32],
indices: &[u16],
offset_x: f32,
offset_y: f32,
) -> Result<(), String> {
let vertex_count = positions.len() / 2;
if self.vertex_count + vertex_count > self.max_vertices {
return Err(format!(
"Mesh batch vertex overflow: {} + {} > {}",
self.vertex_count, vertex_count, self.max_vertices
));
}
if self.index_count + indices.len() > self.max_indices {
return Err(format!(
"Mesh batch index overflow: {} + {} > {}",
self.index_count, indices.len(), self.max_indices
));
}
// Validate input sizes
if uvs.len() != positions.len() {
return Err(format!(
"UV size mismatch: {} vs {}",
uvs.len(), positions.len()
));
}
if colors.len() != vertex_count {
return Err(format!(
"Color count mismatch: {} vs {}",
colors.len(), vertex_count
));
}
// Build vertex data
let base_index = self.vertex_count as u16;
for v in 0..vertex_count {
let pos_idx = v * 2;
// Position with offset (2 floats)
self.vertex_data.push(positions[pos_idx] + offset_x);
self.vertex_data.push(positions[pos_idx + 1] + offset_y);
// TexCoord (2 floats)
self.vertex_data.push(uvs[pos_idx]);
self.vertex_data.push(uvs[pos_idx + 1]);
// Color (4 floats from packed RGBA)
let color = colors[v];
let r = ((color >> 24) & 0xFF) as f32 / 255.0;
let g = ((color >> 16) & 0xFF) as f32 / 255.0;
let b = ((color >> 8) & 0xFF) as f32 / 255.0;
let a = (color & 0xFF) as f32 / 255.0;
self.vertex_data.push(r);
self.vertex_data.push(g);
self.vertex_data.push(b);
self.vertex_data.push(a);
}
// Add indices with base offset
for &idx in indices {
self.index_data.push(base_index + idx);
}
self.vertex_count += vertex_count;
self.index_count += indices.len();
Ok(())
}
/// Get the vertex count.
/// 获取顶点数量。
#[inline]
pub fn vertex_count(&self) -> usize {
self.vertex_count
}
/// Get the index count.
/// 获取索引数量。
#[inline]
pub fn index_count(&self) -> usize {
self.index_count
}
/// Get the VAO handle.
/// 获取 VAO 句柄。
#[inline]
pub fn vao(&self) -> VertexArrayHandle {
self.vao
}
/// Flush and render the batch.
/// 刷新并渲染批处理。
pub fn flush(&self, backend: &mut impl GraphicsBackend) {
if self.vertex_data.is_empty() || self.index_data.is_empty() {
return;
}
// Upload vertex data
backend.update_buffer(self.vbo, 0, bytemuck::cast_slice(&self.vertex_data)).ok();
// Upload index data
backend.update_buffer(self.ibo, 0, bytemuck::cast_slice(&self.index_data)).ok();
// Draw indexed
backend.draw_indexed(self.vao, self.index_count as u32, 0).ok();
}
/// Destroy the batch resources.
/// 销毁批处理资源。
pub fn destroy(self, backend: &mut impl GraphicsBackend) {
backend.destroy_vertex_array(self.vao);
backend.destroy_buffer(self.vbo);
backend.destroy_buffer(self.ibo);
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
//! Sprite batch rendering system.
//! 精灵批处理渲染系统。
//! Batch rendering system.
//! 批处理渲染系统。
mod sprite_batch;
mod text_batch;
mod mesh_batch;
mod vertex;
pub use sprite_batch::{BatchKey, SpriteBatch};
pub use text_batch::TextBatch;
pub use mesh_batch::MeshBatch;
pub use vertex::{SpriteVertex, VERTEX_SIZE};
@@ -0,0 +1,262 @@
//! Text batch renderer for MSDF text rendering.
//! MSDF 文本批处理渲染器。
use es_engine_shared::{
traits::backend::{GraphicsBackend, BufferUsage},
types::{
handle::{BufferHandle, VertexArrayHandle, ShaderHandle},
vertex::{VertexLayout, VertexAttribute, VertexAttributeType},
},
};
/// Number of vertices per glyph (quad).
/// 每个字形的顶点数(四边形)。
const VERTICES_PER_GLYPH: usize = 4;
/// Number of indices per glyph (2 triangles).
/// 每个字形的索引数(2 个三角形)。
const INDICES_PER_GLYPH: usize = 6;
/// Floats per text vertex: position(2) + texCoord(2) + color(4) + outlineColor(4) + outlineWidth(1) = 13
/// 每个文本顶点的浮点数:位置(2) + 纹理坐标(2) + 颜色(4) + 描边颜色(4) + 描边宽度(1) = 13
const FLOATS_PER_VERTEX: usize = 13;
/// Text batch for MSDF text rendering.
/// MSDF 文本批处理。
pub struct TextBatch {
vbo: BufferHandle,
ibo: BufferHandle,
vao: VertexArrayHandle,
shader: ShaderHandle,
max_glyphs: usize,
vertex_data: Vec<f32>,
glyph_count: usize,
}
impl TextBatch {
/// Create a new text batch.
/// 创建新的文本批处理。
pub fn new(backend: &mut impl GraphicsBackend, max_glyphs: usize) -> Result<Self, String> {
let vertex_buffer_size = max_glyphs * VERTICES_PER_GLYPH * FLOATS_PER_VERTEX * 4;
let vbo = backend.create_vertex_buffer(
&vec![0u8; vertex_buffer_size],
BufferUsage::Dynamic,
).map_err(|e| format!("Text VBO: {:?}", e))?;
let indices = Self::generate_indices(max_glyphs);
let ibo = backend.create_index_buffer(
bytemuck::cast_slice(&indices),
BufferUsage::Static,
).map_err(|e| format!("Text IBO: {:?}", e))?;
// MSDF text vertex layout:
// a_position: vec2 (location 0)
// a_texCoord: vec2 (location 1)
// a_color: vec4 (location 2)
// a_outlineColor: vec4 (location 3)
// a_outlineWidth: float (location 4)
let layout = VertexLayout {
attributes: vec![
VertexAttribute {
name: "a_position".into(),
attr_type: VertexAttributeType::Float2,
offset: 0,
normalized: false
},
VertexAttribute {
name: "a_texCoord".into(),
attr_type: VertexAttributeType::Float2,
offset: 8,
normalized: false
},
VertexAttribute {
name: "a_color".into(),
attr_type: VertexAttributeType::Float4,
offset: 16,
normalized: false
},
VertexAttribute {
name: "a_outlineColor".into(),
attr_type: VertexAttributeType::Float4,
offset: 32,
normalized: false
},
VertexAttribute {
name: "a_outlineWidth".into(),
attr_type: VertexAttributeType::Float,
offset: 48,
normalized: false
},
],
stride: FLOATS_PER_VERTEX * 4,
};
let vao = backend.create_vertex_array(vbo, Some(ibo), &layout)
.map_err(|e| format!("Text VAO: {:?}", e))?;
// Compile MSDF text shader
let shader = backend.compile_shader(
crate::renderer::shader::MSDF_TEXT_VERTEX_SHADER,
crate::renderer::shader::MSDF_TEXT_FRAGMENT_SHADER,
).map_err(|e| format!("MSDF shader: {:?}", e))?;
Ok(Self {
vbo,
ibo,
vao,
shader,
max_glyphs,
vertex_data: Vec::with_capacity(max_glyphs * VERTICES_PER_GLYPH * FLOATS_PER_VERTEX),
glyph_count: 0,
})
}
/// Generate indices for all glyphs.
/// 为所有字形生成索引。
fn generate_indices(max_glyphs: usize) -> Vec<u16> {
(0..max_glyphs).flat_map(|i| {
let base = (i * VERTICES_PER_GLYPH) as u16;
// Two triangles: 0-1-2, 2-3-0
[base, base + 1, base + 2, base + 2, base + 3, base]
}).collect()
}
/// Clear the batch.
/// 清除批处理。
pub fn clear(&mut self) {
self.vertex_data.clear();
self.glyph_count = 0;
}
/// Add text glyphs to the batch.
/// 将文本字形添加到批处理。
///
/// # Arguments | 参数
/// * `positions` - Float32Array [x, y, ...] for each vertex (4 per glyph)
/// * `tex_coords` - Float32Array [u, v, ...] for each vertex (4 per glyph)
/// * `colors` - Float32Array [r, g, b, a, ...] for each vertex (4 per glyph)
/// * `outline_colors` - Float32Array [r, g, b, a, ...] for each vertex
/// * `outline_widths` - Float32Array [width, ...] for each vertex
pub fn add_glyphs(
&mut self,
positions: &[f32],
tex_coords: &[f32],
colors: &[f32],
outline_colors: &[f32],
outline_widths: &[f32],
) -> Result<(), String> {
// Calculate glyph count from positions (2 floats per vertex, 4 vertices per glyph)
let vertex_count = positions.len() / 2;
let glyph_count = vertex_count / VERTICES_PER_GLYPH;
if self.glyph_count + glyph_count > self.max_glyphs {
return Err(format!(
"Text batch overflow: {} + {} > {}",
self.glyph_count, glyph_count, self.max_glyphs
));
}
// Validate input sizes
if tex_coords.len() != positions.len() {
return Err(format!(
"TexCoord size mismatch: {} vs {}",
tex_coords.len(), positions.len()
));
}
if colors.len() != vertex_count * 4 {
return Err(format!(
"Colors size mismatch: {} vs {}",
colors.len(), vertex_count * 4
));
}
if outline_colors.len() != vertex_count * 4 {
return Err(format!(
"OutlineColors size mismatch: {} vs {}",
outline_colors.len(), vertex_count * 4
));
}
if outline_widths.len() != vertex_count {
return Err(format!(
"OutlineWidths size mismatch: {} vs {}",
outline_widths.len(), vertex_count
));
}
// Build vertex data
for v in 0..vertex_count {
let pos_idx = v * 2;
let col_idx = v * 4;
// Position (2 floats)
self.vertex_data.push(positions[pos_idx]);
self.vertex_data.push(positions[pos_idx + 1]);
// TexCoord (2 floats)
self.vertex_data.push(tex_coords[pos_idx]);
self.vertex_data.push(tex_coords[pos_idx + 1]);
// Color (4 floats)
self.vertex_data.push(colors[col_idx]);
self.vertex_data.push(colors[col_idx + 1]);
self.vertex_data.push(colors[col_idx + 2]);
self.vertex_data.push(colors[col_idx + 3]);
// Outline color (4 floats)
self.vertex_data.push(outline_colors[col_idx]);
self.vertex_data.push(outline_colors[col_idx + 1]);
self.vertex_data.push(outline_colors[col_idx + 2]);
self.vertex_data.push(outline_colors[col_idx + 3]);
// Outline width (1 float)
self.vertex_data.push(outline_widths[v]);
}
self.glyph_count += glyph_count;
Ok(())
}
/// Get the glyph count.
/// 获取字形数量。
#[inline]
pub fn glyph_count(&self) -> usize {
self.glyph_count
}
/// Get the shader handle.
/// 获取着色器句柄。
#[inline]
pub fn shader(&self) -> ShaderHandle {
self.shader
}
/// Get the VAO handle.
/// 获取 VAO 句柄。
#[inline]
pub fn vao(&self) -> VertexArrayHandle {
self.vao
}
/// Flush and render the batch.
/// 刷新并渲染批处理。
pub fn flush(&self, backend: &mut impl GraphicsBackend) {
if self.vertex_data.is_empty() {
return;
}
// Upload vertex data
backend.update_buffer(self.vbo, 0, bytemuck::cast_slice(&self.vertex_data)).ok();
// Draw indexed
let index_count = (self.glyph_count * INDICES_PER_GLYPH) as u32;
backend.draw_indexed(self.vao, index_count, 0).ok();
}
/// Destroy the batch resources.
/// 销毁批处理资源。
pub fn destroy(self, backend: &mut impl GraphicsBackend) {
backend.destroy_vertex_array(self.vao);
backend.destroy_buffer(self.vbo);
backend.destroy_buffer(self.ibo);
backend.destroy_shader(self.shader);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ mod viewport;
pub use renderer2d::Renderer2D;
pub use camera::Camera2D;
pub use batch::SpriteBatch;
pub use batch::{SpriteBatch, TextBatch, MeshBatch};
pub use texture::{Texture, TextureManager};
pub use grid::GridRenderer;
pub use gizmo::{GizmoRenderer, TransformMode};
@@ -224,6 +224,19 @@ impl Renderer2D {
id == 0 || self.custom_shaders.contains_key(&id)
}
/// Get shader handle by ID.
/// 按 ID 获取着色器句柄。
///
/// Returns the default shader for ID 0, or custom shader for other IDs.
/// ID 0 返回默认着色器,其他 ID 返回自定义着色器。
pub fn get_shader_handle(&self, id: u32) -> Option<ShaderHandle> {
if id == 0 || id == crate::renderer::shader::SHADER_ID_DEFAULT_SPRITE {
Some(self.default_shader)
} else {
self.custom_shaders.get(&id).copied()
}
}
pub fn remove_shader(&mut self, id: u32) -> bool {
if id < 100 { return false; }
self.custom_shaders.remove(&id).is_some()
@@ -1,6 +1,90 @@
//! Built-in shader source code.
//! 内置Shader源代码。
// =============================================================================
// MSDF Text Shaders
// MSDF 文本着色器
// =============================================================================
/// MSDF text vertex shader source.
/// MSDF 文本顶点着色器源代码。
pub const MSDF_TEXT_VERTEX_SHADER: &str = r#"#version 300 es
precision highp float;
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec2 a_texCoord;
layout(location = 2) in vec4 a_color;
layout(location = 3) in vec4 a_outlineColor;
layout(location = 4) in float a_outlineWidth;
uniform mat3 u_projection;
out vec2 v_texCoord;
out vec4 v_color;
out vec4 v_outlineColor;
out float v_outlineWidth;
void main() {
vec3 pos = u_projection * vec3(a_position, 1.0);
gl_Position = vec4(pos.xy, 0.0, 1.0);
v_texCoord = a_texCoord;
v_color = a_color;
v_outlineColor = a_outlineColor;
v_outlineWidth = a_outlineWidth;
}
"#;
/// MSDF text fragment shader source.
/// MSDF 文本片段着色器源代码。
pub const MSDF_TEXT_FRAGMENT_SHADER: &str = r#"#version 300 es
precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in vec4 v_outlineColor;
in float v_outlineWidth;
uniform sampler2D u_msdfTexture;
uniform float u_pxRange;
out vec4 fragColor;
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
void main() {
vec3 msdf = texture(u_msdfTexture, v_texCoord).rgb;
float sd = median(msdf.r, msdf.g, msdf.b);
vec2 unitRange = vec2(u_pxRange) / vec2(textureSize(u_msdfTexture, 0));
vec2 screenTexSize = vec2(1.0) / fwidth(v_texCoord);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
float screenPxDistance = screenPxRange * (sd - 0.5);
float opacity = clamp(screenPxDistance + 0.5, 0.0, 1.0);
if (v_outlineWidth > 0.0) {
float outlineDistance = screenPxRange * (sd - 0.5 + v_outlineWidth);
float outlineOpacity = clamp(outlineDistance + 0.5, 0.0, 1.0);
vec4 outlineCol = vec4(v_outlineColor.rgb, v_outlineColor.a * outlineOpacity);
vec4 fillCol = vec4(v_color.rgb, v_color.a * opacity);
fragColor = mix(outlineCol, fillCol, opacity);
} else {
fragColor = vec4(v_color.rgb, v_color.a * opacity);
}
if (fragColor.a < 0.01) {
discard;
}
}
"#;
// =============================================================================
// Sprite Shaders
// 精灵着色器
// =============================================================================
/// Sprite vertex shader source.
/// 精灵顶点着色器源代码。
///
+4 -1
View File
@@ -6,5 +6,8 @@ mod builtin;
mod manager;
pub use program::ShaderProgram;
pub use builtin::{SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER};
pub use builtin::{
SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER,
MSDF_TEXT_VERTEX_SHADER, MSDF_TEXT_FRAGMENT_SHADER
};
pub use manager::{ShaderManager, SHADER_ID_DEFAULT_SPRITE};
@@ -1,7 +1,7 @@
{
"name": "@esengine/ui-editor",
"name": "@esengine/fairygui-editor",
"version": "1.0.0",
"description": "Editor support for @esengine/ui - inspectors, gizmos, and entity templates",
"description": "Editor support for @esengine/fairygui - inspectors, gizmos, and entity templates",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
@@ -24,13 +24,13 @@
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/ui": "workspace:*"
"@esengine/fairygui": "workspace:*"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
@@ -41,7 +41,7 @@
},
"keywords": [
"ecs",
"ui",
"fairygui",
"editor"
],
"author": "",
+15
View File
@@ -0,0 +1,15 @@
{
"id": "@esengine/fairygui",
"name": "FairyGUI",
"version": "1.0.0",
"description": "FairyGUI UI system for ECS framework",
"category": "UI",
"isCore": false,
"defaultEnabled": true,
"isEngineModule": true,
"dependencies": ["engine-core", "asset-system"],
"exports": {
"components": ["FGUIComponent"],
"systems": ["FGUIRenderSystem"]
}
}
@@ -0,0 +1,747 @@
/**
* FGUIEditorModule
*
* Editor module for FairyGUI integration.
* Registers components, inspectors, and entity templates.
*
* FairyGUI
*/
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
import { Core } from '@esengine/ecs-framework';
import type { IEditorModuleLoader, EntityCreationTemplate } from '@esengine/editor-core';
import {
EntityStoreService,
MessageHub,
EditorComponentRegistry,
ComponentInspectorRegistry,
GizmoRegistry,
GizmoColors,
VirtualNodeRegistry
} from '@esengine/editor-core';
import type { IGizmoRenderData, IRectGizmoData, GizmoColor, IVirtualNode } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import {
FGUIComponent,
GComponent,
GObject,
Stage,
GGraph,
GImage,
GTextField,
GLoader,
GButton,
GList,
GProgressBar,
GSlider
} from '@esengine/fairygui';
import { fguiComponentInspector } from './inspectors';
/**
* Gizmo colors for FGUI nodes
* FGUI Gizmo
*/
const FGUIGizmoColors = {
/** Root component bounds | 根组件边界 */
root: { r: 0.2, g: 0.6, b: 1.0, a: 0.8 } as GizmoColor,
/** Child element bounds (selected virtual node) | 子元素边界(选中的虚拟节点) */
childSelected: { r: 1.0, g: 0.8, b: 0.2, a: 0.8 } as GizmoColor,
/** Child element bounds (unselected) | 子元素边界(未选中) */
childUnselected: { r: 1.0, g: 0.8, b: 0.2, a: 0.15 } as GizmoColor
};
/**
* Collect gizmo data from FGUI node tree
* FGUI Gizmo
*
* Uses the same coordinate conversion as FGUIRenderDataProvider:
* - FGUI: top-left origin, Y-down
* - Engine: center origin, Y-up
* - Conversion: engineX = fguiX - halfWidth, engineY = halfHeight - fguiY
*
* 使 FGUIRenderDataProvider
* - FGUIY
* - Y
* - engineX = fguiX - halfWidth, engineY = halfHeight - fguiY
*
* @param obj The GObject to collect from | GObject
* @param halfWidth Half of Stage.designWidth | Stage.designWidth
* @param halfHeight Half of Stage.designHeight | Stage.designHeight
* @param gizmos Array to add gizmos to | gizmos
* @param entityId The entity ID for virtual node selection check | ID
* @param selectedVirtualNodeId Currently selected virtual node ID | ID
* @param parentPath Path prefix for virtual node ID generation | ID
*/
function collectFGUIGizmos(
obj: GObject,
halfWidth: number,
halfHeight: number,
gizmos: IGizmoRenderData[],
entityId: number,
selectedVirtualNodeId: string | null,
parentPath: string
): void {
// Skip invisible objects
if (!obj.visible) return;
// Generate virtual node ID (same logic as collectFGUIVirtualNodes)
const nodePath = parentPath ? `${parentPath}/${obj.name || obj.id}` : (obj.name || obj.id);
// Use localToGlobal to get the global position in FGUI coordinate system
// This handles all parent transforms correctly
// 使用 localToGlobal 获取 FGUI 坐标系中的全局位置
// 这正确处理了所有父级变换
const globalPos = obj.localToGlobal(0, 0);
const fguiX = globalPos.x;
const fguiY = globalPos.y;
// Convert from FGUI coordinates to engine coordinates
// Same formula as FGUIRenderDataProvider
// 从 FGUI 坐标转换为引擎坐标,与 FGUIRenderDataProvider 使用相同公式
// Engine position is the top-left corner converted to engine coords
const engineX = fguiX - halfWidth;
const engineY = halfHeight - fguiY;
// For gizmo rect, we need the center position
// Engine Y increases upward, so center is at (engineX + width/2, engineY - height/2)
// 对于 gizmo 矩形,我们需要中心位置
// 引擎 Y 向上递增,所以中心在 (engineX + width/2, engineY - height/2)
const centerX = engineX + obj.width / 2;
const centerY = engineY - obj.height / 2;
// Determine color based on selection state
// 根据选中状态确定颜色
const isSelected = nodePath === selectedVirtualNodeId;
const color = isSelected ? FGUIGizmoColors.childSelected : FGUIGizmoColors.childUnselected;
// Add rect gizmo for this object
const rectGizmo: IRectGizmoData = {
type: 'rect',
x: centerX,
y: centerY,
width: obj.width,
height: obj.height,
rotation: 0,
originX: 0.5,
originY: 0.5,
color,
showHandles: isSelected,
virtualNodeId: nodePath
};
gizmos.push(rectGizmo);
// If this is a container, recurse into children
if (obj instanceof GComponent) {
for (let i = 0; i < obj.numChildren; i++) {
const child = obj.getChildAt(i);
collectFGUIGizmos(child, halfWidth, halfHeight, gizmos, entityId, selectedVirtualNodeId, nodePath);
}
}
}
/**
* Gizmo provider for FGUIComponent
* FGUIComponent Gizmo
*
* Generates rect gizmos for all visible FGUI nodes.
* Uses the same coordinate conversion as FGUIRenderDataProvider.
* FGUI gizmos
* 使 FGUIRenderDataProvider
*/
function fguiGizmoProvider(
component: FGUIComponent,
entity: Entity,
isSelected: boolean
): IGizmoRenderData[] {
const gizmos: IGizmoRenderData[] = [];
// Get the root GObject
const root = component.root;
if (!root) return gizmos;
// Get Stage design size for coordinate conversion
// Use the same values as FGUIRenderDataProvider
// 获取 Stage 设计尺寸用于坐标转换,与 FGUIRenderDataProvider 使用相同的值
const stage = Stage.inst;
const halfWidth = stage.designWidth / 2;
const halfHeight = stage.designHeight / 2;
// Root gizmo - root is at (0, 0) in FGUI coords
// In engine coords: center is at (-halfWidth + width/2, halfHeight - height/2)
// 根 Gizmo - 根节点在 FGUI 坐标 (0, 0)
// 在引擎坐标中:中心在 (-halfWidth + width/2, halfHeight - height/2)
const rootCenterX = -halfWidth + root.width / 2;
const rootCenterY = halfHeight - root.height / 2;
const rootGizmo: IRectGizmoData = {
type: 'rect',
x: rootCenterX,
y: rootCenterY,
width: root.width,
height: root.height,
rotation: 0,
originX: 0.5,
originY: 0.5,
color: isSelected ? FGUIGizmoColors.root : { ...FGUIGizmoColors.root, a: 0.4 },
showHandles: isSelected
};
gizmos.push(rootGizmo);
// Collect child gizmos only when selected (performance optimization)
if (isSelected && component.component) {
const comp = component.component;
// Get currently selected virtual node for this entity
// 获取此实体当前选中的虚拟节点
const selectedInfo = VirtualNodeRegistry.getSelectedVirtualNode();
const selectedVirtualNodeId = (selectedInfo && selectedInfo.entityId === entity.id)
? selectedInfo.virtualNodeId
: null;
// First add gizmo for the component itself
// 首先为组件本身添加 gizmo
collectFGUIGizmos(comp, halfWidth, halfHeight, gizmos, entity.id, selectedVirtualNodeId, '');
}
return gizmos;
}
/**
* Get the type name of a GObject
* GObject
*/
function getGObjectTypeName(obj: GObject): string {
// Use constructor name as type
const name = obj.constructor.name;
// Remove 'G' prefix for cleaner display
if (name.startsWith('G') && name.length > 1) {
return name.slice(1);
}
return name;
}
/**
* Graph type enum to string mapping
*
*/
const GraphTypeNames: Record<number, string> = {
0: 'Empty',
1: 'Rect',
2: 'Ellipse',
3: 'Polygon',
4: 'RegularPolygon'
};
/**
* Flip type enum to string mapping
*
*/
const FlipTypeNames: Record<number, string> = {
0: 'None',
1: 'Horizontal',
2: 'Vertical',
3: 'Both'
};
/**
* Fill method enum to string mapping
*
*/
const FillMethodNames: Record<number, string> = {
0: 'None',
1: 'Horizontal',
2: 'Vertical',
3: 'Radial90',
4: 'Radial180',
5: 'Radial360'
};
/**
* Align type enum to string mapping
*
*/
const AlignTypeNames: Record<number, string> = {
0: 'Left',
1: 'Center',
2: 'Right'
};
/**
* Vertical align type enum to string mapping
*
*/
const VertAlignTypeNames: Record<number, string> = {
0: 'Top',
1: 'Middle',
2: 'Bottom'
};
/**
* Loader fill type enum to string mapping
*
*/
const LoaderFillTypeNames: Record<number, string> = {
0: 'None',
1: 'Scale',
2: 'ScaleMatchHeight',
3: 'ScaleMatchWidth',
4: 'ScaleFree',
5: 'ScaleNoBorder'
};
/**
* Button mode enum to string mapping
*
*/
const ButtonModeNames: Record<number, string> = {
0: 'Common',
1: 'Check',
2: 'Radio'
};
/**
* Auto size type enum to string mapping
*
*/
const AutoSizeTypeNames: Record<number, string> = {
0: 'None',
1: 'Both',
2: 'Height',
3: 'Shrink',
4: 'Ellipsis'
};
/**
* Extract type-specific properties from a GObject
* GObject
*/
function extractTypeSpecificData(obj: GObject): Record<string, unknown> {
const data: Record<string, unknown> = {};
// GGraph specific properties
if (obj instanceof GGraph) {
data.graphType = GraphTypeNames[obj.type] || obj.type;
// Use public getters where available, fall back to private fields
data.lineColor = obj.lineColor;
data.fillColor = obj.fillColor;
// Access private fields via type assertion for properties without public getters
const graph = obj as unknown as {
_lineSize: number;
_cornerRadius: number[] | null;
_sides: number;
_startAngle: number;
};
data.lineSize = graph._lineSize;
if (graph._cornerRadius) {
data.cornerRadius = graph._cornerRadius.join(', ');
}
if (obj.type === 4) { // RegularPolygon
data.sides = graph._sides;
data.startAngle = graph._startAngle;
}
}
// GImage specific properties
if (obj instanceof GImage) {
data.color = obj.color;
data.flip = FlipTypeNames[obj.flip] || obj.flip;
data.fillMethod = FillMethodNames[obj.fillMethod] || obj.fillMethod;
if (obj.fillMethod !== 0) {
data.fillOrigin = obj.fillOrigin;
data.fillClockwise = obj.fillClockwise;
data.fillAmount = obj.fillAmount;
}
}
// GTextField specific properties
if (obj instanceof GTextField) {
data.text = obj.text;
data.font = obj.font;
data.fontSize = obj.fontSize;
data.color = obj.color;
data.align = AlignTypeNames[obj.align] || obj.align;
data.valign = VertAlignTypeNames[obj.valign] || obj.valign;
data.leading = obj.leading;
data.letterSpacing = obj.letterSpacing;
data.bold = obj.bold;
data.italic = obj.italic;
data.underline = obj.underline;
data.singleLine = obj.singleLine;
data.autoSize = AutoSizeTypeNames[obj.autoSize] || obj.autoSize;
if (obj.stroke > 0) {
data.stroke = obj.stroke;
data.strokeColor = obj.strokeColor;
}
}
// GLoader specific properties
if (obj instanceof GLoader) {
data.url = obj.url;
data.align = AlignTypeNames[obj.align] || obj.align;
data.verticalAlign = VertAlignTypeNames[obj.verticalAlign] || obj.verticalAlign;
data.fill = LoaderFillTypeNames[obj.fill] || obj.fill;
data.shrinkOnly = obj.shrinkOnly;
data.autoSize = obj.autoSize;
data.color = obj.color;
data.fillMethod = FillMethodNames[obj.fillMethod] || obj.fillMethod;
if (obj.fillMethod !== 0) {
data.fillOrigin = obj.fillOrigin;
data.fillClockwise = obj.fillClockwise;
data.fillAmount = obj.fillAmount;
}
}
// GButton specific properties
if (obj instanceof GButton) {
data.title = obj.title;
data.icon = obj.icon;
data.mode = ButtonModeNames[obj.mode] || obj.mode;
data.selected = obj.selected;
data.titleColor = obj.titleColor;
data.titleFontSize = obj.titleFontSize;
if (obj.selectedTitle) {
data.selectedTitle = obj.selectedTitle;
}
if (obj.selectedIcon) {
data.selectedIcon = obj.selectedIcon;
}
}
// GList specific properties
if (obj instanceof GList) {
data.defaultItem = obj.defaultItem;
data.itemCount = obj.numItems;
data.selectedIndex = obj.selectedIndex;
data.scrollPane = obj.scrollPane ? 'Yes' : 'No';
}
// GProgressBar specific properties
if (obj instanceof GProgressBar) {
data.value = obj.value;
data.max = obj.max;
}
// GSlider specific properties
if (obj instanceof GSlider) {
data.value = obj.value;
data.max = obj.max;
}
// GComponent specific properties (for all components)
if (obj instanceof GComponent) {
data.numChildren = obj.numChildren;
data.numControllers = obj.numControllers;
// Access private _transitions array via type assertion for display
const comp = obj as unknown as { _transitions: unknown[] };
data.numTransitions = comp._transitions?.length || 0;
}
return data;
}
/**
* Collect virtual nodes from FGUI node tree
* FGUI
*
* Uses localToGlobal to get correct global positions.
* 使 localToGlobal
*/
function collectFGUIVirtualNodes(
obj: GObject,
halfWidth: number,
halfHeight: number,
parentPath: string
): IVirtualNode {
// Use localToGlobal to get the global position in FGUI coordinate system
// 使用 localToGlobal 获取 FGUI 坐标系中的全局位置
const globalPos = obj.localToGlobal(0, 0);
// Convert to engine coordinates for display
// 转换为引擎坐标用于显示
const engineX = globalPos.x - halfWidth;
const engineY = halfHeight - globalPos.y;
const nodePath = parentPath ? `${parentPath}/${obj.name || obj.id}` : (obj.name || obj.id);
const children: IVirtualNode[] = [];
// If this is a container, collect children
if (obj instanceof GComponent) {
for (let i = 0; i < obj.numChildren; i++) {
const child = obj.getChildAt(i);
children.push(collectFGUIVirtualNodes(child, halfWidth, halfHeight, nodePath));
}
}
// Extract common properties
const commonData: Record<string, unknown> = {
className: obj.constructor.name,
x: obj.x,
y: obj.y,
width: obj.width,
height: obj.height,
alpha: obj.alpha,
visible: obj.visible,
touchable: obj.touchable,
rotation: obj.rotation,
scaleX: obj.scaleX,
scaleY: obj.scaleY
};
// Extract type-specific properties
const typeSpecificData = extractTypeSpecificData(obj);
return {
id: nodePath,
name: obj.name || `[${getGObjectTypeName(obj)}]`,
type: getGObjectTypeName(obj),
children,
visible: obj.visible,
data: {
...commonData,
...typeSpecificData
},
x: engineX,
y: engineY,
width: obj.width,
height: obj.height
};
}
/**
* Virtual node provider for FGUIComponent
* FGUIComponent
*
* Returns the internal FGUI node tree as virtual nodes.
* FGUI
*/
function fguiVirtualNodeProvider(
component: FGUIComponent,
_entity: Entity
): IVirtualNode[] {
if (!component.isReady || !component.component) {
return [];
}
// Get Stage design size for coordinate conversion
// 获取 Stage 设计尺寸用于坐标转换
const stage = Stage.inst;
const halfWidth = stage.designWidth / 2;
const halfHeight = stage.designHeight / 2;
// Collect from the loaded component
// 从加载的组件收集
const rootNode = collectFGUIVirtualNodes(
component.component,
halfWidth,
halfHeight,
''
);
// Return the children of the root (we don't want to duplicate the root)
return rootNode.children.length > 0 ? rootNode.children : [rootNode];
}
/**
* FGUIEditorModule
*
* Editor module that provides FairyGUI integration.
*
* FairyGUI
*/
export class FGUIEditorModule implements IEditorModuleLoader {
/** MessageHub subscription cleanup | MessageHub 订阅清理函数 */
private _unsubscribes: (() => void)[] = [];
/** Tracked FGUIComponents for state change callbacks | 跟踪的 FGUIComponent 用于状态变化回调 */
private _trackedComponents = new WeakSet<FGUIComponent>();
/**
* Install the module
*
*/
async install(services: ServiceContainer): Promise<void> {
// Register component
const componentRegistry = services.resolve(EditorComponentRegistry);
if (componentRegistry) {
componentRegistry.register({
name: 'FGUIComponent',
type: FGUIComponent,
category: 'components.category.ui',
description: 'FairyGUI component for loading and displaying .fui packages',
icon: 'Layout'
});
}
// Register custom inspector
const inspectorRegistry = services.resolve(ComponentInspectorRegistry);
if (inspectorRegistry) {
inspectorRegistry.register(fguiComponentInspector);
}
// Register gizmo provider for FGUIComponent
// 为 FGUIComponent 注册 Gizmo 提供者
GizmoRegistry.register(FGUIComponent, fguiGizmoProvider);
// Register virtual node provider for FGUIComponent
// 为 FGUIComponent 注册虚拟节点提供者
VirtualNodeRegistry.register(FGUIComponent, fguiVirtualNodeProvider);
// Setup state change bridge for virtual node updates
// 设置状态变化桥接,用于虚拟节点更新
this._setupStateChangeBridge(services);
}
/**
* Setup bridge between FGUIComponent state changes and VirtualNodeRegistry
* FGUIComponent VirtualNodeRegistry
*/
private _setupStateChangeBridge(services: ServiceContainer): void {
const messageHub = services.resolve(MessageHub);
if (!messageHub) return;
// Hook into FGUIComponent when components are added
// 当组件被添加时挂钩 FGUIComponent
const hookComponent = (comp: FGUIComponent, entity: Entity) => {
if (this._trackedComponents.has(comp)) return;
this._trackedComponents.add(comp);
comp.onStateChange = (type) => {
VirtualNodeRegistry.notifyChange(entity.id, type, comp);
};
};
// Scan existing entities for FGUIComponents
// 扫描现有实体中的 FGUIComponent
const scanExistingEntities = () => {
const scene = Core.scene;
if (!scene) return;
for (const entity of scene.entities.buffer) {
const fguiComp = entity.getComponent(FGUIComponent);
if (fguiComp) {
hookComponent(fguiComp, entity);
}
}
};
// Subscribe to component:added events
// 订阅 component:added 事件
const unsubAdded = messageHub.subscribe('component:added', (event: { entityId: number; componentType: string }) => {
if (event.componentType !== 'FGUIComponent') return;
const scene = Core.scene;
if (!scene) return;
const entity = scene.findEntityById(event.entityId);
if (!entity) return;
const fguiComp = entity.getComponent(FGUIComponent);
if (fguiComp) {
hookComponent(fguiComp, entity);
}
});
// Subscribe to scene:loaded to scan existing components
// 订阅 scene:loaded 扫描现有组件
const unsubSceneLoaded = messageHub.subscribe('scene:loaded', () => {
scanExistingEntities();
});
// Initial scan
scanExistingEntities();
this._unsubscribes.push(unsubAdded, unsubSceneLoaded);
}
/**
* Uninstall the module
*
*/
async uninstall(): Promise<void> {
// Cleanup subscriptions
for (const unsub of this._unsubscribes) {
unsub();
}
this._unsubscribes = [];
// Unregister gizmo provider
GizmoRegistry.unregister(FGUIComponent);
// Unregister virtual node provider
VirtualNodeRegistry.unregister(FGUIComponent);
}
/**
* Get entity creation templates
*
*/
getEntityCreationTemplates(): EntityCreationTemplate[] {
return [
{
id: 'create-fgui-root',
label: 'FGUI Root',
icon: 'Layout',
category: 'ui',
order: 300,
create: (): number => this.createFGUIEntity('FGUI Root', { width: 1920, height: 1080 })
},
{
id: 'create-fgui-view',
label: 'FGUI View',
icon: 'Image',
category: 'ui',
order: 301,
create: (): number => this.createFGUIEntity('FGUI View')
}
];
}
/**
* Create FGUI entity with optional configuration
* FGUI
*/
private createFGUIEntity(baseName: string, config?: { width?: number; height?: number }): number {
const scene = Core.scene;
if (!scene) {
throw new Error('Scene not available');
}
const entityStore = Core.services.resolve(EntityStoreService);
const messageHub = Core.services.resolve(MessageHub);
if (!entityStore || !messageHub) {
throw new Error('EntityStoreService or MessageHub not available');
}
// Generate unique name
const existingCount = entityStore.getAllEntities()
.filter((e: Entity) => e.name.startsWith(baseName)).length;
const entityName = existingCount > 0 ? `${baseName} ${existingCount + 1}` : baseName;
// Create entity
const entity = scene.createEntity(entityName);
// Add transform component
entity.addComponent(new TransformComponent());
// Add FGUI component
const fguiComponent = new FGUIComponent();
if (config?.width) fguiComponent.width = config.width;
if (config?.height) fguiComponent.height = config.height;
entity.addComponent(fguiComponent);
// Register and select entity
entityStore.addEntity(entity);
messageHub.publish('entity:added', { entity });
messageHub.publish('scene:modified', {});
entityStore.selectEntity(entity);
return entity.id;
}
}
/**
* Default FGUI editor module instance
* FGUI
*/
export const fguiEditorModule = new FGUIEditorModule();
+54
View File
@@ -0,0 +1,54 @@
/**
* @esengine/fairygui-editor
*
* Editor support for @esengine/fairygui - inspectors, gizmos, and entity templates.
*
* FairyGUI - Gizmo
*/
import type { IEditorPlugin, ModuleManifest } from '@esengine/editor-core';
import { FGUIRuntimeModule } from '@esengine/fairygui';
import { FGUIEditorModule, fguiEditorModule } from './FGUIEditorModule';
// Re-exports
export { FGUIEditorModule, fguiEditorModule } from './FGUIEditorModule';
export { FGUIInspectorContent, FGUIComponentInspector, fguiComponentInspector } from './inspectors';
/**
* Plugin manifest
*
*/
const manifest: ModuleManifest = {
id: '@esengine/fairygui',
name: '@esengine/fairygui',
displayName: 'FairyGUI',
version: '1.0.0',
description: 'FairyGUI UI system for ECS framework with editor support',
category: 'Other',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['engine-core', 'asset-system'],
editorPackage: '@esengine/fairygui-editor',
exports: {
components: ['FGUIComponent'],
systems: ['FGUIRenderSystem'],
loaders: ['FUIAssetLoader']
},
assetExtensions: {
'.fui': 'fui'
}
};
/**
* Complete FGUI Plugin (runtime + editor)
* FGUI +
*/
export const FGUIPlugin: IEditorPlugin = {
manifest,
runtimeModule: new FGUIRuntimeModule(),
editorModule: fguiEditorModule
};
export default fguiEditorModule;
@@ -0,0 +1,242 @@
/**
* FGUIInspector
*
* Custom inspector for FGUIComponent.
* Uses 'append' mode to add Component selection UI after the default PropertyInspector.
*
* FGUIComponent PropertyInspector UI
*/
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Package, AlertCircle, CheckCircle, Loader } from 'lucide-react';
import type { Component } from '@esengine/ecs-framework';
import type { ComponentInspectorContext, IComponentInspector } from '@esengine/editor-core';
import { VirtualNodeRegistry } from '@esengine/editor-core';
import { FGUIComponent } from '@esengine/fairygui';
/** Shared styles | 共享样式 */
const styles = {
section: {
marginTop: '8px',
padding: '8px',
background: 'var(--color-bg-secondary, #252526)',
borderRadius: '4px',
border: '1px solid var(--color-border, #3a3a3a)'
} as React.CSSProperties,
sectionHeader: {
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: '8px',
fontSize: '11px',
fontWeight: 600,
color: 'var(--color-text-secondary, #888)',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px'
} as React.CSSProperties,
row: {
display: 'flex',
alignItems: 'center',
marginBottom: '6px',
gap: '8px'
} as React.CSSProperties,
label: {
width: '70px',
flexShrink: 0,
fontSize: '12px',
color: 'var(--color-text-secondary, #888)'
} as React.CSSProperties,
select: {
flex: 1,
padding: '5px 8px',
background: 'var(--color-bg-tertiary, #1e1e1e)',
border: '1px solid var(--color-border, #3a3a3a)',
borderRadius: '4px',
color: 'inherit',
fontSize: '12px',
minWidth: 0,
cursor: 'pointer'
} as React.CSSProperties,
statusBadge: {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 500
} as React.CSSProperties
};
/**
* FGUIInspectorContent
*
* React component for FGUI inspector content.
* Shows package status and component selection dropdown.
*
* FGUI React
*/
export const FGUIInspectorContent: React.FC<{ context: ComponentInspectorContext }> = ({ context }) => {
const component = context.component as FGUIComponent;
const onChange = context.onChange;
const entityId = context.entity?.id;
// Track version to trigger re-render when component state changes
// 跟踪版本以在组件状态变化时触发重新渲染
const [refreshKey, setRefreshKey] = useState(0);
// Subscribe to VirtualNodeRegistry changes (event-driven, no polling)
// 订阅 VirtualNodeRegistry 变化(事件驱动,无需轮询)
useEffect(() => {
if (entityId === undefined) return;
const unsubscribe = VirtualNodeRegistry.onChange((event) => {
if (event.entityId === entityId) {
setRefreshKey(prev => prev + 1);
}
});
return unsubscribe;
}, [entityId]);
// Get available components from loaded package
// Use refreshKey as dependency to refresh when package/component changes
// 使用 refreshKey 作为依赖,当包/组件变化时刷新
const availableComponents = useMemo(() => {
if (!component.package) return [];
const exported = component.getAvailableComponentNames();
if (exported.length > 0) return exported;
return component.getAllComponentNames();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.package, refreshKey]);
// Handle component name change
const handleComponentChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
if (onChange) {
onChange('componentName', e.target.value);
}
}, [onChange]);
// Render status badge
const renderStatus = () => {
if (component.isLoading) {
return (
<span style={{ ...styles.statusBadge, background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24' }}>
<Loader size={12} style={{ animation: 'fgui-spin 1s linear infinite' }} />
Loading...
</span>
);
}
if (component.error) {
return (
<span style={{ ...styles.statusBadge, background: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}>
<AlertCircle size={12} />
Error
</span>
);
}
if (component.isReady) {
return (
<span style={{ ...styles.statusBadge, background: 'rgba(74, 222, 128, 0.15)', color: '#4ade80' }}>
<CheckCircle size={12} />
{component.package?.name || 'Ready'}
</span>
);
}
return (
<span style={{ ...styles.statusBadge, background: 'rgba(136, 136, 136, 0.15)', color: '#888' }}>
<Package size={12} />
No Package
</span>
);
};
return (
<div style={styles.section}>
{/* Section Header */}
<div style={styles.sectionHeader}>
<Package size={12} />
<span>FGUI Runtime</span>
</div>
{/* Status Row */}
<div style={styles.row}>
<span style={styles.label}>Status</span>
<div style={{ flex: 1 }}>
{renderStatus()}
</div>
</div>
{/* Error Message */}
{component.error && (
<div style={{
marginBottom: '8px',
padding: '6px 8px',
background: 'rgba(248, 113, 113, 0.1)',
border: '1px solid rgba(248, 113, 113, 0.3)',
borderRadius: '4px',
fontSize: '11px',
color: '#f87171',
wordBreak: 'break-word'
}}>
{component.error}
</div>
)}
{/* Component Selection - only show when package is loaded */}
{availableComponents.length > 0 && (
<div style={{ ...styles.row, marginBottom: 0 }}>
<span style={styles.label}>Component</span>
<select
value={component.componentName}
onChange={handleComponentChange}
style={styles.select}
>
<option value="">Select...</option>
{availableComponents.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
)}
{/* Spin animation for loader */}
<style>{`
@keyframes fgui-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
};
/**
* FGUIComponentInspector
*
* Component inspector for FGUIComponent.
* Uses 'append' mode to show additional UI after the default PropertyInspector.
*
* FGUIComponent 使 'append' Inspector UI
*/
export class FGUIComponentInspector implements IComponentInspector<FGUIComponent> {
readonly id = 'fgui-component-inspector';
readonly name = 'FGUI Component Inspector';
readonly priority = 100;
readonly targetComponents = ['FGUIComponent'];
readonly renderMode = 'append' as const;
canHandle(component: Component): component is FGUIComponent {
return component instanceof FGUIComponent;
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(FGUIInspectorContent, { context });
}
}
/**
* Default FGUI component inspector instance
* FGUI
*/
export const fguiComponentInspector = new FGUIComponentInspector();
@@ -0,0 +1,9 @@
/**
* FairyGUI Editor Inspectors
*
* Custom inspectors for FairyGUI components.
*
* FairyGUI
*/
export { FGUIInspectorContent, FGUIComponentInspector, fguiComponentInspector } from './FGUIInspector';
@@ -1,10 +1,11 @@
{
"extends": "../../tsconfig.json",
"extends": "../build-config/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"declaration": true,
"jsx": "react-jsx"
"declarationDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
external: [
'react',
'react-dom',
'@esengine/ecs-framework',
'@esengine/editor-core',
'@esengine/asset-system',
'@esengine/fairygui',
'lucide-react'
],
esbuildOptions(options) {
options.jsx = 'automatic';
}
});
+46
View File
@@ -0,0 +1,46 @@
{
"id": "fairygui",
"name": "@esengine/fairygui",
"globalKey": "fairygui",
"displayName": "FairyGUI",
"description": "FairyGUI UI system integration | FairyGUI UI 系统集成",
"version": "1.0.0",
"category": "UI",
"icon": "Layout",
"tags": [
"ui",
"fairygui",
"gui"
],
"isCore": false,
"defaultEnabled": true,
"isEngineModule": true,
"canContainContent": true,
"platforms": [
"web",
"desktop"
],
"dependencies": [
"core",
"math",
"asset-system"
],
"exports": {
"components": [
"FGUIComponent"
],
"systems": [
"FGUIRenderSystem",
"FGUIUpdateSystem"
],
"loaders": [
"FUIAssetLoader"
]
},
"editorPackage": "@esengine/fairygui-editor",
"assetExtensions": {
".fui": "fui"
},
"outputPath": "dist/index.js",
"pluginExport": "FGUIPlugin"
}
@@ -1,11 +1,13 @@
{
"name": "@esengine/ui",
"name": "@esengine/fairygui",
"version": "1.0.0",
"description": "ECS-based UI system with WebGL rendering for games",
"description": "FairyGUI ECS integration - FairyGUI Editor compatible UI system",
"esengine": {
"plugin": true,
"pluginExport": "UIPlugin",
"category": "ui"
"pluginExport": "FGUIPlugin",
"editorPackage": "@esengine/fairygui-editor",
"category": "ui",
"isEngineModule": true
},
"main": "dist/index.js",
"module": "dist/index.js",
@@ -27,7 +29,8 @@
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/asset-system": "workspace:*"
"@esengine/asset-system": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
@@ -41,8 +44,9 @@
},
"keywords": [
"ecs",
"fairygui",
"ui",
"webgl",
"webgpu",
"game-ui"
],
"author": "",
@@ -0,0 +1,268 @@
/**
* FGUI Texture Manager
*
* Manages texture loading for FairyGUI.
* Uses the global IAssetFileLoader for platform-agnostic asset loading.
*
* FGUI
* 使 IAssetFileLoader
*/
import { getGlobalAssetFileLoader } from '@esengine/asset-system';
/**
* Texture service interface for engine integration
*
*/
export interface ITextureService {
/**
* Load texture from URL/path (e.g., Blob URL)
* URL/ Blob URL
*
* @param url - URL to load texture from (Blob URL, HTTP URL, etc.)
* @returns Engine texture ID (may be 0 if async loading)
*/
loadTextureByPath(url: string): number;
/**
* Get texture ID if already loaded
* ID
*
* @param url - URL to check
* @returns Texture ID or undefined if not loaded
*/
getTextureIdByPath?(url: string): number | undefined;
}
/** Global texture service instance | 全局纹理服务实例 */
let globalTextureService: ITextureService | null = null;
/**
* Set global texture service
*
*/
export function setGlobalTextureService(service: ITextureService | null): void {
globalTextureService = service;
}
/**
* Get global texture service
*
*/
export function getGlobalTextureService(): ITextureService | null {
return globalTextureService;
}
/**
* Texture entry with loading state
*
*/
interface TextureEntry {
/** Engine texture ID (0 = not loaded) | 引擎纹理 ID */
textureId: number;
/** Loading state | 加载状态 */
state: 'pending' | 'loading' | 'loaded' | 'error';
/** Load promise | 加载 Promise */
promise?: Promise<number>;
}
/**
* FGUITextureManager
*
* Centralized texture management for FairyGUI.
* Handles loading, caching, and resolution of textures.
*
* FairyGUI
*
*/
export class FGUITextureManager {
private static _instance: FGUITextureManager | null = null;
/** Texture cache: asset path -> texture entry | 纹理缓存 */
private _cache: Map<string, TextureEntry> = new Map();
private constructor() {}
/**
* Get singleton instance
*
*/
public static getInstance(): FGUITextureManager {
if (!FGUITextureManager._instance) {
FGUITextureManager._instance = new FGUITextureManager();
}
return FGUITextureManager._instance;
}
/**
* Resolve texture path to engine texture ID
* ID
*
* This is the main API for FGUIRenderDataProvider.
* Returns 0 if texture is not yet loaded, triggering async load.
*
* @param texturePath - Relative asset path (e.g., "assets/ui/Bag_atlas0.png")
* @returns Engine texture ID or 0 if pending
*/
public resolveTexture(texturePath: string): number {
const entry = this._cache.get(texturePath);
if (entry) {
if (entry.state === 'loaded') {
return entry.textureId;
}
// Still loading or error, return 0
return 0;
}
// Start loading
this._loadTexture(texturePath);
return 0;
}
/**
* Check if texture is loaded
*
*/
public isTextureLoaded(texturePath: string): boolean {
const entry = this._cache.get(texturePath);
return entry?.state === 'loaded';
}
/**
* Get texture ID if loaded
* ID
*/
public getTextureId(texturePath: string): number | undefined {
const entry = this._cache.get(texturePath);
return entry?.state === 'loaded' ? entry.textureId : undefined;
}
/**
* Preload textures
*
*/
public async preloadTextures(texturePaths: string[]): Promise<void> {
const promises: Promise<number>[] = [];
for (const path of texturePaths) {
const entry = this._cache.get(path);
if (!entry) {
promises.push(this._loadTexture(path));
} else if (entry.promise) {
promises.push(entry.promise);
}
}
await Promise.all(promises);
}
/**
* Clear texture cache
*
*/
public clear(): void {
this._cache.clear();
}
/**
* Load a single texture
*
*/
private _loadTexture(texturePath: string): Promise<number> {
const entry: TextureEntry = {
textureId: 0,
state: 'loading'
};
entry.promise = this._doLoadTexture(texturePath, entry);
this._cache.set(texturePath, entry);
return entry.promise;
}
/**
* Internal texture loading implementation
*
*/
private async _doLoadTexture(texturePath: string, entry: TextureEntry): Promise<number> {
const assetLoader = getGlobalAssetFileLoader();
const textureService = getGlobalTextureService();
if (!assetLoader) {
console.error('[FGUITextureManager] No global asset file loader available');
entry.state = 'error';
return 0;
}
if (!textureService) {
console.error('[FGUITextureManager] No texture service available');
entry.state = 'error';
return 0;
}
try {
// Load image via global asset file loader
// The image.src will be a usable URL (Blob URL in editor, HTTP URL in browser)
// 通过全局资产文件加载器加载图片
// image.src 是可用的 URL(编辑器中是 Blob URL,浏览器中是 HTTP URL
const image = await assetLoader.loadImage(texturePath);
// Use the image's src URL to load texture in engine
// 使用图片的 src URL 在引擎中加载纹理
const textureId = textureService.loadTextureByPath(image.src);
if (textureId > 0) {
entry.textureId = textureId;
entry.state = 'loaded';
} else {
entry.state = 'error';
console.error(`[FGUITextureManager] Failed to create texture: ${texturePath}`);
}
return entry.textureId;
} catch (err) {
entry.state = 'error';
console.error(`[FGUITextureManager] Failed to load texture: ${texturePath}`, err);
return 0;
}
}
}
/**
* Get global FGUI texture manager instance
* FGUI
*/
export function getFGUITextureManager(): FGUITextureManager {
return FGUITextureManager.getInstance();
}
/**
* Special texture key for white pixel (used for Graph rendering)
* Graph
*/
export const WHITE_PIXEL_TEXTURE_KEY = '__fgui_white_pixel__';
/**
* Create texture resolver function for FGUIRenderDataProvider
* FGUIRenderDataProvider
*/
export function createTextureResolver(): (textureId: string | number) => number {
const manager = getFGUITextureManager();
return (textureId: string | number): number => {
if (typeof textureId === 'number') {
return textureId;
}
// Handle special white pixel texture for Graph rendering
// Engine texture ID 0 is the default white texture
// 处理用于 Graph 渲染的特殊白色像素纹理
// 引擎纹理 ID 0 是默认的白色纹理
if (textureId === WHITE_PIXEL_TEXTURE_KEY) {
return 0;
}
return manager.resolveTexture(textureId);
};
}
@@ -0,0 +1,91 @@
/**
* FUI Asset Loader
*
* Asset loader for FairyGUI package files (.fui).
*
* FairyGUI
*/
import type {
IAssetLoader,
IAssetParseContext,
IAssetContent,
AssetContentType
} from '@esengine/asset-system';
import { UIPackage } from '../package/UIPackage';
/**
* FUI asset interface
* FUI
*/
export interface IFUIAsset {
/** Loaded UIPackage instance | 加载的 UIPackage 实例 */
package: UIPackage;
/** Package ID | 包 ID */
id: string;
/** Package name | 包名称 */
name: string;
/** Resource key used for loading | 加载时使用的资源键 */
resKey: string;
}
/**
* FUI asset type constant
* FUI
*/
export const FUI_ASSET_TYPE = 'fui';
/**
* FUIAssetLoader
*
* Loads FairyGUI package files (.fui) and creates UIPackage instances.
*
* FairyGUI UIPackage
*/
export class FUIAssetLoader implements IAssetLoader<IFUIAsset> {
readonly supportedType = FUI_ASSET_TYPE;
readonly supportedExtensions = ['.fui'];
readonly contentType: AssetContentType = 'binary';
/**
* Parse FUI package from binary content
* FUI
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IFUIAsset> {
if (!content.binary) {
throw new Error('FUIAssetLoader: Binary content is empty');
}
// Use path as resource key
const resKey = context.metadata.path;
// Load package from binary data
const pkg = UIPackage.addPackageFromBuffer(resKey, content.binary);
return {
package: pkg,
id: pkg.id,
name: pkg.name,
resKey
};
}
/**
* Dispose loaded FUI asset
* FUI
*/
dispose(asset: IFUIAsset): void {
if (asset.package) {
UIPackage.removePackage(asset.resKey);
}
}
}
/**
* Default FUI asset loader instance
* FUI
*/
export const fuiAssetLoader = new FUIAssetLoader();
// Re-export types from asset-system for convenience
export type { IAssetLoader, IAssetContent, IAssetParseContext, AssetContentType };
+34
View File
@@ -0,0 +1,34 @@
/**
* FairyGUI Asset Loaders
*
* Asset loaders for FairyGUI package files.
*
* FairyGUI
*/
export {
FUIAssetLoader,
fuiAssetLoader,
FUI_ASSET_TYPE
} from './FUIAssetLoader';
export type { IFUIAsset } from './FUIAssetLoader';
// Texture management | 纹理管理
export {
FGUITextureManager,
getFGUITextureManager,
createTextureResolver,
setGlobalTextureService,
getGlobalTextureService
} from './FGUITextureManager';
export type { ITextureService } from './FGUITextureManager';
// Re-export types from asset-system for convenience
export type {
IAssetLoader,
IAssetContent,
IAssetParseContext,
AssetContentType
} from '@esengine/asset-system';
@@ -0,0 +1,353 @@
/**
* Property change callback
*
*/
export type PropertyChangeCallback<T> = (newValue: T, oldValue: T) => void;
/**
* Property binding subscription
*
*/
export interface IPropertySubscription {
/** Unsubscribe from property changes | 取消订阅属性变更 */
unsubscribe(): void;
}
/**
* Observable property interface
*
*/
export interface IObservableProperty<T> {
/** Get current value | 获取当前值 */
readonly value: T;
/** Subscribe to changes | 订阅变更 */
subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription;
/** Bind to another property | 绑定到另一个属性 */
bindTo(target: IWritableProperty<T>): IPropertySubscription;
}
/**
* Writable property interface
*
*/
export interface IWritableProperty<T> extends IObservableProperty<T> {
/** Set value | 设置值 */
value: T;
}
/**
* ObservableProperty
*
* Reactive property that notifies subscribers when value changes.
*
*
*
* @example
* ```typescript
* const name = new ObservableProperty('初始值');
* name.subscribe((newVal, oldVal) => console.log(`Changed: ${oldVal} -> ${newVal}`));
* name.value = '新值'; // 触发回调
* ```
*/
export class ObservableProperty<T> implements IWritableProperty<T> {
private _value: T;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
private _equalityFn: (a: T, b: T) => boolean;
constructor(initialValue: T, equalityFn?: (a: T, b: T) => boolean) {
this._value = initialValue;
this._equalityFn = equalityFn ?? ((a, b) => a === b);
}
public get value(): T {
return this._value;
}
public set value(newValue: T) {
if (!this._equalityFn(this._value, newValue)) {
const oldValue = this._value;
this._value = newValue;
this.notify(newValue, oldValue);
}
}
/**
* Set value without triggering notifications
*
*/
public setSilent(newValue: T): void {
this._value = newValue;
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this._value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
/**
* Create a derived property that transforms this property's value
*
*/
public map<U>(transform: (value: T) => U): IObservableProperty<U> {
const derived = new DerivedProperty<U>(transform(this._value));
this.subscribe((newValue) => {
derived.update(transform(newValue));
});
return derived;
}
/**
* Combine with another property
*
*/
public combine<U, R>(
other: IObservableProperty<U>,
combiner: (a: T, b: U) => R
): IObservableProperty<R> {
const derived = new DerivedProperty<R>(combiner(this._value, other.value));
this.subscribe((newValue) => {
derived.update(combiner(newValue, other.value));
});
other.subscribe((newValue) => {
derived.update(combiner(this._value, newValue));
});
return derived;
}
private notify(newValue: T, oldValue: T): void {
for (const callback of this._subscribers) {
try {
callback(newValue, oldValue);
} catch (error) {
console.error('Error in property change callback:', error);
}
}
}
}
/**
* DerivedProperty
*
* Read-only property derived from other properties.
*
*
*/
class DerivedProperty<T> implements IObservableProperty<T> {
private _value: T;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
constructor(initialValue: T) {
this._value = initialValue;
}
public get value(): T {
return this._value;
}
public update(newValue: T): void {
if (this._value !== newValue) {
const oldValue = this._value;
this._value = newValue;
for (const callback of this._subscribers) {
callback(newValue, oldValue);
}
}
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this._value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
}
/**
* ComputedProperty
*
* Property that computes its value from a function.
*
*
*
* @example
* ```typescript
* const firstName = new ObservableProperty('张');
* const lastName = new ObservableProperty('三');
* const fullName = new ComputedProperty(
* () => firstName.value + lastName.value,
* [firstName, lastName]
* );
* ```
*/
export class ComputedProperty<T> implements IObservableProperty<T> {
private _computeFn: () => T;
private _cachedValue: T;
private _dirty: boolean = false;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
private _subscriptions: IPropertySubscription[] = [];
constructor(computeFn: () => T, dependencies: IObservableProperty<unknown>[]) {
this._computeFn = computeFn;
this._cachedValue = computeFn();
for (const dep of dependencies) {
this._subscriptions.push(
dep.subscribe(() => {
this._dirty = true;
this.recompute();
})
);
}
}
public get value(): T {
if (this._dirty) {
this.recompute();
}
return this._cachedValue;
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this.value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
public dispose(): void {
for (const sub of this._subscriptions) {
sub.unsubscribe();
}
this._subscriptions.length = 0;
this._subscribers.clear();
}
private recompute(): void {
const oldValue = this._cachedValue;
this._cachedValue = this._computeFn();
this._dirty = false;
if (oldValue !== this._cachedValue) {
for (const callback of this._subscribers) {
callback(this._cachedValue, oldValue);
}
}
}
}
/**
* PropertyBinder
*
* Utility for managing multiple property bindings.
*
*
*
* @example
* ```typescript
* const binder = new PropertyBinder();
* binder.bind(source.name, target, 'displayName');
* binder.bind(source.value, target.progressBar, 'progress');
* // Later...
* binder.dispose(); // Cleans up all bindings
* ```
*/
export class PropertyBinder {
private _subscriptions: IPropertySubscription[] = [];
/**
* Bind a property to an object's field
*
*/
public bind<T, K extends keyof T>(
source: IObservableProperty<T[K]>,
target: T,
key: K
): this {
target[key] = source.value;
this._subscriptions.push(
source.subscribe((newValue) => {
target[key] = newValue;
})
);
return this;
}
/**
* Two-way bind between properties
*
*/
public bindTwoWay<T>(
propA: IWritableProperty<T>,
propB: IWritableProperty<T>
): this {
let updating = false;
this._subscriptions.push(
propA.subscribe((newValue) => {
if (!updating) {
updating = true;
propB.value = newValue;
updating = false;
}
})
);
this._subscriptions.push(
propB.subscribe((newValue) => {
if (!updating) {
updating = true;
propA.value = newValue;
updating = false;
}
})
);
return this;
}
/**
* Add a custom subscription
*
*/
public addSubscription(subscription: IPropertySubscription): this {
this._subscriptions.push(subscription);
return this;
}
/**
* Dispose all bindings
*
*/
public dispose(): void {
for (const sub of this._subscriptions) {
sub.unsubscribe();
}
this._subscriptions.length = 0;
}
}
+327
View File
@@ -0,0 +1,327 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { FGUIEvents } from '../events/Events';
import type { GComponent } from './GComponent';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* Controller
*
* Manages state switching for UI components.
* Similar to a state machine, it controls which gear values are active.
*
* UI 齿
*/
export class Controller extends EventDispatcher {
/** Controller name | 控制器名称 */
public name: string = '';
/** Parent component | 父组件 */
public parent: GComponent | null = null;
/** Is changing flag | 是否正在变更中 */
public changing: boolean = false;
/** Auto radio group | 自动单选组 */
public autoRadioGroupDepth: boolean = false;
private _selectedIndex: number = 0;
private _previousIndex: number = 0;
private _pageIds: string[] = [];
private _pageNames: string[] = [];
constructor() {
super();
}
/**
* Get selected index
*
*/
public get selectedIndex(): number {
return this._selectedIndex;
}
/**
* Set selected index
*
*/
public set selectedIndex(value: number) {
if (this._selectedIndex !== value) {
if (value > this._pageIds.length - 1) {
throw new Error('Index out of bounds: ' + value);
}
this.changing = true;
this._previousIndex = this._selectedIndex;
this._selectedIndex = value;
this.parent?.applyController(this);
this.emit(FGUIEvents.STATUS_CHANGED);
this.changing = false;
}
}
/**
* Get selected page
*
*/
public get selectedPage(): string {
if (this._selectedIndex === -1) {
return '';
}
return this._pageNames[this._selectedIndex] || '';
}
/**
* Set selected page
*
*/
public set selectedPage(value: string) {
let index = this._pageNames.indexOf(value);
if (index === -1) {
index = this._pageIds.indexOf(value);
}
if (index !== -1) {
this.selectedIndex = index;
}
}
/**
* Get selected page ID
* ID
*/
public get selectedPageId(): string {
if (this._selectedIndex === -1) {
return '';
}
return this._pageIds[this._selectedIndex] || '';
}
/**
* Set selected page ID
* ID
*/
public set selectedPageId(value: string) {
const index = this._pageIds.indexOf(value);
if (index !== -1) {
this.selectedIndex = index;
}
}
/**
* Get previous selected index
*
*/
public get previousIndex(): number {
return this._previousIndex;
}
/**
* Get previous selected page
*
*/
public get previousPage(): string {
if (this._previousIndex === -1) {
return '';
}
return this._pageNames[this._previousIndex] || '';
}
/**
* Get page count
*
*/
public get pageCount(): number {
return this._pageIds.length;
}
/**
* Get page ID at index
* ID
*/
public getPageId(index: number): string {
return this._pageIds[index] || '';
}
/**
* Set page ID at index
* ID
*/
public setPageId(index: number, id: string): void {
this._pageIds[index] = id;
}
/**
* Get page name at index
*
*/
public getPageName(index: number): string {
return this._pageNames[index] || '';
}
/**
* Set page name at index
*
*/
public setPageName(index: number, name: string): void {
this._pageNames[index] = name;
}
/**
* Get index by page ID
* ID
*/
public getPageIndexById(id: string): number {
return this._pageIds.indexOf(id);
}
/**
* Get ID by page name
* ID
*/
public getPageIdByName(name: string): string {
const index = this._pageNames.indexOf(name);
if (index !== -1) {
return this._pageIds[index];
}
return '';
}
/**
* Check if the controller has the specified page
*
*/
public hasPage(aName: string): boolean {
return this._pageNames.indexOf(aName) !== -1;
}
/**
* Add page
*
*/
public addPage(name: string = ''): void {
this.addPageAt(name, this._pageIds.length);
}
/**
* Add page at index
*
*/
public addPageAt(name: string, index: number): void {
const id = '' + (this._pageIds.length > 0 ? parseInt(this._pageIds[this._pageIds.length - 1]) + 1 : 0);
if (index === this._pageIds.length) {
this._pageIds.push(id);
this._pageNames.push(name);
} else {
this._pageIds.splice(index, 0, id);
this._pageNames.splice(index, 0, name);
}
}
/**
* Remove page at index
*
*/
public removePage(name: string): void {
const index = this._pageNames.indexOf(name);
if (index !== -1) {
this._pageIds.splice(index, 1);
this._pageNames.splice(index, 1);
if (this._selectedIndex >= this._pageIds.length) {
this._selectedIndex = this._pageIds.length - 1;
}
}
}
/**
* Remove page at index
*
*/
public removePageAt(index: number): void {
this._pageIds.splice(index, 1);
this._pageNames.splice(index, 1);
if (this._selectedIndex >= this._pageIds.length) {
this._selectedIndex = this._pageIds.length - 1;
}
}
/**
* Clear all pages
*
*/
public clearPages(): void {
this._pageIds.length = 0;
this._pageNames.length = 0;
this._selectedIndex = -1;
}
/**
* Run actions on page changed
*
*/
public runActions(): void {
// Override in subclasses or handle via events
}
/**
* Setup controller from buffer
*
*/
public setup(buffer: ByteBuffer): void {
const beginPos = buffer.pos;
buffer.seek(beginPos, 0);
this.name = buffer.readS() || '';
if (buffer.readBool()) {
this.autoRadioGroupDepth = true;
}
buffer.seek(beginPos, 1);
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
this._pageIds.push(buffer.readS() || '');
this._pageNames.push(buffer.readS() || '');
}
// Home page index (simplified - ignore advanced home page types)
let homePageIndex = 0;
const homePageType = buffer.readByte();
if (homePageType === 1) {
homePageIndex = buffer.getInt16();
} else if (homePageType === 2 || homePageType === 3) {
// Skip variable name for type 3
if (homePageType === 3) {
buffer.readS();
}
}
buffer.seek(beginPos, 2);
// Skip actions for now
const actionCount = buffer.getInt16();
for (let i = 0; i < actionCount; i++) {
let nextPos = buffer.getInt16();
nextPos += buffer.pos;
buffer.pos = nextPos;
}
if (this.parent && this._pageIds.length > 0) {
this._selectedIndex = homePageIndex;
} else {
this._selectedIndex = -1;
}
}
/**
* Dispose
*
*/
public dispose(): void {
this.parent = null;
super.dispose();
}
}
@@ -0,0 +1,144 @@
import { GObject } from './GObject';
import { GRoot } from './GRoot';
import { GLoader } from '../widgets/GLoader';
import { Stage } from './Stage';
import { FGUIEvents } from '../events/Events';
import { EAlignType, EVertAlignType } from './FieldTypes';
/**
* DragDropManager
*
* Manages drag and drop operations with visual feedback.
*
*
*
* Features:
* - Visual drag agent with icon
* - Source data carrying
* - Drop target detection
* - Singleton pattern
*
* @example
* ```typescript
* // Start drag operation
* DragDropManager.inst.startDrag(sourceObj, 'ui://pkg/icon', myData);
*
* // Listen for drop on target
* targetObj.on(FGUIEvents.DROP, (data) => {
* console.log('Dropped:', data);
* });
*
* // Cancel drag
* DragDropManager.inst.cancel();
* ```
*/
export class DragDropManager {
private static _inst: DragDropManager | null = null;
private _agent: GLoader;
private _sourceData: any = null;
/**
* Get singleton instance
*
*/
public static get inst(): DragDropManager {
if (!DragDropManager._inst) {
DragDropManager._inst = new DragDropManager();
}
return DragDropManager._inst;
}
constructor() {
this._agent = new GLoader();
this._agent.draggable = true;
this._agent.touchable = false; // Important: prevent interference with drop detection
this._agent.setSize(100, 100);
this._agent.setPivot(0.5, 0.5, true);
this._agent.align = EAlignType.Center;
this._agent.verticalAlign = EVertAlignType.Middle;
this._agent.sortingOrder = 1000000;
this._agent.on(FGUIEvents.DRAG_END, this.onDragEnd, this);
}
/**
* Get drag agent object
*
*/
public get dragAgent(): GObject {
return this._agent;
}
/**
* Check if currently dragging
*
*/
public get dragging(): boolean {
return this._agent.parent !== null;
}
/**
* Start a drag operation
*
*
* @param source - Source object initiating drag |
* @param icon - Icon URL for drag agent | URL
* @param sourceData - Data to carry during drag |
* @param touchId - Touch point ID for multi-touch | ID
*/
public startDrag(source: GObject, icon: string, sourceData?: any, touchId?: number): void {
if (this._agent.parent) {
return;
}
this._sourceData = sourceData;
this._agent.url = icon;
GRoot.inst.addChild(this._agent);
const stage = Stage.inst;
const pt = GRoot.inst.globalToLocal(stage.mouseX, stage.mouseY);
this._agent.setXY(pt.x, pt.y);
this._agent.startDrag(touchId);
}
/**
* Cancel current drag operation
*
*/
public cancel(): void {
if (this._agent.parent) {
this._agent.stopDrag();
GRoot.inst.removeChild(this._agent);
this._sourceData = null;
}
}
private onDragEnd(): void {
if (!this._agent.parent) {
// Already cancelled
return;
}
GRoot.inst.removeChild(this._agent);
const sourceData = this._sourceData;
this._sourceData = null;
// Find drop target
const stage = Stage.inst;
const target = GRoot.inst.hitTest(stage.mouseX, stage.mouseY);
if (target) {
// Walk up the display list to find a drop handler
let obj: GObject | null = target;
while (obj) {
if (obj.hasListener(FGUIEvents.DROP)) {
obj.emit(FGUIEvents.DROP, sourceData);
return;
}
obj = obj.parent;
}
}
}
}
+366
View File
@@ -0,0 +1,366 @@
/**
* FairyGUI Field Types
* FairyGUI
*/
/**
* Button mode
*
*/
export const enum EButtonMode {
Common = 0,
Check = 1,
Radio = 2
}
/**
* Auto size type
*
*/
export const enum EAutoSizeType {
None = 0,
Both = 1,
Height = 2,
Shrink = 3,
Ellipsis = 4
}
/**
* Align type
*
*/
export const enum EAlignType {
Left = 0,
Center = 1,
Right = 2
}
/**
* Vertical align type
*
*/
export const enum EVertAlignType {
Top = 0,
Middle = 1,
Bottom = 2
}
/**
* Loader fill type
*
*/
export const enum ELoaderFillType {
None = 0,
Scale = 1,
ScaleMatchHeight = 2,
ScaleMatchWidth = 3,
ScaleFree = 4,
ScaleNoBorder = 5
}
/**
* List layout type
*
*/
export const enum EListLayoutType {
SingleColumn = 0,
SingleRow = 1,
FlowHorizontal = 2,
FlowVertical = 3,
Pagination = 4
}
/**
* List selection mode
*
*/
export const enum EListSelectionMode {
Single = 0,
Multiple = 1,
MultipleSingleClick = 2,
None = 3
}
/**
* Overflow type
*
*/
export const enum EOverflowType {
Visible = 0,
Hidden = 1,
Scroll = 2
}
/**
* Package item type
*
*/
export const enum EPackageItemType {
Image = 0,
MovieClip = 1,
Sound = 2,
Component = 3,
Atlas = 4,
Font = 5,
Swf = 6,
Misc = 7,
Unknown = 8,
Spine = 9,
DragonBones = 10
}
/**
* Object type
*
*/
export const enum EObjectType {
Image = 0,
MovieClip = 1,
Swf = 2,
Graph = 3,
Loader = 4,
Group = 5,
Text = 6,
RichText = 7,
InputText = 8,
Component = 9,
List = 10,
Label = 11,
Button = 12,
ComboBox = 13,
ProgressBar = 14,
Slider = 15,
ScrollBar = 16,
Tree = 17,
Loader3D = 18
}
/**
* Progress title type
*
*/
export const enum EProgressTitleType {
Percent = 0,
ValueAndMax = 1,
Value = 2,
Max = 3
}
/**
* ScrollBar display type
*
*/
export const enum EScrollBarDisplayType {
Default = 0,
Visible = 1,
Auto = 2,
Hidden = 3
}
/**
* Scroll type
*
*/
export const enum EScrollType {
Horizontal = 0,
Vertical = 1,
Both = 2
}
/**
* Flip type
*
*/
export const enum EFlipType {
None = 0,
Horizontal = 1,
Vertical = 2,
Both = 3
}
/**
* Children render order
*
*/
export const enum EChildrenRenderOrder {
Ascent = 0,
Descent = 1,
Arch = 2
}
/**
* Group layout type
*
*/
export const enum EGroupLayoutType {
None = 0,
Horizontal = 1,
Vertical = 2
}
/**
* Popup direction
*
*/
export const enum EPopupDirection {
Auto = 0,
Up = 1,
Down = 2
}
/**
* Relation type
*
*/
export const enum ERelationType {
LeftLeft = 0,
LeftCenter = 1,
LeftRight = 2,
CenterCenter = 3,
RightLeft = 4,
RightCenter = 5,
RightRight = 6,
TopTop = 7,
TopMiddle = 8,
TopBottom = 9,
MiddleMiddle = 10,
BottomTop = 11,
BottomMiddle = 12,
BottomBottom = 13,
Width = 14,
Height = 15,
LeftExtLeft = 16,
LeftExtRight = 17,
RightExtLeft = 18,
RightExtRight = 19,
TopExtTop = 20,
TopExtBottom = 21,
BottomExtTop = 22,
BottomExtBottom = 23,
Size = 24
}
/**
* Fill method
*
*/
export const enum EFillMethod {
None = 0,
Horizontal = 1,
Vertical = 2,
Radial90 = 3,
Radial180 = 4,
Radial360 = 5
}
/**
* Fill origin
*
*/
export const enum EFillOrigin {
Top = 0,
Bottom = 1,
Left = 2,
Right = 3,
TopLeft = 0,
TopRight = 1,
BottomLeft = 2,
BottomRight = 3
}
/**
* Object property ID
* ID
*/
export const enum EObjectPropID {
Text = 0,
Icon = 1,
Color = 2,
OutlineColor = 3,
Playing = 4,
Frame = 5,
DeltaTime = 6,
TimeScale = 7,
FontSize = 8,
Selected = 9
}
/**
* Gear type
* 齿
*/
export const enum EGearType {
Display = 0,
XY = 1,
Size = 2,
Look = 3,
Color = 4,
Animation = 5,
Text = 6,
Icon = 7,
Display2 = 8,
FontSize = 9
}
// EEaseType is re-exported from tween module
export { EEaseType } from '../tween/EaseType';
/**
* Blend mode
*
*/
export const enum EBlendMode {
Normal = 0,
None = 1,
Add = 2,
Multiply = 3,
Screen = 4,
Erase = 5,
Mask = 6,
Below = 7,
Off = 8,
Custom1 = 9,
Custom2 = 10,
Custom3 = 11
}
/**
* Transition action type
*
*/
export const enum ETransitionActionType {
XY = 0,
Size = 1,
Scale = 2,
Pivot = 3,
Alpha = 4,
Rotation = 5,
Color = 6,
Animation = 7,
Visible = 8,
Sound = 9,
Transition = 10,
Shake = 11,
ColorFilter = 12,
Skew = 13,
Text = 14,
Icon = 15,
Unknown = 16
}
/**
* Graph type
*
*/
export const enum EGraphType {
Empty = 0,
Rect = 1,
Ellipse = 2,
Polygon = 3,
RegularPolygon = 4
}
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
import { GObject } from './GObject';
import { EGroupLayoutType } from './FieldTypes';
/**
* GGroup
*
* Group container for layout and visibility control.
* Can arrange children horizontally, vertically, or have no layout.
*
*
*/
export class GGroup extends GObject {
/** Exclude invisible children from layout | 从布局中排除不可见子元素 */
public excludeInvisibles: boolean = false;
private _layout: EGroupLayoutType = EGroupLayoutType.None;
private _lineGap: number = 0;
private _columnGap: number = 0;
private _mainGridIndex: number = -1;
private _mainGridMinSize: number = 50;
private _boundsChanged: boolean = false;
private _updating: boolean = false;
public get layout(): EGroupLayoutType {
return this._layout;
}
public set layout(value: EGroupLayoutType) {
if (this._layout !== value) {
this._layout = value;
this.setBoundsChangedFlag(true);
}
}
public get lineGap(): number {
return this._lineGap;
}
public set lineGap(value: number) {
if (this._lineGap !== value) {
this._lineGap = value;
this.setBoundsChangedFlag();
}
}
public get columnGap(): number {
return this._columnGap;
}
public set columnGap(value: number) {
if (this._columnGap !== value) {
this._columnGap = value;
this.setBoundsChangedFlag();
}
}
public get mainGridIndex(): number {
return this._mainGridIndex;
}
public set mainGridIndex(value: number) {
if (this._mainGridIndex !== value) {
this._mainGridIndex = value;
this.setBoundsChangedFlag();
}
}
public get mainGridMinSize(): number {
return this._mainGridMinSize;
}
public set mainGridMinSize(value: number) {
if (this._mainGridMinSize !== value) {
this._mainGridMinSize = value;
this.setBoundsChangedFlag();
}
}
/**
* Set bounds changed flag
*
*/
public setBoundsChangedFlag(bPositionChanged: boolean = false): void {
if (this._updating) return;
if (bPositionChanged) {
// Position changed, need to recalculate
}
if (!this._boundsChanged) {
this._boundsChanged = true;
}
}
/**
* Ensure bounds are up to date
*
*/
public ensureBoundsCorrect(): void {
if (this._boundsChanged) {
this.updateBounds();
}
}
private updateBounds(): void {
this._boundsChanged = false;
if (!this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
const count = children.length;
if (count === 0) {
this._updating = false;
return;
}
if (this._layout === EGroupLayoutType.None) {
this.updateBoundsNone(children);
} else if (this._layout === EGroupLayoutType.Horizontal) {
this.updateBoundsHorizontal(children);
} else {
this.updateBoundsVertical(children);
}
this._updating = false;
}
private updateBoundsNone(children: GObject[]): void {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const ax = child.xMin;
const ay = child.yMin;
if (ax < minX) minX = ax;
if (ay < minY) minY = ay;
if (ax + child.width > maxX) maxX = ax + child.width;
if (ay + child.height > maxY) maxY = ay + child.height;
}
if (minX === Infinity) {
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
this._width = maxX - minX;
this._height = maxY - minY;
}
private updateBoundsHorizontal(children: GObject[]): void {
let totalWidth = 0;
let maxHeight = 0;
let visibleCount = 0;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
totalWidth += child.width;
if (child.height > maxHeight) maxHeight = child.height;
visibleCount++;
}
if (visibleCount > 0) {
totalWidth += (visibleCount - 1) * this._columnGap;
}
this._width = totalWidth;
this._height = maxHeight;
}
private updateBoundsVertical(children: GObject[]): void {
let maxWidth = 0;
let totalHeight = 0;
let visibleCount = 0;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
totalHeight += child.height;
if (child.width > maxWidth) maxWidth = child.width;
visibleCount++;
}
if (visibleCount > 0) {
totalHeight += (visibleCount - 1) * this._lineGap;
}
this._width = maxWidth;
this._height = totalHeight;
}
/**
* Move children when group is moved
*
*/
public moveChildren(dx: number, dy: number): void {
if (this._updating || !this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
for (const child of children) {
child.setXY(child.x + dx, child.y + dy);
}
this._updating = false;
}
/**
* Resize children when group is resized
*
*/
public resizeChildren(dw: number, dh: number): void {
if (this._layout === EGroupLayoutType.None || this._updating || !this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
const count = children.length;
if (count > 0) {
if (this._layout === EGroupLayoutType.Horizontal) {
const remainingWidth = this._width + dw - (count - 1) * this._columnGap;
let x = children[0].xMin;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const newWidth = child._sizePercentInGroup * remainingWidth;
child.setSize(newWidth, child.height + dh);
child.xMin = x;
x += newWidth + this._columnGap;
}
} else {
const remainingHeight = this._height + dh - (count - 1) * this._lineGap;
let y = children[0].yMin;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const newHeight = child._sizePercentInGroup * remainingHeight;
child.setSize(child.width + dw, newHeight);
child.yMin = y;
y += newHeight + this._lineGap;
}
}
}
this._updating = false;
}
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import type { GObject } from './GObject';
import { UIPackage } from '../package/UIPackage';
/**
* GObjectPool
*
* Object pool for GObject instances, used for efficient UI recycling.
* Objects are pooled by their resource URL.
*
* GObject UI URL
*/
export class GObjectPool {
private _pool: Map<string, GObject[]> = new Map();
private _count: number = 0;
/**
* Get total pooled object count
*
*/
public get count(): number {
return this._count;
}
/**
* Clear all pooled objects
*
*/
public clear(): void {
for (const [, arr] of this._pool) {
for (const obj of arr) {
obj.dispose();
}
}
this._pool.clear();
this._count = 0;
}
/**
* Get object from pool or create new one
*
*
* @param url Resource URL | URL
* @returns GObject instance or null | GObject null
*/
public getObject(url: string): GObject | null {
url = UIPackage.normalizeURL(url);
if (!url) return null;
const arr = this._pool.get(url);
if (arr && arr.length > 0) {
this._count--;
return arr.shift()!;
}
return UIPackage.createObjectFromURL(url);
}
/**
* Return object to pool
*
*
* @param obj GObject to return | GObject
*/
public returnObject(obj: GObject): void {
const url = obj.resourceURL;
if (!url) return;
let arr = this._pool.get(url);
if (!arr) {
arr = [];
this._pool.set(url, arr);
}
this._count++;
arr.push(obj);
}
}
+506
View File
@@ -0,0 +1,506 @@
import { GComponent } from './GComponent';
import { GObject } from './GObject';
import { Stage } from './Stage';
import { Timer } from './Timer';
import { FGUIEvents, IInputEventData } from '../events/Events';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* GRoot
*
* Root container for all UI elements.
* Manages focus, popups, tooltips, and input dispatch.
*
* UI
*/
export class GRoot extends GComponent {
private static _inst: GRoot | null = null;
private _focus: GObject | null = null;
private _tooltipWin: GObject | null = null;
private _defaultTooltipWin: GObject | null = null;
private _popupStack: GObject[] = [];
private _justClosedPopups: GObject[] = [];
private _modalLayer: GObject | null = null;
private _modalWaitPane: GObject | null = null;
private _inputProcessor: InputProcessor;
constructor() {
super();
this._inputProcessor = new InputProcessor(this);
// Set this as stage root so children receive addedToStage events
// 将自己设置为舞台根,这样子对象才能收到 addedToStage 事件
if (this.displayObject) {
this.displayObject.setStage(this.displayObject);
}
// Bind to stage events
const stage = Stage.inst;
stage.on('mousedown', this.onStageMouseDown, this);
stage.on('mouseup', this.onStageMouseUp, this);
stage.on('mousemove', this.onStageMouseMove, this);
stage.on('wheel', this.onStageWheel, this);
stage.on('resize', this.onStageResize, this);
// Set initial size
this.setSize(stage.designWidth, stage.designHeight);
}
/**
* Get singleton instance
*
*/
public static get inst(): GRoot {
if (!GRoot._inst) {
GRoot._inst = new GRoot();
}
return GRoot._inst;
}
/**
* Create a new GRoot (for multi-window support)
* GRoot
*/
public static create(): GRoot {
return new GRoot();
}
// Focus management | 焦点管理
/**
* Get focused object
*
*/
public get focus(): GObject | null {
return this._focus;
}
/**
* Set focused object
*
*/
public set focus(value: GObject | null) {
if (this._focus !== value) {
const oldFocus = this._focus;
this._focus = value;
if (oldFocus) {
oldFocus.emit(FGUIEvents.FOCUS_OUT);
}
if (this._focus) {
this._focus.emit(FGUIEvents.FOCUS_IN);
}
}
}
// Popup management | 弹出窗口管理
/**
* Show popup at position
*
*/
public showPopup(popup: GObject, target?: GObject, dir?: number): void {
if (this._popupStack.indexOf(popup) === -1) {
this._popupStack.push(popup);
}
this.addChild(popup);
this.adjustModalLayer();
if (target) {
const pos = target.localToGlobal(0, 0);
popup.setXY(pos.x, pos.y + target.height);
}
popup.visible = true;
}
/**
* Toggle popup visibility
*
*/
public togglePopup(popup: GObject, target?: GObject, dir?: number): void {
if (this._justClosedPopups.indexOf(popup) !== -1) {
return;
}
if (popup.parent === this && popup.visible) {
this.hidePopup(popup);
} else {
this.showPopup(popup, target, dir);
}
}
/**
* Hide popup
*
*/
public hidePopup(popup?: GObject): void {
if (popup) {
const index = this._popupStack.indexOf(popup);
if (index !== -1) {
this._popupStack.splice(index, 1);
this.closePopup(popup);
}
} else {
// Hide all popups
for (const p of this._popupStack) {
this.closePopup(p);
}
this._popupStack.length = 0;
}
}
private closePopup(popup: GObject): void {
popup.visible = false;
this._justClosedPopups.push(popup);
Timer.inst.callLater(this, () => {
const index = this._justClosedPopups.indexOf(popup);
if (index !== -1) {
this._justClosedPopups.splice(index, 1);
}
});
}
/**
* Check if popup is showing
*
*/
public hasAnyPopup(): boolean {
return this._popupStack.length > 0;
}
// Modal management | 模态管理
private adjustModalLayer(): void {
// Adjust modal layer position and visibility
if (this._modalLayer) {
let hasModal = false;
for (let i = this._popupStack.length - 1; i >= 0; i--) {
// Check if popup is modal
}
this._modalLayer.visible = hasModal;
}
}
/**
* Show modal wait
*
*/
public showModalWait(msg?: string): void {
if (this._modalWaitPane) {
this.addChild(this._modalWaitPane);
this._modalWaitPane.visible = true;
}
}
/**
* Close modal wait
*
*/
public closeModalWait(): void {
if (this._modalWaitPane) {
this._modalWaitPane.visible = false;
this._modalWaitPane.removeFromParent();
}
}
// Tooltip management | 提示管理
/**
* Show tooltip
*
*/
public showTooltips(msg: string): void {
if (!this._defaultTooltipWin) return;
this._tooltipWin = this._defaultTooltipWin;
this._tooltipWin.text = msg;
this.showTooltipsWin(this._tooltipWin);
}
/**
* Show custom tooltip window
*
*/
public showTooltipsWin(tooltipWin: GObject, position?: { x: number; y: number }): void {
this._tooltipWin = tooltipWin;
this.addChild(tooltipWin);
if (position) {
tooltipWin.setXY(position.x, position.y);
} else {
const stage = Stage.inst;
tooltipWin.setXY(stage.mouseX + 10, stage.mouseY + 20);
}
}
/**
* Hide tooltip
*
*/
public hideTooltips(): void {
if (this._tooltipWin) {
this._tooltipWin.removeFromParent();
this._tooltipWin = null;
}
}
// Input handling | 输入处理
private onStageMouseDown(data: IInputEventData): void {
this._inputProcessor.onMouseDown(data);
// Close popups if clicking outside
if (this._popupStack.length > 0) {
const hit = this.hitTest(data.stageX, data.stageY);
if (!hit || !this.isAncestorOf(hit, this._popupStack[this._popupStack.length - 1])) {
this.hidePopup();
}
}
this.hideTooltips();
}
private onStageMouseUp(data: IInputEventData): void {
this._inputProcessor.onMouseUp(data);
}
private onStageMouseMove(data: IInputEventData): void {
this._inputProcessor.onMouseMove(data);
}
private onStageWheel(data: IInputEventData): void {
this._inputProcessor.onMouseWheel(data);
}
private onStageResize(): void {
const stage = Stage.inst;
this.setSize(stage.designWidth, stage.designHeight);
}
private isAncestorOf(obj: GObject, ancestor: GObject): boolean {
let p: GObject | null = obj;
while (p) {
if (p === ancestor) return true;
p = p.parent;
}
return false;
}
/**
* Hit test at position
*
*/
public hitTest(stageX: number, stageY: number): GObject | null {
return this._inputProcessor.hitTest(stageX, stageY);
}
// Drag and drop | 拖放
/**
* Start dragging a source object
*
*/
public startDragSource(source: GObject): void {
GObject.draggingObject = source;
}
/**
* Stop dragging
*
*/
public stopDragSource(): void {
GObject.draggingObject = null;
}
// Window management | 窗口管理
/**
* Show window
*
*/
public showWindow(win: GObject): void {
this.addChild(win);
this.adjustModalLayer();
}
/**
* Hide window immediately
*
*/
public hideWindowImmediately(win: GObject): void {
if (win.parent === this) {
this.removeChild(win);
}
this.adjustModalLayer();
}
/**
* Bring window to front
*
*/
public bringToFront(win: GObject): void {
const cnt = this.numChildren;
let i: number;
if (this._modalLayer && this._modalLayer.parent === this) {
i = this.getChildIndex(this._modalLayer);
} else {
i = cnt - 1;
}
const index = this.getChildIndex(win);
if (index < i) {
this.setChildIndex(win, i);
}
}
/**
* Get top window
*
*/
public getTopWindow(): GObject | null {
const cnt = this.numChildren;
for (let i = cnt - 1; i >= 0; i--) {
const child = this.getChildAt(i);
if (child !== this._modalLayer) {
return child;
}
}
return null;
}
// Update | 更新
/**
* Update GRoot (called each frame by ECS system)
* GRoot ECS
*/
public update(): void {
// Update timers
// Update transitions
// Update scroll panes
}
// Disposal | 销毁
public dispose(): void {
const stage = Stage.inst;
stage.off('mousedown', this.onStageMouseDown);
stage.off('mouseup', this.onStageMouseUp);
stage.off('mousemove', this.onStageMouseMove);
stage.off('wheel', this.onStageWheel);
stage.off('resize', this.onStageResize);
this._inputProcessor.dispose();
if (GRoot._inst === this) {
GRoot._inst = null;
}
super.dispose();
}
// Render | 渲染
public collectRenderData(collector: IRenderCollector): void {
super.collectRenderData(collector);
}
}
/**
* InputProcessor
*
* Handles input event processing and dispatching.
*
*
*/
class InputProcessor {
private _root: GRoot;
private _touchTarget: GObject | null = null;
private _rollOverTarget: GObject | null = null;
constructor(root: GRoot) {
this._root = root;
}
public hitTest(stageX: number, stageY: number): GObject | null {
return this.hitTestInChildren(this._root, stageX, stageY);
}
private hitTestInChildren(container: GComponent, stageX: number, stageY: number): GObject | null {
const count = container.numChildren;
for (let i = count - 1; i >= 0; i--) {
const child = container.getChildAt(i);
if (!child.visible || !child.touchable) continue;
const local = child.globalToLocal(stageX, stageY);
if (local.x >= 0 && local.x < child.width && local.y >= 0 && local.y < child.height) {
if (child instanceof GComponent) {
const deeper = this.hitTestInChildren(child, stageX, stageY);
if (deeper) return deeper;
}
return child;
}
}
return null;
}
public onMouseDown(data: IInputEventData): void {
this._touchTarget = this.hitTest(data.stageX, data.stageY);
if (this._touchTarget) {
this._root.focus = this._touchTarget;
this._touchTarget.emit(FGUIEvents.TOUCH_BEGIN, data);
}
}
public onMouseUp(data: IInputEventData): void {
if (this._touchTarget) {
const target = this.hitTest(data.stageX, data.stageY);
this._touchTarget.emit(FGUIEvents.TOUCH_END, data);
if (target === this._touchTarget) {
this._touchTarget.emit(FGUIEvents.CLICK, data);
}
this._touchTarget = null;
}
}
public onMouseMove(data: IInputEventData): void {
const target = this.hitTest(data.stageX, data.stageY);
// Handle roll over/out
if (target !== this._rollOverTarget) {
if (this._rollOverTarget) {
this._rollOverTarget.emit(FGUIEvents.ROLL_OUT, data);
}
this._rollOverTarget = target;
if (this._rollOverTarget) {
this._rollOverTarget.emit(FGUIEvents.ROLL_OVER, data);
}
}
// Handle touch move
if (this._touchTarget) {
this._touchTarget.emit(FGUIEvents.TOUCH_MOVE, data);
}
}
public onMouseWheel(data: IInputEventData): void {
const target = this.hitTest(data.stageX, data.stageY);
if (target) {
target.emit('wheel', data);
}
}
public dispose(): void {
this._touchTarget = null;
this._rollOverTarget = null;
}
}
@@ -0,0 +1,268 @@
/**
* Service identifier type
*
*/
export type ServiceIdentifier<T = unknown> = abstract new (...args: never[]) => T;
/**
* Service factory function
*
*/
export type ServiceFactory<T> = (container: ServiceContainer) => T;
/**
* Service lifecycle
*
*/
export const enum EServiceLifecycle {
/** Single instance shared across all resolutions | 单例模式 */
Singleton = 'singleton',
/** New instance per resolution | 每次解析创建新实例 */
Transient = 'transient'
}
/**
* Service registration info
*
*/
interface ServiceRegistration<T = unknown> {
factory: ServiceFactory<T>;
lifecycle: EServiceLifecycle;
instance?: T;
}
/**
* ServiceContainer
*
* Lightweight dependency injection container for FairyGUI.
*
*
*
* Features:
* - Singleton and transient lifecycles
* - Factory-based registration
* - Type-safe resolution
* - Circular dependency detection
*
* @example
* ```typescript
* const container = new ServiceContainer();
*
* // Register singleton
* container.registerSingleton(AudioService, () => new AudioService());
*
* // Register with dependencies
* container.registerSingleton(UIManager, (c) => new UIManager(
* c.resolve(AudioService)
* ));
*
* // Resolve
* const uiManager = container.resolve(UIManager);
* ```
*/
export class ServiceContainer {
private _registrations: Map<ServiceIdentifier, ServiceRegistration> = new Map();
private _resolving: Set<ServiceIdentifier> = new Set();
private _disposed: boolean = false;
/**
* Register a singleton service
*
*/
public registerSingleton<T>(
identifier: ServiceIdentifier<T>,
factory: ServiceFactory<T>
): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory,
lifecycle: EServiceLifecycle.Singleton
});
return this;
}
/**
* Register a singleton instance directly
*
*/
public registerInstance<T>(identifier: ServiceIdentifier<T>, instance: T): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory: () => instance,
lifecycle: EServiceLifecycle.Singleton,
instance
});
return this;
}
/**
* Register a transient service (new instance per resolution)
*
*/
public registerTransient<T>(
identifier: ServiceIdentifier<T>,
factory: ServiceFactory<T>
): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory,
lifecycle: EServiceLifecycle.Transient
});
return this;
}
/**
* Resolve a service
*
*/
public resolve<T>(identifier: ServiceIdentifier<T>): T {
this.checkDisposed();
const registration = this._registrations.get(identifier);
if (!registration) {
throw new Error(`Service not registered: ${identifier.name}`);
}
// Check for circular dependency
if (this._resolving.has(identifier)) {
throw new Error(`Circular dependency detected: ${identifier.name}`);
}
// Return cached singleton if available
if (registration.lifecycle === EServiceLifecycle.Singleton && registration.instance !== undefined) {
return registration.instance as T;
}
// Resolve
this._resolving.add(identifier);
try {
const instance = registration.factory(this) as T;
if (registration.lifecycle === EServiceLifecycle.Singleton) {
registration.instance = instance;
}
return instance;
} finally {
this._resolving.delete(identifier);
}
}
/**
* Try to resolve a service, returns null if not found
* null
*/
public tryResolve<T>(identifier: ServiceIdentifier<T>): T | null {
if (!this._registrations.has(identifier)) {
return null;
}
return this.resolve(identifier);
}
/**
* Check if a service is registered
*
*/
public isRegistered<T>(identifier: ServiceIdentifier<T>): boolean {
return this._registrations.has(identifier);
}
/**
* Unregister a service
*
*/
public unregister<T>(identifier: ServiceIdentifier<T>): boolean {
const registration = this._registrations.get(identifier);
if (registration) {
// Dispose singleton if it has dispose method
if (registration.instance && typeof (registration.instance as IDisposable).dispose === 'function') {
(registration.instance as IDisposable).dispose();
}
this._registrations.delete(identifier);
return true;
}
return false;
}
/**
* Create a child container that inherits registrations
*
*/
public createChild(): ServiceContainer {
const child = new ServiceContainer();
// Copy registrations (singletons are shared)
for (const [id, reg] of this._registrations) {
child._registrations.set(id, { ...reg });
}
return child;
}
/**
* Dispose the container and all singleton instances
*
*/
public dispose(): void {
if (this._disposed) return;
for (const registration of this._registrations.values()) {
if (registration.instance && typeof (registration.instance as IDisposable).dispose === 'function') {
(registration.instance as IDisposable).dispose();
}
}
this._registrations.clear();
this._resolving.clear();
this._disposed = true;
}
private checkDisposed(): void {
if (this._disposed) {
throw new Error('ServiceContainer has been disposed');
}
}
}
/**
* Disposable interface
*
*/
interface IDisposable {
dispose(): void;
}
/**
* Global service container instance
*
*/
let _globalContainer: ServiceContainer | null = null;
/**
* Get global service container
*
*/
export function getGlobalContainer(): ServiceContainer {
if (!_globalContainer) {
_globalContainer = new ServiceContainer();
}
return _globalContainer;
}
/**
* Set global service container
*
*/
export function setGlobalContainer(container: ServiceContainer): void {
_globalContainer = container;
}
/**
* Inject decorator marker (for future decorator support)
*
*/
export function Inject<T>(identifier: ServiceIdentifier<T>): PropertyDecorator {
return (_target: object, _propertyKey: string | symbol) => {
// Store metadata for future use
// This is a placeholder for decorator-based injection
void identifier;
};
}
+353
View File
@@ -0,0 +1,353 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { IInputEventData, createInputEventData } from '../events/Events';
/**
* Stage
*
* Represents the root container and manages input events.
*
*
*/
export class Stage extends EventDispatcher {
private static _inst: Stage | null = null;
/** Stage width | 舞台宽度 */
public width: number = 800;
/** Stage height | 舞台高度 */
public height: number = 600;
/** Current mouse/touch X position | 当前鼠标/触摸 X 坐标 */
public mouseX: number = 0;
/** Current mouse/touch Y position | 当前鼠标/触摸 Y 坐标 */
public mouseY: number = 0;
/** Design width | 设计宽度 */
public designWidth: number = 1920;
/** Design height | 设计高度 */
public designHeight: number = 1080;
/** Scale mode | 缩放模式 */
public scaleMode: EScaleMode = EScaleMode.ShowAll;
/** Align mode | 对齐模式 */
public alignH: EAlignMode = EAlignMode.Center;
public alignV: EAlignMode = EAlignMode.Middle;
/** Is touch/pointer down | 是否按下 */
public isTouchDown: boolean = false;
/** Current touch ID | 当前触摸 ID */
public touchId: number = 0;
private _canvas: HTMLCanvasElement | null = null;
private _inputData: IInputEventData;
private _scaleX: number = 1;
private _scaleY: number = 1;
private _offsetX: number = 0;
private _offsetY: number = 0;
private constructor() {
super();
this._inputData = createInputEventData();
}
/**
* Get singleton instance
*
*/
public static get inst(): Stage {
if (!Stage._inst) {
Stage._inst = new Stage();
}
return Stage._inst;
}
/**
* Bind stage to a canvas element
*
*
* @param canvas HTMLCanvasElement to bind |
*/
public bindToCanvas(canvas: HTMLCanvasElement): void {
if (this._canvas) {
this.unbindCanvas();
}
this._canvas = canvas;
this.updateSize();
this.bindEvents();
}
/**
* Unbind from current canvas
*
*/
public unbindCanvas(): void {
if (!this._canvas) return;
this._canvas.removeEventListener('mousedown', this.handleMouseDown);
this._canvas.removeEventListener('mouseup', this.handleMouseUp);
this._canvas.removeEventListener('mousemove', this.handleMouseMove);
this._canvas.removeEventListener('wheel', this.handleWheel);
this._canvas.removeEventListener('touchstart', this.handleTouchStart);
this._canvas.removeEventListener('touchend', this.handleTouchEnd);
this._canvas.removeEventListener('touchmove', this.handleTouchMove);
this._canvas.removeEventListener('touchcancel', this.handleTouchEnd);
this._canvas = null;
}
/**
* Update stage size from canvas
*
*/
public updateSize(): void {
if (!this._canvas) return;
this.width = this._canvas.width;
this.height = this._canvas.height;
this.updateScale();
this.emit('resize', { width: this.width, height: this.height });
}
/**
* Set design size
*
*/
public setDesignSize(width: number, height: number): void {
this.designWidth = width;
this.designHeight = height;
this.updateScale();
}
private updateScale(): void {
const scaleX = this.width / this.designWidth;
const scaleY = this.height / this.designHeight;
switch (this.scaleMode) {
case EScaleMode.ShowAll:
this._scaleX = this._scaleY = Math.min(scaleX, scaleY);
break;
case EScaleMode.NoBorder:
this._scaleX = this._scaleY = Math.max(scaleX, scaleY);
break;
case EScaleMode.ExactFit:
this._scaleX = scaleX;
this._scaleY = scaleY;
break;
case EScaleMode.FixedWidth:
this._scaleX = this._scaleY = scaleX;
break;
case EScaleMode.FixedHeight:
this._scaleX = this._scaleY = scaleY;
break;
case EScaleMode.NoScale:
default:
this._scaleX = this._scaleY = 1;
break;
}
const actualWidth = this.designWidth * this._scaleX;
const actualHeight = this.designHeight * this._scaleY;
switch (this.alignH) {
case EAlignMode.Left:
this._offsetX = 0;
break;
case EAlignMode.Right:
this._offsetX = this.width - actualWidth;
break;
case EAlignMode.Center:
default:
this._offsetX = (this.width - actualWidth) / 2;
break;
}
switch (this.alignV) {
case EAlignMode.Top:
this._offsetY = 0;
break;
case EAlignMode.Bottom:
this._offsetY = this.height - actualHeight;
break;
case EAlignMode.Middle:
default:
this._offsetY = (this.height - actualHeight) / 2;
break;
}
}
/**
* Convert screen coordinates to stage coordinates
*
*/
public screenToStage(screenX: number, screenY: number): { x: number; y: number } {
return {
x: (screenX - this._offsetX) / this._scaleX,
y: (screenY - this._offsetY) / this._scaleY
};
}
/**
* Convert stage coordinates to screen coordinates
*
*/
public stageToScreen(stageX: number, stageY: number): { x: number; y: number } {
return {
x: stageX * this._scaleX + this._offsetX,
y: stageY * this._scaleY + this._offsetY
};
}
private bindEvents(): void {
if (!this._canvas) return;
this._canvas.addEventListener('mousedown', this.handleMouseDown);
this._canvas.addEventListener('mouseup', this.handleMouseUp);
this._canvas.addEventListener('mousemove', this.handleMouseMove);
this._canvas.addEventListener('wheel', this.handleWheel);
this._canvas.addEventListener('touchstart', this.handleTouchStart, { passive: false });
this._canvas.addEventListener('touchend', this.handleTouchEnd);
this._canvas.addEventListener('touchmove', this.handleTouchMove, { passive: false });
this._canvas.addEventListener('touchcancel', this.handleTouchEnd);
}
private getCanvasPosition(e: MouseEvent | Touch): { x: number; y: number } {
if (!this._canvas) return { x: 0, y: 0 };
const rect = this._canvas.getBoundingClientRect();
const scaleX = this._canvas.width / rect.width;
const scaleY = this._canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
private updateInputData(e: MouseEvent | Touch, type: string): void {
const pos = this.getCanvasPosition(e);
const stagePos = this.screenToStage(pos.x, pos.y);
this._inputData.stageX = stagePos.x;
this._inputData.stageY = stagePos.y;
this.mouseX = stagePos.x;
this.mouseY = stagePos.y;
if (e instanceof MouseEvent) {
this._inputData.button = e.button;
this._inputData.ctrlKey = e.ctrlKey;
this._inputData.shiftKey = e.shiftKey;
this._inputData.altKey = e.altKey;
this._inputData.nativeEvent = e;
} else {
this._inputData.touchId = e.identifier;
this.touchId = e.identifier;
}
}
private handleMouseDown = (e: MouseEvent): void => {
this.updateInputData(e, 'mousedown');
this.isTouchDown = true;
this._inputData.touchId = 0;
this.emit('mousedown', this._inputData);
};
private handleMouseUp = (e: MouseEvent): void => {
this.updateInputData(e, 'mouseup');
this.isTouchDown = false;
this.emit('mouseup', this._inputData);
};
private handleMouseMove = (e: MouseEvent): void => {
this.updateInputData(e, 'mousemove');
this.emit('mousemove', this._inputData);
};
private handleWheel = (e: WheelEvent): void => {
this.updateInputData(e, 'wheel');
this._inputData.wheelDelta = e.deltaY;
this._inputData.nativeEvent = e;
this.emit('wheel', this._inputData);
};
private handleTouchStart = (e: TouchEvent): void => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
this.updateInputData(touch, 'touchstart');
this.isTouchDown = true;
this.emit('mousedown', this._inputData);
}
};
private handleTouchEnd = (e: TouchEvent): void => {
if (e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
this.updateInputData(touch, 'touchend');
this.isTouchDown = false;
this.emit('mouseup', this._inputData);
}
};
private handleTouchMove = (e: TouchEvent): void => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
this.updateInputData(touch, 'touchmove');
this.emit('mousemove', this._inputData);
}
};
public get scaleX(): number {
return this._scaleX;
}
public get scaleY(): number {
return this._scaleY;
}
public get offsetX(): number {
return this._offsetX;
}
public get offsetY(): number {
return this._offsetY;
}
}
/**
* Scale mode enum
*
*/
export const enum EScaleMode {
/** No scaling | 不缩放 */
NoScale = 'noscale',
/** Show all content (letterbox) | 显示全部内容(黑边) */
ShowAll = 'showall',
/** Fill screen, clip content | 填充屏幕,裁剪内容 */
NoBorder = 'noborder',
/** Stretch to fit | 拉伸适应 */
ExactFit = 'exactfit',
/** Fixed width, height scales | 固定宽度,高度缩放 */
FixedWidth = 'fixedwidth',
/** Fixed height, width scales | 固定高度,宽度缩放 */
FixedHeight = 'fixedheight'
}
/**
* Align mode enum
*
*/
export const enum EAlignMode {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom'
}
+266
View File
@@ -0,0 +1,266 @@
/**
* Timer callback info
*
*/
interface TimerCallback {
id: number;
caller: any;
callback: Function;
interval: number;
elapsed: number;
repeat: boolean;
removed: boolean;
}
/**
* Call later callback info
*
*/
interface CallLaterItem {
caller: any;
callback: Function;
}
/**
* Timer
*
* Provides timing and scheduling functionality.
*
*
*/
export class Timer {
private static _inst: Timer | null = null;
/** Frame delta time in milliseconds | 帧间隔时间(毫秒) */
public delta: number = 0;
/** Current time in milliseconds | 当前时间(毫秒) */
public currentTime: number = 0;
/** Frame count | 帧数 */
public frameCount: number = 0;
private _callbacks: Map<number, TimerCallback> = new Map();
private _callLaterList: CallLaterItem[] = [];
private _callLaterPending: CallLaterItem[] = [];
private _nextId: number = 1;
private _updating: boolean = false;
private constructor() {
this.currentTime = performance.now();
}
/**
* Get singleton instance
*
*/
public static get inst(): Timer {
if (!Timer._inst) {
Timer._inst = new Timer();
}
return Timer._inst;
}
/**
* Get current time (static shortcut)
*
*/
public static get time(): number {
return Timer.inst.currentTime;
}
/**
* Add a callback to be called each frame
*
*/
public static add(callback: Function, caller: any): void {
Timer.inst.frameLoop(1, caller, callback);
}
/**
* Remove a callback
*
*/
public static remove(callback: Function, caller: any): void {
Timer.inst.clear(caller, callback);
}
/**
* Update timer (called by ECS system each frame)
* ECS
*
* @param deltaMs Delta time in milliseconds |
*/
public update(deltaMs: number): void {
this.delta = deltaMs;
this.currentTime += deltaMs;
this.frameCount++;
this._updating = true;
// Process timers
for (const callback of this._callbacks.values()) {
if (callback.removed) continue;
callback.elapsed += deltaMs;
if (callback.elapsed >= callback.interval) {
callback.callback.call(callback.caller);
if (callback.repeat) {
callback.elapsed = 0;
} else {
callback.removed = true;
}
}
}
// Clean up removed callbacks
for (const [id, callback] of this._callbacks) {
if (callback.removed) {
this._callbacks.delete(id);
}
}
// Process callLater
const pending = this._callLaterList;
this._callLaterList = this._callLaterPending;
this._callLaterPending = [];
for (const item of pending) {
item.callback.call(item.caller);
}
pending.length = 0;
this._callLaterList = pending;
this._updating = false;
}
/**
* Execute callback after specified delay (one time)
*
*
* @param delay Delay in milliseconds |
* @param caller Callback context |
* @param callback Callback function |
*/
public once(delay: number, caller: any, callback: Function): void {
this.addCallback(delay, caller, callback, false);
}
/**
* Execute callback repeatedly at interval
*
*
* @param interval Interval in milliseconds |
* @param caller Callback context |
* @param callback Callback function |
*/
public loop(interval: number, caller: any, callback: Function): void {
this.addCallback(interval, caller, callback, true);
}
/**
* Execute callback every frame
*
*
* @param interval Frame interval (1 = every frame) |
* @param caller Callback context |
* @param callback Callback function |
*/
public frameLoop(interval: number, caller: any, callback: Function): void {
this.loop(interval * 16.67, caller, callback);
}
/**
* Execute callback at the end of current frame
*
*
* @param caller Callback context |
* @param callback Callback function |
*/
public callLater(caller: any, callback: Function): void {
const list = this._updating ? this._callLaterPending : this._callLaterList;
const exists = list.some(
(item) => item.caller === caller && item.callback === callback
);
if (!exists) {
list.push({ caller, callback });
}
}
/**
* Clear a specific callback
*
*
* @param caller Callback context |
* @param callback Callback function |
*/
public clear(caller: any, callback: Function): void {
for (const cb of this._callbacks.values()) {
if (cb.caller === caller && cb.callback === callback) {
cb.removed = true;
}
}
this._callLaterList = this._callLaterList.filter(
(item) => !(item.caller === caller && item.callback === callback)
);
this._callLaterPending = this._callLaterPending.filter(
(item) => !(item.caller === caller && item.callback === callback)
);
}
/**
* Clear all callbacks for a caller
*
*
* @param caller Callback context |
*/
public clearAll(caller: any): void {
for (const cb of this._callbacks.values()) {
if (cb.caller === caller) {
cb.removed = true;
}
}
this._callLaterList = this._callLaterList.filter(
(item) => item.caller !== caller
);
this._callLaterPending = this._callLaterPending.filter(
(item) => item.caller !== caller
);
}
private addCallback(
interval: number,
caller: any,
callback: Function,
repeat: boolean
): void {
this.clear(caller, callback);
const id = this._nextId++;
this._callbacks.set(id, {
id,
caller,
callback,
interval,
elapsed: 0,
repeat,
removed: false
});
}
/**
* Dispose the timer
*
*/
public dispose(): void {
this._callbacks.clear();
this._callLaterList.length = 0;
this._callLaterPending.length = 0;
}
}
+859
View File
@@ -0,0 +1,859 @@
import { EventDispatcher } from '../events/EventDispatcher';
import type { GComponent } from './GComponent';
import type { GObject } from './GObject';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import { EEaseType } from '../tween/EaseType';
import { ByteBuffer } from '../utils/ByteBuffer';
import type { SimpleHandler } from '../display/MovieClip';
/**
* Transition action types
*
*/
export const enum ETransitionActionType {
XY = 0,
Size = 1,
Scale = 2,
Pivot = 3,
Alpha = 4,
Rotation = 5,
Color = 6,
Animation = 7,
Visible = 8,
Sound = 9,
Transition = 10,
Shake = 11,
ColorFilter = 12,
Skew = 13,
Text = 14,
Icon = 15,
Unknown = 16
}
/**
* Transition item value
*
*/
interface ITransitionValue {
f1?: number;
f2?: number;
f3?: number;
f4?: number;
b1?: boolean;
b2?: boolean;
b3?: boolean;
visible?: boolean;
playing?: boolean;
frame?: number;
sound?: string;
volume?: number;
transName?: string;
playTimes?: number;
trans?: Transition;
stopTime?: number;
amplitude?: number;
duration?: number;
offsetX?: number;
offsetY?: number;
lastOffsetX?: number;
lastOffsetY?: number;
text?: string;
audioClip?: string;
flag?: boolean;
}
/**
* Tween config
*
*/
interface ITweenConfig {
duration: number;
easeType: EEaseType;
repeat: number;
yoyo: boolean;
startValue: ITransitionValue;
endValue: ITransitionValue;
endLabel?: string;
endHook?: SimpleHandler;
}
/**
* Transition item
*
*/
interface ITransitionItem {
time: number;
targetId: string;
type: ETransitionActionType;
tweenConfig?: ITweenConfig;
label?: string;
value: ITransitionValue;
hook?: SimpleHandler;
tweener?: GTweener;
target?: GObject;
displayLockToken: number;
}
/** Options flags */
const OPTION_AUTO_STOP_DISABLED = 2;
const OPTION_AUTO_STOP_AT_END = 4;
/**
* Transition
*
* Animation transition system for UI components.
* Supports keyframe animations, tweening, and chained transitions.
*
* UI
*/
export class Transition extends EventDispatcher {
/** Transition name | 过渡动画名称 */
public name: string = '';
private _owner: GComponent;
private _ownerBaseX: number = 0;
private _ownerBaseY: number = 0;
private _items: ITransitionItem[] = [];
private _totalTimes: number = 0;
private _totalTasks: number = 0;
private _playing: boolean = false;
private _paused: boolean = false;
private _onComplete: SimpleHandler | null = null;
private _options: number = 0;
private _reversed: boolean = false;
private _totalDuration: number = 0;
private _autoPlay: boolean = false;
private _autoPlayTimes: number = 1;
private _autoPlayDelay: number = 0;
private _timeScale: number = 1;
private _startTime: number = 0;
private _endTime: number = -1;
constructor(owner: GComponent) {
super();
this._owner = owner;
}
public get owner(): GComponent {
return this._owner;
}
public get playing(): boolean {
return this._playing;
}
public get autoPlay(): boolean {
return this._autoPlay;
}
public set autoPlay(value: boolean) {
this.setAutoPlay(value, this._autoPlayTimes, this._autoPlayDelay);
}
public get autoPlayRepeat(): number {
return this._autoPlayTimes;
}
public get autoPlayDelay(): number {
return this._autoPlayDelay;
}
public get timeScale(): number {
return this._timeScale;
}
public set timeScale(value: number) {
if (this._timeScale !== value) {
this._timeScale = value;
if (this._playing) {
for (const item of this._items) {
if (item.tweener) {
item.tweener.setTimeScale(value);
} else if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.timeScale = value;
}
}
}
}
}
public play(
onComplete?: SimpleHandler,
times: number = 1,
delay: number = 0,
startTime: number = 0,
endTime: number = -1
): void {
this._play(onComplete || null, times, delay, startTime, endTime, false);
}
public playReverse(
onComplete?: SimpleHandler,
times: number = 1,
delay: number = 0,
startTime: number = 0,
endTime: number = -1
): void {
this._play(onComplete || null, times, delay, startTime, endTime, true);
}
public changePlayTimes(value: number): void {
this._totalTimes = value;
}
public setAutoPlay(value: boolean, times: number = -1, delay: number = 0): void {
if (this._autoPlay !== value) {
this._autoPlay = value;
this._autoPlayTimes = times;
this._autoPlayDelay = delay;
if (this._autoPlay) {
if (this._owner.onStage) {
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
}
} else {
if (!this._owner.onStage) {
this.stop(false, true);
}
}
}
}
public _play(
onComplete: SimpleHandler | null,
times: number,
delay: number,
startTime: number,
endTime: number,
reversed: boolean
): void {
this.stop(true, true);
this._totalTimes = times;
this._reversed = reversed;
this._startTime = startTime;
this._endTime = endTime;
this._playing = true;
this._paused = false;
this._onComplete = onComplete;
for (const item of this._items) {
if (!item.target) {
if (item.targetId) {
item.target = this._owner.getChildById(item.targetId) ?? undefined;
} else {
item.target = this._owner;
}
} else if (item.target !== this._owner && item.target.parent !== this._owner) {
item.target = undefined;
}
if (item.target && item.type === ETransitionActionType.Transition) {
let trans = (item.target as GComponent).getTransition(item.value.transName || '');
if (trans === this) trans = null;
if (trans) {
if (item.value.playTimes === 0) {
for (let j = this._items.indexOf(item) - 1; j >= 0; j--) {
const item2 = this._items[j];
if (item2.type === ETransitionActionType.Transition && item2.value.trans === trans) {
item2.value.stopTime = item.time - item2.time;
trans = null;
break;
}
}
if (trans) item.value.stopTime = 0;
} else {
item.value.stopTime = -1;
}
}
item.value.trans = trans ?? undefined;
}
}
if (delay === 0) {
this.onDelayedPlay();
} else {
GTween.delayedCall(delay).setTarget(this).onComplete(() => this.onDelayedPlay());
}
}
public stop(bSetToComplete: boolean = true, bProcessCallback: boolean = false): void {
if (!this._playing) return;
this._playing = false;
this._totalTasks = 0;
this._totalTimes = 0;
const handler = this._onComplete;
this._onComplete = null;
GTween.kill(this);
const cnt = this._items.length;
if (this._reversed) {
for (let i = cnt - 1; i >= 0; i--) {
const item = this._items[i];
if (item.target) this.stopItem(item, bSetToComplete);
}
} else {
for (let i = 0; i < cnt; i++) {
const item = this._items[i];
if (item.target) this.stopItem(item, bSetToComplete);
}
}
if (bProcessCallback && handler) {
if (typeof handler === 'function') handler();
else if (typeof handler.run === 'function') handler.run();
}
}
private stopItem(item: ITransitionItem, bSetToComplete: boolean): void {
if (item.tweener) {
item.tweener.kill(bSetToComplete);
item.tweener = undefined;
if (item.type === ETransitionActionType.Shake && !bSetToComplete && item.target) {
item.target.x -= item.value.lastOffsetX || 0;
item.target.y -= item.value.lastOffsetY || 0;
}
}
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.stop(bSetToComplete, false);
}
}
public pause(): void {
if (!this._playing || this._paused) return;
this._paused = true;
const tweener = GTween.getTween(this);
if (tweener) tweener.setPaused(true);
for (const item of this._items) {
if (!item.target) continue;
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.pause();
}
if (item.tweener) item.tweener.setPaused(true);
}
}
public resume(): void {
if (!this._playing || !this._paused) return;
this._paused = false;
const tweener = GTween.getTween(this);
if (tweener) tweener.setPaused(false);
for (const item of this._items) {
if (!item.target) continue;
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.resume();
}
if (item.tweener) item.tweener.setPaused(false);
}
}
public setValue(label: string, ...values: any[]): void {
for (const item of this._items) {
if (item.label === label) {
const value = item.tweenConfig ? item.tweenConfig.startValue : item.value;
this.setItemValue(item.type, value, values);
return;
} else if (item.tweenConfig?.endLabel === label) {
this.setItemValue(item.type, item.tweenConfig.endValue, values);
return;
}
}
}
private setItemValue(type: ETransitionActionType, value: ITransitionValue, args: any[]): void {
switch (type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Pivot:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
value.b1 = value.b2 = true;
value.f1 = parseFloat(args[0]);
value.f2 = parseFloat(args[1]);
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
case ETransitionActionType.Color:
value.f1 = parseFloat(args[0]);
break;
case ETransitionActionType.Animation:
value.frame = parseInt(args[0]);
if (args.length > 1) value.playing = args[1];
break;
case ETransitionActionType.Visible:
value.visible = args[0];
break;
case ETransitionActionType.Sound:
value.sound = args[0];
if (args.length > 1) value.volume = parseFloat(args[1]);
break;
case ETransitionActionType.Transition:
value.transName = args[0];
if (args.length > 1) value.playTimes = parseInt(args[1]);
break;
case ETransitionActionType.Shake:
value.amplitude = parseFloat(args[0]);
if (args.length > 1) value.duration = parseFloat(args[1]);
break;
case ETransitionActionType.ColorFilter:
value.f1 = parseFloat(args[0]);
value.f2 = parseFloat(args[1]);
value.f3 = parseFloat(args[2]);
value.f4 = parseFloat(args[3]);
break;
case ETransitionActionType.Text:
case ETransitionActionType.Icon:
value.text = args[0];
break;
}
}
public setTarget(label: string, target: GObject): void {
for (const item of this._items) {
if (item.label === label) {
item.targetId = target.id;
item.target = target;
return;
}
}
}
public setHook(label: string, callback: SimpleHandler): void {
for (const item of this._items) {
if (item.label === label) {
item.hook = callback;
return;
} else if (item.tweenConfig?.endLabel === label) {
item.tweenConfig.endHook = callback;
return;
}
}
}
public clearHooks(): void {
for (const item of this._items) {
item.hook = undefined;
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
}
}
public onOwnerAddedToStage(): void {
if (this._autoPlay && !this._playing) {
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
}
}
public onOwnerRemovedFromStage(): void {
if ((this._options & OPTION_AUTO_STOP_DISABLED) === 0) {
this.stop((this._options & OPTION_AUTO_STOP_AT_END) !== 0, false);
}
}
private onDelayedPlay(): void {
this._ownerBaseX = this._owner.x;
this._ownerBaseY = this._owner.y;
this._totalTasks = 1;
const cnt = this._items.length;
for (let i = this._reversed ? cnt - 1 : 0; this._reversed ? i >= 0 : i < cnt; this._reversed ? i-- : i++) {
const item = this._items[i];
if (item.target) this.playItem(item);
}
this._totalTasks--;
this.checkAllComplete();
}
private playItem(item: ITransitionItem): void {
let time: number;
if (item.tweenConfig) {
time = this._reversed
? this._totalDuration - item.time - item.tweenConfig.duration
: item.time;
if (this._endTime === -1 || time < this._endTime) {
const startValue = this._reversed ? item.tweenConfig.endValue : item.tweenConfig.startValue;
const endValue = this._reversed ? item.tweenConfig.startValue : item.tweenConfig.endValue;
item.value.b1 = startValue.b1;
item.value.b2 = startValue.b2;
switch (item.type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
item.tweener = GTween.to2(
startValue.f1 || 0, startValue.f2 || 0,
endValue.f1 || 0, endValue.f2 || 0,
item.tweenConfig.duration
);
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
item.tweener = GTween.to(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
break;
case ETransitionActionType.Color:
item.tweener = GTween.toColor(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
break;
case ETransitionActionType.ColorFilter:
item.tweener = GTween.to4(
startValue.f1 || 0, startValue.f2 || 0, startValue.f3 || 0, startValue.f4 || 0,
endValue.f1 || 0, endValue.f2 || 0, endValue.f3 || 0, endValue.f4 || 0,
item.tweenConfig.duration
);
break;
}
if (item.tweener) {
item.tweener
.setDelay(time)
.setEase(item.tweenConfig.easeType)
.setRepeat(item.tweenConfig.repeat, item.tweenConfig.yoyo)
.setTimeScale(this._timeScale)
.setTarget(item)
.onStart(() => this.callHook(item, false))
.onUpdate(() => this.onTweenUpdate(item))
.onComplete(() => this.onTweenComplete(item));
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - time);
this._totalTasks++;
}
}
} else if (item.type === ETransitionActionType.Shake) {
time = this._reversed
? this._totalDuration - item.time - (item.value.duration || 0)
: item.time;
item.value.offsetX = item.value.offsetY = 0;
item.value.lastOffsetX = item.value.lastOffsetY = 0;
item.tweener = GTween.shake(0, 0, item.value.amplitude || 0, item.value.duration || 0)
.setDelay(time)
.setTimeScale(this._timeScale)
.setTarget(item)
.onUpdate(() => this.onTweenUpdate(item))
.onComplete(() => this.onTweenComplete(item));
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - item.time);
this._totalTasks++;
} else {
time = this._reversed ? this._totalDuration - item.time : item.time;
if (time <= this._startTime) {
this.applyValue(item);
this.callHook(item, false);
} else if (this._endTime === -1 || time <= this._endTime) {
this._totalTasks++;
item.tweener = GTween.delayedCall(time)
.setTimeScale(this._timeScale)
.setTarget(item)
.onComplete(() => {
item.tweener = undefined;
this._totalTasks--;
this.applyValue(item);
this.callHook(item, false);
this.checkAllComplete();
});
}
}
}
private onTweenUpdate(item: ITransitionItem): void {
if (!item.tweener) return;
const tweener = item.tweener;
switch (item.type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
item.value.f1 = tweener.value.x;
item.value.f2 = tweener.value.y;
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
item.value.f1 = tweener.value.x;
break;
case ETransitionActionType.Color:
item.value.f1 = tweener.value.color;
break;
case ETransitionActionType.ColorFilter:
item.value.f1 = tweener.value.x;
item.value.f2 = tweener.value.y;
item.value.f3 = tweener.value.z;
item.value.f4 = tweener.value.w;
break;
case ETransitionActionType.Shake:
item.value.offsetX = tweener.deltaValue.x;
item.value.offsetY = tweener.deltaValue.y;
break;
}
this.applyValue(item);
}
private onTweenComplete(item: ITransitionItem): void {
item.tweener = undefined;
this._totalTasks--;
this.callHook(item, true);
this.checkAllComplete();
}
private checkAllComplete(): void {
if (this._playing && this._totalTasks === 0) {
if (this._totalTimes < 0) {
this.internalPlay();
} else {
this._totalTimes--;
if (this._totalTimes > 0) {
this.internalPlay();
} else {
this._playing = false;
const handler = this._onComplete;
this._onComplete = null;
if (handler) {
if (typeof handler === 'function') handler();
else if (typeof handler.run === 'function') handler.run();
}
}
}
}
}
private internalPlay(): void {
this._ownerBaseX = this._owner.x;
this._ownerBaseY = this._owner.y;
this._totalTasks = 1;
for (const item of this._items) {
if (item.target) this.playItem(item);
}
this._totalTasks--;
}
private callHook(item: ITransitionItem, tweenEnd: boolean): void {
const hook = tweenEnd ? item.tweenConfig?.endHook : item.hook;
if (hook) {
if (typeof hook === 'function') hook();
else if (typeof hook.run === 'function') hook.run();
}
}
private applyValue(item: ITransitionItem): void {
if (!item.target) return;
const value = item.value;
const target = item.target;
switch (item.type) {
case ETransitionActionType.XY:
if (target === this._owner) {
if (value.b1 && value.b2) target.setXY((value.f1 || 0) + this._ownerBaseX, (value.f2 || 0) + this._ownerBaseY);
else if (value.b1) target.x = (value.f1 || 0) + this._ownerBaseX;
else target.y = (value.f2 || 0) + this._ownerBaseY;
} else if (value.b3) {
if (value.b1 && value.b2) target.setXY((value.f1 || 0) * this._owner.width, (value.f2 || 0) * this._owner.height);
else if (value.b1) target.x = (value.f1 || 0) * this._owner.width;
else if (value.b2) target.y = (value.f2 || 0) * this._owner.height;
} else {
if (value.b1 && value.b2) target.setXY(value.f1 || 0, value.f2 || 0);
else if (value.b1) target.x = value.f1 || 0;
else if (value.b2) target.y = value.f2 || 0;
}
break;
case ETransitionActionType.Size:
if (!value.b1) value.f1 = target.width;
if (!value.b2) value.f2 = target.height;
target.setSize(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Pivot:
target.setPivot(value.f1 || 0, value.f2 || 0, target.pivotAsAnchor);
break;
case ETransitionActionType.Alpha:
target.alpha = value.f1 || 0;
break;
case ETransitionActionType.Rotation:
target.rotation = value.f1 || 0;
break;
case ETransitionActionType.Scale:
target.setScale(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Skew:
target.setSkew(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Visible:
target.visible = value.visible || false;
break;
case ETransitionActionType.Transition:
if (this._playing && value.trans) {
this._totalTasks++;
const startTime = this._startTime > item.time ? this._startTime - item.time : 0;
let endTime = this._endTime >= 0 ? this._endTime - item.time : -1;
if (value.stopTime !== undefined && value.stopTime >= 0 && (endTime < 0 || endTime > value.stopTime)) {
endTime = value.stopTime;
}
value.trans.timeScale = this._timeScale;
value.trans._play(() => { this._totalTasks--; this.checkAllComplete(); }, value.playTimes || 1, 0, startTime, endTime, this._reversed);
}
break;
case ETransitionActionType.Shake:
target.x = target.x - (value.lastOffsetX || 0) + (value.offsetX || 0);
target.y = target.y - (value.lastOffsetY || 0) + (value.offsetY || 0);
value.lastOffsetX = value.offsetX;
value.lastOffsetY = value.offsetY;
break;
case ETransitionActionType.Text:
target.text = value.text || '';
break;
case ETransitionActionType.Icon:
target.icon = value.text || '';
break;
}
}
public setup(buffer: ByteBuffer): void {
this.name = buffer.readS();
this._options = buffer.getInt32();
this._autoPlay = buffer.readBool();
this._autoPlayTimes = buffer.getInt32();
this._autoPlayDelay = buffer.getFloat32();
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
const dataLen = buffer.getInt16();
const curPos = buffer.position;
buffer.seek(curPos, 0);
const item: ITransitionItem = {
type: buffer.readByte() as ETransitionActionType,
time: buffer.getFloat32(),
targetId: '',
value: {},
displayLockToken: 0
};
const targetId = buffer.getInt16();
if (targetId >= 0) {
const child = this._owner.getChildAt(targetId);
item.targetId = child?.id || '';
}
item.label = buffer.readS();
if (buffer.readBool()) {
buffer.seek(curPos, 1);
item.tweenConfig = {
duration: buffer.getFloat32(),
easeType: buffer.readByte() as EEaseType,
repeat: buffer.getInt32(),
yoyo: buffer.readBool(),
startValue: {},
endValue: {},
endLabel: buffer.readS()
};
buffer.seek(curPos, 2);
this.decodeValue(item.type, buffer, item.tweenConfig.startValue);
buffer.seek(curPos, 3);
this.decodeValue(item.type, buffer, item.tweenConfig.endValue);
} else {
buffer.seek(curPos, 2);
this.decodeValue(item.type, buffer, item.value);
}
this._items.push(item);
buffer.position = curPos + dataLen;
}
this._totalDuration = 0;
for (const item of this._items) {
let duration = item.time;
if (item.tweenConfig) duration += item.tweenConfig.duration * (item.tweenConfig.repeat + 1);
else if (item.type === ETransitionActionType.Shake) duration += item.value.duration || 0;
if (duration > this._totalDuration) this._totalDuration = duration;
}
}
private decodeValue(type: ETransitionActionType, buffer: ByteBuffer, value: ITransitionValue): void {
switch (type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Pivot:
case ETransitionActionType.Skew:
value.b1 = buffer.readBool();
value.b2 = buffer.readBool();
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
if (buffer.version >= 2 && type === ETransitionActionType.XY) value.b3 = buffer.readBool();
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
value.f1 = buffer.getFloat32();
break;
case ETransitionActionType.Scale:
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
break;
case ETransitionActionType.Color:
value.f1 = buffer.readColor();
break;
case ETransitionActionType.Animation:
value.playing = buffer.readBool();
value.frame = buffer.getInt32();
break;
case ETransitionActionType.Visible:
value.visible = buffer.readBool();
break;
case ETransitionActionType.Sound:
value.sound = buffer.readS();
value.volume = buffer.getFloat32();
break;
case ETransitionActionType.Transition:
value.transName = buffer.readS();
value.playTimes = buffer.getInt32();
break;
case ETransitionActionType.Shake:
value.amplitude = buffer.getFloat32();
value.duration = buffer.getFloat32();
break;
case ETransitionActionType.ColorFilter:
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
value.f3 = buffer.getFloat32();
value.f4 = buffer.getFloat32();
break;
case ETransitionActionType.Text:
case ETransitionActionType.Icon:
value.text = buffer.readS();
break;
}
}
public dispose(): void {
if (this._playing) GTween.kill(this);
for (const item of this._items) {
if (item.tweener) {
item.tweener.kill();
item.tweener = undefined;
}
item.target = undefined;
item.hook = undefined;
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
}
this._items.length = 0;
super.dispose();
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* UIConfig
*
* Global configuration for FairyGUI system.
* Centralizes all configurable settings.
*
* FairyGUI
*/
export const UIConfig = {
/** Default font | 默认字体 */
defaultFont: 'Arial',
/** Default font size | 默认字体大小 */
defaultFontSize: 14,
/** Button sound URL | 按钮声音 URL */
buttonSound: '',
/** Button sound volume scale | 按钮声音音量 */
buttonSoundVolumeScale: 1,
/** Horizontal scrollbar resource | 水平滚动条资源 */
horizontalScrollBar: '',
/** Vertical scrollbar resource | 垂直滚动条资源 */
verticalScrollBar: '',
/** Default scroll step | 默认滚动步进 */
defaultScrollStep: 25,
/** Default touch scroll | 默认触摸滚动 */
defaultTouchScroll: true,
/** Default scroll bounce | 默认滚动回弹 */
defaultScrollBounce: true,
/** Default scroll bar display | 默认滚动条显示 */
defaultScrollBarDisplay: 1,
/** Touch drag sensitivity | 触摸拖拽灵敏度 */
touchDragSensitivity: 10,
/** Click drag sensitivity | 点击拖拽灵敏度 */
clickDragSensitivity: 2,
/** Allow softness on top | 允许顶部弹性 */
allowSoftnessOnTopOrLeftSide: true,
/** Global modal layer resource | 全局模态层资源 */
modalLayerResource: '',
/** Modal layer color | 模态层颜色 */
modalLayerColor: 0x333333,
/** Modal layer alpha | 模态层透明度 */
modalLayerAlpha: 0.4,
/** Popup close on click outside | 点击外部关闭弹窗 */
popupCloseOnClickOutside: true,
/** Branch for resource loading | 资源加载分支 */
branch: '',
/** Loading animation resource | 加载动画资源 */
loadingAnimation: '',
/** Loader error sign resource | 加载器错误标志资源 */
loaderErrorSign: '',
/** Popup menu resource | 弹出菜单资源 */
popupMenu: '',
/** Popup menu separator resource | 弹出菜单分隔符资源 */
popupMenuSeperator: '',
/** Window modal waiting resource | 窗口模态等待资源 */
windowModalWaiting: '',
/** Bring window to front on click | 点击时将窗口置顶 */
bringWindowToFrontOnClick: true
} as const;
/**
* Mutable config type for runtime changes
*
*/
export type UIConfigType = {
-readonly [K in keyof typeof UIConfig]: (typeof UIConfig)[K];
};
/** Runtime config instance | 运行时配置实例 */
const _runtimeConfig: UIConfigType = { ...UIConfig };
/**
* Get current config value
*
*/
export function getUIConfig<K extends keyof UIConfigType>(key: K): UIConfigType[K] {
return _runtimeConfig[key];
}
/**
* Set config value
*
*/
export function setUIConfig<K extends keyof UIConfigType>(key: K, value: UIConfigType[K]): void {
_runtimeConfig[key] = value;
}
/**
* Reset config to defaults
*
*/
export function resetUIConfig(): void {
Object.assign(_runtimeConfig, UIConfig);
}
@@ -0,0 +1,184 @@
import { GObject } from './GObject';
import { EObjectType } from './FieldTypes';
import type { PackageItem } from '../package/PackageItem';
/**
* Object creator function type
*
*/
export type ObjectCreator = () => GObject;
/**
* Extension creator function type
*
*/
export type ExtensionCreator = () => GObject;
/**
* UIObjectFactory
*
* Factory for creating FairyGUI objects.
* All object types are registered via registerCreator() to avoid circular dependencies.
*
* FairyGUI registerCreator()
*/
export class UIObjectFactory {
private static _creators: Map<EObjectType, ObjectCreator> = new Map();
private static _extensions: Map<string, ExtensionCreator> = new Map();
/**
* Register a creator for an object type
*
*/
public static registerCreator(type: EObjectType, creator: ObjectCreator): void {
UIObjectFactory._creators.set(type, creator);
}
/**
* Register an extension creator for a URL
*
*/
public static registerExtension(url: string, creator: ExtensionCreator): void {
UIObjectFactory._extensions.set(url, creator);
}
/**
* Check if extension exists for URL
* URL
*/
public static hasExtension(url: string): boolean {
return UIObjectFactory._extensions.has(url);
}
/**
* Create object by type
*
*/
public static createObject(type: EObjectType, _userClass?: new () => GObject): GObject | null {
const creator = UIObjectFactory._creators.get(type);
if (creator) {
const obj = creator();
return obj;
}
// Fallback for component-based types
switch (type) {
case EObjectType.Component:
case EObjectType.Label:
case EObjectType.ComboBox:
case EObjectType.List:
case EObjectType.Tree:
case EObjectType.ScrollBar:
case EObjectType.MovieClip:
case EObjectType.Swf:
case EObjectType.Loader:
case EObjectType.Loader3D:
// Use Component creator if specific creator not registered
const componentCreator = UIObjectFactory._creators.get(EObjectType.Component);
if (componentCreator) {
const obj = componentCreator();
return obj;
}
break;
}
return new GObject();
}
/**
* Create new object by type (number)
*
*/
public static newObject(type: number): GObject;
/**
* Create new object from package item
*
*/
public static newObject(item: PackageItem): GObject;
public static newObject(arg: number | PackageItem): GObject {
if (typeof arg === 'number') {
const obj = UIObjectFactory.createObject(arg as EObjectType) || new GObject();
return obj;
} else {
const item = arg as PackageItem;
// Check for extension
if (item.owner) {
const url = 'ui://' + item.owner.id + item.id;
const extensionCreator = UIObjectFactory._extensions.get(url);
if (extensionCreator) {
const obj = extensionCreator();
obj.packageItem = item;
return obj;
}
// Also check by name
const urlByName = 'ui://' + item.owner.name + '/' + item.name;
const extensionCreatorByName = UIObjectFactory._extensions.get(urlByName);
if (extensionCreatorByName) {
const obj = extensionCreatorByName();
obj.packageItem = item;
return obj;
}
}
const obj = UIObjectFactory.createObject(item.objectType);
if (obj) {
obj.packageItem = item;
}
return obj || new GObject();
}
}
/**
* Create object from package item
*
*/
public static createObjectFromItem(item: PackageItem): GObject | null {
const obj = UIObjectFactory.createObject(item.objectType);
if (obj) {
obj.packageItem = item;
obj.constructFromResource();
}
return obj;
}
/**
* Create object from URL with extension support
* URL
*/
public static createObjectFromURL(url: string): GObject | null {
const extensionCreator = UIObjectFactory._extensions.get(url);
if (extensionCreator) {
return extensionCreator();
}
return null;
}
/**
* Resolve package item extension
*
*/
public static resolvePackageItemExtension(item: PackageItem): void {
if (!item.owner) return;
const url = 'ui://' + item.owner.id + item.id;
if (UIObjectFactory._extensions.has(url)) {
return;
}
const urlByName = 'ui://' + item.owner.name + '/' + item.name;
if (UIObjectFactory._extensions.has(urlByName)) {
return;
}
}
/**
* Clear all registered creators and extensions
*
*/
public static clear(): void {
UIObjectFactory._creators.clear();
UIObjectFactory._extensions.clear();
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* FairyGUI Module Initialization
*
* This module registers all object type creators with UIObjectFactory.
* It must be imported after all classes are defined to break circular dependencies.
*
* FairyGUI
*/
import { UIObjectFactory } from './UIObjectFactory';
import { EObjectType } from './FieldTypes';
import { GGroup } from './GGroup';
import { GComponent } from './GComponent';
import { GImage } from '../widgets/GImage';
import { GGraph } from '../widgets/GGraph';
import { GTextField } from '../widgets/GTextField';
import { GTextInput } from '../widgets/GTextInput';
import { GButton } from '../widgets/GButton';
import { GProgressBar } from '../widgets/GProgressBar';
import { GSlider } from '../widgets/GSlider';
import { GMovieClip } from '../widgets/GMovieClip';
import { GLoader } from '../widgets/GLoader';
// Register all object type creators
UIObjectFactory.registerCreator(EObjectType.Image, () => new GImage());
UIObjectFactory.registerCreator(EObjectType.Graph, () => new GGraph());
UIObjectFactory.registerCreator(EObjectType.Text, () => new GTextField());
UIObjectFactory.registerCreator(EObjectType.RichText, () => new GTextField());
UIObjectFactory.registerCreator(EObjectType.InputText, () => new GTextInput());
UIObjectFactory.registerCreator(EObjectType.Group, () => new GGroup());
UIObjectFactory.registerCreator(EObjectType.Component, () => new GComponent());
UIObjectFactory.registerCreator(EObjectType.Button, () => new GButton());
UIObjectFactory.registerCreator(EObjectType.ProgressBar, () => new GProgressBar());
UIObjectFactory.registerCreator(EObjectType.Slider, () => new GSlider());
UIObjectFactory.registerCreator(EObjectType.MovieClip, () => new GMovieClip());
UIObjectFactory.registerCreator(EObjectType.Loader, () => new GLoader());
// Component-based types use GComponent as fallback (registered above)
// Label, ComboBox, List, Tree, ScrollBar, Swf, Loader3D
@@ -0,0 +1,35 @@
import { DisplayObject } from './DisplayObject';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* Container
*
* A concrete DisplayObject that can contain children but has no visual content itself.
* Used as the display object for GComponent.
*
* DisplayObject
* GComponent
*/
export class Container extends DisplayObject {
constructor() {
super();
}
/**
* Collect render data from children
*
*/
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible) return;
// Update transform before collecting render data
// 收集渲染数据前更新变换
this.updateTransform();
// Collect render data from all children
// 从所有子对象收集渲染数据
for (const child of this._children) {
child.collectRenderData(collector);
}
}
}
@@ -0,0 +1,638 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { FGUIEvents } from '../events/Events';
import { Point, Rectangle } from '../utils/MathTypes';
import type { IRenderCollector } from '../render/IRenderCollector';
import type { GObject } from '../core/GObject';
/**
* DisplayObject
*
* Abstract display object base class for all visual elements.
*
*
*/
export abstract class DisplayObject extends EventDispatcher {
/** Name of this display object | 显示对象名称 */
public name: string = '';
// Transform properties | 变换属性
protected _x: number = 0;
protected _y: number = 0;
protected _width: number = 0;
protected _height: number = 0;
protected _scaleX: number = 1;
protected _scaleY: number = 1;
protected _rotation: number = 0;
protected _pivotX: number = 0;
protected _pivotY: number = 0;
protected _skewX: number = 0;
protected _skewY: number = 0;
// Display properties | 显示属性
protected _alpha: number = 1;
protected _visible: boolean = true;
protected _touchable: boolean = true;
protected _grayed: boolean = false;
// Hierarchy | 层级关系
protected _parent: DisplayObject | null = null;
protected _children: DisplayObject[] = [];
// Stage reference | 舞台引用
protected _stage: DisplayObject | null = null;
// Dirty flags | 脏标记
protected _transformDirty: boolean = true;
protected _boundsDirty: boolean = true;
// Cached values | 缓存值
protected _worldAlpha: number = 1;
protected _worldMatrix: Float32Array = new Float32Array([1, 0, 0, 1, 0, 0]);
protected _bounds: Rectangle = new Rectangle();
// User data | 用户数据
public userData: unknown = null;
/** Owner GObject reference | 所属 GObject 引用 */
public gOwner: GObject | null = null;
constructor() {
super();
}
// Position | 位置
public get x(): number {
return this._x;
}
public set x(value: number) {
if (this._x !== value) {
this._x = value;
this.markTransformDirty();
}
}
public get y(): number {
return this._y;
}
public set y(value: number) {
if (this._y !== value) {
this._y = value;
this.markTransformDirty();
}
}
public setPosition(x: number, y: number): void {
if (this._x !== x || this._y !== y) {
this._x = x;
this._y = y;
this.markTransformDirty();
}
}
// Size | 尺寸
public get width(): number {
return this._width;
}
public set width(value: number) {
if (this._width !== value) {
this._width = value;
this.markBoundsDirty();
}
}
public get height(): number {
return this._height;
}
public set height(value: number) {
if (this._height !== value) {
this._height = value;
this.markBoundsDirty();
}
}
public setSize(width: number, height: number): void {
if (this._width !== width || this._height !== height) {
this._width = width;
this._height = height;
this.markBoundsDirty();
}
}
// Scale | 缩放
public get scaleX(): number {
return this._scaleX;
}
public set scaleX(value: number) {
if (this._scaleX !== value) {
this._scaleX = value;
this.markTransformDirty();
}
}
public get scaleY(): number {
return this._scaleY;
}
public set scaleY(value: number) {
if (this._scaleY !== value) {
this._scaleY = value;
this.markTransformDirty();
}
}
public setScale(scaleX: number, scaleY: number): void {
if (this._scaleX !== scaleX || this._scaleY !== scaleY) {
this._scaleX = scaleX;
this._scaleY = scaleY;
this.markTransformDirty();
}
}
// Rotation | 旋转
public get rotation(): number {
return this._rotation;
}
public set rotation(value: number) {
if (this._rotation !== value) {
this._rotation = value;
this.markTransformDirty();
}
}
// Pivot | 轴心点
public get pivotX(): number {
return this._pivotX;
}
public set pivotX(value: number) {
if (this._pivotX !== value) {
this._pivotX = value;
this.markTransformDirty();
}
}
public get pivotY(): number {
return this._pivotY;
}
public set pivotY(value: number) {
if (this._pivotY !== value) {
this._pivotY = value;
this.markTransformDirty();
}
}
public setPivot(pivotX: number, pivotY: number): void {
if (this._pivotX !== pivotX || this._pivotY !== pivotY) {
this._pivotX = pivotX;
this._pivotY = pivotY;
this.markTransformDirty();
}
}
// Skew | 倾斜
public get skewX(): number {
return this._skewX;
}
public set skewX(value: number) {
if (this._skewX !== value) {
this._skewX = value;
this.markTransformDirty();
}
}
public get skewY(): number {
return this._skewY;
}
public set skewY(value: number) {
if (this._skewY !== value) {
this._skewY = value;
this.markTransformDirty();
}
}
// Alpha | 透明度
public get alpha(): number {
return this._alpha;
}
public set alpha(value: number) {
if (this._alpha !== value) {
this._alpha = value;
}
}
// Visibility | 可见性
public get visible(): boolean {
return this._visible;
}
public set visible(value: boolean) {
this._visible = value;
}
// Touchable | 可触摸
public get touchable(): boolean {
return this._touchable;
}
public set touchable(value: boolean) {
this._touchable = value;
}
// Grayed | 灰度
public get grayed(): boolean {
return this._grayed;
}
public set grayed(value: boolean) {
this._grayed = value;
}
// Hierarchy | 层级
public get parent(): DisplayObject | null {
return this._parent;
}
/**
* Get stage reference
*
*/
public get stage(): DisplayObject | null {
return this._stage;
}
/**
* Set stage reference (internal use)
* 使
*
* @internal
*/
public setStage(stage: DisplayObject | null): void {
this._stage = stage;
}
public get numChildren(): number {
return this._children.length;
}
/**
* Add a child display object
*
*/
public addChild(child: DisplayObject): void {
this.addChildAt(child, this._children.length);
}
/**
* Add a child at specific index
*
*/
public addChildAt(child: DisplayObject, index: number): void {
if (child._parent === this) {
this.setChildIndex(child, index);
return;
}
if (child._parent) {
child._parent.removeChild(child);
}
index = Math.max(0, Math.min(index, this._children.length));
this._children.splice(index, 0, child);
child._parent = this;
child.markTransformDirty();
// Dispatch addedToStage event if this is on stage
// 如果当前对象在舞台上,分发 addedToStage 事件
if (this._stage !== null) {
this.setChildStage(child, this._stage);
}
}
/**
* Set stage for child and its descendants, dispatch events
*
*/
private setChildStage(child: DisplayObject, stage: DisplayObject | null): void {
const wasOnStage = child._stage !== null;
const isOnStage = stage !== null;
child._stage = stage;
if (!wasOnStage && isOnStage) {
// Dispatch addedToStage event
child.emit(FGUIEvents.ADDED_TO_STAGE);
} else if (wasOnStage && !isOnStage) {
// Dispatch removedFromStage event
child.emit(FGUIEvents.REMOVED_FROM_STAGE);
}
// Recursively set stage for all children
for (const grandChild of child._children) {
this.setChildStage(grandChild, stage);
}
}
/**
* Remove a child display object
*
*/
public removeChild(child: DisplayObject): void {
const index = this._children.indexOf(child);
if (index >= 0) {
this.removeChildAt(index);
}
}
/**
* Remove child at specific index
*
*/
public removeChildAt(index: number): DisplayObject | null {
if (index < 0 || index >= this._children.length) {
return null;
}
const child = this._children[index];
// Dispatch removedFromStage event if on stage
// 如果在舞台上,分发 removedFromStage 事件
if (this._stage !== null) {
this.setChildStage(child, null);
}
this._children.splice(index, 1);
child._parent = null;
return child;
}
/**
* Remove all children
*
*/
public removeChildren(): void {
// Dispatch removedFromStage events if on stage
// 如果在舞台上,分发 removedFromStage 事件
if (this._stage !== null) {
for (const child of this._children) {
this.setChildStage(child, null);
}
}
for (const child of this._children) {
child._parent = null;
}
this._children.length = 0;
}
/**
* Get child at index
*
*/
public getChildAt(index: number): DisplayObject | null {
if (index < 0 || index >= this._children.length) {
return null;
}
return this._children[index];
}
/**
* Get child index
*
*/
public getChildIndex(child: DisplayObject): number {
return this._children.indexOf(child);
}
/**
* Set child index
*
*/
public setChildIndex(child: DisplayObject, index: number): void {
const currentIndex = this._children.indexOf(child);
if (currentIndex < 0) return;
index = Math.max(0, Math.min(index, this._children.length - 1));
if (currentIndex === index) return;
this._children.splice(currentIndex, 1);
this._children.splice(index, 0, child);
}
/**
* Swap two children
*
*/
public swapChildren(child1: DisplayObject, child2: DisplayObject): void {
const index1 = this._children.indexOf(child1);
const index2 = this._children.indexOf(child2);
if (index1 >= 0 && index2 >= 0) {
this._children[index1] = child2;
this._children[index2] = child1;
}
}
/**
* Get child by name
*
*/
public getChildByName(name: string): DisplayObject | null {
for (const child of this._children) {
if (child.name === name) {
return child;
}
}
return null;
}
// Transform | 变换
/**
* Update world matrix
*
*
* World matrix is in FGUI's coordinate system (top-left origin, Y-down).
* Coordinate system conversion to engine (center origin, Y-up) is done in FGUIRenderDataProvider.
*
* 使 FGUI Y
* Y FGUIRenderDataProvider
*/
public updateTransform(): void {
if (!this._transformDirty) return;
const m = this._worldMatrix;
const rad = (this._rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
m[0] = cos * this._scaleX;
m[1] = sin * this._scaleX;
m[2] = -sin * this._scaleY;
m[3] = cos * this._scaleY;
// Keep FGUI's coordinate system (top-left origin, Y-down)
// 保持 FGUI 坐标系(左上角原点,Y 向下)
m[4] = this._x - this._pivotX * m[0] - this._pivotY * m[2];
m[5] = this._y - this._pivotX * m[1] - this._pivotY * m[3];
if (this._parent) {
const pm = this._parent._worldMatrix;
const a = m[0], b = m[1], c = m[2], d = m[3], tx = m[4], ty = m[5];
m[0] = a * pm[0] + b * pm[2];
m[1] = a * pm[1] + b * pm[3];
m[2] = c * pm[0] + d * pm[2];
m[3] = c * pm[1] + d * pm[3];
m[4] = tx * pm[0] + ty * pm[2] + pm[4];
m[5] = tx * pm[1] + ty * pm[3] + pm[5];
this._worldAlpha = this._alpha * this._parent._worldAlpha;
} else {
this._worldAlpha = this._alpha;
}
this._transformDirty = false;
for (const child of this._children) {
child.markTransformDirty();
child.updateTransform();
}
}
/**
* Local to global point conversion
*
*/
public localToGlobal(localPoint: Point, outPoint?: Point): Point {
this.updateTransform();
outPoint = outPoint || new Point();
const m = this._worldMatrix;
outPoint.x = localPoint.x * m[0] + localPoint.y * m[2] + m[4];
outPoint.y = localPoint.x * m[1] + localPoint.y * m[3] + m[5];
return outPoint;
}
/**
* Global to local point conversion
*
*/
public globalToLocal(globalPoint: Point, outPoint?: Point): Point {
this.updateTransform();
outPoint = outPoint || new Point();
const m = this._worldMatrix;
const det = m[0] * m[3] - m[1] * m[2];
if (det === 0) {
outPoint.x = 0;
outPoint.y = 0;
} else {
const invDet = 1 / det;
const x = globalPoint.x - m[4];
const y = globalPoint.y - m[5];
outPoint.x = (x * m[3] - y * m[2]) * invDet;
outPoint.y = (y * m[0] - x * m[1]) * invDet;
}
return outPoint;
}
/**
* Hit test
*
*/
public hitTest(globalX: number, globalY: number): DisplayObject | null {
if (!this._visible || !this._touchable) {
return null;
}
const localPoint = this.globalToLocal(new Point(globalX, globalY));
if (
localPoint.x >= 0 &&
localPoint.x < this._width &&
localPoint.y >= 0 &&
localPoint.y < this._height
) {
for (let i = this._children.length - 1; i >= 0; i--) {
const hit = this._children[i].hitTest(globalX, globalY);
if (hit) return hit;
}
return this;
}
return null;
}
// Dirty flags | 脏标记
protected markTransformDirty(): void {
this._transformDirty = true;
this._boundsDirty = true;
}
protected markBoundsDirty(): void {
this._boundsDirty = true;
}
// Render data collection | 渲染数据收集
/**
* Collect render data (abstract - implemented by subclasses)
* -
*/
public abstract collectRenderData(collector: IRenderCollector): void;
/**
* Get world matrix
*
*/
public get worldMatrix(): Float32Array {
return this._worldMatrix;
}
/**
* Get world alpha
*
*/
public get worldAlpha(): number {
return this._worldAlpha;
}
/**
* Dispose
*
*/
public dispose(): void {
if (this._parent) {
this._parent.removeChild(this);
}
for (const child of this._children) {
child.dispose();
}
this._children.length = 0;
super.dispose();
}
}
+173
View File
@@ -0,0 +1,173 @@
import { DisplayObject } from './DisplayObject';
import { EGraphType } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* Graph
*
* Display object for rendering geometric shapes.
*
*
*/
export class Graph extends DisplayObject {
/** Graph type | 图形类型 */
private _type: EGraphType = EGraphType.Empty;
/** Line width | 线宽 */
public lineSize: number = 1;
/** Line color | 线颜色 */
public lineColor: string = '#000000';
/** Fill color | 填充颜色 */
public fillColor: string = '#FFFFFF';
/** Corner radius for rect | 矩形圆角半径 */
public cornerRadius: number[] | null = null;
/** Polygon points | 多边形顶点 */
public polygonPoints: number[] | null = null;
/** Number of sides for regular polygon | 正多边形边数 */
public sides: number = 3;
/** Start angle for regular polygon | 正多边形起始角度 */
public startAngle: number = 0;
/** Distance multipliers for regular polygon | 正多边形距离乘数 */
public distances: number[] | null = null;
constructor() {
super();
}
/**
* Get graph type
*
*/
public get type(): EGraphType {
return this._type;
}
/**
* Draw rectangle
*
*/
public drawRect(lineSize: number, lineColor: string, fillColor: string, cornerRadius?: number[]): void {
this._type = EGraphType.Rect;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.cornerRadius = cornerRadius || null;
}
/**
* Draw ellipse
*
*/
public drawEllipse(lineSize: number, lineColor: string, fillColor: string): void {
this._type = EGraphType.Ellipse;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
}
/**
* Draw polygon
*
*/
public drawPolygon(lineSize: number, lineColor: string, fillColor: string, points: number[]): void {
this._type = EGraphType.Polygon;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.polygonPoints = points;
}
/**
* Draw regular polygon
*
*/
public drawRegularPolygon(
lineSize: number,
lineColor: string,
fillColor: string,
sides: number,
startAngle?: number,
distances?: number[]
): void {
this._type = EGraphType.RegularPolygon;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.sides = sides;
this.startAngle = startAngle || 0;
this.distances = distances || null;
}
/**
* Clear graph
*
*/
public clear(): void {
this._type = EGraphType.Empty;
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* u320xRRGGBBAA
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0xFFFFFFFF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || this._type === EGraphType.Empty) return;
this.updateTransform();
const fillColorNum = this.parseColor(this.fillColor);
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Graph,
sortOrder: 0,
worldMatrix: this._worldMatrix,
width: this._width,
height: this._height,
alpha: this._worldAlpha,
grayed: this._grayed,
graphType: this._type,
lineSize: this.lineSize,
lineColor: this.parseColor(this.lineColor),
fillColor: fillColorNum,
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.cornerRadius) {
primitive.cornerRadius = this.cornerRadius;
}
if (this._type === EGraphType.Polygon && this.polygonPoints) {
primitive.polygonPoints = this.polygonPoints;
}
if (this._type === EGraphType.RegularPolygon) {
primitive.sides = this.sides;
primitive.startAngle = this.startAngle;
if (this.distances) {
primitive.distances = this.distances;
}
}
collector.addPrimitive(primitive);
}
}
+201
View File
@@ -0,0 +1,201 @@
import { DisplayObject } from './DisplayObject';
import { Rectangle } from '../utils/MathTypes';
import { EFillMethod, EFillOrigin } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* Sprite texture info from FairyGUI package
* FairyGUI
*/
export interface ISpriteTexture {
atlas: string;
atlasId: string;
rect: { x: number; y: number; width: number; height: number };
offset: { x: number; y: number };
originalSize: { x: number; y: number };
rotated: boolean;
/** Atlas width for UV calculation | 图集宽度,用于 UV 计算 */
atlasWidth: number;
/** Atlas height for UV calculation | 图集高度,用于 UV 计算 */
atlasHeight: number;
}
/**
* Image
*
* Display object for rendering images/textures.
*
* /
*/
export class Image extends DisplayObject {
/** Texture ID, key, or sprite info | 纹理 ID、键或精灵信息 */
public texture: string | number | ISpriteTexture | null = null;
/** Tint color (hex string like "#FFFFFF") | 着色颜色 */
public color: string = '#FFFFFF';
/** Scale9 grid for 9-slice scaling | 九宫格缩放 */
public scale9Grid: Rectangle | null = null;
/** Scale by tile | 平铺缩放 */
public scaleByTile: boolean = false;
/** Tile grid indice | 平铺网格索引 */
public tileGridIndice: number = 0;
// Fill properties | 填充属性
private _fillMethod: EFillMethod = EFillMethod.None;
private _fillOrigin: EFillOrigin = EFillOrigin.Top;
private _fillClockwise: boolean = true;
private _fillAmount: number = 1;
constructor() {
super();
}
public get fillMethod(): EFillMethod {
return this._fillMethod;
}
public set fillMethod(value: EFillMethod) {
this._fillMethod = value;
}
public get fillOrigin(): EFillOrigin {
return this._fillOrigin;
}
public set fillOrigin(value: EFillOrigin) {
this._fillOrigin = value;
}
public get fillClockwise(): boolean {
return this._fillClockwise;
}
public set fillClockwise(value: boolean) {
this._fillClockwise = value;
}
public get fillAmount(): number {
return this._fillAmount;
}
public set fillAmount(value: number) {
this._fillAmount = Math.max(0, Math.min(1, value));
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* u320xRRGGBBAA
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0xFFFFFFFF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || !this.texture) return;
this.updateTransform();
// Determine texture ID, UV rect, and draw rect based on texture type
let textureId: string | number;
let uvRect: [number, number, number, number] | undefined;
let drawWidth = this._width;
let drawHeight = this._height;
let drawOffsetX = 0;
let drawOffsetY = 0;
if (typeof this.texture === 'object') {
// ISpriteTexture - use atlas file as texture ID
const sprite = this.texture as ISpriteTexture;
textureId = sprite.atlas;
// Calculate normalized UV from sprite rect and atlas dimensions
const atlasW = sprite.atlasWidth || 1;
const atlasH = sprite.atlasHeight || 1;
const u0 = sprite.rect.x / atlasW;
const v0 = sprite.rect.y / atlasH;
const u1 = (sprite.rect.x + sprite.rect.width) / atlasW;
const v1 = (sprite.rect.y + sprite.rect.height) / atlasH;
uvRect = [u0, v0, u1, v1];
// Handle trimmed sprites (offset and originalSize)
// 处理裁剪过的精灵(偏移和原始尺寸)
const origW = sprite.originalSize.x;
const origH = sprite.originalSize.y;
const regionW = sprite.rect.width;
const regionH = sprite.rect.height;
if (origW !== regionW || origH !== regionH) {
// Sprite was trimmed, calculate actual draw rect
// 精灵被裁剪过,计算实际绘制矩形
const sx = this._width / origW;
const sy = this._height / origH;
drawOffsetX = sprite.offset.x * sx;
drawOffsetY = sprite.offset.y * sy;
drawWidth = regionW * sx;
drawHeight = regionH * sy;
}
} else {
textureId = this.texture;
}
// Create adjusted world matrix if there's an offset
let worldMatrix = this._worldMatrix;
if (drawOffsetX !== 0 || drawOffsetY !== 0) {
// Apply offset to the world matrix translation
// 将偏移应用到世界矩阵的平移部分
worldMatrix = new Float32Array(this._worldMatrix);
const m = this._worldMatrix;
// Transform offset by rotation/scale part of matrix
worldMatrix[4] = m[4] + drawOffsetX * m[0] + drawOffsetY * m[2];
worldMatrix[5] = m[5] + drawOffsetX * m[1] + drawOffsetY * m[3];
}
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Image,
sortOrder: 0,
worldMatrix,
width: drawWidth,
height: drawHeight,
alpha: this._worldAlpha,
grayed: this._grayed,
textureId,
uvRect,
color: this.parseColor(this.color),
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.scale9Grid) {
primitive.scale9Grid = this.scale9Grid;
// Pass source dimensions for nine-slice calculation
// 传递源尺寸用于九宫格计算
if (typeof this.texture === 'object') {
const sprite = this.texture as ISpriteTexture;
primitive.sourceWidth = sprite.rect.width;
primitive.sourceHeight = sprite.rect.height;
} else {
// For non-sprite textures, use the display object's original size
// 对于非精灵纹理,使用显示对象的原始尺寸
primitive.sourceWidth = this._width;
primitive.sourceHeight = this._height;
}
}
if (this.scaleByTile) {
primitive.tileMode = true;
}
collector.addPrimitive(primitive);
}
}
@@ -0,0 +1,341 @@
import { TextField } from './TextField';
/**
* InputTextField
*
* Editable text input display object.
* Creates and manages a hidden HTML input element for text editing.
*
*
* HTML input
*/
export class InputTextField extends TextField {
private _inputElement: HTMLInputElement | HTMLTextAreaElement | null = null;
private _password: boolean = false;
private _keyboardType: string = 'text';
private _editable: boolean = true;
private _maxLength: number = 0;
private _promptText: string = '';
private _promptColor: string = '#999999';
private _restrict: string = '';
private _multiline: boolean = false;
private _hasFocus: boolean = false;
constructor() {
super();
this.touchable = true;
}
/**
* Get/set password mode
* /
*/
public get password(): boolean {
return this._password;
}
public set password(value: boolean) {
if (this._password !== value) {
this._password = value;
this.updateInputType();
}
}
/**
* Get/set keyboard type
* /
*/
public get keyboardType(): string {
return this._keyboardType;
}
public set keyboardType(value: string) {
if (this._keyboardType !== value) {
this._keyboardType = value;
this.updateInputType();
}
}
/**
* Get/set editable state
* /
*/
public get editable(): boolean {
return this._editable;
}
public set editable(value: boolean) {
this._editable = value;
if (this._inputElement) {
if (value) {
this._inputElement.removeAttribute('readonly');
} else {
this._inputElement.setAttribute('readonly', 'true');
}
}
}
/**
* Get/set max length
* /
*/
public get maxLength(): number {
return this._maxLength;
}
public set maxLength(value: number) {
this._maxLength = value;
if (this._inputElement && value > 0) {
this._inputElement.maxLength = value;
}
}
/**
* Get/set placeholder text
* /
*/
public get promptText(): string {
return this._promptText;
}
public set promptText(value: string) {
this._promptText = value;
if (this._inputElement) {
this._inputElement.placeholder = value;
}
}
/**
* Get/set placeholder color
* /
*/
public get promptColor(): string {
return this._promptColor;
}
public set promptColor(value: string) {
this._promptColor = value;
// Apply via CSS
}
/**
* Get/set character restriction pattern
* /
*/
public get restrict(): string {
return this._restrict;
}
public set restrict(value: string) {
this._restrict = value;
if (this._inputElement && value && this._inputElement instanceof HTMLInputElement) {
this._inputElement.pattern = value;
}
}
/**
* Get/set multiline mode
* /
*/
public get multiline(): boolean {
return this._multiline;
}
public set multiline(value: boolean) {
if (this._multiline !== value) {
this._multiline = value;
this.recreateInputElement();
}
}
/**
* Request focus
*
*/
public focus(): void {
this.ensureInputElement();
if (this._inputElement) {
this._inputElement.focus();
this._hasFocus = true;
}
}
/**
* Clear focus
*
*/
public blur(): void {
if (this._inputElement) {
this._inputElement.blur();
this._hasFocus = false;
}
}
/**
* Select all text
*
*/
public selectAll(): void {
if (this._inputElement) {
this._inputElement.select();
}
}
/**
* Set selection range
*
*/
public setSelection(start: number, end: number): void {
if (this._inputElement) {
this._inputElement.setSelectionRange(start, end);
}
}
/**
* Get text from input
*
*/
public getInputText(): string {
if (this._inputElement) {
return this._inputElement.value;
}
return this.text;
}
/**
* Set text to input
*
*/
public setInputText(value: string): void {
this.text = value;
if (this._inputElement) {
this._inputElement.value = value;
}
}
private ensureInputElement(): void {
if (!this._inputElement) {
this.createInputElement();
}
}
private createInputElement(): void {
if (this._multiline) {
this._inputElement = document.createElement('textarea');
} else {
this._inputElement = document.createElement('input');
this.updateInputType();
}
this.applyInputStyles();
this.bindInputEvents();
document.body.appendChild(this._inputElement);
}
private recreateInputElement(): void {
const oldValue = this._inputElement?.value || '';
this.destroyInputElement();
this.createInputElement();
if (this._inputElement) {
this._inputElement.value = oldValue;
}
}
private destroyInputElement(): void {
if (this._inputElement) {
this._inputElement.remove();
this._inputElement = null;
}
}
private updateInputType(): void {
if (this._inputElement && this._inputElement instanceof HTMLInputElement) {
if (this._password) {
this._inputElement.type = 'password';
} else {
this._inputElement.type = this._keyboardType;
}
}
}
private applyInputStyles(): void {
if (!this._inputElement) return;
const style = this._inputElement.style;
style.position = 'absolute';
style.border = 'none';
style.outline = 'none';
style.background = 'transparent';
style.padding = '0';
style.margin = '0';
style.fontFamily = this.font || 'sans-serif';
style.fontSize = `${this.fontSize}px`;
style.color = this.color;
style.opacity = '0'; // Hidden initially, shown when focused
if (this._maxLength > 0) {
this._inputElement.maxLength = this._maxLength;
}
if (this._promptText) {
this._inputElement.placeholder = this._promptText;
}
if (this._restrict && this._inputElement instanceof HTMLInputElement) {
this._inputElement.pattern = this._restrict;
}
if (!this._editable) {
this._inputElement.setAttribute('readonly', 'true');
}
this._inputElement.value = this.text;
}
private bindInputEvents(): void {
if (!this._inputElement) return;
this._inputElement.addEventListener('input', () => {
this.text = this._inputElement?.value || '';
this.emit('input');
});
this._inputElement.addEventListener('focus', () => {
this._hasFocus = true;
if (this._inputElement) {
this._inputElement.style.opacity = '1';
}
this.emit('focus');
});
this._inputElement.addEventListener('blur', () => {
this._hasFocus = false;
if (this._inputElement) {
this._inputElement.style.opacity = '0';
}
this.emit('blur');
});
this._inputElement.addEventListener('keydown', (e: Event) => {
if ((e as KeyboardEvent).key === 'Enter' && !this._multiline) {
this.emit('submit');
}
});
}
/**
* Update input element position based on display object position
*
*/
public updateInputPosition(globalX: number, globalY: number): void {
if (this._inputElement) {
this._inputElement.style.left = `${globalX}px`;
this._inputElement.style.top = `${globalY}px`;
this._inputElement.style.width = `${this.width}px`;
this._inputElement.style.height = `${this.height}px`;
}
}
public dispose(): void {
this.destroyInputElement();
super.dispose();
}
}
+420
View File
@@ -0,0 +1,420 @@
import { Image } from './Image';
import { Timer } from '../core/Timer';
import { FGUIEvents } from '../events/Events';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* Frame data for movie clip animation
*
*/
export interface IFrame {
/** Additional delay for this frame | 该帧额外延迟 */
addDelay: number;
/** Texture ID for this frame | 该帧的纹理 ID */
texture?: string | number | null;
}
/**
* Simple callback handler
*
*/
export type SimpleHandler = (() => void) | { run: () => void };
/**
* MovieClip
*
* Animated sprite display object with frame-based animation.
*
*
*
* Features:
* - Frame-by-frame animation
* - Swing (ping-pong) mode
* - Time scale control
* - Play range and loop control
*/
export class MovieClip extends Image {
/** Frame interval in milliseconds | 帧间隔(毫秒) */
public interval: number = 0;
/** Swing mode (ping-pong) | 摆动模式 */
public swing: boolean = false;
/** Delay between loops | 循环间延迟 */
public repeatDelay: number = 0;
/** Time scale multiplier | 时间缩放 */
public timeScale: number = 1;
private _playing: boolean = true;
private _frameCount: number = 0;
private _frames: IFrame[] = [];
private _frame: number = 0;
private _start: number = 0;
private _end: number = 0;
private _times: number = 0;
private _endAt: number = 0;
private _status: number = 0; // 0-none, 1-next loop, 2-ending, 3-ended
private _frameElapsed: number = 0;
private _reversed: boolean = false;
private _repeatedCount: number = 0;
private _endHandler: SimpleHandler | null = null;
private _isOnStage: boolean = false;
private _lastTime: number = 0;
constructor() {
super();
this.touchable = false;
this.setPlaySettings();
// Subscribe to stage lifecycle events
// 订阅舞台生命周期事件
this.on(FGUIEvents.ADDED_TO_STAGE, this.onAddToStage, this);
this.on(FGUIEvents.REMOVED_FROM_STAGE, this.onRemoveFromStage, this);
}
/**
* Get animation frames
*
*/
public get frames(): IFrame[] {
return this._frames;
}
/**
* Set animation frames
*
*/
public set frames(value: IFrame[]) {
this._frames = value;
this.scaleByTile = false;
this.scale9Grid = null;
if (this._frames && this._frames.length > 0) {
this._frameCount = this._frames.length;
if (this._end === -1 || this._end > this._frameCount - 1) {
this._end = this._frameCount - 1;
}
if (this._endAt === -1 || this._endAt > this._frameCount - 1) {
this._endAt = this._frameCount - 1;
}
if (this._frame < 0 || this._frame > this._frameCount - 1) {
this._frame = this._frameCount - 1;
}
this._frameElapsed = 0;
this._repeatedCount = 0;
this._reversed = false;
} else {
this._frameCount = 0;
}
this.drawFrame();
this.checkTimer();
}
/**
* Get frame count
*
*/
public get frameCount(): number {
return this._frameCount;
}
/**
* Get current frame index
*
*/
public get frame(): number {
return this._frame;
}
/**
* Set current frame index
*
*/
public set frame(value: number) {
if (this._frame !== value) {
if (this._frames && value >= this._frameCount) {
value = this._frameCount - 1;
}
this._frame = value;
this._frameElapsed = 0;
this.drawFrame();
}
}
/**
* Get playing state
*
*/
public get playing(): boolean {
return this._playing;
}
/**
* Set playing state
*
*/
public set playing(value: boolean) {
if (this._playing !== value) {
this._playing = value;
this.checkTimer();
}
}
/**
* Rewind to first frame
*
*/
public rewind(): void {
this._frame = 0;
this._frameElapsed = 0;
this._reversed = false;
this._repeatedCount = 0;
this.drawFrame();
}
/**
* Sync status from another MovieClip
* MovieClip
*/
public syncStatus(anotherMc: MovieClip): void {
this._frame = anotherMc._frame;
this._frameElapsed = anotherMc._frameElapsed;
this._reversed = anotherMc._reversed;
this._repeatedCount = anotherMc._repeatedCount;
this.drawFrame();
}
/**
* Advance animation by time
*
*
* @param timeInMilliseconds Time to advance |
*/
public advance(timeInMilliseconds: number): void {
const beginFrame = this._frame;
const beginReversed = this._reversed;
const backupTime = timeInMilliseconds;
while (true) {
let tt = this.interval + this._frames[this._frame].addDelay;
if (this._frame === 0 && this._repeatedCount > 0) {
tt += this.repeatDelay;
}
if (timeInMilliseconds < tt) {
this._frameElapsed = 0;
break;
}
timeInMilliseconds -= tt;
if (this.swing) {
if (this._reversed) {
this._frame--;
if (this._frame <= 0) {
this._frame = 0;
this._repeatedCount++;
this._reversed = !this._reversed;
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = Math.max(0, this._frameCount - 2);
this._repeatedCount++;
this._reversed = !this._reversed;
}
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = 0;
this._repeatedCount++;
}
}
// Completed one round
if (this._frame === beginFrame && this._reversed === beginReversed) {
const roundTime = backupTime - timeInMilliseconds;
timeInMilliseconds -= Math.floor(timeInMilliseconds / roundTime) * roundTime;
}
}
this.drawFrame();
}
/**
* Set play settings
*
*
* @param start Start frame |
* @param end End frame (-1 for last) | -1
* @param times Loop times (0 for infinite) | 0
* @param endAt Stop at frame (-1 for end) | -1
* @param endHandler Callback on end |
*/
public setPlaySettings(
start: number = 0,
end: number = -1,
times: number = 0,
endAt: number = -1,
endHandler: SimpleHandler | null = null
): void {
this._start = start;
this._end = end;
if (this._end === -1 || this._end > this._frameCount - 1) {
this._end = this._frameCount - 1;
}
this._times = times;
this._endAt = endAt;
if (this._endAt === -1) {
this._endAt = this._end;
}
this._status = 0;
this._endHandler = endHandler;
this.frame = start;
}
/**
* Called when added to stage
*
*/
public onAddToStage(): void {
this._isOnStage = true;
this._lastTime = Timer.time;
this.checkTimer();
}
/**
* Called when removed from stage
*
*/
public onRemoveFromStage(): void {
this._isOnStage = false;
this.checkTimer();
}
/**
* Update animation (called each frame)
*
*/
public update(): void {
if (!this._playing || this._frameCount === 0 || this._status === 3) {
return;
}
const currentTime = Timer.time;
let dt = currentTime - this._lastTime;
this._lastTime = currentTime;
if (dt > 100) {
dt = 100;
}
if (this.timeScale !== 1) {
dt *= this.timeScale;
}
this._frameElapsed += dt;
let tt = this.interval + this._frames[this._frame].addDelay;
if (this._frame === 0 && this._repeatedCount > 0) {
tt += this.repeatDelay;
}
if (this._frameElapsed < tt) {
return;
}
this._frameElapsed -= tt;
if (this._frameElapsed > this.interval) {
this._frameElapsed = this.interval;
}
if (this.swing) {
if (this._reversed) {
this._frame--;
if (this._frame <= 0) {
this._frame = 0;
this._repeatedCount++;
this._reversed = !this._reversed;
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = Math.max(0, this._frameCount - 2);
this._repeatedCount++;
this._reversed = !this._reversed;
}
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = 0;
this._repeatedCount++;
}
}
if (this._status === 1) {
// New loop
this._frame = this._start;
this._frameElapsed = 0;
this._status = 0;
} else if (this._status === 2) {
// Ending
this._frame = this._endAt;
this._frameElapsed = 0;
this._status = 3; // Ended
// Play end callback
if (this._endHandler) {
const handler = this._endHandler;
this._endHandler = null;
if (typeof handler === 'function') {
handler();
} else {
handler.run();
}
}
} else {
if (this._frame === this._end) {
if (this._times > 0) {
this._times--;
if (this._times === 0) {
this._status = 2; // Ending
} else {
this._status = 1; // New loop
}
} else {
this._status = 1; // New loop
}
}
}
this.drawFrame();
}
private drawFrame(): void {
if (this._frameCount > 0 && this._frame < this._frames.length) {
const frame = this._frames[this._frame];
this.texture = frame.texture ?? null;
} else {
this.texture = null;
}
}
private checkTimer(): void {
if (this._playing && this._frameCount > 0 && this._isOnStage) {
Timer.add(this.update, this);
} else {
Timer.remove(this.update, this);
}
}
public collectRenderData(collector: IRenderCollector): void {
super.collectRenderData(collector);
}
}
+270
View File
@@ -0,0 +1,270 @@
import { DisplayObject } from './DisplayObject';
import { EAutoSizeType, EAlignType, EVertAlignType } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* TextField
*
* Display object for rendering text.
*
*
*/
export class TextField extends DisplayObject {
/** Font name | 字体名称 */
public font: string = '';
/** Font size | 字体大小 */
public fontSize: number = 12;
/** Text color (hex string) | 文本颜色 */
public color: string = '#000000';
/** Horizontal alignment | 水平对齐 */
public align: EAlignType = EAlignType.Left;
/** Vertical alignment | 垂直对齐 */
public valign: EVertAlignType = EVertAlignType.Top;
/** Line spacing | 行间距 */
public leading: number = 3;
/** Letter spacing | 字符间距 */
public letterSpacing: number = 0;
/** Bold | 粗体 */
public bold: boolean = false;
/** Italic | 斜体 */
public italic: boolean = false;
/** Underline | 下划线 */
public underline: boolean = false;
/** Single line | 单行 */
public singleLine: boolean = false;
/** Stroke width | 描边宽度 */
public stroke: number = 0;
/** Stroke color | 描边颜色 */
public strokeColor: string = '#000000';
/** UBB enabled | UBB 标签启用 */
public ubbEnabled: boolean = false;
/** Auto size type | 自动尺寸类型 */
public autoSize: EAutoSizeType = EAutoSizeType.Both;
/** Word wrap | 自动换行 */
public wordWrap: boolean = false;
/** Template variables | 模板变量 */
public templateVars: Record<string, string> | null = null;
/** Text width after layout | 排版后文本宽度 */
private _textWidth: number = 0;
/** Text height after layout | 排版后文本高度 */
private _textHeight: number = 0;
/** Text content changed flag | 文本内容变化标记 */
private _textChanged: boolean = true;
/** Internal text storage | 内部文本存储 */
private _text: string = '';
constructor() {
super();
}
/**
* Get/set text content
* /
*/
public get text(): string {
return this._text;
}
public set text(value: string) {
if (this._text !== value) {
this._text = value;
this._textChanged = true;
this.ensureSizeCorrect();
}
}
/**
* Get text width
*
*/
public get textWidth(): number {
if (this._textChanged) {
this.buildLines();
}
return this._textWidth;
}
/**
* Get text height
*
*/
public get textHeight(): number {
if (this._textChanged) {
this.buildLines();
}
return this._textHeight;
}
/**
* Ensure text size is calculated correctly
*
*/
public ensureSizeCorrect(): void {
if (this._textChanged && this.autoSize !== EAutoSizeType.None) {
this.buildLines();
}
}
/** Shared canvas context for text measurement | 共享的 Canvas 上下文用于文本测量 */
private static _measureContext: CanvasRenderingContext2D | null = null;
/**
* Get or create canvas context for text measurement
* canvas
*/
private static getMeasureContext(): CanvasRenderingContext2D {
if (!TextField._measureContext) {
const canvas = document.createElement('canvas');
TextField._measureContext = canvas.getContext('2d')!;
}
return TextField._measureContext;
}
/**
* Build lines and calculate text dimensions
*
*
* 使 Canvas 2D measureText
* Use Canvas 2D measureText for accurate text measurement
*/
private buildLines(): void {
this._textChanged = false;
if (!this._text) {
this._textWidth = 0;
this._textHeight = this.fontSize;
return;
}
const ctx = TextField.getMeasureContext();
// 设置字体样式
// Set font style
const fontStyle = this.italic ? 'italic ' : '';
const fontWeight = this.bold ? 'bold ' : '';
const fontFamily = this.font || 'Arial, sans-serif';
ctx.font = `${fontStyle}${fontWeight}${this.fontSize}px ${fontFamily}`;
const lines = this._text.split('\n');
const lineHeight = this.fontSize + this.leading;
let maxWidth = 0;
for (const line of lines) {
// 使用 canvas measureText 获取精确宽度
// Use canvas measureText for accurate width
let lineWidth = ctx.measureText(line).width;
// 添加字符间距
// Add letter spacing
if (this.letterSpacing !== 0 && line.length > 1) {
lineWidth += this.letterSpacing * (line.length - 1);
}
if (lineWidth > maxWidth) {
maxWidth = lineWidth;
}
}
// 单行模式只取第一行
// Single line mode only takes first line
if (this.singleLine) {
this._textWidth = maxWidth;
this._textHeight = lineHeight;
} else {
this._textWidth = maxWidth;
this._textHeight = lines.length * lineHeight;
}
// 添加 gutter 边距(参考 Unity 实现的 GUTTER_X = 2, GUTTER_Y = 2
// Add gutter padding (refer to Unity implementation: GUTTER_X = 2, GUTTER_Y = 2)
this._textWidth += 4;
this._textHeight += 4;
}
/**
* Set variable
*
*/
public setVar(name: string, value: string): void {
if (!this.templateVars) {
this.templateVars = {};
}
this.templateVars[name] = value;
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* u320xRRGGBBAA
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0x000000FF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || !this._text) return;
this.updateTransform();
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Text,
sortOrder: 0,
worldMatrix: this._worldMatrix,
width: this._width,
height: this._height,
alpha: this._worldAlpha,
grayed: this._grayed,
text: this._text,
font: this.font,
fontSize: this.fontSize,
color: this.parseColor(this.color),
align: this.align,
valign: this.valign,
leading: this.leading,
letterSpacing: this.letterSpacing,
bold: this.bold,
italic: this.italic,
underline: this.underline,
singleLine: this.singleLine,
wordWrap: this.wordWrap,
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.stroke > 0) {
primitive.stroke = this.stroke;
primitive.strokeColor = this.parseColor(this.strokeColor);
}
collector.addPrimitive(primitive);
}
}
+418
View File
@@ -0,0 +1,418 @@
/**
* FGUIComponent
*
* ECS component for FairyGUI integration.
* Manages a FairyGUI package and displays a component from it.
*
* FairyGUI ECS FairyGUI
*/
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
import { GRoot } from '../core/GRoot';
import { GComponent } from '../core/GComponent';
import { UIPackage } from '../package/UIPackage';
import type { GObject } from '../core/GObject';
/**
* FGUI Component interface for ECS
* ECS FGUI
*/
export interface IFGUIComponentData {
/** FUI package asset GUID | FUI 包资产 GUID */
packageGuid: string;
/** Component name to display | 要显示的组件名称 */
componentName: string;
/** Width override (0 = use component default) | 宽度覆盖 (0 = 使用组件默认值) */
width: number;
/** Height override (0 = use component default) | 高度覆盖 (0 = 使用组件默认值) */
height: number;
/** X position | X 位置 */
x: number;
/** Y position | Y 位置 */
y: number;
/** Visibility | 可见性 */
visible: boolean;
/** Alpha (0-1) | 透明度 */
alpha: number;
/** Sorting order | 排序顺序 */
sortingOrder: number;
}
/**
* FGUIComponent
*
* ECS component that wraps a FairyGUI component.
* Allows loading FUI packages and displaying components from them.
*
* FairyGUI ECS FUI
*/
@ECSComponent('FGUIComponent')
@Serializable({ version: 1, typeId: 'FGUIComponent' })
export class FGUIComponent extends Component implements IFGUIComponentData {
// ============= Serialized Properties | 序列化属性 =============
/**
* FUI package asset GUID
* FUI GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Package', extensions: ['.fui'] })
public packageGuid: string = '';
/**
* Component name to display from the package
*
*/
@Serialize()
@Property({ type: 'string', label: 'Component' })
public componentName: string = '';
/**
* Width override (0 = use component default)
* (0 = 使)
*/
@Serialize()
@Property({ type: 'number', label: 'Width', min: 0 })
public width: number = 0;
/**
* Height override (0 = use component default)
* (0 = 使)
*/
@Serialize()
@Property({ type: 'number', label: 'Height', min: 0 })
public height: number = 0;
/**
* X position
* X
*/
@Serialize()
@Property({ type: 'number', label: 'X' })
public x: number = 0;
/**
* Y position
* Y
*/
@Serialize()
@Property({ type: 'number', label: 'Y' })
public y: number = 0;
/**
* Whether the component is visible
*
*/
@Serialize()
@Property({ type: 'boolean', label: 'Visible' })
public visible: boolean = true;
/**
* Alpha (0-1)
* (0-1)
*/
@Serialize()
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
public alpha: number = 1;
/**
* Sorting order for render priority
*
*/
@Serialize()
@Property({ type: 'integer', label: 'Sorting Order' })
public sortingOrder: number = 0;
// ============= Runtime State (not serialized) | 运行时状态(不序列化)=============
/** Loaded UIPackage | 已加载的 UIPackage */
private _package: UIPackage | null = null;
/** Created GRoot instance | 创建的 GRoot 实例 */
private _root: GRoot | null = null;
/** Created component instance | 创建的组件实例 */
private _component: GObject | null = null;
/** Loading state | 加载状态 */
private _loading: boolean = false;
/** Error message if loading failed | 加载失败时的错误信息 */
private _error: string | null = null;
/**
* Version counter, incremented on every state change (load, component change)
* Used by Inspector to detect when to refresh UI
* Inspector UI
*/
private _version: number = 0;
/**
* Optional callback for state changes (used by editor for virtual node updates)
*
*/
private _onStateChange: ((type: 'loaded' | 'updated' | 'disposed') => void) | null = null;
// ============= Getters | 访问器 =============
/**
* Get the GRoot instance
* GRoot
*/
public get root(): GRoot | null {
return this._root;
}
/**
* Get the loaded UIPackage
* UIPackage
*/
public get package(): UIPackage | null {
return this._package;
}
/**
* Get the created component
*
*/
public get component(): GObject | null {
return this._component;
}
/**
* Check if currently loading
*
*/
public get isLoading(): boolean {
return this._loading;
}
/**
* Get error message
*
*/
public get error(): string | null {
return this._error;
}
/**
* Check if component is ready
*
*/
public get isReady(): boolean {
return this._root !== null && this._component !== null;
}
/**
* Get version counter for change detection
* Used by Inspector to detect when to refresh UI
* Inspector UI
*/
public get version(): number {
return this._version;
}
/**
* Set state change callback for editor integration
*
*
* @param callback Called when component state changes ('loaded', 'updated', 'disposed')
*/
public set onStateChange(callback: ((type: 'loaded' | 'updated' | 'disposed') => void) | null) {
this._onStateChange = callback;
}
/**
* Get available component names from the loaded package
*
*/
public getAvailableComponentNames(): string[] {
if (!this._package) return [];
return this._package.getExportedComponentNames();
}
/**
* Get all component names (including non-exported) from the loaded package
*
*/
public getAllComponentNames(): string[] {
if (!this._package) return [];
return this._package.getAllComponentNames();
}
// ============= Methods | 方法 =============
/**
* Initialize the FGUI root
* FGUI
*/
public initRoot(width: number, height: number): void {
if (this._root) {
this._root.dispose();
}
this._root = new GRoot();
this._root.setSize(width, height);
}
/**
* Load package from binary data
*
*/
public loadPackage(resKey: string, data: ArrayBuffer): UIPackage {
this._loading = true;
this._error = null;
try {
this._package = UIPackage.addPackageFromBuffer(resKey, data);
this._loading = false;
return this._package;
} catch (e) {
this._loading = false;
this._error = e instanceof Error ? e.message : String(e);
throw e;
}
}
/**
* Set a pre-loaded package (from FUIAssetLoader)
* FUIAssetLoader
*/
public setLoadedPackage(pkg: UIPackage): void {
this._package = pkg;
this._loading = false;
this._error = null;
this._version++;
this._onStateChange?.('loaded');
}
/**
* Create component from loaded package
*
*
* Note: Disposes existing component before creating new one to avoid visual overlap
*
*/
public createComponent(componentName?: string): GObject | null {
const name = componentName || this.componentName;
if (!this._package) {
return null;
}
if (!name) {
return null;
}
// Dispose existing component before creating new one
// 创建新组件前先销毁已有组件
if (this._component) {
if (this._root) {
this._root.removeChild(this._component);
}
this._component.dispose();
this._component = null;
}
try {
this._component = this._package.createObject(name);
if (this._component && this._root) {
this.syncProperties();
this._root.addChild(this._component);
}
this._version++;
this._onStateChange?.('updated');
return this._component;
} catch (e) {
// Log full error with stack trace for debugging
console.error(`[FGUIComponent] Error creating component "${name}":`, e);
this._error = e instanceof Error ? e.message : String(e);
return null;
}
}
/**
* Get child by name from the component
*
*/
public getChild(name: string): GObject | null {
if (this._component instanceof GComponent) {
return this._component.getChild(name);
}
return null;
}
/**
* Get controller by name from the component
*
*/
public getController(name: string) {
if (this._component instanceof GComponent) {
return this._component.getController(name);
}
return null;
}
/**
* Get transition by name from the component
*
*/
public getTransition(name: string) {
if (this._component instanceof GComponent) {
return this._component.getTransition(name);
}
return null;
}
/**
* Update component properties from ECS data
* ECS
*/
public syncProperties(): void {
if (!this._component) return;
if (this.width > 0) {
this._component.width = this.width;
}
if (this.height > 0) {
this._component.height = this.height;
}
this._component.x = this.x;
this._component.y = this.y;
this._component.visible = this.visible;
this._component.alpha = this.alpha;
this._component.sortingOrder = this.sortingOrder;
}
/**
* Dispose and cleanup
*
*/
public dispose(): void {
const hadContent = this._component !== null || this._root !== null;
if (this._component) {
this._component.dispose();
this._component = null;
}
if (this._root) {
this._root.dispose();
this._root = null;
}
this._package = null;
this._error = null;
if (hadContent) {
this._onStateChange?.('disposed');
}
}
// ============= ECS Lifecycle | ECS 生命周期 =============
/**
* Called when component is removed from entity
*
*/
public override onRemovedFromEntity(): void {
this.dispose();
}
}
@@ -0,0 +1,209 @@
/**
* FGUIRenderSystem
*
* ECS system for rendering FairyGUI components.
* Collects render data from all FGUI components and submits to the engine.
*
* FairyGUI ECS FGUI
*/
import type { IAssetManager } from '@esengine/asset-system';
import { createServiceToken } from '@esengine/ecs-framework';
/**
* Service token for FGUI render system
* FGUI
*/
export const FGUIRenderSystemToken = createServiceToken<FGUIRenderSystem>('fguiRenderSystem');
import { FGUIComponent } from './FGUIComponent';
import { RenderCollector } from '../render/RenderCollector';
import { Timer } from '../core/Timer';
/**
* Render submit callback type
*
*/
export type RenderSubmitCallback = (collector: RenderCollector) => void;
/**
* FGUIRenderSystem
*
* Manages rendering for all FairyGUI components in the scene.
* FairyGUI
*/
export class FGUIRenderSystem {
/** System update order | 系统更新顺序 */
public readonly updateOrder: number = 1000;
/** Render collector | 渲染收集器 */
private _collector: RenderCollector;
/** All registered FGUI components | 所有已注册的 FGUI 组件 */
private _components: Set<FGUIComponent> = new Set();
/** Render submit callback | 渲染提交回调 */
private _onSubmit: RenderSubmitCallback | null = null;
/** Whether the system is enabled | 系统是否启用 */
private _enabled: boolean = true;
/** Last update time | 上次更新时间 */
private _lastTime: number = 0;
/** Asset manager for loading FUI packages | 用于加载 FUI 包的资产管理器 */
private _assetManager: IAssetManager | null = null;
constructor() {
this._collector = new RenderCollector();
}
/**
* Set asset manager for loading FUI packages
* FUI
*/
public setAssetManager(assetManager: IAssetManager): void {
this._assetManager = assetManager;
}
/**
* Get asset manager
*
*/
public get assetManager(): IAssetManager | null {
return this._assetManager;
}
/**
* Set render submit callback
*
*/
public set onSubmit(callback: RenderSubmitCallback | null) {
this._onSubmit = callback;
}
/**
* Get render collector
*
*/
public get collector(): RenderCollector {
return this._collector;
}
/**
* Enable or disable the system
*
*/
public set enabled(value: boolean) {
this._enabled = value;
}
public get enabled(): boolean {
return this._enabled;
}
/**
* Register a FGUI component
* FGUI
*/
public registerComponent(component: FGUIComponent): void {
this._components.add(component);
}
/**
* Unregister a FGUI component
* FGUI
*/
public unregisterComponent(component: FGUIComponent): void {
this._components.delete(component);
}
/**
* Get all registered components
*
*/
public getComponents(): ReadonlySet<FGUIComponent> {
return this._components;
}
/**
* Initialize the system
*
*/
public initialize(): void {
this._lastTime = performance.now() / 1000;
}
/**
* Update all FGUI components
* FGUI
*/
public update(deltaTime?: number): void {
if (!this._enabled) return;
// Calculate delta time in seconds if not provided
const currentTime = performance.now() / 1000;
const dt = deltaTime ?? (currentTime - this._lastTime);
this._lastTime = currentTime;
// Update timers - Timer expects milliseconds
Timer.inst.update(dt * 1000);
// Clear collector for new frame
this._collector.clear();
// Sort components by sorting order
const sortedComponents = Array.from(this._components)
.filter(c => c.isReady && c.visible)
.sort((a, b) => a.sortingOrder - b.sortingOrder);
// Collect render data from each component
for (const component of sortedComponents) {
if (component.root) {
component.syncProperties();
component.root.collectRenderData(this._collector);
}
}
// Submit render data
if (this._onSubmit) {
this._onSubmit(this._collector);
}
}
/**
* Dispose the system
*
*/
public dispose(): void {
for (const component of this._components) {
component.dispose();
}
this._components.clear();
this._onSubmit = null;
}
}
/**
* Default FGUI render system instance
* FGUI
*/
let _defaultSystem: FGUIRenderSystem | null = null;
/**
* Get default FGUI render system
* FGUI
*/
export function getFGUIRenderSystem(): FGUIRenderSystem {
if (!_defaultSystem) {
_defaultSystem = new FGUIRenderSystem();
}
return _defaultSystem;
}
/**
* Set default FGUI render system
* FGUI
*/
export function setFGUIRenderSystem(system: FGUIRenderSystem): void {
_defaultSystem = system;
}
@@ -0,0 +1,179 @@
/**
* FGUIRuntimeModule
*
* Runtime module for FairyGUI integration with the ECS framework.
* Registers components and asset loaders.
*
* FairyGUI ECS
*/
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { CanvasElementToken } from '@esengine/engine-core';
import { AssetManagerToken, type IAssetManager } from '@esengine/asset-system';
import { FGUIComponent } from './FGUIComponent';
import { FGUIRenderSystem, setFGUIRenderSystem } from './FGUIRenderSystem';
import { FGUIUpdateSystem } from './FGUIUpdateSystem';
import { FUIAssetLoader, FUI_ASSET_TYPE } from '../asset/FUIAssetLoader';
import { Stage } from '../core/Stage';
import { getDynamicFontManager, COMMON_ASCII_CHARS } from '../text/DynamicFont';
/**
* FGUIRuntimeModule
*
* Implements IRuntimeModule for FairyGUI integration.
*
* IRuntimeModule FairyGUI
*/
export class FGUIRuntimeModule implements IRuntimeModule {
private _renderSystem: FGUIRenderSystem | null = null;
private _loaderRegistered = false;
/**
* Register components to ComponentRegistry
* ComponentRegistry
*/
registerComponents(registry: IComponentRegistry): void {
registry.register(FGUIComponent);
}
/**
* Create systems for scene
*
*/
createSystems(scene: IScene, context: SystemContext): void {
// Get asset manager from service registry
const assetManager = context.services.get(AssetManagerToken);
// Register FUI asset loader
if (!this._loaderRegistered && assetManager) {
const loader = new FUIAssetLoader();
(assetManager as IAssetManager).registerLoader(FUI_ASSET_TYPE, loader);
this._loaderRegistered = true;
}
// Create and add FGUIUpdateSystem
const updateSystem = new FGUIUpdateSystem();
if (assetManager) {
updateSystem.setAssetManager(assetManager as IAssetManager);
}
scene.addSystem(updateSystem);
}
/**
* Called after all systems are created
*
*/
onSystemsCreated(_scene: IScene, context: SystemContext): void {
// Create render system (not an EntitySystem, handles its own update)
this._renderSystem = new FGUIRenderSystem();
// Set asset manager for the render system
const assetManager = context.services.get(AssetManagerToken);
if (assetManager) {
this._renderSystem.setAssetManager(assetManager as IAssetManager);
}
// Bind Stage to canvas for input events
const canvas = context.services.get(CanvasElementToken);
if (canvas) {
Stage.inst.bindToCanvas(canvas);
}
// Initialize dynamic font system with system default font
// 使用系统默认字体初始化动态字体系统
this.initDynamicFonts();
// Initialize the render system
this._renderSystem.initialize();
// Store global reference
setFGUIRenderSystem(this._renderSystem);
}
/**
* Initialize dynamic font system
*
*
* Creates a default dynamic font using system fonts.
* This allows text rendering without preloaded MSDF fonts.
*
* 使
* MSDF
*/
private initDynamicFonts(): void {
const fontManager = getDynamicFontManager();
// Create default font using system fonts (cross-platform, no licensing issues)
// 使用系统字体创建默认字体(跨平台,无许可问题)
// Font stack: system-ui for modern browsers, then common fallbacks
const defaultFont = fontManager.createFont('default', {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif',
fontSize: 32,
atlasWidth: 1024,
atlasHeight: 1024,
padding: 2,
preloadChars: COMMON_ASCII_CHARS
});
// Also create Arial alias using system sans-serif
// 为 Arial 创建别名,使用系统 sans-serif
fontManager.createFont('Arial', {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif',
fontSize: 32,
atlasWidth: 1024,
atlasHeight: 1024,
padding: 2,
preloadChars: COMMON_ASCII_CHARS
});
// Register as MSDF-compatible fonts
// 注册为 MSDF 兼容字体
defaultFont.registerAsMSDFFont();
}
/**
* Get the render system
*
*/
get renderSystem(): FGUIRenderSystem | null {
return this._renderSystem;
}
}
/**
* Module manifest
*
*/
const manifest: ModuleManifest = {
id: 'fairygui',
name: '@esengine/fairygui',
displayName: 'FairyGUI',
version: '1.0.0',
description: 'FairyGUI UI system for ECS framework',
category: 'Other',
icon: 'Layout',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math', 'asset-system'],
exports: {
components: ['FGUIComponent'],
systems: ['FGUIRenderSystem'],
loaders: ['FUIAssetLoader']
},
editorPackage: '@esengine/fairygui-editor',
assetExtensions: {
'.fui': 'fui'
}
};
/**
* FairyGUI Plugin
* FairyGUI
*/
export const FGUIPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new FGUIRuntimeModule()
};
@@ -0,0 +1,200 @@
/**
* FGUIUpdateSystem
*
* ECS system that handles automatic loading and updating of FGUIComponents.
*
* FGUIComponent ECS
*/
import { EntitySystem, Matcher, type Entity, Time } from '@esengine/ecs-framework';
import type { IAssetManager } from '@esengine/asset-system';
import { FGUIComponent } from './FGUIComponent';
import { getFGUIRenderSystem } from './FGUIRenderSystem';
import type { IFUIAsset } from '../asset/FUIAssetLoader';
/**
* Tracked state for detecting property changes
*
*/
interface TrackedState {
packageGuid: string;
componentName: string;
}
/**
* FGUIUpdateSystem
*
* Automatically loads FUI packages and creates UI components for FGUIComponent.
* FGUIComponent FUI UI
*/
export class FGUIUpdateSystem extends EntitySystem {
private _assetManager: IAssetManager | null = null;
private _trackedStates: WeakMap<FGUIComponent, TrackedState> = new WeakMap();
private _pendingLoads: Map<FGUIComponent, Promise<void>> = new Map();
constructor() {
super(Matcher.empty().all(FGUIComponent));
}
public setAssetManager(assetManager: IAssetManager): void {
this._assetManager = assetManager;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
if (!fguiComp) continue;
// Skip if currently loading
if (fguiComp.isLoading || this._pendingLoads.has(fguiComp)) {
continue;
}
// Check if we need to reload
const tracked = this._trackedStates.get(fguiComp);
const needsReload = this._needsReload(fguiComp, tracked);
if (needsReload && fguiComp.packageGuid) {
this._loadComponent(fguiComp);
}
}
const renderSystem = getFGUIRenderSystem();
if (renderSystem) {
renderSystem.update(Time.deltaTime);
}
}
/**
* Check if component needs to reload
*
*/
private _needsReload(comp: FGUIComponent, tracked: TrackedState | undefined): boolean {
// Not tracked yet - needs initial load
if (!tracked) {
return true;
}
// Package changed - needs full reload
if (tracked.packageGuid !== comp.packageGuid) {
return true;
}
// Component name changed - needs to recreate component
if (tracked.componentName !== comp.componentName) {
// If package is already loaded, just recreate the component
if (comp.package && comp.componentName) {
comp.createComponent(comp.componentName);
// Update tracked state
this._trackedStates.set(comp, {
packageGuid: comp.packageGuid,
componentName: comp.componentName
});
}
return false;
}
return false;
}
private async _loadComponent(comp: FGUIComponent): Promise<void> {
if (!this._assetManager) {
return;
}
const loadPromise = this._doLoad(comp);
this._pendingLoads.set(comp, loadPromise);
try {
await loadPromise;
} finally {
this._pendingLoads.delete(comp);
}
}
private async _doLoad(comp: FGUIComponent): Promise<void> {
const packageRef = comp.packageGuid;
// Dispose previous content before loading new package
comp.dispose();
try {
// Check if packageRef is a path (contains / or . before extension) or a GUID
// GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// Path format: assets/ui/Bag.fui or similar
const isPath = packageRef.includes('/') || packageRef.includes('\\') || packageRef.endsWith('.fui');
const result = isPath
? await this._assetManager!.loadAssetByPath<IFUIAsset>(packageRef)
: await this._assetManager!.loadAsset<IFUIAsset>(packageRef);
if (!result || !result.asset) {
return;
}
const fuiAsset = result.asset;
if (fuiAsset.package) {
const width = comp.width > 0 ? comp.width : 1920;
const height = comp.height > 0 ? comp.height : 1080;
comp.initRoot(width, height);
comp.setLoadedPackage(fuiAsset.package);
if (comp.componentName) {
comp.createComponent(comp.componentName);
}
} else {
const asset = fuiAsset as unknown;
let data: ArrayBuffer | null = null;
if (asset instanceof ArrayBuffer) {
data = asset;
} else if (typeof asset === 'object' && asset !== null && 'data' in asset && (asset as { data: ArrayBuffer }).data instanceof ArrayBuffer) {
data = (asset as { data: ArrayBuffer }).data;
} else if (typeof asset === 'object' && asset !== null && 'buffer' in asset) {
data = (asset as { buffer: ArrayBuffer }).buffer;
}
if (!data) {
return;
}
const width = comp.width > 0 ? comp.width : 1920;
const height = comp.height > 0 ? comp.height : 1080;
comp.initRoot(width, height);
comp.loadPackage(packageRef, data);
if (comp.componentName) {
comp.createComponent(comp.componentName);
}
}
const renderSystem = getFGUIRenderSystem();
if (renderSystem && comp.isReady) {
renderSystem.registerComponent(comp);
}
// Update tracked state after successful load
this._trackedStates.set(comp, {
packageGuid: comp.packageGuid,
componentName: comp.componentName
});
} catch (err) {
console.error(`[FGUI] Error loading package ${packageRef}:`, err);
}
}
protected override onDestroy(): void {
const renderSystem = getFGUIRenderSystem();
if (renderSystem && this.scene) {
for (const entity of this.scene.entities.buffer) {
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
if (fguiComp) {
renderSystem.unregisterComponent(fguiComp);
}
}
}
this._pendingLoads.clear();
this._trackedStates = new WeakMap();
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* FairyGUI ECS Integration
*
* ECS components, systems, and runtime module for FairyGUI integration.
*
* FairyGUI ECS
*/
export { FGUIComponent } from './FGUIComponent';
export type { IFGUIComponentData } from './FGUIComponent';
export {
FGUIRenderSystem,
FGUIRenderSystemToken,
getFGUIRenderSystem,
setFGUIRenderSystem
} from './FGUIRenderSystem';
export type { RenderSubmitCallback } from './FGUIRenderSystem';
export { FGUIUpdateSystem } from './FGUIUpdateSystem';
export { FGUIRuntimeModule, FGUIPlugin } from './FGUIRuntimeModule';
@@ -0,0 +1,349 @@
import type { FGUIEvents } from './Events';
/**
* Event type key from FGUIEvents
* FGUIEvents
*/
export type FGUIEventType = (typeof FGUIEvents)[keyof typeof FGUIEvents];
/**
* Event data mapping - maps event types to their data types
* -
*/
export interface IEventDataMap {
[key: string]: unknown;
}
/**
* Event listener callback with type safety
*
*/
export type TypedEventListener<T = unknown> = (data: T) => void;
/**
* Legacy event listener (for backwards compatibility)
*
*/
export type EventListener = (data?: unknown) => void;
/**
* Event listener info
*
*/
interface ListenerInfo<T = unknown> {
listener: TypedEventListener<T>;
thisArg: unknown;
once: boolean;
priority: number;
}
/**
* Event propagation control
*
*/
export interface IEventContext {
/** Stop propagation | 停止传播 */
stopped: boolean;
/** Prevent default behavior | 阻止默认行为 */
defaultPrevented: boolean;
/** Event type | 事件类型 */
type: string;
/** Current target | 当前目标 */
currentTarget: EventDispatcher | null;
/** Original target | 原始目标 */
target: EventDispatcher | null;
}
/**
* Create event context
*
*/
function createEventContext(type: string, target: EventDispatcher): IEventContext {
return {
stopped: false,
defaultPrevented: false,
type,
currentTarget: target,
target
};
}
/**
* EventDispatcher
*
* Modern event dispatching system with type safety and priority support.
*
*
*
* Features:
* - Type-safe event listeners
* - Priority-based listener ordering
* - Event propagation control
* - Capture phase support
* - Memory-efficient listener management
*/
export class EventDispatcher {
private _listeners: Map<string, ListenerInfo[]> = new Map();
private _captureListeners: Map<string, ListenerInfo[]> = new Map();
private _dispatching: Set<string> = new Set();
private _pendingRemovals: Map<string, ListenerInfo[]> = new Map();
/**
* Register an event listener with optional priority
*
*
* @param type Event type |
* @param listener Callback function |
* @param thisArg Context for callback |
* @param priority Higher priority listeners are called first (default: 0) |
*/
public on<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._listeners, type, listener as TypedEventListener, thisArg, false, priority);
return this;
}
/**
* Register a one-time event listener
*
*/
public once<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._listeners, type, listener as TypedEventListener, thisArg, true, priority);
return this;
}
/**
* Remove an event listener
*
*/
public off<T = unknown>(type: string, listener: TypedEventListener<T>, thisArg?: unknown): this {
this.removeListener(this._listeners, type, listener as TypedEventListener, thisArg);
return this;
}
/**
* Remove all listeners for a type, or all listeners
*
*/
public offAll(type?: string): this {
if (type) {
this._listeners.delete(type);
this._captureListeners.delete(type);
} else {
this._listeners.clear();
this._captureListeners.clear();
}
return this;
}
/**
* Emit an event with typed data
*
*
* @returns true if event was handled, false otherwise
*/
public emit<T = unknown>(type: string, data?: T): boolean {
const listeners = this._listeners.get(type);
if (!listeners || listeners.length === 0) {
return false;
}
this._dispatching.add(type);
const toRemove: ListenerInfo[] = [];
try {
for (const info of listeners) {
try {
info.listener.call(info.thisArg, data);
} catch (error) {
console.error(`Error in event listener for "${type}":`, error);
}
if (info.once) {
toRemove.push(info);
}
}
} finally {
this._dispatching.delete(type);
}
// Remove one-time listeners
for (const info of toRemove) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
// Process pending removals
const pending = this._pendingRemovals.get(type);
if (pending) {
for (const info of pending) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
this._pendingRemovals.delete(type);
}
return true;
}
/**
* Emit with event context for propagation control
*
*/
public emitWithContext<T = unknown>(type: string, data?: T): IEventContext {
const context = createEventContext(type, this);
const listeners = this._listeners.get(type);
if (listeners && listeners.length > 0) {
this._dispatching.add(type);
const toRemove: ListenerInfo[] = [];
try {
for (const info of listeners) {
if (context.stopped) break;
try {
info.listener.call(info.thisArg, data);
} catch (error) {
console.error(`Error in event listener for "${type}":`, error);
}
if (info.once) {
toRemove.push(info);
}
}
} finally {
this._dispatching.delete(type);
}
for (const info of toRemove) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
}
return context;
}
/**
* Check if there are any listeners for a type
*
*/
public hasListener(type: string): boolean {
const listeners = this._listeners.get(type);
return listeners !== undefined && listeners.length > 0;
}
/**
* Get listener count for a type
*
*/
public listenerCount(type: string): number {
const listeners = this._listeners.get(type);
return listeners?.length ?? 0;
}
/**
* Register a capture phase listener
*
*/
public onCapture<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._captureListeners, type, listener as TypedEventListener, thisArg, false, priority);
return this;
}
/**
* Remove a capture phase listener
*
*/
public offCapture<T = unknown>(type: string, listener: TypedEventListener<T>, thisArg?: unknown): this {
this.removeListener(this._captureListeners, type, listener as TypedEventListener, thisArg);
return this;
}
/**
* Dispose all listeners
*
*/
public dispose(): void {
this._listeners.clear();
this._captureListeners.clear();
this._dispatching.clear();
this._pendingRemovals.clear();
}
private addListener(
map: Map<string, ListenerInfo[]>,
type: string,
listener: TypedEventListener,
thisArg: unknown,
once: boolean,
priority: number
): void {
let listeners = map.get(type);
if (!listeners) {
listeners = [];
map.set(type, listeners);
}
// Check for duplicate
const exists = listeners.some((info) => info.listener === listener && info.thisArg === thisArg);
if (exists) return;
const info: ListenerInfo = { listener, thisArg, once, priority };
// Insert by priority (higher priority first)
let inserted = false;
for (let i = 0; i < listeners.length; i++) {
if (priority > listeners[i].priority) {
listeners.splice(i, 0, info);
inserted = true;
break;
}
}
if (!inserted) {
listeners.push(info);
}
}
private removeListener(
map: Map<string, ListenerInfo[]>,
type: string,
listener: TypedEventListener,
thisArg: unknown
): void {
const listeners = map.get(type);
if (!listeners) return;
// If dispatching, defer removal
if (this._dispatching.has(type)) {
let pending = this._pendingRemovals.get(type);
if (!pending) {
pending = [];
this._pendingRemovals.set(type, pending);
}
pending.push({ listener, thisArg, once: false, priority: 0 });
return;
}
const index = listeners.findIndex((info) => info.listener === listener && info.thisArg === thisArg);
if (index !== -1) {
listeners.splice(index, 1);
if (listeners.length === 0) {
map.delete(type);
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* FairyGUI Event Types
* FairyGUI
*/
export const FGUIEvents = {
/** Size changed | 尺寸改变 */
SIZE_CHANGED: 'fguiSizeChanged',
/** Position changed | 位置改变 */
XY_CHANGED: 'fguiXYChanged',
/** Click event | 点击事件 */
CLICK: 'click',
/** Touch/Mouse begin | 触摸/鼠标按下 */
TOUCH_BEGIN: 'touchBegin',
/** Touch/Mouse end | 触摸/鼠标抬起 */
TOUCH_END: 'touchEnd',
/** Touch/Mouse move | 触摸/鼠标移动 */
TOUCH_MOVE: 'touchMove',
/** Roll over (mouse enter) | 鼠标进入 */
ROLL_OVER: 'rollOver',
/** Roll out (mouse leave) | 鼠标离开 */
ROLL_OUT: 'rollOut',
/** Focus in | 获得焦点 */
FOCUS_IN: 'focusIn',
/** Focus out | 失去焦点 */
FOCUS_OUT: 'focusOut',
/** Added to stage | 添加到舞台 */
ADDED_TO_STAGE: 'addedToStage',
/** Removed from stage | 从舞台移除 */
REMOVED_FROM_STAGE: 'removedFromStage',
/** Display (added and visible) | 显示(添加并可见) */
DISPLAY: 'display',
/** Status changed (for Controller) | 状态改变(控制器) */
STATUS_CHANGED: 'statusChanged',
/** State changed (for Button/Slider) | 状态改变(按钮/滑块) */
STATE_CHANGED: 'stateChanged',
/** Pull down release (for list refresh) | 下拉刷新释放 */
PULL_DOWN_RELEASE: 'pullDownRelease',
/** Pull up release (for list load more) | 上拉加载释放 */
PULL_UP_RELEASE: 'pullUpRelease',
/** Scroll event | 滚动事件 */
SCROLL: 'scroll',
/** Scroll end | 滚动结束 */
SCROLL_END: 'scrollEnd',
/** Drag start | 拖拽开始 */
DRAG_START: 'dragStart',
/** Drag move | 拖拽移动 */
DRAG_MOVE: 'dragMove',
/** Drag end | 拖拽结束 */
DRAG_END: 'dragEnd',
/** Drop event | 放下事件 */
DROP: 'drop',
/** Text changed | 文本改变 */
TEXT_CHANGED: 'textChanged',
/** Text submitted (Enter key) | 文本提交(回车键) */
TEXT_SUBMIT: 'textSubmit',
/** Gear stop (animation complete) | 齿轮动画停止 */
GEAR_STOP: 'gearStop',
/** Link click (rich text) | 链接点击(富文本) */
LINK: 'link',
/** Play complete (MovieClip/Transition) | 播放完成 */
PLAY_COMPLETE: 'playComplete',
/** Click on list item | 列表项点击 */
CLICK_ITEM: 'clickItem'
} as const;
/**
* Input event data
*
*/
export interface IInputEventData {
/** Touch/Pointer ID | 触摸/指针 ID */
touchId: number;
/** Stage X position | 舞台 X 坐标 */
stageX: number;
/** Stage Y position | 舞台 Y 坐标 */
stageY: number;
/** Button pressed (0=left, 1=middle, 2=right) | 按下的按钮 */
button: number;
/** Wheel delta | 滚轮增量 */
wheelDelta: number;
/** Is Ctrl key pressed | 是否按下 Ctrl */
ctrlKey: boolean;
/** Is Shift key pressed | 是否按下 Shift */
shiftKey: boolean;
/** Is Alt key pressed | 是否按下 Alt */
altKey: boolean;
/** Original DOM event | 原始 DOM 事件 */
nativeEvent?: MouseEvent | TouchEvent | WheelEvent;
}
/**
* Create default input event data
*
*/
export function createInputEventData(): IInputEventData {
return {
touchId: 0,
stageX: 0,
stageY: 0,
button: 0,
wheelDelta: 0,
ctrlKey: false,
shiftKey: false,
altKey: false
};
}
@@ -0,0 +1,70 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* Animation value for GearAnimation
* GearAnimation
*/
interface IAnimationValue {
playing: boolean;
frame: number;
}
/**
* GearAnimation
*
* Controls object animation state based on controller state.
*
*/
export class GearAnimation extends GearBase {
private _storage: Map<string, IAnimationValue> = new Map();
private _default: IAnimationValue = { playing: true, frame: 0 };
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
playing: (this.owner.getProp(EObjectPropID.Playing) as boolean) ?? true,
frame: (this.owner.getProp(EObjectPropID.Frame) as number) ?? 0
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.setProp(EObjectPropID.Playing, gv.playing);
this.owner.setProp(EObjectPropID.Frame, gv.frame);
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
const gv: IAnimationValue = {
playing: (this.owner.getProp(EObjectPropID.Playing) as boolean) ?? true,
frame: (this.owner.getProp(EObjectPropID.Frame) as number) ?? 0
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status
*
*/
public addStatus(pageId: string | null, playing: boolean, frame: number): void {
if (pageId === null) {
this._default.playing = playing;
this._default.frame = frame;
} else {
this._storage.set(pageId, { playing, frame });
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { GObject } from '../core/GObject';
import type { Controller } from '../core/Controller';
import type { ByteBuffer } from '../utils/ByteBuffer';
import { EEaseType } from '../core/FieldTypes';
/**
* GearBase
*
* Base class for all gear types.
* Gears connect object properties to controller states.
*
* 齿齿
*/
export abstract class GearBase {
/** Owner object | 所有者对象 */
public readonly owner: GObject;
/** Controller | 控制器 */
protected _controller: Controller | null = null;
/** Tween config | 缓动配置 */
public tweenConfig: GearTweenConfig | null = null;
constructor(owner: GObject) {
this.owner = owner;
}
/**
* Get controller
*
*/
public get controller(): Controller | null {
return this._controller;
}
/**
* Set controller
*
*/
public set controller(value: Controller | null) {
if (this._controller !== value) {
this._controller = value;
if (this._controller) {
this.init();
}
}
}
/**
* Check if connected to a controller
*
*/
public get connected(): boolean {
return this._controller !== null;
}
/**
* Initialize gear
* 齿
*/
protected abstract init(): void;
/**
* Apply gear values
* 齿
*/
public abstract apply(): void;
/**
* Update current state
*
*/
public abstract updateState(): void;
/**
* Setup gear from buffer
* 齿
*/
public setup(buffer: ByteBuffer): void {
const parent = this.owner.parent;
if (!parent) return;
this._controller = parent.getControllerAt(buffer.getInt16());
this.init();
const cnt = buffer.getInt16();
// Read pages - subclasses should override to parse their specific data
this.readStatusFromBuffer(buffer, cnt);
// Read default status
if (buffer.readBool()) {
this.readDefaultStatusFromBuffer(buffer);
}
// Read tween config
if (buffer.readBool()) {
this.tweenConfig = new GearTweenConfig();
this.tweenConfig.easeType = buffer.readByte() as EEaseType;
this.tweenConfig.duration = buffer.getFloat32();
this.tweenConfig.delay = buffer.getFloat32();
}
}
/**
* Read status data from buffer
*
*/
protected readStatusFromBuffer(_buffer: ByteBuffer, _cnt: number): void {
// Override in subclasses to parse specific gear data
// Default: skip the data (each page has a string ID)
for (let i = 0; i < _cnt; i++) {
_buffer.readS(); // page id
// Subclass should read its specific data here
}
}
/**
* Read default status from buffer
*
*/
protected readDefaultStatusFromBuffer(_buffer: ByteBuffer): void {
// Override in subclasses
}
/**
* Dispose
*
*/
public dispose(): void {
this._controller = null;
this.tweenConfig = null;
}
}
/**
* Gear tween configuration
* 齿
*/
export class GearTweenConfig {
/** Tween enabled | 是否启用缓动 */
public tween: boolean = true;
/** Ease type | 缓动类型 */
public easeType: EEaseType = EEaseType.QuadOut;
/** Duration in seconds | 持续时间(秒) */
public duration: number = 0.3;
/** Delay in seconds | 延迟时间(秒) */
public delay: number = 0;
}
+74
View File
@@ -0,0 +1,74 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* Color value for GearColor
* GearColor
*/
interface IColorValue {
color: number | null;
strokeColor: number | null;
}
/**
* GearColor
*
* Controls object color and stroke color based on controller state.
*
*/
export class GearColor extends GearBase {
private _storage: Map<string, IColorValue> = new Map();
private _default: IColorValue = { color: null, strokeColor: null };
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
color: this.owner.getProp(EObjectPropID.Color) as number | null,
strokeColor: this.owner.getProp(EObjectPropID.OutlineColor) as number | null
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
if (gv.color !== null) {
this.owner.setProp(EObjectPropID.Color, gv.color);
}
if (gv.strokeColor !== null) {
this.owner.setProp(EObjectPropID.OutlineColor, gv.strokeColor);
}
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
const gv: IColorValue = {
color: this.owner.getProp(EObjectPropID.Color) as number | null,
strokeColor: this.owner.getProp(EObjectPropID.OutlineColor) as number | null
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status from buffer
*
*/
public addStatus(pageId: string | null, color: number | null, strokeColor: number | null): void {
if (pageId === null) {
this._default.color = color;
this._default.strokeColor = strokeColor;
} else {
this._storage.set(pageId, { color, strokeColor });
}
}
}
@@ -0,0 +1,71 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearDisplay
*
* Controls object visibility based on controller state.
*
*/
export class GearDisplay extends GearBase {
/** Pages where object is visible | 对象可见的页面列表 */
public pages: string[] = [];
private _visible: number = 0;
private _displayLockToken: number = 1;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this.pages = [];
}
public apply(): void {
this._displayLockToken++;
if (this._displayLockToken === 0) {
this._displayLockToken = 1;
}
if (
this.pages.length === 0 ||
(this._controller && this.pages.indexOf(this._controller.selectedPageId) !== -1)
) {
this._visible = 1;
} else {
this._visible = 0;
}
}
public updateState(): void {
// GearDisplay doesn't need to save state
}
/**
* Add display lock
*
*/
public addLock(): number {
this._visible++;
return this._displayLockToken;
}
/**
* Release display lock
*
*/
public releaseLock(token: number): void {
if (token === this._displayLockToken) {
this._visible--;
}
}
/**
* Check if object should be visible
*
*/
public override get connected(): boolean {
return this._controller === null || this._visible > 0;
}
}
@@ -0,0 +1,67 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearDisplay2
*
* Advanced display control that combines multiple controllers.
*
*/
export class GearDisplay2 extends GearBase {
/** Pages where object is visible | 对象可见的页面列表 */
public pages: string[] = [];
/** Condition: 0=AND, 1=OR | 条件:0=与,1=或 */
public condition: number = 0;
private _visible: number = 0;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this.pages = [];
}
public apply(): void {
if (
this.pages.length === 0 ||
(this._controller && this.pages.indexOf(this._controller.selectedPageId) !== -1)
) {
this._visible = 1;
} else {
this._visible = 0;
}
}
public updateState(): void {
// GearDisplay2 doesn't need to save state
}
/**
* Evaluate visibility with condition
*
*/
public evaluate(bConnected: boolean): boolean {
if (this._controller === null) {
return true;
}
if (this.condition === 0) {
// AND condition
return bConnected && this._visible > 0;
} else {
// OR condition
return bConnected || this._visible > 0;
}
}
/**
* Check if object should be visible
*
*/
public override get connected(): boolean {
return this._controller === null || this._visible > 0;
}
}
@@ -0,0 +1,53 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* GearFontSize
*
* Controls object font size based on controller state.
*
*/
export class GearFontSize extends GearBase {
private _storage: Map<string, number> = new Map();
private _default: number = 12;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = (this.owner.getProp(EObjectPropID.FontSize) as number) ?? 12;
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const fontSize = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.setProp(EObjectPropID.FontSize, fontSize);
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(
this._controller.selectedPageId,
(this.owner.getProp(EObjectPropID.FontSize) as number) ?? 12
);
}
/**
* Add status
*
*/
public addStatus(pageId: string | null, fontSize: number): void {
if (pageId === null) {
this._default = fontSize;
} else {
this._storage.set(pageId, fontSize);
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearIcon
*
* Controls object icon based on controller state.
*
*/
export class GearIcon extends GearBase {
private _storage: Map<string, string> = new Map();
private _default: string = '';
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = this.owner.icon ?? '';
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const icon = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.icon = icon;
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(this._controller.selectedPageId, this.owner.icon ?? '');
}
/**
* Add status
*
*/
public addStatus(pageId: string | null, icon: string): void {
if (pageId === null) {
this._default = icon;
} else {
this._storage.set(pageId, icon);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Look value for GearLook
* GearLook
*/
interface ILookValue {
alpha: number;
rotation: number;
grayed: boolean;
touchable: boolean;
}
/**
* GearLook
*
* Controls object appearance (alpha, rotation, grayed, touchable) based on controller state.
*
*/
export class GearLook extends GearBase {
private _storage: Map<string, ILookValue> = new Map();
private _default: ILookValue = { alpha: 1, rotation: 0, grayed: false, touchable: true };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
alpha: this.owner.alpha,
rotation: this.owner.rotation,
grayed: this.owner.grayed,
touchable: this.owner.touchable
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
// grayed and touchable cannot be tweened, apply immediately
this.owner._gearLocked = true;
this.owner.grayed = gv.grayed;
this.owner.touchable = gv.touchable;
this.owner._gearLocked = false;
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (this._tweener.endValue.x !== gv.alpha || this._tweener.endValue.y !== gv.rotation) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const oa = this.owner.alpha;
const or = this.owner.rotation;
if (oa !== gv.alpha || or !== gv.rotation) {
this._tweener = GTween.to2(oa, or, gv.alpha, gv.rotation, this.tweenConfig.duration)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'look')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.alpha = tweener.value.x;
this.owner.rotation = tweener.value.y;
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.alpha = gv.alpha;
this.owner.rotation = gv.rotation;
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const gv: ILookValue = {
alpha: this.owner.alpha,
rotation: this.owner.rotation,
grayed: this.owner.grayed,
touchable: this.owner.touchable
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status from buffer
*
*/
public addStatus(
pageId: string | null,
alpha: number,
rotation: number,
grayed: boolean,
touchable: boolean
): void {
if (pageId === null) {
this._default.alpha = alpha;
this._default.rotation = rotation;
this._default.grayed = grayed;
this._default.touchable = touchable;
} else {
this._storage.set(pageId, { alpha, rotation, grayed, touchable });
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Size value for GearSize
* GearSize
*/
interface ISizeValue {
width: number;
height: number;
scaleX: number;
scaleY: number;
}
/**
* GearSize
*
* Controls object size and scale based on controller state.
*
*/
export class GearSize extends GearBase {
private _storage: Map<string, ISizeValue> = new Map();
private _default: ISizeValue = { width: 0, height: 0, scaleX: 1, scaleY: 1 };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
width: this.owner.width,
height: this.owner.height,
scaleX: this.owner.scaleX,
scaleY: this.owner.scaleY
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (
this._tweener.endValue.x !== gv.width ||
this._tweener.endValue.y !== gv.height ||
this._tweener.endValue.z !== gv.scaleX ||
this._tweener.endValue.w !== gv.scaleY
) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const ow = this.owner.width;
const oh = this.owner.height;
const osx = this.owner.scaleX;
const osy = this.owner.scaleY;
if (ow !== gv.width || oh !== gv.height || osx !== gv.scaleX || osy !== gv.scaleY) {
this._tweener = GTween.to4(
ow,
oh,
osx,
osy,
gv.width,
gv.height,
gv.scaleX,
gv.scaleY,
this.tweenConfig.duration
)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'size')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.setSize(tweener.value.x, tweener.value.y);
this.owner.setScale(tweener.value.z, tweener.value.w);
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.setSize(gv.width, gv.height);
this.owner.setScale(gv.scaleX, gv.scaleY);
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const gv: ISizeValue = {
width: this.owner.width,
height: this.owner.height,
scaleX: this.owner.scaleX,
scaleY: this.owner.scaleY
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Update size from relation changes
*
*/
public updateFromRelations(dWidth: number, dHeight: number): void {
if (!this._controller) return;
for (const gv of this._storage.values()) {
gv.width += dWidth;
gv.height += dHeight;
}
this._default.width += dWidth;
this._default.height += dHeight;
this.updateState();
}
/**
* Add status from buffer
*
*/
public addStatus(
pageId: string | null,
width: number,
height: number,
scaleX: number,
scaleY: number
): void {
if (pageId === null) {
this._default.width = width;
this._default.height = height;
this._default.scaleX = scaleX;
this._default.scaleY = scaleY;
} else {
this._storage.set(pageId, { width, height, scaleX, scaleY });
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* GearText
*
* Controls object text content based on controller state.
*
*/
export class GearText extends GearBase {
private _storage: Map<string, string> = new Map();
private _default: string = '';
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = this.owner.text ?? '';
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const text = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.text = text;
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(this._controller.selectedPageId, this.owner.text ?? '');
}
/**
* Add status
*
*/
public addStatus(pageId: string | null, text: string): void {
if (pageId === null) {
this._default = text;
} else {
this._storage.set(pageId, text);
}
}
}
+159
View File
@@ -0,0 +1,159 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Position value for GearXY
* GearXY
*/
interface IPositionValue {
x: number;
y: number;
px: number;
py: number;
}
/**
* GearXY
*
* Controls object position based on controller state.
*
*/
export class GearXY extends GearBase {
/** Use percent positions | 使用百分比位置 */
public positionsInPercent: boolean = false;
private _storage: Map<string, IPositionValue> = new Map();
private _default: IPositionValue = { x: 0, y: 0, px: 0, py: 0 };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
const parent = this.owner.parent;
this._default = {
x: this.owner.x,
y: this.owner.y,
px: parent ? this.owner.x / parent.width : 0,
py: parent ? this.owner.y / parent.height : 0
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
const parent = this.owner.parent;
let ex: number;
let ey: number;
if (this.positionsInPercent && parent) {
ex = gv.px * parent.width;
ey = gv.py * parent.height;
} else {
ex = gv.x;
ey = gv.y;
}
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (this._tweener.endValue.x !== ex || this._tweener.endValue.y !== ey) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const ox = this.owner.x;
const oy = this.owner.y;
if (ox !== ex || oy !== ey) {
this._tweener = GTween.to2(ox, oy, ex, ey, this.tweenConfig.duration)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'xy')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.setXY(tweener.value.x, tweener.value.y);
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.setXY(ex, ey);
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const parent = this.owner.parent;
const gv: IPositionValue = {
x: this.owner.x,
y: this.owner.y,
px: parent ? this.owner.x / parent.width : 0,
py: parent ? this.owner.y / parent.height : 0
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Update positions from relation changes
*
*/
public updateFromRelations(dx: number, dy: number): void {
if (!this._controller || this.positionsInPercent) return;
for (const gv of this._storage.values()) {
gv.x += dx;
gv.y += dy;
}
this._default.x += dx;
this._default.y += dy;
this.updateState();
}
/**
* Add status from buffer
*
*/
public addStatus(pageId: string | null, x: number, y: number): void {
if (pageId === null) {
this._default.x = x;
this._default.y = y;
} else {
const gv = this._storage.get(pageId) ?? { x: 0, y: 0, px: 0, py: 0 };
gv.x = x;
gv.y = y;
this._storage.set(pageId, gv);
}
}
/**
* Add extended status (percent values)
*
*/
public addExtStatus(pageId: string | null, px: number, py: number): void {
if (pageId === null) {
this._default.px = px;
this._default.py = py;
} else {
const gv = this._storage.get(pageId);
if (gv) {
gv.px = px;
gv.py = py;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More