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:
@@ -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';
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
{ "path": "../core" },
|
||||
{ "path": "../engine-core" },
|
||||
{ "path": "../editor-core" },
|
||||
{ "path": "../ui" },
|
||||
{ "path": "../editor-runtime" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -215,6 +215,31 @@ export class EngineBridge implements ITextureEngineBridge, ITextureService, IDyn
|
||||
this.stats.spriteCount = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit mesh batch for rendering arbitrary 2D geometry.
|
||||
* 提交网格批次进行任意 2D 几何体渲染。
|
||||
*
|
||||
* Used for rendering ellipses, polygons, and other complex shapes.
|
||||
* 用于渲染椭圆、多边形和其他复杂形状。
|
||||
*
|
||||
* @param positions - Vertex positions [x, y, ...]
|
||||
* @param uvs - Texture coordinates [u, v, ...]
|
||||
* @param colors - Packed RGBA colors (one per vertex)
|
||||
* @param indices - Triangle indices
|
||||
* @param textureId - Texture ID (0 = white pixel)
|
||||
*/
|
||||
submitMeshBatch(
|
||||
positions: Float32Array,
|
||||
uvs: Float32Array,
|
||||
colors: Uint32Array,
|
||||
indices: Uint16Array,
|
||||
textureId: number
|
||||
): void {
|
||||
if (!this.initialized || positions.length === 0) return;
|
||||
|
||||
this.getEngine().submitMeshBatch(positions, uvs, colors, indices, textureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current frame.
|
||||
* 渲染当前帧。
|
||||
|
||||
@@ -68,6 +68,23 @@ export interface IRenderDataProvider {
|
||||
getRenderData(): readonly ProviderRenderData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh render data for arbitrary 2D geometry
|
||||
* 任意 2D 几何体的网格渲染数据
|
||||
*/
|
||||
export interface MeshRenderData {
|
||||
/** Vertex positions [x, y, ...] | 顶点位置 */
|
||||
positions: Float32Array;
|
||||
/** Texture coordinates [u, v, ...] | 纹理坐标 */
|
||||
uvs: Float32Array;
|
||||
/** Vertex colors (packed RGBA) | 顶点颜色 */
|
||||
colors: Uint32Array;
|
||||
/** Triangle indices | 三角形索引 */
|
||||
indices: Uint16Array;
|
||||
/** Texture ID (0 = white pixel) | 纹理 ID */
|
||||
textureId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for UI render data providers
|
||||
* UI 渲染数据提供者接口
|
||||
@@ -78,6 +95,8 @@ export interface IRenderDataProvider {
|
||||
export interface IUIRenderDataProvider extends IRenderDataProvider {
|
||||
/** Get UI render data | 获取 UI 渲染数据 */
|
||||
getRenderData(): readonly ProviderRenderData[];
|
||||
/** Get mesh render data for complex shapes | 获取复杂形状的网格渲染数据 */
|
||||
getMeshRenderData?(): readonly MeshRenderData[];
|
||||
/** @deprecated Use getRenderData() instead */
|
||||
getScreenSpaceRenderData?(): readonly ProviderRenderData[];
|
||||
/** @deprecated World space UI is no longer supported */
|
||||
@@ -538,6 +557,34 @@ export class EngineRenderSystem extends EntitySystem {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect mesh render data for complex shapes (ellipses, polygons, etc.)
|
||||
// 收集复杂形状(椭圆、多边形等)的网格渲染数据
|
||||
if (this.uiRenderDataProvider.getMeshRenderData) {
|
||||
const meshRenderData = this.uiRenderDataProvider.getMeshRenderData();
|
||||
if (meshRenderData.length > 0) {
|
||||
console.log(`[EngineRenderSystem] Submitting ${meshRenderData.length} mesh batches`);
|
||||
}
|
||||
for (const mesh of meshRenderData) {
|
||||
if (mesh.positions.length === 0) continue;
|
||||
|
||||
console.log('[EngineRenderSystem] Mesh batch:', {
|
||||
vertices: mesh.positions.length / 2,
|
||||
indices: mesh.indices.length,
|
||||
textureId: mesh.textureId
|
||||
});
|
||||
|
||||
// Submit mesh data directly to the engine
|
||||
// 直接将网格数据提交到引擎
|
||||
this.bridge.submitMeshBatch(
|
||||
mesh.positions,
|
||||
mesh.uvs,
|
||||
mesh.colors,
|
||||
mesh.indices,
|
||||
mesh.textureId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"@esengine/sprite-editor": "workspace:*",
|
||||
"@esengine/tilemap": "workspace:*",
|
||||
"@esengine/tilemap-editor": "workspace:*",
|
||||
"@esengine/ui": "workspace:*",
|
||||
"@esengine/ui-editor": "workspace:*",
|
||||
"@esengine/fairygui": "workspace:*",
|
||||
"@esengine/fairygui-editor": "workspace:*",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
|
||||
@@ -525,3 +525,11 @@ pub async fn read_binary_file_as_base64(path: String) -> Result<String, String>
|
||||
|
||||
Ok(STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Read binary file and return as raw bytes.
|
||||
/// 读取二进制文件并返回原始字节。
|
||||
#[tauri::command]
|
||||
pub async fn read_binary_file(file_path: String) -> Result<Vec<u8>, String> {
|
||||
fs::read(&file_path)
|
||||
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ fn main() {
|
||||
commands::write_json_file,
|
||||
commands::list_files_by_extension,
|
||||
commands::read_binary_file_as_base64,
|
||||
commands::read_binary_file,
|
||||
// Engine modules | 引擎模块
|
||||
commands::read_engine_modules_index,
|
||||
commands::read_module_manifest,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
import { ParticlePlugin } from '@esengine/particle-editor';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { UIPlugin } from '@esengine/ui-editor';
|
||||
import { FGUIPlugin } from '@esengine/fairygui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
@@ -63,7 +63,7 @@ export class PluginInstaller {
|
||||
{ name: 'CameraPlugin', plugin: CameraPlugin },
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'UIPlugin', plugin: UIPlugin },
|
||||
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
|
||||
@@ -48,7 +48,7 @@ import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { AudioSourceComponent } from '@esengine/audio';
|
||||
import { UITextComponent } from '@esengine/ui';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
@@ -129,7 +129,7 @@ export class ServiceRegistry {
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'UITextComponent', type: UITextComponent, editorName: 'UIText', category: 'components.category.ui', description: 'components.text.description', icon: 'Type' },
|
||||
{ name: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
|
||||
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
@@ -10,7 +9,6 @@ import { ICommand } from '../ICommand';
|
||||
* Transform state snapshot
|
||||
*/
|
||||
export interface TransformState {
|
||||
// TransformComponent
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
positionZ?: number;
|
||||
@@ -20,14 +18,6 @@ export interface TransformState {
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
scaleZ?: number;
|
||||
// UITransformComponent
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
uiScaleX?: number;
|
||||
uiScaleY?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,19 +31,17 @@ export type TransformOperationType = 'move' | 'rotate' | 'scale';
|
||||
* Transform command for undo/redo support
|
||||
*/
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private readonly componentType: 'transform' | 'uiTransform';
|
||||
private readonly timestamp: number;
|
||||
|
||||
constructor(
|
||||
private readonly messageHub: MessageHub,
|
||||
private readonly entity: Entity,
|
||||
private readonly component: Component,
|
||||
private readonly component: TransformComponent,
|
||||
private readonly operationType: TransformOperationType,
|
||||
private readonly oldState: TransformState,
|
||||
private newState: TransformState
|
||||
) {
|
||||
super();
|
||||
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
@@ -114,25 +102,16 @@ export class TransformCommand extends BaseCommand {
|
||||
* Apply transform state
|
||||
*/
|
||||
private applyState(state: TransformState): void {
|
||||
if (this.componentType === 'transform') {
|
||||
const transform = this.component as TransformComponent;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
} else {
|
||||
const uiTransform = this.component as UITransformComponent;
|
||||
if (state.x !== undefined) uiTransform.x = state.x;
|
||||
if (state.y !== undefined) uiTransform.y = state.y;
|
||||
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
|
||||
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
|
||||
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
|
||||
}
|
||||
const transform = this.component;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,18 +120,16 @@ export class TransformCommand extends BaseCommand {
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
const propertyName = this.operationType === 'move'
|
||||
? (this.componentType === 'transform' ? 'position' : 'x')
|
||||
? 'position'
|
||||
: this.operationType === 'rotate'
|
||||
? 'rotation'
|
||||
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
|
||||
: 'scale';
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName,
|
||||
value: this.componentType === 'transform'
|
||||
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
|
||||
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
|
||||
value: this.component[propertyName as keyof TransformComponent]
|
||||
});
|
||||
|
||||
// 通知 Inspector 刷新 | Notify Inspector to refresh
|
||||
@@ -176,18 +153,4 @@ export class TransformCommand extends BaseCommand {
|
||||
scaleZ: transform.scale.z
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 UITransformComponent 捕获状态
|
||||
* Capture state from UITransformComponent
|
||||
*/
|
||||
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
|
||||
return {
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation,
|
||||
uiScaleX: uiTransform.scaleX,
|
||||
uiScaleY: uiTransform.scaleY
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Entity, Core, HierarchySystem, HierarchyComponent, EntityTags, isFolder, PrefabSerializer, ComponentRegistry, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { EntityStoreService, MessageHub, CommandManager, EntityCreationRegistry, EntityCreationTemplate, PrefabService, AssetRegistryService, ProjectService, VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { useHierarchyStore } from '../stores';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -48,6 +49,36 @@ const categoryIconMap: Record<string, string> = {
|
||||
'other': 'MoreHorizontal',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map virtual node types to Lucide icon names
|
||||
* 将虚拟节点类型映射到 Lucide 图标名称
|
||||
*/
|
||||
const virtualNodeIconMap: Record<string, string> = {
|
||||
'Component': 'LayoutGrid',
|
||||
'Image': 'Image',
|
||||
'Graph': 'Square',
|
||||
'TextField': 'Type',
|
||||
'RichTextField': 'FileText',
|
||||
'Button': 'MousePointer',
|
||||
'List': 'List',
|
||||
'Loader': 'Loader',
|
||||
'ProgressBar': 'BarChart',
|
||||
'Slider': 'Sliders',
|
||||
'ComboBox': 'ChevronDown',
|
||||
'ScrollPane': 'Scroll',
|
||||
'Group': 'FolderOpen',
|
||||
'MovieClip': 'Film',
|
||||
'TextInput': 'FormInput',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon name for a virtual node type
|
||||
* 获取虚拟节点类型的图标名称
|
||||
*/
|
||||
function getVirtualNodeIcon(nodeType: string): string {
|
||||
return virtualNodeIconMap[nodeType] || 'Circle';
|
||||
}
|
||||
|
||||
// 实体类型到图标的映射
|
||||
const entityTypeIcons: Record<string, React.ReactNode> = {
|
||||
'World': <Mountain size={14} className="entity-type-icon world" />,
|
||||
@@ -78,6 +109,21 @@ interface EntityNode {
|
||||
depth: number;
|
||||
bIsFolder: boolean;
|
||||
hasChildren: boolean;
|
||||
/** Virtual nodes from components (e.g., FGUI internal nodes) | 组件的虚拟节点(如 FGUI 内部节点) */
|
||||
virtualNodes?: IVirtualNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened list item - can be either an entity node or a virtual node
|
||||
* 扁平化列表项 - 可以是实体节点或虚拟节点
|
||||
*/
|
||||
interface FlattenedItem {
|
||||
type: 'entity' | 'virtual';
|
||||
entityNode?: EntityNode;
|
||||
virtualNode?: IVirtualNode;
|
||||
depth: number;
|
||||
parentEntityId: number;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +186,15 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [editingEntityId, setEditingEntityId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
// Expanded virtual node IDs (format: "entityId:virtualNodeId")
|
||||
// 展开的虚拟节点 ID(格式:"entityId:virtualNodeId")
|
||||
const [expandedVirtualIds, setExpandedVirtualIds] = useState<Set<string>>(new Set());
|
||||
// Selected virtual node (format: "entityId:virtualNodeId")
|
||||
// 选中的虚拟节点(格式:"entityId:virtualNodeId")
|
||||
const [selectedVirtualId, setSelectedVirtualId] = useState<string | null>(null);
|
||||
// Refresh counter to force virtual nodes recollection
|
||||
// 刷新计数器,用于强制重新收集虚拟节点
|
||||
const [virtualNodeRefreshKey, setVirtualNodeRefreshKey] = useState(0);
|
||||
const { t, locale } = useLocale();
|
||||
|
||||
// Ref for auto-scrolling to selected item | 选中项自动滚动 ref
|
||||
@@ -173,6 +228,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 构建层级树结构
|
||||
* Build hierarchical tree structure
|
||||
*
|
||||
* Also collects virtual nodes from components using VirtualNodeRegistry.
|
||||
* 同时使用 VirtualNodeRegistry 收集组件的虚拟节点。
|
||||
*/
|
||||
const buildEntityTree = useCallback((rootEntities: Entity[]): EntityNode[] => {
|
||||
const scene = Core.scene;
|
||||
@@ -191,12 +250,17 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
}
|
||||
|
||||
// Collect virtual nodes from components
|
||||
// 从组件收集虚拟节点
|
||||
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(entity);
|
||||
|
||||
return {
|
||||
entity,
|
||||
children,
|
||||
depth,
|
||||
bIsFolder: bIsEntityFolder,
|
||||
hasChildren: children.length > 0
|
||||
hasChildren: children.length > 0 || virtualNodes.length > 0,
|
||||
virtualNodes: virtualNodes.length > 0 ? virtualNodes : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -205,17 +269,68 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 扁平化树为带深度信息的列表(用于渲染)
|
||||
* Flatten tree to list with depth info (for rendering)
|
||||
*
|
||||
* Also includes virtual nodes when their parent entity is expanded.
|
||||
* 当父实体展开时,也包含虚拟节点。
|
||||
*/
|
||||
const flattenTree = useCallback((nodes: EntityNode[], expandedSet: Set<number>): EntityNode[] => {
|
||||
const result: EntityNode[] = [];
|
||||
const flattenTree = useCallback((
|
||||
nodes: EntityNode[],
|
||||
expandedSet: Set<number>,
|
||||
expandedVirtualSet: Set<string>
|
||||
): FlattenedItem[] => {
|
||||
const result: FlattenedItem[] = [];
|
||||
|
||||
// Flatten virtual nodes recursively
|
||||
// 递归扁平化虚拟节点
|
||||
const flattenVirtualNodes = (
|
||||
virtualNodes: IVirtualNode[],
|
||||
parentEntityId: number,
|
||||
baseDepth: number
|
||||
) => {
|
||||
for (const vnode of virtualNodes) {
|
||||
const vnodeKey = `${parentEntityId}:${vnode.id}`;
|
||||
const hasVChildren = vnode.children && vnode.children.length > 0;
|
||||
|
||||
result.push({
|
||||
type: 'virtual',
|
||||
virtualNode: vnode,
|
||||
depth: baseDepth,
|
||||
parentEntityId,
|
||||
hasChildren: hasVChildren
|
||||
});
|
||||
|
||||
// If virtual node is expanded, add its children
|
||||
// 如果虚拟节点已展开,添加其子节点
|
||||
if (hasVChildren && expandedVirtualSet.has(vnodeKey)) {
|
||||
flattenVirtualNodes(vnode.children, parentEntityId, baseDepth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const traverse = (nodeList: EntityNode[]) => {
|
||||
for (const node of nodeList) {
|
||||
result.push(node);
|
||||
// Add entity node
|
||||
result.push({
|
||||
type: 'entity',
|
||||
entityNode: node,
|
||||
depth: node.depth,
|
||||
parentEntityId: node.entity.id,
|
||||
hasChildren: node.hasChildren
|
||||
});
|
||||
|
||||
const bIsExpanded = expandedSet.has(node.entity.id);
|
||||
if (bIsExpanded && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
if (bIsExpanded) {
|
||||
// Add child entities
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
|
||||
// Add virtual nodes after entity children
|
||||
// 在实体子节点后添加虚拟节点
|
||||
if (node.virtualNodes && node.virtualNodes.length > 0) {
|
||||
flattenVirtualNodes(node.virtualNodes, node.entity.id, node.depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -226,13 +341,92 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
/**
|
||||
* 层级树和扁平化列表
|
||||
* Hierarchy tree and flattened list
|
||||
*
|
||||
* virtualNodeRefreshKey is used to force rebuild when components change.
|
||||
* virtualNodeRefreshKey 用于在组件变化时强制重建。
|
||||
*/
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree]);
|
||||
const flattenedEntities = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds) : [],
|
||||
[entityTree, expandedIds, flattenTree]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const entityTree = useMemo(() => buildEntityTree(entities), [entities, buildEntityTree, virtualNodeRefreshKey]);
|
||||
const flattenedItems = useMemo(
|
||||
() => expandedIds.has(-1) ? flattenTree(entityTree, expandedIds, expandedVirtualIds) : [],
|
||||
[entityTree, expandedIds, expandedVirtualIds, flattenTree]
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggle virtual node expansion
|
||||
* 切换虚拟节点展开状态
|
||||
*/
|
||||
const toggleVirtualExpand = useCallback((parentEntityId: number, virtualNodeId: string) => {
|
||||
const key = `${parentEntityId}:${virtualNodeId}`;
|
||||
setExpandedVirtualIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle virtual node click
|
||||
* 处理虚拟节点点击
|
||||
*/
|
||||
const handleVirtualNodeClick = useCallback((parentEntityId: number, virtualNode: IVirtualNode) => {
|
||||
const key = `${parentEntityId}:${virtualNode.id}`;
|
||||
setSelectedVirtualId(key);
|
||||
// Clear entity selection when selecting virtual node
|
||||
// 选择虚拟节点时清除实体选择
|
||||
setSelectedIds(new Set());
|
||||
|
||||
// Publish event for Inspector to display virtual node properties
|
||||
// 发布事件以便 Inspector 显示虚拟节点属性
|
||||
messageHub.publish('virtual-node:selected', {
|
||||
parentEntityId,
|
||||
virtualNodeId: virtualNode.id,
|
||||
virtualNode
|
||||
});
|
||||
}, [messageHub, setSelectedIds]);
|
||||
|
||||
// Subscribe to scene:modified to refresh virtual nodes when components change
|
||||
// 订阅 scene:modified 事件,当组件变化时刷新虚拟节点
|
||||
useEffect(() => {
|
||||
const unsubModified = messageHub.subscribe('scene:modified', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
|
||||
// Also subscribe to component-specific events
|
||||
// 同时订阅组件相关事件
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', () => {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubModified();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
};
|
||||
}, [messageHub]);
|
||||
|
||||
// Subscribe to VirtualNodeRegistry changes (event-driven, no polling needed)
|
||||
// 订阅 VirtualNodeRegistry 变化(事件驱动,无需轮询)
|
||||
useEffect(() => {
|
||||
const unsubscribe = VirtualNodeRegistry.onChange((event) => {
|
||||
// Refresh if the changed entity is expanded
|
||||
// 如果变化的实体是展开的,则刷新
|
||||
if (expandedIds.has(event.entityId)) {
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [expandedIds]);
|
||||
|
||||
// 获取插件实体创建模板 | Get entity creation templates from plugins
|
||||
useEffect(() => {
|
||||
const updateTemplates = () => {
|
||||
@@ -257,6 +451,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
// Note: Scene/entity/remote subscriptions moved to useStoreSubscriptions
|
||||
|
||||
const handleEntityClick = (entity: Entity, e: React.MouseEvent) => {
|
||||
// Clear virtual node selection when selecting an entity
|
||||
// 选择实体时清除虚拟节点选择
|
||||
setSelectedVirtualId(null);
|
||||
|
||||
// Force refresh virtual nodes to pick up any newly loaded components
|
||||
// 强制刷新虚拟节点以获取新加载的组件
|
||||
setVirtualNodeRefreshKey(prev => prev + 1);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -927,22 +1129,26 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
}
|
||||
|
||||
// 方向键导航 | Arrow key navigation
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && flattenedEntities.length > 0) {
|
||||
// Only navigate entity nodes, skip virtual nodes
|
||||
// 只导航实体节点,跳过虚拟节点
|
||||
const entityItems = flattenedItems.filter(item => item.type === 'entity');
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && entityItems.length > 0) {
|
||||
e.preventDefault();
|
||||
const currentIndex = selectedId
|
||||
? flattenedEntities.findIndex(n => n.entity.id === selectedId)
|
||||
? entityItems.findIndex(item => item.entityNode?.entity.id === selectedId)
|
||||
: -1;
|
||||
|
||||
let newIndex: number;
|
||||
if (e.key === 'ArrowUp') {
|
||||
newIndex = currentIndex <= 0 ? flattenedEntities.length - 1 : currentIndex - 1;
|
||||
newIndex = currentIndex <= 0 ? entityItems.length - 1 : currentIndex - 1;
|
||||
} else {
|
||||
newIndex = currentIndex >= flattenedEntities.length - 1 ? 0 : currentIndex + 1;
|
||||
newIndex = currentIndex >= entityItems.length - 1 ? 0 : currentIndex + 1;
|
||||
}
|
||||
|
||||
const newEntity = flattenedEntities[newIndex]?.entity;
|
||||
const newEntity = entityItems[newIndex]?.entityNode?.entity;
|
||||
if (newEntity) {
|
||||
setSelectedIds(new Set([newEntity.id]));
|
||||
setSelectedVirtualId(null); // Clear virtual selection
|
||||
entityStore.selectEntity(newEntity);
|
||||
messageHub.publish('entity:selected', { entity: newEntity });
|
||||
}
|
||||
@@ -952,7 +1158,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, isShowingRemote, editingEntityId, flattenedEntities, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
|
||||
}, [selectedId, isShowingRemote, editingEntityId, flattenedItems, entityStore, messageHub, handleStartRename, handleConfirmRename, handleCancelRename, handleDuplicateEntity]);
|
||||
|
||||
/**
|
||||
* 创建文件夹实体
|
||||
@@ -1303,107 +1509,164 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Entity Items */}
|
||||
{flattenedEntities.map((node) => {
|
||||
const { entity, depth, hasChildren, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
const bIsPrefabInstance = isEntityPrefabInstance(entity);
|
||||
{/* Hierarchical Entity and Virtual Node Items */}
|
||||
{flattenedItems.map((item, index) => {
|
||||
// Render entity node
|
||||
if (item.type === 'entity' && item.entityNode) {
|
||||
const node = item.entityNode;
|
||||
const { entity, bIsFolder } = node;
|
||||
const bIsExpanded = expandedIds.has(entity.id);
|
||||
const bIsSelected = selectedIds.has(entity.id);
|
||||
const bIsDragging = draggedEntityId === entity.id;
|
||||
const currentDropTarget = dropTarget?.entityId === entity.id ? dropTarget : null;
|
||||
const bIsPrefabInstance = isEntityPrefabInstance(entity);
|
||||
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + depth * 16;
|
||||
// 计算缩进 (每层 16px,加上基础 8px)
|
||||
const indent = 8 + item.depth * 16;
|
||||
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
// 构建 drop indicator 类名
|
||||
let dropIndicatorClass = '';
|
||||
if (currentDropTarget) {
|
||||
dropIndicatorClass = `drop-${currentDropTarget.indicator}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`entity-${entity.id}`}
|
||||
ref={bIsSelected ? selectedItemRef : undefined}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{isEntityVisible(entity) ? (
|
||||
<Eye
|
||||
size={12}
|
||||
className="item-icon visibility"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff
|
||||
size={12}
|
||||
className="item-icon visibility hidden"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{item.hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
{editingEntityId === entity.id ? (
|
||||
<input
|
||||
className="outliner-item-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleConfirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="outliner-item-name"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingEntityId(entity.id);
|
||||
setEditingName(entity.name || '');
|
||||
}}
|
||||
>
|
||||
{entity.name || `Entity ${entity.id}`}
|
||||
</span>
|
||||
)}
|
||||
{/* 预制体实例徽章 | Prefab instance badge */}
|
||||
{bIsPrefabInstance && (
|
||||
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
|
||||
P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entity.id}
|
||||
ref={bIsSelected ? selectedItemRef : undefined}
|
||||
className={`outliner-item ${bIsSelected ? 'selected' : ''} ${bIsDragging ? 'dragging' : ''} ${dropIndicatorClass} ${bIsPrefabInstance ? 'prefab-instance' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
draggable
|
||||
onClick={(e) => handleEntityClick(entity, e)}
|
||||
onDragStart={(e) => handleDragStart(e, entity.id)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, node)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntityClick(entity, e);
|
||||
handleContextMenu(e, entity.id);
|
||||
}}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{isEntityVisible(entity) ? (
|
||||
<Eye
|
||||
size={12}
|
||||
className="item-icon visibility"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff
|
||||
size={12}
|
||||
className="item-icon visibility hidden"
|
||||
onClick={(e) => handleToggleVisibility(entity, e)}
|
||||
/>
|
||||
)}
|
||||
// Render virtual node (read-only)
|
||||
// 渲染虚拟节点(只读)
|
||||
if (item.type === 'virtual' && item.virtualNode) {
|
||||
const vnode = item.virtualNode;
|
||||
const vnodeKey = `${item.parentEntityId}:${vnode.id}`;
|
||||
const bIsVExpanded = expandedVirtualIds.has(vnodeKey);
|
||||
const bIsVSelected = selectedVirtualId === vnodeKey;
|
||||
|
||||
// 计算缩进
|
||||
const indent = 8 + item.depth * 16;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`virtual-${vnodeKey}-${index}`}
|
||||
className={`outliner-item virtual-node ${bIsVSelected ? 'selected' : ''} ${!vnode.visible ? 'hidden-node' : ''}`}
|
||||
style={{ paddingLeft: `${indent}px` }}
|
||||
onClick={() => handleVirtualNodeClick(item.parentEntityId, vnode)}
|
||||
>
|
||||
<div className="outliner-item-icons">
|
||||
{vnode.visible ? (
|
||||
<Eye size={12} className="item-icon visibility" />
|
||||
) : (
|
||||
<EyeOff size={12} className="item-icon visibility hidden" />
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{item.hasChildren ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleVirtualExpand(item.parentEntityId, vnode.id);
|
||||
}}
|
||||
>
|
||||
{bIsVExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{/* 虚拟节点类型图标 */}
|
||||
{getIconComponent(getVirtualNodeIcon(vnode.type), 14)}
|
||||
<span className="outliner-item-name virtual-name">
|
||||
{vnode.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="outliner-item-type virtual-type">{vnode.type}</div>
|
||||
</div>
|
||||
<div className="outliner-item-content">
|
||||
{/* 展开/折叠按钮 */}
|
||||
{hasChildren || bIsFolder ? (
|
||||
<span
|
||||
className="outliner-item-expand clickable"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(entity.id); }}
|
||||
>
|
||||
{bIsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="outliner-item-expand" />
|
||||
)}
|
||||
{getEntityIcon(entity)}
|
||||
{editingEntityId === entity.id ? (
|
||||
<input
|
||||
className="outliner-item-name-input"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={handleConfirmRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmRename();
|
||||
if (e.key === 'Escape') handleCancelRename();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="outliner-item-name"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingEntityId(entity.id);
|
||||
setEditingName(entity.name || '');
|
||||
}}
|
||||
>
|
||||
{entity.name || `Entity ${entity.id}`}
|
||||
</span>
|
||||
)}
|
||||
{/* 预制体实例徽章 | Prefab instance badge */}
|
||||
{bIsPrefabInstance && (
|
||||
<span className="prefab-badge" title={t('inspector.prefab.instance', {}, 'Prefab Instance')}>
|
||||
P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="outliner-item-type">{getEntityType(entity)}</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -184,14 +184,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Initial values for profiler:',
|
||||
Array.from(initialValues.entries()).filter(([k]) => k.startsWith('profiler.')));
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
newValues.set(key, value);
|
||||
|
||||
// When preset is selected, also update width and height values
|
||||
// 当选择预设时,同时更新宽度和高度值
|
||||
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newValues.set('project.uiDesignResolution.width', w);
|
||||
newValues.set('project.uiDesignResolution.height', h);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(newValues);
|
||||
|
||||
const newErrors = new Map(errors);
|
||||
@@ -218,7 +227,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
|
||||
if (!shouldDeferSave) {
|
||||
settings.set(key, value);
|
||||
console.log(`[SettingsWindow] Saved ${key}:`, value);
|
||||
|
||||
// 触发设置变更事件
|
||||
// Trigger settings changed event
|
||||
@@ -237,28 +245,27 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
let uiResolutionChanged = false;
|
||||
let newWidth = 1920;
|
||||
let newHeight = 1080;
|
||||
// Get width and height directly from values - these are the actual UI input values
|
||||
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
|
||||
const widthFromValues = values.get('project.uiDesignResolution.width');
|
||||
const heightFromValues = values.get('project.uiDesignResolution.height');
|
||||
|
||||
// Use the width/height values directly (they are always set from either user input or initial load)
|
||||
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
|
||||
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
|
||||
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
|
||||
|
||||
// Check if resolution differs from saved config
|
||||
// 检查分辨率是否与保存的配置不同
|
||||
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
|
||||
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
|
||||
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.uiDesignResolution.width') {
|
||||
newWidth = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.height') {
|
||||
newHeight = value;
|
||||
uiResolutionChanged = true;
|
||||
} else if (key === 'project.uiDesignResolution.preset') {
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
uiResolutionChanged = true;
|
||||
}
|
||||
} else if (key === 'project.disabledModules') {
|
||||
if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
@@ -270,7 +277,9 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
}
|
||||
|
||||
if (uiResolutionChanged && projectService) {
|
||||
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
@@ -570,14 +579,14 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={handleCancel}>
|
||||
<div className="settings-overlay" onClick={handleSave}>
|
||||
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Left Sidebar */}
|
||||
<div className="settings-sidebar-new">
|
||||
<div className="settings-sidebar-header">
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('settingsWindow.editorPreferences')}</span>
|
||||
<button className="settings-sidebar-close" onClick={handleCancel}>
|
||||
<button className="settings-sidebar-close" onClick={handleSave}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useLocale } from '../hooks/useLocale';
|
||||
import { EngineService } from '../services/EngineService';
|
||||
import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core';
|
||||
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget, VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { UITransformComponent } from '@esengine/ui';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RuntimeResolver } from '../services/RuntimeResolver';
|
||||
@@ -302,7 +302,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const transformModeRef = useRef<TransformMode>('select');
|
||||
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
|
||||
const initialTransformStateRef = useRef<TransformState | null>(null);
|
||||
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
|
||||
const transformComponentRef = useRef<TransformComponent | null>(null);
|
||||
const snapEnabledRef = useRef(true);
|
||||
const gridSnapRef = useRef(10);
|
||||
const rotationSnapRef = useRef(15);
|
||||
@@ -454,18 +454,48 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
if (gizmoService) {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
const zoom = camera2DZoomRef.current;
|
||||
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
|
||||
const clickResult = gizmoService.handleClickEx(worldPos.x, worldPos.y, zoom);
|
||||
|
||||
if (hitEntityId !== null) {
|
||||
if (clickResult !== null) {
|
||||
// Find and select the hit entity
|
||||
// 找到并选中命中的实体
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
const hitEntity = scene.entities.findEntityById(hitEntityId);
|
||||
const hitEntity = scene.entities.findEntityById(clickResult.entityId);
|
||||
if (hitEntity && messageHubRef.current) {
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService);
|
||||
entityStore?.selectEntity(hitEntity);
|
||||
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
|
||||
|
||||
// Check if clicked on a virtual node
|
||||
// 检查是否点击了虚拟节点
|
||||
if (clickResult.virtualNodeId) {
|
||||
// Get the virtual node data from VirtualNodeRegistry
|
||||
// 从 VirtualNodeRegistry 获取虚拟节点数据
|
||||
const virtualNodes = VirtualNodeRegistry.getAllVirtualNodesForEntity(hitEntity);
|
||||
const findVirtualNode = (nodes: typeof virtualNodes, targetId: string): typeof virtualNodes[0] | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) return node;
|
||||
const found = findVirtualNode(node.children, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const virtualNode = findVirtualNode(virtualNodes, clickResult.virtualNodeId);
|
||||
|
||||
if (virtualNode) {
|
||||
// Publish virtual-node:selected event (will trigger Inspector update)
|
||||
// 发布 virtual-node:selected 事件(将触发 Inspector 更新)
|
||||
messageHubRef.current.publish('virtual-node:selected', {
|
||||
parentEntityId: clickResult.entityId,
|
||||
virtualNodeId: clickResult.virtualNodeId,
|
||||
virtualNode
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Normal entity selection
|
||||
// 普通实体选择
|
||||
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
|
||||
}
|
||||
e.preventDefault();
|
||||
return; // Don't start camera pan
|
||||
}
|
||||
@@ -487,13 +517,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
const entity = selectedEntityRef.current;
|
||||
if (entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (transform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
|
||||
transformComponentRef.current = transform;
|
||||
} else if (uiTransform) {
|
||||
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
|
||||
transformComponentRef.current = uiTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,63 +599,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
}
|
||||
}
|
||||
|
||||
// Try UITransformComponent
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (uiTransform) {
|
||||
if (mode === 'move') {
|
||||
uiTransform.x += worldDelta.x;
|
||||
uiTransform.y += worldDelta.y;
|
||||
} else if (mode === 'rotate') {
|
||||
const rotationSpeed = 0.01;
|
||||
uiTransform.rotation += deltaX * rotationSpeed;
|
||||
} else if (mode === 'scale') {
|
||||
const oldWidth = uiTransform.width * uiTransform.scaleX;
|
||||
const oldHeight = uiTransform.height * uiTransform.scaleY;
|
||||
|
||||
// pivot点的世界坐标(缩放前)
|
||||
const pivotWorldX = uiTransform.x + oldWidth * uiTransform.pivotX;
|
||||
const pivotWorldY = uiTransform.y + oldHeight * uiTransform.pivotY;
|
||||
|
||||
const startDist = Math.sqrt((worldStart.x - pivotWorldX) ** 2 + (worldStart.y - pivotWorldY) ** 2);
|
||||
const endDist = Math.sqrt((worldEnd.x - pivotWorldX) ** 2 + (worldEnd.y - pivotWorldY) ** 2);
|
||||
|
||||
if (startDist > 0) {
|
||||
const scaleFactor = endDist / startDist;
|
||||
const newScaleX = uiTransform.scaleX * scaleFactor;
|
||||
const newScaleY = uiTransform.scaleY * scaleFactor;
|
||||
|
||||
const newWidth = uiTransform.width * newScaleX;
|
||||
const newHeight = uiTransform.height * newScaleY;
|
||||
|
||||
// 调整位置使pivot点保持不动
|
||||
uiTransform.x = pivotWorldX - newWidth * uiTransform.pivotX;
|
||||
uiTransform.y = pivotWorldY - newHeight * uiTransform.pivotY;
|
||||
uiTransform.scaleX = newScaleX;
|
||||
uiTransform.scaleY = newScaleY;
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transform display for UI | 更新 UI 的实时变换显示
|
||||
setLiveTransform({
|
||||
type: mode as 'move' | 'rotate' | 'scale',
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
rotation: uiTransform.rotation * 180 / Math.PI,
|
||||
scaleX: uiTransform.scaleX,
|
||||
scaleY: uiTransform.scaleY
|
||||
});
|
||||
|
||||
if (messageHubRef.current) {
|
||||
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
|
||||
messageHubRef.current.publish('component:property:changed', {
|
||||
entity,
|
||||
component: uiTransform,
|
||||
propertyName,
|
||||
value: uiTransform[propertyName]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
// Not dragging - update gizmo hover state
|
||||
@@ -683,18 +652,6 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
}
|
||||
}
|
||||
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
if (uiTransform) {
|
||||
if (mode === 'move') {
|
||||
uiTransform.x = snapToGrid(uiTransform.x);
|
||||
uiTransform.y = snapToGrid(uiTransform.y);
|
||||
} else if (mode === 'rotate') {
|
||||
uiTransform.rotation = snapRotation(uiTransform.rotation);
|
||||
} else if (mode === 'scale') {
|
||||
uiTransform.scaleX = snapScale(uiTransform.scaleX);
|
||||
uiTransform.scaleY = snapScale(uiTransform.scaleY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
|
||||
@@ -705,13 +662,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
|
||||
if (entity && initialState && component && hub && cmdManager) {
|
||||
const mode = transformModeRef.current as TransformOperationType;
|
||||
let newState: TransformState;
|
||||
|
||||
if (component instanceof TransformComponent) {
|
||||
newState = TransformCommand.captureTransformState(component);
|
||||
} else {
|
||||
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
|
||||
}
|
||||
const newState = TransformCommand.captureTransformState(component);
|
||||
|
||||
// Only create command if state actually changed | 只有状态实际改变时才创建命令
|
||||
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
|
||||
@@ -1715,58 +1666,115 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
|
||||
|
||||
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
|
||||
const assetGuid = e.dataTransfer.getData('asset-guid');
|
||||
const lowerPath = assetPath?.toLowerCase() || '';
|
||||
|
||||
if (!assetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for supported asset types | 检查支持的资产类型
|
||||
const isPrefab = lowerPath.endsWith('.prefab');
|
||||
const isFui = lowerPath.endsWith('.fui');
|
||||
|
||||
if (!isPrefab && !isFui) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 获取服务 | Get services
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
const scene = Core.scene;
|
||||
|
||||
if (!entityStore || !scene || !messageHub) {
|
||||
console.error('[Viewport] Required services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
|
||||
const canvas = canvasRef.current;
|
||||
let worldPos = { x: 0, y: 0 };
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
const canvasX = screenX * dpr;
|
||||
const canvasY = screenY * dpr;
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
worldPos = {
|
||||
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
|
||||
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取预制体文件 | Read prefab file
|
||||
const prefabJson = await TauriAPI.readFileContent(assetPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
// 获取服务 | Get services
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
|
||||
if (!entityStore || !messageHub || !commandManager) {
|
||||
console.error('[Viewport] Required services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
|
||||
const canvas = canvasRef.current;
|
||||
let worldPos = { x: 0, y: 0 };
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
const canvasX = screenX * dpr;
|
||||
const canvasY = screenY * dpr;
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
worldPos = {
|
||||
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
|
||||
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
|
||||
};
|
||||
}
|
||||
|
||||
// 创建实例化命令 | Create instantiate command
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
prefabData,
|
||||
{
|
||||
position: worldPos,
|
||||
trackInstance: true
|
||||
if (isPrefab) {
|
||||
// 处理预制体 | Handle prefab
|
||||
if (!commandManager) {
|
||||
console.error('[Viewport] CommandManager not available');
|
||||
return;
|
||||
}
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
|
||||
const prefabJson = await TauriAPI.readFileContent(assetPath);
|
||||
const prefabData = PrefabSerializer.deserialize(prefabJson);
|
||||
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
messageHub,
|
||||
prefabData,
|
||||
{
|
||||
position: worldPos,
|
||||
trackInstance: true
|
||||
}
|
||||
);
|
||||
commandManager.execute(command);
|
||||
|
||||
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
|
||||
} else if (isFui) {
|
||||
// 处理 FUI 文件 | Handle FUI file
|
||||
const filename = assetPath.split(/[/\\]/).pop() || 'FGUI View';
|
||||
const entityName = filename.replace('.fui', '');
|
||||
|
||||
// 生成唯一名称 | Generate unique name
|
||||
const existingCount = entityStore.getAllEntities()
|
||||
.filter((ent: Entity) => ent.name.startsWith(entityName)).length;
|
||||
const finalName = existingCount > 0 ? `${entityName} ${existingCount + 1}` : entityName;
|
||||
|
||||
// 创建实体 | Create entity
|
||||
const entity = scene.createEntity(finalName);
|
||||
|
||||
// 添加 TransformComponent | Add TransformComponent
|
||||
const transform = new TransformComponent();
|
||||
transform.position.x = worldPos.x;
|
||||
transform.position.y = worldPos.y;
|
||||
entity.addComponent(transform);
|
||||
|
||||
// 添加 FGUIComponent | Add FGUIComponent
|
||||
const fguiComponent = new FGUIComponent();
|
||||
// 优先使用 GUID,如果没有则使用路径(编辑器会通过 AssetRegistry 解析)
|
||||
// Prefer GUID, fallback to path (editor resolves via AssetRegistry)
|
||||
fguiComponent.packageGuid = assetGuid || assetPath;
|
||||
fguiComponent.width = 1920;
|
||||
fguiComponent.height = 1080;
|
||||
entity.addComponent(fguiComponent);
|
||||
|
||||
// 注册并选中实体 | Register and select entity
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
console.log(`[Viewport] FGUI entity created at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${finalName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Viewport] Failed to instantiate prefab:', error);
|
||||
console.error('[Viewport] Failed to handle drop:', error);
|
||||
messageHub?.publish('notification:error', {
|
||||
title: 'Drop Failed',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}, [messageHub, commandManager]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@ import {
|
||||
ExtensionInspector,
|
||||
AssetFileInspector,
|
||||
RemoteEntityInspector,
|
||||
PrefabInspector
|
||||
PrefabInspector,
|
||||
VirtualNodeInspector
|
||||
} from './views';
|
||||
import { EntityInspectorPanel } from '../inspector';
|
||||
|
||||
@@ -112,5 +113,14 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
);
|
||||
}
|
||||
|
||||
if (target.type === 'virtual-node') {
|
||||
return (
|
||||
<VirtualNodeInspector
|
||||
parentEntityId={target.data.parentEntityId}
|
||||
virtualNode={target.data.virtualNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, InspectorRegistry, CommandManager } from '@esengine/editor-core';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
|
||||
export interface InspectorProps {
|
||||
entityStore: EntityStoreService;
|
||||
@@ -20,11 +21,22 @@ export interface AssetFileInfo {
|
||||
|
||||
type ExtensionData = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Virtual node target data
|
||||
* 虚拟节点目标数据
|
||||
*/
|
||||
export interface VirtualNodeTargetData {
|
||||
parentEntityId: number;
|
||||
virtualNodeId: string;
|
||||
virtualNode: IVirtualNode;
|
||||
}
|
||||
|
||||
export type InspectorTarget =
|
||||
| { type: 'entity'; data: Entity }
|
||||
| { type: 'remote-entity'; data: RemoteEntity; details?: EntityDetails }
|
||||
| { type: 'asset-file'; data: AssetFileInfo; content?: string; isImage?: boolean }
|
||||
| { type: 'extension'; data: ExtensionData }
|
||||
| { type: 'virtual-node'; data: VirtualNodeTargetData }
|
||||
| null;
|
||||
|
||||
export interface RemoteEntity {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 虚拟节点检查器
|
||||
* Virtual Node Inspector
|
||||
*
|
||||
* 显示 FGUI 等组件内部虚拟节点的只读属性
|
||||
* Displays read-only properties of virtual nodes from components like FGUI
|
||||
*/
|
||||
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import { Box, Eye, EyeOff, Move, Maximize2, RotateCw, Palette, Type, Image, Square, Layers, MousePointer, Sliders } from 'lucide-react';
|
||||
import '../../../styles/VirtualNodeInspector.css';
|
||||
|
||||
interface VirtualNodeInspectorProps {
|
||||
parentEntityId: number;
|
||||
virtualNode: IVirtualNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number to fixed decimal places
|
||||
* 格式化数字到固定小数位
|
||||
*/
|
||||
function formatNumber(value: number | undefined, decimals: number = 2): string {
|
||||
if (value === undefined || value === null) return '-';
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Property row component
|
||||
* 属性行组件
|
||||
*/
|
||||
function PropertyRow({ label, value, icon }: { label: string; value: React.ReactNode; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="virtual-node-property-row">
|
||||
<span className="property-label">
|
||||
{icon && <span className="property-icon">{icon}</span>}
|
||||
{label}
|
||||
</span>
|
||||
<span className="property-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section component
|
||||
* 分组组件
|
||||
*/
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="virtual-node-section">
|
||||
<div className="section-header">{title}</div>
|
||||
<div className="section-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color swatch component for displaying colors
|
||||
* 颜色色块组件
|
||||
*/
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<span className="color-swatch-wrapper">
|
||||
<span
|
||||
className="color-swatch"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="color-value">{color}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property key is for common/transform properties
|
||||
* 检查属性键是否为公共/变换属性
|
||||
*/
|
||||
const COMMON_PROPS = new Set([
|
||||
'className', 'x', 'y', 'width', 'height', 'alpha', 'visible',
|
||||
'touchable', 'rotation', 'scaleX', 'scaleY', 'pivotX', 'pivotY', 'grayed'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Property categories for type-specific display
|
||||
* 类型特定显示的属性分类
|
||||
*/
|
||||
const TYPE_SPECIFIC_SECTIONS: Record<string, { title: string; icon: React.ReactNode; props: string[] }> = {
|
||||
Graph: {
|
||||
title: '图形属性 | Graph',
|
||||
icon: <Square size={12} />,
|
||||
props: ['graphType', 'lineSize', 'lineColor', 'fillColor', 'cornerRadius', 'sides', 'startAngle']
|
||||
},
|
||||
Image: {
|
||||
title: '图像属性 | Image',
|
||||
icon: <Image size={12} />,
|
||||
props: ['color', 'flip', 'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
|
||||
},
|
||||
TextField: {
|
||||
title: '文本属性 | Text',
|
||||
icon: <Type size={12} />,
|
||||
props: ['text', 'font', 'fontSize', 'color', 'align', 'valign', 'leading', 'letterSpacing',
|
||||
'bold', 'italic', 'underline', 'singleLine', 'autoSize', 'stroke', 'strokeColor']
|
||||
},
|
||||
Loader: {
|
||||
title: '加载器属性 | Loader',
|
||||
icon: <Image size={12} />,
|
||||
props: ['url', 'align', 'verticalAlign', 'fill', 'shrinkOnly', 'autoSize', 'color',
|
||||
'fillMethod', 'fillOrigin', 'fillClockwise', 'fillAmount']
|
||||
},
|
||||
Button: {
|
||||
title: '按钮属性 | Button',
|
||||
icon: <MousePointer size={12} />,
|
||||
props: ['title', 'icon', 'mode', 'selected', 'titleColor', 'titleFontSize',
|
||||
'selectedTitle', 'selectedIcon']
|
||||
},
|
||||
List: {
|
||||
title: '列表属性 | List',
|
||||
icon: <Layers size={12} />,
|
||||
props: ['defaultItem', 'itemCount', 'selectedIndex', 'scrollPane']
|
||||
},
|
||||
ProgressBar: {
|
||||
title: '进度条属性 | Progress',
|
||||
icon: <Sliders size={12} />,
|
||||
props: ['value', 'max']
|
||||
},
|
||||
Slider: {
|
||||
title: '滑块属性 | Slider',
|
||||
icon: <Sliders size={12} />,
|
||||
props: ['value', 'max']
|
||||
},
|
||||
Component: {
|
||||
title: '组件属性 | Component',
|
||||
icon: <Layers size={12} />,
|
||||
props: ['numChildren', 'numControllers', 'numTransitions']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a property value for display
|
||||
* 格式化属性值以供显示
|
||||
*/
|
||||
function formatPropertyValue(key: string, value: unknown): React.ReactNode {
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Color properties - show color swatch
|
||||
if (typeof value === 'string' && (
|
||||
key.toLowerCase().includes('color') ||
|
||||
key === 'fillColor' ||
|
||||
key === 'lineColor' ||
|
||||
key === 'strokeColor' ||
|
||||
key === 'titleColor'
|
||||
)) {
|
||||
if (value.startsWith('#') || value.startsWith('rgb')) {
|
||||
return <ColorSwatch color={value} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Truncate long strings
|
||||
if (value.length > 50) {
|
||||
return value.substring(0, 47) + '...';
|
||||
}
|
||||
return value || '-';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function VirtualNodeInspector({ parentEntityId, virtualNode }: VirtualNodeInspectorProps) {
|
||||
const { name, type, visible, x, y, width, height, data } = virtualNode;
|
||||
|
||||
// Extract additional properties from data
|
||||
// 从 data 中提取额外属性
|
||||
const alpha = data.alpha as number | undefined;
|
||||
const rotation = data.rotation as number | undefined;
|
||||
const scaleX = data.scaleX as number | undefined;
|
||||
const scaleY = data.scaleY as number | undefined;
|
||||
const touchable = data.touchable as boolean | undefined;
|
||||
const grayed = data.grayed as boolean | undefined;
|
||||
const pivotX = data.pivotX as number | undefined;
|
||||
const pivotY = data.pivotY as number | undefined;
|
||||
|
||||
// Get type-specific section config
|
||||
const typeSection = TYPE_SPECIFIC_SECTIONS[type];
|
||||
|
||||
// Collect type-specific properties
|
||||
const typeSpecificProps: Array<{ key: string; value: unknown }> = [];
|
||||
const otherProps: Array<{ key: string; value: unknown }> = [];
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (COMMON_PROPS.has(key)) {
|
||||
return; // Skip common props
|
||||
}
|
||||
|
||||
if (typeSection?.props.includes(key)) {
|
||||
typeSpecificProps.push({ key, value });
|
||||
} else {
|
||||
otherProps.push({ key, value });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="entity-inspector virtual-node-inspector">
|
||||
{/* Header */}
|
||||
<div className="virtual-node-header">
|
||||
<Box size={16} className="header-icon" />
|
||||
<div className="header-info">
|
||||
<div className="header-name">{name}</div>
|
||||
<div className="header-type">{type}</div>
|
||||
</div>
|
||||
<div className="header-badge">
|
||||
Virtual Node
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only notice */}
|
||||
<div className="virtual-node-notice">
|
||||
此节点为只读,属性由运行时动态生成
|
||||
</div>
|
||||
|
||||
{/* Basic Properties */}
|
||||
<Section title="基本属性 | Basic">
|
||||
<PropertyRow
|
||||
label="Visible"
|
||||
value={visible ? <Eye size={14} /> : <EyeOff size={14} className="disabled" />}
|
||||
/>
|
||||
{touchable !== undefined && (
|
||||
<PropertyRow
|
||||
label="Touchable"
|
||||
value={touchable ? 'Yes' : 'No'}
|
||||
/>
|
||||
)}
|
||||
{grayed !== undefined && (
|
||||
<PropertyRow
|
||||
label="Grayed"
|
||||
value={grayed ? 'Yes' : 'No'}
|
||||
/>
|
||||
)}
|
||||
{alpha !== undefined && (
|
||||
<PropertyRow
|
||||
label="Alpha"
|
||||
value={formatNumber(alpha)}
|
||||
icon={<Palette size={12} />}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Transform */}
|
||||
<Section title="变换 | Transform">
|
||||
<PropertyRow
|
||||
label="Position"
|
||||
value={`(${formatNumber(x)}, ${formatNumber(y)})`}
|
||||
icon={<Move size={12} />}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="Size"
|
||||
value={`${formatNumber(width)} × ${formatNumber(height)}`}
|
||||
icon={<Maximize2 size={12} />}
|
||||
/>
|
||||
{(rotation !== undefined && rotation !== 0) && (
|
||||
<PropertyRow
|
||||
label="Rotation"
|
||||
value={`${formatNumber(rotation)}°`}
|
||||
icon={<RotateCw size={12} />}
|
||||
/>
|
||||
)}
|
||||
{(scaleX !== undefined || scaleY !== undefined) && (
|
||||
<PropertyRow
|
||||
label="Scale"
|
||||
value={`(${formatNumber(scaleX ?? 1)}, ${formatNumber(scaleY ?? 1)})`}
|
||||
/>
|
||||
)}
|
||||
{(pivotX !== undefined || pivotY !== undefined) && (
|
||||
<PropertyRow
|
||||
label="Pivot"
|
||||
value={`(${formatNumber(pivotX ?? 0)}, ${formatNumber(pivotY ?? 0)})`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Type-Specific Properties */}
|
||||
{typeSection && typeSpecificProps.length > 0 && (
|
||||
<Section title={typeSection.title}>
|
||||
{typeSpecificProps.map(({ key, value }) => (
|
||||
<PropertyRow
|
||||
key={key}
|
||||
label={key}
|
||||
value={formatPropertyValue(key, value)}
|
||||
icon={key === typeSection.props[0] ? typeSection.icon : undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Other Properties */}
|
||||
{otherProps.length > 0 && (
|
||||
<Section title="其他属性 | Other">
|
||||
{otherProps.map(({ key, value }) => (
|
||||
<PropertyRow
|
||||
key={key}
|
||||
label={key}
|
||||
value={formatPropertyValue(key, value)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Debug Info */}
|
||||
<Section title="调试信息 | Debug">
|
||||
<PropertyRow label="Parent Entity ID" value={parentEntityId} />
|
||||
<PropertyRow label="Virtual Node ID" value={virtualNode.id} />
|
||||
<PropertyRow label="Child Count" value={virtualNode.children?.length ?? 0} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { AssetFileInspector } from './AssetFileInspector';
|
||||
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||
export { EntityInspector } from './EntityInspector';
|
||||
export { PrefabInspector } from './PrefabInspector';
|
||||
export { VirtualNodeInspector } from './VirtualNodeInspector';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Core, HierarchyComponent, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
|
||||
import { VirtualNodeRegistry } from '@esengine/editor-core';
|
||||
import { useHierarchyStore } from '../stores/HierarchyStore';
|
||||
import { useInspectorStore } from '../stores/InspectorStore';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
@@ -285,6 +286,7 @@ export function useStoreSubscriptions({
|
||||
setRemoteEntityTarget,
|
||||
setAssetFileTarget,
|
||||
setExtensionTarget,
|
||||
setVirtualNodeTarget,
|
||||
clearTarget,
|
||||
updateRemoteEntityDetails,
|
||||
incrementComponentVersion,
|
||||
@@ -306,6 +308,9 @@ export function useStoreSubscriptions({
|
||||
|
||||
// 实体选择处理 | Handle entity selection
|
||||
const handleEntitySelection = (data: { entity: any | null }) => {
|
||||
// Clear virtual node selection when selecting an entity
|
||||
// 选择实体时清除虚拟节点选择
|
||||
VirtualNodeRegistry.clearSelectedVirtualNode();
|
||||
if (data.entity) {
|
||||
setEntityTarget(data.entity);
|
||||
} else {
|
||||
@@ -336,6 +341,18 @@ export function useStoreSubscriptions({
|
||||
setExtensionTarget(data.data as Record<string, unknown>);
|
||||
};
|
||||
|
||||
// 虚拟节点选择处理 | Handle virtual node selection
|
||||
const handleVirtualNodeSelection = (data: {
|
||||
parentEntityId: number;
|
||||
virtualNodeId: string;
|
||||
virtualNode: any;
|
||||
}) => {
|
||||
// Update VirtualNodeRegistry for Gizmo highlighting
|
||||
// 更新 VirtualNodeRegistry 用于 Gizmo 高亮
|
||||
VirtualNodeRegistry.setSelectedVirtualNode(data.parentEntityId, data.virtualNodeId);
|
||||
setVirtualNodeTarget(data.parentEntityId, data.virtualNodeId, data.virtualNode);
|
||||
};
|
||||
|
||||
// 资产文件选择处理 | Handle asset file selection
|
||||
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||
const fileInfo = data.fileInfo;
|
||||
@@ -382,6 +399,7 @@ export function useStoreSubscriptions({
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubVirtualNodeSelect = messageHub.subscribe('virtual-node:selected', handleVirtualNodeSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', incrementComponentVersion);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', incrementComponentVersion);
|
||||
@@ -394,6 +412,7 @@ export function useStoreSubscriptions({
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubVirtualNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { createLogger, Core } from '@esengine/ecs-framework';
|
||||
import type { IEditorPlugin, IEditorModuleLoader, ModuleManifest } from '@esengine/editor-core';
|
||||
import { SettingsRegistry, ProjectService, moduleRegistry } from '@esengine/editor-core';
|
||||
import EngineService from '../../services/EngineService';
|
||||
import { EngineService } from '../../services/EngineService';
|
||||
|
||||
/**
|
||||
* Get engine modules from ModuleRegistry.
|
||||
@@ -37,6 +37,7 @@ export const UI_RESOLUTION_PRESETS = [
|
||||
{ label: '1920 x 1080 (Full HD)', value: { width: 1920, height: 1080 } },
|
||||
{ label: '1280 x 720 (HD)', value: { width: 1280, height: 720 } },
|
||||
{ label: '1366 x 768 (HD+)', value: { width: 1366, height: 768 } },
|
||||
{ label: '1136 x 640 (iPhone 5)', value: { width: 1136, height: 640 } },
|
||||
{ label: '2560 x 1440 (2K)', value: { width: 2560, height: 1440 } },
|
||||
{ label: '3840 x 2160 (4K)', value: { width: 3840, height: 2160 } },
|
||||
{ label: '750 x 1334 (iPhone 6/7/8)', value: { width: 750, height: 1334 } },
|
||||
@@ -137,74 +138,6 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
} as any // Cast to any to allow custom props
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'dynamic-atlas',
|
||||
title: '$pluginSettings.project.dynamicAtlas.title',
|
||||
description: '$pluginSettings.project.dynamicAtlas.description',
|
||||
settings: [
|
||||
{
|
||||
key: 'project.dynamicAtlas.enabled',
|
||||
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.expansionStrategy',
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
|
||||
type: 'select',
|
||||
defaultValue: 'fixed',
|
||||
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
|
||||
options: [
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
|
||||
value: 'fixed'
|
||||
},
|
||||
{
|
||||
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
|
||||
value: 'dynamic'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.fixedPageSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 1024,
|
||||
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
|
||||
options: [
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 },
|
||||
{ label: '2048 x 2048', value: 2048 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxPages',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
|
||||
type: 'select',
|
||||
defaultValue: 4,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
|
||||
options: [
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '4', value: 4 },
|
||||
{ label: '8', value: 8 }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'project.dynamicAtlas.maxTextureSize',
|
||||
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
|
||||
type: 'select',
|
||||
defaultValue: 512,
|
||||
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
|
||||
options: [
|
||||
{ label: '256 x 256', value: 256 },
|
||||
{ label: '512 x 512', value: 512 },
|
||||
{ label: '1024 x 1024', value: 1024 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -241,34 +174,11 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
|
||||
this.applyUIDesignResolution();
|
||||
}
|
||||
|
||||
// Check if dynamic atlas settings changed
|
||||
// 检查动态图集设置是否更改
|
||||
if ('project.dynamicAtlas.enabled' in changedSettings ||
|
||||
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
|
||||
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
|
||||
'project.dynamicAtlas.maxPages' in changedSettings ||
|
||||
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
|
||||
|
||||
logger.info('Dynamic atlas settings changed, reinitializing...');
|
||||
this.applyDynamicAtlasSettings();
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', this.settingsListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dynamic atlas settings
|
||||
* 应用动态图集设置
|
||||
*/
|
||||
private applyDynamicAtlasSettings(): void {
|
||||
const engineService = EngineService.getInstance();
|
||||
if (engineService.isInitialized()) {
|
||||
engineService.reinitializeDynamicAtlas();
|
||||
logger.info('Dynamic atlas settings applied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply UI design resolution from ProjectService
|
||||
* 从 ProjectService 应用 UI 设计分辨率
|
||||
|
||||
@@ -21,16 +21,14 @@ import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@ese
|
||||
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import {
|
||||
invalidateUIRenderCaches,
|
||||
UIRenderProviderToken,
|
||||
UIInputSystemToken,
|
||||
initializeDynamicAtlasService,
|
||||
reinitializeDynamicAtlasService,
|
||||
registerTexturePathMapping,
|
||||
AtlasExpansionStrategy,
|
||||
type IAtlasEngineBridge,
|
||||
type DynamicAtlasConfig
|
||||
} from '@esengine/ui';
|
||||
FGUIRenderSystemToken,
|
||||
getFGUIRenderSystem,
|
||||
FGUIRenderDataProvider,
|
||||
setGlobalTextureService,
|
||||
createTextureResolver,
|
||||
Stage,
|
||||
getDOMTextRenderer
|
||||
} from '@esengine/fairygui';
|
||||
import { SettingsService } from './SettingsService';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
@@ -60,6 +58,7 @@ import { WebInputSubsystem } from '@esengine/platform-web';
|
||||
import { resetEngineState } from '../hooks/useEngine';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { IdGenerator } from '../utils/idGenerator';
|
||||
|
||||
import { TauriAssetReader } from './TauriAssetReader';
|
||||
|
||||
const logger = createLogger('EngineService');
|
||||
@@ -303,20 +302,106 @@ export class EngineService {
|
||||
const animatorSystem = services.get(SpriteAnimatorSystemToken);
|
||||
const behaviorTreeSystem = services.get(BehaviorTreeSystemToken);
|
||||
const physicsSystem = services.get(Physics2DSystemToken);
|
||||
const uiInputSystem = services.get(UIInputSystemToken);
|
||||
const uiRenderProvider = services.get(UIRenderProviderToken);
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
|
||||
if (animatorSystem) runtimeServices.register(SpriteAnimatorSystemToken, animatorSystem);
|
||||
if (behaviorTreeSystem) runtimeServices.register(BehaviorTreeSystemToken, behaviorTreeSystem);
|
||||
if (physicsSystem) runtimeServices.register(Physics2DSystemToken, physicsSystem);
|
||||
if (uiInputSystem) runtimeServices.register(UIInputSystemToken, uiInputSystem);
|
||||
if (uiRenderProvider) runtimeServices.register(UIRenderProviderToken, uiRenderProvider);
|
||||
if (fguiRenderSystem) runtimeServices.register(FGUIRenderSystemToken, fguiRenderSystem);
|
||||
}
|
||||
|
||||
// 设置 UI 渲染数据提供者
|
||||
const uiRenderProvider = services.get(UIRenderProviderToken);
|
||||
if (uiRenderProvider && this._runtime.renderSystem) {
|
||||
this._runtime.renderSystem.setUIRenderDataProvider(uiRenderProvider);
|
||||
// 设置 FairyGUI 渲染系统 | Set FairyGUI render system
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
const renderSystem = this._runtime?.renderSystem;
|
||||
if (fguiRenderSystem && this._runtime?.bridge && renderSystem) {
|
||||
const bridge = this._runtime.bridge;
|
||||
|
||||
// Set global texture service for FGUI
|
||||
// 设置 FGUI 的全局纹理服务
|
||||
setGlobalTextureService({
|
||||
loadTextureByPath: (url: string) => bridge.loadTextureByPath(url),
|
||||
getTextureIdByPath: (url: string) => bridge.getTextureIdByPath(url)
|
||||
});
|
||||
|
||||
// Create render data provider to convert FGUI primitives to engine format
|
||||
// 创建渲染数据提供者,将 FGUI 图元转换为引擎格式
|
||||
const fguiRenderDataProvider = new FGUIRenderDataProvider();
|
||||
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
|
||||
fguiRenderDataProvider.setSorting('UI', 1000);
|
||||
|
||||
// Use the centralized texture resolver from FGUITextureManager
|
||||
// 使用 FGUITextureManager 的集中式纹理解析器
|
||||
fguiRenderDataProvider.setTextureResolver(createTextureResolver());
|
||||
|
||||
// Initialize DOM text renderer for text fallback
|
||||
// 初始化 DOM 文本渲染器作为文本回退
|
||||
const domTextRenderer = getDOMTextRenderer();
|
||||
const canvas = document.getElementById('viewport-canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
domTextRenderer.initialize(canvas);
|
||||
}
|
||||
|
||||
// Create UI render data provider adapter for EngineRenderSystem
|
||||
// 为 EngineRenderSystem 创建 UI 渲染数据提供者适配器
|
||||
// This adapter updates FGUI and returns render data in the format expected by the engine
|
||||
// 此适配器更新 FGUI 并以引擎期望的格式返回渲染数据
|
||||
const runtime = this._runtime;
|
||||
const uiRenderProvider = {
|
||||
getRenderData: () => {
|
||||
// Update canvas size for coordinate conversion
|
||||
// FGUI uses top-left origin, engine uses center origin
|
||||
// 更新画布尺寸用于坐标转换(FGUI 使用左上角原点,引擎使用中心原点)
|
||||
const canvasSize = renderSystem.getUICanvasSize();
|
||||
const canvasWidth = canvasSize.width > 0 ? canvasSize.width : 1920;
|
||||
const canvasHeight = canvasSize.height > 0 ? canvasSize.height : 1080;
|
||||
fguiRenderDataProvider.setCanvasSize(canvasWidth, canvasHeight);
|
||||
|
||||
// Update DOM text renderer settings
|
||||
// 更新 DOM 文本渲染器设置
|
||||
domTextRenderer.setDesignSize(canvasWidth, canvasHeight);
|
||||
domTextRenderer.setPreviewMode(renderSystem.isPreviewMode());
|
||||
|
||||
// In editor mode, sync camera state for world-space text rendering
|
||||
// 在编辑器模式下,同步相机状态以进行世界空间文本渲染
|
||||
if (!renderSystem.isPreviewMode() && runtime?.bridge) {
|
||||
const camera = runtime.bridge.getCamera();
|
||||
domTextRenderer.setCamera({
|
||||
x: camera.x,
|
||||
y: camera.y,
|
||||
zoom: camera.zoom,
|
||||
rotation: camera.rotation
|
||||
});
|
||||
}
|
||||
|
||||
// Update FGUI system to collect render primitives
|
||||
// 更新 FGUI 系统以收集渲染图元
|
||||
fguiRenderSystem.update();
|
||||
|
||||
// Render text using DOM (fallback until MSDF text is fully integrated)
|
||||
// 使用 DOM 渲染文本(作为回退,直到 MSDF 文本完全集成)
|
||||
domTextRenderer.beginFrame();
|
||||
domTextRenderer.renderPrimitives(fguiRenderSystem.collector.getPrimitives());
|
||||
domTextRenderer.endFrame();
|
||||
|
||||
// Get render data from provider
|
||||
// 从提供者获取渲染数据
|
||||
fguiRenderDataProvider.setCollector(fguiRenderSystem.collector);
|
||||
return fguiRenderDataProvider.getRenderData();
|
||||
},
|
||||
|
||||
getMeshRenderData: () => {
|
||||
// Get mesh render data for complex shapes (ellipses, polygons, etc.)
|
||||
// 获取复杂形状(椭圆、多边形等)的网格渲染数据
|
||||
return fguiRenderDataProvider.getMeshRenderData();
|
||||
}
|
||||
};
|
||||
|
||||
// Register with EngineRenderSystem
|
||||
// 注册到 EngineRenderSystem
|
||||
renderSystem.setUIRenderDataProvider(uiRenderProvider);
|
||||
|
||||
fguiRenderSystem.enabled = true;
|
||||
logger.info('FairyGUI render system connected to engine via UI render provider');
|
||||
}
|
||||
|
||||
// 在编辑器模式下,禁用游戏逻辑系统
|
||||
@@ -350,14 +435,10 @@ export class EngineService {
|
||||
pluginManager.clearSceneSystems();
|
||||
}
|
||||
|
||||
// 使用服务注册表获取 UI 输入系统
|
||||
// Use service registry to get UI input system
|
||||
const runtimeServices = this._runtime?.getServiceRegistry();
|
||||
if (runtimeServices) {
|
||||
const uiInputSystem = runtimeServices.get(UIInputSystemToken);
|
||||
if (uiInputSystem && uiInputSystem.unbind) {
|
||||
uiInputSystem.unbind();
|
||||
}
|
||||
// 清理 FairyGUI 渲染系统 | Clean up FairyGUI render system
|
||||
const fguiRenderSystem = getFGUIRenderSystem();
|
||||
if (fguiRenderSystem) {
|
||||
fguiRenderSystem.enabled = false;
|
||||
}
|
||||
|
||||
// 清理 viewport | Clear viewport
|
||||
@@ -509,17 +590,26 @@ export class EngineService {
|
||||
const pathTransformerFn = (path: string) => {
|
||||
if (!path.startsWith('http://') && !path.startsWith('https://') &&
|
||||
!path.startsWith('data:') && !path.startsWith('asset://')) {
|
||||
// Normalize path separators to forward slashes first
|
||||
// 首先将路径分隔符规范化为正斜杠
|
||||
path = path.replace(/\\/g, '/');
|
||||
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
if (projectService && projectService.isProjectOpen()) {
|
||||
const projectInfo = projectService.getCurrentProject();
|
||||
if (projectInfo) {
|
||||
const projectPath = projectInfo.path;
|
||||
const separator = projectPath.includes('\\') ? '\\' : '/';
|
||||
path = `${projectPath}${separator}${path.replace(/\//g, separator)}`;
|
||||
// Normalize project path to forward slashes
|
||||
// 将项目路径规范化为正斜杠
|
||||
const projectPath = projectInfo.path.replace(/\\/g, '/');
|
||||
path = `${projectPath}/${path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return convertFileSrc(path);
|
||||
// Use convertFileSrc which handles the asset protocol correctly
|
||||
// 使用 convertFileSrc 正确处理 asset 协议
|
||||
const result = convertFileSrc(path);
|
||||
console.log(`[pathTransformer] ${path} -> ${result}`);
|
||||
return result;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
@@ -599,58 +689,6 @@ export class EngineService {
|
||||
this._sceneResourceManager = new SceneResourceManager();
|
||||
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
|
||||
|
||||
// 初始化动态图集服务(用于 UI 合批)
|
||||
// Initialize dynamic atlas service (for UI batching)
|
||||
const bridge = this._runtime.bridge;
|
||||
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (atlasEnabled) {
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
initializeDynamicAtlasService(atlasBridge, {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
|
||||
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
|
||||
maxPageSize: 2048, // 最大页面大小 | Max page size
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
});
|
||||
}
|
||||
|
||||
// 注册纹理加载回调,当纹理加载时自动注册路径映射
|
||||
// Register texture load callback to register path mapping when textures load
|
||||
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
|
||||
registerTexturePathMapping(guid, path);
|
||||
});
|
||||
}
|
||||
|
||||
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
|
||||
if (sceneManagerService) {
|
||||
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
|
||||
@@ -1178,9 +1216,16 @@ export class EngineService {
|
||||
|
||||
/**
|
||||
* Set UI canvas size for boundary display.
|
||||
* Also syncs with FGUI Stage design size for coordinate conversion.
|
||||
*
|
||||
* 设置 UI 画布尺寸用于边界显示,同时同步到 FGUI Stage 设计尺寸用于坐标转换
|
||||
*/
|
||||
setUICanvasSize(width: number, height: number): void {
|
||||
this._runtime?.setUICanvasSize(width, height);
|
||||
|
||||
// Sync to FGUI Stage design size for coordinate conversion
|
||||
// 同步到 FGUI Stage 设计尺寸用于坐标转换
|
||||
Stage.inst.setDesignSize(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1213,9 +1258,8 @@ export class EngineService {
|
||||
const success = this._runtime?.saveSceneSnapshot() ?? false;
|
||||
|
||||
if (success) {
|
||||
// 清除 UI 渲染缓存(因为纹理已被清除)
|
||||
// Clear UI render caches (since textures have been cleared)
|
||||
invalidateUIRenderCaches();
|
||||
// 场景快照保存成功
|
||||
// Scene snapshot saved successfully
|
||||
}
|
||||
|
||||
return success;
|
||||
@@ -1230,9 +1274,6 @@ export class EngineService {
|
||||
const success = await this._runtime.restoreSceneSnapshot();
|
||||
|
||||
if (success) {
|
||||
// 清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Reset particle component textureIds before loading resources
|
||||
// 在加载资源前重置粒子组件的 textureId
|
||||
// This ensures ParticleUpdateSystem will reload textures
|
||||
@@ -1408,76 +1449,6 @@ export class EngineService {
|
||||
return this._runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize dynamic atlas with current settings.
|
||||
* 使用当前设置重新初始化动态图集。
|
||||
*
|
||||
* Call this when dynamic atlas settings change to apply them.
|
||||
* 当动态图集设置更改时调用此方法以应用更改。
|
||||
*/
|
||||
reinitializeDynamicAtlas(): void {
|
||||
const bridge = this._runtime?.bridge;
|
||||
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
|
||||
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
|
||||
return;
|
||||
}
|
||||
|
||||
const atlasBridge: IAtlasEngineBridge = {
|
||||
createBlankTexture: (width: number, height: number) => {
|
||||
return bridge.createBlankTexture!(width, height);
|
||||
},
|
||||
updateTextureRegion: (
|
||||
id: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pixels: Uint8Array
|
||||
) => {
|
||||
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
|
||||
}
|
||||
};
|
||||
|
||||
// 从设置中获取动态图集配置
|
||||
// Get dynamic atlas config from settings
|
||||
const settingsService = SettingsService.getInstance();
|
||||
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
|
||||
|
||||
if (!atlasEnabled) {
|
||||
logger.info('Dynamic atlas is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
|
||||
const expansionStrategy = strategyValue === 'dynamic'
|
||||
? AtlasExpansionStrategy.Dynamic
|
||||
: AtlasExpansionStrategy.Fixed;
|
||||
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
|
||||
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
|
||||
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
|
||||
|
||||
logger.info('Dynamic atlas settings read from SettingsService:', {
|
||||
strategyValue,
|
||||
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
|
||||
fixedPageSize,
|
||||
maxPages,
|
||||
maxTextureSize
|
||||
});
|
||||
|
||||
const config: DynamicAtlasConfig = {
|
||||
expansionStrategy,
|
||||
initialPageSize: 256,
|
||||
fixedPageSize,
|
||||
maxPageSize: 2048,
|
||||
maxPages,
|
||||
maxTextureSize,
|
||||
padding: 1
|
||||
};
|
||||
|
||||
reinitializeDynamicAtlasService(atlasBridge, config);
|
||||
logger.info('Dynamic atlas reinitialized with config:', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose engine resources.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { ParticleSystemComponent } from '@esengine/particle';
|
||||
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
|
||||
import { FGUIComponent, GRoot, GComponent } from '@esengine/fairygui';
|
||||
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
@@ -102,123 +102,41 @@ export interface ParticleDebugInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 元素调试信息
|
||||
* UI element debug info
|
||||
* FairyGUI 元素调试信息
|
||||
* FairyGUI element debug info
|
||||
*/
|
||||
export interface UIDebugInfo {
|
||||
export interface FGUIDebugInfo {
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown';
|
||||
packageName: string;
|
||||
componentName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
rotation: number;
|
||||
visible: boolean;
|
||||
alpha: number;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
|
||||
depth: number;
|
||||
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
|
||||
worldOrderInLayer: number;
|
||||
textureGuid?: string;
|
||||
textureUrl?: string;
|
||||
backgroundColor?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
/** 材质/着色器 ID | Material/Shader ID */
|
||||
materialId: number;
|
||||
/** 着色器名称 | Shader name */
|
||||
shaderName: string;
|
||||
/** Shader uniform 覆盖值 | Shader uniform override values */
|
||||
uniforms: Record<string, UniformDebugValue>;
|
||||
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
|
||||
aspectRatio: number;
|
||||
/** 子对象数量 | Child count */
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染调试快照
|
||||
* Render debug snapshot
|
||||
*/
|
||||
/**
|
||||
* 图集条目调试信息
|
||||
* Atlas entry debug info
|
||||
*/
|
||||
export interface AtlasEntryDebugInfo {
|
||||
/** 纹理 GUID | Texture GUID */
|
||||
guid: string;
|
||||
/** 在图集中的位置 | Position in atlas */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** UV 坐标 | UV coordinates */
|
||||
uv: [number, number, number, number];
|
||||
/** 纹理图像 data URL(用于预览)| Texture image data URL (for preview) */
|
||||
dataUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图集页面调试信息
|
||||
* Atlas page debug info
|
||||
*/
|
||||
export interface AtlasPageDebugInfo {
|
||||
/** 页面索引 | Page index */
|
||||
pageIndex: number;
|
||||
/** 纹理 ID | Texture ID */
|
||||
textureId: number;
|
||||
/** 页面尺寸 | Page size */
|
||||
width: number;
|
||||
height: number;
|
||||
/** 占用率 | Occupancy */
|
||||
occupancy: number;
|
||||
/** 此页面中的条目 | Entries in this page */
|
||||
entries: AtlasEntryDebugInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态图集统计信息
|
||||
* Dynamic atlas statistics
|
||||
*/
|
||||
export interface AtlasStats {
|
||||
/** 是否启用 | Whether enabled */
|
||||
enabled: boolean;
|
||||
/** 图集页数 | Number of atlas pages */
|
||||
pageCount: number;
|
||||
/** 已加入图集的纹理数 | Number of textures in atlas */
|
||||
textureCount: number;
|
||||
/** 平均占用率 | Average occupancy */
|
||||
averageOccupancy: number;
|
||||
/** 正在加载的纹理数 | Number of loading textures */
|
||||
loadingCount: number;
|
||||
/** 加载失败的纹理数 | Number of failed textures */
|
||||
failedCount: number;
|
||||
/** 每个页面的详细信息 | Detailed info for each page */
|
||||
pages: AtlasPageDebugInfo[];
|
||||
}
|
||||
|
||||
export interface RenderDebugSnapshot {
|
||||
timestamp: number;
|
||||
frameNumber: number;
|
||||
textures: TextureDebugInfo[];
|
||||
sprites: SpriteDebugInfo[];
|
||||
particles: ParticleDebugInfo[];
|
||||
uiElements: UIDebugInfo[];
|
||||
/** UI 合批调试信息 | UI batch debug info */
|
||||
uiBatches: BatchDebugInfo[];
|
||||
/** 动态图集统计 | Dynamic atlas stats */
|
||||
atlasStats?: AtlasStats;
|
||||
fguiElements: FGUIDebugInfo[];
|
||||
stats: {
|
||||
totalSprites: number;
|
||||
totalParticles: number;
|
||||
totalUIElements: number;
|
||||
totalFGUIElements: number;
|
||||
totalTextures: number;
|
||||
drawCalls: number;
|
||||
/** UI 批次数 | UI batch count */
|
||||
uiBatchCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,12 +292,6 @@ export class RenderDebugService {
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
this._textureCache.set(textureGuid, dataUrl);
|
||||
|
||||
// 注册 GUID 到 data URL 映射(用于动态图集)
|
||||
// Register GUID to data URL mapping (for dynamic atlas)
|
||||
if (isGuid) {
|
||||
registerTexturePathMapping(textureGuid, dataUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
|
||||
} finally {
|
||||
@@ -399,82 +311,28 @@ export class RenderDebugService {
|
||||
|
||||
this._frameNumber++;
|
||||
|
||||
// 收集 UI 合批信息 | Collect UI batch info
|
||||
const uiCollector = getUIRenderCollector();
|
||||
const uiBatches = [...uiCollector.getBatchDebugInfo()];
|
||||
|
||||
// 收集动态图集统计 | Collect dynamic atlas stats
|
||||
const atlasService = getDynamicAtlasService();
|
||||
let atlasStats: AtlasStats | undefined;
|
||||
if (atlasService) {
|
||||
const stats = atlasService.getStats();
|
||||
const pageDetails = atlasService.getPageDetails();
|
||||
|
||||
// 转换页面详细信息 | Convert page details
|
||||
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
|
||||
pageIndex: page.pageIndex,
|
||||
textureId: page.textureId,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
occupancy: page.occupancy,
|
||||
entries: page.entries.map(e => ({
|
||||
guid: e.guid,
|
||||
x: e.entry.region.x,
|
||||
y: e.entry.region.y,
|
||||
width: e.entry.region.width,
|
||||
height: e.entry.region.height,
|
||||
uv: e.entry.uv,
|
||||
// 从纹理缓存获取 data URL | Get data URL from texture cache
|
||||
dataUrl: this._textureCache.get(e.guid)
|
||||
}))
|
||||
}));
|
||||
|
||||
atlasStats = {
|
||||
enabled: true,
|
||||
pageCount: stats.pageCount,
|
||||
textureCount: stats.textureCount,
|
||||
averageOccupancy: stats.averageOccupancy,
|
||||
loadingCount: stats.loadingCount,
|
||||
failedCount: stats.failedCount,
|
||||
pages
|
||||
};
|
||||
} else {
|
||||
atlasStats = {
|
||||
enabled: false,
|
||||
pageCount: 0,
|
||||
textureCount: 0,
|
||||
averageOccupancy: 0,
|
||||
loadingCount: 0,
|
||||
failedCount: 0,
|
||||
pages: []
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: RenderDebugSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
frameNumber: this._frameNumber,
|
||||
textures: this._collectTextures(),
|
||||
sprites: this._collectSprites(scene.entities.buffer),
|
||||
particles: this._collectParticles(scene.entities.buffer),
|
||||
uiElements: this._collectUI(scene.entities.buffer),
|
||||
uiBatches,
|
||||
atlasStats,
|
||||
fguiElements: this._collectFGUI(scene.entities.buffer),
|
||||
stats: {
|
||||
totalSprites: 0,
|
||||
totalParticles: 0,
|
||||
totalUIElements: 0,
|
||||
totalFGUIElements: 0,
|
||||
totalTextures: 0,
|
||||
drawCalls: 0,
|
||||
uiBatchCount: uiBatches.length,
|
||||
},
|
||||
};
|
||||
|
||||
// 计算统计 | Calculate stats
|
||||
snapshot.stats.totalSprites = snapshot.sprites.length;
|
||||
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
|
||||
snapshot.stats.totalUIElements = snapshot.uiElements.length;
|
||||
snapshot.stats.totalFGUIElements = snapshot.fguiElements.length;
|
||||
snapshot.stats.totalTextures = snapshot.textures.length;
|
||||
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
|
||||
snapshot.stats.drawCalls = snapshot.sprites.length + snapshot.particles.length + snapshot.fguiElements.length;
|
||||
|
||||
// 保存快照 | Save snapshot
|
||||
this._snapshots.push(snapshot);
|
||||
@@ -673,97 +531,36 @@ export class RenderDebugService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集 UI 元素信息
|
||||
* Collect UI element info
|
||||
* 收集 FairyGUI 元素信息
|
||||
* Collect FairyGUI element info
|
||||
*/
|
||||
private _collectUI(entities: readonly Entity[]): UIDebugInfo[] {
|
||||
const uiElements: UIDebugInfo[] = [];
|
||||
private _collectFGUI(entities: readonly Entity[]): FGUIDebugInfo[] {
|
||||
const fguiElements: FGUIDebugInfo[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const uiTransform = entity.getComponent(UITransformComponent);
|
||||
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
|
||||
|
||||
if (!uiTransform) continue;
|
||||
if (!fguiComp) continue;
|
||||
|
||||
const uiRender = entity.getComponent(UIRenderComponent);
|
||||
const uiText = entity.getComponent(UITextComponent);
|
||||
const root = fguiComp.root;
|
||||
const displayObject = root as GComponent | null;
|
||||
|
||||
// 确定类型 | Determine type
|
||||
let type: UIDebugInfo['type'] = 'unknown';
|
||||
if (uiText) {
|
||||
type = 'text';
|
||||
} else if (uiRender) {
|
||||
switch (uiRender.type) {
|
||||
case 'rect': type = 'rect'; break;
|
||||
case 'image': type = 'image'; break;
|
||||
case 'ninepatch': type = 'ninepatch'; break;
|
||||
case 'circle': type = 'circle'; break;
|
||||
case 'rounded-rect': type = 'rounded-rect'; break;
|
||||
default: type = 'rect';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const textureGuid = uiRender?.textureGuid?.toString() ?? '';
|
||||
|
||||
// 转换颜色为十六进制字符串 | Convert color to hex string
|
||||
const backgroundColor = uiRender?.backgroundColor !== undefined
|
||||
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
|
||||
: undefined;
|
||||
|
||||
// 获取材质/着色器 ID | Get material/shader ID
|
||||
const materialId = uiRender?.getMaterialId?.() ?? 0;
|
||||
|
||||
// 收集 uniform 覆盖值 | Collect uniform override values
|
||||
const uniforms: Record<string, UniformDebugValue> = {};
|
||||
const overrides = uiRender?.materialOverrides ?? {};
|
||||
for (const [name, override] of Object.entries(overrides)) {
|
||||
uniforms[name] = {
|
||||
type: override.type,
|
||||
value: override.value
|
||||
};
|
||||
}
|
||||
|
||||
// 计算 aspectRatio (与 Rust 端一致: width / height)
|
||||
// Calculate aspectRatio (same as Rust side: width / height)
|
||||
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
|
||||
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
|
||||
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
|
||||
|
||||
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
|
||||
// worldOrderInLayer = depth * 1000 + orderInLayer
|
||||
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
|
||||
const depth = Math.floor(worldOrderInLayer / 1000);
|
||||
|
||||
uiElements.push({
|
||||
fguiElements.push({
|
||||
entityId: entity.id,
|
||||
entityName: entity.name,
|
||||
type,
|
||||
x: uiTransform.x,
|
||||
y: uiTransform.y,
|
||||
width: uiTransform.width,
|
||||
height: uiTransform.height,
|
||||
worldX: uiTransform.worldX,
|
||||
worldY: uiTransform.worldY,
|
||||
rotation: uiTransform.rotation,
|
||||
visible: uiTransform.visible && uiTransform.worldVisible,
|
||||
alpha: uiTransform.worldAlpha,
|
||||
sortingLayer: uiTransform.sortingLayer,
|
||||
orderInLayer: uiTransform.orderInLayer,
|
||||
depth,
|
||||
worldOrderInLayer,
|
||||
textureGuid: textureGuid || undefined,
|
||||
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
|
||||
backgroundColor,
|
||||
text: uiText?.text,
|
||||
fontSize: uiText?.fontSize,
|
||||
materialId,
|
||||
shaderName: getShaderName(materialId),
|
||||
uniforms,
|
||||
aspectRatio,
|
||||
packageName: fguiComp.packageGuid ?? '',
|
||||
componentName: fguiComp.componentName ?? '',
|
||||
x: displayObject?.x ?? 0,
|
||||
y: displayObject?.y ?? 0,
|
||||
width: displayObject?.width ?? 0,
|
||||
height: displayObject?.height ?? 0,
|
||||
visible: displayObject?.visible ?? true,
|
||||
alpha: displayObject?.alpha ?? 1,
|
||||
childCount: displayObject?.numChildren ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return uiElements;
|
||||
return fguiElements;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -805,8 +602,3 @@ export class RenderDebugService {
|
||||
|
||||
// 全局实例 | Global instance
|
||||
export const renderDebugService = RenderDebugService.getInstance();
|
||||
|
||||
// 导出到全局以便控制台使用 | Export to global for console usage
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).renderDebugService = renderDebugService;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { IAssetReader } from '@esengine/asset-system';
|
||||
|
||||
/** Blob URL cache to avoid re-reading files | Blob URL 缓存避免重复读取文件 */
|
||||
const blobUrlCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Asset reader implementation for Tauri.
|
||||
* Tauri 的资产读取器实现。
|
||||
*
|
||||
* Uses Tauri backend commands to read files and creates Blob URLs for images.
|
||||
* This approach works reliably with WebGL/Canvas without protocol restrictions.
|
||||
*
|
||||
* 使用 Tauri 后端命令读取文件,并为图片创建 Blob URL。
|
||||
* 这种方法在 WebGL/Canvas 中可靠工作,没有协议限制。
|
||||
*/
|
||||
export class TauriAssetReader implements IAssetReader {
|
||||
/**
|
||||
@@ -33,31 +41,71 @@ export class TauriAssetReader implements IAssetReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image from file.
|
||||
* 从文件加载图片。
|
||||
* Load image from file via backend.
|
||||
* 通过后端从文件加载图片。
|
||||
*
|
||||
* Reads binary data via Tauri backend and creates a Blob URL.
|
||||
* This bypasses browser protocol restrictions (asset://, file://).
|
||||
*
|
||||
* 通过 Tauri 后端读取二进制数据并创建 Blob URL。
|
||||
* 这绕过了浏览器协议限制。
|
||||
*/
|
||||
async loadImage(absolutePath: string): Promise<HTMLImageElement> {
|
||||
// Only convert if not already a URL.
|
||||
// 仅当不是 URL 时才转换。
|
||||
let assetUrl = absolutePath;
|
||||
if (!absolutePath.startsWith('http://') &&
|
||||
!absolutePath.startsWith('https://') &&
|
||||
!absolutePath.startsWith('data:') &&
|
||||
!absolutePath.startsWith('asset://')) {
|
||||
assetUrl = convertFileSrc(absolutePath);
|
||||
// Return cached if available
|
||||
let blobUrl = blobUrlCache.get(absolutePath);
|
||||
|
||||
if (!blobUrl) {
|
||||
// Read binary via backend
|
||||
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
|
||||
const data = new Uint8Array(bytes);
|
||||
|
||||
// Determine MIME type from extension
|
||||
const ext = absolutePath.toLowerCase().split('.').pop();
|
||||
let mimeType = 'image/png';
|
||||
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
|
||||
else if (ext === 'gif') mimeType = 'image/gif';
|
||||
else if (ext === 'webp') mimeType = 'image/webp';
|
||||
|
||||
// Create Blob URL
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(absolutePath, blobUrl);
|
||||
}
|
||||
|
||||
// Load image from Blob URL
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
// 允许跨域访问,防止 canvas 被污染
|
||||
// Allow cross-origin access to prevent canvas tainting
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
|
||||
image.src = assetUrl;
|
||||
image.src = blobUrl!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Blob URL for a file (for engine texture loading).
|
||||
* 获取文件的 Blob URL(用于引擎纹理加载)。
|
||||
*/
|
||||
async getBlobUrl(absolutePath: string): Promise<string> {
|
||||
let blobUrl = blobUrlCache.get(absolutePath);
|
||||
|
||||
if (!blobUrl) {
|
||||
const bytes = await invoke<number[]>('read_binary_file', { filePath: absolutePath });
|
||||
const data = new Uint8Array(bytes);
|
||||
|
||||
const ext = absolutePath.toLowerCase().split('.').pop();
|
||||
let mimeType = 'image/png';
|
||||
if (ext === 'jpg' || ext === 'jpeg') mimeType = 'image/jpeg';
|
||||
else if (ext === 'gif') mimeType = 'image/gif';
|
||||
else if (ext === 'webp') mimeType = 'image/webp';
|
||||
|
||||
const blob = new Blob([data], { type: mimeType });
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
blobUrlCache.set(absolutePath, blobUrl);
|
||||
}
|
||||
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load audio from file.
|
||||
* 从文件加载音频。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails } from '../components/inspectors/types';
|
||||
import type { IVirtualNode } from '@esengine/editor-core';
|
||||
import type { InspectorTarget, AssetFileInfo, RemoteEntity, EntityDetails, VirtualNodeTargetData } from '../components/inspectors/types';
|
||||
|
||||
// ============= Types =============
|
||||
|
||||
@@ -45,6 +46,8 @@ export interface InspectorActions {
|
||||
setAssetFileTarget: (fileInfo: AssetFileInfo, content?: string, isImage?: boolean) => void;
|
||||
/** 设置扩展目标 | Set extension target */
|
||||
setExtensionTarget: (data: Record<string, unknown>) => void;
|
||||
/** 设置虚拟节点目标 | Set virtual node target */
|
||||
setVirtualNodeTarget: (parentEntityId: number, virtualNodeId: string, virtualNode: IVirtualNode) => void;
|
||||
/** 清除目标 | Clear target */
|
||||
clearTarget: () => void;
|
||||
/** 更新远程实体详情 | Update remote entity details */
|
||||
@@ -118,6 +121,17 @@ export const useInspectorStore = create<InspectorStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setVirtualNodeTarget: (parentEntityId, virtualNodeId, virtualNode) => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
set({
|
||||
target: {
|
||||
type: 'virtual-node',
|
||||
data: { parentEntityId, virtualNodeId, virtualNode }
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearTarget: () => {
|
||||
// 锁定时忽略 | Ignore when locked
|
||||
if (get().isLocked) return;
|
||||
|
||||
@@ -795,3 +795,49 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Virtual Nodes | 虚拟节点 ==================== */
|
||||
/* Virtual nodes are read-only internal nodes from components like FGUI */
|
||||
/* 虚拟节点是来自组件(如 FGUI)的只读内部节点 */
|
||||
|
||||
.outliner-item.virtual-node {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-left: 2px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node.selected {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node .outliner-item-name.virtual-name {
|
||||
color: #fbbf24;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node .outliner-item-type.virtual-type {
|
||||
color: #d97706;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Hidden virtual node (invisible in UI) */
|
||||
/* 隐藏的虚拟节点(在 UI 中不可见) */
|
||||
.outliner-item.virtual-node.hidden-node {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.outliner-item.virtual-node.hidden-node .outliner-item-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Virtual node icon colors */
|
||||
/* 虚拟节点图标颜色 */
|
||||
.outliner-item.virtual-node svg {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
165
packages/editor-app/src/styles/VirtualNodeInspector.css
Normal file
165
packages/editor-app/src/styles/VirtualNodeInspector.css
Normal 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;
|
||||
}
|
||||
@@ -27,11 +27,27 @@ export interface GizmoColor {
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base gizmo data with optional virtual node reference
|
||||
* 带有可选虚拟节点引用的基础 Gizmo 数据
|
||||
*/
|
||||
export interface IGizmoDataBase {
|
||||
/**
|
||||
* Optional virtual node ID for component internal nodes
|
||||
* 可选的虚拟节点 ID,用于组件内部节点
|
||||
*
|
||||
* When set, clicking this gizmo will select the virtual node
|
||||
* instead of just the entity.
|
||||
* 设置后,点击此 gizmo 将选中虚拟节点而不只是实体。
|
||||
*/
|
||||
virtualNodeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangle gizmo data (rendered via Rust WebGL)
|
||||
* 矩形 gizmo 数据(通过 Rust WebGL 渲染)
|
||||
*/
|
||||
export interface IRectGizmoData {
|
||||
export interface IRectGizmoData extends IGizmoDataBase {
|
||||
type: 'rect';
|
||||
/** Center X position in world space | 世界空间中心 X 位置 */
|
||||
x: number;
|
||||
@@ -57,7 +73,7 @@ export interface IRectGizmoData {
|
||||
* Circle gizmo data
|
||||
* 圆形 gizmo 数据
|
||||
*/
|
||||
export interface ICircleGizmoData {
|
||||
export interface ICircleGizmoData extends IGizmoDataBase {
|
||||
type: 'circle';
|
||||
/** Center X position | 中心 X 位置 */
|
||||
x: number;
|
||||
@@ -73,7 +89,7 @@ export interface ICircleGizmoData {
|
||||
* Line gizmo data
|
||||
* 线条 gizmo 数据
|
||||
*/
|
||||
export interface ILineGizmoData {
|
||||
export interface ILineGizmoData extends IGizmoDataBase {
|
||||
type: 'line';
|
||||
/** Line points | 线段点 */
|
||||
points: Array<{ x: number; y: number }>;
|
||||
@@ -87,7 +103,7 @@ export interface ILineGizmoData {
|
||||
* Grid gizmo data
|
||||
* 网格 gizmo 数据
|
||||
*/
|
||||
export interface IGridGizmoData {
|
||||
export interface IGridGizmoData extends IGizmoDataBase {
|
||||
type: 'grid';
|
||||
/** Top-left X position | 左上角 X 位置 */
|
||||
x: number;
|
||||
@@ -109,7 +125,7 @@ export interface IGridGizmoData {
|
||||
* Capsule gizmo data
|
||||
* 胶囊 gizmo 数据
|
||||
*/
|
||||
export interface ICapsuleGizmoData {
|
||||
export interface ICapsuleGizmoData extends IGizmoDataBase {
|
||||
type: 'capsule';
|
||||
/** Center X position | 中心 X 位置 */
|
||||
x: number;
|
||||
|
||||
@@ -127,6 +127,8 @@ const EXTENSION_TYPE_MAP: Record<string, AssetRegistryType> = {
|
||||
'.tsx': 'tileset',
|
||||
// Particle system
|
||||
'.particle': 'particle',
|
||||
// FairyGUI
|
||||
'.fui': 'fui',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,19 @@ export interface GizmoHitResult {
|
||||
entityId: number;
|
||||
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
|
||||
distance: number;
|
||||
/** Virtual node ID if this gizmo represents a virtual node | 虚拟节点 ID(如果此 gizmo 代表虚拟节点) */
|
||||
virtualNodeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click result with entity and optional virtual node
|
||||
* 点击结果,包含实体和可选的虚拟节点
|
||||
*/
|
||||
export interface GizmoClickResult {
|
||||
/** Entity ID | 实体 ID */
|
||||
entityId: number;
|
||||
/** Virtual node ID if clicked on a virtual node gizmo | 虚拟节点 ID(如果点击了虚拟节点 gizmo) */
|
||||
virtualNodeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +86,23 @@ export interface IGizmoInteractionService {
|
||||
* 清除悬停状态
|
||||
*/
|
||||
clearHover(): void;
|
||||
|
||||
/**
|
||||
* Handle click at position with virtual node support
|
||||
* 处理位置点击,支持虚拟节点
|
||||
*
|
||||
* @param worldX World X coordinate | 世界 X 坐标
|
||||
* @param worldY World Y coordinate | 世界 Y 坐标
|
||||
* @param zoom Current viewport zoom level | 当前视口缩放级别
|
||||
* @returns Click result with entity and optional virtual node | 点击结果
|
||||
*/
|
||||
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | null;
|
||||
|
||||
/**
|
||||
* Get currently hovered virtual node ID
|
||||
* 获取当前悬停的虚拟节点 ID
|
||||
*/
|
||||
getHoveredVirtualNodeId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +115,7 @@ export interface IGizmoInteractionService {
|
||||
export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
private hoveredEntityId: number | null = null;
|
||||
private hoveredGizmo: IGizmoRenderData | null = null;
|
||||
private hoveredVirtualNodeId: string | null = null;
|
||||
|
||||
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
|
||||
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
|
||||
@@ -96,8 +127,8 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
private lastClickPos: { x: number; y: number } | null = null;
|
||||
/** Last click time | 上次点击时间 */
|
||||
private lastClickTime: number = 0;
|
||||
/** All hit entities at current click position | 当前点击位置的所有命中实体 */
|
||||
private hitEntitiesAtClick: number[] = [];
|
||||
/** All hit results at current click position | 当前点击位置的所有命中结果 */
|
||||
private hitResultsAtClick: GizmoClickResult[] = [];
|
||||
/** Current cycle index | 当前循环索引 */
|
||||
private cycleIndex: number = 0;
|
||||
/** Position tolerance for same-position detection | 判断相同位置的容差 */
|
||||
@@ -121,6 +152,14 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
return this.hoveredGizmo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currently hovered virtual node ID
|
||||
* 获取当前悬停的虚拟节点 ID
|
||||
*/
|
||||
getHoveredVirtualNodeId(): string | null {
|
||||
return this.hoveredVirtualNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mouse position and perform hit test
|
||||
* 更新鼠标位置并执行命中测试
|
||||
@@ -130,6 +169,7 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
if (!scene) {
|
||||
this.hoveredEntityId = null;
|
||||
this.hoveredGizmo = null;
|
||||
this.hoveredVirtualNodeId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,7 +206,8 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
closestHit = {
|
||||
gizmo,
|
||||
entityId: entity.id,
|
||||
distance
|
||||
distance,
|
||||
virtualNodeId: gizmo.virtualNodeId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -176,6 +217,7 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
|
||||
this.hoveredEntityId = closestHit?.entityId ?? null;
|
||||
this.hoveredGizmo = closestHit?.gizmo ?? null;
|
||||
this.hoveredVirtualNodeId = closestHit?.virtualNodeId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,56 +248,66 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
* 支持重复点击时循环选择重叠的实体
|
||||
*/
|
||||
handleClick(worldX: number, worldY: number, zoom: number): number | null {
|
||||
const result = this.handleClickEx(worldX, worldY, zoom);
|
||||
return result?.entityId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click at position with virtual node support
|
||||
* Supports cycling through overlapping gizmos on repeated clicks
|
||||
* 处理位置点击,支持虚拟节点
|
||||
* 支持重复点击时循环选择重叠的 gizmos
|
||||
*/
|
||||
handleClickEx(worldX: number, worldY: number, zoom: number): GizmoClickResult | null {
|
||||
const now = Date.now();
|
||||
const isSamePosition = this.lastClickPos !== null &&
|
||||
Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom &&
|
||||
Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom;
|
||||
const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE;
|
||||
|
||||
// If clicking at same position within time window, cycle to next entity
|
||||
// 如果在时间窗口内点击相同位置,循环到下一个实体
|
||||
if (isSamePosition && isWithinTimeWindow && this.hitEntitiesAtClick.length > 1) {
|
||||
this.cycleIndex = (this.cycleIndex + 1) % this.hitEntitiesAtClick.length;
|
||||
// If clicking at same position within time window, cycle to next result
|
||||
// 如果在时间窗口内点击相同位置,循环到下一个结果
|
||||
if (isSamePosition && isWithinTimeWindow && this.hitResultsAtClick.length > 1) {
|
||||
this.cycleIndex = (this.cycleIndex + 1) % this.hitResultsAtClick.length;
|
||||
this.lastClickTime = now;
|
||||
const selectedId = this.hitEntitiesAtClick[this.cycleIndex];
|
||||
this.hoveredEntityId = selectedId;
|
||||
return selectedId;
|
||||
const result = this.hitResultsAtClick[this.cycleIndex];
|
||||
this.hoveredEntityId = result.entityId;
|
||||
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
|
||||
return result;
|
||||
}
|
||||
|
||||
// New position or timeout - collect all hit entities
|
||||
// 新位置或超时 - 收集所有命中的实体
|
||||
this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom);
|
||||
// New position or timeout - collect all hit results
|
||||
// 新位置或超时 - 收集所有命中结果
|
||||
this.hitResultsAtClick = this.collectAllHitResults(worldX, worldY, zoom);
|
||||
this.cycleIndex = 0;
|
||||
this.lastClickPos = { x: worldX, y: worldY };
|
||||
this.lastClickTime = now;
|
||||
|
||||
if (this.hitEntitiesAtClick.length > 0) {
|
||||
const selectedId = this.hitEntitiesAtClick[0];
|
||||
this.hoveredEntityId = selectedId;
|
||||
return selectedId;
|
||||
if (this.hitResultsAtClick.length > 0) {
|
||||
const result = this.hitResultsAtClick[0];
|
||||
this.hoveredEntityId = result.entityId;
|
||||
this.hoveredVirtualNodeId = result.virtualNodeId ?? null;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all entities hit at the given position, sorted by distance
|
||||
* 收集给定位置命中的所有实体,按距离排序
|
||||
* Collect all hit results at the given position, sorted by distance
|
||||
* 收集给定位置的所有命中结果,按距离排序
|
||||
*/
|
||||
private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] {
|
||||
private collectAllHitResults(worldX: number, worldY: number, zoom: number): GizmoClickResult[] {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const hits: GizmoHitResult[] = [];
|
||||
const hits: Array<GizmoClickResult & { distance: number }> = [];
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entityHit = false;
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const component of entity.components) {
|
||||
const componentType = component.constructor as ComponentType;
|
||||
if (!GizmoRegistry.hasProvider(componentType)) {
|
||||
@@ -265,30 +317,37 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
|
||||
for (const gizmo of gizmos) {
|
||||
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
|
||||
entityHit = true;
|
||||
const center = GizmoHitTester.getGizmoCenter(gizmo);
|
||||
const distance = Math.sqrt(
|
||||
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
|
||||
);
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
hits.push({
|
||||
entityId: entity.id,
|
||||
virtualNodeId: gizmo.virtualNodeId,
|
||||
distance
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entityHit) {
|
||||
hits.push({
|
||||
gizmo: {} as IGizmoRenderData, // Not needed for sorting
|
||||
entityId: entity.id,
|
||||
distance: minDistance
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance (closest first)
|
||||
// 按距离排序(最近的在前)
|
||||
hits.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
return hits.map(hit => hit.entityId);
|
||||
// Remove duplicates (same entity + virtualNodeId), keeping closest
|
||||
// 去重(相同实体 + virtualNodeId),保留最近的
|
||||
const seen = new Set<string>();
|
||||
const uniqueHits: GizmoClickResult[] = [];
|
||||
for (const hit of hits) {
|
||||
const key = `${hit.entityId}:${hit.virtualNodeId ?? ''}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueHits.push({ entityId: hit.entityId, virtualNodeId: hit.virtualNodeId });
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueHits;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,5 +357,6 @@ export class GizmoInteractionService implements IGizmoInteractionService {
|
||||
clearHover(): void {
|
||||
this.hoveredEntityId = null;
|
||||
this.hoveredGizmo = null;
|
||||
this.hoveredVirtualNodeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
323
packages/editor-core/src/Services/VirtualNodeRegistry.ts
Normal file
323
packages/editor-core/src/Services/VirtualNodeRegistry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../editor-core" },
|
||||
{ "path": "../ui" }
|
||||
{ "path": "../editor-core" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -108,8 +108,8 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to packed u32 (ABGR format for WebGL).
|
||||
/// 转换为打包的u32(WebGL的ABGR格式)。
|
||||
/// Convert to packed u32 (0xRRGGBBAA format, industry standard).
|
||||
/// 转换为打包的 u32(0xRRGGBBAA 格式,行业标准)。
|
||||
#[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]
|
||||
|
||||
243
packages/engine/src/renderer/batch/mesh_batch.rs
Normal file
243
packages/engine/src/renderer/batch/mesh_batch.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
262
packages/engine/src/renderer/batch/text_batch.rs
Normal file
262
packages/engine/src/renderer/batch/text_batch.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ mod viewport;
|
||||
|
||||
pub use renderer2d::Renderer2D;
|
||||
pub use camera::Camera2D;
|
||||
pub use batch::SpriteBatch;
|
||||
pub use batch::{SpriteBatch, TextBatch, MeshBatch};
|
||||
pub use texture::{Texture, TextureManager};
|
||||
pub use grid::GridRenderer;
|
||||
pub use gizmo::{GizmoRenderer, TransformMode};
|
||||
|
||||
@@ -224,6 +224,19 @@ impl Renderer2D {
|
||||
id == 0 || self.custom_shaders.contains_key(&id)
|
||||
}
|
||||
|
||||
/// Get shader handle by ID.
|
||||
/// 按 ID 获取着色器句柄。
|
||||
///
|
||||
/// Returns the default shader for ID 0, or custom shader for other IDs.
|
||||
/// ID 0 返回默认着色器,其他 ID 返回自定义着色器。
|
||||
pub fn get_shader_handle(&self, id: u32) -> Option<ShaderHandle> {
|
||||
if id == 0 || id == crate::renderer::shader::SHADER_ID_DEFAULT_SPRITE {
|
||||
Some(self.default_shader)
|
||||
} else {
|
||||
self.custom_shaders.get(&id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_shader(&mut self, id: u32) -> bool {
|
||||
if id < 100 { return false; }
|
||||
self.custom_shaders.remove(&id).is_some()
|
||||
|
||||
@@ -1,6 +1,90 @@
|
||||
//! Built-in shader source code.
|
||||
//! 内置Shader源代码。
|
||||
|
||||
// =============================================================================
|
||||
// MSDF Text Shaders
|
||||
// MSDF 文本着色器
|
||||
// =============================================================================
|
||||
|
||||
/// MSDF text vertex shader source.
|
||||
/// MSDF 文本顶点着色器源代码。
|
||||
pub const MSDF_TEXT_VERTEX_SHADER: &str = r#"#version 300 es
|
||||
precision highp float;
|
||||
|
||||
layout(location = 0) in vec2 a_position;
|
||||
layout(location = 1) in vec2 a_texCoord;
|
||||
layout(location = 2) in vec4 a_color;
|
||||
layout(location = 3) in vec4 a_outlineColor;
|
||||
layout(location = 4) in float a_outlineWidth;
|
||||
|
||||
uniform mat3 u_projection;
|
||||
|
||||
out vec2 v_texCoord;
|
||||
out vec4 v_color;
|
||||
out vec4 v_outlineColor;
|
||||
out float v_outlineWidth;
|
||||
|
||||
void main() {
|
||||
vec3 pos = u_projection * vec3(a_position, 1.0);
|
||||
gl_Position = vec4(pos.xy, 0.0, 1.0);
|
||||
v_texCoord = a_texCoord;
|
||||
v_color = a_color;
|
||||
v_outlineColor = a_outlineColor;
|
||||
v_outlineWidth = a_outlineWidth;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// MSDF text fragment shader source.
|
||||
/// MSDF 文本片段着色器源代码。
|
||||
pub const MSDF_TEXT_FRAGMENT_SHADER: &str = r#"#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_texCoord;
|
||||
in vec4 v_color;
|
||||
in vec4 v_outlineColor;
|
||||
in float v_outlineWidth;
|
||||
|
||||
uniform sampler2D u_msdfTexture;
|
||||
uniform float u_pxRange;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float median(float r, float g, float b) {
|
||||
return max(min(r, g), min(max(r, g), b));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 msdf = texture(u_msdfTexture, v_texCoord).rgb;
|
||||
float sd = median(msdf.r, msdf.g, msdf.b);
|
||||
|
||||
vec2 unitRange = vec2(u_pxRange) / vec2(textureSize(u_msdfTexture, 0));
|
||||
vec2 screenTexSize = vec2(1.0) / fwidth(v_texCoord);
|
||||
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
|
||||
|
||||
float screenPxDistance = screenPxRange * (sd - 0.5);
|
||||
float opacity = clamp(screenPxDistance + 0.5, 0.0, 1.0);
|
||||
|
||||
if (v_outlineWidth > 0.0) {
|
||||
float outlineDistance = screenPxRange * (sd - 0.5 + v_outlineWidth);
|
||||
float outlineOpacity = clamp(outlineDistance + 0.5, 0.0, 1.0);
|
||||
vec4 outlineCol = vec4(v_outlineColor.rgb, v_outlineColor.a * outlineOpacity);
|
||||
vec4 fillCol = vec4(v_color.rgb, v_color.a * opacity);
|
||||
fragColor = mix(outlineCol, fillCol, opacity);
|
||||
} else {
|
||||
fragColor = vec4(v_color.rgb, v_color.a * opacity);
|
||||
}
|
||||
|
||||
if (fragColor.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
// =============================================================================
|
||||
// Sprite Shaders
|
||||
// 精灵着色器
|
||||
// =============================================================================
|
||||
|
||||
/// Sprite vertex shader source.
|
||||
/// 精灵顶点着色器源代码。
|
||||
///
|
||||
|
||||
@@ -6,5 +6,8 @@ mod builtin;
|
||||
mod manager;
|
||||
|
||||
pub use program::ShaderProgram;
|
||||
pub use builtin::{SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER};
|
||||
pub use builtin::{
|
||||
SPRITE_VERTEX_SHADER, SPRITE_FRAGMENT_SHADER,
|
||||
MSDF_TEXT_VERTEX_SHADER, MSDF_TEXT_FRAGMENT_SHADER
|
||||
};
|
||||
pub use manager::{ShaderManager, SHADER_ID_DEFAULT_SPRITE};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@esengine/ui-editor",
|
||||
"name": "@esengine/fairygui-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/ui - inspectors, gizmos, and entity templates",
|
||||
"description": "Editor support for @esengine/fairygui - inspectors, gizmos, and entity templates",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -24,13 +24,13 @@
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/ui": "workspace:*"
|
||||
"@esengine/fairygui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/editor-runtime": "workspace:*",
|
||||
"@esengine/material-system": "workspace:*",
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"ui",
|
||||
"fairygui",
|
||||
"editor"
|
||||
],
|
||||
"author": "",
|
||||
15
packages/fairygui-editor/plugin.json
Normal file
15
packages/fairygui-editor/plugin.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
747
packages/fairygui-editor/src/FGUIEditorModule.ts
Normal file
747
packages/fairygui-editor/src/FGUIEditorModule.ts
Normal 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();
|
||||
54
packages/fairygui-editor/src/index.ts
Normal file
54
packages/fairygui-editor/src/index.ts
Normal 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;
|
||||
242
packages/fairygui-editor/src/inspectors/FGUIInspector.tsx
Normal file
242
packages/fairygui-editor/src/inspectors/FGUIInspector.tsx
Normal 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();
|
||||
9
packages/fairygui-editor/src/inspectors/index.ts
Normal file
9
packages/fairygui-editor/src/inspectors/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* FairyGUI Editor Inspectors
|
||||
*
|
||||
* Custom inspectors for FairyGUI components.
|
||||
*
|
||||
* FairyGUI 组件的自定义检视器
|
||||
*/
|
||||
|
||||
export { FGUIInspectorContent, FGUIComponentInspector, fguiComponentInspector } from './FGUIInspector';
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../build-config/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
20
packages/fairygui-editor/tsup.config.ts
Normal file
20
packages/fairygui-editor/tsup.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'@esengine/ecs-framework',
|
||||
'@esengine/editor-core',
|
||||
'@esengine/asset-system',
|
||||
'@esengine/fairygui',
|
||||
'lucide-react'
|
||||
],
|
||||
esbuildOptions(options) {
|
||||
options.jsx = 'automatic';
|
||||
}
|
||||
});
|
||||
46
packages/fairygui/module.json
Normal file
46
packages/fairygui/module.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "fairygui",
|
||||
"name": "@esengine/fairygui",
|
||||
"globalKey": "fairygui",
|
||||
"displayName": "FairyGUI",
|
||||
"description": "FairyGUI UI system integration | FairyGUI UI 系统集成",
|
||||
"version": "1.0.0",
|
||||
"category": "UI",
|
||||
"icon": "Layout",
|
||||
"tags": [
|
||||
"ui",
|
||||
"fairygui",
|
||||
"gui"
|
||||
],
|
||||
"isCore": false,
|
||||
"defaultEnabled": true,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": true,
|
||||
"platforms": [
|
||||
"web",
|
||||
"desktop"
|
||||
],
|
||||
"dependencies": [
|
||||
"core",
|
||||
"math",
|
||||
"asset-system"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"FGUIComponent"
|
||||
],
|
||||
"systems": [
|
||||
"FGUIRenderSystem",
|
||||
"FGUIUpdateSystem"
|
||||
],
|
||||
"loaders": [
|
||||
"FUIAssetLoader"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/fairygui-editor",
|
||||
"assetExtensions": {
|
||||
".fui": "fui"
|
||||
},
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "FGUIPlugin"
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "@esengine/ui",
|
||||
"name": "@esengine/fairygui",
|
||||
"version": "1.0.0",
|
||||
"description": "ECS-based UI system with WebGL rendering for games",
|
||||
"description": "FairyGUI ECS integration - FairyGUI Editor compatible UI system",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
"pluginExport": "UIPlugin",
|
||||
"category": "ui"
|
||||
"pluginExport": "FGUIPlugin",
|
||||
"editorPackage": "@esengine/fairygui-editor",
|
||||
"category": "ui",
|
||||
"isEngineModule": true
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
@@ -27,7 +29,8 @@
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/asset-system": "workspace:*"
|
||||
"@esengine/asset-system": "workspace:*",
|
||||
"@esengine/ecs-framework-math": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
@@ -41,8 +44,9 @@
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"fairygui",
|
||||
"ui",
|
||||
"webgl",
|
||||
"webgpu",
|
||||
"game-ui"
|
||||
],
|
||||
"author": "",
|
||||
268
packages/fairygui/src/asset/FGUITextureManager.ts
Normal file
268
packages/fairygui/src/asset/FGUITextureManager.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
91
packages/fairygui/src/asset/FUIAssetLoader.ts
Normal file
91
packages/fairygui/src/asset/FUIAssetLoader.ts
Normal 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 };
|
||||
34
packages/fairygui/src/asset/index.ts
Normal file
34
packages/fairygui/src/asset/index.ts
Normal 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';
|
||||
353
packages/fairygui/src/binding/PropertyBinding.ts
Normal file
353
packages/fairygui/src/binding/PropertyBinding.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
327
packages/fairygui/src/core/Controller.ts
Normal file
327
packages/fairygui/src/core/Controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
144
packages/fairygui/src/core/DragDropManager.ts
Normal file
144
packages/fairygui/src/core/DragDropManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
366
packages/fairygui/src/core/FieldTypes.ts
Normal file
366
packages/fairygui/src/core/FieldTypes.ts
Normal 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
|
||||
}
|
||||
1005
packages/fairygui/src/core/GComponent.ts
Normal file
1005
packages/fairygui/src/core/GComponent.ts
Normal file
File diff suppressed because it is too large
Load Diff
261
packages/fairygui/src/core/GGroup.ts
Normal file
261
packages/fairygui/src/core/GGroup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1086
packages/fairygui/src/core/GObject.ts
Normal file
1086
packages/fairygui/src/core/GObject.ts
Normal file
File diff suppressed because it is too large
Load Diff
77
packages/fairygui/src/core/GObjectPool.ts
Normal file
77
packages/fairygui/src/core/GObjectPool.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { GObject } from './GObject';
|
||||
import { UIPackage } from '../package/UIPackage';
|
||||
|
||||
/**
|
||||
* GObjectPool
|
||||
*
|
||||
* Object pool for GObject instances, used for efficient UI recycling.
|
||||
* Objects are pooled by their resource URL.
|
||||
*
|
||||
* GObject 实例对象池,用于高效的 UI 回收。对象按资源 URL 分池管理。
|
||||
*/
|
||||
export class GObjectPool {
|
||||
private _pool: Map<string, GObject[]> = new Map();
|
||||
private _count: number = 0;
|
||||
|
||||
/**
|
||||
* Get total pooled object count
|
||||
* 获取池中对象总数
|
||||
*/
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pooled objects
|
||||
* 清空所有池化对象
|
||||
*/
|
||||
public clear(): void {
|
||||
for (const [, arr] of this._pool) {
|
||||
for (const obj of arr) {
|
||||
obj.dispose();
|
||||
}
|
||||
}
|
||||
this._pool.clear();
|
||||
this._count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object from pool or create new one
|
||||
* 从池中获取对象或创建新对象
|
||||
*
|
||||
* @param url Resource URL | 资源 URL
|
||||
* @returns GObject instance or null | GObject 实例或 null
|
||||
*/
|
||||
public getObject(url: string): GObject | null {
|
||||
url = UIPackage.normalizeURL(url);
|
||||
if (!url) return null;
|
||||
|
||||
const arr = this._pool.get(url);
|
||||
if (arr && arr.length > 0) {
|
||||
this._count--;
|
||||
return arr.shift()!;
|
||||
}
|
||||
|
||||
return UIPackage.createObjectFromURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return object to pool
|
||||
* 将对象归还到池中
|
||||
*
|
||||
* @param obj GObject to return | 要归还的 GObject
|
||||
*/
|
||||
public returnObject(obj: GObject): void {
|
||||
const url = obj.resourceURL;
|
||||
if (!url) return;
|
||||
|
||||
let arr = this._pool.get(url);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
this._pool.set(url, arr);
|
||||
}
|
||||
|
||||
this._count++;
|
||||
arr.push(obj);
|
||||
}
|
||||
}
|
||||
506
packages/fairygui/src/core/GRoot.ts
Normal file
506
packages/fairygui/src/core/GRoot.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
268
packages/fairygui/src/core/ServiceContainer.ts
Normal file
268
packages/fairygui/src/core/ServiceContainer.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
353
packages/fairygui/src/core/Stage.ts
Normal file
353
packages/fairygui/src/core/Stage.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { EventDispatcher } from '../events/EventDispatcher';
|
||||
import { IInputEventData, createInputEventData } from '../events/Events';
|
||||
|
||||
/**
|
||||
* Stage
|
||||
*
|
||||
* Represents the root container and manages input events.
|
||||
*
|
||||
* 表示根容器并管理输入事件
|
||||
*/
|
||||
export class Stage extends EventDispatcher {
|
||||
private static _inst: Stage | null = null;
|
||||
|
||||
/** Stage width | 舞台宽度 */
|
||||
public width: number = 800;
|
||||
|
||||
/** Stage height | 舞台高度 */
|
||||
public height: number = 600;
|
||||
|
||||
/** Current mouse/touch X position | 当前鼠标/触摸 X 坐标 */
|
||||
public mouseX: number = 0;
|
||||
|
||||
/** Current mouse/touch Y position | 当前鼠标/触摸 Y 坐标 */
|
||||
public mouseY: number = 0;
|
||||
|
||||
/** Design width | 设计宽度 */
|
||||
public designWidth: number = 1920;
|
||||
|
||||
/** Design height | 设计高度 */
|
||||
public designHeight: number = 1080;
|
||||
|
||||
/** Scale mode | 缩放模式 */
|
||||
public scaleMode: EScaleMode = EScaleMode.ShowAll;
|
||||
|
||||
/** Align mode | 对齐模式 */
|
||||
public alignH: EAlignMode = EAlignMode.Center;
|
||||
public alignV: EAlignMode = EAlignMode.Middle;
|
||||
|
||||
/** Is touch/pointer down | 是否按下 */
|
||||
public isTouchDown: boolean = false;
|
||||
|
||||
/** Current touch ID | 当前触摸 ID */
|
||||
public touchId: number = 0;
|
||||
|
||||
private _canvas: HTMLCanvasElement | null = null;
|
||||
private _inputData: IInputEventData;
|
||||
private _scaleX: number = 1;
|
||||
private _scaleY: number = 1;
|
||||
private _offsetX: number = 0;
|
||||
private _offsetY: number = 0;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this._inputData = createInputEventData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static get inst(): Stage {
|
||||
if (!Stage._inst) {
|
||||
Stage._inst = new Stage();
|
||||
}
|
||||
return Stage._inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind stage to a canvas element
|
||||
* 绑定舞台到画布元素
|
||||
*
|
||||
* @param canvas HTMLCanvasElement to bind | 要绑定的画布元素
|
||||
*/
|
||||
public bindToCanvas(canvas: HTMLCanvasElement): void {
|
||||
if (this._canvas) {
|
||||
this.unbindCanvas();
|
||||
}
|
||||
|
||||
this._canvas = canvas;
|
||||
this.updateSize();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind from current canvas
|
||||
* 解绑当前画布
|
||||
*/
|
||||
public unbindCanvas(): void {
|
||||
if (!this._canvas) return;
|
||||
|
||||
this._canvas.removeEventListener('mousedown', this.handleMouseDown);
|
||||
this._canvas.removeEventListener('mouseup', this.handleMouseUp);
|
||||
this._canvas.removeEventListener('mousemove', this.handleMouseMove);
|
||||
this._canvas.removeEventListener('wheel', this.handleWheel);
|
||||
this._canvas.removeEventListener('touchstart', this.handleTouchStart);
|
||||
this._canvas.removeEventListener('touchend', this.handleTouchEnd);
|
||||
this._canvas.removeEventListener('touchmove', this.handleTouchMove);
|
||||
this._canvas.removeEventListener('touchcancel', this.handleTouchEnd);
|
||||
|
||||
this._canvas = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stage size from canvas
|
||||
* 从画布更新舞台尺寸
|
||||
*/
|
||||
public updateSize(): void {
|
||||
if (!this._canvas) return;
|
||||
|
||||
this.width = this._canvas.width;
|
||||
this.height = this._canvas.height;
|
||||
|
||||
this.updateScale();
|
||||
this.emit('resize', { width: this.width, height: this.height });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set design size
|
||||
* 设置设计尺寸
|
||||
*/
|
||||
public setDesignSize(width: number, height: number): void {
|
||||
this.designWidth = width;
|
||||
this.designHeight = height;
|
||||
this.updateScale();
|
||||
}
|
||||
|
||||
private updateScale(): void {
|
||||
const scaleX = this.width / this.designWidth;
|
||||
const scaleY = this.height / this.designHeight;
|
||||
|
||||
switch (this.scaleMode) {
|
||||
case EScaleMode.ShowAll:
|
||||
this._scaleX = this._scaleY = Math.min(scaleX, scaleY);
|
||||
break;
|
||||
case EScaleMode.NoBorder:
|
||||
this._scaleX = this._scaleY = Math.max(scaleX, scaleY);
|
||||
break;
|
||||
case EScaleMode.ExactFit:
|
||||
this._scaleX = scaleX;
|
||||
this._scaleY = scaleY;
|
||||
break;
|
||||
case EScaleMode.FixedWidth:
|
||||
this._scaleX = this._scaleY = scaleX;
|
||||
break;
|
||||
case EScaleMode.FixedHeight:
|
||||
this._scaleX = this._scaleY = scaleY;
|
||||
break;
|
||||
case EScaleMode.NoScale:
|
||||
default:
|
||||
this._scaleX = this._scaleY = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const actualWidth = this.designWidth * this._scaleX;
|
||||
const actualHeight = this.designHeight * this._scaleY;
|
||||
|
||||
switch (this.alignH) {
|
||||
case EAlignMode.Left:
|
||||
this._offsetX = 0;
|
||||
break;
|
||||
case EAlignMode.Right:
|
||||
this._offsetX = this.width - actualWidth;
|
||||
break;
|
||||
case EAlignMode.Center:
|
||||
default:
|
||||
this._offsetX = (this.width - actualWidth) / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (this.alignV) {
|
||||
case EAlignMode.Top:
|
||||
this._offsetY = 0;
|
||||
break;
|
||||
case EAlignMode.Bottom:
|
||||
this._offsetY = this.height - actualHeight;
|
||||
break;
|
||||
case EAlignMode.Middle:
|
||||
default:
|
||||
this._offsetY = (this.height - actualHeight) / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert screen coordinates to stage coordinates
|
||||
* 将屏幕坐标转换为舞台坐标
|
||||
*/
|
||||
public screenToStage(screenX: number, screenY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - this._offsetX) / this._scaleX,
|
||||
y: (screenY - this._offsetY) / this._scaleY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert stage coordinates to screen coordinates
|
||||
* 将舞台坐标转换为屏幕坐标
|
||||
*/
|
||||
public stageToScreen(stageX: number, stageY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: stageX * this._scaleX + this._offsetX,
|
||||
y: stageY * this._scaleY + this._offsetY
|
||||
};
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
if (!this._canvas) return;
|
||||
|
||||
this._canvas.addEventListener('mousedown', this.handleMouseDown);
|
||||
this._canvas.addEventListener('mouseup', this.handleMouseUp);
|
||||
this._canvas.addEventListener('mousemove', this.handleMouseMove);
|
||||
this._canvas.addEventListener('wheel', this.handleWheel);
|
||||
this._canvas.addEventListener('touchstart', this.handleTouchStart, { passive: false });
|
||||
this._canvas.addEventListener('touchend', this.handleTouchEnd);
|
||||
this._canvas.addEventListener('touchmove', this.handleTouchMove, { passive: false });
|
||||
this._canvas.addEventListener('touchcancel', this.handleTouchEnd);
|
||||
}
|
||||
|
||||
private getCanvasPosition(e: MouseEvent | Touch): { x: number; y: number } {
|
||||
if (!this._canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const scaleX = this._canvas.width / rect.width;
|
||||
const scaleY = this._canvas.height / rect.height;
|
||||
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY
|
||||
};
|
||||
}
|
||||
|
||||
private updateInputData(e: MouseEvent | Touch, type: string): void {
|
||||
const pos = this.getCanvasPosition(e);
|
||||
const stagePos = this.screenToStage(pos.x, pos.y);
|
||||
|
||||
this._inputData.stageX = stagePos.x;
|
||||
this._inputData.stageY = stagePos.y;
|
||||
this.mouseX = stagePos.x;
|
||||
this.mouseY = stagePos.y;
|
||||
|
||||
if (e instanceof MouseEvent) {
|
||||
this._inputData.button = e.button;
|
||||
this._inputData.ctrlKey = e.ctrlKey;
|
||||
this._inputData.shiftKey = e.shiftKey;
|
||||
this._inputData.altKey = e.altKey;
|
||||
this._inputData.nativeEvent = e;
|
||||
} else {
|
||||
this._inputData.touchId = e.identifier;
|
||||
this.touchId = e.identifier;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseDown = (e: MouseEvent): void => {
|
||||
this.updateInputData(e, 'mousedown');
|
||||
this.isTouchDown = true;
|
||||
this._inputData.touchId = 0;
|
||||
this.emit('mousedown', this._inputData);
|
||||
};
|
||||
|
||||
private handleMouseUp = (e: MouseEvent): void => {
|
||||
this.updateInputData(e, 'mouseup');
|
||||
this.isTouchDown = false;
|
||||
this.emit('mouseup', this._inputData);
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent): void => {
|
||||
this.updateInputData(e, 'mousemove');
|
||||
this.emit('mousemove', this._inputData);
|
||||
};
|
||||
|
||||
private handleWheel = (e: WheelEvent): void => {
|
||||
this.updateInputData(e, 'wheel');
|
||||
this._inputData.wheelDelta = e.deltaY;
|
||||
this._inputData.nativeEvent = e;
|
||||
this.emit('wheel', this._inputData);
|
||||
};
|
||||
|
||||
private handleTouchStart = (e: TouchEvent): void => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length > 0) {
|
||||
const touch = e.touches[0];
|
||||
this.updateInputData(touch, 'touchstart');
|
||||
this.isTouchDown = true;
|
||||
this.emit('mousedown', this._inputData);
|
||||
}
|
||||
};
|
||||
|
||||
private handleTouchEnd = (e: TouchEvent): void => {
|
||||
if (e.changedTouches.length > 0) {
|
||||
const touch = e.changedTouches[0];
|
||||
this.updateInputData(touch, 'touchend');
|
||||
this.isTouchDown = false;
|
||||
this.emit('mouseup', this._inputData);
|
||||
}
|
||||
};
|
||||
|
||||
private handleTouchMove = (e: TouchEvent): void => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length > 0) {
|
||||
const touch = e.touches[0];
|
||||
this.updateInputData(touch, 'touchmove');
|
||||
this.emit('mousemove', this._inputData);
|
||||
}
|
||||
};
|
||||
|
||||
public get scaleX(): number {
|
||||
return this._scaleX;
|
||||
}
|
||||
|
||||
public get scaleY(): number {
|
||||
return this._scaleY;
|
||||
}
|
||||
|
||||
public get offsetX(): number {
|
||||
return this._offsetX;
|
||||
}
|
||||
|
||||
public get offsetY(): number {
|
||||
return this._offsetY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale mode enum
|
||||
* 缩放模式枚举
|
||||
*/
|
||||
export const enum EScaleMode {
|
||||
/** No scaling | 不缩放 */
|
||||
NoScale = 'noscale',
|
||||
/** Show all content (letterbox) | 显示全部内容(黑边) */
|
||||
ShowAll = 'showall',
|
||||
/** Fill screen, clip content | 填充屏幕,裁剪内容 */
|
||||
NoBorder = 'noborder',
|
||||
/** Stretch to fit | 拉伸适应 */
|
||||
ExactFit = 'exactfit',
|
||||
/** Fixed width, height scales | 固定宽度,高度缩放 */
|
||||
FixedWidth = 'fixedwidth',
|
||||
/** Fixed height, width scales | 固定高度,宽度缩放 */
|
||||
FixedHeight = 'fixedheight'
|
||||
}
|
||||
|
||||
/**
|
||||
* Align mode enum
|
||||
* 对齐模式枚举
|
||||
*/
|
||||
export const enum EAlignMode {
|
||||
Left = 'left',
|
||||
Center = 'center',
|
||||
Right = 'right',
|
||||
Top = 'top',
|
||||
Middle = 'middle',
|
||||
Bottom = 'bottom'
|
||||
}
|
||||
266
packages/fairygui/src/core/Timer.ts
Normal file
266
packages/fairygui/src/core/Timer.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Timer callback info
|
||||
* 定时器回调信息
|
||||
*/
|
||||
interface TimerCallback {
|
||||
id: number;
|
||||
caller: any;
|
||||
callback: Function;
|
||||
interval: number;
|
||||
elapsed: number;
|
||||
repeat: boolean;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call later callback info
|
||||
* 延迟调用回调信息
|
||||
*/
|
||||
interface CallLaterItem {
|
||||
caller: any;
|
||||
callback: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer
|
||||
*
|
||||
* Provides timing and scheduling functionality.
|
||||
*
|
||||
* 提供计时和调度功能
|
||||
*/
|
||||
export class Timer {
|
||||
private static _inst: Timer | null = null;
|
||||
|
||||
/** Frame delta time in milliseconds | 帧间隔时间(毫秒) */
|
||||
public delta: number = 0;
|
||||
|
||||
/** Current time in milliseconds | 当前时间(毫秒) */
|
||||
public currentTime: number = 0;
|
||||
|
||||
/** Frame count | 帧数 */
|
||||
public frameCount: number = 0;
|
||||
|
||||
private _callbacks: Map<number, TimerCallback> = new Map();
|
||||
private _callLaterList: CallLaterItem[] = [];
|
||||
private _callLaterPending: CallLaterItem[] = [];
|
||||
private _nextId: number = 1;
|
||||
private _updating: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
this.currentTime = performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static get inst(): Timer {
|
||||
if (!Timer._inst) {
|
||||
Timer._inst = new Timer();
|
||||
}
|
||||
return Timer._inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time (static shortcut)
|
||||
* 获取当前时间(静态快捷方式)
|
||||
*/
|
||||
public static get time(): number {
|
||||
return Timer.inst.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to be called each frame
|
||||
* 添加每帧调用的回调
|
||||
*/
|
||||
public static add(callback: Function, caller: any): void {
|
||||
Timer.inst.frameLoop(1, caller, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callback
|
||||
* 移除回调
|
||||
*/
|
||||
public static remove(callback: Function, caller: any): void {
|
||||
Timer.inst.clear(caller, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timer (called by ECS system each frame)
|
||||
* 更新定时器(每帧由 ECS 系统调用)
|
||||
*
|
||||
* @param deltaMs Delta time in milliseconds | 间隔时间(毫秒)
|
||||
*/
|
||||
public update(deltaMs: number): void {
|
||||
this.delta = deltaMs;
|
||||
this.currentTime += deltaMs;
|
||||
this.frameCount++;
|
||||
|
||||
this._updating = true;
|
||||
|
||||
// Process timers
|
||||
for (const callback of this._callbacks.values()) {
|
||||
if (callback.removed) continue;
|
||||
|
||||
callback.elapsed += deltaMs;
|
||||
if (callback.elapsed >= callback.interval) {
|
||||
callback.callback.call(callback.caller);
|
||||
if (callback.repeat) {
|
||||
callback.elapsed = 0;
|
||||
} else {
|
||||
callback.removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up removed callbacks
|
||||
for (const [id, callback] of this._callbacks) {
|
||||
if (callback.removed) {
|
||||
this._callbacks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process callLater
|
||||
const pending = this._callLaterList;
|
||||
this._callLaterList = this._callLaterPending;
|
||||
this._callLaterPending = [];
|
||||
|
||||
for (const item of pending) {
|
||||
item.callback.call(item.caller);
|
||||
}
|
||||
pending.length = 0;
|
||||
this._callLaterList = pending;
|
||||
|
||||
this._updating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback after specified delay (one time)
|
||||
* 延迟执行回调(一次)
|
||||
*
|
||||
* @param delay Delay in milliseconds | 延迟时间(毫秒)
|
||||
* @param caller Callback context | 回调上下文
|
||||
* @param callback Callback function | 回调函数
|
||||
*/
|
||||
public once(delay: number, caller: any, callback: Function): void {
|
||||
this.addCallback(delay, caller, callback, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback repeatedly at interval
|
||||
* 按间隔重复执行回调
|
||||
*
|
||||
* @param interval Interval in milliseconds | 间隔时间(毫秒)
|
||||
* @param caller Callback context | 回调上下文
|
||||
* @param callback Callback function | 回调函数
|
||||
*/
|
||||
public loop(interval: number, caller: any, callback: Function): void {
|
||||
this.addCallback(interval, caller, callback, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback every frame
|
||||
* 每帧执行回调
|
||||
*
|
||||
* @param interval Frame interval (1 = every frame) | 帧间隔
|
||||
* @param caller Callback context | 回调上下文
|
||||
* @param callback Callback function | 回调函数
|
||||
*/
|
||||
public frameLoop(interval: number, caller: any, callback: Function): void {
|
||||
this.loop(interval * 16.67, caller, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback at the end of current frame
|
||||
* 在当前帧结束时执行回调
|
||||
*
|
||||
* @param caller Callback context | 回调上下文
|
||||
* @param callback Callback function | 回调函数
|
||||
*/
|
||||
public callLater(caller: any, callback: Function): void {
|
||||
const list = this._updating ? this._callLaterPending : this._callLaterList;
|
||||
|
||||
const exists = list.some(
|
||||
(item) => item.caller === caller && item.callback === callback
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
list.push({ caller, callback });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific callback
|
||||
* 清除指定回调
|
||||
*
|
||||
* @param caller Callback context | 回调上下文
|
||||
* @param callback Callback function | 回调函数
|
||||
*/
|
||||
public clear(caller: any, callback: Function): void {
|
||||
for (const cb of this._callbacks.values()) {
|
||||
if (cb.caller === caller && cb.callback === callback) {
|
||||
cb.removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
this._callLaterList = this._callLaterList.filter(
|
||||
(item) => !(item.caller === caller && item.callback === callback)
|
||||
);
|
||||
|
||||
this._callLaterPending = this._callLaterPending.filter(
|
||||
(item) => !(item.caller === caller && item.callback === callback)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all callbacks for a caller
|
||||
* 清除指定对象的所有回调
|
||||
*
|
||||
* @param caller Callback context | 回调上下文
|
||||
*/
|
||||
public clearAll(caller: any): void {
|
||||
for (const cb of this._callbacks.values()) {
|
||||
if (cb.caller === caller) {
|
||||
cb.removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
this._callLaterList = this._callLaterList.filter(
|
||||
(item) => item.caller !== caller
|
||||
);
|
||||
|
||||
this._callLaterPending = this._callLaterPending.filter(
|
||||
(item) => item.caller !== caller
|
||||
);
|
||||
}
|
||||
|
||||
private addCallback(
|
||||
interval: number,
|
||||
caller: any,
|
||||
callback: Function,
|
||||
repeat: boolean
|
||||
): void {
|
||||
this.clear(caller, callback);
|
||||
|
||||
const id = this._nextId++;
|
||||
this._callbacks.set(id, {
|
||||
id,
|
||||
caller,
|
||||
callback,
|
||||
interval,
|
||||
elapsed: 0,
|
||||
repeat,
|
||||
removed: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the timer
|
||||
* 销毁定时器
|
||||
*/
|
||||
public dispose(): void {
|
||||
this._callbacks.clear();
|
||||
this._callLaterList.length = 0;
|
||||
this._callLaterPending.length = 0;
|
||||
}
|
||||
}
|
||||
859
packages/fairygui/src/core/Transition.ts
Normal file
859
packages/fairygui/src/core/Transition.ts
Normal file
@@ -0,0 +1,859 @@
|
||||
import { EventDispatcher } from '../events/EventDispatcher';
|
||||
import type { GComponent } from './GComponent';
|
||||
import type { GObject } from './GObject';
|
||||
import { GTween } from '../tween/GTween';
|
||||
import type { GTweener } from '../tween/GTweener';
|
||||
import { EEaseType } from '../tween/EaseType';
|
||||
import { ByteBuffer } from '../utils/ByteBuffer';
|
||||
import type { SimpleHandler } from '../display/MovieClip';
|
||||
|
||||
/**
|
||||
* Transition action types
|
||||
* 过渡动画动作类型
|
||||
*/
|
||||
export const enum ETransitionActionType {
|
||||
XY = 0,
|
||||
Size = 1,
|
||||
Scale = 2,
|
||||
Pivot = 3,
|
||||
Alpha = 4,
|
||||
Rotation = 5,
|
||||
Color = 6,
|
||||
Animation = 7,
|
||||
Visible = 8,
|
||||
Sound = 9,
|
||||
Transition = 10,
|
||||
Shake = 11,
|
||||
ColorFilter = 12,
|
||||
Skew = 13,
|
||||
Text = 14,
|
||||
Icon = 15,
|
||||
Unknown = 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition item value
|
||||
* 过渡项值
|
||||
*/
|
||||
interface ITransitionValue {
|
||||
f1?: number;
|
||||
f2?: number;
|
||||
f3?: number;
|
||||
f4?: number;
|
||||
b1?: boolean;
|
||||
b2?: boolean;
|
||||
b3?: boolean;
|
||||
visible?: boolean;
|
||||
playing?: boolean;
|
||||
frame?: number;
|
||||
sound?: string;
|
||||
volume?: number;
|
||||
transName?: string;
|
||||
playTimes?: number;
|
||||
trans?: Transition;
|
||||
stopTime?: number;
|
||||
amplitude?: number;
|
||||
duration?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
lastOffsetX?: number;
|
||||
lastOffsetY?: number;
|
||||
text?: string;
|
||||
audioClip?: string;
|
||||
flag?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tween config
|
||||
* 补间配置
|
||||
*/
|
||||
interface ITweenConfig {
|
||||
duration: number;
|
||||
easeType: EEaseType;
|
||||
repeat: number;
|
||||
yoyo: boolean;
|
||||
startValue: ITransitionValue;
|
||||
endValue: ITransitionValue;
|
||||
endLabel?: string;
|
||||
endHook?: SimpleHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition item
|
||||
* 过渡项
|
||||
*/
|
||||
interface ITransitionItem {
|
||||
time: number;
|
||||
targetId: string;
|
||||
type: ETransitionActionType;
|
||||
tweenConfig?: ITweenConfig;
|
||||
label?: string;
|
||||
value: ITransitionValue;
|
||||
hook?: SimpleHandler;
|
||||
tweener?: GTweener;
|
||||
target?: GObject;
|
||||
displayLockToken: number;
|
||||
}
|
||||
|
||||
/** Options flags */
|
||||
const OPTION_AUTO_STOP_DISABLED = 2;
|
||||
const OPTION_AUTO_STOP_AT_END = 4;
|
||||
|
||||
/**
|
||||
* Transition
|
||||
*
|
||||
* Animation transition system for UI components.
|
||||
* Supports keyframe animations, tweening, and chained transitions.
|
||||
*
|
||||
* UI 组件的动画过渡系统,支持关键帧动画、补间和链式过渡
|
||||
*/
|
||||
export class Transition extends EventDispatcher {
|
||||
/** Transition name | 过渡动画名称 */
|
||||
public name: string = '';
|
||||
|
||||
private _owner: GComponent;
|
||||
private _ownerBaseX: number = 0;
|
||||
private _ownerBaseY: number = 0;
|
||||
private _items: ITransitionItem[] = [];
|
||||
private _totalTimes: number = 0;
|
||||
private _totalTasks: number = 0;
|
||||
private _playing: boolean = false;
|
||||
private _paused: boolean = false;
|
||||
private _onComplete: SimpleHandler | null = null;
|
||||
private _options: number = 0;
|
||||
private _reversed: boolean = false;
|
||||
private _totalDuration: number = 0;
|
||||
private _autoPlay: boolean = false;
|
||||
private _autoPlayTimes: number = 1;
|
||||
private _autoPlayDelay: number = 0;
|
||||
private _timeScale: number = 1;
|
||||
private _startTime: number = 0;
|
||||
private _endTime: number = -1;
|
||||
|
||||
constructor(owner: GComponent) {
|
||||
super();
|
||||
this._owner = owner;
|
||||
}
|
||||
|
||||
public get owner(): GComponent {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
public get playing(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
public get autoPlay(): boolean {
|
||||
return this._autoPlay;
|
||||
}
|
||||
|
||||
public set autoPlay(value: boolean) {
|
||||
this.setAutoPlay(value, this._autoPlayTimes, this._autoPlayDelay);
|
||||
}
|
||||
|
||||
public get autoPlayRepeat(): number {
|
||||
return this._autoPlayTimes;
|
||||
}
|
||||
|
||||
public get autoPlayDelay(): number {
|
||||
return this._autoPlayDelay;
|
||||
}
|
||||
|
||||
public get timeScale(): number {
|
||||
return this._timeScale;
|
||||
}
|
||||
|
||||
public set timeScale(value: number) {
|
||||
if (this._timeScale !== value) {
|
||||
this._timeScale = value;
|
||||
if (this._playing) {
|
||||
for (const item of this._items) {
|
||||
if (item.tweener) {
|
||||
item.tweener.setTimeScale(value);
|
||||
} else if (item.type === ETransitionActionType.Transition && item.value.trans) {
|
||||
item.value.trans.timeScale = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public play(
|
||||
onComplete?: SimpleHandler,
|
||||
times: number = 1,
|
||||
delay: number = 0,
|
||||
startTime: number = 0,
|
||||
endTime: number = -1
|
||||
): void {
|
||||
this._play(onComplete || null, times, delay, startTime, endTime, false);
|
||||
}
|
||||
|
||||
public playReverse(
|
||||
onComplete?: SimpleHandler,
|
||||
times: number = 1,
|
||||
delay: number = 0,
|
||||
startTime: number = 0,
|
||||
endTime: number = -1
|
||||
): void {
|
||||
this._play(onComplete || null, times, delay, startTime, endTime, true);
|
||||
}
|
||||
|
||||
public changePlayTimes(value: number): void {
|
||||
this._totalTimes = value;
|
||||
}
|
||||
|
||||
public setAutoPlay(value: boolean, times: number = -1, delay: number = 0): void {
|
||||
if (this._autoPlay !== value) {
|
||||
this._autoPlay = value;
|
||||
this._autoPlayTimes = times;
|
||||
this._autoPlayDelay = delay;
|
||||
|
||||
if (this._autoPlay) {
|
||||
if (this._owner.onStage) {
|
||||
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
|
||||
}
|
||||
} else {
|
||||
if (!this._owner.onStage) {
|
||||
this.stop(false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public _play(
|
||||
onComplete: SimpleHandler | null,
|
||||
times: number,
|
||||
delay: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
reversed: boolean
|
||||
): void {
|
||||
this.stop(true, true);
|
||||
|
||||
this._totalTimes = times;
|
||||
this._reversed = reversed;
|
||||
this._startTime = startTime;
|
||||
this._endTime = endTime;
|
||||
this._playing = true;
|
||||
this._paused = false;
|
||||
this._onComplete = onComplete;
|
||||
|
||||
for (const item of this._items) {
|
||||
if (!item.target) {
|
||||
if (item.targetId) {
|
||||
item.target = this._owner.getChildById(item.targetId) ?? undefined;
|
||||
} else {
|
||||
item.target = this._owner;
|
||||
}
|
||||
} else if (item.target !== this._owner && item.target.parent !== this._owner) {
|
||||
item.target = undefined;
|
||||
}
|
||||
|
||||
if (item.target && item.type === ETransitionActionType.Transition) {
|
||||
let trans = (item.target as GComponent).getTransition(item.value.transName || '');
|
||||
if (trans === this) trans = null;
|
||||
if (trans) {
|
||||
if (item.value.playTimes === 0) {
|
||||
for (let j = this._items.indexOf(item) - 1; j >= 0; j--) {
|
||||
const item2 = this._items[j];
|
||||
if (item2.type === ETransitionActionType.Transition && item2.value.trans === trans) {
|
||||
item2.value.stopTime = item.time - item2.time;
|
||||
trans = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (trans) item.value.stopTime = 0;
|
||||
} else {
|
||||
item.value.stopTime = -1;
|
||||
}
|
||||
}
|
||||
item.value.trans = trans ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (delay === 0) {
|
||||
this.onDelayedPlay();
|
||||
} else {
|
||||
GTween.delayedCall(delay).setTarget(this).onComplete(() => this.onDelayedPlay());
|
||||
}
|
||||
}
|
||||
|
||||
public stop(bSetToComplete: boolean = true, bProcessCallback: boolean = false): void {
|
||||
if (!this._playing) return;
|
||||
|
||||
this._playing = false;
|
||||
this._totalTasks = 0;
|
||||
this._totalTimes = 0;
|
||||
const handler = this._onComplete;
|
||||
this._onComplete = null;
|
||||
|
||||
GTween.kill(this);
|
||||
|
||||
const cnt = this._items.length;
|
||||
if (this._reversed) {
|
||||
for (let i = cnt - 1; i >= 0; i--) {
|
||||
const item = this._items[i];
|
||||
if (item.target) this.stopItem(item, bSetToComplete);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const item = this._items[i];
|
||||
if (item.target) this.stopItem(item, bSetToComplete);
|
||||
}
|
||||
}
|
||||
|
||||
if (bProcessCallback && handler) {
|
||||
if (typeof handler === 'function') handler();
|
||||
else if (typeof handler.run === 'function') handler.run();
|
||||
}
|
||||
}
|
||||
|
||||
private stopItem(item: ITransitionItem, bSetToComplete: boolean): void {
|
||||
if (item.tweener) {
|
||||
item.tweener.kill(bSetToComplete);
|
||||
item.tweener = undefined;
|
||||
|
||||
if (item.type === ETransitionActionType.Shake && !bSetToComplete && item.target) {
|
||||
item.target.x -= item.value.lastOffsetX || 0;
|
||||
item.target.y -= item.value.lastOffsetY || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === ETransitionActionType.Transition && item.value.trans) {
|
||||
item.value.trans.stop(bSetToComplete, false);
|
||||
}
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (!this._playing || this._paused) return;
|
||||
this._paused = true;
|
||||
|
||||
const tweener = GTween.getTween(this);
|
||||
if (tweener) tweener.setPaused(true);
|
||||
|
||||
for (const item of this._items) {
|
||||
if (!item.target) continue;
|
||||
if (item.type === ETransitionActionType.Transition && item.value.trans) {
|
||||
item.value.trans.pause();
|
||||
}
|
||||
if (item.tweener) item.tweener.setPaused(true);
|
||||
}
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (!this._playing || !this._paused) return;
|
||||
this._paused = false;
|
||||
|
||||
const tweener = GTween.getTween(this);
|
||||
if (tweener) tweener.setPaused(false);
|
||||
|
||||
for (const item of this._items) {
|
||||
if (!item.target) continue;
|
||||
if (item.type === ETransitionActionType.Transition && item.value.trans) {
|
||||
item.value.trans.resume();
|
||||
}
|
||||
if (item.tweener) item.tweener.setPaused(false);
|
||||
}
|
||||
}
|
||||
|
||||
public setValue(label: string, ...values: any[]): void {
|
||||
for (const item of this._items) {
|
||||
if (item.label === label) {
|
||||
const value = item.tweenConfig ? item.tweenConfig.startValue : item.value;
|
||||
this.setItemValue(item.type, value, values);
|
||||
return;
|
||||
} else if (item.tweenConfig?.endLabel === label) {
|
||||
this.setItemValue(item.type, item.tweenConfig.endValue, values);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setItemValue(type: ETransitionActionType, value: ITransitionValue, args: any[]): void {
|
||||
switch (type) {
|
||||
case ETransitionActionType.XY:
|
||||
case ETransitionActionType.Size:
|
||||
case ETransitionActionType.Pivot:
|
||||
case ETransitionActionType.Scale:
|
||||
case ETransitionActionType.Skew:
|
||||
value.b1 = value.b2 = true;
|
||||
value.f1 = parseFloat(args[0]);
|
||||
value.f2 = parseFloat(args[1]);
|
||||
break;
|
||||
case ETransitionActionType.Alpha:
|
||||
case ETransitionActionType.Rotation:
|
||||
case ETransitionActionType.Color:
|
||||
value.f1 = parseFloat(args[0]);
|
||||
break;
|
||||
case ETransitionActionType.Animation:
|
||||
value.frame = parseInt(args[0]);
|
||||
if (args.length > 1) value.playing = args[1];
|
||||
break;
|
||||
case ETransitionActionType.Visible:
|
||||
value.visible = args[0];
|
||||
break;
|
||||
case ETransitionActionType.Sound:
|
||||
value.sound = args[0];
|
||||
if (args.length > 1) value.volume = parseFloat(args[1]);
|
||||
break;
|
||||
case ETransitionActionType.Transition:
|
||||
value.transName = args[0];
|
||||
if (args.length > 1) value.playTimes = parseInt(args[1]);
|
||||
break;
|
||||
case ETransitionActionType.Shake:
|
||||
value.amplitude = parseFloat(args[0]);
|
||||
if (args.length > 1) value.duration = parseFloat(args[1]);
|
||||
break;
|
||||
case ETransitionActionType.ColorFilter:
|
||||
value.f1 = parseFloat(args[0]);
|
||||
value.f2 = parseFloat(args[1]);
|
||||
value.f3 = parseFloat(args[2]);
|
||||
value.f4 = parseFloat(args[3]);
|
||||
break;
|
||||
case ETransitionActionType.Text:
|
||||
case ETransitionActionType.Icon:
|
||||
value.text = args[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public setTarget(label: string, target: GObject): void {
|
||||
for (const item of this._items) {
|
||||
if (item.label === label) {
|
||||
item.targetId = target.id;
|
||||
item.target = target;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setHook(label: string, callback: SimpleHandler): void {
|
||||
for (const item of this._items) {
|
||||
if (item.label === label) {
|
||||
item.hook = callback;
|
||||
return;
|
||||
} else if (item.tweenConfig?.endLabel === label) {
|
||||
item.tweenConfig.endHook = callback;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clearHooks(): void {
|
||||
for (const item of this._items) {
|
||||
item.hook = undefined;
|
||||
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public onOwnerAddedToStage(): void {
|
||||
if (this._autoPlay && !this._playing) {
|
||||
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
|
||||
}
|
||||
}
|
||||
|
||||
public onOwnerRemovedFromStage(): void {
|
||||
if ((this._options & OPTION_AUTO_STOP_DISABLED) === 0) {
|
||||
this.stop((this._options & OPTION_AUTO_STOP_AT_END) !== 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
private onDelayedPlay(): void {
|
||||
this._ownerBaseX = this._owner.x;
|
||||
this._ownerBaseY = this._owner.y;
|
||||
this._totalTasks = 1;
|
||||
|
||||
const cnt = this._items.length;
|
||||
for (let i = this._reversed ? cnt - 1 : 0; this._reversed ? i >= 0 : i < cnt; this._reversed ? i-- : i++) {
|
||||
const item = this._items[i];
|
||||
if (item.target) this.playItem(item);
|
||||
}
|
||||
|
||||
this._totalTasks--;
|
||||
this.checkAllComplete();
|
||||
}
|
||||
|
||||
private playItem(item: ITransitionItem): void {
|
||||
let time: number;
|
||||
|
||||
if (item.tweenConfig) {
|
||||
time = this._reversed
|
||||
? this._totalDuration - item.time - item.tweenConfig.duration
|
||||
: item.time;
|
||||
|
||||
if (this._endTime === -1 || time < this._endTime) {
|
||||
const startValue = this._reversed ? item.tweenConfig.endValue : item.tweenConfig.startValue;
|
||||
const endValue = this._reversed ? item.tweenConfig.startValue : item.tweenConfig.endValue;
|
||||
|
||||
item.value.b1 = startValue.b1;
|
||||
item.value.b2 = startValue.b2;
|
||||
|
||||
switch (item.type) {
|
||||
case ETransitionActionType.XY:
|
||||
case ETransitionActionType.Size:
|
||||
case ETransitionActionType.Scale:
|
||||
case ETransitionActionType.Skew:
|
||||
item.tweener = GTween.to2(
|
||||
startValue.f1 || 0, startValue.f2 || 0,
|
||||
endValue.f1 || 0, endValue.f2 || 0,
|
||||
item.tweenConfig.duration
|
||||
);
|
||||
break;
|
||||
case ETransitionActionType.Alpha:
|
||||
case ETransitionActionType.Rotation:
|
||||
item.tweener = GTween.to(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
|
||||
break;
|
||||
case ETransitionActionType.Color:
|
||||
item.tweener = GTween.toColor(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
|
||||
break;
|
||||
case ETransitionActionType.ColorFilter:
|
||||
item.tweener = GTween.to4(
|
||||
startValue.f1 || 0, startValue.f2 || 0, startValue.f3 || 0, startValue.f4 || 0,
|
||||
endValue.f1 || 0, endValue.f2 || 0, endValue.f3 || 0, endValue.f4 || 0,
|
||||
item.tweenConfig.duration
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.tweener) {
|
||||
item.tweener
|
||||
.setDelay(time)
|
||||
.setEase(item.tweenConfig.easeType)
|
||||
.setRepeat(item.tweenConfig.repeat, item.tweenConfig.yoyo)
|
||||
.setTimeScale(this._timeScale)
|
||||
.setTarget(item)
|
||||
.onStart(() => this.callHook(item, false))
|
||||
.onUpdate(() => this.onTweenUpdate(item))
|
||||
.onComplete(() => this.onTweenComplete(item));
|
||||
|
||||
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - time);
|
||||
this._totalTasks++;
|
||||
}
|
||||
}
|
||||
} else if (item.type === ETransitionActionType.Shake) {
|
||||
time = this._reversed
|
||||
? this._totalDuration - item.time - (item.value.duration || 0)
|
||||
: item.time;
|
||||
|
||||
item.value.offsetX = item.value.offsetY = 0;
|
||||
item.value.lastOffsetX = item.value.lastOffsetY = 0;
|
||||
|
||||
item.tweener = GTween.shake(0, 0, item.value.amplitude || 0, item.value.duration || 0)
|
||||
.setDelay(time)
|
||||
.setTimeScale(this._timeScale)
|
||||
.setTarget(item)
|
||||
.onUpdate(() => this.onTweenUpdate(item))
|
||||
.onComplete(() => this.onTweenComplete(item));
|
||||
|
||||
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - item.time);
|
||||
this._totalTasks++;
|
||||
} else {
|
||||
time = this._reversed ? this._totalDuration - item.time : item.time;
|
||||
|
||||
if (time <= this._startTime) {
|
||||
this.applyValue(item);
|
||||
this.callHook(item, false);
|
||||
} else if (this._endTime === -1 || time <= this._endTime) {
|
||||
this._totalTasks++;
|
||||
item.tweener = GTween.delayedCall(time)
|
||||
.setTimeScale(this._timeScale)
|
||||
.setTarget(item)
|
||||
.onComplete(() => {
|
||||
item.tweener = undefined;
|
||||
this._totalTasks--;
|
||||
this.applyValue(item);
|
||||
this.callHook(item, false);
|
||||
this.checkAllComplete();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onTweenUpdate(item: ITransitionItem): void {
|
||||
if (!item.tweener) return;
|
||||
const tweener = item.tweener;
|
||||
|
||||
switch (item.type) {
|
||||
case ETransitionActionType.XY:
|
||||
case ETransitionActionType.Size:
|
||||
case ETransitionActionType.Scale:
|
||||
case ETransitionActionType.Skew:
|
||||
item.value.f1 = tweener.value.x;
|
||||
item.value.f2 = tweener.value.y;
|
||||
break;
|
||||
case ETransitionActionType.Alpha:
|
||||
case ETransitionActionType.Rotation:
|
||||
item.value.f1 = tweener.value.x;
|
||||
break;
|
||||
case ETransitionActionType.Color:
|
||||
item.value.f1 = tweener.value.color;
|
||||
break;
|
||||
case ETransitionActionType.ColorFilter:
|
||||
item.value.f1 = tweener.value.x;
|
||||
item.value.f2 = tweener.value.y;
|
||||
item.value.f3 = tweener.value.z;
|
||||
item.value.f4 = tweener.value.w;
|
||||
break;
|
||||
case ETransitionActionType.Shake:
|
||||
item.value.offsetX = tweener.deltaValue.x;
|
||||
item.value.offsetY = tweener.deltaValue.y;
|
||||
break;
|
||||
}
|
||||
this.applyValue(item);
|
||||
}
|
||||
|
||||
private onTweenComplete(item: ITransitionItem): void {
|
||||
item.tweener = undefined;
|
||||
this._totalTasks--;
|
||||
this.callHook(item, true);
|
||||
this.checkAllComplete();
|
||||
}
|
||||
|
||||
private checkAllComplete(): void {
|
||||
if (this._playing && this._totalTasks === 0) {
|
||||
if (this._totalTimes < 0) {
|
||||
this.internalPlay();
|
||||
} else {
|
||||
this._totalTimes--;
|
||||
if (this._totalTimes > 0) {
|
||||
this.internalPlay();
|
||||
} else {
|
||||
this._playing = false;
|
||||
const handler = this._onComplete;
|
||||
this._onComplete = null;
|
||||
if (handler) {
|
||||
if (typeof handler === 'function') handler();
|
||||
else if (typeof handler.run === 'function') handler.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private internalPlay(): void {
|
||||
this._ownerBaseX = this._owner.x;
|
||||
this._ownerBaseY = this._owner.y;
|
||||
this._totalTasks = 1;
|
||||
|
||||
for (const item of this._items) {
|
||||
if (item.target) this.playItem(item);
|
||||
}
|
||||
this._totalTasks--;
|
||||
}
|
||||
|
||||
private callHook(item: ITransitionItem, tweenEnd: boolean): void {
|
||||
const hook = tweenEnd ? item.tweenConfig?.endHook : item.hook;
|
||||
if (hook) {
|
||||
if (typeof hook === 'function') hook();
|
||||
else if (typeof hook.run === 'function') hook.run();
|
||||
}
|
||||
}
|
||||
|
||||
private applyValue(item: ITransitionItem): void {
|
||||
if (!item.target) return;
|
||||
const value = item.value;
|
||||
const target = item.target;
|
||||
|
||||
switch (item.type) {
|
||||
case ETransitionActionType.XY:
|
||||
if (target === this._owner) {
|
||||
if (value.b1 && value.b2) target.setXY((value.f1 || 0) + this._ownerBaseX, (value.f2 || 0) + this._ownerBaseY);
|
||||
else if (value.b1) target.x = (value.f1 || 0) + this._ownerBaseX;
|
||||
else target.y = (value.f2 || 0) + this._ownerBaseY;
|
||||
} else if (value.b3) {
|
||||
if (value.b1 && value.b2) target.setXY((value.f1 || 0) * this._owner.width, (value.f2 || 0) * this._owner.height);
|
||||
else if (value.b1) target.x = (value.f1 || 0) * this._owner.width;
|
||||
else if (value.b2) target.y = (value.f2 || 0) * this._owner.height;
|
||||
} else {
|
||||
if (value.b1 && value.b2) target.setXY(value.f1 || 0, value.f2 || 0);
|
||||
else if (value.b1) target.x = value.f1 || 0;
|
||||
else if (value.b2) target.y = value.f2 || 0;
|
||||
}
|
||||
break;
|
||||
case ETransitionActionType.Size:
|
||||
if (!value.b1) value.f1 = target.width;
|
||||
if (!value.b2) value.f2 = target.height;
|
||||
target.setSize(value.f1 || 0, value.f2 || 0);
|
||||
break;
|
||||
case ETransitionActionType.Pivot:
|
||||
target.setPivot(value.f1 || 0, value.f2 || 0, target.pivotAsAnchor);
|
||||
break;
|
||||
case ETransitionActionType.Alpha:
|
||||
target.alpha = value.f1 || 0;
|
||||
break;
|
||||
case ETransitionActionType.Rotation:
|
||||
target.rotation = value.f1 || 0;
|
||||
break;
|
||||
case ETransitionActionType.Scale:
|
||||
target.setScale(value.f1 || 0, value.f2 || 0);
|
||||
break;
|
||||
case ETransitionActionType.Skew:
|
||||
target.setSkew(value.f1 || 0, value.f2 || 0);
|
||||
break;
|
||||
case ETransitionActionType.Visible:
|
||||
target.visible = value.visible || false;
|
||||
break;
|
||||
case ETransitionActionType.Transition:
|
||||
if (this._playing && value.trans) {
|
||||
this._totalTasks++;
|
||||
const startTime = this._startTime > item.time ? this._startTime - item.time : 0;
|
||||
let endTime = this._endTime >= 0 ? this._endTime - item.time : -1;
|
||||
if (value.stopTime !== undefined && value.stopTime >= 0 && (endTime < 0 || endTime > value.stopTime)) {
|
||||
endTime = value.stopTime;
|
||||
}
|
||||
value.trans.timeScale = this._timeScale;
|
||||
value.trans._play(() => { this._totalTasks--; this.checkAllComplete(); }, value.playTimes || 1, 0, startTime, endTime, this._reversed);
|
||||
}
|
||||
break;
|
||||
case ETransitionActionType.Shake:
|
||||
target.x = target.x - (value.lastOffsetX || 0) + (value.offsetX || 0);
|
||||
target.y = target.y - (value.lastOffsetY || 0) + (value.offsetY || 0);
|
||||
value.lastOffsetX = value.offsetX;
|
||||
value.lastOffsetY = value.offsetY;
|
||||
break;
|
||||
case ETransitionActionType.Text:
|
||||
target.text = value.text || '';
|
||||
break;
|
||||
case ETransitionActionType.Icon:
|
||||
target.icon = value.text || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public setup(buffer: ByteBuffer): void {
|
||||
this.name = buffer.readS();
|
||||
this._options = buffer.getInt32();
|
||||
this._autoPlay = buffer.readBool();
|
||||
this._autoPlayTimes = buffer.getInt32();
|
||||
this._autoPlayDelay = buffer.getFloat32();
|
||||
|
||||
const cnt = buffer.getInt16();
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const dataLen = buffer.getInt16();
|
||||
const curPos = buffer.position;
|
||||
|
||||
buffer.seek(curPos, 0);
|
||||
|
||||
const item: ITransitionItem = {
|
||||
type: buffer.readByte() as ETransitionActionType,
|
||||
time: buffer.getFloat32(),
|
||||
targetId: '',
|
||||
value: {},
|
||||
displayLockToken: 0
|
||||
};
|
||||
|
||||
const targetId = buffer.getInt16();
|
||||
if (targetId >= 0) {
|
||||
const child = this._owner.getChildAt(targetId);
|
||||
item.targetId = child?.id || '';
|
||||
}
|
||||
|
||||
item.label = buffer.readS();
|
||||
|
||||
if (buffer.readBool()) {
|
||||
buffer.seek(curPos, 1);
|
||||
item.tweenConfig = {
|
||||
duration: buffer.getFloat32(),
|
||||
easeType: buffer.readByte() as EEaseType,
|
||||
repeat: buffer.getInt32(),
|
||||
yoyo: buffer.readBool(),
|
||||
startValue: {},
|
||||
endValue: {},
|
||||
endLabel: buffer.readS()
|
||||
};
|
||||
|
||||
buffer.seek(curPos, 2);
|
||||
this.decodeValue(item.type, buffer, item.tweenConfig.startValue);
|
||||
|
||||
buffer.seek(curPos, 3);
|
||||
this.decodeValue(item.type, buffer, item.tweenConfig.endValue);
|
||||
} else {
|
||||
buffer.seek(curPos, 2);
|
||||
this.decodeValue(item.type, buffer, item.value);
|
||||
}
|
||||
|
||||
this._items.push(item);
|
||||
buffer.position = curPos + dataLen;
|
||||
}
|
||||
|
||||
this._totalDuration = 0;
|
||||
for (const item of this._items) {
|
||||
let duration = item.time;
|
||||
if (item.tweenConfig) duration += item.tweenConfig.duration * (item.tweenConfig.repeat + 1);
|
||||
else if (item.type === ETransitionActionType.Shake) duration += item.value.duration || 0;
|
||||
if (duration > this._totalDuration) this._totalDuration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
private decodeValue(type: ETransitionActionType, buffer: ByteBuffer, value: ITransitionValue): void {
|
||||
switch (type) {
|
||||
case ETransitionActionType.XY:
|
||||
case ETransitionActionType.Size:
|
||||
case ETransitionActionType.Pivot:
|
||||
case ETransitionActionType.Skew:
|
||||
value.b1 = buffer.readBool();
|
||||
value.b2 = buffer.readBool();
|
||||
value.f1 = buffer.getFloat32();
|
||||
value.f2 = buffer.getFloat32();
|
||||
if (buffer.version >= 2 && type === ETransitionActionType.XY) value.b3 = buffer.readBool();
|
||||
break;
|
||||
case ETransitionActionType.Alpha:
|
||||
case ETransitionActionType.Rotation:
|
||||
value.f1 = buffer.getFloat32();
|
||||
break;
|
||||
case ETransitionActionType.Scale:
|
||||
value.f1 = buffer.getFloat32();
|
||||
value.f2 = buffer.getFloat32();
|
||||
break;
|
||||
case ETransitionActionType.Color:
|
||||
value.f1 = buffer.readColor();
|
||||
break;
|
||||
case ETransitionActionType.Animation:
|
||||
value.playing = buffer.readBool();
|
||||
value.frame = buffer.getInt32();
|
||||
break;
|
||||
case ETransitionActionType.Visible:
|
||||
value.visible = buffer.readBool();
|
||||
break;
|
||||
case ETransitionActionType.Sound:
|
||||
value.sound = buffer.readS();
|
||||
value.volume = buffer.getFloat32();
|
||||
break;
|
||||
case ETransitionActionType.Transition:
|
||||
value.transName = buffer.readS();
|
||||
value.playTimes = buffer.getInt32();
|
||||
break;
|
||||
case ETransitionActionType.Shake:
|
||||
value.amplitude = buffer.getFloat32();
|
||||
value.duration = buffer.getFloat32();
|
||||
break;
|
||||
case ETransitionActionType.ColorFilter:
|
||||
value.f1 = buffer.getFloat32();
|
||||
value.f2 = buffer.getFloat32();
|
||||
value.f3 = buffer.getFloat32();
|
||||
value.f4 = buffer.getFloat32();
|
||||
break;
|
||||
case ETransitionActionType.Text:
|
||||
case ETransitionActionType.Icon:
|
||||
value.text = buffer.readS();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this._playing) GTween.kill(this);
|
||||
|
||||
for (const item of this._items) {
|
||||
if (item.tweener) {
|
||||
item.tweener.kill();
|
||||
item.tweener = undefined;
|
||||
}
|
||||
item.target = undefined;
|
||||
item.hook = undefined;
|
||||
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
|
||||
}
|
||||
|
||||
this._items.length = 0;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
116
packages/fairygui/src/core/UIConfig.ts
Normal file
116
packages/fairygui/src/core/UIConfig.ts
Normal 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);
|
||||
}
|
||||
184
packages/fairygui/src/core/UIObjectFactory.ts
Normal file
184
packages/fairygui/src/core/UIObjectFactory.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
39
packages/fairygui/src/core/init.ts
Normal file
39
packages/fairygui/src/core/init.ts
Normal 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
|
||||
35
packages/fairygui/src/display/Container.ts
Normal file
35
packages/fairygui/src/display/Container.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
638
packages/fairygui/src/display/DisplayObject.ts
Normal file
638
packages/fairygui/src/display/DisplayObject.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
173
packages/fairygui/src/display/Graph.ts
Normal file
173
packages/fairygui/src/display/Graph.ts
Normal 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)
|
||||
* 解析颜色字符串为打包的 u32(0xRRGGBBAA 格式)
|
||||
*/
|
||||
private parseColor(color: string): number {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1);
|
||||
if (hex.length === 6) {
|
||||
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
|
||||
} else if (hex.length === 8) {
|
||||
return parseInt(hex, 16) >>> 0;
|
||||
}
|
||||
}
|
||||
return 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
public collectRenderData(collector: IRenderCollector): void {
|
||||
if (!this._visible || this._alpha <= 0 || this._type === EGraphType.Empty) return;
|
||||
|
||||
this.updateTransform();
|
||||
|
||||
const fillColorNum = this.parseColor(this.fillColor);
|
||||
|
||||
const primitive: IRenderPrimitive = {
|
||||
type: ERenderPrimitiveType.Graph,
|
||||
sortOrder: 0,
|
||||
worldMatrix: this._worldMatrix,
|
||||
width: this._width,
|
||||
height: this._height,
|
||||
alpha: this._worldAlpha,
|
||||
grayed: this._grayed,
|
||||
graphType: this._type,
|
||||
lineSize: this.lineSize,
|
||||
lineColor: this.parseColor(this.lineColor),
|
||||
fillColor: fillColorNum,
|
||||
clipRect: collector.getCurrentClipRect() || undefined
|
||||
};
|
||||
|
||||
if (this.cornerRadius) {
|
||||
primitive.cornerRadius = this.cornerRadius;
|
||||
}
|
||||
|
||||
if (this._type === EGraphType.Polygon && this.polygonPoints) {
|
||||
primitive.polygonPoints = this.polygonPoints;
|
||||
}
|
||||
|
||||
if (this._type === EGraphType.RegularPolygon) {
|
||||
primitive.sides = this.sides;
|
||||
primitive.startAngle = this.startAngle;
|
||||
if (this.distances) {
|
||||
primitive.distances = this.distances;
|
||||
}
|
||||
}
|
||||
|
||||
collector.addPrimitive(primitive);
|
||||
}
|
||||
}
|
||||
201
packages/fairygui/src/display/Image.ts
Normal file
201
packages/fairygui/src/display/Image.ts
Normal 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)
|
||||
* 解析颜色字符串为打包的 u32(0xRRGGBBAA 格式)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
341
packages/fairygui/src/display/InputTextField.ts
Normal file
341
packages/fairygui/src/display/InputTextField.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
420
packages/fairygui/src/display/MovieClip.ts
Normal file
420
packages/fairygui/src/display/MovieClip.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { Image } from './Image';
|
||||
import { Timer } from '../core/Timer';
|
||||
import { FGUIEvents } from '../events/Events';
|
||||
import type { IRenderCollector } from '../render/IRenderCollector';
|
||||
|
||||
/**
|
||||
* Frame data for movie clip animation
|
||||
* 动画帧数据
|
||||
*/
|
||||
export interface IFrame {
|
||||
/** Additional delay for this frame | 该帧额外延迟 */
|
||||
addDelay: number;
|
||||
/** Texture ID for this frame | 该帧的纹理 ID */
|
||||
texture?: string | number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple callback handler
|
||||
* 简单回调处理器
|
||||
*/
|
||||
export type SimpleHandler = (() => void) | { run: () => void };
|
||||
|
||||
/**
|
||||
* MovieClip
|
||||
*
|
||||
* Animated sprite display object with frame-based animation.
|
||||
*
|
||||
* 基于帧的动画精灵显示对象
|
||||
*
|
||||
* Features:
|
||||
* - Frame-by-frame animation
|
||||
* - Swing (ping-pong) mode
|
||||
* - Time scale control
|
||||
* - Play range and loop control
|
||||
*/
|
||||
export class MovieClip extends Image {
|
||||
/** Frame interval in milliseconds | 帧间隔(毫秒) */
|
||||
public interval: number = 0;
|
||||
|
||||
/** Swing mode (ping-pong) | 摆动模式 */
|
||||
public swing: boolean = false;
|
||||
|
||||
/** Delay between loops | 循环间延迟 */
|
||||
public repeatDelay: number = 0;
|
||||
|
||||
/** Time scale multiplier | 时间缩放 */
|
||||
public timeScale: number = 1;
|
||||
|
||||
private _playing: boolean = true;
|
||||
private _frameCount: number = 0;
|
||||
private _frames: IFrame[] = [];
|
||||
private _frame: number = 0;
|
||||
private _start: number = 0;
|
||||
private _end: number = 0;
|
||||
private _times: number = 0;
|
||||
private _endAt: number = 0;
|
||||
private _status: number = 0; // 0-none, 1-next loop, 2-ending, 3-ended
|
||||
|
||||
private _frameElapsed: number = 0;
|
||||
private _reversed: boolean = false;
|
||||
private _repeatedCount: number = 0;
|
||||
private _endHandler: SimpleHandler | null = null;
|
||||
private _isOnStage: boolean = false;
|
||||
private _lastTime: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.touchable = false;
|
||||
this.setPlaySettings();
|
||||
|
||||
// Subscribe to stage lifecycle events
|
||||
// 订阅舞台生命周期事件
|
||||
this.on(FGUIEvents.ADDED_TO_STAGE, this.onAddToStage, this);
|
||||
this.on(FGUIEvents.REMOVED_FROM_STAGE, this.onRemoveFromStage, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get animation frames
|
||||
* 获取动画帧
|
||||
*/
|
||||
public get frames(): IFrame[] {
|
||||
return this._frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set animation frames
|
||||
* 设置动画帧
|
||||
*/
|
||||
public set frames(value: IFrame[]) {
|
||||
this._frames = value;
|
||||
this.scaleByTile = false;
|
||||
this.scale9Grid = null;
|
||||
|
||||
if (this._frames && this._frames.length > 0) {
|
||||
this._frameCount = this._frames.length;
|
||||
|
||||
if (this._end === -1 || this._end > this._frameCount - 1) {
|
||||
this._end = this._frameCount - 1;
|
||||
}
|
||||
if (this._endAt === -1 || this._endAt > this._frameCount - 1) {
|
||||
this._endAt = this._frameCount - 1;
|
||||
}
|
||||
if (this._frame < 0 || this._frame > this._frameCount - 1) {
|
||||
this._frame = this._frameCount - 1;
|
||||
}
|
||||
|
||||
this._frameElapsed = 0;
|
||||
this._repeatedCount = 0;
|
||||
this._reversed = false;
|
||||
} else {
|
||||
this._frameCount = 0;
|
||||
}
|
||||
|
||||
this.drawFrame();
|
||||
this.checkTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frame count
|
||||
* 获取帧数
|
||||
*/
|
||||
public get frameCount(): number {
|
||||
return this._frameCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current frame index
|
||||
* 获取当前帧索引
|
||||
*/
|
||||
public get frame(): number {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current frame index
|
||||
* 设置当前帧索引
|
||||
*/
|
||||
public set frame(value: number) {
|
||||
if (this._frame !== value) {
|
||||
if (this._frames && value >= this._frameCount) {
|
||||
value = this._frameCount - 1;
|
||||
}
|
||||
|
||||
this._frame = value;
|
||||
this._frameElapsed = 0;
|
||||
this.drawFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playing state
|
||||
* 获取播放状态
|
||||
*/
|
||||
public get playing(): boolean {
|
||||
return this._playing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set playing state
|
||||
* 设置播放状态
|
||||
*/
|
||||
public set playing(value: boolean) {
|
||||
if (this._playing !== value) {
|
||||
this._playing = value;
|
||||
this.checkTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind to first frame
|
||||
* 倒回到第一帧
|
||||
*/
|
||||
public rewind(): void {
|
||||
this._frame = 0;
|
||||
this._frameElapsed = 0;
|
||||
this._reversed = false;
|
||||
this._repeatedCount = 0;
|
||||
|
||||
this.drawFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status from another MovieClip
|
||||
* 从另一个 MovieClip 同步状态
|
||||
*/
|
||||
public syncStatus(anotherMc: MovieClip): void {
|
||||
this._frame = anotherMc._frame;
|
||||
this._frameElapsed = anotherMc._frameElapsed;
|
||||
this._reversed = anotherMc._reversed;
|
||||
this._repeatedCount = anotherMc._repeatedCount;
|
||||
|
||||
this.drawFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance animation by time
|
||||
* 推进动画时间
|
||||
*
|
||||
* @param timeInMilliseconds Time to advance | 推进时间(毫秒)
|
||||
*/
|
||||
public advance(timeInMilliseconds: number): void {
|
||||
const beginFrame = this._frame;
|
||||
const beginReversed = this._reversed;
|
||||
const backupTime = timeInMilliseconds;
|
||||
|
||||
while (true) {
|
||||
let tt = this.interval + this._frames[this._frame].addDelay;
|
||||
if (this._frame === 0 && this._repeatedCount > 0) {
|
||||
tt += this.repeatDelay;
|
||||
}
|
||||
if (timeInMilliseconds < tt) {
|
||||
this._frameElapsed = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
timeInMilliseconds -= tt;
|
||||
|
||||
if (this.swing) {
|
||||
if (this._reversed) {
|
||||
this._frame--;
|
||||
if (this._frame <= 0) {
|
||||
this._frame = 0;
|
||||
this._repeatedCount++;
|
||||
this._reversed = !this._reversed;
|
||||
}
|
||||
} else {
|
||||
this._frame++;
|
||||
if (this._frame > this._frameCount - 1) {
|
||||
this._frame = Math.max(0, this._frameCount - 2);
|
||||
this._repeatedCount++;
|
||||
this._reversed = !this._reversed;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._frame++;
|
||||
if (this._frame > this._frameCount - 1) {
|
||||
this._frame = 0;
|
||||
this._repeatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Completed one round
|
||||
if (this._frame === beginFrame && this._reversed === beginReversed) {
|
||||
const roundTime = backupTime - timeInMilliseconds;
|
||||
timeInMilliseconds -= Math.floor(timeInMilliseconds / roundTime) * roundTime;
|
||||
}
|
||||
}
|
||||
|
||||
this.drawFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set play settings
|
||||
* 设置播放参数
|
||||
*
|
||||
* @param start Start frame | 开始帧
|
||||
* @param end End frame (-1 for last) | 结束帧(-1 为最后一帧)
|
||||
* @param times Loop times (0 for infinite) | 循环次数(0 为无限)
|
||||
* @param endAt Stop at frame (-1 for end) | 停止帧(-1 为结束帧)
|
||||
* @param endHandler Callback on end | 结束回调
|
||||
*/
|
||||
public setPlaySettings(
|
||||
start: number = 0,
|
||||
end: number = -1,
|
||||
times: number = 0,
|
||||
endAt: number = -1,
|
||||
endHandler: SimpleHandler | null = null
|
||||
): void {
|
||||
this._start = start;
|
||||
this._end = end;
|
||||
if (this._end === -1 || this._end > this._frameCount - 1) {
|
||||
this._end = this._frameCount - 1;
|
||||
}
|
||||
this._times = times;
|
||||
this._endAt = endAt;
|
||||
if (this._endAt === -1) {
|
||||
this._endAt = this._end;
|
||||
}
|
||||
this._status = 0;
|
||||
this._endHandler = endHandler;
|
||||
this.frame = start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when added to stage
|
||||
* 添加到舞台时调用
|
||||
*/
|
||||
public onAddToStage(): void {
|
||||
this._isOnStage = true;
|
||||
this._lastTime = Timer.time;
|
||||
this.checkTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when removed from stage
|
||||
* 从舞台移除时调用
|
||||
*/
|
||||
public onRemoveFromStage(): void {
|
||||
this._isOnStage = false;
|
||||
this.checkTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation (called each frame)
|
||||
* 更新动画(每帧调用)
|
||||
*/
|
||||
public update(): void {
|
||||
if (!this._playing || this._frameCount === 0 || this._status === 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Timer.time;
|
||||
let dt = currentTime - this._lastTime;
|
||||
this._lastTime = currentTime;
|
||||
|
||||
if (dt > 100) {
|
||||
dt = 100;
|
||||
}
|
||||
if (this.timeScale !== 1) {
|
||||
dt *= this.timeScale;
|
||||
}
|
||||
|
||||
this._frameElapsed += dt;
|
||||
let tt = this.interval + this._frames[this._frame].addDelay;
|
||||
if (this._frame === 0 && this._repeatedCount > 0) {
|
||||
tt += this.repeatDelay;
|
||||
}
|
||||
if (this._frameElapsed < tt) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._frameElapsed -= tt;
|
||||
if (this._frameElapsed > this.interval) {
|
||||
this._frameElapsed = this.interval;
|
||||
}
|
||||
|
||||
if (this.swing) {
|
||||
if (this._reversed) {
|
||||
this._frame--;
|
||||
if (this._frame <= 0) {
|
||||
this._frame = 0;
|
||||
this._repeatedCount++;
|
||||
this._reversed = !this._reversed;
|
||||
}
|
||||
} else {
|
||||
this._frame++;
|
||||
if (this._frame > this._frameCount - 1) {
|
||||
this._frame = Math.max(0, this._frameCount - 2);
|
||||
this._repeatedCount++;
|
||||
this._reversed = !this._reversed;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._frame++;
|
||||
if (this._frame > this._frameCount - 1) {
|
||||
this._frame = 0;
|
||||
this._repeatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._status === 1) {
|
||||
// New loop
|
||||
this._frame = this._start;
|
||||
this._frameElapsed = 0;
|
||||
this._status = 0;
|
||||
} else if (this._status === 2) {
|
||||
// Ending
|
||||
this._frame = this._endAt;
|
||||
this._frameElapsed = 0;
|
||||
this._status = 3; // Ended
|
||||
|
||||
// Play end callback
|
||||
if (this._endHandler) {
|
||||
const handler = this._endHandler;
|
||||
this._endHandler = null;
|
||||
if (typeof handler === 'function') {
|
||||
handler();
|
||||
} else {
|
||||
handler.run();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this._frame === this._end) {
|
||||
if (this._times > 0) {
|
||||
this._times--;
|
||||
if (this._times === 0) {
|
||||
this._status = 2; // Ending
|
||||
} else {
|
||||
this._status = 1; // New loop
|
||||
}
|
||||
} else {
|
||||
this._status = 1; // New loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.drawFrame();
|
||||
}
|
||||
|
||||
private drawFrame(): void {
|
||||
if (this._frameCount > 0 && this._frame < this._frames.length) {
|
||||
const frame = this._frames[this._frame];
|
||||
this.texture = frame.texture ?? null;
|
||||
} else {
|
||||
this.texture = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkTimer(): void {
|
||||
if (this._playing && this._frameCount > 0 && this._isOnStage) {
|
||||
Timer.add(this.update, this);
|
||||
} else {
|
||||
Timer.remove(this.update, this);
|
||||
}
|
||||
}
|
||||
|
||||
public collectRenderData(collector: IRenderCollector): void {
|
||||
super.collectRenderData(collector);
|
||||
}
|
||||
}
|
||||
270
packages/fairygui/src/display/TextField.ts
Normal file
270
packages/fairygui/src/display/TextField.ts
Normal 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)
|
||||
* 解析颜色字符串为打包的 u32(0xRRGGBBAA 格式)
|
||||
*/
|
||||
private parseColor(color: string): number {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1);
|
||||
if (hex.length === 6) {
|
||||
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
|
||||
} else if (hex.length === 8) {
|
||||
return parseInt(hex, 16) >>> 0;
|
||||
}
|
||||
}
|
||||
return 0x000000FF;
|
||||
}
|
||||
|
||||
public collectRenderData(collector: IRenderCollector): void {
|
||||
if (!this._visible || this._alpha <= 0 || !this._text) return;
|
||||
|
||||
this.updateTransform();
|
||||
|
||||
const primitive: IRenderPrimitive = {
|
||||
type: ERenderPrimitiveType.Text,
|
||||
sortOrder: 0,
|
||||
worldMatrix: this._worldMatrix,
|
||||
width: this._width,
|
||||
height: this._height,
|
||||
alpha: this._worldAlpha,
|
||||
grayed: this._grayed,
|
||||
text: this._text,
|
||||
font: this.font,
|
||||
fontSize: this.fontSize,
|
||||
color: this.parseColor(this.color),
|
||||
align: this.align,
|
||||
valign: this.valign,
|
||||
leading: this.leading,
|
||||
letterSpacing: this.letterSpacing,
|
||||
bold: this.bold,
|
||||
italic: this.italic,
|
||||
underline: this.underline,
|
||||
singleLine: this.singleLine,
|
||||
wordWrap: this.wordWrap,
|
||||
clipRect: collector.getCurrentClipRect() || undefined
|
||||
};
|
||||
|
||||
if (this.stroke > 0) {
|
||||
primitive.stroke = this.stroke;
|
||||
primitive.strokeColor = this.parseColor(this.strokeColor);
|
||||
}
|
||||
|
||||
collector.addPrimitive(primitive);
|
||||
}
|
||||
}
|
||||
418
packages/fairygui/src/ecs/FGUIComponent.ts
Normal file
418
packages/fairygui/src/ecs/FGUIComponent.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
209
packages/fairygui/src/ecs/FGUIRenderSystem.ts
Normal file
209
packages/fairygui/src/ecs/FGUIRenderSystem.ts
Normal 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;
|
||||
}
|
||||
179
packages/fairygui/src/ecs/FGUIRuntimeModule.ts
Normal file
179
packages/fairygui/src/ecs/FGUIRuntimeModule.ts
Normal 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()
|
||||
};
|
||||
200
packages/fairygui/src/ecs/FGUIUpdateSystem.ts
Normal file
200
packages/fairygui/src/ecs/FGUIUpdateSystem.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
21
packages/fairygui/src/ecs/index.ts
Normal file
21
packages/fairygui/src/ecs/index.ts
Normal 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';
|
||||
349
packages/fairygui/src/events/EventDispatcher.ts
Normal file
349
packages/fairygui/src/events/EventDispatcher.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
packages/fairygui/src/events/Events.ts
Normal file
142
packages/fairygui/src/events/Events.ts
Normal 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
|
||||
};
|
||||
}
|
||||
70
packages/fairygui/src/gears/GearAnimation.ts
Normal file
70
packages/fairygui/src/gears/GearAnimation.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
152
packages/fairygui/src/gears/GearBase.ts
Normal file
152
packages/fairygui/src/gears/GearBase.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { GObject } from '../core/GObject';
|
||||
import type { Controller } from '../core/Controller';
|
||||
import type { ByteBuffer } from '../utils/ByteBuffer';
|
||||
import { EEaseType } from '../core/FieldTypes';
|
||||
|
||||
/**
|
||||
* GearBase
|
||||
*
|
||||
* Base class for all gear types.
|
||||
* Gears connect object properties to controller states.
|
||||
*
|
||||
* 所有齿轮类型的基类,齿轮将对象属性连接到控制器状态
|
||||
*/
|
||||
export abstract class GearBase {
|
||||
/** Owner object | 所有者对象 */
|
||||
public readonly owner: GObject;
|
||||
|
||||
/** Controller | 控制器 */
|
||||
protected _controller: Controller | null = null;
|
||||
|
||||
/** Tween config | 缓动配置 */
|
||||
public tweenConfig: GearTweenConfig | null = null;
|
||||
|
||||
constructor(owner: GObject) {
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controller
|
||||
* 获取控制器
|
||||
*/
|
||||
public get controller(): Controller | null {
|
||||
return this._controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set controller
|
||||
* 设置控制器
|
||||
*/
|
||||
public set controller(value: Controller | null) {
|
||||
if (this._controller !== value) {
|
||||
this._controller = value;
|
||||
if (this._controller) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to a controller
|
||||
* 检查是否连接到控制器
|
||||
*/
|
||||
public get connected(): boolean {
|
||||
return this._controller !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize gear
|
||||
* 初始化齿轮
|
||||
*/
|
||||
protected abstract init(): void;
|
||||
|
||||
/**
|
||||
* Apply gear values
|
||||
* 应用齿轮值
|
||||
*/
|
||||
public abstract apply(): void;
|
||||
|
||||
/**
|
||||
* Update current state
|
||||
* 更新当前状态
|
||||
*/
|
||||
public abstract updateState(): void;
|
||||
|
||||
/**
|
||||
* Setup gear from buffer
|
||||
* 从缓冲区设置齿轮
|
||||
*/
|
||||
public setup(buffer: ByteBuffer): void {
|
||||
const parent = this.owner.parent;
|
||||
if (!parent) return;
|
||||
|
||||
this._controller = parent.getControllerAt(buffer.getInt16());
|
||||
this.init();
|
||||
|
||||
const cnt = buffer.getInt16();
|
||||
|
||||
// Read pages - subclasses should override to parse their specific data
|
||||
this.readStatusFromBuffer(buffer, cnt);
|
||||
|
||||
// Read default status
|
||||
if (buffer.readBool()) {
|
||||
this.readDefaultStatusFromBuffer(buffer);
|
||||
}
|
||||
|
||||
// Read tween config
|
||||
if (buffer.readBool()) {
|
||||
this.tweenConfig = new GearTweenConfig();
|
||||
this.tweenConfig.easeType = buffer.readByte() as EEaseType;
|
||||
this.tweenConfig.duration = buffer.getFloat32();
|
||||
this.tweenConfig.delay = buffer.getFloat32();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read status data from buffer
|
||||
* 从缓冲区读取状态数据
|
||||
*/
|
||||
protected readStatusFromBuffer(_buffer: ByteBuffer, _cnt: number): void {
|
||||
// Override in subclasses to parse specific gear data
|
||||
// Default: skip the data (each page has a string ID)
|
||||
for (let i = 0; i < _cnt; i++) {
|
||||
_buffer.readS(); // page id
|
||||
// Subclass should read its specific data here
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read default status from buffer
|
||||
* 从缓冲区读取默认状态
|
||||
*/
|
||||
protected readDefaultStatusFromBuffer(_buffer: ByteBuffer): void {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose
|
||||
* 销毁
|
||||
*/
|
||||
public dispose(): void {
|
||||
this._controller = null;
|
||||
this.tweenConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gear tween configuration
|
||||
* 齿轮缓动配置
|
||||
*/
|
||||
export class GearTweenConfig {
|
||||
/** Tween enabled | 是否启用缓动 */
|
||||
public tween: boolean = true;
|
||||
|
||||
/** Ease type | 缓动类型 */
|
||||
public easeType: EEaseType = EEaseType.QuadOut;
|
||||
|
||||
/** Duration in seconds | 持续时间(秒) */
|
||||
public duration: number = 0.3;
|
||||
|
||||
/** Delay in seconds | 延迟时间(秒) */
|
||||
public delay: number = 0;
|
||||
}
|
||||
74
packages/fairygui/src/gears/GearColor.ts
Normal file
74
packages/fairygui/src/gears/GearColor.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/fairygui/src/gears/GearDisplay.ts
Normal file
71
packages/fairygui/src/gears/GearDisplay.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
packages/fairygui/src/gears/GearDisplay2.ts
Normal file
67
packages/fairygui/src/gears/GearDisplay2.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
packages/fairygui/src/gears/GearFontSize.ts
Normal file
53
packages/fairygui/src/gears/GearFontSize.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/fairygui/src/gears/GearIcon.ts
Normal file
49
packages/fairygui/src/gears/GearIcon.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { GearBase } from './GearBase';
|
||||
import type { GObject } from '../core/GObject';
|
||||
|
||||
/**
|
||||
* GearIcon
|
||||
*
|
||||
* Controls object icon based on controller state.
|
||||
* 根据控制器状态控制对象图标
|
||||
*/
|
||||
export class GearIcon extends GearBase {
|
||||
private _storage: Map<string, string> = new Map();
|
||||
private _default: string = '';
|
||||
|
||||
constructor(owner: GObject) {
|
||||
super(owner);
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
this._default = this.owner.icon ?? '';
|
||||
this._storage.clear();
|
||||
}
|
||||
|
||||
public apply(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const icon = this._storage.get(this._controller.selectedPageId) ?? this._default;
|
||||
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.icon = icon;
|
||||
this.owner._gearLocked = false;
|
||||
}
|
||||
|
||||
public updateState(): void {
|
||||
if (!this._controller) return;
|
||||
this._storage.set(this._controller.selectedPageId, this.owner.icon ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add status
|
||||
* 添加状态
|
||||
*/
|
||||
public addStatus(pageId: string | null, icon: string): void {
|
||||
if (pageId === null) {
|
||||
this._default = icon;
|
||||
} else {
|
||||
this._storage.set(pageId, icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
packages/fairygui/src/gears/GearLook.ts
Normal file
122
packages/fairygui/src/gears/GearLook.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { GearBase } from './GearBase';
|
||||
import { GTween } from '../tween/GTween';
|
||||
import type { GTweener } from '../tween/GTweener';
|
||||
import type { GObject } from '../core/GObject';
|
||||
|
||||
/**
|
||||
* Look value for GearLook
|
||||
* GearLook 的外观值
|
||||
*/
|
||||
interface ILookValue {
|
||||
alpha: number;
|
||||
rotation: number;
|
||||
grayed: boolean;
|
||||
touchable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GearLook
|
||||
*
|
||||
* Controls object appearance (alpha, rotation, grayed, touchable) based on controller state.
|
||||
* 根据控制器状态控制对象外观(透明度、旋转、灰度、可触摸)
|
||||
*/
|
||||
export class GearLook extends GearBase {
|
||||
private _storage: Map<string, ILookValue> = new Map();
|
||||
private _default: ILookValue = { alpha: 1, rotation: 0, grayed: false, touchable: true };
|
||||
private _tweener: GTweener | null = null;
|
||||
|
||||
constructor(owner: GObject) {
|
||||
super(owner);
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
this._default = {
|
||||
alpha: this.owner.alpha,
|
||||
rotation: this.owner.rotation,
|
||||
grayed: this.owner.grayed,
|
||||
touchable: this.owner.touchable
|
||||
};
|
||||
this._storage.clear();
|
||||
}
|
||||
|
||||
public apply(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
|
||||
|
||||
// grayed and touchable cannot be tweened, apply immediately
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.grayed = gv.grayed;
|
||||
this.owner.touchable = gv.touchable;
|
||||
this.owner._gearLocked = false;
|
||||
|
||||
if (this.tweenConfig?.tween && this.owner.onStage) {
|
||||
if (this._tweener) {
|
||||
if (this._tweener.endValue.x !== gv.alpha || this._tweener.endValue.y !== gv.rotation) {
|
||||
this._tweener.kill();
|
||||
this._tweener = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const oa = this.owner.alpha;
|
||||
const or = this.owner.rotation;
|
||||
|
||||
if (oa !== gv.alpha || or !== gv.rotation) {
|
||||
this._tweener = GTween.to2(oa, or, gv.alpha, gv.rotation, this.tweenConfig.duration)
|
||||
.setDelay(this.tweenConfig.delay)
|
||||
.setEase(this.tweenConfig.easeType)
|
||||
.setTarget(this, 'look')
|
||||
.onUpdate((tweener) => {
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.alpha = tweener.value.x;
|
||||
this.owner.rotation = tweener.value.y;
|
||||
this.owner._gearLocked = false;
|
||||
})
|
||||
.onComplete(() => {
|
||||
this._tweener = null;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.alpha = gv.alpha;
|
||||
this.owner.rotation = gv.rotation;
|
||||
this.owner._gearLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
public updateState(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const gv: ILookValue = {
|
||||
alpha: this.owner.alpha,
|
||||
rotation: this.owner.rotation,
|
||||
grayed: this.owner.grayed,
|
||||
touchable: this.owner.touchable
|
||||
};
|
||||
|
||||
this._storage.set(this._controller.selectedPageId, gv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add status from buffer
|
||||
* 从缓冲区添加状态
|
||||
*/
|
||||
public addStatus(
|
||||
pageId: string | null,
|
||||
alpha: number,
|
||||
rotation: number,
|
||||
grayed: boolean,
|
||||
touchable: boolean
|
||||
): void {
|
||||
if (pageId === null) {
|
||||
this._default.alpha = alpha;
|
||||
this._default.rotation = rotation;
|
||||
this._default.grayed = grayed;
|
||||
this._default.touchable = touchable;
|
||||
} else {
|
||||
this._storage.set(pageId, { alpha, rotation, grayed, touchable });
|
||||
}
|
||||
}
|
||||
}
|
||||
150
packages/fairygui/src/gears/GearSize.ts
Normal file
150
packages/fairygui/src/gears/GearSize.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { GearBase } from './GearBase';
|
||||
import { GTween } from '../tween/GTween';
|
||||
import type { GTweener } from '../tween/GTweener';
|
||||
import type { GObject } from '../core/GObject';
|
||||
|
||||
/**
|
||||
* Size value for GearSize
|
||||
* GearSize 的尺寸值
|
||||
*/
|
||||
interface ISizeValue {
|
||||
width: number;
|
||||
height: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GearSize
|
||||
*
|
||||
* Controls object size and scale based on controller state.
|
||||
* 根据控制器状态控制对象尺寸和缩放
|
||||
*/
|
||||
export class GearSize extends GearBase {
|
||||
private _storage: Map<string, ISizeValue> = new Map();
|
||||
private _default: ISizeValue = { width: 0, height: 0, scaleX: 1, scaleY: 1 };
|
||||
private _tweener: GTweener | null = null;
|
||||
|
||||
constructor(owner: GObject) {
|
||||
super(owner);
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
this._default = {
|
||||
width: this.owner.width,
|
||||
height: this.owner.height,
|
||||
scaleX: this.owner.scaleX,
|
||||
scaleY: this.owner.scaleY
|
||||
};
|
||||
this._storage.clear();
|
||||
}
|
||||
|
||||
public apply(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
|
||||
|
||||
if (this.tweenConfig?.tween && this.owner.onStage) {
|
||||
if (this._tweener) {
|
||||
if (
|
||||
this._tweener.endValue.x !== gv.width ||
|
||||
this._tweener.endValue.y !== gv.height ||
|
||||
this._tweener.endValue.z !== gv.scaleX ||
|
||||
this._tweener.endValue.w !== gv.scaleY
|
||||
) {
|
||||
this._tweener.kill();
|
||||
this._tweener = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ow = this.owner.width;
|
||||
const oh = this.owner.height;
|
||||
const osx = this.owner.scaleX;
|
||||
const osy = this.owner.scaleY;
|
||||
|
||||
if (ow !== gv.width || oh !== gv.height || osx !== gv.scaleX || osy !== gv.scaleY) {
|
||||
this._tweener = GTween.to4(
|
||||
ow,
|
||||
oh,
|
||||
osx,
|
||||
osy,
|
||||
gv.width,
|
||||
gv.height,
|
||||
gv.scaleX,
|
||||
gv.scaleY,
|
||||
this.tweenConfig.duration
|
||||
)
|
||||
.setDelay(this.tweenConfig.delay)
|
||||
.setEase(this.tweenConfig.easeType)
|
||||
.setTarget(this, 'size')
|
||||
.onUpdate((tweener) => {
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.setSize(tweener.value.x, tweener.value.y);
|
||||
this.owner.setScale(tweener.value.z, tweener.value.w);
|
||||
this.owner._gearLocked = false;
|
||||
})
|
||||
.onComplete(() => {
|
||||
this._tweener = null;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.setSize(gv.width, gv.height);
|
||||
this.owner.setScale(gv.scaleX, gv.scaleY);
|
||||
this.owner._gearLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
public updateState(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const gv: ISizeValue = {
|
||||
width: this.owner.width,
|
||||
height: this.owner.height,
|
||||
scaleX: this.owner.scaleX,
|
||||
scaleY: this.owner.scaleY
|
||||
};
|
||||
|
||||
this._storage.set(this._controller.selectedPageId, gv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update size from relation changes
|
||||
* 从关联变更中更新尺寸
|
||||
*/
|
||||
public updateFromRelations(dWidth: number, dHeight: number): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
for (const gv of this._storage.values()) {
|
||||
gv.width += dWidth;
|
||||
gv.height += dHeight;
|
||||
}
|
||||
this._default.width += dWidth;
|
||||
this._default.height += dHeight;
|
||||
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add status from buffer
|
||||
* 从缓冲区添加状态
|
||||
*/
|
||||
public addStatus(
|
||||
pageId: string | null,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleX: number,
|
||||
scaleY: number
|
||||
): void {
|
||||
if (pageId === null) {
|
||||
this._default.width = width;
|
||||
this._default.height = height;
|
||||
this._default.scaleX = scaleX;
|
||||
this._default.scaleY = scaleY;
|
||||
} else {
|
||||
this._storage.set(pageId, { width, height, scaleX, scaleY });
|
||||
}
|
||||
}
|
||||
}
|
||||
50
packages/fairygui/src/gears/GearText.ts
Normal file
50
packages/fairygui/src/gears/GearText.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { GearBase } from './GearBase';
|
||||
import { EObjectPropID } from '../core/FieldTypes';
|
||||
import type { GObject } from '../core/GObject';
|
||||
|
||||
/**
|
||||
* GearText
|
||||
*
|
||||
* Controls object text content based on controller state.
|
||||
* 根据控制器状态控制对象文本内容
|
||||
*/
|
||||
export class GearText extends GearBase {
|
||||
private _storage: Map<string, string> = new Map();
|
||||
private _default: string = '';
|
||||
|
||||
constructor(owner: GObject) {
|
||||
super(owner);
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
this._default = this.owner.text ?? '';
|
||||
this._storage.clear();
|
||||
}
|
||||
|
||||
public apply(): void {
|
||||
if (!this._controller) return;
|
||||
|
||||
const text = this._storage.get(this._controller.selectedPageId) ?? this._default;
|
||||
|
||||
this.owner._gearLocked = true;
|
||||
this.owner.text = text;
|
||||
this.owner._gearLocked = false;
|
||||
}
|
||||
|
||||
public updateState(): void {
|
||||
if (!this._controller) return;
|
||||
this._storage.set(this._controller.selectedPageId, this.owner.text ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add status
|
||||
* 添加状态
|
||||
*/
|
||||
public addStatus(pageId: string | null, text: string): void {
|
||||
if (pageId === null) {
|
||||
this._default = text;
|
||||
} else {
|
||||
this._storage.set(pageId, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
159
packages/fairygui/src/gears/GearXY.ts
Normal file
159
packages/fairygui/src/gears/GearXY.ts
Normal 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
Reference in New Issue
Block a user