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

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

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

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

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

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

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

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

* feat(fairygui): 实现 UI 控件

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

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

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

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

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

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

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

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

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

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

View File

@@ -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';

View File

@@ -32,7 +32,6 @@
{ "path": "../core" },
{ "path": "../engine-core" },
{ "path": "../editor-core" },
{ "path": "../ui" },
{ "path": "../editor-runtime" }
]
}

View File

@@ -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.
* 渲染当前帧。

View File

@@ -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
);
}
}
}
/**

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;

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",

View File

@@ -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))
}

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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' }

View File

@@ -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
};
}
}

View File

@@ -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>
)

View File

@@ -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>

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

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -4,3 +4,4 @@ export { AssetFileInspector } from './AssetFileInspector';
export { RemoteEntityInspector } from './RemoteEntityInspector';
export { EntityInspector } from './EntityInspector';
export { PrefabInspector } from './PrefabInspector';
export { VirtualNodeInspector } from './VirtualNodeInspector';

View File

@@ -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();

View File

@@ -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 设计分辨率

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.
*/

View File

@@ -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;
}

View File

@@ -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.
* 从文件加载音频。

View 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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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",

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

View File

@@ -22,7 +22,6 @@
"include": ["src"],
"references": [
{ "path": "../core" },
{ "path": "../editor-core" },
{ "path": "../ui" }
{ "path": "../editor-core" }
]
}

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',

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);

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> {

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]

View File

@@ -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);
}
}

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};

View File

@@ -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);
}
}

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};

View File

@@ -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()

View File

@@ -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.
/// 精灵顶点着色器源代码。
///

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};

View File

@@ -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": "",

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"]
}
}

View File

@@ -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 相同的坐标转换:
* - FGUI左上角为原点Y 向下
* - 引擎中心为原点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();

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;

View File

@@ -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();

View File

@@ -0,0 +1,9 @@
/**
* FairyGUI Editor Inspectors
*
* Custom inspectors for FairyGUI components.
*
* FairyGUI 组件的自定义检视器
*/
export { FGUIInspectorContent, FGUIComponentInspector, fguiComponentInspector } from './FGUIInspector';

View File

@@ -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"]

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';
}
});

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"
}

View File

@@ -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": "",

View File

@@ -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);
};
}

View File

@@ -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 };

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';

View File

@@ -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;
}
}

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();
}
}

View File

@@ -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;
}
}
}
}

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

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

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);
}
}

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;
}
}

View File

@@ -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;
};
}

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'
}

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;
}
}

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();
}
}

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);
}

View File

@@ -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();
}
}

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

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

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);
}
}

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);
}
}

View File

@@ -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();
}
}

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);
}
}

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);
}
}

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();
}
}

View File

@@ -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;
}

View File

@@ -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()
};

View File

@@ -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();
}
}

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';

View File

@@ -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);
}
}
}
}

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
};
}

View File

@@ -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 });
}
}
}

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;
}

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 });
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

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);
}
}
}

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 });
}
}
}

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 });
}
}
}

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);
}
}
}

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