refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,44 @@
{
"id": "audio",
"name": "@esengine/audio",
"globalKey": "audio",
"displayName": "Audio",
"description": "Audio playback and sound effects | 音频播放和音效",
"version": "1.0.0",
"category": "Audio",
"icon": "Volume2",
"tags": [
"audio",
"sound",
"music"
],
"isCore": false,
"defaultEnabled": false,
"isEngineModule": true,
"canContainContent": true,
"platforms": [
"web",
"desktop",
"mobile"
],
"dependencies": [
"core",
"asset-system"
],
"exports": {
"components": [
"AudioSourceComponent",
"AudioListenerComponent"
],
"systems": [
"AudioSystem"
],
"other": [
"AudioClip",
"AudioMixer"
]
},
"requiresWasm": false,
"outputPath": "dist/index.js",
"pluginExport": "AudioPlugin"
}

View File

@@ -0,0 +1,46 @@
{
"name": "@esengine/audio",
"version": "1.0.0",
"description": "ECS-based audio system",
"esengine": {
"plugin": true,
"pluginExport": "AudioPlugin",
"category": "audio",
"isEnginePlugin": true
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",
"typescript": "^5.3.3"
},
"keywords": [
"ecs",
"audio",
"sound",
"music"
],
"author": "yhh",
"license": "MIT"
}

View File

@@ -0,0 +1,28 @@
import type { IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
import { AudioSourceComponent } from './AudioSourceComponent';
class AudioRuntimeModule implements IRuntimeModule {
registerComponents(registry: IComponentRegistry): void {
registry.register(AudioSourceComponent);
}
}
const manifest: ModuleManifest = {
id: 'audio',
name: '@esengine/audio',
displayName: 'Audio',
version: '1.0.0',
description: '音频组件',
category: 'Audio',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
dependencies: ['core', 'asset-system'],
exports: { components: ['AudioSourceComponent'] }
};
export const AudioPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new AudioRuntimeModule()
};

View File

@@ -0,0 +1,47 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
@ECSComponent('AudioSource')
@Serializable({ version: 2, typeId: 'AudioSource' })
export class AudioSourceComponent extends Component {
/**
* 音频资产 GUID
* Audio clip asset GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Audio Clip', assetType: 'audio' })
clipGuid: string = '';
/** 范围 [0, 1] */
@Serialize()
@Property({ type: 'number', label: 'Volume', min: 0, max: 1, step: 0.01 })
volume: number = 1;
@Serialize()
@Property({ type: 'number', label: 'Pitch', min: 0.1, max: 3, step: 0.1 })
pitch: number = 1;
@Serialize()
@Property({ type: 'boolean', label: 'Loop' })
loop: boolean = false;
@Serialize()
@Property({ type: 'boolean', label: 'Play On Awake' })
playOnAwake: boolean = false;
@Serialize()
@Property({ type: 'boolean', label: 'Mute' })
mute: boolean = false;
/** 0 = 2D, 1 = 3D */
@Serialize()
@Property({ type: 'number', label: 'Spatial Blend', min: 0, max: 1, step: 0.1 })
spatialBlend: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Min Distance' })
minDistance: number = 1;
@Serialize()
@Property({ type: 'number', label: 'Max Distance' })
maxDistance: number = 500;
}

View File

@@ -0,0 +1,6 @@
export { AudioSourceComponent } from './AudioSourceComponent';
export { AudioPlugin } from './AudioPlugin';
// Service Tokens (reserved for future use)
// 服务令牌(预留用于未来扩展)
// export { AudioManagerToken, type IAudioManager } from './tokens';

View File

@@ -0,0 +1,31 @@
/**
* Audio Module Service Tokens
* 音频模块服务令牌
*
* 遵循"谁定义接口,谁导出 Token"原则。
* Following "who defines interface, who exports Token" principle.
*
* 当前模块仅提供组件,暂无服务定义。
* 此文件预留用于未来可能添加的 AudioManager 服务。
*
* Currently this module only provides components, no services defined yet.
* This file is reserved for potential future AudioManager service.
*/
// import { createServiceToken } from '@esengine/ecs-framework';
// ============================================================================
// Reserved for future service tokens
// 预留用于未来的服务令牌
// ============================================================================
// export interface IAudioManager {
// // 播放音效 | Play sound effect
// playSound(path: string): void;
// // 播放背景音乐 | Play background music
// playMusic(path: string): void;
// // 停止所有音频 | Stop all audio
// stopAll(): void;
// }
// export const AudioManagerToken = createServiceToken<IAudioManager>('audioManager');

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "../../framework/core"
}
]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...runtimeOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});

View File

@@ -0,0 +1,39 @@
{
"id": "camera",
"name": "@esengine/camera",
"globalKey": "camera",
"displayName": "Camera",
"description": "Camera and viewport management | 相机和视口管理",
"version": "1.0.0",
"category": "Rendering",
"icon": "Video",
"tags": [
"camera",
"viewport",
"rendering"
],
"isCore": false,
"defaultEnabled": true,
"isEngineModule": true,
"canContainContent": false,
"platforms": [
"web",
"desktop",
"mobile"
],
"dependencies": [
"core",
"math"
],
"exports": {
"components": [
"CameraComponent"
],
"systems": [
"CameraSystem"
]
},
"requiresWasm": false,
"outputPath": "dist/index.js",
"pluginExport": "CameraPlugin"
}

View File

@@ -0,0 +1,48 @@
{
"name": "@esengine/camera",
"version": "1.0.0",
"description": "Camera component and systems for 2D/3D rendering",
"esengine": {
"plugin": true,
"pluginExport": "CameraPlugin",
"category": "core",
"isEnginePlugin": true
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",
"typescript": "^5.3.3"
},
"keywords": [
"ecs",
"camera",
"2d",
"3d",
"rendering"
],
"author": "yhh",
"license": "MIT"
}

View File

@@ -0,0 +1,72 @@
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
export enum ECameraProjection {
Perspective = 'perspective',
Orthographic = 'orthographic'
}
@ECSComponent('Camera')
@Serializable({ version: 1, typeId: 'Camera' })
export class CameraComponent extends Component {
@Serialize()
@Property({
type: 'enum',
label: 'Projection',
options: [
{ label: 'Orthographic', value: ECameraProjection.Orthographic },
{ label: 'Perspective', value: ECameraProjection.Perspective }
]
})
public projection: ECameraProjection = ECameraProjection.Orthographic;
/** 透视模式下的视野角度,范围 [1, 179] 度 */
@Serialize()
@Property({ type: 'number', label: 'Field of View', min: 1, max: 179 })
public fieldOfView: number = 60;
/** 正交模式下的可见区域半高度(世界单位) */
@Serialize()
@Property({ type: 'number', label: 'Orthographic Size', min: 0.1, step: 0.1 })
public orthographicSize: number = 5;
@Serialize()
@Property({ type: 'number', label: 'Near Clip', min: 0.01, step: 0.1 })
public nearClipPlane: number = 0.1;
@Serialize()
@Property({ type: 'number', label: 'Far Clip', min: 1, step: 10 })
public farClipPlane: number = 1000;
/** 视口归一化坐标,范围 [0, 1] */
@Serialize()
@Property({ type: 'number', label: 'Viewport X', min: 0, max: 1, step: 0.01 })
public viewportX: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Viewport Y', min: 0, max: 1, step: 0.01 })
public viewportY: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Viewport Width', min: 0, max: 1, step: 0.01 })
public viewportWidth: number = 1;
@Serialize()
@Property({ type: 'number', label: 'Viewport Height', min: 0, max: 1, step: 0.01 })
public viewportHeight: number = 1;
/** 渲染优先级,值越大越后渲染(覆盖在上层) */
@Serialize()
@Property({ type: 'integer', label: 'Depth' })
public depth: number = 0;
@Serialize()
@Property({ type: 'color', label: 'Background Color' })
public backgroundColor: string = '#000000';
constructor() {
super();
}
}
/** @deprecated 使用 ECameraProjection 代替 */
export const CameraProjection = ECameraProjection;

View File

@@ -0,0 +1,320 @@
/**
* 相机管理器 - 提供相机相关的全局服务
* Camera Manager - Provides global camera services
*
* 主要功能:
* - 管理主相机
* - 屏幕坐标与世界坐标转换
*
* Main features:
* - Manage main camera
* - Screen to world coordinate conversion
*/
import type { Entity, IScene } from '@esengine/ecs-framework';
import type { IVector2 } from '@esengine/ecs-framework-math';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent, ECameraProjection } from './CameraComponent';
/**
* 相机管理器接口
* Camera manager interface
*/
export interface ICameraManager {
/**
* 设置场景引用
* Set scene reference
*/
setScene(scene: IScene | null): void;
/**
* 设置视口尺寸
* Set viewport size
*/
setViewportSize(width: number, height: number): void;
/**
* 获取主相机实体
* Get main camera entity
*/
getMainCamera(): Entity | null;
/**
* 获取主相机组件
* Get main camera component
*/
getMainCameraComponent(): CameraComponent | null;
/**
* 屏幕坐标转世界坐标
* Convert screen coordinates to world coordinates
*
* @param screenX 屏幕 X 坐标 | Screen X coordinate
* @param screenY 屏幕 Y 坐标 | Screen Y coordinate
* @returns 世界坐标 | World coordinates
*/
screenToWorld(screenX: number, screenY: number): IVector2;
/**
* 世界坐标转屏幕坐标
* Convert world coordinates to screen coordinates
*
* @param worldX 世界 X 坐标 | World X coordinate
* @param worldY 世界 Y 坐标 | World Y coordinate
* @returns 屏幕坐标 | Screen coordinates
*/
worldToScreen(worldX: number, worldY: number): IVector2;
}
/**
* 相机管理器实现
* Camera manager implementation
*
* @example
* ```typescript
* // 获取全局实例
* import { CameraManager } from '@esengine/camera';
*
* // 设置场景和视口
* CameraManager.setScene(scene);
* CameraManager.setViewportSize(800, 600);
*
* // 屏幕坐标转世界坐标
* const worldPos = CameraManager.screenToWorld(mouseX, mouseY);
* console.log(`World position: ${worldPos.x}, ${worldPos.y}`);
* ```
*/
export class CameraManagerImpl implements ICameraManager {
private _scene: IScene | null = null;
private _viewportWidth: number = 800;
private _viewportHeight: number = 600;
private _mainCameraEntity: Entity | null = null;
private _mainCameraEntityDirty: boolean = true;
/**
* 设置场景引用
* Set scene reference
*/
setScene(scene: IScene | null): void {
this._scene = scene;
this._mainCameraEntityDirty = true;
this._mainCameraEntity = null;
}
/**
* 设置视口尺寸
* Set viewport size
*/
setViewportSize(width: number, height: number): void {
this._viewportWidth = Math.max(1, width);
this._viewportHeight = Math.max(1, height);
}
/**
* 获取视口宽度
* Get viewport width
*/
get viewportWidth(): number {
return this._viewportWidth;
}
/**
* 获取视口高度
* Get viewport height
*/
get viewportHeight(): number {
return this._viewportHeight;
}
/**
* 获取视口宽高比
* Get viewport aspect ratio
*/
get aspectRatio(): number {
return this._viewportWidth / this._viewportHeight;
}
/**
* 标记主相机需要重新查找
* Mark main camera as dirty (needs re-lookup)
*/
invalidateMainCamera(): void {
this._mainCameraEntityDirty = true;
}
/**
* 获取主相机实体
* Get main camera entity
*/
getMainCamera(): Entity | null {
if (this._mainCameraEntityDirty || !this._mainCameraEntity) {
this._mainCameraEntity = this._findMainCamera();
this._mainCameraEntityDirty = false;
}
return this._mainCameraEntity;
}
/**
* 获取主相机组件
* Get main camera component
*/
getMainCameraComponent(): CameraComponent | null {
const entity = this.getMainCamera();
return entity?.getComponent(CameraComponent) ?? null;
}
/**
* 查找主相机depth 最小的相机)
* Find main camera (camera with lowest depth)
*/
private _findMainCamera(): Entity | null {
if (!this._scene) return null;
let mainCamera: Entity | null = null;
let lowestDepth = Infinity;
// 使用 entities.buffer 遍历实体列表
// Use entities.buffer to iterate entity list
const entities = this._scene.entities.buffer;
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity.enabled) continue;
const camera = entity.getComponent(CameraComponent);
if (camera && camera.depth < lowestDepth) {
lowestDepth = camera.depth;
mainCamera = entity;
}
}
return mainCamera;
}
/**
* 屏幕坐标转世界坐标
* Convert screen coordinates to world coordinates
*
* 对于正交相机:
* - 屏幕坐标 (0, 0) 在左上角
* - orthographicSize 是可见区域的半高度
*
* For orthographic camera:
* - Screen coordinates (0, 0) at top-left
* - orthographicSize is half-height of visible area
*/
screenToWorld(screenX: number, screenY: number): IVector2 {
const camera = this.getMainCameraComponent();
const cameraEntity = this.getMainCamera();
if (!camera || !cameraEntity) {
// 没有相机时,返回简单的偏移 | No camera, return simple offset
return {
x: screenX - this._viewportWidth / 2,
y: screenY - this._viewportHeight / 2
};
}
// 获取相机位置 | Get camera position
const transform = cameraEntity.getComponent(TransformComponent);
const cameraX = transform?.worldPosition.x ?? 0;
const cameraY = transform?.worldPosition.y ?? 0;
if (camera.projection === ECameraProjection.Orthographic) {
return this._screenToWorldOrthographic(screenX, screenY, camera, cameraX, cameraY);
} else {
// 透视相机暂不支持,返回正交结果
// Perspective camera not supported yet, return orthographic result
return this._screenToWorldOrthographic(screenX, screenY, camera, cameraX, cameraY);
}
}
/**
* 正交相机的屏幕到世界转换
* Screen to world conversion for orthographic camera
*/
private _screenToWorldOrthographic(
screenX: number,
screenY: number,
camera: CameraComponent,
cameraX: number,
cameraY: number
): IVector2 {
const orthoSize = camera.orthographicSize;
const aspect = this.aspectRatio;
// 归一化设备坐标 (NDC) [-1, 1]
// Normalized Device Coordinates (NDC) [-1, 1]
const ndcX = (screenX / this._viewportWidth) * 2 - 1;
const ndcY = 1 - (screenY / this._viewportHeight) * 2; // Y 轴翻转 | Flip Y axis
// 世界坐标 | World coordinates
const worldX = cameraX + ndcX * orthoSize * aspect;
const worldY = cameraY + ndcY * orthoSize;
return { x: worldX, y: worldY };
}
/**
* 世界坐标转屏幕坐标
* Convert world coordinates to screen coordinates
*/
worldToScreen(worldX: number, worldY: number): IVector2 {
const camera = this.getMainCameraComponent();
const cameraEntity = this.getMainCamera();
if (!camera || !cameraEntity) {
// 没有相机时,返回简单的偏移 | No camera, return simple offset
return {
x: worldX + this._viewportWidth / 2,
y: worldY + this._viewportHeight / 2
};
}
// 获取相机位置 | Get camera position
const transform = cameraEntity.getComponent(TransformComponent);
const cameraX = transform?.worldPosition.x ?? 0;
const cameraY = transform?.worldPosition.y ?? 0;
if (camera.projection === ECameraProjection.Orthographic) {
return this._worldToScreenOrthographic(worldX, worldY, camera, cameraX, cameraY);
} else {
// 透视相机暂不支持 | Perspective camera not supported yet
return this._worldToScreenOrthographic(worldX, worldY, camera, cameraX, cameraY);
}
}
/**
* 正交相机的世界到屏幕转换
* World to screen conversion for orthographic camera
*/
private _worldToScreenOrthographic(
worldX: number,
worldY: number,
camera: CameraComponent,
cameraX: number,
cameraY: number
): IVector2 {
const orthoSize = camera.orthographicSize;
const aspect = this.aspectRatio;
// 相对于相机的偏移 | Offset relative to camera
const offsetX = worldX - cameraX;
const offsetY = worldY - cameraY;
// NDC 坐标 | NDC coordinates
const ndcX = offsetX / (orthoSize * aspect);
const ndcY = offsetY / orthoSize;
// 屏幕坐标 | Screen coordinates
const screenX = (ndcX + 1) * 0.5 * this._viewportWidth;
const screenY = (1 - ndcY) * 0.5 * this._viewportHeight; // Y 轴翻转 | Flip Y axis
return { x: screenX, y: screenY };
}
}
/**
* 全局相机管理器实例
* Global camera manager instance
*/
export const CameraManager = new CameraManagerImpl();

View File

@@ -0,0 +1,43 @@
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { RenderConfigServiceToken } from '@esengine/engine-core';
import { CameraComponent } from './CameraComponent';
import { CameraSystem } from './CameraSystem';
class CameraRuntimeModule implements IRuntimeModule {
registerComponents(registry: IComponentRegistry): void {
registry.register(CameraComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
// 从服务注册表获取渲染配置服务 | Get render config service from registry
const renderConfig = context.services.get(RenderConfigServiceToken);
if (!renderConfig) {
console.warn('[CameraPlugin] RenderConfigService not found, CameraSystem will not be created');
return;
}
// 创建并添加 CameraSystem | Create and add CameraSystem
const cameraSystem = new CameraSystem(renderConfig);
scene.addSystem(cameraSystem);
}
}
const manifest: ModuleManifest = {
id: 'camera',
name: '@esengine/camera',
displayName: 'Camera',
version: '1.0.0',
description: '2D/3D 相机组件',
category: 'Rendering',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
dependencies: ['core', 'math'],
exports: { components: ['CameraComponent'] }
};
export const CameraPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new CameraRuntimeModule()
};

View File

@@ -0,0 +1,52 @@
/**
* Camera System
* 相机系统
*/
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
import type { IRenderConfigService } from '@esengine/engine-core';
import { CameraComponent } from './CameraComponent';
@ECSSystem('Camera', { updateOrder: -100 })
export class CameraSystem extends EntitySystem {
private renderConfig: IRenderConfigService;
private lastAppliedCameraId: number | null = null;
constructor(renderConfig: IRenderConfigService) {
// Match entities with CameraComponent
super(Matcher.empty().all(CameraComponent));
this.renderConfig = renderConfig;
}
protected override onBegin(): void {
// Will process cameras in process()
}
protected override process(entities: readonly Entity[]): void {
// Use first enabled camera
for (const entity of entities) {
if (!entity.enabled) continue;
const camera = entity.getComponent(CameraComponent);
if (!camera) continue;
// Only apply if camera changed
if (this.lastAppliedCameraId !== entity.id) {
this.applyCamera(camera);
this.lastAppliedCameraId = entity.id;
}
// Only use first active camera
break;
}
}
private applyCamera(camera: CameraComponent): void {
// Apply background color
const bgColor = camera.backgroundColor || '#000000';
const r = parseInt(bgColor.slice(1, 3), 16) / 255;
const g = parseInt(bgColor.slice(3, 5), 16) / 255;
const b = parseInt(bgColor.slice(5, 7), 16) / 255;
this.renderConfig.setClearColor(r, g, b, 1.0);
}
}

View File

@@ -0,0 +1,8 @@
export { CameraComponent, ECameraProjection, CameraProjection } from './CameraComponent';
export { CameraSystem } from './CameraSystem';
export { CameraPlugin } from './CameraPlugin';
export { CameraManager, CameraManagerImpl, type ICameraManager } from './CameraManager';
// Service Tokens
// 服务令牌
export { CameraManagerToken } from './tokens';

View File

@@ -0,0 +1,20 @@
/**
* Camera Module Service Tokens
* 相机模块服务令牌
*
* 遵循"谁定义接口,谁导出 Token"原则。
* Following "who defines interface, who exports Token" principle.
*/
import { createServiceToken } from '@esengine/ecs-framework';
import type { ICameraManager } from './CameraManager';
// Re-export interface for consumers
// 重新导出接口供消费者使用
export type { ICameraManager };
/**
* 相机管理器服务令牌
* Camera manager service token
*/
export const CameraManagerToken = createServiceToken<ICameraManager>('cameraManager');

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "../../framework/core"
}
]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...runtimeOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});

View File

@@ -0,0 +1,24 @@
{
"id": "effect",
"name": "@esengine/effect",
"globalKey": "effect",
"displayName": "Effect System",
"description": "效果系统,支持持续时间、叠加规则和属性修改器 | Effect system with duration, stacking rules, and attribute modifiers",
"version": "1.0.0",
"category": "Gameplay",
"icon": "Sparkles",
"tags": ["effect", "buff", "debuff", "modifier", "status"],
"isCore": false,
"defaultEnabled": true,
"isEngineModule": true,
"canContainContent": false,
"platforms": ["web", "desktop"],
"dependencies": ["core"],
"exports": {
"components": ["EffectContainer"],
"systems": ["EffectSystem"]
},
"requiresWasm": false,
"outputPath": "dist/index.js",
"pluginExport": "EffectPlugin"
}

View File

@@ -0,0 +1,40 @@
{
"name": "@esengine/effect",
"version": "1.0.0",
"description": "Effect system for ECS Framework / ECS 框架的效果系统",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"module.json"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"tslib": "^2.8.1"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/blueprint": "workspace:*",
"@esengine/build-config": "workspace:*",
"@types/node": "^20.19.17",
"rimraf": "^5.0.0",
"tsup": "^8.0.0",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,443 @@
/**
* @zh 效果容器
* @en Effect Container
*
* @zh 管理单个实体上的所有效果
* @en Manages all effects on a single entity
*/
import type {
IEffectInstance,
IEffectDefinition,
IEffectEvent,
EffectEventListener,
EffectEventType,
IEffectHandler
} from './IEffect';
let instanceCounter = 0;
function generateInstanceId(): string {
return `effect_${Date.now()}_${++instanceCounter}`;
}
/**
* @zh 效果容器
* @en Effect container
*/
export class EffectContainer<TTarget = unknown> {
private readonly _effects: Map<string, IEffectInstance> = new Map();
private readonly _effectsByType: Map<string, Set<string>> = new Map();
private readonly _effectsByTag: Map<string, Set<string>> = new Map();
private readonly _handlers: Map<string, IEffectHandler<TTarget>> = new Map();
private readonly _listeners: Map<EffectEventType, Set<EffectEventListener>> = new Map();
private readonly _target: TTarget;
private readonly _targetId: string;
/**
* @zh 创建效果容器
* @en Create effect container
*
* @param target - @zh 目标对象 @en Target object
* @param targetId - @zh 目标 ID @en Target ID
*/
constructor(target: TTarget, targetId: string) {
this._target = target;
this._targetId = targetId;
}
/**
* @zh 获取目标对象
* @en Get target object
*/
get target(): TTarget {
return this._target;
}
/**
* @zh 获取目标 ID
* @en Get target ID
*/
get targetId(): string {
return this._targetId;
}
/**
* @zh 获取效果数量
* @en Get effect count
*/
get count(): number {
return this._effects.size;
}
/**
* @zh 注册效果处理器
* @en Register effect handler
*/
registerHandler(typeId: string, handler: IEffectHandler<TTarget>): void {
this._handlers.set(typeId, handler);
}
/**
* @zh 注销效果处理器
* @en Unregister effect handler
*/
unregisterHandler(typeId: string): void {
this._handlers.delete(typeId);
}
/**
* @zh 添加事件监听器
* @en Add event listener
*/
addEventListener(type: EffectEventType, listener: EffectEventListener): void {
if (!this._listeners.has(type)) {
this._listeners.set(type, new Set());
}
this._listeners.get(type)!.add(listener);
}
/**
* @zh 移除事件监听器
* @en Remove event listener
*/
removeEventListener(type: EffectEventType, listener: EffectEventListener): void {
this._listeners.get(type)?.delete(listener);
}
private _emitEvent(type: EffectEventType, effect: IEffectInstance, data?: Record<string, unknown>): void {
const event: IEffectEvent = {
type,
effect,
targetId: this._targetId,
timestamp: Date.now(),
data
};
this._listeners.get(type)?.forEach(listener => listener(event));
}
/**
* @zh 应用效果
* @en Apply effect
*
* @param definition - @zh 效果定义 @en Effect definition
* @param sourceId - @zh 来源 ID @en Source ID
* @param initialData - @zh 初始数据 @en Initial data
* @returns @zh 效果实例或 null @en Effect instance or null
*/
apply(
definition: IEffectDefinition,
sourceId?: string,
initialData?: Record<string, unknown>
): IEffectInstance | null {
// Handle exclusive tags - remove conflicting effects
if (definition.exclusiveTags) {
for (const tag of definition.exclusiveTags) {
this.removeByTag(tag);
}
}
// Check for existing effect of same type
const existingIds = this._effectsByType.get(definition.typeId);
if (existingIds && existingIds.size > 0) {
const existingId = existingIds.values().next().value as string;
const existing = this._effects.get(existingId);
if (existing) {
return this._handleStacking(existing, definition);
}
}
// Create new effect instance
const instance = this._createInstance(definition, sourceId, initialData);
// Add to collections
this._effects.set(instance.instanceId, instance);
if (!this._effectsByType.has(definition.typeId)) {
this._effectsByType.set(definition.typeId, new Set());
}
this._effectsByType.get(definition.typeId)!.add(instance.instanceId);
for (const tag of definition.tags) {
if (!this._effectsByTag.has(tag)) {
this._effectsByTag.set(tag, new Set());
}
this._effectsByTag.get(tag)!.add(instance.instanceId);
}
// Call handler
const handler = this._handlers.get(definition.typeId);
handler?.onApply?.(instance, this._target);
// Emit event
this._emitEvent('applied', instance);
return instance;
}
private _createInstance(
definition: IEffectDefinition,
sourceId?: string,
initialData?: Record<string, unknown>
): IEffectInstance {
const duration = definition.duration;
let remainingTime = Infinity;
if (duration.type === 'timed' && duration.duration !== undefined) {
remainingTime = duration.duration;
}
return {
instanceId: generateInstanceId(),
definition,
sourceId,
stacks: 1,
remainingTime,
nextTickTime: definition.tickInterval ?? 0,
data: { ...initialData },
isActive: true,
appliedAt: Date.now()
};
}
private _handleStacking(existing: IEffectInstance, definition: IEffectDefinition): IEffectInstance | null {
const rule = definition.stacking.rule;
switch (rule) {
case 'refresh':
// Reset duration
if (definition.duration.type === 'timed' && definition.duration.duration !== undefined) {
existing.remainingTime = definition.duration.duration;
}
this._handlers.get(definition.typeId)?.onRefresh?.(existing, this._target);
this._emitEvent('refreshed', existing);
return existing;
case 'stack':
// Add stacks
const maxStacks = definition.stacking.maxStacks ?? Infinity;
if (existing.stacks < maxStacks) {
existing.stacks++;
this._handlers.get(definition.typeId)?.onStack?.(existing, this._target, existing.stacks);
this._emitEvent('stacked', existing, { stacks: existing.stacks });
}
// Also refresh duration
if (definition.duration.type === 'timed' && definition.duration.duration !== undefined) {
existing.remainingTime = definition.duration.duration;
}
return existing;
case 'replace':
// Remove existing and apply new
this.remove(existing.instanceId);
return null; // Will be created as new
case 'ignore':
// Do nothing
return existing;
case 'independent':
default:
// Allow multiple instances - will create new one
return null;
}
}
/**
* @zh 移除效果
* @en Remove effect
*
* @param instanceId - @zh 实例 ID @en Instance ID
*/
remove(instanceId: string): boolean {
const effect = this._effects.get(instanceId);
if (!effect) return false;
effect.isActive = false;
// Call handler
const handler = this._handlers.get(effect.definition.typeId);
handler?.onRemove?.(effect, this._target);
// Remove from collections
this._effects.delete(instanceId);
this._effectsByType.get(effect.definition.typeId)?.delete(instanceId);
for (const tag of effect.definition.tags) {
this._effectsByTag.get(tag)?.delete(instanceId);
}
// Emit event
this._emitEvent('removed', effect);
return true;
}
/**
* @zh 按类型移除效果
* @en Remove effects by type
*/
removeByType(typeId: string): number {
const ids = this._effectsByType.get(typeId);
if (!ids) return 0;
let count = 0;
for (const id of [...ids]) {
if (this.remove(id)) count++;
}
return count;
}
/**
* @zh 按标签移除效果
* @en Remove effects by tag
*/
removeByTag(tag: string): number {
const ids = this._effectsByTag.get(tag);
if (!ids) return 0;
let count = 0;
for (const id of [...ids]) {
if (this.remove(id)) count++;
}
return count;
}
/**
* @zh 移除所有效果
* @en Remove all effects
*/
removeAll(): void {
for (const id of [...this._effects.keys()]) {
this.remove(id);
}
}
/**
* @zh 获取效果实例
* @en Get effect instance
*/
get(instanceId: string): IEffectInstance | undefined {
return this._effects.get(instanceId);
}
/**
* @zh 按类型获取效果
* @en Get effects by type
*/
getByType(typeId: string): IEffectInstance[] {
const ids = this._effectsByType.get(typeId);
if (!ids) return [];
return [...ids].map(id => this._effects.get(id)!).filter(Boolean);
}
/**
* @zh 按标签获取效果
* @en Get effects by tag
*/
getByTag(tag: string): IEffectInstance[] {
const ids = this._effectsByTag.get(tag);
if (!ids) return [];
return [...ids].map(id => this._effects.get(id)!).filter(Boolean);
}
/**
* @zh 检查是否有指定类型的效果
* @en Check if has effect of specified type
*/
hasType(typeId: string): boolean {
const ids = this._effectsByType.get(typeId);
return ids !== undefined && ids.size > 0;
}
/**
* @zh 检查是否有指定标签的效果
* @en Check if has effect with specified tag
*/
hasTag(tag: string): boolean {
const ids = this._effectsByTag.get(tag);
return ids !== undefined && ids.size > 0;
}
/**
* @zh 获取指定类型的叠加层数
* @en Get stack count for specified type
*/
getStacks(typeId: string): number {
const effects = this.getByType(typeId);
return effects.reduce((sum, e) => sum + e.stacks, 0);
}
/**
* @zh 获取所有效果
* @en Get all effects
*/
getAll(): IEffectInstance[] {
return [...this._effects.values()];
}
/**
* @zh 更新效果(每帧调用)
* @en Update effects (called every frame)
*
* @param deltaTime - @zh 帧时间(秒)@en Delta time in seconds
*/
update(deltaTime: number): void {
const toRemove: string[] = [];
for (const effect of this._effects.values()) {
if (!effect.isActive) continue;
const definition = effect.definition;
const handler = this._handlers.get(definition.typeId);
// Update remaining time
if (definition.duration.type === 'timed') {
effect.remainingTime -= deltaTime;
if (effect.remainingTime <= 0) {
this._emitEvent('expired', effect);
toRemove.push(effect.instanceId);
continue;
}
}
// Check conditional duration
if (definition.duration.type === 'conditional') {
const condition = definition.duration.condition;
if (condition && !condition()) {
this._emitEvent('expired', effect);
toRemove.push(effect.instanceId);
continue;
}
}
// Handle periodic tick
if (definition.tickInterval && definition.tickInterval > 0) {
effect.nextTickTime -= deltaTime;
if (effect.nextTickTime <= 0) {
handler?.onTick?.(effect, this._target, deltaTime);
this._emitEvent('ticked', effect);
effect.nextTickTime = definition.tickInterval;
}
}
// Call update handler
handler?.onUpdate?.(effect, this._target, deltaTime);
}
// Remove expired effects
for (const id of toRemove) {
this.remove(id);
}
}
}
/**
* @zh 创建效果容器
* @en Create effect container
*/
export function createEffectContainer<TTarget>(target: TTarget, targetId: string): EffectContainer<TTarget> {
return new EffectContainer(target, targetId);
}

View File

@@ -0,0 +1,306 @@
/**
* @zh 效果接口定义
* @en Effect Interface Definitions
*/
// =============================================================================
// 持续时间类型 | Duration Types
// =============================================================================
/**
* @zh 持续时间类型
* @en Duration type
*/
export type DurationType = 'permanent' | 'timed' | 'conditional';
/**
* @zh 持续时间配置
* @en Duration configuration
*/
export interface IEffectDuration {
/**
* @zh 持续时间类型
* @en Duration type
*/
readonly type: DurationType;
/**
* @zh 持续时间(秒),仅 timed 类型有效
* @en Duration in seconds, only valid for timed type
*/
readonly duration?: number;
/**
* @zh 剩余时间(秒)
* @en Remaining time in seconds
*/
remainingTime?: number;
/**
* @zh 条件检查函数,仅 conditional 类型有效
* @en Condition check function, only valid for conditional type
*/
readonly condition?: () => boolean;
}
// =============================================================================
// 叠加规则 | Stacking Rules
// =============================================================================
/**
* @zh 叠加规则类型
* @en Stacking rule type
*/
export type StackingRule = 'refresh' | 'stack' | 'independent' | 'replace' | 'ignore';
/**
* @zh 叠加配置
* @en Stacking configuration
*/
export interface IStackingConfig {
/**
* @zh 叠加规则
* @en Stacking rule
*/
readonly rule: StackingRule;
/**
* @zh 最大叠加层数stack 规则)
* @en Maximum stack count (for stack rule)
*/
readonly maxStacks?: number;
/**
* @zh 每层效果强度倍率stack 规则)
* @en Effect intensity multiplier per stack (for stack rule)
*/
readonly stackMultiplier?: number;
}
// =============================================================================
// 效果接口 | Effect Interface
// =============================================================================
/**
* @zh 效果定义
* @en Effect definition
*/
export interface IEffectDefinition {
/**
* @zh 效果类型 ID
* @en Effect type ID
*/
readonly typeId: string;
/**
* @zh 显示名称
* @en Display name
*/
readonly displayName: string;
/**
* @zh 描述
* @en Description
*/
readonly description?: string;
/**
* @zh 图标
* @en Icon
*/
readonly icon?: string;
/**
* @zh 标签(用于分组、互斥、增强)
* @en Tags (for grouping, exclusion, enhancement)
*/
readonly tags: readonly string[];
/**
* @zh 持续时间配置
* @en Duration configuration
*/
readonly duration: IEffectDuration;
/**
* @zh 叠加配置
* @en Stacking configuration
*/
readonly stacking: IStackingConfig;
/**
* @zh 周期性触发间隔0 表示不周期触发
* @en Periodic trigger interval in seconds, 0 means no periodic trigger
*/
readonly tickInterval?: number;
/**
* @zh 互斥标签(拥有这些标签的效果会被移除)
* @en Exclusive tags (effects with these tags will be removed)
*/
readonly exclusiveTags?: readonly string[];
/**
* @zh 效果优先级(用于处理顺序)
* @en Effect priority (for processing order)
*/
readonly priority?: number;
}
/**
* @zh 效果实例
* @en Effect instance
*/
export interface IEffectInstance {
/**
* @zh 实例唯一 ID
* @en Instance unique ID
*/
readonly instanceId: string;
/**
* @zh 效果定义
* @en Effect definition
*/
readonly definition: IEffectDefinition;
/**
* @zh 效果来源(施加者 ID
* @en Effect source (applier ID)
*/
readonly sourceId?: string;
/**
* @zh 当前叠加层数
* @en Current stack count
*/
stacks: number;
/**
* @zh 剩余时间(秒)
* @en Remaining time in seconds
*/
remainingTime: number;
/**
* @zh 下次触发时间(秒)
* @en Next tick time in seconds
*/
nextTickTime: number;
/**
* @zh 效果数据
* @en Effect data
*/
data: Record<string, unknown>;
/**
* @zh 效果是否激活
* @en Whether the effect is active
*/
isActive: boolean;
/**
* @zh 应用时间戳
* @en Application timestamp
*/
readonly appliedAt: number;
}
// =============================================================================
// 效果事件 | Effect Events
// =============================================================================
/**
* @zh 效果事件类型
* @en Effect event type
*/
export type EffectEventType = 'applied' | 'removed' | 'stacked' | 'refreshed' | 'ticked' | 'expired';
/**
* @zh 效果事件
* @en Effect event
*/
export interface IEffectEvent {
/**
* @zh 事件类型
* @en Event type
*/
readonly type: EffectEventType;
/**
* @zh 效果实例
* @en Effect instance
*/
readonly effect: IEffectInstance;
/**
* @zh 目标实体 ID
* @en Target entity ID
*/
readonly targetId: string;
/**
* @zh 事件时间戳
* @en Event timestamp
*/
readonly timestamp: number;
/**
* @zh 额外数据
* @en Extra data
*/
readonly data?: Record<string, unknown>;
}
/**
* @zh 效果事件监听器
* @en Effect event listener
*/
export type EffectEventListener = (event: IEffectEvent) => void;
// =============================================================================
// 效果处理器 | Effect Handler
// =============================================================================
/**
* @zh 效果处理器接口
* @en Effect handler interface
*/
export interface IEffectHandler<TTarget = unknown> {
/**
* @zh 效果应用时调用
* @en Called when effect is applied
*/
onApply?(effect: IEffectInstance, target: TTarget): void;
/**
* @zh 效果移除时调用
* @en Called when effect is removed
*/
onRemove?(effect: IEffectInstance, target: TTarget): void;
/**
* @zh 效果叠加时调用
* @en Called when effect is stacked
*/
onStack?(effect: IEffectInstance, target: TTarget, newStacks: number): void;
/**
* @zh 效果刷新时调用
* @en Called when effect is refreshed
*/
onRefresh?(effect: IEffectInstance, target: TTarget): void;
/**
* @zh 效果周期触发时调用
* @en Called on periodic tick
*/
onTick?(effect: IEffectInstance, target: TTarget, deltaTime: number): void;
/**
* @zh 效果更新时调用(每帧)
* @en Called on update (every frame)
*/
onUpdate?(effect: IEffectInstance, target: TTarget, deltaTime: number): void;
}

View File

@@ -0,0 +1,19 @@
/**
* @zh 效果核心模块
* @en Effect Core Module
*/
export type {
DurationType,
IEffectDuration,
StackingRule,
IStackingConfig,
IEffectDefinition,
IEffectInstance,
EffectEventType,
IEffectEvent,
EffectEventListener,
IEffectHandler
} from './IEffect';
export { EffectContainer, createEffectContainer } from './EffectContainer';

View File

@@ -0,0 +1,80 @@
/**
* @esengine/effect
*
* @zh 效果系统
* @en Effect System
*
* @zh 提供 Buff/Debuff 效果管理和属性修改器
* @en Provides Buff/Debuff effect management and attribute modifiers
*/
// =============================================================================
// Core | 核心
// =============================================================================
export type {
DurationType,
IEffectDuration,
StackingRule,
IStackingConfig,
IEffectDefinition,
IEffectInstance,
EffectEventType,
IEffectEvent,
EffectEventListener,
IEffectHandler
} from './core';
export { EffectContainer, createEffectContainer } from './core';
// =============================================================================
// Modifiers | 修改器
// =============================================================================
export type {
ModifierOperation,
ModifierPriority,
IModifier,
IAttributeCalculator
} from './modifiers';
export {
NumericCalculator,
ModifierContainer,
createModifierContainer
} from './modifiers';
// =============================================================================
// Blueprint Nodes | 蓝图节点
// =============================================================================
export {
// Templates
ApplyEffectTemplate,
RemoveEffectTemplate,
RemoveEffectByTagTemplate,
HasEffectTemplate,
HasEffectTagTemplate,
GetEffectStacksTemplate,
GetEffectRemainingTimeTemplate,
GetEffectCountTemplate,
ClearAllEffectsTemplate,
OnEffectAppliedTemplate,
OnEffectRemovedTemplate,
OnEffectTickTemplate,
// Executors
ApplyEffectExecutor,
RemoveEffectExecutor,
RemoveEffectByTagExecutor,
HasEffectExecutor,
HasEffectTagExecutor,
GetEffectStacksExecutor,
GetEffectRemainingTimeExecutor,
GetEffectCountExecutor,
ClearAllEffectsExecutor,
OnEffectAppliedExecutor,
OnEffectRemovedExecutor,
OnEffectTickExecutor,
// Collection
EffectNodeDefinitions
} from './nodes';

View File

@@ -0,0 +1,84 @@
/**
* @zh 修改器接口定义
* @en Modifier Interface Definitions
*/
// =============================================================================
// 修改器类型 | Modifier Types
// =============================================================================
/**
* @zh 修改器操作类型
* @en Modifier operation type
*/
export type ModifierOperation = 'add' | 'multiply' | 'override' | 'min' | 'max';
/**
* @zh 修改器优先级
* @en Modifier priority
*/
export type ModifierPriority = 'base' | 'add' | 'multiply' | 'final';
/**
* @zh 修改器接口
* @en Modifier interface
*/
export interface IModifier<T = number> {
/**
* @zh 修改器 ID
* @en Modifier ID
*/
readonly id: string;
/**
* @zh 修改器来源(效果实例 ID
* @en Modifier source (effect instance ID)
*/
readonly sourceId: string;
/**
* @zh 修改的属性名
* @en Modified attribute name
*/
readonly attribute: string;
/**
* @zh 操作类型
* @en Operation type
*/
readonly operation: ModifierOperation;
/**
* @zh 优先级
* @en Priority
*/
readonly priority: ModifierPriority;
/**
* @zh 修改值
* @en Modifier value
*/
value: T;
/**
* @zh 是否激活
* @en Whether active
*/
isActive: boolean;
}
/**
* @zh 属性值计算器
* @en Attribute value calculator
*/
export interface IAttributeCalculator<T = number> {
/**
* @zh 计算最终属性值
* @en Calculate final attribute value
*
* @param baseValue - @zh 基础值 @en Base value
* @param modifiers - @zh 修改器列表 @en Modifier list
* @returns @zh 最终值 @en Final value
*/
calculate(baseValue: T, modifiers: IModifier<T>[]): T;
}

View File

@@ -0,0 +1,304 @@
/**
* @zh 修改器容器
* @en Modifier Container
*
* @zh 管理属性修改器并计算最终值
* @en Manages attribute modifiers and calculates final values
*/
import type { IModifier, ModifierOperation, ModifierPriority, IAttributeCalculator } from './IModifier';
let modifierCounter = 0;
function generateModifierId(): string {
return `mod_${Date.now()}_${++modifierCounter}`;
}
/**
* @zh 默认数值计算器
* @en Default numeric calculator
*/
export class NumericCalculator implements IAttributeCalculator<number> {
calculate(baseValue: number, modifiers: IModifier<number>[]): number {
if (modifiers.length === 0) return baseValue;
// Sort by priority
const sorted = [...modifiers].sort((a, b) => {
const priorityOrder: Record<ModifierPriority, number> = {
base: 0,
add: 1,
multiply: 2,
final: 3
};
return priorityOrder[a.priority] - priorityOrder[b.priority];
});
let value = baseValue;
let addSum = 0;
let multiplyProduct = 1;
for (const mod of sorted) {
if (!mod.isActive) continue;
switch (mod.operation) {
case 'add':
addSum += mod.value;
break;
case 'multiply':
multiplyProduct *= mod.value;
break;
case 'override':
value = mod.value;
addSum = 0;
multiplyProduct = 1;
break;
case 'min':
value = Math.min(value, mod.value);
break;
case 'max':
value = Math.max(value, mod.value);
break;
}
}
// Apply in order: base → add → multiply
return (value + addSum) * multiplyProduct;
}
}
/**
* @zh 修改器容器
* @en Modifier container
*/
export class ModifierContainer<T = number> {
private readonly _modifiers: Map<string, IModifier<T>> = new Map();
private readonly _modifiersByAttribute: Map<string, Set<string>> = new Map();
private readonly _modifiersBySource: Map<string, Set<string>> = new Map();
private readonly _calculator: IAttributeCalculator<T>;
private readonly _baseValues: Map<string, T> = new Map();
private readonly _cachedValues: Map<string, T> = new Map();
private _isDirty = false;
/**
* @zh 创建修改器容器
* @en Create modifier container
*
* @param calculator - @zh 属性计算器 @en Attribute calculator
*/
constructor(calculator?: IAttributeCalculator<T>) {
this._calculator = calculator ?? new NumericCalculator() as unknown as IAttributeCalculator<T>;
}
/**
* @zh 设置属性基础值
* @en Set attribute base value
*/
setBaseValue(attribute: string, value: T): void {
this._baseValues.set(attribute, value);
this._invalidateAttribute(attribute);
}
/**
* @zh 获取属性基础值
* @en Get attribute base value
*/
getBaseValue(attribute: string): T | undefined {
return this._baseValues.get(attribute);
}
/**
* @zh 添加修改器
* @en Add modifier
*/
addModifier(
attribute: string,
operation: ModifierOperation,
value: T,
sourceId: string,
priority: ModifierPriority = 'add'
): IModifier<T> {
const modifier: IModifier<T> = {
id: generateModifierId(),
sourceId,
attribute,
operation,
priority,
value,
isActive: true
};
this._modifiers.set(modifier.id, modifier);
// Index by attribute
if (!this._modifiersByAttribute.has(attribute)) {
this._modifiersByAttribute.set(attribute, new Set());
}
this._modifiersByAttribute.get(attribute)!.add(modifier.id);
// Index by source
if (!this._modifiersBySource.has(sourceId)) {
this._modifiersBySource.set(sourceId, new Set());
}
this._modifiersBySource.get(sourceId)!.add(modifier.id);
this._invalidateAttribute(attribute);
return modifier;
}
/**
* @zh 移除修改器
* @en Remove modifier
*/
removeModifier(modifierId: string): boolean {
const modifier = this._modifiers.get(modifierId);
if (!modifier) return false;
this._modifiers.delete(modifierId);
this._modifiersByAttribute.get(modifier.attribute)?.delete(modifierId);
this._modifiersBySource.get(modifier.sourceId)?.delete(modifierId);
this._invalidateAttribute(modifier.attribute);
return true;
}
/**
* @zh 按来源移除修改器
* @en Remove modifiers by source
*/
removeBySource(sourceId: string): number {
const ids = this._modifiersBySource.get(sourceId);
if (!ids) return 0;
let count = 0;
for (const id of [...ids]) {
if (this.removeModifier(id)) count++;
}
this._modifiersBySource.delete(sourceId);
return count;
}
/**
* @zh 按属性移除修改器
* @en Remove modifiers by attribute
*/
removeByAttribute(attribute: string): number {
const ids = this._modifiersByAttribute.get(attribute);
if (!ids) return 0;
let count = 0;
for (const id of [...ids]) {
if (this.removeModifier(id)) count++;
}
return count;
}
/**
* @zh 获取属性的所有修改器
* @en Get all modifiers for an attribute
*/
getModifiersForAttribute(attribute: string): IModifier<T>[] {
const ids = this._modifiersByAttribute.get(attribute);
if (!ids) return [];
return [...ids].map(id => this._modifiers.get(id)!).filter(Boolean);
}
/**
* @zh 获取来源的所有修改器
* @en Get all modifiers from a source
*/
getModifiersFromSource(sourceId: string): IModifier<T>[] {
const ids = this._modifiersBySource.get(sourceId);
if (!ids) return [];
return [...ids].map(id => this._modifiers.get(id)!).filter(Boolean);
}
/**
* @zh 计算属性最终值
* @en Calculate attribute final value
*/
getValue(attribute: string): T {
// Check cache
if (!this._isDirty && this._cachedValues.has(attribute)) {
return this._cachedValues.get(attribute)!;
}
const baseValue = this._baseValues.get(attribute);
if (baseValue === undefined) {
throw new Error(`No base value set for attribute: ${attribute}`);
}
const modifiers = this.getModifiersForAttribute(attribute);
const finalValue = this._calculator.calculate(baseValue, modifiers);
this._cachedValues.set(attribute, finalValue);
return finalValue;
}
/**
* @zh 尝试获取属性最终值
* @en Try to get attribute final value
*/
tryGetValue(attribute: string, defaultValue: T): T {
try {
return this.getValue(attribute);
} catch {
return defaultValue;
}
}
/**
* @zh 检查属性是否有修改器
* @en Check if attribute has modifiers
*/
hasModifiers(attribute: string): boolean {
const ids = this._modifiersByAttribute.get(attribute);
return ids !== undefined && ids.size > 0;
}
/**
* @zh 获取所有已修改的属性
* @en Get all modified attributes
*/
getModifiedAttributes(): string[] {
return [...this._modifiersByAttribute.keys()];
}
/**
* @zh 清除所有修改器
* @en Clear all modifiers
*/
clear(): void {
this._modifiers.clear();
this._modifiersByAttribute.clear();
this._modifiersBySource.clear();
this._cachedValues.clear();
this._isDirty = true;
}
private _invalidateAttribute(attribute: string): void {
this._cachedValues.delete(attribute);
this._isDirty = true;
}
/**
* @zh 标记缓存已更新
* @en Mark cache as updated
*/
markClean(): void {
this._isDirty = false;
}
}
/**
* @zh 创建修改器容器
* @en Create modifier container
*/
export function createModifierContainer<T = number>(
calculator?: IAttributeCalculator<T>
): ModifierContainer<T> {
return new ModifierContainer(calculator);
}

View File

@@ -0,0 +1,17 @@
/**
* @zh 修改器模块
* @en Modifier Module
*/
export type {
ModifierOperation,
ModifierPriority,
IModifier,
IAttributeCalculator
} from './IModifier';
export {
NumericCalculator,
ModifierContainer,
createModifierContainer
} from './ModifierContainer';

View File

@@ -0,0 +1,489 @@
/**
* @zh 效果系统蓝图节点
* @en Effect System Blueprint Nodes
*/
import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint';
import type { EffectContainer } from '../core/EffectContainer';
import type { IEffectDefinition, IEffectInstance, EffectEventType } from '../core/IEffect';
// =============================================================================
// 执行上下文接口 | Execution Context Interface
// =============================================================================
interface EffectContext {
entity: {
getComponent<T>(type: new (...args: unknown[]) => T): T | null;
};
getEffectContainer(): EffectContainer | null;
evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown;
setOutputs(nodeId: string, outputs: Record<string, unknown>): void;
triggerOutput(nodeId: string, pinName: string): void;
}
// =============================================================================
// ApplyEffect 节点 | ApplyEffect Node
// =============================================================================
export const ApplyEffectTemplate: BlueprintNodeTemplate = {
type: 'ApplyEffect',
title: 'Apply Effect',
category: 'custom',
description: 'Apply an effect to target / 对目标应用效果',
keywords: ['effect', 'buff', 'debuff', 'apply', 'status'],
menuPath: ['Effect', 'Apply Effect'],
inputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' },
{ name: 'duration', displayName: 'Duration', type: 'float' },
{ name: 'sourceId', displayName: 'Source ID', type: 'string' }
],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'success', displayName: 'Success', type: 'bool' },
{ name: 'instanceId', displayName: 'Instance ID', type: 'string' }
],
color: '#e91e63'
};
export class ApplyEffectExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const effectTypeId = ctx.evaluateInput(node.id, 'effectTypeId', '') as string;
const duration = ctx.evaluateInput(node.id, 'duration', 0) as number;
const sourceId = ctx.evaluateInput(node.id, 'sourceId', '') as string;
const container = ctx.getEffectContainer();
if (!container || !effectTypeId) {
return {
outputs: { success: false, instanceId: '' },
nextExec: 'exec'
};
}
// Create a basic effect definition
const definition: IEffectDefinition = {
typeId: effectTypeId,
displayName: effectTypeId,
tags: [],
duration: duration > 0
? { type: 'timed', duration, remainingTime: duration }
: { type: 'permanent' },
stacking: { rule: 'refresh' }
};
const instance = container.apply(definition, sourceId || undefined);
return {
outputs: {
success: instance !== null,
instanceId: instance?.instanceId ?? ''
},
nextExec: 'exec'
};
}
}
// =============================================================================
// RemoveEffect 节点 | RemoveEffect Node
// =============================================================================
export const RemoveEffectTemplate: BlueprintNodeTemplate = {
type: 'RemoveEffect',
title: 'Remove Effect',
category: 'custom',
description: 'Remove effect from target / 从目标移除效果',
keywords: ['effect', 'remove', 'clear', 'dispel'],
menuPath: ['Effect', 'Remove Effect'],
inputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' }
],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'removed', displayName: 'Removed', type: 'int' }
],
color: '#e91e63'
};
export class RemoveEffectExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const effectTypeId = ctx.evaluateInput(node.id, 'effectTypeId', '') as string;
const container = ctx.getEffectContainer();
if (!container || !effectTypeId) {
return { outputs: { removed: 0 }, nextExec: 'exec' };
}
const removed = container.removeByType(effectTypeId);
return { outputs: { removed }, nextExec: 'exec' };
}
}
// =============================================================================
// RemoveEffectByTag 节点 | RemoveEffectByTag Node
// =============================================================================
export const RemoveEffectByTagTemplate: BlueprintNodeTemplate = {
type: 'RemoveEffectByTag',
title: 'Remove Effect By Tag',
category: 'custom',
description: 'Remove effects with specific tag / 移除带有指定标签的效果',
keywords: ['effect', 'remove', 'tag', 'dispel'],
menuPath: ['Effect', 'Remove Effect By Tag'],
inputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'tag', displayName: 'Tag', type: 'string' }
],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'removed', displayName: 'Removed', type: 'int' }
],
color: '#e91e63'
};
export class RemoveEffectByTagExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const tag = ctx.evaluateInput(node.id, 'tag', '') as string;
const container = ctx.getEffectContainer();
if (!container || !tag) {
return { outputs: { removed: 0 }, nextExec: 'exec' };
}
const removed = container.removeByTag(tag);
return { outputs: { removed }, nextExec: 'exec' };
}
}
// =============================================================================
// HasEffect 节点 | HasEffect Node
// =============================================================================
export const HasEffectTemplate: BlueprintNodeTemplate = {
type: 'HasEffect',
title: 'Has Effect',
category: 'custom',
description: 'Check if target has effect / 检查目标是否有效果',
keywords: ['effect', 'check', 'has', 'status'],
menuPath: ['Effect', 'Has Effect'],
isPure: true,
inputs: [
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' }
],
outputs: [
{ name: 'hasEffect', displayName: 'Has Effect', type: 'bool' }
],
color: '#e91e63'
};
export class HasEffectExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const effectTypeId = ctx.evaluateInput(node.id, 'effectTypeId', '') as string;
const container = ctx.getEffectContainer();
if (!container || !effectTypeId) {
return { outputs: { hasEffect: false } };
}
const hasEffect = container.hasType(effectTypeId);
return { outputs: { hasEffect } };
}
}
// =============================================================================
// HasEffectTag 节点 | HasEffectTag Node
// =============================================================================
export const HasEffectTagTemplate: BlueprintNodeTemplate = {
type: 'HasEffectTag',
title: 'Has Effect Tag',
category: 'custom',
description: 'Check if target has effect with tag / 检查目标是否有带标签的效果',
keywords: ['effect', 'check', 'tag', 'status'],
menuPath: ['Effect', 'Has Effect Tag'],
isPure: true,
inputs: [
{ name: 'tag', displayName: 'Tag', type: 'string' }
],
outputs: [
{ name: 'hasTag', displayName: 'Has Tag', type: 'bool' }
],
color: '#e91e63'
};
export class HasEffectTagExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const tag = ctx.evaluateInput(node.id, 'tag', '') as string;
const container = ctx.getEffectContainer();
if (!container || !tag) {
return { outputs: { hasTag: false } };
}
const hasTag = container.hasTag(tag);
return { outputs: { hasTag } };
}
}
// =============================================================================
// GetEffectStacks 节点 | GetEffectStacks Node
// =============================================================================
export const GetEffectStacksTemplate: BlueprintNodeTemplate = {
type: 'GetEffectStacks',
title: 'Get Effect Stacks',
category: 'custom',
description: 'Get effect stack count / 获取效果叠加层数',
keywords: ['effect', 'stacks', 'count', 'layers'],
menuPath: ['Effect', 'Get Effect Stacks'],
isPure: true,
inputs: [
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' }
],
outputs: [
{ name: 'stacks', displayName: 'Stacks', type: 'int' }
],
color: '#e91e63'
};
export class GetEffectStacksExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const effectTypeId = ctx.evaluateInput(node.id, 'effectTypeId', '') as string;
const container = ctx.getEffectContainer();
if (!container || !effectTypeId) {
return { outputs: { stacks: 0 } };
}
const stacks = container.getStacks(effectTypeId);
return { outputs: { stacks } };
}
}
// =============================================================================
// GetEffectRemainingTime 节点 | GetEffectRemainingTime Node
// =============================================================================
export const GetEffectRemainingTimeTemplate: BlueprintNodeTemplate = {
type: 'GetEffectRemainingTime',
title: 'Get Effect Remaining Time',
category: 'custom',
description: 'Get remaining time of effect / 获取效果剩余时间',
keywords: ['effect', 'time', 'remaining', 'duration'],
menuPath: ['Effect', 'Get Effect Remaining Time'],
isPure: true,
inputs: [
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' }
],
outputs: [
{ name: 'remainingTime', displayName: 'Remaining Time', type: 'float' },
{ name: 'hasEffect', displayName: 'Has Effect', type: 'bool' }
],
color: '#e91e63'
};
export class GetEffectRemainingTimeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const effectTypeId = ctx.evaluateInput(node.id, 'effectTypeId', '') as string;
const container = ctx.getEffectContainer();
if (!container || !effectTypeId) {
return { outputs: { remainingTime: 0, hasEffect: false } };
}
const effects = container.getByType(effectTypeId);
if (effects.length === 0) {
return { outputs: { remainingTime: 0, hasEffect: false } };
}
// Return max remaining time among all instances
const remainingTime = Math.max(...effects.map(e => e.remainingTime));
return { outputs: { remainingTime, hasEffect: true } };
}
}
// =============================================================================
// GetEffectCount 节点 | GetEffectCount Node
// =============================================================================
export const GetEffectCountTemplate: BlueprintNodeTemplate = {
type: 'GetEffectCount',
title: 'Get Effect Count',
category: 'custom',
description: 'Get total effect count / 获取效果总数',
keywords: ['effect', 'count', 'total'],
menuPath: ['Effect', 'Get Effect Count'],
isPure: true,
inputs: [],
outputs: [
{ name: 'count', displayName: 'Count', type: 'int' }
],
color: '#e91e63'
};
export class GetEffectCountExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const container = ctx.getEffectContainer();
return { outputs: { count: container?.count ?? 0 } };
}
}
// =============================================================================
// ClearAllEffects 节点 | ClearAllEffects Node
// =============================================================================
export const ClearAllEffectsTemplate: BlueprintNodeTemplate = {
type: 'ClearAllEffects',
title: 'Clear All Effects',
category: 'custom',
description: 'Remove all effects from target / 移除目标所有效果',
keywords: ['effect', 'clear', 'remove', 'all'],
menuPath: ['Effect', 'Clear All Effects'],
inputs: [
{ name: 'exec', displayName: '', type: 'exec' }
],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' }
],
color: '#e91e63'
};
export class ClearAllEffectsExecutor implements INodeExecutor {
execute(_node: BlueprintNode, context: unknown): ExecutionResult {
const ctx = context as EffectContext;
const container = ctx.getEffectContainer();
container?.removeAll();
return { nextExec: 'exec' };
}
}
// =============================================================================
// OnEffectApplied 事件节点 | OnEffectApplied Event Node
// =============================================================================
export const OnEffectAppliedTemplate: BlueprintNodeTemplate = {
type: 'OnEffectApplied',
title: 'On Effect Applied',
category: 'event',
description: 'Triggered when effect is applied / 效果应用时触发',
keywords: ['effect', 'event', 'applied', 'add'],
menuPath: ['Effect', 'Events', 'On Effect Applied'],
inputs: [],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' },
{ name: 'instanceId', displayName: 'Instance ID', type: 'string' },
{ name: 'stacks', displayName: 'Stacks', type: 'int' }
],
color: '#ff5722'
};
export class OnEffectAppliedExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
return { nextExec: 'exec' };
}
}
// =============================================================================
// OnEffectRemoved 事件节点 | OnEffectRemoved Event Node
// =============================================================================
export const OnEffectRemovedTemplate: BlueprintNodeTemplate = {
type: 'OnEffectRemoved',
title: 'On Effect Removed',
category: 'event',
description: 'Triggered when effect is removed / 效果移除时触发',
keywords: ['effect', 'event', 'removed', 'expire'],
menuPath: ['Effect', 'Events', 'On Effect Removed'],
inputs: [],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' },
{ name: 'instanceId', displayName: 'Instance ID', type: 'string' }
],
color: '#ff5722'
};
export class OnEffectRemovedExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
return { nextExec: 'exec' };
}
}
// =============================================================================
// OnEffectTick 事件节点 | OnEffectTick Event Node
// =============================================================================
export const OnEffectTickTemplate: BlueprintNodeTemplate = {
type: 'OnEffectTick',
title: 'On Effect Tick',
category: 'event',
description: 'Triggered on effect periodic tick / 效果周期触发时调用',
keywords: ['effect', 'event', 'tick', 'periodic'],
menuPath: ['Effect', 'Events', 'On Effect Tick'],
inputs: [],
outputs: [
{ name: 'exec', displayName: '', type: 'exec' },
{ name: 'effectTypeId', displayName: 'Effect Type', type: 'string' },
{ name: 'instanceId', displayName: 'Instance ID', type: 'string' },
{ name: 'stacks', displayName: 'Stacks', type: 'int' }
],
color: '#ff5722'
};
export class OnEffectTickExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: unknown): ExecutionResult {
return { nextExec: 'exec' };
}
}
// =============================================================================
// 节点定义集合 | Node Definition Collection
// =============================================================================
export const EffectNodeDefinitions = {
templates: [
ApplyEffectTemplate,
RemoveEffectTemplate,
RemoveEffectByTagTemplate,
HasEffectTemplate,
HasEffectTagTemplate,
GetEffectStacksTemplate,
GetEffectRemainingTimeTemplate,
GetEffectCountTemplate,
ClearAllEffectsTemplate,
OnEffectAppliedTemplate,
OnEffectRemovedTemplate,
OnEffectTickTemplate
],
executors: new Map<string, INodeExecutor>([
['ApplyEffect', new ApplyEffectExecutor()],
['RemoveEffect', new RemoveEffectExecutor()],
['RemoveEffectByTag', new RemoveEffectByTagExecutor()],
['HasEffect', new HasEffectExecutor()],
['HasEffectTag', new HasEffectTagExecutor()],
['GetEffectStacks', new GetEffectStacksExecutor()],
['GetEffectRemainingTime', new GetEffectRemainingTimeExecutor()],
['GetEffectCount', new GetEffectCountExecutor()],
['ClearAllEffects', new ClearAllEffectsExecutor()],
['OnEffectApplied', new OnEffectAppliedExecutor()],
['OnEffectRemoved', new OnEffectRemovedExecutor()],
['OnEffectTick', new OnEffectTickExecutor()]
])
};

View File

@@ -0,0 +1,35 @@
/**
* @zh 效果蓝图节点模块
* @en Effect Blueprint Nodes Module
*/
export {
// Templates
ApplyEffectTemplate,
RemoveEffectTemplate,
RemoveEffectByTagTemplate,
HasEffectTemplate,
HasEffectTagTemplate,
GetEffectStacksTemplate,
GetEffectRemainingTimeTemplate,
GetEffectCountTemplate,
ClearAllEffectsTemplate,
OnEffectAppliedTemplate,
OnEffectRemovedTemplate,
OnEffectTickTemplate,
// Executors
ApplyEffectExecutor,
RemoveEffectExecutor,
RemoveEffectByTagExecutor,
HasEffectExecutor,
HasEffectTagExecutor,
GetEffectStacksExecutor,
GetEffectRemainingTimeExecutor,
GetEffectCountExecutor,
ClearAllEffectsExecutor,
OnEffectAppliedExecutor,
OnEffectRemovedExecutor,
OnEffectTickExecutor,
// Collection
EffectNodeDefinitions
} from './EffectNodes';

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "../../framework/core"
},
{
"path": "../../framework/blueprint"
}
]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
treeshake: true,
tsconfig: 'tsconfig.build.json'
});

View File

@@ -0,0 +1,46 @@
{
"id": "fairygui",
"name": "@esengine/fairygui",
"globalKey": "fairygui",
"displayName": "FairyGUI",
"description": "FairyGUI UI system integration | FairyGUI UI 系统集成",
"version": "1.0.0",
"category": "UI",
"icon": "Layout",
"tags": [
"ui",
"fairygui",
"gui"
],
"isCore": false,
"defaultEnabled": true,
"isEngineModule": true,
"canContainContent": true,
"platforms": [
"web",
"desktop"
],
"dependencies": [
"core",
"math",
"asset-system"
],
"exports": {
"components": [
"FGUIComponent"
],
"systems": [
"FGUIRenderSystem",
"FGUIUpdateSystem"
],
"loaders": [
"FUIAssetLoader"
]
},
"editorPackage": "@esengine/fairygui-editor",
"assetExtensions": {
".fui": "fui"
},
"outputPath": "dist/index.js",
"pluginExport": "FGUIPlugin"
}

View File

@@ -0,0 +1,54 @@
{
"name": "@esengine/fairygui",
"version": "1.0.0",
"description": "FairyGUI ECS integration - FairyGUI Editor compatible UI system",
"esengine": {
"plugin": true,
"pluginExport": "FGUIPlugin",
"editorPackage": "@esengine/fairygui-editor",
"category": "ui",
"isEngineModule": true
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/asset-system": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*"
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",
"typescript": "^5.3.3"
},
"keywords": [
"ecs",
"fairygui",
"ui",
"webgpu",
"game-ui"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,268 @@
/**
* FGUI Texture Manager
*
* Manages texture loading for FairyGUI.
* Uses the global IAssetFileLoader for platform-agnostic asset loading.
*
* FGUI 纹理管理器
* 使用全局 IAssetFileLoader 进行平台无关的资产加载
*/
import { getGlobalAssetFileLoader } from '@esengine/asset-system';
/**
* Texture service interface for engine integration
* 引擎集成的纹理服务接口
*/
export interface ITextureService {
/**
* Load texture from URL/path (e.g., Blob URL)
* 从 URL/路径加载纹理(如 Blob URL
*
* @param url - URL to load texture from (Blob URL, HTTP URL, etc.)
* @returns Engine texture ID (may be 0 if async loading)
*/
loadTextureByPath(url: string): number;
/**
* Get texture ID if already loaded
* 获取已加载的纹理 ID
*
* @param url - URL to check
* @returns Texture ID or undefined if not loaded
*/
getTextureIdByPath?(url: string): number | undefined;
}
/** Global texture service instance | 全局纹理服务实例 */
let globalTextureService: ITextureService | null = null;
/**
* Set global texture service
* 设置全局纹理服务
*/
export function setGlobalTextureService(service: ITextureService | null): void {
globalTextureService = service;
}
/**
* Get global texture service
* 获取全局纹理服务
*/
export function getGlobalTextureService(): ITextureService | null {
return globalTextureService;
}
/**
* Texture entry with loading state
* 带加载状态的纹理条目
*/
interface TextureEntry {
/** Engine texture ID (0 = not loaded) | 引擎纹理 ID */
textureId: number;
/** Loading state | 加载状态 */
state: 'pending' | 'loading' | 'loaded' | 'error';
/** Load promise | 加载 Promise */
promise?: Promise<number>;
}
/**
* FGUITextureManager
*
* Centralized texture management for FairyGUI.
* Handles loading, caching, and resolution of textures.
*
* FairyGUI 的集中纹理管理
* 处理纹理的加载、缓存和解析
*/
export class FGUITextureManager {
private static _instance: FGUITextureManager | null = null;
/** Texture cache: asset path -> texture entry | 纹理缓存 */
private _cache: Map<string, TextureEntry> = new Map();
private constructor() {}
/**
* Get singleton instance
* 获取单例实例
*/
public static getInstance(): FGUITextureManager {
if (!FGUITextureManager._instance) {
FGUITextureManager._instance = new FGUITextureManager();
}
return FGUITextureManager._instance;
}
/**
* Resolve texture path to engine texture ID
* 解析纹理路径为引擎纹理 ID
*
* This is the main API for FGUIRenderDataProvider.
* Returns 0 if texture is not yet loaded, triggering async load.
*
* @param texturePath - Relative asset path (e.g., "assets/ui/Bag_atlas0.png")
* @returns Engine texture ID or 0 if pending
*/
public resolveTexture(texturePath: string): number {
const entry = this._cache.get(texturePath);
if (entry) {
if (entry.state === 'loaded') {
return entry.textureId;
}
// Still loading or error, return 0
return 0;
}
// Start loading
this._loadTexture(texturePath);
return 0;
}
/**
* Check if texture is loaded
* 检查纹理是否已加载
*/
public isTextureLoaded(texturePath: string): boolean {
const entry = this._cache.get(texturePath);
return entry?.state === 'loaded';
}
/**
* Get texture ID if loaded
* 获取已加载的纹理 ID
*/
public getTextureId(texturePath: string): number | undefined {
const entry = this._cache.get(texturePath);
return entry?.state === 'loaded' ? entry.textureId : undefined;
}
/**
* Preload textures
* 预加载纹理
*/
public async preloadTextures(texturePaths: string[]): Promise<void> {
const promises: Promise<number>[] = [];
for (const path of texturePaths) {
const entry = this._cache.get(path);
if (!entry) {
promises.push(this._loadTexture(path));
} else if (entry.promise) {
promises.push(entry.promise);
}
}
await Promise.all(promises);
}
/**
* Clear texture cache
* 清除纹理缓存
*/
public clear(): void {
this._cache.clear();
}
/**
* Load a single texture
* 加载单个纹理
*/
private _loadTexture(texturePath: string): Promise<number> {
const entry: TextureEntry = {
textureId: 0,
state: 'loading'
};
entry.promise = this._doLoadTexture(texturePath, entry);
this._cache.set(texturePath, entry);
return entry.promise;
}
/**
* Internal texture loading implementation
* 内部纹理加载实现
*/
private async _doLoadTexture(texturePath: string, entry: TextureEntry): Promise<number> {
const assetLoader = getGlobalAssetFileLoader();
const textureService = getGlobalTextureService();
if (!assetLoader) {
console.error('[FGUITextureManager] No global asset file loader available');
entry.state = 'error';
return 0;
}
if (!textureService) {
console.error('[FGUITextureManager] No texture service available');
entry.state = 'error';
return 0;
}
try {
// Load image via global asset file loader
// The image.src will be a usable URL (Blob URL in editor, HTTP URL in browser)
// 通过全局资产文件加载器加载图片
// image.src 是可用的 URL编辑器中是 Blob URL浏览器中是 HTTP URL
const image = await assetLoader.loadImage(texturePath);
// Use the image's src URL to load texture in engine
// 使用图片的 src URL 在引擎中加载纹理
const textureId = textureService.loadTextureByPath(image.src);
if (textureId > 0) {
entry.textureId = textureId;
entry.state = 'loaded';
} else {
entry.state = 'error';
console.error(`[FGUITextureManager] Failed to create texture: ${texturePath}`);
}
return entry.textureId;
} catch (err) {
entry.state = 'error';
console.error(`[FGUITextureManager] Failed to load texture: ${texturePath}`, err);
return 0;
}
}
}
/**
* Get global FGUI texture manager instance
* 获取全局 FGUI 纹理管理器实例
*/
export function getFGUITextureManager(): FGUITextureManager {
return FGUITextureManager.getInstance();
}
/**
* Special texture key for white pixel (used for Graph rendering)
* 白色像素的特殊纹理键(用于 Graph 渲染)
*/
export const WHITE_PIXEL_TEXTURE_KEY = '__fgui_white_pixel__';
/**
* Create texture resolver function for FGUIRenderDataProvider
* 创建 FGUIRenderDataProvider 的纹理解析函数
*/
export function createTextureResolver(): (textureId: string | number) => number {
const manager = getFGUITextureManager();
return (textureId: string | number): number => {
if (typeof textureId === 'number') {
return textureId;
}
// Handle special white pixel texture for Graph rendering
// Engine texture ID 0 is the default white texture
// 处理用于 Graph 渲染的特殊白色像素纹理
// 引擎纹理 ID 0 是默认的白色纹理
if (textureId === WHITE_PIXEL_TEXTURE_KEY) {
return 0;
}
return manager.resolveTexture(textureId);
};
}

View File

@@ -0,0 +1,91 @@
/**
* FUI Asset Loader
*
* Asset loader for FairyGUI package files (.fui).
*
* FairyGUI 包文件资产加载器
*/
import type {
IAssetLoader,
IAssetParseContext,
IAssetContent,
AssetContentType
} from '@esengine/asset-system';
import { UIPackage } from '../package/UIPackage';
/**
* FUI asset interface
* FUI 资产接口
*/
export interface IFUIAsset {
/** Loaded UIPackage instance | 加载的 UIPackage 实例 */
package: UIPackage;
/** Package ID | 包 ID */
id: string;
/** Package name | 包名称 */
name: string;
/** Resource key used for loading | 加载时使用的资源键 */
resKey: string;
}
/**
* FUI asset type constant
* FUI 资产类型常量
*/
export const FUI_ASSET_TYPE = 'fui';
/**
* FUIAssetLoader
*
* Loads FairyGUI package files (.fui) and creates UIPackage instances.
*
* 加载 FairyGUI 包文件并创建 UIPackage 实例
*/
export class FUIAssetLoader implements IAssetLoader<IFUIAsset> {
readonly supportedType = FUI_ASSET_TYPE;
readonly supportedExtensions = ['.fui'];
readonly contentType: AssetContentType = 'binary';
/**
* Parse FUI package from binary content
* 从二进制内容解析 FUI 包
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IFUIAsset> {
if (!content.binary) {
throw new Error('FUIAssetLoader: Binary content is empty');
}
// Use path as resource key
const resKey = context.metadata.path;
// Load package from binary data
const pkg = UIPackage.addPackageFromBuffer(resKey, content.binary);
return {
package: pkg,
id: pkg.id,
name: pkg.name,
resKey
};
}
/**
* Dispose loaded FUI asset
* 释放已加载的 FUI 资产
*/
dispose(asset: IFUIAsset): void {
if (asset.package) {
UIPackage.removePackage(asset.resKey);
}
}
}
/**
* Default FUI asset loader instance
* 默认 FUI 资产加载器实例
*/
export const fuiAssetLoader = new FUIAssetLoader();
// Re-export types from asset-system for convenience
export type { IAssetLoader, IAssetContent, IAssetParseContext, AssetContentType };

View File

@@ -0,0 +1,34 @@
/**
* FairyGUI Asset Loaders
*
* Asset loaders for FairyGUI package files.
*
* FairyGUI 包文件的资产加载器
*/
export {
FUIAssetLoader,
fuiAssetLoader,
FUI_ASSET_TYPE
} from './FUIAssetLoader';
export type { IFUIAsset } from './FUIAssetLoader';
// Texture management | 纹理管理
export {
FGUITextureManager,
getFGUITextureManager,
createTextureResolver,
setGlobalTextureService,
getGlobalTextureService
} from './FGUITextureManager';
export type { ITextureService } from './FGUITextureManager';
// Re-export types from asset-system for convenience
export type {
IAssetLoader,
IAssetContent,
IAssetParseContext,
AssetContentType
} from '@esengine/asset-system';

View File

@@ -0,0 +1,353 @@
/**
* Property change callback
* 属性变更回调
*/
export type PropertyChangeCallback<T> = (newValue: T, oldValue: T) => void;
/**
* Property binding subscription
* 属性绑定订阅
*/
export interface IPropertySubscription {
/** Unsubscribe from property changes | 取消订阅属性变更 */
unsubscribe(): void;
}
/**
* Observable property interface
* 可观察属性接口
*/
export interface IObservableProperty<T> {
/** Get current value | 获取当前值 */
readonly value: T;
/** Subscribe to changes | 订阅变更 */
subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription;
/** Bind to another property | 绑定到另一个属性 */
bindTo(target: IWritableProperty<T>): IPropertySubscription;
}
/**
* Writable property interface
* 可写属性接口
*/
export interface IWritableProperty<T> extends IObservableProperty<T> {
/** Set value | 设置值 */
value: T;
}
/**
* ObservableProperty
*
* Reactive property that notifies subscribers when value changes.
*
* 响应式属性,值变更时通知订阅者
*
* @example
* ```typescript
* const name = new ObservableProperty('初始值');
* name.subscribe((newVal, oldVal) => console.log(`Changed: ${oldVal} -> ${newVal}`));
* name.value = '新值'; // 触发回调
* ```
*/
export class ObservableProperty<T> implements IWritableProperty<T> {
private _value: T;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
private _equalityFn: (a: T, b: T) => boolean;
constructor(initialValue: T, equalityFn?: (a: T, b: T) => boolean) {
this._value = initialValue;
this._equalityFn = equalityFn ?? ((a, b) => a === b);
}
public get value(): T {
return this._value;
}
public set value(newValue: T) {
if (!this._equalityFn(this._value, newValue)) {
const oldValue = this._value;
this._value = newValue;
this.notify(newValue, oldValue);
}
}
/**
* Set value without triggering notifications
* 设置值但不触发通知
*/
public setSilent(newValue: T): void {
this._value = newValue;
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this._value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
/**
* Create a derived property that transforms this property's value
* 创建一个转换此属性值的派生属性
*/
public map<U>(transform: (value: T) => U): IObservableProperty<U> {
const derived = new DerivedProperty<U>(transform(this._value));
this.subscribe((newValue) => {
derived.update(transform(newValue));
});
return derived;
}
/**
* Combine with another property
* 与另一个属性组合
*/
public combine<U, R>(
other: IObservableProperty<U>,
combiner: (a: T, b: U) => R
): IObservableProperty<R> {
const derived = new DerivedProperty<R>(combiner(this._value, other.value));
this.subscribe((newValue) => {
derived.update(combiner(newValue, other.value));
});
other.subscribe((newValue) => {
derived.update(combiner(this._value, newValue));
});
return derived;
}
private notify(newValue: T, oldValue: T): void {
for (const callback of this._subscribers) {
try {
callback(newValue, oldValue);
} catch (error) {
console.error('Error in property change callback:', error);
}
}
}
}
/**
* DerivedProperty
*
* Read-only property derived from other properties.
*
* 从其他属性派生的只读属性
*/
class DerivedProperty<T> implements IObservableProperty<T> {
private _value: T;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
constructor(initialValue: T) {
this._value = initialValue;
}
public get value(): T {
return this._value;
}
public update(newValue: T): void {
if (this._value !== newValue) {
const oldValue = this._value;
this._value = newValue;
for (const callback of this._subscribers) {
callback(newValue, oldValue);
}
}
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this._value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
}
/**
* ComputedProperty
*
* Property that computes its value from a function.
*
* 通过函数计算值的属性
*
* @example
* ```typescript
* const firstName = new ObservableProperty('张');
* const lastName = new ObservableProperty('三');
* const fullName = new ComputedProperty(
* () => firstName.value + lastName.value,
* [firstName, lastName]
* );
* ```
*/
export class ComputedProperty<T> implements IObservableProperty<T> {
private _computeFn: () => T;
private _cachedValue: T;
private _dirty: boolean = false;
private _subscribers: Set<PropertyChangeCallback<T>> = new Set();
private _subscriptions: IPropertySubscription[] = [];
constructor(computeFn: () => T, dependencies: IObservableProperty<unknown>[]) {
this._computeFn = computeFn;
this._cachedValue = computeFn();
for (const dep of dependencies) {
this._subscriptions.push(
dep.subscribe(() => {
this._dirty = true;
this.recompute();
})
);
}
}
public get value(): T {
if (this._dirty) {
this.recompute();
}
return this._cachedValue;
}
public subscribe(callback: PropertyChangeCallback<T>): IPropertySubscription {
this._subscribers.add(callback);
return {
unsubscribe: () => this._subscribers.delete(callback)
};
}
public bindTo(target: IWritableProperty<T>): IPropertySubscription {
target.value = this.value;
return this.subscribe((newValue) => {
target.value = newValue;
});
}
public dispose(): void {
for (const sub of this._subscriptions) {
sub.unsubscribe();
}
this._subscriptions.length = 0;
this._subscribers.clear();
}
private recompute(): void {
const oldValue = this._cachedValue;
this._cachedValue = this._computeFn();
this._dirty = false;
if (oldValue !== this._cachedValue) {
for (const callback of this._subscribers) {
callback(this._cachedValue, oldValue);
}
}
}
}
/**
* PropertyBinder
*
* Utility for managing multiple property bindings.
*
* 管理多个属性绑定的工具类
*
* @example
* ```typescript
* const binder = new PropertyBinder();
* binder.bind(source.name, target, 'displayName');
* binder.bind(source.value, target.progressBar, 'progress');
* // Later...
* binder.dispose(); // Cleans up all bindings
* ```
*/
export class PropertyBinder {
private _subscriptions: IPropertySubscription[] = [];
/**
* Bind a property to an object's field
* 将属性绑定到对象的字段
*/
public bind<T, K extends keyof T>(
source: IObservableProperty<T[K]>,
target: T,
key: K
): this {
target[key] = source.value;
this._subscriptions.push(
source.subscribe((newValue) => {
target[key] = newValue;
})
);
return this;
}
/**
* Two-way bind between properties
* 属性间双向绑定
*/
public bindTwoWay<T>(
propA: IWritableProperty<T>,
propB: IWritableProperty<T>
): this {
let updating = false;
this._subscriptions.push(
propA.subscribe((newValue) => {
if (!updating) {
updating = true;
propB.value = newValue;
updating = false;
}
})
);
this._subscriptions.push(
propB.subscribe((newValue) => {
if (!updating) {
updating = true;
propA.value = newValue;
updating = false;
}
})
);
return this;
}
/**
* Add a custom subscription
* 添加自定义订阅
*/
public addSubscription(subscription: IPropertySubscription): this {
this._subscriptions.push(subscription);
return this;
}
/**
* Dispose all bindings
* 销毁所有绑定
*/
public dispose(): void {
for (const sub of this._subscriptions) {
sub.unsubscribe();
}
this._subscriptions.length = 0;
}
}

View File

@@ -0,0 +1,327 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { FGUIEvents } from '../events/Events';
import type { GComponent } from './GComponent';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* Controller
*
* Manages state switching for UI components.
* Similar to a state machine, it controls which gear values are active.
*
* 管理 UI 组件的状态切换,类似状态机,控制哪些齿轮值处于活动状态
*/
export class Controller extends EventDispatcher {
/** Controller name | 控制器名称 */
public name: string = '';
/** Parent component | 父组件 */
public parent: GComponent | null = null;
/** Is changing flag | 是否正在变更中 */
public changing: boolean = false;
/** Auto radio group | 自动单选组 */
public autoRadioGroupDepth: boolean = false;
private _selectedIndex: number = 0;
private _previousIndex: number = 0;
private _pageIds: string[] = [];
private _pageNames: string[] = [];
constructor() {
super();
}
/**
* Get selected index
* 获取选中索引
*/
public get selectedIndex(): number {
return this._selectedIndex;
}
/**
* Set selected index
* 设置选中索引
*/
public set selectedIndex(value: number) {
if (this._selectedIndex !== value) {
if (value > this._pageIds.length - 1) {
throw new Error('Index out of bounds: ' + value);
}
this.changing = true;
this._previousIndex = this._selectedIndex;
this._selectedIndex = value;
this.parent?.applyController(this);
this.emit(FGUIEvents.STATUS_CHANGED);
this.changing = false;
}
}
/**
* Get selected page
* 获取选中页面名称
*/
public get selectedPage(): string {
if (this._selectedIndex === -1) {
return '';
}
return this._pageNames[this._selectedIndex] || '';
}
/**
* Set selected page
* 设置选中页面
*/
public set selectedPage(value: string) {
let index = this._pageNames.indexOf(value);
if (index === -1) {
index = this._pageIds.indexOf(value);
}
if (index !== -1) {
this.selectedIndex = index;
}
}
/**
* Get selected page ID
* 获取选中页面 ID
*/
public get selectedPageId(): string {
if (this._selectedIndex === -1) {
return '';
}
return this._pageIds[this._selectedIndex] || '';
}
/**
* Set selected page ID
* 设置选中页面 ID
*/
public set selectedPageId(value: string) {
const index = this._pageIds.indexOf(value);
if (index !== -1) {
this.selectedIndex = index;
}
}
/**
* Get previous selected index
* 获取之前选中的索引
*/
public get previousIndex(): number {
return this._previousIndex;
}
/**
* Get previous selected page
* 获取之前选中的页面
*/
public get previousPage(): string {
if (this._previousIndex === -1) {
return '';
}
return this._pageNames[this._previousIndex] || '';
}
/**
* Get page count
* 获取页面数量
*/
public get pageCount(): number {
return this._pageIds.length;
}
/**
* Get page ID at index
* 获取指定索引的页面 ID
*/
public getPageId(index: number): string {
return this._pageIds[index] || '';
}
/**
* Set page ID at index
* 设置指定索引的页面 ID
*/
public setPageId(index: number, id: string): void {
this._pageIds[index] = id;
}
/**
* Get page name at index
* 获取指定索引的页面名称
*/
public getPageName(index: number): string {
return this._pageNames[index] || '';
}
/**
* Set page name at index
* 设置指定索引的页面名称
*/
public setPageName(index: number, name: string): void {
this._pageNames[index] = name;
}
/**
* Get index by page ID
* 通过页面 ID 获取索引
*/
public getPageIndexById(id: string): number {
return this._pageIds.indexOf(id);
}
/**
* Get ID by page name
* 通过页面名称获取 ID
*/
public getPageIdByName(name: string): string {
const index = this._pageNames.indexOf(name);
if (index !== -1) {
return this._pageIds[index];
}
return '';
}
/**
* Check if the controller has the specified page
* 检查控制器是否有指定页面
*/
public hasPage(aName: string): boolean {
return this._pageNames.indexOf(aName) !== -1;
}
/**
* Add page
* 添加页面
*/
public addPage(name: string = ''): void {
this.addPageAt(name, this._pageIds.length);
}
/**
* Add page at index
* 在指定位置添加页面
*/
public addPageAt(name: string, index: number): void {
const id = '' + (this._pageIds.length > 0 ? parseInt(this._pageIds[this._pageIds.length - 1]) + 1 : 0);
if (index === this._pageIds.length) {
this._pageIds.push(id);
this._pageNames.push(name);
} else {
this._pageIds.splice(index, 0, id);
this._pageNames.splice(index, 0, name);
}
}
/**
* Remove page at index
* 移除指定索引的页面
*/
public removePage(name: string): void {
const index = this._pageNames.indexOf(name);
if (index !== -1) {
this._pageIds.splice(index, 1);
this._pageNames.splice(index, 1);
if (this._selectedIndex >= this._pageIds.length) {
this._selectedIndex = this._pageIds.length - 1;
}
}
}
/**
* Remove page at index
* 移除指定索引的页面
*/
public removePageAt(index: number): void {
this._pageIds.splice(index, 1);
this._pageNames.splice(index, 1);
if (this._selectedIndex >= this._pageIds.length) {
this._selectedIndex = this._pageIds.length - 1;
}
}
/**
* Clear all pages
* 清除所有页面
*/
public clearPages(): void {
this._pageIds.length = 0;
this._pageNames.length = 0;
this._selectedIndex = -1;
}
/**
* Run actions on page changed
* 页面改变时执行动作
*/
public runActions(): void {
// Override in subclasses or handle via events
}
/**
* Setup controller from buffer
* 从缓冲区设置控制器
*/
public setup(buffer: ByteBuffer): void {
const beginPos = buffer.pos;
buffer.seek(beginPos, 0);
this.name = buffer.readS() || '';
if (buffer.readBool()) {
this.autoRadioGroupDepth = true;
}
buffer.seek(beginPos, 1);
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
this._pageIds.push(buffer.readS() || '');
this._pageNames.push(buffer.readS() || '');
}
// Home page index (simplified - ignore advanced home page types)
let homePageIndex = 0;
const homePageType = buffer.readByte();
if (homePageType === 1) {
homePageIndex = buffer.getInt16();
} else if (homePageType === 2 || homePageType === 3) {
// Skip variable name for type 3
if (homePageType === 3) {
buffer.readS();
}
}
buffer.seek(beginPos, 2);
// Skip actions for now
const actionCount = buffer.getInt16();
for (let i = 0; i < actionCount; i++) {
let nextPos = buffer.getInt16();
nextPos += buffer.pos;
buffer.pos = nextPos;
}
if (this.parent && this._pageIds.length > 0) {
this._selectedIndex = homePageIndex;
} else {
this._selectedIndex = -1;
}
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
this.parent = null;
super.dispose();
}
}

View File

@@ -0,0 +1,144 @@
import { GObject } from './GObject';
import { GRoot } from './GRoot';
import { GLoader } from '../widgets/GLoader';
import { Stage } from './Stage';
import { FGUIEvents } from '../events/Events';
import { EAlignType, EVertAlignType } from './FieldTypes';
/**
* DragDropManager
*
* Manages drag and drop operations with visual feedback.
*
* 管理带有视觉反馈的拖放操作
*
* Features:
* - Visual drag agent with icon
* - Source data carrying
* - Drop target detection
* - Singleton pattern
*
* @example
* ```typescript
* // Start drag operation
* DragDropManager.inst.startDrag(sourceObj, 'ui://pkg/icon', myData);
*
* // Listen for drop on target
* targetObj.on(FGUIEvents.DROP, (data) => {
* console.log('Dropped:', data);
* });
*
* // Cancel drag
* DragDropManager.inst.cancel();
* ```
*/
export class DragDropManager {
private static _inst: DragDropManager | null = null;
private _agent: GLoader;
private _sourceData: any = null;
/**
* Get singleton instance
* 获取单例实例
*/
public static get inst(): DragDropManager {
if (!DragDropManager._inst) {
DragDropManager._inst = new DragDropManager();
}
return DragDropManager._inst;
}
constructor() {
this._agent = new GLoader();
this._agent.draggable = true;
this._agent.touchable = false; // Important: prevent interference with drop detection
this._agent.setSize(100, 100);
this._agent.setPivot(0.5, 0.5, true);
this._agent.align = EAlignType.Center;
this._agent.verticalAlign = EVertAlignType.Middle;
this._agent.sortingOrder = 1000000;
this._agent.on(FGUIEvents.DRAG_END, this.onDragEnd, this);
}
/**
* Get drag agent object
* 获取拖拽代理对象
*/
public get dragAgent(): GObject {
return this._agent;
}
/**
* Check if currently dragging
* 检查是否正在拖拽
*/
public get dragging(): boolean {
return this._agent.parent !== null;
}
/**
* Start a drag operation
* 开始拖拽操作
*
* @param source - Source object initiating drag | 发起拖拽的源对象
* @param icon - Icon URL for drag agent | 拖拽代理的图标 URL
* @param sourceData - Data to carry during drag | 拖拽期间携带的数据
* @param touchId - Touch point ID for multi-touch | 多点触控的触摸点 ID
*/
public startDrag(source: GObject, icon: string, sourceData?: any, touchId?: number): void {
if (this._agent.parent) {
return;
}
this._sourceData = sourceData;
this._agent.url = icon;
GRoot.inst.addChild(this._agent);
const stage = Stage.inst;
const pt = GRoot.inst.globalToLocal(stage.mouseX, stage.mouseY);
this._agent.setXY(pt.x, pt.y);
this._agent.startDrag(touchId);
}
/**
* Cancel current drag operation
* 取消当前拖拽操作
*/
public cancel(): void {
if (this._agent.parent) {
this._agent.stopDrag();
GRoot.inst.removeChild(this._agent);
this._sourceData = null;
}
}
private onDragEnd(): void {
if (!this._agent.parent) {
// Already cancelled
return;
}
GRoot.inst.removeChild(this._agent);
const sourceData = this._sourceData;
this._sourceData = null;
// Find drop target
const stage = Stage.inst;
const target = GRoot.inst.hitTest(stage.mouseX, stage.mouseY);
if (target) {
// Walk up the display list to find a drop handler
let obj: GObject | null = target;
while (obj) {
if (obj.hasListener(FGUIEvents.DROP)) {
obj.emit(FGUIEvents.DROP, sourceData);
return;
}
obj = obj.parent;
}
}
}
}

View File

@@ -0,0 +1,366 @@
/**
* FairyGUI Field Types
* FairyGUI 字段类型定义
*/
/**
* Button mode
* 按钮模式
*/
export const enum EButtonMode {
Common = 0,
Check = 1,
Radio = 2
}
/**
* Auto size type
* 自动尺寸类型
*/
export const enum EAutoSizeType {
None = 0,
Both = 1,
Height = 2,
Shrink = 3,
Ellipsis = 4
}
/**
* Align type
* 水平对齐类型
*/
export const enum EAlignType {
Left = 0,
Center = 1,
Right = 2
}
/**
* Vertical align type
* 垂直对齐类型
*/
export const enum EVertAlignType {
Top = 0,
Middle = 1,
Bottom = 2
}
/**
* Loader fill type
* 加载器填充类型
*/
export const enum ELoaderFillType {
None = 0,
Scale = 1,
ScaleMatchHeight = 2,
ScaleMatchWidth = 3,
ScaleFree = 4,
ScaleNoBorder = 5
}
/**
* List layout type
* 列表布局类型
*/
export const enum EListLayoutType {
SingleColumn = 0,
SingleRow = 1,
FlowHorizontal = 2,
FlowVertical = 3,
Pagination = 4
}
/**
* List selection mode
* 列表选择模式
*/
export const enum EListSelectionMode {
Single = 0,
Multiple = 1,
MultipleSingleClick = 2,
None = 3
}
/**
* Overflow type
* 溢出类型
*/
export const enum EOverflowType {
Visible = 0,
Hidden = 1,
Scroll = 2
}
/**
* Package item type
* 包资源类型
*/
export const enum EPackageItemType {
Image = 0,
MovieClip = 1,
Sound = 2,
Component = 3,
Atlas = 4,
Font = 5,
Swf = 6,
Misc = 7,
Unknown = 8,
Spine = 9,
DragonBones = 10
}
/**
* Object type
* 对象类型
*/
export const enum EObjectType {
Image = 0,
MovieClip = 1,
Swf = 2,
Graph = 3,
Loader = 4,
Group = 5,
Text = 6,
RichText = 7,
InputText = 8,
Component = 9,
List = 10,
Label = 11,
Button = 12,
ComboBox = 13,
ProgressBar = 14,
Slider = 15,
ScrollBar = 16,
Tree = 17,
Loader3D = 18
}
/**
* Progress title type
* 进度条标题类型
*/
export const enum EProgressTitleType {
Percent = 0,
ValueAndMax = 1,
Value = 2,
Max = 3
}
/**
* ScrollBar display type
* 滚动条显示类型
*/
export const enum EScrollBarDisplayType {
Default = 0,
Visible = 1,
Auto = 2,
Hidden = 3
}
/**
* Scroll type
* 滚动类型
*/
export const enum EScrollType {
Horizontal = 0,
Vertical = 1,
Both = 2
}
/**
* Flip type
* 翻转类型
*/
export const enum EFlipType {
None = 0,
Horizontal = 1,
Vertical = 2,
Both = 3
}
/**
* Children render order
* 子对象渲染顺序
*/
export const enum EChildrenRenderOrder {
Ascent = 0,
Descent = 1,
Arch = 2
}
/**
* Group layout type
* 组布局类型
*/
export const enum EGroupLayoutType {
None = 0,
Horizontal = 1,
Vertical = 2
}
/**
* Popup direction
* 弹出方向
*/
export const enum EPopupDirection {
Auto = 0,
Up = 1,
Down = 2
}
/**
* Relation type
* 关联类型
*/
export const enum ERelationType {
LeftLeft = 0,
LeftCenter = 1,
LeftRight = 2,
CenterCenter = 3,
RightLeft = 4,
RightCenter = 5,
RightRight = 6,
TopTop = 7,
TopMiddle = 8,
TopBottom = 9,
MiddleMiddle = 10,
BottomTop = 11,
BottomMiddle = 12,
BottomBottom = 13,
Width = 14,
Height = 15,
LeftExtLeft = 16,
LeftExtRight = 17,
RightExtLeft = 18,
RightExtRight = 19,
TopExtTop = 20,
TopExtBottom = 21,
BottomExtTop = 22,
BottomExtBottom = 23,
Size = 24
}
/**
* Fill method
* 填充方法
*/
export const enum EFillMethod {
None = 0,
Horizontal = 1,
Vertical = 2,
Radial90 = 3,
Radial180 = 4,
Radial360 = 5
}
/**
* Fill origin
* 填充起点
*/
export const enum EFillOrigin {
Top = 0,
Bottom = 1,
Left = 2,
Right = 3,
TopLeft = 0,
TopRight = 1,
BottomLeft = 2,
BottomRight = 3
}
/**
* Object property ID
* 对象属性 ID
*/
export const enum EObjectPropID {
Text = 0,
Icon = 1,
Color = 2,
OutlineColor = 3,
Playing = 4,
Frame = 5,
DeltaTime = 6,
TimeScale = 7,
FontSize = 8,
Selected = 9
}
/**
* Gear type
* 齿轮类型
*/
export const enum EGearType {
Display = 0,
XY = 1,
Size = 2,
Look = 3,
Color = 4,
Animation = 5,
Text = 6,
Icon = 7,
Display2 = 8,
FontSize = 9
}
// EEaseType is re-exported from tween module
export { EEaseType } from '../tween/EaseType';
/**
* Blend mode
* 混合模式
*/
export const enum EBlendMode {
Normal = 0,
None = 1,
Add = 2,
Multiply = 3,
Screen = 4,
Erase = 5,
Mask = 6,
Below = 7,
Off = 8,
Custom1 = 9,
Custom2 = 10,
Custom3 = 11
}
/**
* Transition action type
* 过渡动作类型
*/
export const enum ETransitionActionType {
XY = 0,
Size = 1,
Scale = 2,
Pivot = 3,
Alpha = 4,
Rotation = 5,
Color = 6,
Animation = 7,
Visible = 8,
Sound = 9,
Transition = 10,
Shake = 11,
ColorFilter = 12,
Skew = 13,
Text = 14,
Icon = 15,
Unknown = 16
}
/**
* Graph type
* 图形类型
*/
export const enum EGraphType {
Empty = 0,
Rect = 1,
Ellipse = 2,
Polygon = 3,
RegularPolygon = 4
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
import { GObject } from './GObject';
import { EGroupLayoutType } from './FieldTypes';
/**
* GGroup
*
* Group container for layout and visibility control.
* Can arrange children horizontally, vertically, or have no layout.
*
* 组容器,用于布局和可见性控制,可水平、垂直或无布局排列子元素
*/
export class GGroup extends GObject {
/** Exclude invisible children from layout | 从布局中排除不可见子元素 */
public excludeInvisibles: boolean = false;
private _layout: EGroupLayoutType = EGroupLayoutType.None;
private _lineGap: number = 0;
private _columnGap: number = 0;
private _mainGridIndex: number = -1;
private _mainGridMinSize: number = 50;
private _boundsChanged: boolean = false;
private _updating: boolean = false;
public get layout(): EGroupLayoutType {
return this._layout;
}
public set layout(value: EGroupLayoutType) {
if (this._layout !== value) {
this._layout = value;
this.setBoundsChangedFlag(true);
}
}
public get lineGap(): number {
return this._lineGap;
}
public set lineGap(value: number) {
if (this._lineGap !== value) {
this._lineGap = value;
this.setBoundsChangedFlag();
}
}
public get columnGap(): number {
return this._columnGap;
}
public set columnGap(value: number) {
if (this._columnGap !== value) {
this._columnGap = value;
this.setBoundsChangedFlag();
}
}
public get mainGridIndex(): number {
return this._mainGridIndex;
}
public set mainGridIndex(value: number) {
if (this._mainGridIndex !== value) {
this._mainGridIndex = value;
this.setBoundsChangedFlag();
}
}
public get mainGridMinSize(): number {
return this._mainGridMinSize;
}
public set mainGridMinSize(value: number) {
if (this._mainGridMinSize !== value) {
this._mainGridMinSize = value;
this.setBoundsChangedFlag();
}
}
/**
* Set bounds changed flag
* 设置边界变更标记
*/
public setBoundsChangedFlag(bPositionChanged: boolean = false): void {
if (this._updating) return;
if (bPositionChanged) {
// Position changed, need to recalculate
}
if (!this._boundsChanged) {
this._boundsChanged = true;
}
}
/**
* Ensure bounds are up to date
* 确保边界是最新的
*/
public ensureBoundsCorrect(): void {
if (this._boundsChanged) {
this.updateBounds();
}
}
private updateBounds(): void {
this._boundsChanged = false;
if (!this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
const count = children.length;
if (count === 0) {
this._updating = false;
return;
}
if (this._layout === EGroupLayoutType.None) {
this.updateBoundsNone(children);
} else if (this._layout === EGroupLayoutType.Horizontal) {
this.updateBoundsHorizontal(children);
} else {
this.updateBoundsVertical(children);
}
this._updating = false;
}
private updateBoundsNone(children: GObject[]): void {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const ax = child.xMin;
const ay = child.yMin;
if (ax < minX) minX = ax;
if (ay < minY) minY = ay;
if (ax + child.width > maxX) maxX = ax + child.width;
if (ay + child.height > maxY) maxY = ay + child.height;
}
if (minX === Infinity) {
minX = 0;
minY = 0;
maxX = 0;
maxY = 0;
}
this._width = maxX - minX;
this._height = maxY - minY;
}
private updateBoundsHorizontal(children: GObject[]): void {
let totalWidth = 0;
let maxHeight = 0;
let visibleCount = 0;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
totalWidth += child.width;
if (child.height > maxHeight) maxHeight = child.height;
visibleCount++;
}
if (visibleCount > 0) {
totalWidth += (visibleCount - 1) * this._columnGap;
}
this._width = totalWidth;
this._height = maxHeight;
}
private updateBoundsVertical(children: GObject[]): void {
let maxWidth = 0;
let totalHeight = 0;
let visibleCount = 0;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
totalHeight += child.height;
if (child.width > maxWidth) maxWidth = child.width;
visibleCount++;
}
if (visibleCount > 0) {
totalHeight += (visibleCount - 1) * this._lineGap;
}
this._width = maxWidth;
this._height = totalHeight;
}
/**
* Move children when group is moved
* 组移动时移动子元素
*/
public moveChildren(dx: number, dy: number): void {
if (this._updating || !this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
for (const child of children) {
child.setXY(child.x + dx, child.y + dy);
}
this._updating = false;
}
/**
* Resize children when group is resized
* 组调整大小时调整子元素
*/
public resizeChildren(dw: number, dh: number): void {
if (this._layout === EGroupLayoutType.None || this._updating || !this._parent) return;
this._updating = true;
const children = this._parent.getChildrenInGroup(this);
const count = children.length;
if (count > 0) {
if (this._layout === EGroupLayoutType.Horizontal) {
const remainingWidth = this._width + dw - (count - 1) * this._columnGap;
let x = children[0].xMin;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const newWidth = child._sizePercentInGroup * remainingWidth;
child.setSize(newWidth, child.height + dh);
child.xMin = x;
x += newWidth + this._columnGap;
}
} else {
const remainingHeight = this._height + dh - (count - 1) * this._lineGap;
let y = children[0].yMin;
for (const child of children) {
if (this.excludeInvisibles && !child.internalVisible3) continue;
const newHeight = child._sizePercentInGroup * remainingHeight;
child.setSize(child.width + dw, newHeight);
child.yMin = y;
y += newHeight + this._lineGap;
}
}
}
this._updating = false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import type { GObject } from './GObject';
import { UIPackage } from '../package/UIPackage';
/**
* GObjectPool
*
* Object pool for GObject instances, used for efficient UI recycling.
* Objects are pooled by their resource URL.
*
* GObject 实例对象池,用于高效的 UI 回收。对象按资源 URL 分池管理。
*/
export class GObjectPool {
private _pool: Map<string, GObject[]> = new Map();
private _count: number = 0;
/**
* Get total pooled object count
* 获取池中对象总数
*/
public get count(): number {
return this._count;
}
/**
* Clear all pooled objects
* 清空所有池化对象
*/
public clear(): void {
for (const [, arr] of this._pool) {
for (const obj of arr) {
obj.dispose();
}
}
this._pool.clear();
this._count = 0;
}
/**
* Get object from pool or create new one
* 从池中获取对象或创建新对象
*
* @param url Resource URL | 资源 URL
* @returns GObject instance or null | GObject 实例或 null
*/
public getObject(url: string): GObject | null {
url = UIPackage.normalizeURL(url);
if (!url) return null;
const arr = this._pool.get(url);
if (arr && arr.length > 0) {
this._count--;
return arr.shift()!;
}
return UIPackage.createObjectFromURL(url);
}
/**
* Return object to pool
* 将对象归还到池中
*
* @param obj GObject to return | 要归还的 GObject
*/
public returnObject(obj: GObject): void {
const url = obj.resourceURL;
if (!url) return;
let arr = this._pool.get(url);
if (!arr) {
arr = [];
this._pool.set(url, arr);
}
this._count++;
arr.push(obj);
}
}

View File

@@ -0,0 +1,506 @@
import { GComponent } from './GComponent';
import { GObject } from './GObject';
import { Stage } from './Stage';
import { Timer } from './Timer';
import { FGUIEvents, IInputEventData } from '../events/Events';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* GRoot
*
* Root container for all UI elements.
* Manages focus, popups, tooltips, and input dispatch.
*
* 所有 UI 元素的根容器,管理焦点、弹出窗口、提示和输入分发
*/
export class GRoot extends GComponent {
private static _inst: GRoot | null = null;
private _focus: GObject | null = null;
private _tooltipWin: GObject | null = null;
private _defaultTooltipWin: GObject | null = null;
private _popupStack: GObject[] = [];
private _justClosedPopups: GObject[] = [];
private _modalLayer: GObject | null = null;
private _modalWaitPane: GObject | null = null;
private _inputProcessor: InputProcessor;
constructor() {
super();
this._inputProcessor = new InputProcessor(this);
// Set this as stage root so children receive addedToStage events
// 将自己设置为舞台根,这样子对象才能收到 addedToStage 事件
if (this.displayObject) {
this.displayObject.setStage(this.displayObject);
}
// Bind to stage events
const stage = Stage.inst;
stage.on('mousedown', this.onStageMouseDown, this);
stage.on('mouseup', this.onStageMouseUp, this);
stage.on('mousemove', this.onStageMouseMove, this);
stage.on('wheel', this.onStageWheel, this);
stage.on('resize', this.onStageResize, this);
// Set initial size
this.setSize(stage.designWidth, stage.designHeight);
}
/**
* Get singleton instance
* 获取单例实例
*/
public static get inst(): GRoot {
if (!GRoot._inst) {
GRoot._inst = new GRoot();
}
return GRoot._inst;
}
/**
* Create a new GRoot (for multi-window support)
* 创建新的 GRoot支持多窗口
*/
public static create(): GRoot {
return new GRoot();
}
// Focus management | 焦点管理
/**
* Get focused object
* 获取当前焦点对象
*/
public get focus(): GObject | null {
return this._focus;
}
/**
* Set focused object
* 设置焦点对象
*/
public set focus(value: GObject | null) {
if (this._focus !== value) {
const oldFocus = this._focus;
this._focus = value;
if (oldFocus) {
oldFocus.emit(FGUIEvents.FOCUS_OUT);
}
if (this._focus) {
this._focus.emit(FGUIEvents.FOCUS_IN);
}
}
}
// Popup management | 弹出窗口管理
/**
* Show popup at position
* 在指定位置显示弹出窗口
*/
public showPopup(popup: GObject, target?: GObject, dir?: number): void {
if (this._popupStack.indexOf(popup) === -1) {
this._popupStack.push(popup);
}
this.addChild(popup);
this.adjustModalLayer();
if (target) {
const pos = target.localToGlobal(0, 0);
popup.setXY(pos.x, pos.y + target.height);
}
popup.visible = true;
}
/**
* Toggle popup visibility
* 切换弹出窗口可见性
*/
public togglePopup(popup: GObject, target?: GObject, dir?: number): void {
if (this._justClosedPopups.indexOf(popup) !== -1) {
return;
}
if (popup.parent === this && popup.visible) {
this.hidePopup(popup);
} else {
this.showPopup(popup, target, dir);
}
}
/**
* Hide popup
* 隐藏弹出窗口
*/
public hidePopup(popup?: GObject): void {
if (popup) {
const index = this._popupStack.indexOf(popup);
if (index !== -1) {
this._popupStack.splice(index, 1);
this.closePopup(popup);
}
} else {
// Hide all popups
for (const p of this._popupStack) {
this.closePopup(p);
}
this._popupStack.length = 0;
}
}
private closePopup(popup: GObject): void {
popup.visible = false;
this._justClosedPopups.push(popup);
Timer.inst.callLater(this, () => {
const index = this._justClosedPopups.indexOf(popup);
if (index !== -1) {
this._justClosedPopups.splice(index, 1);
}
});
}
/**
* Check if popup is showing
* 检查弹出窗口是否正在显示
*/
public hasAnyPopup(): boolean {
return this._popupStack.length > 0;
}
// Modal management | 模态管理
private adjustModalLayer(): void {
// Adjust modal layer position and visibility
if (this._modalLayer) {
let hasModal = false;
for (let i = this._popupStack.length - 1; i >= 0; i--) {
// Check if popup is modal
}
this._modalLayer.visible = hasModal;
}
}
/**
* Show modal wait
* 显示模态等待
*/
public showModalWait(msg?: string): void {
if (this._modalWaitPane) {
this.addChild(this._modalWaitPane);
this._modalWaitPane.visible = true;
}
}
/**
* Close modal wait
* 关闭模态等待
*/
public closeModalWait(): void {
if (this._modalWaitPane) {
this._modalWaitPane.visible = false;
this._modalWaitPane.removeFromParent();
}
}
// Tooltip management | 提示管理
/**
* Show tooltip
* 显示提示
*/
public showTooltips(msg: string): void {
if (!this._defaultTooltipWin) return;
this._tooltipWin = this._defaultTooltipWin;
this._tooltipWin.text = msg;
this.showTooltipsWin(this._tooltipWin);
}
/**
* Show custom tooltip window
* 显示自定义提示窗口
*/
public showTooltipsWin(tooltipWin: GObject, position?: { x: number; y: number }): void {
this._tooltipWin = tooltipWin;
this.addChild(tooltipWin);
if (position) {
tooltipWin.setXY(position.x, position.y);
} else {
const stage = Stage.inst;
tooltipWin.setXY(stage.mouseX + 10, stage.mouseY + 20);
}
}
/**
* Hide tooltip
* 隐藏提示
*/
public hideTooltips(): void {
if (this._tooltipWin) {
this._tooltipWin.removeFromParent();
this._tooltipWin = null;
}
}
// Input handling | 输入处理
private onStageMouseDown(data: IInputEventData): void {
this._inputProcessor.onMouseDown(data);
// Close popups if clicking outside
if (this._popupStack.length > 0) {
const hit = this.hitTest(data.stageX, data.stageY);
if (!hit || !this.isAncestorOf(hit, this._popupStack[this._popupStack.length - 1])) {
this.hidePopup();
}
}
this.hideTooltips();
}
private onStageMouseUp(data: IInputEventData): void {
this._inputProcessor.onMouseUp(data);
}
private onStageMouseMove(data: IInputEventData): void {
this._inputProcessor.onMouseMove(data);
}
private onStageWheel(data: IInputEventData): void {
this._inputProcessor.onMouseWheel(data);
}
private onStageResize(): void {
const stage = Stage.inst;
this.setSize(stage.designWidth, stage.designHeight);
}
private isAncestorOf(obj: GObject, ancestor: GObject): boolean {
let p: GObject | null = obj;
while (p) {
if (p === ancestor) return true;
p = p.parent;
}
return false;
}
/**
* Hit test at position
* 位置碰撞检测
*/
public hitTest(stageX: number, stageY: number): GObject | null {
return this._inputProcessor.hitTest(stageX, stageY);
}
// Drag and drop | 拖放
/**
* Start dragging a source object
* 开始拖拽源对象
*/
public startDragSource(source: GObject): void {
GObject.draggingObject = source;
}
/**
* Stop dragging
* 停止拖拽
*/
public stopDragSource(): void {
GObject.draggingObject = null;
}
// Window management | 窗口管理
/**
* Show window
* 显示窗口
*/
public showWindow(win: GObject): void {
this.addChild(win);
this.adjustModalLayer();
}
/**
* Hide window immediately
* 立即隐藏窗口
*/
public hideWindowImmediately(win: GObject): void {
if (win.parent === this) {
this.removeChild(win);
}
this.adjustModalLayer();
}
/**
* Bring window to front
* 将窗口置于最前
*/
public bringToFront(win: GObject): void {
const cnt = this.numChildren;
let i: number;
if (this._modalLayer && this._modalLayer.parent === this) {
i = this.getChildIndex(this._modalLayer);
} else {
i = cnt - 1;
}
const index = this.getChildIndex(win);
if (index < i) {
this.setChildIndex(win, i);
}
}
/**
* Get top window
* 获取最上层窗口
*/
public getTopWindow(): GObject | null {
const cnt = this.numChildren;
for (let i = cnt - 1; i >= 0; i--) {
const child = this.getChildAt(i);
if (child !== this._modalLayer) {
return child;
}
}
return null;
}
// Update | 更新
/**
* Update GRoot (called each frame by ECS system)
* 更新 GRoot每帧由 ECS 系统调用)
*/
public update(): void {
// Update timers
// Update transitions
// Update scroll panes
}
// Disposal | 销毁
public dispose(): void {
const stage = Stage.inst;
stage.off('mousedown', this.onStageMouseDown);
stage.off('mouseup', this.onStageMouseUp);
stage.off('mousemove', this.onStageMouseMove);
stage.off('wheel', this.onStageWheel);
stage.off('resize', this.onStageResize);
this._inputProcessor.dispose();
if (GRoot._inst === this) {
GRoot._inst = null;
}
super.dispose();
}
// Render | 渲染
public collectRenderData(collector: IRenderCollector): void {
super.collectRenderData(collector);
}
}
/**
* InputProcessor
*
* Handles input event processing and dispatching.
*
* 处理输入事件的处理和分发
*/
class InputProcessor {
private _root: GRoot;
private _touchTarget: GObject | null = null;
private _rollOverTarget: GObject | null = null;
constructor(root: GRoot) {
this._root = root;
}
public hitTest(stageX: number, stageY: number): GObject | null {
return this.hitTestInChildren(this._root, stageX, stageY);
}
private hitTestInChildren(container: GComponent, stageX: number, stageY: number): GObject | null {
const count = container.numChildren;
for (let i = count - 1; i >= 0; i--) {
const child = container.getChildAt(i);
if (!child.visible || !child.touchable) continue;
const local = child.globalToLocal(stageX, stageY);
if (local.x >= 0 && local.x < child.width && local.y >= 0 && local.y < child.height) {
if (child instanceof GComponent) {
const deeper = this.hitTestInChildren(child, stageX, stageY);
if (deeper) return deeper;
}
return child;
}
}
return null;
}
public onMouseDown(data: IInputEventData): void {
this._touchTarget = this.hitTest(data.stageX, data.stageY);
if (this._touchTarget) {
this._root.focus = this._touchTarget;
this._touchTarget.emit(FGUIEvents.TOUCH_BEGIN, data);
}
}
public onMouseUp(data: IInputEventData): void {
if (this._touchTarget) {
const target = this.hitTest(data.stageX, data.stageY);
this._touchTarget.emit(FGUIEvents.TOUCH_END, data);
if (target === this._touchTarget) {
this._touchTarget.emit(FGUIEvents.CLICK, data);
}
this._touchTarget = null;
}
}
public onMouseMove(data: IInputEventData): void {
const target = this.hitTest(data.stageX, data.stageY);
// Handle roll over/out
if (target !== this._rollOverTarget) {
if (this._rollOverTarget) {
this._rollOverTarget.emit(FGUIEvents.ROLL_OUT, data);
}
this._rollOverTarget = target;
if (this._rollOverTarget) {
this._rollOverTarget.emit(FGUIEvents.ROLL_OVER, data);
}
}
// Handle touch move
if (this._touchTarget) {
this._touchTarget.emit(FGUIEvents.TOUCH_MOVE, data);
}
}
public onMouseWheel(data: IInputEventData): void {
const target = this.hitTest(data.stageX, data.stageY);
if (target) {
target.emit('wheel', data);
}
}
public dispose(): void {
this._touchTarget = null;
this._rollOverTarget = null;
}
}

View File

@@ -0,0 +1,268 @@
/**
* Service identifier type
* 服务标识类型
*/
export type ServiceIdentifier<T = unknown> = abstract new (...args: never[]) => T;
/**
* Service factory function
* 服务工厂函数
*/
export type ServiceFactory<T> = (container: ServiceContainer) => T;
/**
* Service lifecycle
* 服务生命周期
*/
export const enum EServiceLifecycle {
/** Single instance shared across all resolutions | 单例模式 */
Singleton = 'singleton',
/** New instance per resolution | 每次解析创建新实例 */
Transient = 'transient'
}
/**
* Service registration info
* 服务注册信息
*/
interface ServiceRegistration<T = unknown> {
factory: ServiceFactory<T>;
lifecycle: EServiceLifecycle;
instance?: T;
}
/**
* ServiceContainer
*
* Lightweight dependency injection container for FairyGUI.
*
* 轻量级依赖注入容器
*
* Features:
* - Singleton and transient lifecycles
* - Factory-based registration
* - Type-safe resolution
* - Circular dependency detection
*
* @example
* ```typescript
* const container = new ServiceContainer();
*
* // Register singleton
* container.registerSingleton(AudioService, () => new AudioService());
*
* // Register with dependencies
* container.registerSingleton(UIManager, (c) => new UIManager(
* c.resolve(AudioService)
* ));
*
* // Resolve
* const uiManager = container.resolve(UIManager);
* ```
*/
export class ServiceContainer {
private _registrations: Map<ServiceIdentifier, ServiceRegistration> = new Map();
private _resolving: Set<ServiceIdentifier> = new Set();
private _disposed: boolean = false;
/**
* Register a singleton service
* 注册单例服务
*/
public registerSingleton<T>(
identifier: ServiceIdentifier<T>,
factory: ServiceFactory<T>
): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory,
lifecycle: EServiceLifecycle.Singleton
});
return this;
}
/**
* Register a singleton instance directly
* 直接注册单例实例
*/
public registerInstance<T>(identifier: ServiceIdentifier<T>, instance: T): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory: () => instance,
lifecycle: EServiceLifecycle.Singleton,
instance
});
return this;
}
/**
* Register a transient service (new instance per resolution)
* 注册瞬时服务(每次解析创建新实例)
*/
public registerTransient<T>(
identifier: ServiceIdentifier<T>,
factory: ServiceFactory<T>
): this {
this.checkDisposed();
this._registrations.set(identifier, {
factory,
lifecycle: EServiceLifecycle.Transient
});
return this;
}
/**
* Resolve a service
* 解析服务
*/
public resolve<T>(identifier: ServiceIdentifier<T>): T {
this.checkDisposed();
const registration = this._registrations.get(identifier);
if (!registration) {
throw new Error(`Service not registered: ${identifier.name}`);
}
// Check for circular dependency
if (this._resolving.has(identifier)) {
throw new Error(`Circular dependency detected: ${identifier.name}`);
}
// Return cached singleton if available
if (registration.lifecycle === EServiceLifecycle.Singleton && registration.instance !== undefined) {
return registration.instance as T;
}
// Resolve
this._resolving.add(identifier);
try {
const instance = registration.factory(this) as T;
if (registration.lifecycle === EServiceLifecycle.Singleton) {
registration.instance = instance;
}
return instance;
} finally {
this._resolving.delete(identifier);
}
}
/**
* Try to resolve a service, returns null if not found
* 尝试解析服务,未找到时返回 null
*/
public tryResolve<T>(identifier: ServiceIdentifier<T>): T | null {
if (!this._registrations.has(identifier)) {
return null;
}
return this.resolve(identifier);
}
/**
* Check if a service is registered
* 检查服务是否已注册
*/
public isRegistered<T>(identifier: ServiceIdentifier<T>): boolean {
return this._registrations.has(identifier);
}
/**
* Unregister a service
* 取消注册服务
*/
public unregister<T>(identifier: ServiceIdentifier<T>): boolean {
const registration = this._registrations.get(identifier);
if (registration) {
// Dispose singleton if it has dispose method
if (registration.instance && typeof (registration.instance as IDisposable).dispose === 'function') {
(registration.instance as IDisposable).dispose();
}
this._registrations.delete(identifier);
return true;
}
return false;
}
/**
* Create a child container that inherits registrations
* 创建继承注册的子容器
*/
public createChild(): ServiceContainer {
const child = new ServiceContainer();
// Copy registrations (singletons are shared)
for (const [id, reg] of this._registrations) {
child._registrations.set(id, { ...reg });
}
return child;
}
/**
* Dispose the container and all singleton instances
* 销毁容器和所有单例实例
*/
public dispose(): void {
if (this._disposed) return;
for (const registration of this._registrations.values()) {
if (registration.instance && typeof (registration.instance as IDisposable).dispose === 'function') {
(registration.instance as IDisposable).dispose();
}
}
this._registrations.clear();
this._resolving.clear();
this._disposed = true;
}
private checkDisposed(): void {
if (this._disposed) {
throw new Error('ServiceContainer has been disposed');
}
}
}
/**
* Disposable interface
* 可销毁接口
*/
interface IDisposable {
dispose(): void;
}
/**
* Global service container instance
* 全局服务容器实例
*/
let _globalContainer: ServiceContainer | null = null;
/**
* Get global service container
* 获取全局服务容器
*/
export function getGlobalContainer(): ServiceContainer {
if (!_globalContainer) {
_globalContainer = new ServiceContainer();
}
return _globalContainer;
}
/**
* Set global service container
* 设置全局服务容器
*/
export function setGlobalContainer(container: ServiceContainer): void {
_globalContainer = container;
}
/**
* Inject decorator marker (for future decorator support)
* 注入装饰器标记(用于未来装饰器支持)
*/
export function Inject<T>(identifier: ServiceIdentifier<T>): PropertyDecorator {
return (_target: object, _propertyKey: string | symbol) => {
// Store metadata for future use
// This is a placeholder for decorator-based injection
void identifier;
};
}

View File

@@ -0,0 +1,353 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { IInputEventData, createInputEventData } from '../events/Events';
/**
* Stage
*
* Represents the root container and manages input events.
*
* 表示根容器并管理输入事件
*/
export class Stage extends EventDispatcher {
private static _inst: Stage | null = null;
/** Stage width | 舞台宽度 */
public width: number = 800;
/** Stage height | 舞台高度 */
public height: number = 600;
/** Current mouse/touch X position | 当前鼠标/触摸 X 坐标 */
public mouseX: number = 0;
/** Current mouse/touch Y position | 当前鼠标/触摸 Y 坐标 */
public mouseY: number = 0;
/** Design width | 设计宽度 */
public designWidth: number = 1920;
/** Design height | 设计高度 */
public designHeight: number = 1080;
/** Scale mode | 缩放模式 */
public scaleMode: EScaleMode = EScaleMode.ShowAll;
/** Align mode | 对齐模式 */
public alignH: EAlignMode = EAlignMode.Center;
public alignV: EAlignMode = EAlignMode.Middle;
/** Is touch/pointer down | 是否按下 */
public isTouchDown: boolean = false;
/** Current touch ID | 当前触摸 ID */
public touchId: number = 0;
private _canvas: HTMLCanvasElement | null = null;
private _inputData: IInputEventData;
private _scaleX: number = 1;
private _scaleY: number = 1;
private _offsetX: number = 0;
private _offsetY: number = 0;
private constructor() {
super();
this._inputData = createInputEventData();
}
/**
* Get singleton instance
* 获取单例实例
*/
public static get inst(): Stage {
if (!Stage._inst) {
Stage._inst = new Stage();
}
return Stage._inst;
}
/**
* Bind stage to a canvas element
* 绑定舞台到画布元素
*
* @param canvas HTMLCanvasElement to bind | 要绑定的画布元素
*/
public bindToCanvas(canvas: HTMLCanvasElement): void {
if (this._canvas) {
this.unbindCanvas();
}
this._canvas = canvas;
this.updateSize();
this.bindEvents();
}
/**
* Unbind from current canvas
* 解绑当前画布
*/
public unbindCanvas(): void {
if (!this._canvas) return;
this._canvas.removeEventListener('mousedown', this.handleMouseDown);
this._canvas.removeEventListener('mouseup', this.handleMouseUp);
this._canvas.removeEventListener('mousemove', this.handleMouseMove);
this._canvas.removeEventListener('wheel', this.handleWheel);
this._canvas.removeEventListener('touchstart', this.handleTouchStart);
this._canvas.removeEventListener('touchend', this.handleTouchEnd);
this._canvas.removeEventListener('touchmove', this.handleTouchMove);
this._canvas.removeEventListener('touchcancel', this.handleTouchEnd);
this._canvas = null;
}
/**
* Update stage size from canvas
* 从画布更新舞台尺寸
*/
public updateSize(): void {
if (!this._canvas) return;
this.width = this._canvas.width;
this.height = this._canvas.height;
this.updateScale();
this.emit('resize', { width: this.width, height: this.height });
}
/**
* Set design size
* 设置设计尺寸
*/
public setDesignSize(width: number, height: number): void {
this.designWidth = width;
this.designHeight = height;
this.updateScale();
}
private updateScale(): void {
const scaleX = this.width / this.designWidth;
const scaleY = this.height / this.designHeight;
switch (this.scaleMode) {
case EScaleMode.ShowAll:
this._scaleX = this._scaleY = Math.min(scaleX, scaleY);
break;
case EScaleMode.NoBorder:
this._scaleX = this._scaleY = Math.max(scaleX, scaleY);
break;
case EScaleMode.ExactFit:
this._scaleX = scaleX;
this._scaleY = scaleY;
break;
case EScaleMode.FixedWidth:
this._scaleX = this._scaleY = scaleX;
break;
case EScaleMode.FixedHeight:
this._scaleX = this._scaleY = scaleY;
break;
case EScaleMode.NoScale:
default:
this._scaleX = this._scaleY = 1;
break;
}
const actualWidth = this.designWidth * this._scaleX;
const actualHeight = this.designHeight * this._scaleY;
switch (this.alignH) {
case EAlignMode.Left:
this._offsetX = 0;
break;
case EAlignMode.Right:
this._offsetX = this.width - actualWidth;
break;
case EAlignMode.Center:
default:
this._offsetX = (this.width - actualWidth) / 2;
break;
}
switch (this.alignV) {
case EAlignMode.Top:
this._offsetY = 0;
break;
case EAlignMode.Bottom:
this._offsetY = this.height - actualHeight;
break;
case EAlignMode.Middle:
default:
this._offsetY = (this.height - actualHeight) / 2;
break;
}
}
/**
* Convert screen coordinates to stage coordinates
* 将屏幕坐标转换为舞台坐标
*/
public screenToStage(screenX: number, screenY: number): { x: number; y: number } {
return {
x: (screenX - this._offsetX) / this._scaleX,
y: (screenY - this._offsetY) / this._scaleY
};
}
/**
* Convert stage coordinates to screen coordinates
* 将舞台坐标转换为屏幕坐标
*/
public stageToScreen(stageX: number, stageY: number): { x: number; y: number } {
return {
x: stageX * this._scaleX + this._offsetX,
y: stageY * this._scaleY + this._offsetY
};
}
private bindEvents(): void {
if (!this._canvas) return;
this._canvas.addEventListener('mousedown', this.handleMouseDown);
this._canvas.addEventListener('mouseup', this.handleMouseUp);
this._canvas.addEventListener('mousemove', this.handleMouseMove);
this._canvas.addEventListener('wheel', this.handleWheel);
this._canvas.addEventListener('touchstart', this.handleTouchStart, { passive: false });
this._canvas.addEventListener('touchend', this.handleTouchEnd);
this._canvas.addEventListener('touchmove', this.handleTouchMove, { passive: false });
this._canvas.addEventListener('touchcancel', this.handleTouchEnd);
}
private getCanvasPosition(e: MouseEvent | Touch): { x: number; y: number } {
if (!this._canvas) return { x: 0, y: 0 };
const rect = this._canvas.getBoundingClientRect();
const scaleX = this._canvas.width / rect.width;
const scaleY = this._canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
private updateInputData(e: MouseEvent | Touch, type: string): void {
const pos = this.getCanvasPosition(e);
const stagePos = this.screenToStage(pos.x, pos.y);
this._inputData.stageX = stagePos.x;
this._inputData.stageY = stagePos.y;
this.mouseX = stagePos.x;
this.mouseY = stagePos.y;
if (e instanceof MouseEvent) {
this._inputData.button = e.button;
this._inputData.ctrlKey = e.ctrlKey;
this._inputData.shiftKey = e.shiftKey;
this._inputData.altKey = e.altKey;
this._inputData.nativeEvent = e;
} else {
this._inputData.touchId = e.identifier;
this.touchId = e.identifier;
}
}
private handleMouseDown = (e: MouseEvent): void => {
this.updateInputData(e, 'mousedown');
this.isTouchDown = true;
this._inputData.touchId = 0;
this.emit('mousedown', this._inputData);
};
private handleMouseUp = (e: MouseEvent): void => {
this.updateInputData(e, 'mouseup');
this.isTouchDown = false;
this.emit('mouseup', this._inputData);
};
private handleMouseMove = (e: MouseEvent): void => {
this.updateInputData(e, 'mousemove');
this.emit('mousemove', this._inputData);
};
private handleWheel = (e: WheelEvent): void => {
this.updateInputData(e, 'wheel');
this._inputData.wheelDelta = e.deltaY;
this._inputData.nativeEvent = e;
this.emit('wheel', this._inputData);
};
private handleTouchStart = (e: TouchEvent): void => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
this.updateInputData(touch, 'touchstart');
this.isTouchDown = true;
this.emit('mousedown', this._inputData);
}
};
private handleTouchEnd = (e: TouchEvent): void => {
if (e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
this.updateInputData(touch, 'touchend');
this.isTouchDown = false;
this.emit('mouseup', this._inputData);
}
};
private handleTouchMove = (e: TouchEvent): void => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
this.updateInputData(touch, 'touchmove');
this.emit('mousemove', this._inputData);
}
};
public get scaleX(): number {
return this._scaleX;
}
public get scaleY(): number {
return this._scaleY;
}
public get offsetX(): number {
return this._offsetX;
}
public get offsetY(): number {
return this._offsetY;
}
}
/**
* Scale mode enum
* 缩放模式枚举
*/
export const enum EScaleMode {
/** No scaling | 不缩放 */
NoScale = 'noscale',
/** Show all content (letterbox) | 显示全部内容(黑边) */
ShowAll = 'showall',
/** Fill screen, clip content | 填充屏幕,裁剪内容 */
NoBorder = 'noborder',
/** Stretch to fit | 拉伸适应 */
ExactFit = 'exactfit',
/** Fixed width, height scales | 固定宽度,高度缩放 */
FixedWidth = 'fixedwidth',
/** Fixed height, width scales | 固定高度,宽度缩放 */
FixedHeight = 'fixedheight'
}
/**
* Align mode enum
* 对齐模式枚举
*/
export const enum EAlignMode {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom'
}

View File

@@ -0,0 +1,266 @@
/**
* Timer callback info
* 定时器回调信息
*/
interface TimerCallback {
id: number;
caller: any;
callback: Function;
interval: number;
elapsed: number;
repeat: boolean;
removed: boolean;
}
/**
* Call later callback info
* 延迟调用回调信息
*/
interface CallLaterItem {
caller: any;
callback: Function;
}
/**
* Timer
*
* Provides timing and scheduling functionality.
*
* 提供计时和调度功能
*/
export class Timer {
private static _inst: Timer | null = null;
/** Frame delta time in milliseconds | 帧间隔时间(毫秒) */
public delta: number = 0;
/** Current time in milliseconds | 当前时间(毫秒) */
public currentTime: number = 0;
/** Frame count | 帧数 */
public frameCount: number = 0;
private _callbacks: Map<number, TimerCallback> = new Map();
private _callLaterList: CallLaterItem[] = [];
private _callLaterPending: CallLaterItem[] = [];
private _nextId: number = 1;
private _updating: boolean = false;
private constructor() {
this.currentTime = performance.now();
}
/**
* Get singleton instance
* 获取单例实例
*/
public static get inst(): Timer {
if (!Timer._inst) {
Timer._inst = new Timer();
}
return Timer._inst;
}
/**
* Get current time (static shortcut)
* 获取当前时间(静态快捷方式)
*/
public static get time(): number {
return Timer.inst.currentTime;
}
/**
* Add a callback to be called each frame
* 添加每帧调用的回调
*/
public static add(callback: Function, caller: any): void {
Timer.inst.frameLoop(1, caller, callback);
}
/**
* Remove a callback
* 移除回调
*/
public static remove(callback: Function, caller: any): void {
Timer.inst.clear(caller, callback);
}
/**
* Update timer (called by ECS system each frame)
* 更新定时器(每帧由 ECS 系统调用)
*
* @param deltaMs Delta time in milliseconds | 间隔时间(毫秒)
*/
public update(deltaMs: number): void {
this.delta = deltaMs;
this.currentTime += deltaMs;
this.frameCount++;
this._updating = true;
// Process timers
for (const callback of this._callbacks.values()) {
if (callback.removed) continue;
callback.elapsed += deltaMs;
if (callback.elapsed >= callback.interval) {
callback.callback.call(callback.caller);
if (callback.repeat) {
callback.elapsed = 0;
} else {
callback.removed = true;
}
}
}
// Clean up removed callbacks
for (const [id, callback] of this._callbacks) {
if (callback.removed) {
this._callbacks.delete(id);
}
}
// Process callLater
const pending = this._callLaterList;
this._callLaterList = this._callLaterPending;
this._callLaterPending = [];
for (const item of pending) {
item.callback.call(item.caller);
}
pending.length = 0;
this._callLaterList = pending;
this._updating = false;
}
/**
* Execute callback after specified delay (one time)
* 延迟执行回调(一次)
*
* @param delay Delay in milliseconds | 延迟时间(毫秒)
* @param caller Callback context | 回调上下文
* @param callback Callback function | 回调函数
*/
public once(delay: number, caller: any, callback: Function): void {
this.addCallback(delay, caller, callback, false);
}
/**
* Execute callback repeatedly at interval
* 按间隔重复执行回调
*
* @param interval Interval in milliseconds | 间隔时间(毫秒)
* @param caller Callback context | 回调上下文
* @param callback Callback function | 回调函数
*/
public loop(interval: number, caller: any, callback: Function): void {
this.addCallback(interval, caller, callback, true);
}
/**
* Execute callback every frame
* 每帧执行回调
*
* @param interval Frame interval (1 = every frame) | 帧间隔
* @param caller Callback context | 回调上下文
* @param callback Callback function | 回调函数
*/
public frameLoop(interval: number, caller: any, callback: Function): void {
this.loop(interval * 16.67, caller, callback);
}
/**
* Execute callback at the end of current frame
* 在当前帧结束时执行回调
*
* @param caller Callback context | 回调上下文
* @param callback Callback function | 回调函数
*/
public callLater(caller: any, callback: Function): void {
const list = this._updating ? this._callLaterPending : this._callLaterList;
const exists = list.some(
(item) => item.caller === caller && item.callback === callback
);
if (!exists) {
list.push({ caller, callback });
}
}
/**
* Clear a specific callback
* 清除指定回调
*
* @param caller Callback context | 回调上下文
* @param callback Callback function | 回调函数
*/
public clear(caller: any, callback: Function): void {
for (const cb of this._callbacks.values()) {
if (cb.caller === caller && cb.callback === callback) {
cb.removed = true;
}
}
this._callLaterList = this._callLaterList.filter(
(item) => !(item.caller === caller && item.callback === callback)
);
this._callLaterPending = this._callLaterPending.filter(
(item) => !(item.caller === caller && item.callback === callback)
);
}
/**
* Clear all callbacks for a caller
* 清除指定对象的所有回调
*
* @param caller Callback context | 回调上下文
*/
public clearAll(caller: any): void {
for (const cb of this._callbacks.values()) {
if (cb.caller === caller) {
cb.removed = true;
}
}
this._callLaterList = this._callLaterList.filter(
(item) => item.caller !== caller
);
this._callLaterPending = this._callLaterPending.filter(
(item) => item.caller !== caller
);
}
private addCallback(
interval: number,
caller: any,
callback: Function,
repeat: boolean
): void {
this.clear(caller, callback);
const id = this._nextId++;
this._callbacks.set(id, {
id,
caller,
callback,
interval,
elapsed: 0,
repeat,
removed: false
});
}
/**
* Dispose the timer
* 销毁定时器
*/
public dispose(): void {
this._callbacks.clear();
this._callLaterList.length = 0;
this._callLaterPending.length = 0;
}
}

View File

@@ -0,0 +1,859 @@
import { EventDispatcher } from '../events/EventDispatcher';
import type { GComponent } from './GComponent';
import type { GObject } from './GObject';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import { EEaseType } from '../tween/EaseType';
import { ByteBuffer } from '../utils/ByteBuffer';
import type { SimpleHandler } from '../display/MovieClip';
/**
* Transition action types
* 过渡动画动作类型
*/
export const enum ETransitionActionType {
XY = 0,
Size = 1,
Scale = 2,
Pivot = 3,
Alpha = 4,
Rotation = 5,
Color = 6,
Animation = 7,
Visible = 8,
Sound = 9,
Transition = 10,
Shake = 11,
ColorFilter = 12,
Skew = 13,
Text = 14,
Icon = 15,
Unknown = 16
}
/**
* Transition item value
* 过渡项值
*/
interface ITransitionValue {
f1?: number;
f2?: number;
f3?: number;
f4?: number;
b1?: boolean;
b2?: boolean;
b3?: boolean;
visible?: boolean;
playing?: boolean;
frame?: number;
sound?: string;
volume?: number;
transName?: string;
playTimes?: number;
trans?: Transition;
stopTime?: number;
amplitude?: number;
duration?: number;
offsetX?: number;
offsetY?: number;
lastOffsetX?: number;
lastOffsetY?: number;
text?: string;
audioClip?: string;
flag?: boolean;
}
/**
* Tween config
* 补间配置
*/
interface ITweenConfig {
duration: number;
easeType: EEaseType;
repeat: number;
yoyo: boolean;
startValue: ITransitionValue;
endValue: ITransitionValue;
endLabel?: string;
endHook?: SimpleHandler;
}
/**
* Transition item
* 过渡项
*/
interface ITransitionItem {
time: number;
targetId: string;
type: ETransitionActionType;
tweenConfig?: ITweenConfig;
label?: string;
value: ITransitionValue;
hook?: SimpleHandler;
tweener?: GTweener;
target?: GObject;
displayLockToken: number;
}
/** Options flags */
const OPTION_AUTO_STOP_DISABLED = 2;
const OPTION_AUTO_STOP_AT_END = 4;
/**
* Transition
*
* Animation transition system for UI components.
* Supports keyframe animations, tweening, and chained transitions.
*
* UI 组件的动画过渡系统,支持关键帧动画、补间和链式过渡
*/
export class Transition extends EventDispatcher {
/** Transition name | 过渡动画名称 */
public name: string = '';
private _owner: GComponent;
private _ownerBaseX: number = 0;
private _ownerBaseY: number = 0;
private _items: ITransitionItem[] = [];
private _totalTimes: number = 0;
private _totalTasks: number = 0;
private _playing: boolean = false;
private _paused: boolean = false;
private _onComplete: SimpleHandler | null = null;
private _options: number = 0;
private _reversed: boolean = false;
private _totalDuration: number = 0;
private _autoPlay: boolean = false;
private _autoPlayTimes: number = 1;
private _autoPlayDelay: number = 0;
private _timeScale: number = 1;
private _startTime: number = 0;
private _endTime: number = -1;
constructor(owner: GComponent) {
super();
this._owner = owner;
}
public get owner(): GComponent {
return this._owner;
}
public get playing(): boolean {
return this._playing;
}
public get autoPlay(): boolean {
return this._autoPlay;
}
public set autoPlay(value: boolean) {
this.setAutoPlay(value, this._autoPlayTimes, this._autoPlayDelay);
}
public get autoPlayRepeat(): number {
return this._autoPlayTimes;
}
public get autoPlayDelay(): number {
return this._autoPlayDelay;
}
public get timeScale(): number {
return this._timeScale;
}
public set timeScale(value: number) {
if (this._timeScale !== value) {
this._timeScale = value;
if (this._playing) {
for (const item of this._items) {
if (item.tweener) {
item.tweener.setTimeScale(value);
} else if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.timeScale = value;
}
}
}
}
}
public play(
onComplete?: SimpleHandler,
times: number = 1,
delay: number = 0,
startTime: number = 0,
endTime: number = -1
): void {
this._play(onComplete || null, times, delay, startTime, endTime, false);
}
public playReverse(
onComplete?: SimpleHandler,
times: number = 1,
delay: number = 0,
startTime: number = 0,
endTime: number = -1
): void {
this._play(onComplete || null, times, delay, startTime, endTime, true);
}
public changePlayTimes(value: number): void {
this._totalTimes = value;
}
public setAutoPlay(value: boolean, times: number = -1, delay: number = 0): void {
if (this._autoPlay !== value) {
this._autoPlay = value;
this._autoPlayTimes = times;
this._autoPlayDelay = delay;
if (this._autoPlay) {
if (this._owner.onStage) {
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
}
} else {
if (!this._owner.onStage) {
this.stop(false, true);
}
}
}
}
public _play(
onComplete: SimpleHandler | null,
times: number,
delay: number,
startTime: number,
endTime: number,
reversed: boolean
): void {
this.stop(true, true);
this._totalTimes = times;
this._reversed = reversed;
this._startTime = startTime;
this._endTime = endTime;
this._playing = true;
this._paused = false;
this._onComplete = onComplete;
for (const item of this._items) {
if (!item.target) {
if (item.targetId) {
item.target = this._owner.getChildById(item.targetId) ?? undefined;
} else {
item.target = this._owner;
}
} else if (item.target !== this._owner && item.target.parent !== this._owner) {
item.target = undefined;
}
if (item.target && item.type === ETransitionActionType.Transition) {
let trans = (item.target as GComponent).getTransition(item.value.transName || '');
if (trans === this) trans = null;
if (trans) {
if (item.value.playTimes === 0) {
for (let j = this._items.indexOf(item) - 1; j >= 0; j--) {
const item2 = this._items[j];
if (item2.type === ETransitionActionType.Transition && item2.value.trans === trans) {
item2.value.stopTime = item.time - item2.time;
trans = null;
break;
}
}
if (trans) item.value.stopTime = 0;
} else {
item.value.stopTime = -1;
}
}
item.value.trans = trans ?? undefined;
}
}
if (delay === 0) {
this.onDelayedPlay();
} else {
GTween.delayedCall(delay).setTarget(this).onComplete(() => this.onDelayedPlay());
}
}
public stop(bSetToComplete: boolean = true, bProcessCallback: boolean = false): void {
if (!this._playing) return;
this._playing = false;
this._totalTasks = 0;
this._totalTimes = 0;
const handler = this._onComplete;
this._onComplete = null;
GTween.kill(this);
const cnt = this._items.length;
if (this._reversed) {
for (let i = cnt - 1; i >= 0; i--) {
const item = this._items[i];
if (item.target) this.stopItem(item, bSetToComplete);
}
} else {
for (let i = 0; i < cnt; i++) {
const item = this._items[i];
if (item.target) this.stopItem(item, bSetToComplete);
}
}
if (bProcessCallback && handler) {
if (typeof handler === 'function') handler();
else if (typeof handler.run === 'function') handler.run();
}
}
private stopItem(item: ITransitionItem, bSetToComplete: boolean): void {
if (item.tweener) {
item.tweener.kill(bSetToComplete);
item.tweener = undefined;
if (item.type === ETransitionActionType.Shake && !bSetToComplete && item.target) {
item.target.x -= item.value.lastOffsetX || 0;
item.target.y -= item.value.lastOffsetY || 0;
}
}
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.stop(bSetToComplete, false);
}
}
public pause(): void {
if (!this._playing || this._paused) return;
this._paused = true;
const tweener = GTween.getTween(this);
if (tweener) tweener.setPaused(true);
for (const item of this._items) {
if (!item.target) continue;
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.pause();
}
if (item.tweener) item.tweener.setPaused(true);
}
}
public resume(): void {
if (!this._playing || !this._paused) return;
this._paused = false;
const tweener = GTween.getTween(this);
if (tweener) tweener.setPaused(false);
for (const item of this._items) {
if (!item.target) continue;
if (item.type === ETransitionActionType.Transition && item.value.trans) {
item.value.trans.resume();
}
if (item.tweener) item.tweener.setPaused(false);
}
}
public setValue(label: string, ...values: any[]): void {
for (const item of this._items) {
if (item.label === label) {
const value = item.tweenConfig ? item.tweenConfig.startValue : item.value;
this.setItemValue(item.type, value, values);
return;
} else if (item.tweenConfig?.endLabel === label) {
this.setItemValue(item.type, item.tweenConfig.endValue, values);
return;
}
}
}
private setItemValue(type: ETransitionActionType, value: ITransitionValue, args: any[]): void {
switch (type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Pivot:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
value.b1 = value.b2 = true;
value.f1 = parseFloat(args[0]);
value.f2 = parseFloat(args[1]);
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
case ETransitionActionType.Color:
value.f1 = parseFloat(args[0]);
break;
case ETransitionActionType.Animation:
value.frame = parseInt(args[0]);
if (args.length > 1) value.playing = args[1];
break;
case ETransitionActionType.Visible:
value.visible = args[0];
break;
case ETransitionActionType.Sound:
value.sound = args[0];
if (args.length > 1) value.volume = parseFloat(args[1]);
break;
case ETransitionActionType.Transition:
value.transName = args[0];
if (args.length > 1) value.playTimes = parseInt(args[1]);
break;
case ETransitionActionType.Shake:
value.amplitude = parseFloat(args[0]);
if (args.length > 1) value.duration = parseFloat(args[1]);
break;
case ETransitionActionType.ColorFilter:
value.f1 = parseFloat(args[0]);
value.f2 = parseFloat(args[1]);
value.f3 = parseFloat(args[2]);
value.f4 = parseFloat(args[3]);
break;
case ETransitionActionType.Text:
case ETransitionActionType.Icon:
value.text = args[0];
break;
}
}
public setTarget(label: string, target: GObject): void {
for (const item of this._items) {
if (item.label === label) {
item.targetId = target.id;
item.target = target;
return;
}
}
}
public setHook(label: string, callback: SimpleHandler): void {
for (const item of this._items) {
if (item.label === label) {
item.hook = callback;
return;
} else if (item.tweenConfig?.endLabel === label) {
item.tweenConfig.endHook = callback;
return;
}
}
}
public clearHooks(): void {
for (const item of this._items) {
item.hook = undefined;
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
}
}
public onOwnerAddedToStage(): void {
if (this._autoPlay && !this._playing) {
this.play(undefined, this._autoPlayTimes, this._autoPlayDelay);
}
}
public onOwnerRemovedFromStage(): void {
if ((this._options & OPTION_AUTO_STOP_DISABLED) === 0) {
this.stop((this._options & OPTION_AUTO_STOP_AT_END) !== 0, false);
}
}
private onDelayedPlay(): void {
this._ownerBaseX = this._owner.x;
this._ownerBaseY = this._owner.y;
this._totalTasks = 1;
const cnt = this._items.length;
for (let i = this._reversed ? cnt - 1 : 0; this._reversed ? i >= 0 : i < cnt; this._reversed ? i-- : i++) {
const item = this._items[i];
if (item.target) this.playItem(item);
}
this._totalTasks--;
this.checkAllComplete();
}
private playItem(item: ITransitionItem): void {
let time: number;
if (item.tweenConfig) {
time = this._reversed
? this._totalDuration - item.time - item.tweenConfig.duration
: item.time;
if (this._endTime === -1 || time < this._endTime) {
const startValue = this._reversed ? item.tweenConfig.endValue : item.tweenConfig.startValue;
const endValue = this._reversed ? item.tweenConfig.startValue : item.tweenConfig.endValue;
item.value.b1 = startValue.b1;
item.value.b2 = startValue.b2;
switch (item.type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
item.tweener = GTween.to2(
startValue.f1 || 0, startValue.f2 || 0,
endValue.f1 || 0, endValue.f2 || 0,
item.tweenConfig.duration
);
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
item.tweener = GTween.to(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
break;
case ETransitionActionType.Color:
item.tweener = GTween.toColor(startValue.f1 || 0, endValue.f1 || 0, item.tweenConfig.duration);
break;
case ETransitionActionType.ColorFilter:
item.tweener = GTween.to4(
startValue.f1 || 0, startValue.f2 || 0, startValue.f3 || 0, startValue.f4 || 0,
endValue.f1 || 0, endValue.f2 || 0, endValue.f3 || 0, endValue.f4 || 0,
item.tweenConfig.duration
);
break;
}
if (item.tweener) {
item.tweener
.setDelay(time)
.setEase(item.tweenConfig.easeType)
.setRepeat(item.tweenConfig.repeat, item.tweenConfig.yoyo)
.setTimeScale(this._timeScale)
.setTarget(item)
.onStart(() => this.callHook(item, false))
.onUpdate(() => this.onTweenUpdate(item))
.onComplete(() => this.onTweenComplete(item));
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - time);
this._totalTasks++;
}
}
} else if (item.type === ETransitionActionType.Shake) {
time = this._reversed
? this._totalDuration - item.time - (item.value.duration || 0)
: item.time;
item.value.offsetX = item.value.offsetY = 0;
item.value.lastOffsetX = item.value.lastOffsetY = 0;
item.tweener = GTween.shake(0, 0, item.value.amplitude || 0, item.value.duration || 0)
.setDelay(time)
.setTimeScale(this._timeScale)
.setTarget(item)
.onUpdate(() => this.onTweenUpdate(item))
.onComplete(() => this.onTweenComplete(item));
if (this._endTime >= 0) item.tweener.setBreakpoint(this._endTime - item.time);
this._totalTasks++;
} else {
time = this._reversed ? this._totalDuration - item.time : item.time;
if (time <= this._startTime) {
this.applyValue(item);
this.callHook(item, false);
} else if (this._endTime === -1 || time <= this._endTime) {
this._totalTasks++;
item.tweener = GTween.delayedCall(time)
.setTimeScale(this._timeScale)
.setTarget(item)
.onComplete(() => {
item.tweener = undefined;
this._totalTasks--;
this.applyValue(item);
this.callHook(item, false);
this.checkAllComplete();
});
}
}
}
private onTweenUpdate(item: ITransitionItem): void {
if (!item.tweener) return;
const tweener = item.tweener;
switch (item.type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Scale:
case ETransitionActionType.Skew:
item.value.f1 = tweener.value.x;
item.value.f2 = tweener.value.y;
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
item.value.f1 = tweener.value.x;
break;
case ETransitionActionType.Color:
item.value.f1 = tweener.value.color;
break;
case ETransitionActionType.ColorFilter:
item.value.f1 = tweener.value.x;
item.value.f2 = tweener.value.y;
item.value.f3 = tweener.value.z;
item.value.f4 = tweener.value.w;
break;
case ETransitionActionType.Shake:
item.value.offsetX = tweener.deltaValue.x;
item.value.offsetY = tweener.deltaValue.y;
break;
}
this.applyValue(item);
}
private onTweenComplete(item: ITransitionItem): void {
item.tweener = undefined;
this._totalTasks--;
this.callHook(item, true);
this.checkAllComplete();
}
private checkAllComplete(): void {
if (this._playing && this._totalTasks === 0) {
if (this._totalTimes < 0) {
this.internalPlay();
} else {
this._totalTimes--;
if (this._totalTimes > 0) {
this.internalPlay();
} else {
this._playing = false;
const handler = this._onComplete;
this._onComplete = null;
if (handler) {
if (typeof handler === 'function') handler();
else if (typeof handler.run === 'function') handler.run();
}
}
}
}
}
private internalPlay(): void {
this._ownerBaseX = this._owner.x;
this._ownerBaseY = this._owner.y;
this._totalTasks = 1;
for (const item of this._items) {
if (item.target) this.playItem(item);
}
this._totalTasks--;
}
private callHook(item: ITransitionItem, tweenEnd: boolean): void {
const hook = tweenEnd ? item.tweenConfig?.endHook : item.hook;
if (hook) {
if (typeof hook === 'function') hook();
else if (typeof hook.run === 'function') hook.run();
}
}
private applyValue(item: ITransitionItem): void {
if (!item.target) return;
const value = item.value;
const target = item.target;
switch (item.type) {
case ETransitionActionType.XY:
if (target === this._owner) {
if (value.b1 && value.b2) target.setXY((value.f1 || 0) + this._ownerBaseX, (value.f2 || 0) + this._ownerBaseY);
else if (value.b1) target.x = (value.f1 || 0) + this._ownerBaseX;
else target.y = (value.f2 || 0) + this._ownerBaseY;
} else if (value.b3) {
if (value.b1 && value.b2) target.setXY((value.f1 || 0) * this._owner.width, (value.f2 || 0) * this._owner.height);
else if (value.b1) target.x = (value.f1 || 0) * this._owner.width;
else if (value.b2) target.y = (value.f2 || 0) * this._owner.height;
} else {
if (value.b1 && value.b2) target.setXY(value.f1 || 0, value.f2 || 0);
else if (value.b1) target.x = value.f1 || 0;
else if (value.b2) target.y = value.f2 || 0;
}
break;
case ETransitionActionType.Size:
if (!value.b1) value.f1 = target.width;
if (!value.b2) value.f2 = target.height;
target.setSize(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Pivot:
target.setPivot(value.f1 || 0, value.f2 || 0, target.pivotAsAnchor);
break;
case ETransitionActionType.Alpha:
target.alpha = value.f1 || 0;
break;
case ETransitionActionType.Rotation:
target.rotation = value.f1 || 0;
break;
case ETransitionActionType.Scale:
target.setScale(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Skew:
target.setSkew(value.f1 || 0, value.f2 || 0);
break;
case ETransitionActionType.Visible:
target.visible = value.visible || false;
break;
case ETransitionActionType.Transition:
if (this._playing && value.trans) {
this._totalTasks++;
const startTime = this._startTime > item.time ? this._startTime - item.time : 0;
let endTime = this._endTime >= 0 ? this._endTime - item.time : -1;
if (value.stopTime !== undefined && value.stopTime >= 0 && (endTime < 0 || endTime > value.stopTime)) {
endTime = value.stopTime;
}
value.trans.timeScale = this._timeScale;
value.trans._play(() => { this._totalTasks--; this.checkAllComplete(); }, value.playTimes || 1, 0, startTime, endTime, this._reversed);
}
break;
case ETransitionActionType.Shake:
target.x = target.x - (value.lastOffsetX || 0) + (value.offsetX || 0);
target.y = target.y - (value.lastOffsetY || 0) + (value.offsetY || 0);
value.lastOffsetX = value.offsetX;
value.lastOffsetY = value.offsetY;
break;
case ETransitionActionType.Text:
target.text = value.text || '';
break;
case ETransitionActionType.Icon:
target.icon = value.text || '';
break;
}
}
public setup(buffer: ByteBuffer): void {
this.name = buffer.readS();
this._options = buffer.getInt32();
this._autoPlay = buffer.readBool();
this._autoPlayTimes = buffer.getInt32();
this._autoPlayDelay = buffer.getFloat32();
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
const dataLen = buffer.getInt16();
const curPos = buffer.position;
buffer.seek(curPos, 0);
const item: ITransitionItem = {
type: buffer.readByte() as ETransitionActionType,
time: buffer.getFloat32(),
targetId: '',
value: {},
displayLockToken: 0
};
const targetId = buffer.getInt16();
if (targetId >= 0) {
const child = this._owner.getChildAt(targetId);
item.targetId = child?.id || '';
}
item.label = buffer.readS();
if (buffer.readBool()) {
buffer.seek(curPos, 1);
item.tweenConfig = {
duration: buffer.getFloat32(),
easeType: buffer.readByte() as EEaseType,
repeat: buffer.getInt32(),
yoyo: buffer.readBool(),
startValue: {},
endValue: {},
endLabel: buffer.readS()
};
buffer.seek(curPos, 2);
this.decodeValue(item.type, buffer, item.tweenConfig.startValue);
buffer.seek(curPos, 3);
this.decodeValue(item.type, buffer, item.tweenConfig.endValue);
} else {
buffer.seek(curPos, 2);
this.decodeValue(item.type, buffer, item.value);
}
this._items.push(item);
buffer.position = curPos + dataLen;
}
this._totalDuration = 0;
for (const item of this._items) {
let duration = item.time;
if (item.tweenConfig) duration += item.tweenConfig.duration * (item.tweenConfig.repeat + 1);
else if (item.type === ETransitionActionType.Shake) duration += item.value.duration || 0;
if (duration > this._totalDuration) this._totalDuration = duration;
}
}
private decodeValue(type: ETransitionActionType, buffer: ByteBuffer, value: ITransitionValue): void {
switch (type) {
case ETransitionActionType.XY:
case ETransitionActionType.Size:
case ETransitionActionType.Pivot:
case ETransitionActionType.Skew:
value.b1 = buffer.readBool();
value.b2 = buffer.readBool();
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
if (buffer.version >= 2 && type === ETransitionActionType.XY) value.b3 = buffer.readBool();
break;
case ETransitionActionType.Alpha:
case ETransitionActionType.Rotation:
value.f1 = buffer.getFloat32();
break;
case ETransitionActionType.Scale:
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
break;
case ETransitionActionType.Color:
value.f1 = buffer.readColor();
break;
case ETransitionActionType.Animation:
value.playing = buffer.readBool();
value.frame = buffer.getInt32();
break;
case ETransitionActionType.Visible:
value.visible = buffer.readBool();
break;
case ETransitionActionType.Sound:
value.sound = buffer.readS();
value.volume = buffer.getFloat32();
break;
case ETransitionActionType.Transition:
value.transName = buffer.readS();
value.playTimes = buffer.getInt32();
break;
case ETransitionActionType.Shake:
value.amplitude = buffer.getFloat32();
value.duration = buffer.getFloat32();
break;
case ETransitionActionType.ColorFilter:
value.f1 = buffer.getFloat32();
value.f2 = buffer.getFloat32();
value.f3 = buffer.getFloat32();
value.f4 = buffer.getFloat32();
break;
case ETransitionActionType.Text:
case ETransitionActionType.Icon:
value.text = buffer.readS();
break;
}
}
public dispose(): void {
if (this._playing) GTween.kill(this);
for (const item of this._items) {
if (item.tweener) {
item.tweener.kill();
item.tweener = undefined;
}
item.target = undefined;
item.hook = undefined;
if (item.tweenConfig) item.tweenConfig.endHook = undefined;
}
this._items.length = 0;
super.dispose();
}
}

View File

@@ -0,0 +1,116 @@
/**
* UIConfig
*
* Global configuration for FairyGUI system.
* Centralizes all configurable settings.
*
* FairyGUI 系统的全局配置,集中管理所有可配置项
*/
export const UIConfig = {
/** Default font | 默认字体 */
defaultFont: 'Arial',
/** Default font size | 默认字体大小 */
defaultFontSize: 14,
/** Button sound URL | 按钮声音 URL */
buttonSound: '',
/** Button sound volume scale | 按钮声音音量 */
buttonSoundVolumeScale: 1,
/** Horizontal scrollbar resource | 水平滚动条资源 */
horizontalScrollBar: '',
/** Vertical scrollbar resource | 垂直滚动条资源 */
verticalScrollBar: '',
/** Default scroll step | 默认滚动步进 */
defaultScrollStep: 25,
/** Default touch scroll | 默认触摸滚动 */
defaultTouchScroll: true,
/** Default scroll bounce | 默认滚动回弹 */
defaultScrollBounce: true,
/** Default scroll bar display | 默认滚动条显示 */
defaultScrollBarDisplay: 1,
/** Touch drag sensitivity | 触摸拖拽灵敏度 */
touchDragSensitivity: 10,
/** Click drag sensitivity | 点击拖拽灵敏度 */
clickDragSensitivity: 2,
/** Allow softness on top | 允许顶部弹性 */
allowSoftnessOnTopOrLeftSide: true,
/** Global modal layer resource | 全局模态层资源 */
modalLayerResource: '',
/** Modal layer color | 模态层颜色 */
modalLayerColor: 0x333333,
/** Modal layer alpha | 模态层透明度 */
modalLayerAlpha: 0.4,
/** Popup close on click outside | 点击外部关闭弹窗 */
popupCloseOnClickOutside: true,
/** Branch for resource loading | 资源加载分支 */
branch: '',
/** Loading animation resource | 加载动画资源 */
loadingAnimation: '',
/** Loader error sign resource | 加载器错误标志资源 */
loaderErrorSign: '',
/** Popup menu resource | 弹出菜单资源 */
popupMenu: '',
/** Popup menu separator resource | 弹出菜单分隔符资源 */
popupMenuSeperator: '',
/** Window modal waiting resource | 窗口模态等待资源 */
windowModalWaiting: '',
/** Bring window to front on click | 点击时将窗口置顶 */
bringWindowToFrontOnClick: true
} as const;
/**
* Mutable config type for runtime changes
* 可变配置类型用于运行时修改
*/
export type UIConfigType = {
-readonly [K in keyof typeof UIConfig]: (typeof UIConfig)[K];
};
/** Runtime config instance | 运行时配置实例 */
const _runtimeConfig: UIConfigType = { ...UIConfig };
/**
* Get current config value
* 获取当前配置值
*/
export function getUIConfig<K extends keyof UIConfigType>(key: K): UIConfigType[K] {
return _runtimeConfig[key];
}
/**
* Set config value
* 设置配置值
*/
export function setUIConfig<K extends keyof UIConfigType>(key: K, value: UIConfigType[K]): void {
_runtimeConfig[key] = value;
}
/**
* Reset config to defaults
* 重置配置为默认值
*/
export function resetUIConfig(): void {
Object.assign(_runtimeConfig, UIConfig);
}

View File

@@ -0,0 +1,184 @@
import { GObject } from './GObject';
import { EObjectType } from './FieldTypes';
import type { PackageItem } from '../package/PackageItem';
/**
* Object creator function type
* 对象创建函数类型
*/
export type ObjectCreator = () => GObject;
/**
* Extension creator function type
* 扩展创建函数类型
*/
export type ExtensionCreator = () => GObject;
/**
* UIObjectFactory
*
* Factory for creating FairyGUI objects.
* All object types are registered via registerCreator() to avoid circular dependencies.
*
* FairyGUI 对象工厂,所有对象类型通过 registerCreator() 注册以避免循环依赖
*/
export class UIObjectFactory {
private static _creators: Map<EObjectType, ObjectCreator> = new Map();
private static _extensions: Map<string, ExtensionCreator> = new Map();
/**
* Register a creator for an object type
* 注册对象类型创建器
*/
public static registerCreator(type: EObjectType, creator: ObjectCreator): void {
UIObjectFactory._creators.set(type, creator);
}
/**
* Register an extension creator for a URL
* 注册扩展创建器
*/
public static registerExtension(url: string, creator: ExtensionCreator): void {
UIObjectFactory._extensions.set(url, creator);
}
/**
* Check if extension exists for URL
* 检查 URL 是否有扩展
*/
public static hasExtension(url: string): boolean {
return UIObjectFactory._extensions.has(url);
}
/**
* Create object by type
* 根据类型创建对象
*/
public static createObject(type: EObjectType, _userClass?: new () => GObject): GObject | null {
const creator = UIObjectFactory._creators.get(type);
if (creator) {
const obj = creator();
return obj;
}
// Fallback for component-based types
switch (type) {
case EObjectType.Component:
case EObjectType.Label:
case EObjectType.ComboBox:
case EObjectType.List:
case EObjectType.Tree:
case EObjectType.ScrollBar:
case EObjectType.MovieClip:
case EObjectType.Swf:
case EObjectType.Loader:
case EObjectType.Loader3D:
// Use Component creator if specific creator not registered
const componentCreator = UIObjectFactory._creators.get(EObjectType.Component);
if (componentCreator) {
const obj = componentCreator();
return obj;
}
break;
}
return new GObject();
}
/**
* Create new object by type (number)
* 根据类型号创建新对象
*/
public static newObject(type: number): GObject;
/**
* Create new object from package item
* 从包资源项创建新对象
*/
public static newObject(item: PackageItem): GObject;
public static newObject(arg: number | PackageItem): GObject {
if (typeof arg === 'number') {
const obj = UIObjectFactory.createObject(arg as EObjectType) || new GObject();
return obj;
} else {
const item = arg as PackageItem;
// Check for extension
if (item.owner) {
const url = 'ui://' + item.owner.id + item.id;
const extensionCreator = UIObjectFactory._extensions.get(url);
if (extensionCreator) {
const obj = extensionCreator();
obj.packageItem = item;
return obj;
}
// Also check by name
const urlByName = 'ui://' + item.owner.name + '/' + item.name;
const extensionCreatorByName = UIObjectFactory._extensions.get(urlByName);
if (extensionCreatorByName) {
const obj = extensionCreatorByName();
obj.packageItem = item;
return obj;
}
}
const obj = UIObjectFactory.createObject(item.objectType);
if (obj) {
obj.packageItem = item;
}
return obj || new GObject();
}
}
/**
* Create object from package item
* 从包资源项创建对象
*/
public static createObjectFromItem(item: PackageItem): GObject | null {
const obj = UIObjectFactory.createObject(item.objectType);
if (obj) {
obj.packageItem = item;
obj.constructFromResource();
}
return obj;
}
/**
* Create object from URL with extension support
* 从 URL 创建对象(支持扩展)
*/
public static createObjectFromURL(url: string): GObject | null {
const extensionCreator = UIObjectFactory._extensions.get(url);
if (extensionCreator) {
return extensionCreator();
}
return null;
}
/**
* Resolve package item extension
* 解析包项扩展
*/
public static resolvePackageItemExtension(item: PackageItem): void {
if (!item.owner) return;
const url = 'ui://' + item.owner.id + item.id;
if (UIObjectFactory._extensions.has(url)) {
return;
}
const urlByName = 'ui://' + item.owner.name + '/' + item.name;
if (UIObjectFactory._extensions.has(urlByName)) {
return;
}
}
/**
* Clear all registered creators and extensions
* 清除所有注册的创建器和扩展
*/
public static clear(): void {
UIObjectFactory._creators.clear();
UIObjectFactory._extensions.clear();
}
}

View File

@@ -0,0 +1,39 @@
/**
* FairyGUI Module Initialization
*
* This module registers all object type creators with UIObjectFactory.
* It must be imported after all classes are defined to break circular dependencies.
*
* FairyGUI 模块初始化,注册所有对象类型创建器以打破循环依赖
*/
import { UIObjectFactory } from './UIObjectFactory';
import { EObjectType } from './FieldTypes';
import { GGroup } from './GGroup';
import { GComponent } from './GComponent';
import { GImage } from '../widgets/GImage';
import { GGraph } from '../widgets/GGraph';
import { GTextField } from '../widgets/GTextField';
import { GTextInput } from '../widgets/GTextInput';
import { GButton } from '../widgets/GButton';
import { GProgressBar } from '../widgets/GProgressBar';
import { GSlider } from '../widgets/GSlider';
import { GMovieClip } from '../widgets/GMovieClip';
import { GLoader } from '../widgets/GLoader';
// Register all object type creators
UIObjectFactory.registerCreator(EObjectType.Image, () => new GImage());
UIObjectFactory.registerCreator(EObjectType.Graph, () => new GGraph());
UIObjectFactory.registerCreator(EObjectType.Text, () => new GTextField());
UIObjectFactory.registerCreator(EObjectType.RichText, () => new GTextField());
UIObjectFactory.registerCreator(EObjectType.InputText, () => new GTextInput());
UIObjectFactory.registerCreator(EObjectType.Group, () => new GGroup());
UIObjectFactory.registerCreator(EObjectType.Component, () => new GComponent());
UIObjectFactory.registerCreator(EObjectType.Button, () => new GButton());
UIObjectFactory.registerCreator(EObjectType.ProgressBar, () => new GProgressBar());
UIObjectFactory.registerCreator(EObjectType.Slider, () => new GSlider());
UIObjectFactory.registerCreator(EObjectType.MovieClip, () => new GMovieClip());
UIObjectFactory.registerCreator(EObjectType.Loader, () => new GLoader());
// Component-based types use GComponent as fallback (registered above)
// Label, ComboBox, List, Tree, ScrollBar, Swf, Loader3D

View File

@@ -0,0 +1,35 @@
import { DisplayObject } from './DisplayObject';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* Container
*
* A concrete DisplayObject that can contain children but has no visual content itself.
* Used as the display object for GComponent.
*
* 一个具体的 DisplayObject可以包含子对象但本身没有可视内容。
* 用作 GComponent 的显示对象。
*/
export class Container extends DisplayObject {
constructor() {
super();
}
/**
* Collect render data from children
* 从子对象收集渲染数据
*/
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible) return;
// Update transform before collecting render data
// 收集渲染数据前更新变换
this.updateTransform();
// Collect render data from all children
// 从所有子对象收集渲染数据
for (const child of this._children) {
child.collectRenderData(collector);
}
}
}

View File

@@ -0,0 +1,638 @@
import { EventDispatcher } from '../events/EventDispatcher';
import { FGUIEvents } from '../events/Events';
import { Point, Rectangle } from '../utils/MathTypes';
import type { IRenderCollector } from '../render/IRenderCollector';
import type { GObject } from '../core/GObject';
/**
* DisplayObject
*
* Abstract display object base class for all visual elements.
*
* 抽象显示对象基类,所有可视元素的基础
*/
export abstract class DisplayObject extends EventDispatcher {
/** Name of this display object | 显示对象名称 */
public name: string = '';
// Transform properties | 变换属性
protected _x: number = 0;
protected _y: number = 0;
protected _width: number = 0;
protected _height: number = 0;
protected _scaleX: number = 1;
protected _scaleY: number = 1;
protected _rotation: number = 0;
protected _pivotX: number = 0;
protected _pivotY: number = 0;
protected _skewX: number = 0;
protected _skewY: number = 0;
// Display properties | 显示属性
protected _alpha: number = 1;
protected _visible: boolean = true;
protected _touchable: boolean = true;
protected _grayed: boolean = false;
// Hierarchy | 层级关系
protected _parent: DisplayObject | null = null;
protected _children: DisplayObject[] = [];
// Stage reference | 舞台引用
protected _stage: DisplayObject | null = null;
// Dirty flags | 脏标记
protected _transformDirty: boolean = true;
protected _boundsDirty: boolean = true;
// Cached values | 缓存值
protected _worldAlpha: number = 1;
protected _worldMatrix: Float32Array = new Float32Array([1, 0, 0, 1, 0, 0]);
protected _bounds: Rectangle = new Rectangle();
// User data | 用户数据
public userData: unknown = null;
/** Owner GObject reference | 所属 GObject 引用 */
public gOwner: GObject | null = null;
constructor() {
super();
}
// Position | 位置
public get x(): number {
return this._x;
}
public set x(value: number) {
if (this._x !== value) {
this._x = value;
this.markTransformDirty();
}
}
public get y(): number {
return this._y;
}
public set y(value: number) {
if (this._y !== value) {
this._y = value;
this.markTransformDirty();
}
}
public setPosition(x: number, y: number): void {
if (this._x !== x || this._y !== y) {
this._x = x;
this._y = y;
this.markTransformDirty();
}
}
// Size | 尺寸
public get width(): number {
return this._width;
}
public set width(value: number) {
if (this._width !== value) {
this._width = value;
this.markBoundsDirty();
}
}
public get height(): number {
return this._height;
}
public set height(value: number) {
if (this._height !== value) {
this._height = value;
this.markBoundsDirty();
}
}
public setSize(width: number, height: number): void {
if (this._width !== width || this._height !== height) {
this._width = width;
this._height = height;
this.markBoundsDirty();
}
}
// Scale | 缩放
public get scaleX(): number {
return this._scaleX;
}
public set scaleX(value: number) {
if (this._scaleX !== value) {
this._scaleX = value;
this.markTransformDirty();
}
}
public get scaleY(): number {
return this._scaleY;
}
public set scaleY(value: number) {
if (this._scaleY !== value) {
this._scaleY = value;
this.markTransformDirty();
}
}
public setScale(scaleX: number, scaleY: number): void {
if (this._scaleX !== scaleX || this._scaleY !== scaleY) {
this._scaleX = scaleX;
this._scaleY = scaleY;
this.markTransformDirty();
}
}
// Rotation | 旋转
public get rotation(): number {
return this._rotation;
}
public set rotation(value: number) {
if (this._rotation !== value) {
this._rotation = value;
this.markTransformDirty();
}
}
// Pivot | 轴心点
public get pivotX(): number {
return this._pivotX;
}
public set pivotX(value: number) {
if (this._pivotX !== value) {
this._pivotX = value;
this.markTransformDirty();
}
}
public get pivotY(): number {
return this._pivotY;
}
public set pivotY(value: number) {
if (this._pivotY !== value) {
this._pivotY = value;
this.markTransformDirty();
}
}
public setPivot(pivotX: number, pivotY: number): void {
if (this._pivotX !== pivotX || this._pivotY !== pivotY) {
this._pivotX = pivotX;
this._pivotY = pivotY;
this.markTransformDirty();
}
}
// Skew | 倾斜
public get skewX(): number {
return this._skewX;
}
public set skewX(value: number) {
if (this._skewX !== value) {
this._skewX = value;
this.markTransformDirty();
}
}
public get skewY(): number {
return this._skewY;
}
public set skewY(value: number) {
if (this._skewY !== value) {
this._skewY = value;
this.markTransformDirty();
}
}
// Alpha | 透明度
public get alpha(): number {
return this._alpha;
}
public set alpha(value: number) {
if (this._alpha !== value) {
this._alpha = value;
}
}
// Visibility | 可见性
public get visible(): boolean {
return this._visible;
}
public set visible(value: boolean) {
this._visible = value;
}
// Touchable | 可触摸
public get touchable(): boolean {
return this._touchable;
}
public set touchable(value: boolean) {
this._touchable = value;
}
// Grayed | 灰度
public get grayed(): boolean {
return this._grayed;
}
public set grayed(value: boolean) {
this._grayed = value;
}
// Hierarchy | 层级
public get parent(): DisplayObject | null {
return this._parent;
}
/**
* Get stage reference
* 获取舞台引用
*/
public get stage(): DisplayObject | null {
return this._stage;
}
/**
* Set stage reference (internal use)
* 设置舞台引用(内部使用)
*
* @internal
*/
public setStage(stage: DisplayObject | null): void {
this._stage = stage;
}
public get numChildren(): number {
return this._children.length;
}
/**
* Add a child display object
* 添加子显示对象
*/
public addChild(child: DisplayObject): void {
this.addChildAt(child, this._children.length);
}
/**
* Add a child at specific index
* 在指定位置添加子显示对象
*/
public addChildAt(child: DisplayObject, index: number): void {
if (child._parent === this) {
this.setChildIndex(child, index);
return;
}
if (child._parent) {
child._parent.removeChild(child);
}
index = Math.max(0, Math.min(index, this._children.length));
this._children.splice(index, 0, child);
child._parent = this;
child.markTransformDirty();
// Dispatch addedToStage event if this is on stage
// 如果当前对象在舞台上,分发 addedToStage 事件
if (this._stage !== null) {
this.setChildStage(child, this._stage);
}
}
/**
* Set stage for child and its descendants, dispatch events
* 为子对象及其后代设置舞台,分发事件
*/
private setChildStage(child: DisplayObject, stage: DisplayObject | null): void {
const wasOnStage = child._stage !== null;
const isOnStage = stage !== null;
child._stage = stage;
if (!wasOnStage && isOnStage) {
// Dispatch addedToStage event
child.emit(FGUIEvents.ADDED_TO_STAGE);
} else if (wasOnStage && !isOnStage) {
// Dispatch removedFromStage event
child.emit(FGUIEvents.REMOVED_FROM_STAGE);
}
// Recursively set stage for all children
for (const grandChild of child._children) {
this.setChildStage(grandChild, stage);
}
}
/**
* Remove a child display object
* 移除子显示对象
*/
public removeChild(child: DisplayObject): void {
const index = this._children.indexOf(child);
if (index >= 0) {
this.removeChildAt(index);
}
}
/**
* Remove child at specific index
* 移除指定位置的子显示对象
*/
public removeChildAt(index: number): DisplayObject | null {
if (index < 0 || index >= this._children.length) {
return null;
}
const child = this._children[index];
// Dispatch removedFromStage event if on stage
// 如果在舞台上,分发 removedFromStage 事件
if (this._stage !== null) {
this.setChildStage(child, null);
}
this._children.splice(index, 1);
child._parent = null;
return child;
}
/**
* Remove all children
* 移除所有子显示对象
*/
public removeChildren(): void {
// Dispatch removedFromStage events if on stage
// 如果在舞台上,分发 removedFromStage 事件
if (this._stage !== null) {
for (const child of this._children) {
this.setChildStage(child, null);
}
}
for (const child of this._children) {
child._parent = null;
}
this._children.length = 0;
}
/**
* Get child at index
* 获取指定位置的子显示对象
*/
public getChildAt(index: number): DisplayObject | null {
if (index < 0 || index >= this._children.length) {
return null;
}
return this._children[index];
}
/**
* Get child index
* 获取子显示对象的索引
*/
public getChildIndex(child: DisplayObject): number {
return this._children.indexOf(child);
}
/**
* Set child index
* 设置子显示对象的索引
*/
public setChildIndex(child: DisplayObject, index: number): void {
const currentIndex = this._children.indexOf(child);
if (currentIndex < 0) return;
index = Math.max(0, Math.min(index, this._children.length - 1));
if (currentIndex === index) return;
this._children.splice(currentIndex, 1);
this._children.splice(index, 0, child);
}
/**
* Swap two children
* 交换两个子显示对象
*/
public swapChildren(child1: DisplayObject, child2: DisplayObject): void {
const index1 = this._children.indexOf(child1);
const index2 = this._children.indexOf(child2);
if (index1 >= 0 && index2 >= 0) {
this._children[index1] = child2;
this._children[index2] = child1;
}
}
/**
* Get child by name
* 通过名称获取子显示对象
*/
public getChildByName(name: string): DisplayObject | null {
for (const child of this._children) {
if (child.name === name) {
return child;
}
}
return null;
}
// Transform | 变换
/**
* Update world matrix
* 更新世界矩阵
*
* World matrix is in FGUI's coordinate system (top-left origin, Y-down).
* Coordinate system conversion to engine (center origin, Y-up) is done in FGUIRenderDataProvider.
*
* 世界矩阵使用 FGUI 坐标系左上角原点Y 向下)。
* 坐标系转换到引擎中心原点Y 向上)在 FGUIRenderDataProvider 中完成。
*/
public updateTransform(): void {
if (!this._transformDirty) return;
const m = this._worldMatrix;
const rad = (this._rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
m[0] = cos * this._scaleX;
m[1] = sin * this._scaleX;
m[2] = -sin * this._scaleY;
m[3] = cos * this._scaleY;
// Keep FGUI's coordinate system (top-left origin, Y-down)
// 保持 FGUI 坐标系左上角原点Y 向下)
m[4] = this._x - this._pivotX * m[0] - this._pivotY * m[2];
m[5] = this._y - this._pivotX * m[1] - this._pivotY * m[3];
if (this._parent) {
const pm = this._parent._worldMatrix;
const a = m[0], b = m[1], c = m[2], d = m[3], tx = m[4], ty = m[5];
m[0] = a * pm[0] + b * pm[2];
m[1] = a * pm[1] + b * pm[3];
m[2] = c * pm[0] + d * pm[2];
m[3] = c * pm[1] + d * pm[3];
m[4] = tx * pm[0] + ty * pm[2] + pm[4];
m[5] = tx * pm[1] + ty * pm[3] + pm[5];
this._worldAlpha = this._alpha * this._parent._worldAlpha;
} else {
this._worldAlpha = this._alpha;
}
this._transformDirty = false;
for (const child of this._children) {
child.markTransformDirty();
child.updateTransform();
}
}
/**
* Local to global point conversion
* 本地坐标转全局坐标
*/
public localToGlobal(localPoint: Point, outPoint?: Point): Point {
this.updateTransform();
outPoint = outPoint || new Point();
const m = this._worldMatrix;
outPoint.x = localPoint.x * m[0] + localPoint.y * m[2] + m[4];
outPoint.y = localPoint.x * m[1] + localPoint.y * m[3] + m[5];
return outPoint;
}
/**
* Global to local point conversion
* 全局坐标转本地坐标
*/
public globalToLocal(globalPoint: Point, outPoint?: Point): Point {
this.updateTransform();
outPoint = outPoint || new Point();
const m = this._worldMatrix;
const det = m[0] * m[3] - m[1] * m[2];
if (det === 0) {
outPoint.x = 0;
outPoint.y = 0;
} else {
const invDet = 1 / det;
const x = globalPoint.x - m[4];
const y = globalPoint.y - m[5];
outPoint.x = (x * m[3] - y * m[2]) * invDet;
outPoint.y = (y * m[0] - x * m[1]) * invDet;
}
return outPoint;
}
/**
* Hit test
* 碰撞检测
*/
public hitTest(globalX: number, globalY: number): DisplayObject | null {
if (!this._visible || !this._touchable) {
return null;
}
const localPoint = this.globalToLocal(new Point(globalX, globalY));
if (
localPoint.x >= 0 &&
localPoint.x < this._width &&
localPoint.y >= 0 &&
localPoint.y < this._height
) {
for (let i = this._children.length - 1; i >= 0; i--) {
const hit = this._children[i].hitTest(globalX, globalY);
if (hit) return hit;
}
return this;
}
return null;
}
// Dirty flags | 脏标记
protected markTransformDirty(): void {
this._transformDirty = true;
this._boundsDirty = true;
}
protected markBoundsDirty(): void {
this._boundsDirty = true;
}
// Render data collection | 渲染数据收集
/**
* Collect render data (abstract - implemented by subclasses)
* 收集渲染数据(抽象方法 - 由子类实现)
*/
public abstract collectRenderData(collector: IRenderCollector): void;
/**
* Get world matrix
* 获取世界矩阵
*/
public get worldMatrix(): Float32Array {
return this._worldMatrix;
}
/**
* Get world alpha
* 获取世界透明度
*/
public get worldAlpha(): number {
return this._worldAlpha;
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
if (this._parent) {
this._parent.removeChild(this);
}
for (const child of this._children) {
child.dispose();
}
this._children.length = 0;
super.dispose();
}
}

View File

@@ -0,0 +1,173 @@
import { DisplayObject } from './DisplayObject';
import { EGraphType } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* Graph
*
* Display object for rendering geometric shapes.
*
* 用于渲染几何图形的显示对象
*/
export class Graph extends DisplayObject {
/** Graph type | 图形类型 */
private _type: EGraphType = EGraphType.Empty;
/** Line width | 线宽 */
public lineSize: number = 1;
/** Line color | 线颜色 */
public lineColor: string = '#000000';
/** Fill color | 填充颜色 */
public fillColor: string = '#FFFFFF';
/** Corner radius for rect | 矩形圆角半径 */
public cornerRadius: number[] | null = null;
/** Polygon points | 多边形顶点 */
public polygonPoints: number[] | null = null;
/** Number of sides for regular polygon | 正多边形边数 */
public sides: number = 3;
/** Start angle for regular polygon | 正多边形起始角度 */
public startAngle: number = 0;
/** Distance multipliers for regular polygon | 正多边形距离乘数 */
public distances: number[] | null = null;
constructor() {
super();
}
/**
* Get graph type
* 获取图形类型
*/
public get type(): EGraphType {
return this._type;
}
/**
* Draw rectangle
* 绘制矩形
*/
public drawRect(lineSize: number, lineColor: string, fillColor: string, cornerRadius?: number[]): void {
this._type = EGraphType.Rect;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.cornerRadius = cornerRadius || null;
}
/**
* Draw ellipse
* 绘制椭圆
*/
public drawEllipse(lineSize: number, lineColor: string, fillColor: string): void {
this._type = EGraphType.Ellipse;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
}
/**
* Draw polygon
* 绘制多边形
*/
public drawPolygon(lineSize: number, lineColor: string, fillColor: string, points: number[]): void {
this._type = EGraphType.Polygon;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.polygonPoints = points;
}
/**
* Draw regular polygon
* 绘制正多边形
*/
public drawRegularPolygon(
lineSize: number,
lineColor: string,
fillColor: string,
sides: number,
startAngle?: number,
distances?: number[]
): void {
this._type = EGraphType.RegularPolygon;
this.lineSize = lineSize;
this.lineColor = lineColor;
this.fillColor = fillColor;
this.sides = sides;
this.startAngle = startAngle || 0;
this.distances = distances || null;
}
/**
* Clear graph
* 清除图形
*/
public clear(): void {
this._type = EGraphType.Empty;
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* 解析颜色字符串为打包的 u320xRRGGBBAA 格式)
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0xFFFFFFFF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || this._type === EGraphType.Empty) return;
this.updateTransform();
const fillColorNum = this.parseColor(this.fillColor);
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Graph,
sortOrder: 0,
worldMatrix: this._worldMatrix,
width: this._width,
height: this._height,
alpha: this._worldAlpha,
grayed: this._grayed,
graphType: this._type,
lineSize: this.lineSize,
lineColor: this.parseColor(this.lineColor),
fillColor: fillColorNum,
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.cornerRadius) {
primitive.cornerRadius = this.cornerRadius;
}
if (this._type === EGraphType.Polygon && this.polygonPoints) {
primitive.polygonPoints = this.polygonPoints;
}
if (this._type === EGraphType.RegularPolygon) {
primitive.sides = this.sides;
primitive.startAngle = this.startAngle;
if (this.distances) {
primitive.distances = this.distances;
}
}
collector.addPrimitive(primitive);
}
}

View File

@@ -0,0 +1,201 @@
import { DisplayObject } from './DisplayObject';
import { Rectangle } from '../utils/MathTypes';
import { EFillMethod, EFillOrigin } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* Sprite texture info from FairyGUI package
* FairyGUI 包中的精灵纹理信息
*/
export interface ISpriteTexture {
atlas: string;
atlasId: string;
rect: { x: number; y: number; width: number; height: number };
offset: { x: number; y: number };
originalSize: { x: number; y: number };
rotated: boolean;
/** Atlas width for UV calculation | 图集宽度,用于 UV 计算 */
atlasWidth: number;
/** Atlas height for UV calculation | 图集高度,用于 UV 计算 */
atlasHeight: number;
}
/**
* Image
*
* Display object for rendering images/textures.
*
* 用于渲染图像/纹理的显示对象
*/
export class Image extends DisplayObject {
/** Texture ID, key, or sprite info | 纹理 ID、键或精灵信息 */
public texture: string | number | ISpriteTexture | null = null;
/** Tint color (hex string like "#FFFFFF") | 着色颜色 */
public color: string = '#FFFFFF';
/** Scale9 grid for 9-slice scaling | 九宫格缩放 */
public scale9Grid: Rectangle | null = null;
/** Scale by tile | 平铺缩放 */
public scaleByTile: boolean = false;
/** Tile grid indice | 平铺网格索引 */
public tileGridIndice: number = 0;
// Fill properties | 填充属性
private _fillMethod: EFillMethod = EFillMethod.None;
private _fillOrigin: EFillOrigin = EFillOrigin.Top;
private _fillClockwise: boolean = true;
private _fillAmount: number = 1;
constructor() {
super();
}
public get fillMethod(): EFillMethod {
return this._fillMethod;
}
public set fillMethod(value: EFillMethod) {
this._fillMethod = value;
}
public get fillOrigin(): EFillOrigin {
return this._fillOrigin;
}
public set fillOrigin(value: EFillOrigin) {
this._fillOrigin = value;
}
public get fillClockwise(): boolean {
return this._fillClockwise;
}
public set fillClockwise(value: boolean) {
this._fillClockwise = value;
}
public get fillAmount(): number {
return this._fillAmount;
}
public set fillAmount(value: number) {
this._fillAmount = Math.max(0, Math.min(1, value));
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* 解析颜色字符串为打包的 u320xRRGGBBAA 格式)
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0xFFFFFFFF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || !this.texture) return;
this.updateTransform();
// Determine texture ID, UV rect, and draw rect based on texture type
let textureId: string | number;
let uvRect: [number, number, number, number] | undefined;
let drawWidth = this._width;
let drawHeight = this._height;
let drawOffsetX = 0;
let drawOffsetY = 0;
if (typeof this.texture === 'object') {
// ISpriteTexture - use atlas file as texture ID
const sprite = this.texture as ISpriteTexture;
textureId = sprite.atlas;
// Calculate normalized UV from sprite rect and atlas dimensions
const atlasW = sprite.atlasWidth || 1;
const atlasH = sprite.atlasHeight || 1;
const u0 = sprite.rect.x / atlasW;
const v0 = sprite.rect.y / atlasH;
const u1 = (sprite.rect.x + sprite.rect.width) / atlasW;
const v1 = (sprite.rect.y + sprite.rect.height) / atlasH;
uvRect = [u0, v0, u1, v1];
// Handle trimmed sprites (offset and originalSize)
// 处理裁剪过的精灵(偏移和原始尺寸)
const origW = sprite.originalSize.x;
const origH = sprite.originalSize.y;
const regionW = sprite.rect.width;
const regionH = sprite.rect.height;
if (origW !== regionW || origH !== regionH) {
// Sprite was trimmed, calculate actual draw rect
// 精灵被裁剪过,计算实际绘制矩形
const sx = this._width / origW;
const sy = this._height / origH;
drawOffsetX = sprite.offset.x * sx;
drawOffsetY = sprite.offset.y * sy;
drawWidth = regionW * sx;
drawHeight = regionH * sy;
}
} else {
textureId = this.texture;
}
// Create adjusted world matrix if there's an offset
let worldMatrix = this._worldMatrix;
if (drawOffsetX !== 0 || drawOffsetY !== 0) {
// Apply offset to the world matrix translation
// 将偏移应用到世界矩阵的平移部分
worldMatrix = new Float32Array(this._worldMatrix);
const m = this._worldMatrix;
// Transform offset by rotation/scale part of matrix
worldMatrix[4] = m[4] + drawOffsetX * m[0] + drawOffsetY * m[2];
worldMatrix[5] = m[5] + drawOffsetX * m[1] + drawOffsetY * m[3];
}
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Image,
sortOrder: 0,
worldMatrix,
width: drawWidth,
height: drawHeight,
alpha: this._worldAlpha,
grayed: this._grayed,
textureId,
uvRect,
color: this.parseColor(this.color),
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.scale9Grid) {
primitive.scale9Grid = this.scale9Grid;
// Pass source dimensions for nine-slice calculation
// 传递源尺寸用于九宫格计算
if (typeof this.texture === 'object') {
const sprite = this.texture as ISpriteTexture;
primitive.sourceWidth = sprite.rect.width;
primitive.sourceHeight = sprite.rect.height;
} else {
// For non-sprite textures, use the display object's original size
// 对于非精灵纹理,使用显示对象的原始尺寸
primitive.sourceWidth = this._width;
primitive.sourceHeight = this._height;
}
}
if (this.scaleByTile) {
primitive.tileMode = true;
}
collector.addPrimitive(primitive);
}
}

View File

@@ -0,0 +1,341 @@
import { TextField } from './TextField';
/**
* InputTextField
*
* Editable text input display object.
* Creates and manages a hidden HTML input element for text editing.
*
* 可编辑文本输入显示对象
* 创建并管理隐藏的 HTML input 元素用于文本编辑
*/
export class InputTextField extends TextField {
private _inputElement: HTMLInputElement | HTMLTextAreaElement | null = null;
private _password: boolean = false;
private _keyboardType: string = 'text';
private _editable: boolean = true;
private _maxLength: number = 0;
private _promptText: string = '';
private _promptColor: string = '#999999';
private _restrict: string = '';
private _multiline: boolean = false;
private _hasFocus: boolean = false;
constructor() {
super();
this.touchable = true;
}
/**
* Get/set password mode
* 获取/设置密码模式
*/
public get password(): boolean {
return this._password;
}
public set password(value: boolean) {
if (this._password !== value) {
this._password = value;
this.updateInputType();
}
}
/**
* Get/set keyboard type
* 获取/设置键盘类型
*/
public get keyboardType(): string {
return this._keyboardType;
}
public set keyboardType(value: string) {
if (this._keyboardType !== value) {
this._keyboardType = value;
this.updateInputType();
}
}
/**
* Get/set editable state
* 获取/设置可编辑状态
*/
public get editable(): boolean {
return this._editable;
}
public set editable(value: boolean) {
this._editable = value;
if (this._inputElement) {
if (value) {
this._inputElement.removeAttribute('readonly');
} else {
this._inputElement.setAttribute('readonly', 'true');
}
}
}
/**
* Get/set max length
* 获取/设置最大长度
*/
public get maxLength(): number {
return this._maxLength;
}
public set maxLength(value: number) {
this._maxLength = value;
if (this._inputElement && value > 0) {
this._inputElement.maxLength = value;
}
}
/**
* Get/set placeholder text
* 获取/设置占位符文本
*/
public get promptText(): string {
return this._promptText;
}
public set promptText(value: string) {
this._promptText = value;
if (this._inputElement) {
this._inputElement.placeholder = value;
}
}
/**
* Get/set placeholder color
* 获取/设置占位符颜色
*/
public get promptColor(): string {
return this._promptColor;
}
public set promptColor(value: string) {
this._promptColor = value;
// Apply via CSS
}
/**
* Get/set character restriction pattern
* 获取/设置字符限制模式
*/
public get restrict(): string {
return this._restrict;
}
public set restrict(value: string) {
this._restrict = value;
if (this._inputElement && value && this._inputElement instanceof HTMLInputElement) {
this._inputElement.pattern = value;
}
}
/**
* Get/set multiline mode
* 获取/设置多行模式
*/
public get multiline(): boolean {
return this._multiline;
}
public set multiline(value: boolean) {
if (this._multiline !== value) {
this._multiline = value;
this.recreateInputElement();
}
}
/**
* Request focus
* 请求焦点
*/
public focus(): void {
this.ensureInputElement();
if (this._inputElement) {
this._inputElement.focus();
this._hasFocus = true;
}
}
/**
* Clear focus
* 清除焦点
*/
public blur(): void {
if (this._inputElement) {
this._inputElement.blur();
this._hasFocus = false;
}
}
/**
* Select all text
* 选择所有文本
*/
public selectAll(): void {
if (this._inputElement) {
this._inputElement.select();
}
}
/**
* Set selection range
* 设置选择范围
*/
public setSelection(start: number, end: number): void {
if (this._inputElement) {
this._inputElement.setSelectionRange(start, end);
}
}
/**
* Get text from input
* 从输入获取文本
*/
public getInputText(): string {
if (this._inputElement) {
return this._inputElement.value;
}
return this.text;
}
/**
* Set text to input
* 设置文本到输入
*/
public setInputText(value: string): void {
this.text = value;
if (this._inputElement) {
this._inputElement.value = value;
}
}
private ensureInputElement(): void {
if (!this._inputElement) {
this.createInputElement();
}
}
private createInputElement(): void {
if (this._multiline) {
this._inputElement = document.createElement('textarea');
} else {
this._inputElement = document.createElement('input');
this.updateInputType();
}
this.applyInputStyles();
this.bindInputEvents();
document.body.appendChild(this._inputElement);
}
private recreateInputElement(): void {
const oldValue = this._inputElement?.value || '';
this.destroyInputElement();
this.createInputElement();
if (this._inputElement) {
this._inputElement.value = oldValue;
}
}
private destroyInputElement(): void {
if (this._inputElement) {
this._inputElement.remove();
this._inputElement = null;
}
}
private updateInputType(): void {
if (this._inputElement && this._inputElement instanceof HTMLInputElement) {
if (this._password) {
this._inputElement.type = 'password';
} else {
this._inputElement.type = this._keyboardType;
}
}
}
private applyInputStyles(): void {
if (!this._inputElement) return;
const style = this._inputElement.style;
style.position = 'absolute';
style.border = 'none';
style.outline = 'none';
style.background = 'transparent';
style.padding = '0';
style.margin = '0';
style.fontFamily = this.font || 'sans-serif';
style.fontSize = `${this.fontSize}px`;
style.color = this.color;
style.opacity = '0'; // Hidden initially, shown when focused
if (this._maxLength > 0) {
this._inputElement.maxLength = this._maxLength;
}
if (this._promptText) {
this._inputElement.placeholder = this._promptText;
}
if (this._restrict && this._inputElement instanceof HTMLInputElement) {
this._inputElement.pattern = this._restrict;
}
if (!this._editable) {
this._inputElement.setAttribute('readonly', 'true');
}
this._inputElement.value = this.text;
}
private bindInputEvents(): void {
if (!this._inputElement) return;
this._inputElement.addEventListener('input', () => {
this.text = this._inputElement?.value || '';
this.emit('input');
});
this._inputElement.addEventListener('focus', () => {
this._hasFocus = true;
if (this._inputElement) {
this._inputElement.style.opacity = '1';
}
this.emit('focus');
});
this._inputElement.addEventListener('blur', () => {
this._hasFocus = false;
if (this._inputElement) {
this._inputElement.style.opacity = '0';
}
this.emit('blur');
});
this._inputElement.addEventListener('keydown', (e: Event) => {
if ((e as KeyboardEvent).key === 'Enter' && !this._multiline) {
this.emit('submit');
}
});
}
/**
* Update input element position based on display object position
* 根据显示对象位置更新输入元素位置
*/
public updateInputPosition(globalX: number, globalY: number): void {
if (this._inputElement) {
this._inputElement.style.left = `${globalX}px`;
this._inputElement.style.top = `${globalY}px`;
this._inputElement.style.width = `${this.width}px`;
this._inputElement.style.height = `${this.height}px`;
}
}
public dispose(): void {
this.destroyInputElement();
super.dispose();
}
}

View File

@@ -0,0 +1,420 @@
import { Image } from './Image';
import { Timer } from '../core/Timer';
import { FGUIEvents } from '../events/Events';
import type { IRenderCollector } from '../render/IRenderCollector';
/**
* Frame data for movie clip animation
* 动画帧数据
*/
export interface IFrame {
/** Additional delay for this frame | 该帧额外延迟 */
addDelay: number;
/** Texture ID for this frame | 该帧的纹理 ID */
texture?: string | number | null;
}
/**
* Simple callback handler
* 简单回调处理器
*/
export type SimpleHandler = (() => void) | { run: () => void };
/**
* MovieClip
*
* Animated sprite display object with frame-based animation.
*
* 基于帧的动画精灵显示对象
*
* Features:
* - Frame-by-frame animation
* - Swing (ping-pong) mode
* - Time scale control
* - Play range and loop control
*/
export class MovieClip extends Image {
/** Frame interval in milliseconds | 帧间隔(毫秒) */
public interval: number = 0;
/** Swing mode (ping-pong) | 摆动模式 */
public swing: boolean = false;
/** Delay between loops | 循环间延迟 */
public repeatDelay: number = 0;
/** Time scale multiplier | 时间缩放 */
public timeScale: number = 1;
private _playing: boolean = true;
private _frameCount: number = 0;
private _frames: IFrame[] = [];
private _frame: number = 0;
private _start: number = 0;
private _end: number = 0;
private _times: number = 0;
private _endAt: number = 0;
private _status: number = 0; // 0-none, 1-next loop, 2-ending, 3-ended
private _frameElapsed: number = 0;
private _reversed: boolean = false;
private _repeatedCount: number = 0;
private _endHandler: SimpleHandler | null = null;
private _isOnStage: boolean = false;
private _lastTime: number = 0;
constructor() {
super();
this.touchable = false;
this.setPlaySettings();
// Subscribe to stage lifecycle events
// 订阅舞台生命周期事件
this.on(FGUIEvents.ADDED_TO_STAGE, this.onAddToStage, this);
this.on(FGUIEvents.REMOVED_FROM_STAGE, this.onRemoveFromStage, this);
}
/**
* Get animation frames
* 获取动画帧
*/
public get frames(): IFrame[] {
return this._frames;
}
/**
* Set animation frames
* 设置动画帧
*/
public set frames(value: IFrame[]) {
this._frames = value;
this.scaleByTile = false;
this.scale9Grid = null;
if (this._frames && this._frames.length > 0) {
this._frameCount = this._frames.length;
if (this._end === -1 || this._end > this._frameCount - 1) {
this._end = this._frameCount - 1;
}
if (this._endAt === -1 || this._endAt > this._frameCount - 1) {
this._endAt = this._frameCount - 1;
}
if (this._frame < 0 || this._frame > this._frameCount - 1) {
this._frame = this._frameCount - 1;
}
this._frameElapsed = 0;
this._repeatedCount = 0;
this._reversed = false;
} else {
this._frameCount = 0;
}
this.drawFrame();
this.checkTimer();
}
/**
* Get frame count
* 获取帧数
*/
public get frameCount(): number {
return this._frameCount;
}
/**
* Get current frame index
* 获取当前帧索引
*/
public get frame(): number {
return this._frame;
}
/**
* Set current frame index
* 设置当前帧索引
*/
public set frame(value: number) {
if (this._frame !== value) {
if (this._frames && value >= this._frameCount) {
value = this._frameCount - 1;
}
this._frame = value;
this._frameElapsed = 0;
this.drawFrame();
}
}
/**
* Get playing state
* 获取播放状态
*/
public get playing(): boolean {
return this._playing;
}
/**
* Set playing state
* 设置播放状态
*/
public set playing(value: boolean) {
if (this._playing !== value) {
this._playing = value;
this.checkTimer();
}
}
/**
* Rewind to first frame
* 倒回到第一帧
*/
public rewind(): void {
this._frame = 0;
this._frameElapsed = 0;
this._reversed = false;
this._repeatedCount = 0;
this.drawFrame();
}
/**
* Sync status from another MovieClip
* 从另一个 MovieClip 同步状态
*/
public syncStatus(anotherMc: MovieClip): void {
this._frame = anotherMc._frame;
this._frameElapsed = anotherMc._frameElapsed;
this._reversed = anotherMc._reversed;
this._repeatedCount = anotherMc._repeatedCount;
this.drawFrame();
}
/**
* Advance animation by time
* 推进动画时间
*
* @param timeInMilliseconds Time to advance | 推进时间(毫秒)
*/
public advance(timeInMilliseconds: number): void {
const beginFrame = this._frame;
const beginReversed = this._reversed;
const backupTime = timeInMilliseconds;
while (true) {
let tt = this.interval + this._frames[this._frame].addDelay;
if (this._frame === 0 && this._repeatedCount > 0) {
tt += this.repeatDelay;
}
if (timeInMilliseconds < tt) {
this._frameElapsed = 0;
break;
}
timeInMilliseconds -= tt;
if (this.swing) {
if (this._reversed) {
this._frame--;
if (this._frame <= 0) {
this._frame = 0;
this._repeatedCount++;
this._reversed = !this._reversed;
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = Math.max(0, this._frameCount - 2);
this._repeatedCount++;
this._reversed = !this._reversed;
}
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = 0;
this._repeatedCount++;
}
}
// Completed one round
if (this._frame === beginFrame && this._reversed === beginReversed) {
const roundTime = backupTime - timeInMilliseconds;
timeInMilliseconds -= Math.floor(timeInMilliseconds / roundTime) * roundTime;
}
}
this.drawFrame();
}
/**
* Set play settings
* 设置播放参数
*
* @param start Start frame | 开始帧
* @param end End frame (-1 for last) | 结束帧(-1 为最后一帧)
* @param times Loop times (0 for infinite) | 循环次数0 为无限)
* @param endAt Stop at frame (-1 for end) | 停止帧(-1 为结束帧)
* @param endHandler Callback on end | 结束回调
*/
public setPlaySettings(
start: number = 0,
end: number = -1,
times: number = 0,
endAt: number = -1,
endHandler: SimpleHandler | null = null
): void {
this._start = start;
this._end = end;
if (this._end === -1 || this._end > this._frameCount - 1) {
this._end = this._frameCount - 1;
}
this._times = times;
this._endAt = endAt;
if (this._endAt === -1) {
this._endAt = this._end;
}
this._status = 0;
this._endHandler = endHandler;
this.frame = start;
}
/**
* Called when added to stage
* 添加到舞台时调用
*/
public onAddToStage(): void {
this._isOnStage = true;
this._lastTime = Timer.time;
this.checkTimer();
}
/**
* Called when removed from stage
* 从舞台移除时调用
*/
public onRemoveFromStage(): void {
this._isOnStage = false;
this.checkTimer();
}
/**
* Update animation (called each frame)
* 更新动画(每帧调用)
*/
public update(): void {
if (!this._playing || this._frameCount === 0 || this._status === 3) {
return;
}
const currentTime = Timer.time;
let dt = currentTime - this._lastTime;
this._lastTime = currentTime;
if (dt > 100) {
dt = 100;
}
if (this.timeScale !== 1) {
dt *= this.timeScale;
}
this._frameElapsed += dt;
let tt = this.interval + this._frames[this._frame].addDelay;
if (this._frame === 0 && this._repeatedCount > 0) {
tt += this.repeatDelay;
}
if (this._frameElapsed < tt) {
return;
}
this._frameElapsed -= tt;
if (this._frameElapsed > this.interval) {
this._frameElapsed = this.interval;
}
if (this.swing) {
if (this._reversed) {
this._frame--;
if (this._frame <= 0) {
this._frame = 0;
this._repeatedCount++;
this._reversed = !this._reversed;
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = Math.max(0, this._frameCount - 2);
this._repeatedCount++;
this._reversed = !this._reversed;
}
}
} else {
this._frame++;
if (this._frame > this._frameCount - 1) {
this._frame = 0;
this._repeatedCount++;
}
}
if (this._status === 1) {
// New loop
this._frame = this._start;
this._frameElapsed = 0;
this._status = 0;
} else if (this._status === 2) {
// Ending
this._frame = this._endAt;
this._frameElapsed = 0;
this._status = 3; // Ended
// Play end callback
if (this._endHandler) {
const handler = this._endHandler;
this._endHandler = null;
if (typeof handler === 'function') {
handler();
} else {
handler.run();
}
}
} else {
if (this._frame === this._end) {
if (this._times > 0) {
this._times--;
if (this._times === 0) {
this._status = 2; // Ending
} else {
this._status = 1; // New loop
}
} else {
this._status = 1; // New loop
}
}
}
this.drawFrame();
}
private drawFrame(): void {
if (this._frameCount > 0 && this._frame < this._frames.length) {
const frame = this._frames[this._frame];
this.texture = frame.texture ?? null;
} else {
this.texture = null;
}
}
private checkTimer(): void {
if (this._playing && this._frameCount > 0 && this._isOnStage) {
Timer.add(this.update, this);
} else {
Timer.remove(this.update, this);
}
}
public collectRenderData(collector: IRenderCollector): void {
super.collectRenderData(collector);
}
}

View File

@@ -0,0 +1,270 @@
import { DisplayObject } from './DisplayObject';
import { EAutoSizeType, EAlignType, EVertAlignType } from '../core/FieldTypes';
import type { IRenderCollector, IRenderPrimitive } from '../render/IRenderCollector';
import { ERenderPrimitiveType } from '../render/IRenderCollector';
/**
* TextField
*
* Display object for rendering text.
*
* 用于渲染文本的显示对象
*/
export class TextField extends DisplayObject {
/** Font name | 字体名称 */
public font: string = '';
/** Font size | 字体大小 */
public fontSize: number = 12;
/** Text color (hex string) | 文本颜色 */
public color: string = '#000000';
/** Horizontal alignment | 水平对齐 */
public align: EAlignType = EAlignType.Left;
/** Vertical alignment | 垂直对齐 */
public valign: EVertAlignType = EVertAlignType.Top;
/** Line spacing | 行间距 */
public leading: number = 3;
/** Letter spacing | 字符间距 */
public letterSpacing: number = 0;
/** Bold | 粗体 */
public bold: boolean = false;
/** Italic | 斜体 */
public italic: boolean = false;
/** Underline | 下划线 */
public underline: boolean = false;
/** Single line | 单行 */
public singleLine: boolean = false;
/** Stroke width | 描边宽度 */
public stroke: number = 0;
/** Stroke color | 描边颜色 */
public strokeColor: string = '#000000';
/** UBB enabled | UBB 标签启用 */
public ubbEnabled: boolean = false;
/** Auto size type | 自动尺寸类型 */
public autoSize: EAutoSizeType = EAutoSizeType.Both;
/** Word wrap | 自动换行 */
public wordWrap: boolean = false;
/** Template variables | 模板变量 */
public templateVars: Record<string, string> | null = null;
/** Text width after layout | 排版后文本宽度 */
private _textWidth: number = 0;
/** Text height after layout | 排版后文本高度 */
private _textHeight: number = 0;
/** Text content changed flag | 文本内容变化标记 */
private _textChanged: boolean = true;
/** Internal text storage | 内部文本存储 */
private _text: string = '';
constructor() {
super();
}
/**
* Get/set text content
* 获取/设置文本内容
*/
public get text(): string {
return this._text;
}
public set text(value: string) {
if (this._text !== value) {
this._text = value;
this._textChanged = true;
this.ensureSizeCorrect();
}
}
/**
* Get text width
* 获取文本宽度
*/
public get textWidth(): number {
if (this._textChanged) {
this.buildLines();
}
return this._textWidth;
}
/**
* Get text height
* 获取文本高度
*/
public get textHeight(): number {
if (this._textChanged) {
this.buildLines();
}
return this._textHeight;
}
/**
* Ensure text size is calculated correctly
* 确保文本尺寸正确计算
*/
public ensureSizeCorrect(): void {
if (this._textChanged && this.autoSize !== EAutoSizeType.None) {
this.buildLines();
}
}
/** Shared canvas context for text measurement | 共享的 Canvas 上下文用于文本测量 */
private static _measureContext: CanvasRenderingContext2D | null = null;
/**
* Get or create canvas context for text measurement
* 获取或创建用于文本测量的 canvas 上下文
*/
private static getMeasureContext(): CanvasRenderingContext2D {
if (!TextField._measureContext) {
const canvas = document.createElement('canvas');
TextField._measureContext = canvas.getContext('2d')!;
}
return TextField._measureContext;
}
/**
* Build lines and calculate text dimensions
* 构建行信息并计算文本尺寸
*
* 使用 Canvas 2D measureText 精确测量文本尺寸
* Use Canvas 2D measureText for accurate text measurement
*/
private buildLines(): void {
this._textChanged = false;
if (!this._text) {
this._textWidth = 0;
this._textHeight = this.fontSize;
return;
}
const ctx = TextField.getMeasureContext();
// 设置字体样式
// Set font style
const fontStyle = this.italic ? 'italic ' : '';
const fontWeight = this.bold ? 'bold ' : '';
const fontFamily = this.font || 'Arial, sans-serif';
ctx.font = `${fontStyle}${fontWeight}${this.fontSize}px ${fontFamily}`;
const lines = this._text.split('\n');
const lineHeight = this.fontSize + this.leading;
let maxWidth = 0;
for (const line of lines) {
// 使用 canvas measureText 获取精确宽度
// Use canvas measureText for accurate width
let lineWidth = ctx.measureText(line).width;
// 添加字符间距
// Add letter spacing
if (this.letterSpacing !== 0 && line.length > 1) {
lineWidth += this.letterSpacing * (line.length - 1);
}
if (lineWidth > maxWidth) {
maxWidth = lineWidth;
}
}
// 单行模式只取第一行
// Single line mode only takes first line
if (this.singleLine) {
this._textWidth = maxWidth;
this._textHeight = lineHeight;
} else {
this._textWidth = maxWidth;
this._textHeight = lines.length * lineHeight;
}
// 添加 gutter 边距(参考 Unity 实现的 GUTTER_X = 2, GUTTER_Y = 2
// Add gutter padding (refer to Unity implementation: GUTTER_X = 2, GUTTER_Y = 2)
this._textWidth += 4;
this._textHeight += 4;
}
/**
* Set variable
* 设置变量
*/
public setVar(name: string, value: string): void {
if (!this.templateVars) {
this.templateVars = {};
}
this.templateVars[name] = value;
}
/**
* Parse color string to packed u32 (0xRRGGBBAA format)
* 解析颜色字符串为打包的 u320xRRGGBBAA 格式)
*/
private parseColor(color: string): number {
if (color.startsWith('#')) {
const hex = color.slice(1);
if (hex.length === 6) {
return ((parseInt(hex, 16) << 8) | 0xFF) >>> 0;
} else if (hex.length === 8) {
return parseInt(hex, 16) >>> 0;
}
}
return 0x000000FF;
}
public collectRenderData(collector: IRenderCollector): void {
if (!this._visible || this._alpha <= 0 || !this._text) return;
this.updateTransform();
const primitive: IRenderPrimitive = {
type: ERenderPrimitiveType.Text,
sortOrder: 0,
worldMatrix: this._worldMatrix,
width: this._width,
height: this._height,
alpha: this._worldAlpha,
grayed: this._grayed,
text: this._text,
font: this.font,
fontSize: this.fontSize,
color: this.parseColor(this.color),
align: this.align,
valign: this.valign,
leading: this.leading,
letterSpacing: this.letterSpacing,
bold: this.bold,
italic: this.italic,
underline: this.underline,
singleLine: this.singleLine,
wordWrap: this.wordWrap,
clipRect: collector.getCurrentClipRect() || undefined
};
if (this.stroke > 0) {
primitive.stroke = this.stroke;
primitive.strokeColor = this.parseColor(this.strokeColor);
}
collector.addPrimitive(primitive);
}
}

View File

@@ -0,0 +1,418 @@
/**
* FGUIComponent
*
* ECS component for FairyGUI integration.
* Manages a FairyGUI package and displays a component from it.
*
* FairyGUI 的 ECS 组件,管理 FairyGUI 包并显示其中的组件
*/
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
import { GRoot } from '../core/GRoot';
import { GComponent } from '../core/GComponent';
import { UIPackage } from '../package/UIPackage';
import type { GObject } from '../core/GObject';
/**
* FGUI Component interface for ECS
* ECS 的 FGUI 组件接口
*/
export interface IFGUIComponentData {
/** FUI package asset GUID | FUI 包资产 GUID */
packageGuid: string;
/** Component name to display | 要显示的组件名称 */
componentName: string;
/** Width override (0 = use component default) | 宽度覆盖 (0 = 使用组件默认值) */
width: number;
/** Height override (0 = use component default) | 高度覆盖 (0 = 使用组件默认值) */
height: number;
/** X position | X 位置 */
x: number;
/** Y position | Y 位置 */
y: number;
/** Visibility | 可见性 */
visible: boolean;
/** Alpha (0-1) | 透明度 */
alpha: number;
/** Sorting order | 排序顺序 */
sortingOrder: number;
}
/**
* FGUIComponent
*
* ECS component that wraps a FairyGUI component.
* Allows loading FUI packages and displaying components from them.
*
* 封装 FairyGUI 组件的 ECS 组件,支持加载 FUI 包并显示其中的组件
*/
@ECSComponent('FGUIComponent')
@Serializable({ version: 1, typeId: 'FGUIComponent' })
export class FGUIComponent extends Component implements IFGUIComponentData {
// ============= Serialized Properties | 序列化属性 =============
/**
* FUI package asset GUID
* FUI 包资产 GUID
*/
@Serialize()
@Property({ type: 'asset', label: 'Package', extensions: ['.fui'] })
public packageGuid: string = '';
/**
* Component name to display from the package
* 要从包中显示的组件名称
*/
@Serialize()
@Property({ type: 'string', label: 'Component' })
public componentName: string = '';
/**
* Width override (0 = use component default)
* 宽度覆盖 (0 = 使用组件默认值)
*/
@Serialize()
@Property({ type: 'number', label: 'Width', min: 0 })
public width: number = 0;
/**
* Height override (0 = use component default)
* 高度覆盖 (0 = 使用组件默认值)
*/
@Serialize()
@Property({ type: 'number', label: 'Height', min: 0 })
public height: number = 0;
/**
* X position
* X 位置
*/
@Serialize()
@Property({ type: 'number', label: 'X' })
public x: number = 0;
/**
* Y position
* Y 位置
*/
@Serialize()
@Property({ type: 'number', label: 'Y' })
public y: number = 0;
/**
* Whether the component is visible
* 组件是否可见
*/
@Serialize()
@Property({ type: 'boolean', label: 'Visible' })
public visible: boolean = true;
/**
* Alpha (0-1)
* 透明度 (0-1)
*/
@Serialize()
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
public alpha: number = 1;
/**
* Sorting order for render priority
* 渲染优先级排序
*/
@Serialize()
@Property({ type: 'integer', label: 'Sorting Order' })
public sortingOrder: number = 0;
// ============= Runtime State (not serialized) | 运行时状态(不序列化)=============
/** Loaded UIPackage | 已加载的 UIPackage */
private _package: UIPackage | null = null;
/** Created GRoot instance | 创建的 GRoot 实例 */
private _root: GRoot | null = null;
/** Created component instance | 创建的组件实例 */
private _component: GObject | null = null;
/** Loading state | 加载状态 */
private _loading: boolean = false;
/** Error message if loading failed | 加载失败时的错误信息 */
private _error: string | null = null;
/**
* Version counter, incremented on every state change (load, component change)
* Used by Inspector to detect when to refresh UI
* 版本计数器,每次状态变化(加载、组件切换)时递增,用于 Inspector 检测何时刷新 UI
*/
private _version: number = 0;
/**
* Optional callback for state changes (used by editor for virtual node updates)
* 可选的状态变化回调(编辑器用于虚拟节点更新)
*/
private _onStateChange: ((type: 'loaded' | 'updated' | 'disposed') => void) | null = null;
// ============= Getters | 访问器 =============
/**
* Get the GRoot instance
* 获取 GRoot 实例
*/
public get root(): GRoot | null {
return this._root;
}
/**
* Get the loaded UIPackage
* 获取已加载的 UIPackage
*/
public get package(): UIPackage | null {
return this._package;
}
/**
* Get the created component
* 获取已创建的组件
*/
public get component(): GObject | null {
return this._component;
}
/**
* Check if currently loading
* 检查是否正在加载
*/
public get isLoading(): boolean {
return this._loading;
}
/**
* Get error message
* 获取错误信息
*/
public get error(): string | null {
return this._error;
}
/**
* Check if component is ready
* 检查组件是否已准备好
*/
public get isReady(): boolean {
return this._root !== null && this._component !== null;
}
/**
* Get version counter for change detection
* Used by Inspector to detect when to refresh UI
* 获取版本计数器用于变化检测,用于 Inspector 检测何时刷新 UI
*/
public get version(): number {
return this._version;
}
/**
* Set state change callback for editor integration
* 设置状态变化回调用于编辑器集成
*
* @param callback Called when component state changes ('loaded', 'updated', 'disposed')
*/
public set onStateChange(callback: ((type: 'loaded' | 'updated' | 'disposed') => void) | null) {
this._onStateChange = callback;
}
/**
* Get available component names from the loaded package
* 获取已加载包中可用的组件名称
*/
public getAvailableComponentNames(): string[] {
if (!this._package) return [];
return this._package.getExportedComponentNames();
}
/**
* Get all component names (including non-exported) from the loaded package
* 获取已加载包中所有组件名称(包括未导出的)
*/
public getAllComponentNames(): string[] {
if (!this._package) return [];
return this._package.getAllComponentNames();
}
// ============= Methods | 方法 =============
/**
* Initialize the FGUI root
* 初始化 FGUI 根节点
*/
public initRoot(width: number, height: number): void {
if (this._root) {
this._root.dispose();
}
this._root = new GRoot();
this._root.setSize(width, height);
}
/**
* Load package from binary data
* 从二进制数据加载包
*/
public loadPackage(resKey: string, data: ArrayBuffer): UIPackage {
this._loading = true;
this._error = null;
try {
this._package = UIPackage.addPackageFromBuffer(resKey, data);
this._loading = false;
return this._package;
} catch (e) {
this._loading = false;
this._error = e instanceof Error ? e.message : String(e);
throw e;
}
}
/**
* Set a pre-loaded package (from FUIAssetLoader)
* 设置预加载的包(来自 FUIAssetLoader
*/
public setLoadedPackage(pkg: UIPackage): void {
this._package = pkg;
this._loading = false;
this._error = null;
this._version++;
this._onStateChange?.('loaded');
}
/**
* Create component from loaded package
* 从已加载的包创建组件
*
* Note: Disposes existing component before creating new one to avoid visual overlap
* 注意:创建新组件前会先销毁已有组件,避免视觉叠加
*/
public createComponent(componentName?: string): GObject | null {
const name = componentName || this.componentName;
if (!this._package) {
return null;
}
if (!name) {
return null;
}
// Dispose existing component before creating new one
// 创建新组件前先销毁已有组件
if (this._component) {
if (this._root) {
this._root.removeChild(this._component);
}
this._component.dispose();
this._component = null;
}
try {
this._component = this._package.createObject(name);
if (this._component && this._root) {
this.syncProperties();
this._root.addChild(this._component);
}
this._version++;
this._onStateChange?.('updated');
return this._component;
} catch (e) {
// Log full error with stack trace for debugging
console.error(`[FGUIComponent] Error creating component "${name}":`, e);
this._error = e instanceof Error ? e.message : String(e);
return null;
}
}
/**
* Get child by name from the component
* 从组件中按名称获取子对象
*/
public getChild(name: string): GObject | null {
if (this._component instanceof GComponent) {
return this._component.getChild(name);
}
return null;
}
/**
* Get controller by name from the component
* 从组件中按名称获取控制器
*/
public getController(name: string) {
if (this._component instanceof GComponent) {
return this._component.getController(name);
}
return null;
}
/**
* Get transition by name from the component
* 从组件中按名称获取过渡动画
*/
public getTransition(name: string) {
if (this._component instanceof GComponent) {
return this._component.getTransition(name);
}
return null;
}
/**
* Update component properties from ECS data
* 从 ECS 数据更新组件属性
*/
public syncProperties(): void {
if (!this._component) return;
if (this.width > 0) {
this._component.width = this.width;
}
if (this.height > 0) {
this._component.height = this.height;
}
this._component.x = this.x;
this._component.y = this.y;
this._component.visible = this.visible;
this._component.alpha = this.alpha;
this._component.sortingOrder = this.sortingOrder;
}
/**
* Dispose and cleanup
* 释放和清理
*/
public dispose(): void {
const hadContent = this._component !== null || this._root !== null;
if (this._component) {
this._component.dispose();
this._component = null;
}
if (this._root) {
this._root.dispose();
this._root = null;
}
this._package = null;
this._error = null;
if (hadContent) {
this._onStateChange?.('disposed');
}
}
// ============= ECS Lifecycle | ECS 生命周期 =============
/**
* Called when component is removed from entity
* 组件从实体移除时调用
*/
public override onRemovedFromEntity(): void {
this.dispose();
}
}

View File

@@ -0,0 +1,209 @@
/**
* FGUIRenderSystem
*
* ECS system for rendering FairyGUI components.
* Collects render data from all FGUI components and submits to the engine.
*
* 用于渲染 FairyGUI 组件的 ECS 系统,收集所有 FGUI 组件的渲染数据并提交到引擎
*/
import type { IAssetManager } from '@esengine/asset-system';
import { createServiceToken } from '@esengine/ecs-framework';
/**
* Service token for FGUI render system
* FGUI 渲染系统的服务令牌
*/
export const FGUIRenderSystemToken = createServiceToken<FGUIRenderSystem>('fguiRenderSystem');
import { FGUIComponent } from './FGUIComponent';
import { RenderCollector } from '../render/RenderCollector';
import { Timer } from '../core/Timer';
/**
* Render submit callback type
* 渲染提交回调类型
*/
export type RenderSubmitCallback = (collector: RenderCollector) => void;
/**
* FGUIRenderSystem
*
* Manages rendering for all FairyGUI components in the scene.
* 管理场景中所有 FairyGUI 组件的渲染
*/
export class FGUIRenderSystem {
/** System update order | 系统更新顺序 */
public readonly updateOrder: number = 1000;
/** Render collector | 渲染收集器 */
private _collector: RenderCollector;
/** All registered FGUI components | 所有已注册的 FGUI 组件 */
private _components: Set<FGUIComponent> = new Set();
/** Render submit callback | 渲染提交回调 */
private _onSubmit: RenderSubmitCallback | null = null;
/** Whether the system is enabled | 系统是否启用 */
private _enabled: boolean = true;
/** Last update time | 上次更新时间 */
private _lastTime: number = 0;
/** Asset manager for loading FUI packages | 用于加载 FUI 包的资产管理器 */
private _assetManager: IAssetManager | null = null;
constructor() {
this._collector = new RenderCollector();
}
/**
* Set asset manager for loading FUI packages
* 设置用于加载 FUI 包的资产管理器
*/
public setAssetManager(assetManager: IAssetManager): void {
this._assetManager = assetManager;
}
/**
* Get asset manager
* 获取资产管理器
*/
public get assetManager(): IAssetManager | null {
return this._assetManager;
}
/**
* Set render submit callback
* 设置渲染提交回调
*/
public set onSubmit(callback: RenderSubmitCallback | null) {
this._onSubmit = callback;
}
/**
* Get render collector
* 获取渲染收集器
*/
public get collector(): RenderCollector {
return this._collector;
}
/**
* Enable or disable the system
* 启用或禁用系统
*/
public set enabled(value: boolean) {
this._enabled = value;
}
public get enabled(): boolean {
return this._enabled;
}
/**
* Register a FGUI component
* 注册 FGUI 组件
*/
public registerComponent(component: FGUIComponent): void {
this._components.add(component);
}
/**
* Unregister a FGUI component
* 注销 FGUI 组件
*/
public unregisterComponent(component: FGUIComponent): void {
this._components.delete(component);
}
/**
* Get all registered components
* 获取所有已注册的组件
*/
public getComponents(): ReadonlySet<FGUIComponent> {
return this._components;
}
/**
* Initialize the system
* 初始化系统
*/
public initialize(): void {
this._lastTime = performance.now() / 1000;
}
/**
* Update all FGUI components
* 更新所有 FGUI 组件
*/
public update(deltaTime?: number): void {
if (!this._enabled) return;
// Calculate delta time in seconds if not provided
const currentTime = performance.now() / 1000;
const dt = deltaTime ?? (currentTime - this._lastTime);
this._lastTime = currentTime;
// Update timers - Timer expects milliseconds
Timer.inst.update(dt * 1000);
// Clear collector for new frame
this._collector.clear();
// Sort components by sorting order
const sortedComponents = Array.from(this._components)
.filter(c => c.isReady && c.visible)
.sort((a, b) => a.sortingOrder - b.sortingOrder);
// Collect render data from each component
for (const component of sortedComponents) {
if (component.root) {
component.syncProperties();
component.root.collectRenderData(this._collector);
}
}
// Submit render data
if (this._onSubmit) {
this._onSubmit(this._collector);
}
}
/**
* Dispose the system
* 释放系统
*/
public dispose(): void {
for (const component of this._components) {
component.dispose();
}
this._components.clear();
this._onSubmit = null;
}
}
/**
* Default FGUI render system instance
* 默认 FGUI 渲染系统实例
*/
let _defaultSystem: FGUIRenderSystem | null = null;
/**
* Get default FGUI render system
* 获取默认 FGUI 渲染系统
*/
export function getFGUIRenderSystem(): FGUIRenderSystem {
if (!_defaultSystem) {
_defaultSystem = new FGUIRenderSystem();
}
return _defaultSystem;
}
/**
* Set default FGUI render system
* 设置默认 FGUI 渲染系统
*/
export function setFGUIRenderSystem(system: FGUIRenderSystem): void {
_defaultSystem = system;
}

View File

@@ -0,0 +1,179 @@
/**
* FGUIRuntimeModule
*
* Runtime module for FairyGUI integration with the ECS framework.
* Registers components and asset loaders.
*
* FairyGUI ECS 集成的运行时模块,注册组件和资产加载器
*/
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { CanvasElementToken } from '@esengine/engine-core';
import { AssetManagerToken, type IAssetManager } from '@esengine/asset-system';
import { FGUIComponent } from './FGUIComponent';
import { FGUIRenderSystem, setFGUIRenderSystem } from './FGUIRenderSystem';
import { FGUIUpdateSystem } from './FGUIUpdateSystem';
import { FUIAssetLoader, FUI_ASSET_TYPE } from '../asset/FUIAssetLoader';
import { Stage } from '../core/Stage';
import { getDynamicFontManager, COMMON_ASCII_CHARS } from '../text/DynamicFont';
/**
* FGUIRuntimeModule
*
* Implements IRuntimeModule for FairyGUI integration.
*
* 实现 IRuntimeModule 的 FairyGUI 集成模块
*/
export class FGUIRuntimeModule implements IRuntimeModule {
private _renderSystem: FGUIRenderSystem | null = null;
private _loaderRegistered = false;
/**
* Register components to ComponentRegistry
* 注册组件到 ComponentRegistry
*/
registerComponents(registry: IComponentRegistry): void {
registry.register(FGUIComponent);
}
/**
* Create systems for scene
* 为场景创建系统
*/
createSystems(scene: IScene, context: SystemContext): void {
// Get asset manager from service registry
const assetManager = context.services.get(AssetManagerToken);
// Register FUI asset loader
if (!this._loaderRegistered && assetManager) {
const loader = new FUIAssetLoader();
(assetManager as IAssetManager).registerLoader(FUI_ASSET_TYPE, loader);
this._loaderRegistered = true;
}
// Create and add FGUIUpdateSystem
const updateSystem = new FGUIUpdateSystem();
if (assetManager) {
updateSystem.setAssetManager(assetManager as IAssetManager);
}
scene.addSystem(updateSystem);
}
/**
* Called after all systems are created
* 所有系统创建完成后调用
*/
onSystemsCreated(_scene: IScene, context: SystemContext): void {
// Create render system (not an EntitySystem, handles its own update)
this._renderSystem = new FGUIRenderSystem();
// Set asset manager for the render system
const assetManager = context.services.get(AssetManagerToken);
if (assetManager) {
this._renderSystem.setAssetManager(assetManager as IAssetManager);
}
// Bind Stage to canvas for input events
const canvas = context.services.get(CanvasElementToken);
if (canvas) {
Stage.inst.bindToCanvas(canvas);
}
// Initialize dynamic font system with system default font
// 使用系统默认字体初始化动态字体系统
this.initDynamicFonts();
// Initialize the render system
this._renderSystem.initialize();
// Store global reference
setFGUIRenderSystem(this._renderSystem);
}
/**
* Initialize dynamic font system
* 初始化动态字体系统
*
* Creates a default dynamic font using system fonts.
* This allows text rendering without preloaded MSDF fonts.
*
* 创建使用系统字体的默认动态字体。
* 这允许在没有预加载 MSDF 字体的情况下渲染文本。
*/
private initDynamicFonts(): void {
const fontManager = getDynamicFontManager();
// Create default font using system fonts (cross-platform, no licensing issues)
// 使用系统字体创建默认字体(跨平台,无许可问题)
// Font stack: system-ui for modern browsers, then common fallbacks
const defaultFont = fontManager.createFont('default', {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif',
fontSize: 32,
atlasWidth: 1024,
atlasHeight: 1024,
padding: 2,
preloadChars: COMMON_ASCII_CHARS
});
// Also create Arial alias using system sans-serif
// 为 Arial 创建别名,使用系统 sans-serif
fontManager.createFont('Arial', {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif',
fontSize: 32,
atlasWidth: 1024,
atlasHeight: 1024,
padding: 2,
preloadChars: COMMON_ASCII_CHARS
});
// Register as MSDF-compatible fonts
// 注册为 MSDF 兼容字体
defaultFont.registerAsMSDFFont();
}
/**
* Get the render system
* 获取渲染系统
*/
get renderSystem(): FGUIRenderSystem | null {
return this._renderSystem;
}
}
/**
* Module manifest
* 模块清单
*/
const manifest: ModuleManifest = {
id: 'fairygui',
name: '@esengine/fairygui',
displayName: 'FairyGUI',
version: '1.0.0',
description: 'FairyGUI UI system for ECS framework',
category: 'Other',
icon: 'Layout',
isCore: false,
defaultEnabled: true,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math', 'asset-system'],
exports: {
components: ['FGUIComponent'],
systems: ['FGUIRenderSystem'],
loaders: ['FUIAssetLoader']
},
editorPackage: '@esengine/fairygui-editor',
assetExtensions: {
'.fui': 'fui'
}
};
/**
* FairyGUI Plugin
* FairyGUI 插件
*/
export const FGUIPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new FGUIRuntimeModule()
};

View File

@@ -0,0 +1,200 @@
/**
* FGUIUpdateSystem
*
* ECS system that handles automatic loading and updating of FGUIComponents.
*
* 处理 FGUIComponent 自动加载和更新的 ECS 系统
*/
import { EntitySystem, Matcher, type Entity, Time } from '@esengine/ecs-framework';
import type { IAssetManager } from '@esengine/asset-system';
import { FGUIComponent } from './FGUIComponent';
import { getFGUIRenderSystem } from './FGUIRenderSystem';
import type { IFUIAsset } from '../asset/FUIAssetLoader';
/**
* Tracked state for detecting property changes
* 用于检测属性变化的跟踪状态
*/
interface TrackedState {
packageGuid: string;
componentName: string;
}
/**
* FGUIUpdateSystem
*
* Automatically loads FUI packages and creates UI components for FGUIComponent.
* 自动为 FGUIComponent 加载 FUI 包并创建 UI 组件
*/
export class FGUIUpdateSystem extends EntitySystem {
private _assetManager: IAssetManager | null = null;
private _trackedStates: WeakMap<FGUIComponent, TrackedState> = new WeakMap();
private _pendingLoads: Map<FGUIComponent, Promise<void>> = new Map();
constructor() {
super(Matcher.empty().all(FGUIComponent));
}
public setAssetManager(assetManager: IAssetManager): void {
this._assetManager = assetManager;
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
if (!fguiComp) continue;
// Skip if currently loading
if (fguiComp.isLoading || this._pendingLoads.has(fguiComp)) {
continue;
}
// Check if we need to reload
const tracked = this._trackedStates.get(fguiComp);
const needsReload = this._needsReload(fguiComp, tracked);
if (needsReload && fguiComp.packageGuid) {
this._loadComponent(fguiComp);
}
}
const renderSystem = getFGUIRenderSystem();
if (renderSystem) {
renderSystem.update(Time.deltaTime);
}
}
/**
* Check if component needs to reload
* 检查组件是否需要重新加载
*/
private _needsReload(comp: FGUIComponent, tracked: TrackedState | undefined): boolean {
// Not tracked yet - needs initial load
if (!tracked) {
return true;
}
// Package changed - needs full reload
if (tracked.packageGuid !== comp.packageGuid) {
return true;
}
// Component name changed - needs to recreate component
if (tracked.componentName !== comp.componentName) {
// If package is already loaded, just recreate the component
if (comp.package && comp.componentName) {
comp.createComponent(comp.componentName);
// Update tracked state
this._trackedStates.set(comp, {
packageGuid: comp.packageGuid,
componentName: comp.componentName
});
}
return false;
}
return false;
}
private async _loadComponent(comp: FGUIComponent): Promise<void> {
if (!this._assetManager) {
return;
}
const loadPromise = this._doLoad(comp);
this._pendingLoads.set(comp, loadPromise);
try {
await loadPromise;
} finally {
this._pendingLoads.delete(comp);
}
}
private async _doLoad(comp: FGUIComponent): Promise<void> {
const packageRef = comp.packageGuid;
// Dispose previous content before loading new package
comp.dispose();
try {
// Check if packageRef is a path (contains / or . before extension) or a GUID
// GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// Path format: assets/ui/Bag.fui or similar
const isPath = packageRef.includes('/') || packageRef.includes('\\') || packageRef.endsWith('.fui');
const result = isPath
? await this._assetManager!.loadAssetByPath<IFUIAsset>(packageRef)
: await this._assetManager!.loadAsset<IFUIAsset>(packageRef);
if (!result || !result.asset) {
return;
}
const fuiAsset = result.asset;
if (fuiAsset.package) {
const width = comp.width > 0 ? comp.width : 1920;
const height = comp.height > 0 ? comp.height : 1080;
comp.initRoot(width, height);
comp.setLoadedPackage(fuiAsset.package);
if (comp.componentName) {
comp.createComponent(comp.componentName);
}
} else {
const asset = fuiAsset as unknown;
let data: ArrayBuffer | null = null;
if (asset instanceof ArrayBuffer) {
data = asset;
} else if (typeof asset === 'object' && asset !== null && 'data' in asset && (asset as { data: ArrayBuffer }).data instanceof ArrayBuffer) {
data = (asset as { data: ArrayBuffer }).data;
} else if (typeof asset === 'object' && asset !== null && 'buffer' in asset) {
data = (asset as { buffer: ArrayBuffer }).buffer;
}
if (!data) {
return;
}
const width = comp.width > 0 ? comp.width : 1920;
const height = comp.height > 0 ? comp.height : 1080;
comp.initRoot(width, height);
comp.loadPackage(packageRef, data);
if (comp.componentName) {
comp.createComponent(comp.componentName);
}
}
const renderSystem = getFGUIRenderSystem();
if (renderSystem && comp.isReady) {
renderSystem.registerComponent(comp);
}
// Update tracked state after successful load
this._trackedStates.set(comp, {
packageGuid: comp.packageGuid,
componentName: comp.componentName
});
} catch (err) {
console.error(`[FGUI] Error loading package ${packageRef}:`, err);
}
}
protected override onDestroy(): void {
const renderSystem = getFGUIRenderSystem();
if (renderSystem && this.scene) {
for (const entity of this.scene.entities.buffer) {
const fguiComp = entity.getComponent(FGUIComponent) as FGUIComponent | null;
if (fguiComp) {
renderSystem.unregisterComponent(fguiComp);
}
}
}
this._pendingLoads.clear();
this._trackedStates = new WeakMap();
}
}

View File

@@ -0,0 +1,21 @@
/**
* FairyGUI ECS Integration
*
* ECS components, systems, and runtime module for FairyGUI integration.
*
* FairyGUI 的 ECS 组件、系统和运行时模块
*/
export { FGUIComponent } from './FGUIComponent';
export type { IFGUIComponentData } from './FGUIComponent';
export {
FGUIRenderSystem,
FGUIRenderSystemToken,
getFGUIRenderSystem,
setFGUIRenderSystem
} from './FGUIRenderSystem';
export type { RenderSubmitCallback } from './FGUIRenderSystem';
export { FGUIUpdateSystem } from './FGUIUpdateSystem';
export { FGUIRuntimeModule, FGUIPlugin } from './FGUIRuntimeModule';

View File

@@ -0,0 +1,349 @@
import type { FGUIEvents } from './Events';
/**
* Event type key from FGUIEvents
* FGUIEvents 事件类型键
*/
export type FGUIEventType = (typeof FGUIEvents)[keyof typeof FGUIEvents];
/**
* Event data mapping - maps event types to their data types
* 事件数据映射 - 将事件类型映射到其数据类型
*/
export interface IEventDataMap {
[key: string]: unknown;
}
/**
* Event listener callback with type safety
* 类型安全的事件监听回调
*/
export type TypedEventListener<T = unknown> = (data: T) => void;
/**
* Legacy event listener (for backwards compatibility)
* 传统事件监听器(向后兼容)
*/
export type EventListener = (data?: unknown) => void;
/**
* Event listener info
* 事件监听信息
*/
interface ListenerInfo<T = unknown> {
listener: TypedEventListener<T>;
thisArg: unknown;
once: boolean;
priority: number;
}
/**
* Event propagation control
* 事件传播控制
*/
export interface IEventContext {
/** Stop propagation | 停止传播 */
stopped: boolean;
/** Prevent default behavior | 阻止默认行为 */
defaultPrevented: boolean;
/** Event type | 事件类型 */
type: string;
/** Current target | 当前目标 */
currentTarget: EventDispatcher | null;
/** Original target | 原始目标 */
target: EventDispatcher | null;
}
/**
* Create event context
* 创建事件上下文
*/
function createEventContext(type: string, target: EventDispatcher): IEventContext {
return {
stopped: false,
defaultPrevented: false,
type,
currentTarget: target,
target
};
}
/**
* EventDispatcher
*
* Modern event dispatching system with type safety and priority support.
*
* 现代化的事件分发系统,支持类型安全和优先级
*
* Features:
* - Type-safe event listeners
* - Priority-based listener ordering
* - Event propagation control
* - Capture phase support
* - Memory-efficient listener management
*/
export class EventDispatcher {
private _listeners: Map<string, ListenerInfo[]> = new Map();
private _captureListeners: Map<string, ListenerInfo[]> = new Map();
private _dispatching: Set<string> = new Set();
private _pendingRemovals: Map<string, ListenerInfo[]> = new Map();
/**
* Register an event listener with optional priority
* 注册事件监听器(支持优先级)
*
* @param type Event type | 事件类型
* @param listener Callback function | 回调函数
* @param thisArg Context for callback | 回调上下文
* @param priority Higher priority listeners are called first (default: 0) | 优先级越高越先调用
*/
public on<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._listeners, type, listener as TypedEventListener, thisArg, false, priority);
return this;
}
/**
* Register a one-time event listener
* 注册一次性事件监听器
*/
public once<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._listeners, type, listener as TypedEventListener, thisArg, true, priority);
return this;
}
/**
* Remove an event listener
* 移除事件监听器
*/
public off<T = unknown>(type: string, listener: TypedEventListener<T>, thisArg?: unknown): this {
this.removeListener(this._listeners, type, listener as TypedEventListener, thisArg);
return this;
}
/**
* Remove all listeners for a type, or all listeners
* 移除指定类型的所有监听器,或移除所有监听器
*/
public offAll(type?: string): this {
if (type) {
this._listeners.delete(type);
this._captureListeners.delete(type);
} else {
this._listeners.clear();
this._captureListeners.clear();
}
return this;
}
/**
* Emit an event with typed data
* 发送带类型数据的事件
*
* @returns true if event was handled, false otherwise
*/
public emit<T = unknown>(type: string, data?: T): boolean {
const listeners = this._listeners.get(type);
if (!listeners || listeners.length === 0) {
return false;
}
this._dispatching.add(type);
const toRemove: ListenerInfo[] = [];
try {
for (const info of listeners) {
try {
info.listener.call(info.thisArg, data);
} catch (error) {
console.error(`Error in event listener for "${type}":`, error);
}
if (info.once) {
toRemove.push(info);
}
}
} finally {
this._dispatching.delete(type);
}
// Remove one-time listeners
for (const info of toRemove) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
// Process pending removals
const pending = this._pendingRemovals.get(type);
if (pending) {
for (const info of pending) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
this._pendingRemovals.delete(type);
}
return true;
}
/**
* Emit with event context for propagation control
* 发送带事件上下文的事件(用于传播控制)
*/
public emitWithContext<T = unknown>(type: string, data?: T): IEventContext {
const context = createEventContext(type, this);
const listeners = this._listeners.get(type);
if (listeners && listeners.length > 0) {
this._dispatching.add(type);
const toRemove: ListenerInfo[] = [];
try {
for (const info of listeners) {
if (context.stopped) break;
try {
info.listener.call(info.thisArg, data);
} catch (error) {
console.error(`Error in event listener for "${type}":`, error);
}
if (info.once) {
toRemove.push(info);
}
}
} finally {
this._dispatching.delete(type);
}
for (const info of toRemove) {
this.removeListener(this._listeners, type, info.listener, info.thisArg);
}
}
return context;
}
/**
* Check if there are any listeners for a type
* 检查是否有指定类型的监听器
*/
public hasListener(type: string): boolean {
const listeners = this._listeners.get(type);
return listeners !== undefined && listeners.length > 0;
}
/**
* Get listener count for a type
* 获取指定类型的监听器数量
*/
public listenerCount(type: string): number {
const listeners = this._listeners.get(type);
return listeners?.length ?? 0;
}
/**
* Register a capture phase listener
* 注册捕获阶段监听器
*/
public onCapture<T = unknown>(
type: string,
listener: TypedEventListener<T>,
thisArg?: unknown,
priority: number = 0
): this {
this.addListener(this._captureListeners, type, listener as TypedEventListener, thisArg, false, priority);
return this;
}
/**
* Remove a capture phase listener
* 移除捕获阶段监听器
*/
public offCapture<T = unknown>(type: string, listener: TypedEventListener<T>, thisArg?: unknown): this {
this.removeListener(this._captureListeners, type, listener as TypedEventListener, thisArg);
return this;
}
/**
* Dispose all listeners
* 销毁所有监听器
*/
public dispose(): void {
this._listeners.clear();
this._captureListeners.clear();
this._dispatching.clear();
this._pendingRemovals.clear();
}
private addListener(
map: Map<string, ListenerInfo[]>,
type: string,
listener: TypedEventListener,
thisArg: unknown,
once: boolean,
priority: number
): void {
let listeners = map.get(type);
if (!listeners) {
listeners = [];
map.set(type, listeners);
}
// Check for duplicate
const exists = listeners.some((info) => info.listener === listener && info.thisArg === thisArg);
if (exists) return;
const info: ListenerInfo = { listener, thisArg, once, priority };
// Insert by priority (higher priority first)
let inserted = false;
for (let i = 0; i < listeners.length; i++) {
if (priority > listeners[i].priority) {
listeners.splice(i, 0, info);
inserted = true;
break;
}
}
if (!inserted) {
listeners.push(info);
}
}
private removeListener(
map: Map<string, ListenerInfo[]>,
type: string,
listener: TypedEventListener,
thisArg: unknown
): void {
const listeners = map.get(type);
if (!listeners) return;
// If dispatching, defer removal
if (this._dispatching.has(type)) {
let pending = this._pendingRemovals.get(type);
if (!pending) {
pending = [];
this._pendingRemovals.set(type, pending);
}
pending.push({ listener, thisArg, once: false, priority: 0 });
return;
}
const index = listeners.findIndex((info) => info.listener === listener && info.thisArg === thisArg);
if (index !== -1) {
listeners.splice(index, 1);
if (listeners.length === 0) {
map.delete(type);
}
}
}
}

View File

@@ -0,0 +1,142 @@
/**
* FairyGUI Event Types
* FairyGUI 事件类型常量
*/
export const FGUIEvents = {
/** Size changed | 尺寸改变 */
SIZE_CHANGED: 'fguiSizeChanged',
/** Position changed | 位置改变 */
XY_CHANGED: 'fguiXYChanged',
/** Click event | 点击事件 */
CLICK: 'click',
/** Touch/Mouse begin | 触摸/鼠标按下 */
TOUCH_BEGIN: 'touchBegin',
/** Touch/Mouse end | 触摸/鼠标抬起 */
TOUCH_END: 'touchEnd',
/** Touch/Mouse move | 触摸/鼠标移动 */
TOUCH_MOVE: 'touchMove',
/** Roll over (mouse enter) | 鼠标进入 */
ROLL_OVER: 'rollOver',
/** Roll out (mouse leave) | 鼠标离开 */
ROLL_OUT: 'rollOut',
/** Focus in | 获得焦点 */
FOCUS_IN: 'focusIn',
/** Focus out | 失去焦点 */
FOCUS_OUT: 'focusOut',
/** Added to stage | 添加到舞台 */
ADDED_TO_STAGE: 'addedToStage',
/** Removed from stage | 从舞台移除 */
REMOVED_FROM_STAGE: 'removedFromStage',
/** Display (added and visible) | 显示(添加并可见) */
DISPLAY: 'display',
/** Status changed (for Controller) | 状态改变(控制器) */
STATUS_CHANGED: 'statusChanged',
/** State changed (for Button/Slider) | 状态改变(按钮/滑块) */
STATE_CHANGED: 'stateChanged',
/** Pull down release (for list refresh) | 下拉刷新释放 */
PULL_DOWN_RELEASE: 'pullDownRelease',
/** Pull up release (for list load more) | 上拉加载释放 */
PULL_UP_RELEASE: 'pullUpRelease',
/** Scroll event | 滚动事件 */
SCROLL: 'scroll',
/** Scroll end | 滚动结束 */
SCROLL_END: 'scrollEnd',
/** Drag start | 拖拽开始 */
DRAG_START: 'dragStart',
/** Drag move | 拖拽移动 */
DRAG_MOVE: 'dragMove',
/** Drag end | 拖拽结束 */
DRAG_END: 'dragEnd',
/** Drop event | 放下事件 */
DROP: 'drop',
/** Text changed | 文本改变 */
TEXT_CHANGED: 'textChanged',
/** Text submitted (Enter key) | 文本提交(回车键) */
TEXT_SUBMIT: 'textSubmit',
/** Gear stop (animation complete) | 齿轮动画停止 */
GEAR_STOP: 'gearStop',
/** Link click (rich text) | 链接点击(富文本) */
LINK: 'link',
/** Play complete (MovieClip/Transition) | 播放完成 */
PLAY_COMPLETE: 'playComplete',
/** Click on list item | 列表项点击 */
CLICK_ITEM: 'clickItem'
} as const;
/**
* Input event data
* 输入事件数据
*/
export interface IInputEventData {
/** Touch/Pointer ID | 触摸/指针 ID */
touchId: number;
/** Stage X position | 舞台 X 坐标 */
stageX: number;
/** Stage Y position | 舞台 Y 坐标 */
stageY: number;
/** Button pressed (0=left, 1=middle, 2=right) | 按下的按钮 */
button: number;
/** Wheel delta | 滚轮增量 */
wheelDelta: number;
/** Is Ctrl key pressed | 是否按下 Ctrl */
ctrlKey: boolean;
/** Is Shift key pressed | 是否按下 Shift */
shiftKey: boolean;
/** Is Alt key pressed | 是否按下 Alt */
altKey: boolean;
/** Original DOM event | 原始 DOM 事件 */
nativeEvent?: MouseEvent | TouchEvent | WheelEvent;
}
/**
* Create default input event data
* 创建默认输入事件数据
*/
export function createInputEventData(): IInputEventData {
return {
touchId: 0,
stageX: 0,
stageY: 0,
button: 0,
wheelDelta: 0,
ctrlKey: false,
shiftKey: false,
altKey: false
};
}

View File

@@ -0,0 +1,70 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* Animation value for GearAnimation
* GearAnimation 的动画值
*/
interface IAnimationValue {
playing: boolean;
frame: number;
}
/**
* GearAnimation
*
* Controls object animation state based on controller state.
* 根据控制器状态控制对象动画状态
*/
export class GearAnimation extends GearBase {
private _storage: Map<string, IAnimationValue> = new Map();
private _default: IAnimationValue = { playing: true, frame: 0 };
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
playing: (this.owner.getProp(EObjectPropID.Playing) as boolean) ?? true,
frame: (this.owner.getProp(EObjectPropID.Frame) as number) ?? 0
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.setProp(EObjectPropID.Playing, gv.playing);
this.owner.setProp(EObjectPropID.Frame, gv.frame);
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
const gv: IAnimationValue = {
playing: (this.owner.getProp(EObjectPropID.Playing) as boolean) ?? true,
frame: (this.owner.getProp(EObjectPropID.Frame) as number) ?? 0
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status
* 添加状态
*/
public addStatus(pageId: string | null, playing: boolean, frame: number): void {
if (pageId === null) {
this._default.playing = playing;
this._default.frame = frame;
} else {
this._storage.set(pageId, { playing, frame });
}
}
}

View File

@@ -0,0 +1,152 @@
import type { GObject } from '../core/GObject';
import type { Controller } from '../core/Controller';
import type { ByteBuffer } from '../utils/ByteBuffer';
import { EEaseType } from '../core/FieldTypes';
/**
* GearBase
*
* Base class for all gear types.
* Gears connect object properties to controller states.
*
* 所有齿轮类型的基类,齿轮将对象属性连接到控制器状态
*/
export abstract class GearBase {
/** Owner object | 所有者对象 */
public readonly owner: GObject;
/** Controller | 控制器 */
protected _controller: Controller | null = null;
/** Tween config | 缓动配置 */
public tweenConfig: GearTweenConfig | null = null;
constructor(owner: GObject) {
this.owner = owner;
}
/**
* Get controller
* 获取控制器
*/
public get controller(): Controller | null {
return this._controller;
}
/**
* Set controller
* 设置控制器
*/
public set controller(value: Controller | null) {
if (this._controller !== value) {
this._controller = value;
if (this._controller) {
this.init();
}
}
}
/**
* Check if connected to a controller
* 检查是否连接到控制器
*/
public get connected(): boolean {
return this._controller !== null;
}
/**
* Initialize gear
* 初始化齿轮
*/
protected abstract init(): void;
/**
* Apply gear values
* 应用齿轮值
*/
public abstract apply(): void;
/**
* Update current state
* 更新当前状态
*/
public abstract updateState(): void;
/**
* Setup gear from buffer
* 从缓冲区设置齿轮
*/
public setup(buffer: ByteBuffer): void {
const parent = this.owner.parent;
if (!parent) return;
this._controller = parent.getControllerAt(buffer.getInt16());
this.init();
const cnt = buffer.getInt16();
// Read pages - subclasses should override to parse their specific data
this.readStatusFromBuffer(buffer, cnt);
// Read default status
if (buffer.readBool()) {
this.readDefaultStatusFromBuffer(buffer);
}
// Read tween config
if (buffer.readBool()) {
this.tweenConfig = new GearTweenConfig();
this.tweenConfig.easeType = buffer.readByte() as EEaseType;
this.tweenConfig.duration = buffer.getFloat32();
this.tweenConfig.delay = buffer.getFloat32();
}
}
/**
* Read status data from buffer
* 从缓冲区读取状态数据
*/
protected readStatusFromBuffer(_buffer: ByteBuffer, _cnt: number): void {
// Override in subclasses to parse specific gear data
// Default: skip the data (each page has a string ID)
for (let i = 0; i < _cnt; i++) {
_buffer.readS(); // page id
// Subclass should read its specific data here
}
}
/**
* Read default status from buffer
* 从缓冲区读取默认状态
*/
protected readDefaultStatusFromBuffer(_buffer: ByteBuffer): void {
// Override in subclasses
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
this._controller = null;
this.tweenConfig = null;
}
}
/**
* Gear tween configuration
* 齿轮缓动配置
*/
export class GearTweenConfig {
/** Tween enabled | 是否启用缓动 */
public tween: boolean = true;
/** Ease type | 缓动类型 */
public easeType: EEaseType = EEaseType.QuadOut;
/** Duration in seconds | 持续时间(秒) */
public duration: number = 0.3;
/** Delay in seconds | 延迟时间(秒) */
public delay: number = 0;
}

View File

@@ -0,0 +1,74 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* Color value for GearColor
* GearColor 的颜色值
*/
interface IColorValue {
color: number | null;
strokeColor: number | null;
}
/**
* GearColor
*
* Controls object color and stroke color based on controller state.
* 根据控制器状态控制对象颜色和描边颜色
*/
export class GearColor extends GearBase {
private _storage: Map<string, IColorValue> = new Map();
private _default: IColorValue = { color: null, strokeColor: null };
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
color: this.owner.getProp(EObjectPropID.Color) as number | null,
strokeColor: this.owner.getProp(EObjectPropID.OutlineColor) as number | null
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
if (gv.color !== null) {
this.owner.setProp(EObjectPropID.Color, gv.color);
}
if (gv.strokeColor !== null) {
this.owner.setProp(EObjectPropID.OutlineColor, gv.strokeColor);
}
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
const gv: IColorValue = {
color: this.owner.getProp(EObjectPropID.Color) as number | null,
strokeColor: this.owner.getProp(EObjectPropID.OutlineColor) as number | null
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status from buffer
* 从缓冲区添加状态
*/
public addStatus(pageId: string | null, color: number | null, strokeColor: number | null): void {
if (pageId === null) {
this._default.color = color;
this._default.strokeColor = strokeColor;
} else {
this._storage.set(pageId, { color, strokeColor });
}
}
}

View File

@@ -0,0 +1,71 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearDisplay
*
* Controls object visibility based on controller state.
* 根据控制器状态控制对象可见性
*/
export class GearDisplay extends GearBase {
/** Pages where object is visible | 对象可见的页面列表 */
public pages: string[] = [];
private _visible: number = 0;
private _displayLockToken: number = 1;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this.pages = [];
}
public apply(): void {
this._displayLockToken++;
if (this._displayLockToken === 0) {
this._displayLockToken = 1;
}
if (
this.pages.length === 0 ||
(this._controller && this.pages.indexOf(this._controller.selectedPageId) !== -1)
) {
this._visible = 1;
} else {
this._visible = 0;
}
}
public updateState(): void {
// GearDisplay doesn't need to save state
}
/**
* Add display lock
* 添加显示锁
*/
public addLock(): number {
this._visible++;
return this._displayLockToken;
}
/**
* Release display lock
* 释放显示锁
*/
public releaseLock(token: number): void {
if (token === this._displayLockToken) {
this._visible--;
}
}
/**
* Check if object should be visible
* 检查对象是否应该可见
*/
public override get connected(): boolean {
return this._controller === null || this._visible > 0;
}
}

View File

@@ -0,0 +1,67 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearDisplay2
*
* Advanced display control that combines multiple controllers.
* 高级显示控制,组合多个控制器
*/
export class GearDisplay2 extends GearBase {
/** Pages where object is visible | 对象可见的页面列表 */
public pages: string[] = [];
/** Condition: 0=AND, 1=OR | 条件0=与1=或 */
public condition: number = 0;
private _visible: number = 0;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this.pages = [];
}
public apply(): void {
if (
this.pages.length === 0 ||
(this._controller && this.pages.indexOf(this._controller.selectedPageId) !== -1)
) {
this._visible = 1;
} else {
this._visible = 0;
}
}
public updateState(): void {
// GearDisplay2 doesn't need to save state
}
/**
* Evaluate visibility with condition
* 根据条件评估可见性
*/
public evaluate(bConnected: boolean): boolean {
if (this._controller === null) {
return true;
}
if (this.condition === 0) {
// AND condition
return bConnected && this._visible > 0;
} else {
// OR condition
return bConnected || this._visible > 0;
}
}
/**
* Check if object should be visible
* 检查对象是否应该可见
*/
public override get connected(): boolean {
return this._controller === null || this._visible > 0;
}
}

View File

@@ -0,0 +1,53 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* GearFontSize
*
* Controls object font size based on controller state.
* 根据控制器状态控制对象字体大小
*/
export class GearFontSize extends GearBase {
private _storage: Map<string, number> = new Map();
private _default: number = 12;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = (this.owner.getProp(EObjectPropID.FontSize) as number) ?? 12;
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const fontSize = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.setProp(EObjectPropID.FontSize, fontSize);
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(
this._controller.selectedPageId,
(this.owner.getProp(EObjectPropID.FontSize) as number) ?? 12
);
}
/**
* Add status
* 添加状态
*/
public addStatus(pageId: string | null, fontSize: number): void {
if (pageId === null) {
this._default = fontSize;
} else {
this._storage.set(pageId, fontSize);
}
}
}

View File

@@ -0,0 +1,49 @@
import { GearBase } from './GearBase';
import type { GObject } from '../core/GObject';
/**
* GearIcon
*
* Controls object icon based on controller state.
* 根据控制器状态控制对象图标
*/
export class GearIcon extends GearBase {
private _storage: Map<string, string> = new Map();
private _default: string = '';
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = this.owner.icon ?? '';
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const icon = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.icon = icon;
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(this._controller.selectedPageId, this.owner.icon ?? '');
}
/**
* Add status
* 添加状态
*/
public addStatus(pageId: string | null, icon: string): void {
if (pageId === null) {
this._default = icon;
} else {
this._storage.set(pageId, icon);
}
}
}

View File

@@ -0,0 +1,122 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Look value for GearLook
* GearLook 的外观值
*/
interface ILookValue {
alpha: number;
rotation: number;
grayed: boolean;
touchable: boolean;
}
/**
* GearLook
*
* Controls object appearance (alpha, rotation, grayed, touchable) based on controller state.
* 根据控制器状态控制对象外观(透明度、旋转、灰度、可触摸)
*/
export class GearLook extends GearBase {
private _storage: Map<string, ILookValue> = new Map();
private _default: ILookValue = { alpha: 1, rotation: 0, grayed: false, touchable: true };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
alpha: this.owner.alpha,
rotation: this.owner.rotation,
grayed: this.owner.grayed,
touchable: this.owner.touchable
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
// grayed and touchable cannot be tweened, apply immediately
this.owner._gearLocked = true;
this.owner.grayed = gv.grayed;
this.owner.touchable = gv.touchable;
this.owner._gearLocked = false;
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (this._tweener.endValue.x !== gv.alpha || this._tweener.endValue.y !== gv.rotation) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const oa = this.owner.alpha;
const or = this.owner.rotation;
if (oa !== gv.alpha || or !== gv.rotation) {
this._tweener = GTween.to2(oa, or, gv.alpha, gv.rotation, this.tweenConfig.duration)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'look')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.alpha = tweener.value.x;
this.owner.rotation = tweener.value.y;
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.alpha = gv.alpha;
this.owner.rotation = gv.rotation;
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const gv: ILookValue = {
alpha: this.owner.alpha,
rotation: this.owner.rotation,
grayed: this.owner.grayed,
touchable: this.owner.touchable
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Add status from buffer
* 从缓冲区添加状态
*/
public addStatus(
pageId: string | null,
alpha: number,
rotation: number,
grayed: boolean,
touchable: boolean
): void {
if (pageId === null) {
this._default.alpha = alpha;
this._default.rotation = rotation;
this._default.grayed = grayed;
this._default.touchable = touchable;
} else {
this._storage.set(pageId, { alpha, rotation, grayed, touchable });
}
}
}

View File

@@ -0,0 +1,150 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Size value for GearSize
* GearSize 的尺寸值
*/
interface ISizeValue {
width: number;
height: number;
scaleX: number;
scaleY: number;
}
/**
* GearSize
*
* Controls object size and scale based on controller state.
* 根据控制器状态控制对象尺寸和缩放
*/
export class GearSize extends GearBase {
private _storage: Map<string, ISizeValue> = new Map();
private _default: ISizeValue = { width: 0, height: 0, scaleX: 1, scaleY: 1 };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = {
width: this.owner.width,
height: this.owner.height,
scaleX: this.owner.scaleX,
scaleY: this.owner.scaleY
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (
this._tweener.endValue.x !== gv.width ||
this._tweener.endValue.y !== gv.height ||
this._tweener.endValue.z !== gv.scaleX ||
this._tweener.endValue.w !== gv.scaleY
) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const ow = this.owner.width;
const oh = this.owner.height;
const osx = this.owner.scaleX;
const osy = this.owner.scaleY;
if (ow !== gv.width || oh !== gv.height || osx !== gv.scaleX || osy !== gv.scaleY) {
this._tweener = GTween.to4(
ow,
oh,
osx,
osy,
gv.width,
gv.height,
gv.scaleX,
gv.scaleY,
this.tweenConfig.duration
)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'size')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.setSize(tweener.value.x, tweener.value.y);
this.owner.setScale(tweener.value.z, tweener.value.w);
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.setSize(gv.width, gv.height);
this.owner.setScale(gv.scaleX, gv.scaleY);
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const gv: ISizeValue = {
width: this.owner.width,
height: this.owner.height,
scaleX: this.owner.scaleX,
scaleY: this.owner.scaleY
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Update size from relation changes
* 从关联变更中更新尺寸
*/
public updateFromRelations(dWidth: number, dHeight: number): void {
if (!this._controller) return;
for (const gv of this._storage.values()) {
gv.width += dWidth;
gv.height += dHeight;
}
this._default.width += dWidth;
this._default.height += dHeight;
this.updateState();
}
/**
* Add status from buffer
* 从缓冲区添加状态
*/
public addStatus(
pageId: string | null,
width: number,
height: number,
scaleX: number,
scaleY: number
): void {
if (pageId === null) {
this._default.width = width;
this._default.height = height;
this._default.scaleX = scaleX;
this._default.scaleY = scaleY;
} else {
this._storage.set(pageId, { width, height, scaleX, scaleY });
}
}
}

View File

@@ -0,0 +1,50 @@
import { GearBase } from './GearBase';
import { EObjectPropID } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
/**
* GearText
*
* Controls object text content based on controller state.
* 根据控制器状态控制对象文本内容
*/
export class GearText extends GearBase {
private _storage: Map<string, string> = new Map();
private _default: string = '';
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
this._default = this.owner.text ?? '';
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const text = this._storage.get(this._controller.selectedPageId) ?? this._default;
this.owner._gearLocked = true;
this.owner.text = text;
this.owner._gearLocked = false;
}
public updateState(): void {
if (!this._controller) return;
this._storage.set(this._controller.selectedPageId, this.owner.text ?? '');
}
/**
* Add status
* 添加状态
*/
public addStatus(pageId: string | null, text: string): void {
if (pageId === null) {
this._default = text;
} else {
this._storage.set(pageId, text);
}
}
}

View File

@@ -0,0 +1,159 @@
import { GearBase } from './GearBase';
import { GTween } from '../tween/GTween';
import type { GTweener } from '../tween/GTweener';
import type { GObject } from '../core/GObject';
/**
* Position value for GearXY
* GearXY 的位置值
*/
interface IPositionValue {
x: number;
y: number;
px: number;
py: number;
}
/**
* GearXY
*
* Controls object position based on controller state.
* 根据控制器状态控制对象位置
*/
export class GearXY extends GearBase {
/** Use percent positions | 使用百分比位置 */
public positionsInPercent: boolean = false;
private _storage: Map<string, IPositionValue> = new Map();
private _default: IPositionValue = { x: 0, y: 0, px: 0, py: 0 };
private _tweener: GTweener | null = null;
constructor(owner: GObject) {
super(owner);
}
protected init(): void {
const parent = this.owner.parent;
this._default = {
x: this.owner.x,
y: this.owner.y,
px: parent ? this.owner.x / parent.width : 0,
py: parent ? this.owner.y / parent.height : 0
};
this._storage.clear();
}
public apply(): void {
if (!this._controller) return;
const gv = this._storage.get(this._controller.selectedPageId) ?? this._default;
const parent = this.owner.parent;
let ex: number;
let ey: number;
if (this.positionsInPercent && parent) {
ex = gv.px * parent.width;
ey = gv.py * parent.height;
} else {
ex = gv.x;
ey = gv.y;
}
if (this.tweenConfig?.tween && this.owner.onStage) {
if (this._tweener) {
if (this._tweener.endValue.x !== ex || this._tweener.endValue.y !== ey) {
this._tweener.kill();
this._tweener = null;
} else {
return;
}
}
const ox = this.owner.x;
const oy = this.owner.y;
if (ox !== ex || oy !== ey) {
this._tweener = GTween.to2(ox, oy, ex, ey, this.tweenConfig.duration)
.setDelay(this.tweenConfig.delay)
.setEase(this.tweenConfig.easeType)
.setTarget(this, 'xy')
.onUpdate((tweener) => {
this.owner._gearLocked = true;
this.owner.setXY(tweener.value.x, tweener.value.y);
this.owner._gearLocked = false;
})
.onComplete(() => {
this._tweener = null;
});
}
} else {
this.owner._gearLocked = true;
this.owner.setXY(ex, ey);
this.owner._gearLocked = false;
}
}
public updateState(): void {
if (!this._controller) return;
const parent = this.owner.parent;
const gv: IPositionValue = {
x: this.owner.x,
y: this.owner.y,
px: parent ? this.owner.x / parent.width : 0,
py: parent ? this.owner.y / parent.height : 0
};
this._storage.set(this._controller.selectedPageId, gv);
}
/**
* Update positions from relation changes
* 从关联变更中更新位置
*/
public updateFromRelations(dx: number, dy: number): void {
if (!this._controller || this.positionsInPercent) return;
for (const gv of this._storage.values()) {
gv.x += dx;
gv.y += dy;
}
this._default.x += dx;
this._default.y += dy;
this.updateState();
}
/**
* Add status from buffer
* 从缓冲区添加状态
*/
public addStatus(pageId: string | null, x: number, y: number): void {
if (pageId === null) {
this._default.x = x;
this._default.y = y;
} else {
const gv = this._storage.get(pageId) ?? { x: 0, y: 0, px: 0, py: 0 };
gv.x = x;
gv.y = y;
this._storage.set(pageId, gv);
}
}
/**
* Add extended status (percent values)
* 添加扩展状态(百分比值)
*/
public addExtStatus(pageId: string | null, px: number, py: number): void {
if (pageId === null) {
this._default.px = px;
this._default.py = py;
} else {
const gv = this._storage.get(pageId);
if (gv) {
gv.px = px;
gv.py = py;
}
}
}
}

View File

@@ -0,0 +1,11 @@
export { GearBase, GearTweenConfig } from './GearBase';
export { GearDisplay } from './GearDisplay';
export { GearDisplay2 } from './GearDisplay2';
export { GearXY } from './GearXY';
export { GearSize } from './GearSize';
export { GearLook } from './GearLook';
export { GearColor } from './GearColor';
export { GearText } from './GearText';
export { GearIcon } from './GearIcon';
export { GearFontSize } from './GearFontSize';
export { GearAnimation } from './GearAnimation';

View File

@@ -0,0 +1,254 @@
/**
* @esengine/fairygui
*
* FairyGUI ECS integration package.
* Provides a complete UI system compatible with FairyGUI Editor.
*
* FairyGUI ECS 集成包,提供与 FairyGUI 编辑器兼容的完整 UI 系统
*/
// Core classes | 核心类
export { GObject } from './core/GObject';
export { GComponent } from './core/GComponent';
export { GRoot } from './core/GRoot';
export { GGroup } from './core/GGroup';
export { Controller } from './core/Controller';
export { Transition } from './core/Transition';
export { Timer } from './core/Timer';
export { Stage, EScaleMode, EAlignMode } from './core/Stage';
export { UIConfig, getUIConfig, setUIConfig } from './core/UIConfig';
export { UIObjectFactory } from './core/UIObjectFactory';
export { GObjectPool } from './core/GObjectPool';
export { DragDropManager } from './core/DragDropManager';
export {
ServiceContainer,
getGlobalContainer,
setGlobalContainer,
EServiceLifecycle,
Inject
} from './core/ServiceContainer';
export type { ServiceIdentifier, ServiceFactory } from './core/ServiceContainer';
// Field types | 字段类型
export {
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 './core/FieldTypes';
// Display objects | 显示对象
export { DisplayObject } from './display/DisplayObject';
export { Container } from './display/Container';
export { Image } from './display/Image';
export { TextField } from './display/TextField';
export { Graph } from './display/Graph';
export { MovieClip } from './display/MovieClip';
export type { IFrame, SimpleHandler } from './display/MovieClip';
export { InputTextField } from './display/InputTextField';
// Widgets | 控件
export { GImage } from './widgets/GImage';
export { GTextField } from './widgets/GTextField';
export { GGraph } from './widgets/GGraph';
export { GButton } from './widgets/GButton';
export { GProgressBar } from './widgets/GProgressBar';
export { GSlider } from './widgets/GSlider';
export { GLoader } from './widgets/GLoader';
export { GMovieClip } from './widgets/GMovieClip';
export { GList } from './widgets/GList';
export type { ItemRenderer, ItemProvider } from './widgets/GList';
export { GTextInput, EKeyboardType } from './widgets/GTextInput';
export { PopupMenu } from './widgets/PopupMenu';
export { Window } from './widgets/Window';
export type { IUISource } from './widgets/Window';
// Events | 事件
export { EventDispatcher } from './events/EventDispatcher';
export type { TypedEventListener, EventListener, FGUIEventType, IEventContext } from './events/EventDispatcher';
export { FGUIEvents } from './events/Events';
export type { IInputEventData } from './events/Events';
// Layout | 布局
export { Relations } from './layout/Relations';
export { RelationItem } from './layout/RelationItem';
// Gears | 齿轮
export {
GearBase,
GearTweenConfig,
GearDisplay,
GearDisplay2,
GearXY,
GearSize,
GearLook,
GearColor,
GearText,
GearIcon,
GearFontSize,
GearAnimation
} from './gears';
// Scroll | 滚动
export { ScrollPane } from './scroll/ScrollPane';
// Package | 包管理
export { UIPackage } from './package/UIPackage';
export { PackageItem } from './package/PackageItem';
// Utils | 工具
export { Point, Rectangle, Margin } from './utils/MathTypes';
export type { IPoint, IRectangle } from './utils/MathTypes';
export { ByteBuffer } from './utils/ByteBuffer';
// Binding | 绑定
export {
ObservableProperty,
ComputedProperty,
PropertyBinder
} from './binding/PropertyBinding';
export type {
IObservableProperty,
IWritableProperty,
IPropertySubscription,
PropertyChangeCallback
} from './binding/PropertyBinding';
// Render | 渲染
export { RenderCollector } from './render/RenderCollector';
export { RenderBridge } from './render/RenderBridge';
export { Canvas2DBackend } from './render/Canvas2DBackend';
export {
DOMTextRenderer,
getDOMTextRenderer,
setDOMTextRenderer
} from './render/DOMTextRenderer';
export type { ICameraState } from './render/DOMTextRenderer';
export {
FGUIRenderDataProvider,
createFGUIRenderDataProvider
} from './render/FGUIRenderDataProvider';
export type {
IEngineRenderData,
ITextRenderData,
IMeshRenderData,
IFGUIRenderDataProvider,
TextureResolverFn
} from './render/FGUIRenderDataProvider';
export type {
IRenderCollector,
IRenderPrimitive,
ERenderPrimitiveType,
ETextAlign,
ETextVAlign
} from './render/IRenderCollector';
export type {
IRenderBackend,
IRenderStats,
ITextureHandle,
IFontHandle,
RenderBackendFactory
} from './render/IRenderBackend';
// Tween | 补间动画
export { GTween } from './tween/GTween';
export { GTweener } from './tween/GTweener';
export type { TweenCallback } from './tween/GTweener';
export { TweenManager } from './tween/TweenManager';
export { TweenValue } from './tween/TweenValue';
export { evaluateEase } from './tween/EaseType';
// Text | 文本渲染
export {
MSDFFont,
MSDFFontManager,
getMSDFFontManager,
layoutText,
measureText,
createTextBatch,
mergeTextBatches,
MSDFFontLoader,
loadMSDFFont,
createFontData,
BitmapFont,
BitmapFontManager,
getBitmapFontManager,
convertBitmapToMSDFFormat,
DynamicFont,
DynamicFontManager,
getDynamicFontManager,
COMMON_CJK_CHARS,
COMMON_ASCII_CHARS
} from './text';
export type {
IMSDFGlyph,
IMSDFKerning,
IMSDFFontAtlas,
IMSDFFontMetrics,
IMSDFFontData,
IPositionedGlyph,
ITextLayoutOptions,
ITextLayoutResult,
ITextBatchData,
ITextBatchOptions,
IFontLoadResult,
IBitmapGlyph,
IBitmapFontData,
IDynamicFontConfig,
TextureUploadCallback
} from './text';
// Asset | 资产
export {
FUIAssetLoader,
fuiAssetLoader,
FGUITextureManager,
getFGUITextureManager,
createTextureResolver,
setGlobalTextureService,
getGlobalTextureService
} from './asset';
export type {
IFUIAsset,
IAssetLoader,
IAssetContent,
IAssetParseContext,
ITextureService
} from './asset';
// ECS Integration | ECS 集成
export {
FGUIComponent,
FGUIRenderSystem,
FGUIRenderSystemToken,
getFGUIRenderSystem,
setFGUIRenderSystem,
FGUIRuntimeModule,
FGUIPlugin
} from './ecs';
export type { IFGUIComponentData, RenderSubmitCallback } from './ecs';
// Internal wiring (breaks circular dependencies)
import './core/init';

View File

@@ -0,0 +1,388 @@
import { ERelationType } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
import { FGUIEvents } from '../events/Events';
/**
* Relation definition
* 关联定义
*/
interface RelationDef {
relationType: ERelationType;
usePercent: boolean;
percent: number;
}
/**
* RelationItem
*
* Represents a single relation constraint between two objects.
*
* 表示两个对象之间的单个关联约束
*/
export class RelationItem {
/** Owner object | 所有者对象 */
public readonly owner: GObject;
private _target: GObject | null = null;
private _relations: RelationDef[] = [];
private _targetX: number = 0;
private _targetY: number = 0;
private _targetWidth: number = 0;
private _targetHeight: number = 0;
constructor(owner: GObject) {
this.owner = owner;
}
/**
* Get target object
* 获取目标对象
*/
public get target(): GObject | null {
return this._target;
}
/**
* Set target object
* 设置目标对象
*/
public set target(value: GObject | null) {
if (this._target !== value) {
if (this._target) {
this.releaseRefTarget(this._target);
}
this._target = value;
if (this._target) {
this.addRefTarget(this._target);
}
}
}
/**
* Add a relation
* 添加关联
*/
public add(relationType: ERelationType, bUsePercent: boolean): void {
if (relationType === ERelationType.Size) {
this.add(ERelationType.Width, bUsePercent);
this.add(ERelationType.Height, bUsePercent);
return;
}
const existing = this._relations.find(r => r.relationType === relationType);
if (existing) {
existing.usePercent = bUsePercent;
} else {
this._relations.push({
relationType,
usePercent: bUsePercent,
percent: 0
});
}
this.internalAdd(relationType, bUsePercent);
}
/**
* Internal add relation (used by Relations.setup)
* 内部添加关联(由 Relations.setup 使用)
*/
public internalAdd(relationType: ERelationType, bUsePercent: boolean): void {
// Add the relation definition if it doesn't exist
let def = this._relations.find(r => r.relationType === relationType);
if (!def) {
def = {
relationType,
usePercent: bUsePercent,
percent: 0
};
this._relations.push(def);
} else {
def.usePercent = bUsePercent;
}
if (!this._target) return;
// Calculate initial percent if needed
if (bUsePercent) {
switch (relationType) {
case ERelationType.LeftLeft:
case ERelationType.LeftCenter:
case ERelationType.LeftRight:
case ERelationType.CenterCenter:
case ERelationType.RightLeft:
case ERelationType.RightCenter:
case ERelationType.RightRight:
if (this._targetWidth > 0) {
def.percent = this.owner.x / this._targetWidth;
}
break;
case ERelationType.TopTop:
case ERelationType.TopMiddle:
case ERelationType.TopBottom:
case ERelationType.MiddleMiddle:
case ERelationType.BottomTop:
case ERelationType.BottomMiddle:
case ERelationType.BottomBottom:
if (this._targetHeight > 0) {
def.percent = this.owner.y / this._targetHeight;
}
break;
case ERelationType.Width:
if (this._targetWidth > 0) {
def.percent = this.owner.width / this._targetWidth;
}
break;
case ERelationType.Height:
if (this._targetHeight > 0) {
def.percent = this.owner.height / this._targetHeight;
}
break;
}
}
}
/**
* Remove a relation
* 移除关联
*/
public remove(relationType: ERelationType): void {
if (relationType === ERelationType.Size) {
this.remove(ERelationType.Width);
this.remove(ERelationType.Height);
return;
}
const index = this._relations.findIndex(r => r.relationType === relationType);
if (index !== -1) {
this._relations.splice(index, 1);
}
}
/**
* Check if empty
* 检查是否为空
*/
public isEmpty(): boolean {
return this._relations.length === 0;
}
/**
* Copy from another item
* 从另一个项复制
*/
public copyFrom(source: RelationItem): void {
this.target = source.target;
this._relations = source._relations.map(r => ({ ...r }));
}
private addRefTarget(target: GObject): void {
if (!target) return;
target.on(FGUIEvents.XY_CHANGED, this.onTargetXYChanged, this);
target.on(FGUIEvents.SIZE_CHANGED, this.onTargetSizeChanged, this);
this._targetX = target.x;
this._targetY = target.y;
this._targetWidth = target.width;
this._targetHeight = target.height;
}
private releaseRefTarget(target: GObject): void {
if (!target) return;
target.off(FGUIEvents.XY_CHANGED, this.onTargetXYChanged);
target.off(FGUIEvents.SIZE_CHANGED, this.onTargetSizeChanged);
}
private onTargetXYChanged(): void {
if (!this._target || this.owner._gearLocked) return;
const ox = this._targetX;
const oy = this._targetY;
this._targetX = this._target.x;
this._targetY = this._target.y;
this.applyOnXYChanged(this._targetX - ox, this._targetY - oy);
}
private onTargetSizeChanged(): void {
if (!this._target || this.owner._gearLocked) return;
const ow = this._targetWidth;
const oh = this._targetHeight;
this._targetWidth = this._target.width;
this._targetHeight = this._target.height;
this.applyOnSizeChanged(this._targetWidth - ow, this._targetHeight - oh);
}
/**
* Apply relations when target position changed
* 当目标位置改变时应用关联
*/
public applyOnXYChanged(dx: number, dy: number): void {
for (const def of this._relations) {
switch (def.relationType) {
case ERelationType.LeftLeft:
case ERelationType.LeftCenter:
case ERelationType.LeftRight:
case ERelationType.CenterCenter:
case ERelationType.RightLeft:
case ERelationType.RightCenter:
case ERelationType.RightRight:
this.owner.x += dx;
break;
case ERelationType.TopTop:
case ERelationType.TopMiddle:
case ERelationType.TopBottom:
case ERelationType.MiddleMiddle:
case ERelationType.BottomTop:
case ERelationType.BottomMiddle:
case ERelationType.BottomBottom:
this.owner.y += dy;
break;
}
}
}
/**
* Apply relations when target size changed
* 当目标尺寸改变时应用关联
*/
public applyOnSizeChanged(dWidth: number, dHeight: number): void {
if (!this._target) return;
let ox = this.owner.x;
let oy = this.owner.y;
for (const def of this._relations) {
switch (def.relationType) {
case ERelationType.LeftLeft:
// No change needed
break;
case ERelationType.LeftCenter:
ox = this._target.width / 2 + (ox - this._targetWidth / 2);
break;
case ERelationType.LeftRight:
ox = this._target.width + (ox - this._targetWidth);
break;
case ERelationType.CenterCenter:
ox = this._target.width / 2 + (ox + this.owner.width / 2 - this._targetWidth / 2) - this.owner.width / 2;
break;
case ERelationType.RightLeft:
ox = ox + this.owner.width - this._target.width / 2 + (this._targetWidth / 2 - this.owner.width);
break;
case ERelationType.RightCenter:
ox = this._target.width / 2 + (ox + this.owner.width - this._targetWidth / 2) - this.owner.width;
break;
case ERelationType.RightRight:
ox = this._target.width + (ox + this.owner.width - this._targetWidth) - this.owner.width;
break;
case ERelationType.TopTop:
// No change needed
break;
case ERelationType.TopMiddle:
oy = this._target.height / 2 + (oy - this._targetHeight / 2);
break;
case ERelationType.TopBottom:
oy = this._target.height + (oy - this._targetHeight);
break;
case ERelationType.MiddleMiddle:
oy = this._target.height / 2 + (oy + this.owner.height / 2 - this._targetHeight / 2) - this.owner.height / 2;
break;
case ERelationType.BottomTop:
oy = oy + this.owner.height - this._target.height / 2 + (this._targetHeight / 2 - this.owner.height);
break;
case ERelationType.BottomMiddle:
oy = this._target.height / 2 + (oy + this.owner.height - this._targetHeight / 2) - this.owner.height;
break;
case ERelationType.BottomBottom:
oy = this._target.height + (oy + this.owner.height - this._targetHeight) - this.owner.height;
break;
case ERelationType.Width:
if (def.usePercent) {
this.owner.width = this._target.width * def.percent;
} else {
this.owner.width += dWidth;
}
break;
case ERelationType.Height:
if (def.usePercent) {
this.owner.height = this._target.height * def.percent;
} else {
this.owner.height += dHeight;
}
break;
}
}
if (ox !== this.owner.x || oy !== this.owner.y) {
this.owner.setXY(ox, oy);
}
}
/**
* Apply relations when owner resized
* 当所有者尺寸改变时应用关联
*/
public applyOnSelfResized(dWidth: number, dHeight: number, bApplyPivot: boolean): void {
if (!this._target) return;
for (const def of this._relations) {
switch (def.relationType) {
case ERelationType.CenterCenter:
this.owner.x -= dWidth / 2;
break;
case ERelationType.RightCenter:
case ERelationType.RightLeft:
case ERelationType.RightRight:
this.owner.x -= dWidth;
break;
case ERelationType.MiddleMiddle:
this.owner.y -= dHeight / 2;
break;
case ERelationType.BottomMiddle:
case ERelationType.BottomTop:
case ERelationType.BottomBottom:
this.owner.y -= dHeight;
break;
}
}
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
if (this._target) {
this.releaseRefTarget(this._target);
this._target = null;
}
this._relations.length = 0;
}
}

View File

@@ -0,0 +1,184 @@
import { ERelationType } from '../core/FieldTypes';
import type { GObject } from '../core/GObject';
import type { GComponent } from '../core/GComponent';
import type { ByteBuffer } from '../utils/ByteBuffer';
import { RelationItem } from './RelationItem';
/**
* Relations
*
* Manages constraint-based layout relationships between UI objects.
*
* 管理 UI 对象之间的约束布局关系
*/
export class Relations {
/** Owner object | 所有者对象 */
public readonly owner: GObject;
/** Size dirty flag | 尺寸脏标记 */
public sizeDirty: boolean = false;
private _items: RelationItem[] = [];
constructor(owner: GObject) {
this.owner = owner;
}
/**
* Add a relation
* 添加关联
*/
public add(target: GObject, relationType: ERelationType, bUsePercent: boolean = false): void {
let item: RelationItem | null = null;
for (const existing of this._items) {
if (existing.target === target) {
item = existing;
break;
}
}
if (!item) {
item = new RelationItem(this.owner);
item.target = target;
this._items.push(item);
}
item.add(relationType, bUsePercent);
}
/**
* Remove a relation
* 移除关联
*/
public remove(target: GObject, relationType: ERelationType = ERelationType.Size): void {
for (let i = this._items.length - 1; i >= 0; i--) {
const item = this._items[i];
if (item.target === target) {
item.remove(relationType);
if (item.isEmpty()) {
this._items.splice(i, 1);
}
break;
}
}
}
/**
* Check if target has any relations
* 检查目标是否有任何关联
*/
public contains(target: GObject): boolean {
return this._items.some(item => item.target === target);
}
/**
* Clear relations with a target
* 清除与目标的所有关联
*/
public clearFor(target: GObject): void {
for (let i = this._items.length - 1; i >= 0; i--) {
if (this._items[i].target === target) {
this._items.splice(i, 1);
}
}
}
/**
* Clear all relations
* 清除所有关联
*/
public clearAll(): void {
for (const item of this._items) {
item.dispose();
}
this._items.length = 0;
}
/**
* Copy relations from another object
* 从另一个对象复制关联
*/
public copyFrom(source: Relations): void {
this.clearAll();
for (const item of source._items) {
const newItem = new RelationItem(this.owner);
newItem.copyFrom(item);
this._items.push(newItem);
}
}
/**
* Called when owner size changed
* 当所有者尺寸改变时调用
*/
public onOwnerSizeChanged(dWidth: number, dHeight: number, bApplyPivot: boolean): void {
for (const item of this._items) {
item.applyOnSelfResized(dWidth, dHeight, bApplyPivot);
}
}
/**
* Ensure relations size is correct
* 确保关联尺寸正确
*/
public ensureRelationsSizeCorrect(): void {
if (!this.sizeDirty) return;
this.sizeDirty = false;
for (const item of this._items) {
item.target?.ensureSizeCorrect();
}
}
/**
* Get items count
* 获取项目数量
*/
public get count(): number {
return this._items.length;
}
/**
* Setup relations from buffer
* 从缓冲区设置关联
*/
public setup(buffer: ByteBuffer, bParentToChild: boolean): void {
const cnt = buffer.readByte();
for (let i = 0; i < cnt; i++) {
const targetIndex = buffer.getInt16();
let target: GObject | null = null;
if (targetIndex === -1) {
target = this.owner.parent;
} else if (bParentToChild) {
target = (this.owner as GComponent).getChildAt(targetIndex);
} else if (this.owner.parent) {
target = this.owner.parent.getChildAt(targetIndex);
}
if (!target) continue;
const newItem = new RelationItem(this.owner);
newItem.target = target;
this._items.push(newItem);
const cnt2 = buffer.readByte();
for (let j = 0; j < cnt2; j++) {
const rt = buffer.readByte() as ERelationType;
const bUsePercent = buffer.readBool();
newItem.internalAdd(rt, bUsePercent);
}
}
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
this.clearAll();
}
}

View File

@@ -0,0 +1,149 @@
import { EPackageItemType, EObjectType } from '../core/FieldTypes';
import type { UIPackage } from './UIPackage';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* PackageItem
*
* Represents a resource item in a UI package.
*
* 表示 UI 包中的资源项
*/
export class PackageItem {
/** Owner package | 所属包 */
public owner: UIPackage | null = null;
/** Item type | 项目类型 */
public type: EPackageItemType = EPackageItemType.Unknown;
/** Object type | 对象类型 */
public objectType: EObjectType = EObjectType.Image;
/** Item ID | 项目 ID */
public id: string = '';
/** Item name | 项目名称 */
public name: string = '';
/** Width | 宽度 */
public width: number = 0;
/** Height | 高度 */
public height: number = 0;
/** File path | 文件路径 */
public file: string = '';
/** Is exported | 是否导出 */
public exported: boolean = false;
// Image specific | 图像相关
/** Scale9 grid | 九宫格 */
public scale9Grid: { x: number; y: number; width: number; height: number } | null = null;
/** Scale by tile | 平铺缩放 */
public scaleByTile: boolean = false;
/** Tile grid indent | 平铺网格缩进 */
public tileGridIndice: number = 0;
// MovieClip specific | 动画相关
/** Frame delay | 帧延迟 */
public interval: number = 0;
/** Repeat delay | 重复延迟 */
public repeatDelay: number = 0;
/** Swing | 摇摆 */
public swing: boolean = false;
// Sound specific | 音频相关
/** Volume | 音量 */
public volume: number = 1;
// Component specific | 组件相关
/** Raw data (ByteBuffer for parsed data) | 原始数据 */
public rawData: ByteBuffer | null = null;
/** Branch index | 分支索引 */
public branches: string[] | null = null;
/** High resolution | 高分辨率 */
public highResolution: string[] | null = null;
// Loaded content | 加载的内容
/** Loaded texture | 加载的纹理 */
public texture: any = null;
/** Loaded frames | 加载的帧 */
public frames: any[] | null = null;
/** Loaded font | 加载的字体 */
public bitmapFont: any = null;
/** Loading flag | 加载中标记 */
public loading: boolean = false;
/** Decoded flag | 已解码标记 */
public decoded: boolean = false;
/**
* Get full path
* 获取完整路径
*/
public toString(): string {
return this.owner ? `${this.owner.name}/${this.name}` : this.name;
}
/**
* Get branch version of this item
* 获取此项目的分支版本
*/
public getBranch(): PackageItem {
if (this.branches && this.branches.length > 0 && this.owner) {
const branchName = this.owner.constructor.name === 'UIPackage'
? (this.owner as any).constructor.branch
: '';
if (branchName) {
const branchIndex = this.branches.indexOf(branchName);
if (branchIndex >= 0) {
const branchItem = this.owner.getItemById(this.branches[branchIndex]);
if (branchItem) return branchItem;
}
}
}
return this;
}
/**
* Get high resolution version of this item
* 获取此项目的高分辨率版本
*/
public getHighResolution(): PackageItem {
if (this.highResolution && this.highResolution.length > 0 && this.owner) {
// For now, return first high res version if available
const hiResItem = this.owner.getItemById(this.highResolution[0]);
if (hiResItem) return hiResItem;
}
return this;
}
/**
* Load this item's content
* 加载此项目的内容
*/
public load(): void {
if (this.loading || this.decoded) return;
this.loading = true;
// Loading is typically done by the package
// This is a placeholder for async loading
if (this.owner) {
this.owner.getItemAsset(this);
}
this.loading = false;
this.decoded = true;
}
}

View File

@@ -0,0 +1,867 @@
import { PackageItem } from './PackageItem';
import { EPackageItemType, EObjectType } from '../core/FieldTypes';
import { UIObjectFactory } from '../core/UIObjectFactory';
import { ByteBuffer } from '../utils/ByteBuffer';
import type { GObject } from '../core/GObject';
/** FairyGUI package file magic number | FairyGUI 包文件魔数 */
const PACKAGE_MAGIC = 0x46475549; // 'FGUI'
/** Package dependency | 包依赖 */
interface IPackageDependency {
id: string;
name: string;
}
/** Atlas sprite info | 图集精灵信息 */
interface IAtlasSprite {
atlas: PackageItem;
rect: { x: number; y: number; width: number; height: number };
offset: { x: number; y: number };
originalSize: { x: number; y: number };
rotated: boolean;
}
/**
* UIPackage
*
* Represents a FairyGUI package (.fui file).
* Manages loading and accessing package resources.
*
* 表示 FairyGUI 包(.fui 文件),管理包资源的加载和访问
*/
export class UIPackage {
/** Package ID | 包 ID */
public id: string = '';
/** Package name | 包名称 */
public name: string = '';
/** Package URL | 包 URL */
public url: string = '';
/** Is constructing | 正在构造中 */
public static _constructing: number = 0;
private _items: PackageItem[] = [];
private _itemsById: Map<string, PackageItem> = new Map();
private _itemsByName: Map<string, PackageItem> = new Map();
private _sprites: Map<string, IAtlasSprite> = new Map();
private _dependencies: IPackageDependency[] = [];
private _branches: string[] = [];
private _branchIndex: number = -1;
private _resKey: string = '';
private static _packages: Map<string, UIPackage> = new Map();
private static _packagesByUrl: Map<string, UIPackage> = new Map();
private static _branch: string = '';
private static _vars: Map<string, string> = new Map();
/**
* Get branch name
* 获取分支名称
*/
public static get branch(): string {
return UIPackage._branch;
}
/**
* Set branch name
* 设置分支名称
*/
public static set branch(value: string) {
UIPackage._branch = value;
for (const pkg of UIPackage._packages.values()) {
if (pkg._branches.length > 0) {
pkg._branchIndex = pkg._branches.indexOf(value);
}
}
}
/**
* Get variable
* 获取变量
*/
public static getVar(key: string): string | undefined {
return UIPackage._vars.get(key);
}
/**
* Set variable
* 设置变量
*/
public static setVar(key: string, value: string): void {
UIPackage._vars.set(key, value);
}
/**
* Get package by ID
* 通过 ID 获取包
*/
public static getById(id: string): UIPackage | null {
return UIPackage._packages.get(id) || null;
}
/**
* Get package by ID (instance method wrapper)
* 通过 ID 获取包(实例方法包装器)
*/
public getPackageById(id: string): UIPackage | null {
return UIPackage.getById(id);
}
/**
* Get package by name
* 通过名称获取包
*/
public static getByName(name: string): UIPackage | null {
for (const pkg of UIPackage._packages.values()) {
if (pkg.name === name) {
return pkg;
}
}
return null;
}
/**
* Add package from binary data
* 从二进制数据添加包
*/
public static addPackageFromBuffer(resKey: string, descData: ArrayBuffer): UIPackage {
const buffer = new ByteBuffer(descData);
const pkg = new UIPackage();
pkg._resKey = resKey;
pkg.loadPackage(buffer);
UIPackage._packages.set(pkg.id, pkg);
UIPackage._packages.set(resKey, pkg);
return pkg;
}
/**
* Add a loaded package
* 添加已加载的包
*/
public static addPackage(pkg: UIPackage): void {
UIPackage._packages.set(pkg.id, pkg);
if (pkg.url) {
UIPackage._packagesByUrl.set(pkg.url, pkg);
}
}
/**
* Remove a package
* 移除包
*/
public static removePackage(idOrName: string): void {
let pkg: UIPackage | null | undefined = UIPackage._packages.get(idOrName);
if (!pkg) {
pkg = UIPackage.getByName(idOrName);
}
if (pkg) {
UIPackage._packages.delete(pkg.id);
UIPackage._packages.delete(pkg._resKey);
if (pkg.url) {
UIPackage._packagesByUrl.delete(pkg.url);
}
pkg.dispose();
}
}
/**
* Create object from URL
* 从 URL 创建对象
*/
public static createObject(pkgName: string, resName: string): GObject | null {
const pkg = UIPackage.getByName(pkgName);
if (pkg) {
return pkg.createObject(resName);
}
return null;
}
/**
* Create object from URL string
* 从 URL 字符串创建对象
*/
public static createObjectFromURL(url: string): GObject | null {
const pi = UIPackage.getItemByURL(url);
if (pi) {
return pi.owner?.internalCreateObject(pi) ?? null;
}
return null;
}
/**
* Get item by URL
* 通过 URL 获取项
*/
public static getItemByURL(url: string): PackageItem | null {
if (!url) return null;
// URL format: ui://pkgName/resName or ui://pkgId/resId
const pos = url.indexOf('//');
if (pos === -1) return null;
const pos2 = url.indexOf('/', pos + 2);
if (pos2 === -1) {
if (url.length > 13) {
const pkgId = url.substring(5, 13);
const pkg = UIPackage.getById(pkgId);
if (pkg) {
const srcId = url.substring(13);
return pkg.getItemById(srcId);
}
}
} else {
const pkgName = url.substring(pos + 2, pos2);
const pkg = UIPackage.getByName(pkgName);
if (pkg) {
const resName = url.substring(pos2 + 1);
return pkg.getItemByName(resName);
}
}
return null;
}
/**
* Get item asset by URL
* 通过 URL 获取项资源
*/
public static getItemAssetByURL(url: string): any {
const pi = UIPackage.getItemByURL(url);
if (pi) {
return pi.owner?.getItemAsset(pi);
}
return null;
}
/**
* Normalize URL
* 标准化 URL
*/
public static normalizeURL(url: string): string {
if (!url) return '';
if (url.startsWith('ui://')) return url;
return 'ui://' + url;
}
/**
* Get item URL
* 获取项目 URL
*/
public static getItemURL(pkgName: string, resName: string): string | null {
const pkg = UIPackage.getByName(pkgName);
if (!pkg) return null;
const pi = pkg.getItemByName(resName);
if (!pi) return null;
return 'ui://' + pkg.id + pi.id;
}
// Instance methods | 实例方法
/**
* Load package from buffer
* 从缓冲区加载包
*/
private loadPackage(buffer: ByteBuffer): void {
if (buffer.getUint32() !== PACKAGE_MAGIC) {
throw new Error('FairyGUI: invalid package format in \'' + this._resKey + '\'');
}
buffer.version = buffer.getInt32();
const compressed = buffer.readBool();
this.id = buffer.readUTFString();
this.name = buffer.readUTFString();
buffer.skip(20);
// Handle compressed data
if (compressed) {
// Note: Compression requires pako or similar library
// For now, we'll throw an error if the package is compressed
throw new Error('FairyGUI: compressed packages are not supported yet');
}
const ver2 = buffer.version >= 2;
const indexTablePos = buffer.pos;
// Read string table
buffer.seek(indexTablePos, 4);
const strCount = buffer.getInt32();
const stringTable: string[] = [];
for (let i = 0; i < strCount; i++) {
stringTable[i] = buffer.readUTFString();
}
buffer.stringTable = stringTable;
// Read custom strings (version 2+)
if (buffer.seek(indexTablePos, 5)) {
const customCount = buffer.readInt32();
for (let i = 0; i < customCount; i++) {
const index = buffer.readUint16();
const len = buffer.readInt32();
stringTable[index] = buffer.getCustomString(len);
}
}
// Read dependencies
buffer.seek(indexTablePos, 0);
const depCount = buffer.getInt16();
for (let i = 0; i < depCount; i++) {
this._dependencies.push({
id: buffer.readS(),
name: buffer.readS()
});
}
// Read branches (version 2+)
let branchIncluded = false;
if (ver2) {
const branchCount = buffer.getInt16();
if (branchCount > 0) {
this._branches = buffer.readSArray(branchCount);
if (UIPackage._branch) {
this._branchIndex = this._branches.indexOf(UIPackage._branch);
}
branchIncluded = true;
}
}
// Read items
buffer.seek(indexTablePos, 1);
const path = this._resKey;
const lastSlash = path.lastIndexOf('/');
const shortPath = lastSlash === -1 ? '' : path.substring(0, lastSlash + 1);
// Remove .fui extension for atlas base path (e.g., "assets/ui/Bag.fui" -> "assets/ui/Bag_")
// 移除 .fui 扩展名用于图集基础路径
const baseName = path.endsWith('.fui') ? path.slice(0, -4) : path;
const basePath = baseName + '_';
const itemCount = buffer.getUint16();
for (let i = 0; i < itemCount; i++) {
let nextPos = buffer.getInt32();
nextPos += buffer.pos;
const pi = new PackageItem();
pi.owner = this;
pi.type = buffer.readByte() as EPackageItemType;
pi.id = buffer.readS();
pi.name = buffer.readS();
buffer.readS(); // path
const file = buffer.readS();
if (file) {
pi.file = file;
}
buffer.readBool(); // exported
pi.width = buffer.getInt32();
pi.height = buffer.getInt32();
switch (pi.type) {
case EPackageItemType.Image: {
pi.objectType = EObjectType.Image;
const scaleOption = buffer.readByte();
if (scaleOption === 1) {
pi.scale9Grid = {
x: buffer.getInt32(),
y: buffer.getInt32(),
width: buffer.getInt32(),
height: buffer.getInt32()
};
pi.tileGridIndice = buffer.getInt32();
} else if (scaleOption === 2) {
pi.scaleByTile = true;
}
buffer.readBool(); // smoothing
break;
}
case EPackageItemType.MovieClip: {
buffer.readBool(); // smoothing
pi.objectType = EObjectType.MovieClip;
pi.rawData = buffer.readBuffer();
break;
}
case EPackageItemType.Font: {
pi.rawData = buffer.readBuffer();
break;
}
case EPackageItemType.Component: {
const extension = buffer.readByte();
if (extension > 0) {
pi.objectType = extension as EObjectType;
} else {
pi.objectType = EObjectType.Component;
}
pi.rawData = buffer.readBuffer();
UIObjectFactory.resolvePackageItemExtension(pi);
break;
}
case EPackageItemType.Atlas:
case EPackageItemType.Sound:
case EPackageItemType.Misc: {
pi.file = basePath + pi.file;
break;
}
case EPackageItemType.Spine:
case EPackageItemType.DragonBones: {
pi.file = shortPath + pi.file;
buffer.getFloat32(); // skeletonAnchor.x
buffer.getFloat32(); // skeletonAnchor.y
break;
}
}
// Version 2 specific
if (ver2) {
const branchStr = buffer.readS();
if (branchStr) {
pi.name = branchStr + '/' + pi.name;
}
const branchCnt = buffer.getUint8();
if (branchCnt > 0) {
if (branchIncluded) {
pi.branches = buffer.readSArray(branchCnt);
} else {
this._itemsById.set(buffer.readS(), pi);
}
}
const highResCnt = buffer.getUint8();
if (highResCnt > 0) {
pi.highResolution = buffer.readSArray(highResCnt);
}
}
this._items.push(pi);
this._itemsById.set(pi.id, pi);
if (pi.name) {
this._itemsByName.set(pi.name, pi);
}
buffer.pos = nextPos;
}
// Read sprites
buffer.seek(indexTablePos, 2);
const spriteCount = buffer.getUint16();
for (let i = 0; i < spriteCount; i++) {
let nextPos = buffer.getUint16();
nextPos += buffer.pos;
const itemId = buffer.readS();
const atlasItem = this._itemsById.get(buffer.readS());
if (atlasItem) {
const sprite: IAtlasSprite = {
atlas: atlasItem,
rect: {
x: buffer.getInt32(),
y: buffer.getInt32(),
width: buffer.getInt32(),
height: buffer.getInt32()
},
offset: { x: 0, y: 0 },
originalSize: { x: 0, y: 0 },
rotated: buffer.readBool()
};
if (ver2 && buffer.readBool()) {
sprite.offset.x = buffer.getInt32();
sprite.offset.y = buffer.getInt32();
sprite.originalSize.x = buffer.getInt32();
sprite.originalSize.y = buffer.getInt32();
} else {
sprite.originalSize.x = sprite.rect.width;
sprite.originalSize.y = sprite.rect.height;
}
this._sprites.set(itemId, sprite);
}
buffer.pos = nextPos;
}
// Read hit test data (optional)
if (buffer.seek(indexTablePos, 3)) {
const hitTestCount = buffer.getUint16();
for (let i = 0; i < hitTestCount; i++) {
let nextPos = buffer.getInt32();
nextPos += buffer.pos;
const pi = this._itemsById.get(buffer.readS());
if (pi && pi.type === EPackageItemType.Image) {
// PixelHitTestData would be loaded here
// For now we skip this
}
buffer.pos = nextPos;
}
}
}
/**
* Get item by ID
* 通过 ID 获取项
*/
public getItemById(id: string): PackageItem | null {
return this._itemsById.get(id) || null;
}
/**
* Get item by name
* 通过名称获取项
*/
public getItemByName(name: string): PackageItem | null {
return this._itemsByName.get(name) || null;
}
/**
* Get all atlas file paths in this package
* 获取此包中所有图集文件路径
*/
public getAtlasFiles(): string[] {
const files: string[] = [];
for (const item of this._items) {
if (item.type === EPackageItemType.Atlas && item.file) {
files.push(item.file);
}
}
return files;
}
/**
* Get sprite by item ID
* 通过项目 ID 获取精灵
*/
public getSprite(itemId: string): IAtlasSprite | null {
return this._sprites.get(itemId) || null;
}
/**
* Get item asset
* 获取项资源
*/
public getItemAsset(item: PackageItem): any {
switch (item.type) {
case EPackageItemType.Image:
if (!item.decoded) {
item.decoded = true;
const sprite = this._sprites.get(item.id);
if (sprite) {
// Store sprite info for rendering
// The atlas file path is used as texture ID
// Include atlas dimensions for UV calculation
item.texture = {
atlas: sprite.atlas.file,
atlasId: sprite.atlas.id,
rect: sprite.rect,
offset: sprite.offset,
originalSize: sprite.originalSize,
rotated: sprite.rotated,
atlasWidth: sprite.atlas.width,
atlasHeight: sprite.atlas.height
};
}
}
return item.texture;
case EPackageItemType.Atlas:
if (!item.decoded) {
item.decoded = true;
// Load atlas texture
// This would require asset loading infrastructure
}
return item.texture;
case EPackageItemType.MovieClip:
if (!item.decoded) {
item.decoded = true;
this.loadMovieClip(item);
}
return item.frames;
case EPackageItemType.Font:
if (!item.decoded) {
item.decoded = true;
this.loadFont(item);
}
return item.bitmapFont;
case EPackageItemType.Component:
return item.rawData;
default:
return null;
}
}
/**
* Load movie clip data
* 加载动画片段数据
*/
private loadMovieClip(item: PackageItem): void {
const buffer = item.rawData as ByteBuffer;
if (!buffer) return;
buffer.seek(0, 0);
item.interval = buffer.getInt32();
item.swing = buffer.readBool();
item.repeatDelay = buffer.getInt32();
buffer.seek(0, 1);
const frameCount = buffer.getInt16();
item.frames = [];
for (let i = 0; i < frameCount; i++) {
let nextPos = buffer.getInt16();
nextPos += buffer.pos;
const fx = buffer.getInt32();
const fy = buffer.getInt32();
buffer.getInt32(); // width
buffer.getInt32(); // height
const addDelay = buffer.getInt32();
const spriteId = buffer.readS();
const frame: any = { addDelay, texture: null };
if (spriteId) {
const sprite = this._sprites.get(spriteId);
if (sprite) {
// Create texture from sprite with atlas info for UV calculation
// 从 sprite 创建纹理信息,包含用于 UV 计算的图集信息
frame.texture = {
atlas: sprite.atlas.file,
atlasId: sprite.atlas.id,
rect: sprite.rect,
offset: sprite.offset,
originalSize: sprite.originalSize,
rotated: sprite.rotated,
atlasWidth: sprite.atlas.width,
atlasHeight: sprite.atlas.height
};
}
}
item.frames[i] = frame;
buffer.pos = nextPos;
}
}
/**
* Load font data
* 加载字体数据
*/
private loadFont(item: PackageItem): void {
const buffer = item.rawData as ByteBuffer;
if (!buffer) return;
buffer.seek(0, 0);
const ttf = buffer.readBool();
const tint = buffer.readBool();
buffer.readBool(); // autoScaleSize
buffer.readBool(); // has channel
const fontSize = Math.max(buffer.getInt32(), 1);
const xadvance = buffer.getInt32();
const lineHeight = buffer.getInt32();
const font: any = {
ttf,
tint,
fontSize,
lineHeight: Math.max(lineHeight, fontSize),
glyphs: new Map()
};
buffer.seek(0, 1);
const glyphCount = buffer.getInt32();
for (let i = 0; i < glyphCount; i++) {
let nextPos = buffer.getInt16();
nextPos += buffer.pos;
const ch = buffer.getUint16();
const glyph: any = {};
const img = buffer.readS();
const bx = buffer.getInt32();
const by = buffer.getInt32();
glyph.x = buffer.getInt32();
glyph.y = buffer.getInt32();
glyph.width = buffer.getInt32();
glyph.height = buffer.getInt32();
glyph.advance = buffer.getInt32();
buffer.readByte(); // channel
if (!ttf && glyph.advance === 0) {
glyph.advance = xadvance > 0 ? xadvance : glyph.x + glyph.width;
}
font.glyphs.set(ch, glyph);
buffer.pos = nextPos;
}
item.bitmapFont = font;
}
/**
* Create object from item name
* 从项名称创建对象
*/
public createObject(resName: string): GObject | null {
const pi = this.getItemByName(resName);
if (pi) {
return this.internalCreateObject(pi);
}
console.warn(`[UIPackage] createObject: item not found: "${resName}" in package "${this.name}". Available items:`, Array.from(this._itemsByName.keys()));
return null;
}
/**
* Internal create object from package item
* 从包资源项内部创建对象
*/
public internalCreateObject(item: PackageItem): GObject | null {
// Check for extension first
const url = 'ui://' + this.id + item.id;
if (UIObjectFactory.hasExtension(url)) {
const obj = UIObjectFactory.createObjectFromURL(url);
if (obj) {
obj.packageItem = item;
UIPackage._constructing++;
obj.constructFromResource();
UIPackage._constructing--;
return obj;
}
}
// Create object based on item type
const obj = UIObjectFactory.createObject(item.objectType);
if (obj) {
obj.packageItem = item;
UIPackage._constructing++;
obj.constructFromResource();
UIPackage._constructing--;
}
return obj;
}
/**
* Create object asynchronously
* 异步创建对象
*/
public createObjectAsync(resName: string, callback: (obj: GObject | null) => void): void {
const pi = this.getItemByName(resName);
if (pi) {
this.internalCreateObjectAsync(pi, callback);
} else {
callback(null);
}
}
/**
* Internal create object asynchronously
* 内部异步创建对象
*/
public internalCreateObjectAsync(item: PackageItem, callback: (obj: GObject | null) => void): void {
const obj = this.internalCreateObject(item);
callback(obj);
}
/**
* Get item URL
* 获取项目 URL
*/
public getItemUrl(item: PackageItem): string {
return 'ui://' + this.id + item.id;
}
/**
* Add item
* 添加项
*/
public addItem(item: PackageItem): void {
item.owner = this;
this._items.push(item);
this._itemsById.set(item.id, item);
this._itemsByName.set(item.name, item);
}
/**
* Get all items
* 获取所有项
*/
public get items(): readonly PackageItem[] {
return this._items;
}
/**
* Get all exported component names
* 获取所有导出的组件名称
*/
public getExportedComponentNames(): string[] {
return this._items
.filter(item => item.type === EPackageItemType.Component && item.exported)
.map(item => item.name);
}
/**
* Get all component names (including non-exported)
* 获取所有组件名称(包括未导出的)
*/
public getAllComponentNames(): string[] {
return this._items
.filter(item => item.type === EPackageItemType.Component)
.map(item => item.name);
}
/**
* Get dependencies
* 获取依赖
*/
public get dependencies(): readonly IPackageDependency[] {
return this._dependencies;
}
/**
* Load all assets
* 加载所有资源
*/
public loadAllAssets(): void {
for (const item of this._items) {
this.getItemAsset(item);
}
}
/**
* Dispose
* 销毁
*/
public dispose(): void {
for (const item of this._items) {
item.owner = null;
if (item.type === EPackageItemType.Atlas && item.texture) {
// Dispose texture if needed
item.texture = null;
}
}
this._items.length = 0;
this._itemsById.clear();
this._itemsByName.clear();
this._sprites.clear();
this._dependencies.length = 0;
}
}

View File

@@ -0,0 +1,547 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderPrimitive, ETextAlign, ETextVAlign } from './IRenderCollector';
import { ERenderPrimitiveType } from './IRenderCollector';
import type { IRenderBackend, IRenderStats, ITextureHandle, IFontHandle } from './IRenderBackend';
/**
* Canvas2D texture handle
* Canvas2D 纹理句柄
*/
class Canvas2DTexture implements ITextureHandle {
private static _nextId = 1;
public readonly id: number;
public readonly width: number;
public readonly height: number;
public readonly source: ImageBitmap | HTMLImageElement | HTMLCanvasElement;
private _valid: boolean = true;
constructor(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
this.id = Canvas2DTexture._nextId++;
this.source = source;
this.width = source.width;
this.height = source.height;
}
public get isValid(): boolean {
return this._valid;
}
public invalidate(): void {
this._valid = false;
}
}
/**
* Canvas2D font handle
* Canvas2D 字体句柄
*/
class Canvas2DFont implements IFontHandle {
public readonly family: string;
private _loaded: boolean = false;
constructor(family: string) {
this.family = family;
}
public get isLoaded(): boolean {
return this._loaded;
}
public setLoaded(): void {
this._loaded = true;
}
}
/**
* Canvas2DBackend
*
* Canvas 2D rendering backend for FairyGUI.
* Provides fallback rendering when WebGPU is not available.
*
* Canvas 2D 渲染后端
* 在 WebGPU 不可用时提供回退渲染
*/
export class Canvas2DBackend implements IRenderBackend {
public readonly name = 'Canvas2D';
private _canvas: HTMLCanvasElement | null = null;
private _ctx: CanvasRenderingContext2D | null = null;
private _width: number = 0;
private _height: number = 0;
private _initialized: boolean = false;
private _textures: Map<number, Canvas2DTexture> = new Map();
private _clipRect: IRectangle | null = null;
private _stats: IRenderStats = {
drawCalls: 0,
triangles: 0,
textureSwitches: 0,
batches: 0,
frameTime: 0
};
private _frameStartTime: number = 0;
private _lastTextureId: number = -1;
public get isInitialized(): boolean {
return this._initialized;
}
public get width(): number {
return this._width;
}
public get height(): number {
return this._height;
}
public async initialize(canvas: HTMLCanvasElement): Promise<boolean> {
this._canvas = canvas;
this._ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true
});
if (!this._ctx) {
console.error('Failed to get Canvas 2D context');
return false;
}
this._width = canvas.width;
this._height = canvas.height;
this._initialized = true;
return true;
}
public beginFrame(): void {
if (!this._ctx) return;
this._frameStartTime = performance.now();
this._stats.drawCalls = 0;
this._stats.triangles = 0;
this._stats.textureSwitches = 0;
this._stats.batches = 0;
this._lastTextureId = -1;
// Clear canvas
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
this._ctx.clearRect(0, 0, this._width, this._height);
}
public endFrame(): void {
this._stats.frameTime = performance.now() - this._frameStartTime;
}
public submitPrimitives(primitives: readonly IRenderPrimitive[]): void {
if (!this._ctx || primitives.length === 0) return;
this._stats.batches++;
for (const primitive of primitives) {
this.renderPrimitive(primitive);
}
}
public setClipRect(rect: IRectangle | null): void {
if (!this._ctx) return;
this._clipRect = rect;
this._ctx.restore();
this._ctx.save();
if (rect) {
this._ctx.beginPath();
this._ctx.rect(rect.x, rect.y, rect.width, rect.height);
this._ctx.clip();
}
}
public createTexture(
source: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): ITextureHandle {
let textureSource: ImageBitmap | HTMLImageElement | HTMLCanvasElement;
if (source instanceof ImageData) {
// Convert ImageData to canvas
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.putImageData(source, 0, 0);
}
textureSource = canvas;
} else {
textureSource = source;
}
const texture = new Canvas2DTexture(textureSource);
this._textures.set(texture.id, texture);
return texture;
}
public destroyTexture(texture: ITextureHandle): void {
const cached = this._textures.get(texture.id);
if (cached) {
cached.invalidate();
this._textures.delete(texture.id);
}
}
public async loadFont(family: string, url?: string): Promise<IFontHandle> {
const font = new Canvas2DFont(family);
if (url) {
try {
const fontFace = new FontFace(family, `url(${url})`);
await fontFace.load();
// Use type assertion for FontFaceSet.add which exists in browsers
(document.fonts as unknown as { add(font: FontFace): void }).add(fontFace);
font.setLoaded();
} catch (error) {
console.error(`Failed to load font: ${family}`, error);
}
} else {
// Assume system font is available
font.setLoaded();
}
return font;
}
public resize(width: number, height: number): void {
if (!this._canvas) return;
this._canvas.width = width;
this._canvas.height = height;
this._width = width;
this._height = height;
}
public getStats(): IRenderStats {
return { ...this._stats };
}
public dispose(): void {
for (const texture of this._textures.values()) {
texture.invalidate();
}
this._textures.clear();
this._ctx = null;
this._canvas = null;
this._initialized = false;
}
private renderPrimitive(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const textureId = typeof primitive.textureId === 'number' ? primitive.textureId : -1;
// Track texture switches
if (textureId !== -1 && textureId !== this._lastTextureId) {
this._stats.textureSwitches++;
this._lastTextureId = textureId;
}
// Apply transform
ctx.save();
ctx.globalAlpha = primitive.alpha ?? 1;
if (primitive.transform) {
const t = primitive.transform;
ctx.setTransform(t.a, t.b, t.c, t.d, t.tx, t.ty);
}
switch (primitive.type) {
case ERenderPrimitiveType.Image:
this.renderImage(primitive);
break;
case ERenderPrimitiveType.Text:
this.renderText(primitive);
break;
case ERenderPrimitiveType.Rect:
this.renderRect(primitive);
break;
case ERenderPrimitiveType.Ellipse:
this.renderEllipse(primitive);
break;
case ERenderPrimitiveType.Polygon:
this.renderPolygon(primitive);
break;
case ERenderPrimitiveType.Graph:
// Handle graph type based on graphType property
this.renderGraph(primitive);
break;
}
ctx.restore();
this._stats.drawCalls++;
}
private renderImage(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const textureId = typeof primitive.textureId === 'number' ? primitive.textureId : -1;
if (textureId === -1) return;
const texture = this._textures.get(textureId);
if (!texture || !texture.isValid) return;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? texture.width;
const height = primitive.height ?? texture.height;
const srcRect = primitive.srcRect;
if (srcRect) {
this._ctx.drawImage(
texture.source,
srcRect.x,
srcRect.y,
srcRect.width,
srcRect.height,
x,
y,
width,
height
);
} else {
this._ctx.drawImage(texture.source, x, y, width, height);
}
this._stats.triangles += 2;
}
private renderText(primitive: IRenderPrimitive): void {
if (!this._ctx || !primitive.text) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const text = primitive.text;
const font = primitive.font ?? 'Arial';
const fontSize = primitive.fontSize ?? 14;
const color = primitive.color ?? 0x000000;
const textAlign = primitive.textAlign ?? primitive.align ?? 'left';
const textVAlign = primitive.textVAlign ?? primitive.valign ?? 'top';
const width = primitive.width;
const height = primitive.height;
ctx.font = `${fontSize}px ${font}`;
ctx.fillStyle = this.colorToCSS(color);
ctx.textBaseline = this.mapVAlign(String(textVAlign));
ctx.textAlign = this.mapHAlign(String(textAlign));
let drawX = x;
let drawY = y;
if (width !== undefined) {
if (textAlign === 'center') drawX = x + width / 2;
else if (textAlign === 'right') drawX = x + width;
}
if (height !== undefined) {
if (textVAlign === 'middle') drawY = y + height / 2;
else if (textVAlign === 'bottom') drawY = y + height;
}
ctx.fillText(text, drawX, drawY);
}
private renderRect(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fillRect(x, y, width, height);
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.strokeRect(x, y, width, height);
}
this._stats.triangles += 2;
}
private renderEllipse(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// Approximate triangle count for ellipse
this._stats.triangles += 32;
}
private renderPolygon(primitive: IRenderPrimitive): void {
const points = primitive.points ?? primitive.polygonPoints;
if (!this._ctx || !points || points.length < 4) return;
const ctx = this._ctx;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
this._stats.triangles += Math.max(0, points.length / 2 - 2);
}
private renderGraph(primitive: IRenderPrimitive): void {
// Render based on graphType
const graphType = primitive.graphType;
if (graphType === undefined) return;
// For now, delegate to rect/ellipse/polygon based on type
switch (graphType) {
case 0: // Rect
this.renderRect(primitive);
break;
case 1: // Ellipse
this.renderEllipse(primitive);
break;
case 2: // Polygon
this.renderPolygon(primitive);
break;
case 3: // Regular Polygon
this.renderRegularPolygon(primitive);
break;
}
}
private renderRegularPolygon(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const sides = primitive.sides ?? 6;
const startAngle = (primitive.startAngle ?? 0) * Math.PI / 180;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = startAngle + (i * 2 * Math.PI) / sides;
const px = cx + Math.cos(angle) * rx;
const py = cy + Math.sin(angle) * ry;
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.closePath();
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
this._stats.triangles += sides;
}
/**
* Convert packed color (0xRRGGBBAA) to CSS rgba string
* 将打包颜色0xRRGGBBAA转换为 CSS rgba 字符串
*/
private colorToCSS(color: number): string {
const r = (color >> 24) & 0xff;
const g = (color >> 16) & 0xff;
const b = (color >> 8) & 0xff;
const a = (color & 0xff) / 255;
return `rgba(${r},${g},${b},${a})`;
}
private mapHAlign(align: ETextAlign | string | undefined): CanvasTextAlign {
switch (align) {
case 'center':
return 'center';
case 'right':
return 'right';
default:
return 'left';
}
}
private mapVAlign(align: ETextVAlign | string | undefined): CanvasTextBaseline {
switch (align) {
case 'middle':
return 'middle';
case 'bottom':
return 'bottom';
default:
return 'top';
}
}
}

View File

@@ -0,0 +1,577 @@
/**
* DOMTextRenderer
*
* Renders FGUI text primitives using HTML DOM elements.
* This provides text rendering when the engine doesn't support native text rendering.
*
* 使用 HTML DOM 元素渲染 FGUI 文本图元
* 当引擎不支持原生文本渲染时提供文本渲染能力
*
* Coordinate systems:
* - FGUI coordinate: top-left origin (0,0), Y-down
* - Engine world coordinate: center origin (0,0), Y-up
* - DOM coordinate: top-left origin, Y-down
*
* Editor mode: UI renders in world space, follows editor camera
* Preview mode: UI renders in screen space, fixed overlay
*
* 坐标系:
* - FGUI 坐标:左上角原点 (0,0)Y 向下
* - 引擎世界坐标:中心原点 (0,0)Y 向上
* - DOM 坐标左上角原点Y 向下
*
* 编辑器模式UI 在世界空间渲染,跟随编辑器相机
* 预览模式UI 在屏幕空间渲染,固定覆盖层
*/
import type { IRenderPrimitive } from './IRenderCollector';
import { ERenderPrimitiveType } from './IRenderCollector';
import { EAlignType, EVertAlignType } from '../core/FieldTypes';
/**
* Camera state for coordinate conversion
* 相机状态,用于坐标转换
*/
export interface ICameraState {
x: number;
y: number;
zoom: number;
rotation?: number;
}
/**
* Text element pool entry
* 文本元素池条目
*/
interface TextElement {
element: HTMLDivElement;
inUse: boolean;
primitiveHash: string;
}
/**
* DOMTextRenderer
*
* Manages a pool of HTML elements for text rendering.
* 管理用于文本渲染的 HTML 元素池
*/
export class DOMTextRenderer {
/** Container element | 容器元素 */
private _container: HTMLDivElement | null = null;
/** Text element pool | 文本元素池 */
private _elementPool: TextElement[] = [];
/** Current frame elements in use | 当前帧使用的元素数量 */
private _elementsInUse: number = 0;
/** Canvas reference for coordinate conversion | 画布引用,用于坐标转换 */
private _canvas: HTMLCanvasElement | null = null;
/** Design width | 设计宽度 */
private _designWidth: number = 1920;
/** Design height | 设计高度 */
private _designHeight: number = 1080;
/** Whether initialized | 是否已初始化 */
private _initialized: boolean = false;
/** Preview mode (screen space) vs Editor mode (world space) | 预览模式屏幕空间vs 编辑器模式(世界空间) */
private _previewMode: boolean = false;
/** Camera state for editor mode | 编辑器模式的相机状态 */
private _camera: ICameraState = { x: 0, y: 0, zoom: 1 };
/**
* Initialize the renderer
* 初始化渲染器
*/
public initialize(canvas: HTMLCanvasElement): void {
if (this._initialized) return;
this._canvas = canvas;
// Create container overlay that matches canvas exactly
// 使用 fixed 定位,这样可以直接使用 getBoundingClientRect 的坐标
// Use fixed positioning so we can directly use getBoundingClientRect coordinates
this._container = document.createElement('div');
this._container.id = 'fgui-text-container';
this._container.style.cssText = `
position: fixed;
pointer-events: none;
overflow: hidden;
z-index: 9999;
`;
// Append to body for fixed positioning
// 附加到 body 以使用 fixed 定位
document.body.appendChild(this._container);
this._initialized = true;
}
/**
* Set design size for coordinate conversion
* 设置设计尺寸,用于坐标转换
*/
public setDesignSize(width: number, height: number): void {
this._designWidth = width;
this._designHeight = height;
}
/**
* Set preview mode
* 设置预览模式
*
* In preview mode (true): UI uses screen space overlay, fixed on screen
* In editor mode (false): UI renders in world space, follows editor camera
*
* 预览模式trueUI 使用屏幕空间叠加,固定在屏幕上
* 编辑器模式falseUI 在世界空间渲染,跟随编辑器相机
*/
public setPreviewMode(mode: boolean): void {
this._previewMode = mode;
}
/**
* Set camera state for editor mode
* 设置编辑器模式的相机状态
*/
public setCamera(camera: ICameraState): void {
this._camera = camera;
}
/**
* Begin a new frame
* 开始新的一帧
*/
public beginFrame(): void {
// Mark all elements as not in use
for (const entry of this._elementPool) {
entry.inUse = false;
}
this._elementsInUse = 0;
}
/**
* Render text primitives
* 渲染文本图元
*/
public renderPrimitives(primitives: readonly IRenderPrimitive[]): void {
if (!this._container || !this._canvas) {
// Try to auto-initialize if not done yet
// 如果尚未初始化,尝试自动初始化
if (!this._initialized) {
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
if (canvas) {
this.initialize(canvas);
}
}
if (!this._container || !this._canvas) return;
}
// Get canvas position and size
// 获取 canvas 位置和尺寸
const canvasRect = this._canvas.getBoundingClientRect();
// Update container to match canvas position
// 更新容器以匹配 canvas 位置
this._container.style.left = `${canvasRect.left}px`;
this._container.style.top = `${canvasRect.top}px`;
this._container.style.width = `${canvasRect.width}px`;
this._container.style.height = `${canvasRect.height}px`;
for (const primitive of primitives) {
if (primitive.type !== ERenderPrimitiveType.Text) continue;
if (!primitive.text) continue;
if (this._previewMode) {
// Preview mode: Screen space rendering
// 预览模式:屏幕空间渲染
this.renderTextPrimitiveScreenSpace(primitive, canvasRect);
} else {
// Editor mode: World space rendering with camera transform
// 编辑器模式:应用相机变换的世界空间渲染
this.renderTextPrimitiveWorldSpace(primitive, canvasRect);
}
}
}
/**
* Render text in screen space (preview mode)
* 在屏幕空间渲染文本(预览模式)
*/
private renderTextPrimitiveScreenSpace(primitive: IRenderPrimitive, canvasRect: DOMRect): void {
// Calculate scale from design resolution to actual canvas size
// 计算从设计分辨率到实际画布尺寸的缩放
const scaleX = canvasRect.width / this._designWidth;
const scaleY = canvasRect.height / this._designHeight;
const scale = Math.min(scaleX, scaleY);
// Calculate offset to center the UI (when aspect ratios don't match)
// 计算居中 UI 的偏移量(当宽高比不匹配时)
const offsetX = (canvasRect.width - this._designWidth * scale) / 2;
const offsetY = (canvasRect.height - this._designHeight * scale) / 2;
this.renderTextPrimitive(primitive, scale, offsetX, offsetY, scale);
}
/**
* Render text in world space (editor mode)
* 在世界空间渲染文本(编辑器模式)
*
* Coordinate conversion:
* 1. FGUI coordinates (top-left origin, Y-down) -> Engine world coordinates (center origin, Y-up)
* 2. Apply camera transform (pan and zoom)
* 3. Engine screen coordinates -> DOM coordinates (top-left origin, Y-down)
*
* 坐标转换:
* 1. FGUI 坐标左上角原点Y向下 -> 引擎世界坐标中心原点Y向上
* 2. 应用相机变换(平移和缩放)
* 3. 引擎屏幕坐标 -> DOM 坐标左上角原点Y向下
*/
private renderTextPrimitiveWorldSpace(primitive: IRenderPrimitive, canvasRect: DOMRect): void {
const element = this.getOrCreateElement();
// Get FGUI position from world matrix
// FGUI coordinates: top-left origin, Y-down
const m = primitive.worldMatrix;
const fguiX = m ? m[4] : 0;
const fguiY = m ? m[5] : 0;
// Extract scale from matrix (same as FGUIRenderDataProvider)
// 从矩阵提取缩放(与 FGUIRenderDataProvider 相同)
const matrixScaleX = m ? Math.sqrt(m[0] * m[0] + m[1] * m[1]) : 1;
const matrixScaleY = m ? Math.sqrt(m[2] * m[2] + m[3] * m[3]) : 1;
// Convert FGUI coordinates to engine world coordinates (same as FGUIRenderDataProvider)
// FGUI: (0,0) = top-left, Y-down
// Engine: (0,0) = center, Y-up
// 使用与 FGUIRenderDataProvider 相同的坐标转换逻辑
const halfDesignWidth = this._designWidth / 2;
const halfDesignHeight = this._designHeight / 2;
// Engine world coordinates
// 引擎世界坐标
const worldX = fguiX - halfDesignWidth;
const worldY = halfDesignHeight - fguiY;
// Apply camera transform (pan and zoom)
// The engine applies camera to sprites; we need to do the same for DOM text
// 应用相机变换(平移和缩放)
// 引擎对精灵应用相机变换;我们需要对 DOM 文本做同样处理
const viewX = (worldX - this._camera.x) * this._camera.zoom;
const viewY = (worldY - this._camera.y) * this._camera.zoom;
// Convert to DOM screen coordinates
// Screen center is at (canvasWidth/2, canvasHeight/2)
// Engine Y-up -> DOM Y-down
// 转换为 DOM 屏幕坐标
const screenX = canvasRect.width / 2 + viewX;
const screenY = canvasRect.height / 2 - viewY;
// Calculate size with matrix scale and camera zoom
// 使用矩阵缩放和相机缩放计算尺寸
const width = primitive.width * matrixScaleX * this._camera.zoom;
const height = primitive.height * matrixScaleY * this._camera.zoom;
const fontSize = (primitive.fontSize ?? 12) * matrixScaleY * this._camera.zoom;
// Build style
const style = element.style;
style.display = 'block';
style.position = 'absolute';
style.left = `${screenX}px`;
style.top = `${screenY}px`;
style.width = `${width}px`;
style.height = `${height}px`;
style.fontSize = `${fontSize}px`;
style.fontFamily = primitive.font || 'Arial, sans-serif';
style.color = this.colorToCSS(primitive.color ?? 0xFFFFFFFF);
style.opacity = String(primitive.alpha ?? 1);
style.overflow = 'hidden';
// Text wrapping (world space mode):
// - singleLine: no wrap at all (nowrap)
// - wordWrap: wrap at word boundaries when exceeding width (pre-wrap)
// - neither: preserve whitespace but no auto-wrap (pre)
// 文本换行(世界空间模式):
// - singleLine: 完全不换行 (nowrap)
// - wordWrap: 超出宽度时在单词边界换行 (pre-wrap)
// - 都不是: 保留空白但不自动换行 (pre)
if (primitive.singleLine) {
style.whiteSpace = 'nowrap';
style.wordBreak = 'normal';
} else if (primitive.wordWrap) {
style.whiteSpace = 'pre-wrap';
style.wordBreak = 'break-word';
} else {
style.whiteSpace = 'pre';
style.wordBreak = 'normal';
}
// Combined scale factor for consistent sizing
// 统一的缩放因子以保持一致性
const sizeScale = matrixScaleY * this._camera.zoom;
style.lineHeight = `${fontSize + (primitive.leading ?? 0) * sizeScale}px`;
style.letterSpacing = `${(primitive.letterSpacing ?? 0) * sizeScale}px`;
// Text decoration
const decorations: string[] = [];
if (primitive.underline) decorations.push('underline');
style.textDecoration = decorations.join(' ') || 'none';
// Font style
style.fontWeight = primitive.bold ? 'bold' : 'normal';
style.fontStyle = primitive.italic ? 'italic' : 'normal';
// Text alignment
style.textAlign = this.mapHAlign(primitive.align as EAlignType);
style.display = 'flex';
style.alignItems = this.mapVAlignFlex(primitive.valign as EVertAlignType);
style.justifyContent = this.mapHAlignFlex(primitive.align as EAlignType);
// Text stroke (using text-shadow for approximation)
if (primitive.stroke && primitive.stroke > 0) {
const strokeColor = this.colorToCSS(primitive.strokeColor ?? 0x000000FF);
const strokeWidth = primitive.stroke * sizeScale;
style.textShadow = `
-${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
-${strokeWidth}px ${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px ${strokeWidth}px 0 ${strokeColor}
`;
} else {
style.textShadow = 'none';
}
// Set text content
element.textContent = primitive.text ?? '';
}
/**
* End frame - hide unused elements
* 结束帧 - 隐藏未使用的元素
*/
public endFrame(): void {
for (const entry of this._elementPool) {
if (!entry.inUse) {
entry.element.style.display = 'none';
}
}
}
/**
* Render a single text primitive (screen space mode)
* 渲染单个文本图元(屏幕空间模式)
*
* @param primitive - Text primitive to render
* @param scale - Uniform scale factor for position
* @param offsetX - X offset for centering
* @param offsetY - Y offset for centering
* @param sizeScale - Scale factor for size and font (can differ from position scale)
*/
private renderTextPrimitive(primitive: IRenderPrimitive, scale: number, offsetX: number, offsetY: number, sizeScale: number): void {
const element = this.getOrCreateElement();
// Calculate position from world matrix
// FGUI coordinates: top-left origin, Y-down
const m = primitive.worldMatrix;
let x = m ? m[4] : 0;
let y = m ? m[5] : 0;
// Apply scale and offset
x = x * scale + offsetX;
y = y * scale + offsetY;
const width = primitive.width * sizeScale;
const height = primitive.height * sizeScale;
const fontSize = (primitive.fontSize ?? 12) * sizeScale;
// Build style
const style = element.style;
style.display = 'block';
style.position = 'absolute';
style.left = `${x}px`;
style.top = `${y}px`;
style.width = `${width}px`;
style.height = `${height}px`;
style.fontSize = `${fontSize}px`;
style.fontFamily = primitive.font || 'Arial, sans-serif';
style.color = this.colorToCSS(primitive.color ?? 0xFFFFFFFF);
style.opacity = String(primitive.alpha ?? 1);
style.overflow = 'hidden';
// Text wrapping (screen space mode):
// 文本换行(屏幕空间模式)
if (primitive.singleLine) {
style.whiteSpace = 'nowrap';
style.wordBreak = 'normal';
} else if (primitive.wordWrap) {
style.whiteSpace = 'pre-wrap';
style.wordBreak = 'break-word';
} else {
style.whiteSpace = 'pre';
style.wordBreak = 'normal';
}
style.lineHeight = `${fontSize + (primitive.leading ?? 0) * sizeScale}px`;
style.letterSpacing = `${(primitive.letterSpacing ?? 0) * sizeScale}px`;
// Text decoration
const decorations: string[] = [];
if (primitive.underline) decorations.push('underline');
style.textDecoration = decorations.join(' ') || 'none';
// Font style
style.fontWeight = primitive.bold ? 'bold' : 'normal';
style.fontStyle = primitive.italic ? 'italic' : 'normal';
// Text alignment
style.textAlign = this.mapHAlign(primitive.align as EAlignType);
style.display = 'flex';
style.alignItems = this.mapVAlignFlex(primitive.valign as EVertAlignType);
style.justifyContent = this.mapHAlignFlex(primitive.align as EAlignType);
// Text stroke (using text-shadow for approximation)
if (primitive.stroke && primitive.stroke > 0) {
const strokeColor = this.colorToCSS(primitive.strokeColor ?? 0x000000FF);
const strokeWidth = primitive.stroke * sizeScale;
style.textShadow = `
-${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
-${strokeWidth}px ${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px ${strokeWidth}px 0 ${strokeColor}
`;
} else {
style.textShadow = 'none';
}
// Set text content
element.textContent = primitive.text ?? '';
}
/**
* Get or create a text element
* 获取或创建文本元素
*/
private getOrCreateElement(): HTMLDivElement {
// Find unused element
for (const entry of this._elementPool) {
if (!entry.inUse) {
entry.inUse = true;
this._elementsInUse++;
return entry.element;
}
}
// Create new element
const element = document.createElement('div');
element.style.pointerEvents = 'none';
this._container!.appendChild(element);
const entry: TextElement = {
element,
inUse: true,
primitiveHash: ''
};
this._elementPool.push(entry);
this._elementsInUse++;
return element;
}
/**
* Convert packed color (0xRRGGBBAA) to CSS rgba string
* 将打包颜色0xRRGGBBAA转换为 CSS rgba 字符串
*/
private colorToCSS(color: number): string {
const r = (color >> 24) & 0xff;
const g = (color >> 16) & 0xff;
const b = (color >> 8) & 0xff;
const a = (color & 0xff) / 255;
return `rgba(${r},${g},${b},${a})`;
}
/**
* Map horizontal alignment to CSS
* 将水平对齐映射到 CSS
*/
private mapHAlign(align: EAlignType | undefined): string {
switch (align) {
case EAlignType.Center:
return 'center';
case EAlignType.Right:
return 'right';
default:
return 'left';
}
}
/**
* Map horizontal alignment to flexbox
* 将水平对齐映射到 flexbox
*/
private mapHAlignFlex(align: EAlignType | undefined): string {
switch (align) {
case EAlignType.Center:
return 'center';
case EAlignType.Right:
return 'flex-end';
default:
return 'flex-start';
}
}
/**
* Map vertical alignment to flexbox
* 将垂直对齐映射到 flexbox
*/
private mapVAlignFlex(align: EVertAlignType | undefined): string {
switch (align) {
case EVertAlignType.Middle:
return 'center';
case EVertAlignType.Bottom:
return 'flex-end';
default:
return 'flex-start';
}
}
/**
* Dispose the renderer
* 释放渲染器
*/
public dispose(): void {
if (this._container && this._container.parentElement) {
this._container.parentElement.removeChild(this._container);
}
this._container = null;
this._elementPool = [];
this._initialized = false;
}
}
/**
* Default DOM text renderer instance
* 默认 DOM 文本渲染器实例
*/
let _defaultRenderer: DOMTextRenderer | null = null;
/**
* Get default DOM text renderer
* 获取默认 DOM 文本渲染器
*/
export function getDOMTextRenderer(): DOMTextRenderer {
if (!_defaultRenderer) {
_defaultRenderer = new DOMTextRenderer();
}
return _defaultRenderer;
}
/**
* Set default DOM text renderer
* 设置默认 DOM 文本渲染器
*/
export function setDOMTextRenderer(renderer: DOMTextRenderer | null): void {
_defaultRenderer = renderer;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,480 @@
/**
* GraphMeshGenerator
*
* Generates mesh data for FairyGUI graph primitives (rect, ellipse, polygon).
* Uses triangulation to convert shapes into triangles for GPU rendering.
*
* 为 FairyGUI 图形图元(矩形、椭圆、多边形)生成网格数据
* 使用三角化将形状转换为 GPU 可渲染的三角形
*/
/**
* Mesh vertex data
* 网格顶点数据
*/
export interface MeshVertex {
x: number;
y: number;
u: number;
v: number;
color: number;
}
/**
* Generated mesh data
* 生成的网格数据
*/
export interface GraphMeshData {
/** Vertex positions [x, y, ...] | 顶点位置 */
positions: number[];
/** Texture coordinates [u, v, ...] | 纹理坐标 */
uvs: number[];
/** Vertex colors (packed RGBA) | 顶点颜色 */
colors: number[];
/** Triangle indices | 三角形索引 */
indices: number[];
}
/**
* GraphMeshGenerator
*
* Generates mesh data for various graph shapes.
* 为各种图形形状生成网格数据
*/
export class GraphMeshGenerator {
/**
* Generate mesh for a filled rectangle
* 生成填充矩形的网格
*/
public static generateRect(
width: number,
height: number,
fillColor: number,
cornerRadius?: number[]
): GraphMeshData {
// Simple rectangle without corner radius
// 没有圆角的简单矩形
if (!cornerRadius || cornerRadius.every(r => r <= 0)) {
return this.generateSimpleRect(width, height, fillColor);
}
// Rectangle with corner radius - generate as polygon
// 带圆角的矩形 - 作为多边形生成
return this.generateRoundedRect(width, height, fillColor, cornerRadius);
}
/**
* Generate simple rectangle (4 vertices, 2 triangles)
* 生成简单矩形4 个顶点2 个三角形)
*/
private static generateSimpleRect(
width: number,
height: number,
color: number
): GraphMeshData {
// Vertices: top-left, top-right, bottom-right, bottom-left
const positions = [
0, 0, // top-left
width, 0, // top-right
width, height, // bottom-right
0, height // bottom-left
];
const uvs = [
0, 0,
1, 0,
1, 1,
0, 1
];
const colors = [color, color, color, color];
// Two triangles: 0-1-2, 0-2-3
const indices = [0, 1, 2, 0, 2, 3];
return { positions, uvs, colors, indices };
}
/**
* Generate rounded rectangle
* 生成圆角矩形
*/
private static generateRoundedRect(
width: number,
height: number,
color: number,
cornerRadius: number[]
): GraphMeshData {
const [tl, tr, br, bl] = cornerRadius;
const segments = 8; // Segments per corner
const points: number[] = [];
// Generate points for each corner
// Top-left corner
this.addCornerPoints(points, tl, tl, tl, Math.PI, Math.PI * 1.5, segments);
// Top-right corner
this.addCornerPoints(points, width - tr, tr, tr, Math.PI * 1.5, Math.PI * 2, segments);
// Bottom-right corner
this.addCornerPoints(points, width - br, height - br, br, 0, Math.PI * 0.5, segments);
// Bottom-left corner
this.addCornerPoints(points, bl, height - bl, bl, Math.PI * 0.5, Math.PI, segments);
// Triangulate the polygon
return this.triangulatePolygon(points, width, height, color);
}
/**
* Add corner arc points
* 添加圆角弧线点
*/
private static addCornerPoints(
points: number[],
cx: number,
cy: number,
radius: number,
startAngle: number,
endAngle: number,
segments: number
): void {
if (radius <= 0) {
points.push(cx, cy);
return;
}
const angleStep = (endAngle - startAngle) / segments;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + angleStep * i;
points.push(
cx + Math.cos(angle) * radius,
cy + Math.sin(angle) * radius
);
}
}
/**
* Generate mesh for an ellipse
* 生成椭圆的网格
*/
public static generateEllipse(
width: number,
height: number,
fillColor: number
): GraphMeshData {
const radiusX = width / 2;
const radiusY = height / 2;
const centerX = radiusX;
const centerY = radiusY;
// Calculate number of segments based on perimeter
// 根据周长计算分段数
const perimeter = Math.PI * (radiusX + radiusY);
const segments = Math.min(Math.max(Math.ceil(perimeter / 4), 24), 128);
const positions: number[] = [centerX, centerY]; // Center vertex
const uvs: number[] = [0.5, 0.5]; // Center UV
const colors: number[] = [fillColor];
const indices: number[] = [];
const angleStep = (Math.PI * 2) / segments;
for (let i = 0; i <= segments; i++) {
const angle = angleStep * i;
const x = centerX + Math.cos(angle) * radiusX;
const y = centerY + Math.sin(angle) * radiusY;
positions.push(x, y);
uvs.push(
(Math.cos(angle) + 1) / 2,
(Math.sin(angle) + 1) / 2
);
colors.push(fillColor);
// Create triangle from center to edge
if (i > 0) {
indices.push(0, i, i + 1);
}
}
// Close the circle
indices.push(0, segments, 1);
return { positions, uvs, colors, indices };
}
/**
* Generate mesh for a polygon
* 生成多边形的网格
*
* Uses ear clipping algorithm for triangulation.
* 使用耳切法进行三角化
*/
public static generatePolygon(
points: number[],
width: number,
height: number,
fillColor: number
): GraphMeshData {
return this.triangulatePolygon(points, width, height, fillColor);
}
/**
* Triangulate a polygon using ear clipping algorithm
* 使用耳切法三角化多边形
*/
private static triangulatePolygon(
points: number[],
width: number,
height: number,
color: number
): GraphMeshData {
const numVertices = points.length / 2;
if (numVertices < 3) {
return { positions: [], uvs: [], colors: [], indices: [] };
}
const positions: number[] = [...points];
const uvs: number[] = [];
const colors: number[] = [];
// Generate UVs based on position
for (let i = 0; i < numVertices; i++) {
const x = points[i * 2];
const y = points[i * 2 + 1];
uvs.push(width > 0 ? x / width : 0, height > 0 ? y / height : 0);
colors.push(color);
}
// Ear clipping triangulation
const indices: number[] = [];
const restIndices: number[] = [];
for (let i = 0; i < numVertices; i++) {
restIndices.push(i);
}
while (restIndices.length > 3) {
let earFound = false;
for (let i = 0; i < restIndices.length; i++) {
const i0 = restIndices[i];
const i1 = restIndices[(i + 1) % restIndices.length];
const i2 = restIndices[(i + 2) % restIndices.length];
const ax = points[i0 * 2], ay = points[i0 * 2 + 1];
const bx = points[i1 * 2], by = points[i1 * 2 + 1];
const cx = points[i2 * 2], cy = points[i2 * 2 + 1];
// Check if this is a convex vertex (ear candidate)
if ((ay - by) * (cx - bx) + (bx - ax) * (cy - by) >= 0) {
// Check if no other point is inside this triangle
let isEar = true;
for (let j = 0; j < restIndices.length; j++) {
if (j === i || j === (i + 1) % restIndices.length || j === (i + 2) % restIndices.length) {
continue;
}
const idx = restIndices[j];
const px = points[idx * 2], py = points[idx * 2 + 1];
if (this.isPointInTriangle(px, py, ax, ay, bx, by, cx, cy)) {
isEar = false;
break;
}
}
if (isEar) {
indices.push(i0, i1, i2);
restIndices.splice((i + 1) % restIndices.length, 1);
earFound = true;
break;
}
}
}
if (!earFound) {
// No ear found, polygon may be degenerate
break;
}
}
// Add the last triangle
if (restIndices.length === 3) {
indices.push(restIndices[0], restIndices[1], restIndices[2]);
}
return { positions, uvs, colors, indices };
}
/**
* Check if point is inside triangle
* 检查点是否在三角形内
*/
private static isPointInTriangle(
px: number, py: number,
ax: number, ay: number,
bx: number, by: number,
cx: number, cy: number
): boolean {
const v0x = cx - ax, v0y = cy - ay;
const v1x = bx - ax, v1y = by - ay;
const v2x = px - ax, v2y = py - ay;
const dot00 = v0x * v0x + v0y * v0y;
const dot01 = v0x * v1x + v0y * v1y;
const dot02 = v0x * v2x + v0y * v2y;
const dot11 = v1x * v1x + v1y * v1y;
const dot12 = v1x * v2x + v1y * v2y;
const invDen = 1 / (dot00 * dot11 - dot01 * dot01);
const u = (dot11 * dot02 - dot01 * dot12) * invDen;
const v = (dot00 * dot12 - dot01 * dot02) * invDen;
return u >= 0 && v >= 0 && u + v < 1;
}
/**
* Generate outline mesh (stroke)
* 生成轮廓线网格(描边)
*/
public static generateOutline(
points: number[],
lineWidth: number,
lineColor: number,
closed: boolean = true
): GraphMeshData {
const numPoints = points.length / 2;
if (numPoints < 2) {
return { positions: [], uvs: [], colors: [], indices: [] };
}
const positions: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
const indices: number[] = [];
const halfWidth = lineWidth / 2;
for (let i = 0; i < numPoints; i++) {
const x0 = points[i * 2];
const y0 = points[i * 2 + 1];
let x1: number, y1: number;
if (i < numPoints - 1) {
x1 = points[(i + 1) * 2];
y1 = points[(i + 1) * 2 + 1];
} else if (closed) {
x1 = points[0];
y1 = points[1];
} else {
continue; // Last point, no segment
}
// Calculate perpendicular vector
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) continue;
const nx = -dy / len * halfWidth;
const ny = dx / len * halfWidth;
// Add 4 vertices for this segment (quad)
const baseIdx = positions.length / 2;
positions.push(
x0 - nx, y0 - ny,
x0 + nx, y0 + ny,
x1 - nx, y1 - ny,
x1 + nx, y1 + ny
);
uvs.push(0, 0, 0, 1, 1, 0, 1, 1);
colors.push(lineColor, lineColor, lineColor, lineColor);
// Two triangles for the quad
indices.push(
baseIdx, baseIdx + 1, baseIdx + 3,
baseIdx, baseIdx + 3, baseIdx + 2
);
// Joint with previous segment
if (i > 0) {
const prevBaseIdx = baseIdx - 4;
indices.push(
prevBaseIdx + 2, prevBaseIdx + 3, baseIdx + 1,
prevBaseIdx + 2, baseIdx + 1, baseIdx
);
}
}
// Close the outline joints
if (closed && numPoints > 2) {
const lastBaseIdx = positions.length / 2 - 4;
indices.push(
lastBaseIdx + 2, lastBaseIdx + 3, 1,
lastBaseIdx + 2, 1, 0
);
}
return { positions, uvs, colors, indices };
}
/**
* Generate mesh for rectangle outline
* 生成矩形轮廓线网格
*/
public static generateRectOutline(
width: number,
height: number,
lineWidth: number,
lineColor: number,
cornerRadius?: number[]
): GraphMeshData {
const points: number[] = [];
if (!cornerRadius || cornerRadius.every(r => r <= 0)) {
// Simple rectangle
points.push(0, 0, width, 0, width, height, 0, height);
} else {
// Rounded rectangle
const [tl, tr, br, bl] = cornerRadius;
const segments = 8;
this.addCornerPoints(points, tl, tl, tl, Math.PI, Math.PI * 1.5, segments);
this.addCornerPoints(points, width - tr, tr, tr, Math.PI * 1.5, Math.PI * 2, segments);
this.addCornerPoints(points, width - br, height - br, br, 0, Math.PI * 0.5, segments);
this.addCornerPoints(points, bl, height - bl, bl, Math.PI * 0.5, Math.PI, segments);
}
return this.generateOutline(points, lineWidth, lineColor, true);
}
/**
* Generate mesh for ellipse outline
* 生成椭圆轮廓线网格
*/
public static generateEllipseOutline(
width: number,
height: number,
lineWidth: number,
lineColor: number
): GraphMeshData {
const radiusX = width / 2;
const radiusY = height / 2;
const centerX = radiusX;
const centerY = radiusY;
const perimeter = Math.PI * (radiusX + radiusY);
const segments = Math.min(Math.max(Math.ceil(perimeter / 4), 24), 128);
const points: number[] = [];
const angleStep = (Math.PI * 2) / segments;
for (let i = 0; i < segments; i++) {
const angle = angleStep * i;
points.push(
centerX + Math.cos(angle) * radiusX,
centerY + Math.sin(angle) * radiusY
);
}
return this.generateOutline(points, lineWidth, lineColor, true);
}
}

View File

@@ -0,0 +1,140 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderPrimitive } from './IRenderCollector';
/**
* Texture handle
* 纹理句柄
*/
export interface ITextureHandle {
/** Unique identifier | 唯一标识 */
readonly id: number;
/** Texture width | 纹理宽度 */
readonly width: number;
/** Texture height | 纹理高度 */
readonly height: number;
/** Is texture valid | 纹理是否有效 */
readonly isValid: boolean;
}
/**
* Font handle
* 字体句柄
*/
export interface IFontHandle {
/** Font family name | 字体名称 */
readonly family: string;
/** Is font loaded | 字体是否已加载 */
readonly isLoaded: boolean;
}
/**
* Render statistics
* 渲染统计
*/
export interface IRenderStats {
/** Draw call count | 绘制调用数 */
drawCalls: number;
/** Triangle count | 三角形数量 */
triangles: number;
/** Texture switches | 纹理切换次数 */
textureSwitches: number;
/** Batch count | 批次数量 */
batches: number;
/** Frame time in ms | 帧时间(毫秒) */
frameTime: number;
}
/**
* Render backend interface
*
* Abstract interface for graphics backend (WebGPU, WebGL, Canvas2D).
*
* 图形后端抽象接口WebGPU、WebGL、Canvas2D
*/
export interface IRenderBackend {
/** Backend name | 后端名称 */
readonly name: string;
/** Is backend initialized | 后端是否已初始化 */
readonly isInitialized: boolean;
/** Canvas width | 画布宽度 */
readonly width: number;
/** Canvas height | 画布高度 */
readonly height: number;
/**
* Initialize the backend
* 初始化后端
*/
initialize(canvas: HTMLCanvasElement): Promise<boolean>;
/**
* Begin a new frame
* 开始新帧
*/
beginFrame(): void;
/**
* End the current frame
* 结束当前帧
*/
endFrame(): void;
/**
* Submit render primitives for rendering
* 提交渲染图元进行渲染
*/
submitPrimitives(primitives: readonly IRenderPrimitive[]): void;
/**
* Set clip rectangle
* 设置裁剪矩形
*/
setClipRect(rect: IRectangle | null): void;
/**
* Create a texture from image data
* 从图像数据创建纹理
*/
createTexture(
source: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): ITextureHandle;
/**
* Destroy a texture
* 销毁纹理
*/
destroyTexture(texture: ITextureHandle): void;
/**
* Load a font
* 加载字体
*/
loadFont(family: string, url?: string): Promise<IFontHandle>;
/**
* Resize the backend
* 调整后端大小
*/
resize(width: number, height: number): void;
/**
* Get render statistics
* 获取渲染统计
*/
getStats(): IRenderStats;
/**
* Dispose the backend
* 销毁后端
*/
dispose(): void;
}
/**
* Backend factory function type
* 后端工厂函数类型
*/
export type RenderBackendFactory = () => IRenderBackend;

View File

@@ -0,0 +1,287 @@
import type { IRectangle } from '../utils/MathTypes';
import type { EGraphType, EAlignType, EVertAlignType } from '../core/FieldTypes';
/**
* Render primitive type
* 渲染图元类型
*/
export const enum ERenderPrimitiveType {
Rect = 'rect',
Image = 'image',
Text = 'text',
Mesh = 'mesh',
Graph = 'graph',
Ellipse = 'ellipse',
Polygon = 'polygon'
}
/**
* Blend mode
* 混合模式
*/
export const enum EBlendModeType {
Normal = 'normal',
Add = 'add',
Multiply = 'multiply',
Screen = 'screen'
}
/**
* Transform matrix (2D affine)
* 变换矩阵2D 仿射)
*/
export interface ITransformMatrix {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
/**
* Text alignment
* 文本对齐
*/
export const enum ETextAlign {
Left = 'left',
Center = 'center',
Right = 'right'
}
/**
* Text vertical alignment
* 文本垂直对齐
*/
export const enum ETextVAlign {
Top = 'top',
Middle = 'middle',
Bottom = 'bottom'
}
/**
* Render primitive data
* 渲染图元数据
*/
export interface IRenderPrimitive {
/** Primitive type | 图元类型 */
type: ERenderPrimitiveType;
/** Sort order (higher = on top) | 排序顺序(越大越上层) */
sortOrder: number;
/** World matrix (6 elements: a, b, c, d, tx, ty) | 世界矩阵 */
worldMatrix: Float32Array;
/** X position | X 坐标 */
x?: number;
/** Y position | Y 坐标 */
y?: number;
/** Width | 宽度 */
width: number;
/** Height | 高度 */
height: number;
/** Alpha | 透明度 */
alpha: number;
/** Is grayed | 是否灰度 */
grayed: boolean;
/** Transform matrix | 变换矩阵 */
transform?: ITransformMatrix;
/** Blend mode | 混合模式 */
blendMode?: EBlendModeType;
/** Clip rect (in stage coordinates) | 裁剪矩形(舞台坐标) */
clipRect?: IRectangle;
/** Source rectangle (for image) | 源矩形(用于图像) */
srcRect?: IRectangle;
// Image properties | 图像属性
/** Texture ID or key | 纹理 ID 或键 */
textureId?: string | number;
/** UV rect [u, v, uWidth, vHeight] | UV 矩形 */
uvRect?: [number, number, number, number];
/** Tint color (RGBA packed) | 着色颜色 */
color?: number;
/** Nine-patch grid | 九宫格 */
scale9Grid?: IRectangle;
/** Source width for nine-slice (original texture region width) | 九宫格源宽度(原始纹理区域宽度) */
sourceWidth?: number;
/** Source height for nine-slice (original texture region height) | 九宫格源高度(原始纹理区域高度) */
sourceHeight?: number;
/** Tile mode | 平铺模式 */
tileMode?: boolean;
// Text properties | 文本属性
/** Text content | 文本内容 */
text?: string;
/** Font family | 字体 */
font?: string;
/** Font size | 字体大小 */
fontSize?: number;
/** Text color | 文本颜色 */
textColor?: number;
/** Bold | 粗体 */
bold?: boolean;
/** Italic | 斜体 */
italic?: boolean;
/** Underline | 下划线 */
underline?: boolean;
/** Text align | 文本对齐 */
align?: ETextAlign | EAlignType;
/** Text horizontal align (alias) | 文本水平对齐(别名) */
textAlign?: ETextAlign | string;
/** Text vertical align | 文本垂直对齐 */
valign?: ETextVAlign | EVertAlignType;
/** Text vertical align (alias) | 文本垂直对齐(别名) */
textVAlign?: ETextVAlign | string;
/** Leading (line spacing) | 行间距 */
leading?: number;
/** Letter spacing | 字间距 */
letterSpacing?: number;
/** Outline color | 描边颜色 */
outlineColor?: number;
/** Outline width | 描边宽度 */
outlineWidth?: number;
/** Shadow color | 阴影颜色 */
shadowColor?: number;
/** Shadow offset | 阴影偏移 */
shadowOffset?: [number, number];
// Rect properties | 矩形属性
/** Fill color | 填充颜色 */
fillColor?: number;
/** Stroke color | 边框颜色 */
strokeColor?: number;
/** Stroke width | 边框宽度 */
strokeWidth?: number;
/** Corner radius | 圆角半径 */
cornerRadius?: number | number[];
/** Single line | 单行 */
singleLine?: boolean;
/** Word wrap | 自动换行 */
wordWrap?: boolean;
/** Stroke | 描边宽度 */
stroke?: number;
// Graph properties | 图形属性
/** Graph type | 图形类型 */
graphType?: EGraphType;
/** Line size | 线宽 */
lineSize?: number;
/** Line color | 线颜色 */
lineColor?: number;
/** Polygon points | 多边形顶点 */
polygonPoints?: number[];
/** Points array (alias for polygonPoints) | 点数组polygonPoints 别名) */
points?: number[];
/** Line width | 线宽 */
lineWidth?: number;
/** Sides for regular polygon | 正多边形边数 */
sides?: number;
/** Start angle for regular polygon | 正多边形起始角度 */
startAngle?: number;
/** Distance multipliers for regular polygon | 正多边形距离乘数 */
distances?: number[];
// Mesh properties | 网格属性
/** Vertices [x, y, ...] | 顶点 */
vertices?: Float32Array;
/** UVs [u, v, ...] | UV 坐标 */
uvs?: Float32Array;
/** Indices | 索引 */
indices?: Uint16Array;
}
/**
* Render collector interface
* 渲染收集器接口
*/
export interface IRenderCollector {
/**
* Add a render primitive
* 添加渲染图元
*/
addPrimitive(primitive: IRenderPrimitive): void;
/**
* Push a clip rect
* 压入裁剪矩形
*/
pushClipRect(rect: IRectangle): void;
/**
* Pop the current clip rect
* 弹出当前裁剪矩形
*/
popClipRect(): void;
/**
* Get current clip rect
* 获取当前裁剪矩形
*/
getCurrentClipRect(): IRectangle | null;
/**
* Clear all primitives
* 清除所有图元
*/
clear(): void;
/**
* Get all primitives (sorted by sortOrder)
* 获取所有图元(按 sortOrder 排序)
*/
getPrimitives(): readonly IRenderPrimitive[];
}

View File

@@ -0,0 +1,310 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderCollector, IRenderPrimitive } from './IRenderCollector';
import type { IRenderBackend, IRenderStats, ITextureHandle, IFontHandle } from './IRenderBackend';
/**
* Texture cache entry
* 纹理缓存条目
*/
interface TextureCacheEntry {
handle: ITextureHandle;
lastUsedFrame: number;
refCount: number;
}
/**
* RenderBridge
*
* Bridges FairyGUI render primitives to the graphics backend.
* Provides batching, caching, and optimization.
*
* 将 FairyGUI 渲染图元桥接到图形后端
* 提供批处理、缓存和优化
*
* Features:
* - Automatic batching of similar primitives
* - Texture atlas support
* - Font caching
* - Render statistics
*
* @example
* ```typescript
* const bridge = new RenderBridge(webgpuBackend);
* await bridge.initialize(canvas);
*
* // In render loop
* bridge.beginFrame();
* root.collectRenderData(collector);
* bridge.render(collector);
* bridge.endFrame();
* ```
*/
export class RenderBridge {
private _backend: IRenderBackend;
private _textureCache: Map<string, TextureCacheEntry> = new Map();
private _fontCache: Map<string, IFontHandle> = new Map();
private _currentFrame: number = 0;
private _textureCacheMaxAge: number = 60; // Frames before texture is evicted
private _clipStack: IRectangle[] = [];
private _batchBuffer: IRenderPrimitive[] = [];
constructor(backend: IRenderBackend) {
this._backend = backend;
}
/**
* Get the underlying backend
* 获取底层后端
*/
public get backend(): IRenderBackend {
return this._backend;
}
/**
* Check if bridge is initialized
* 检查桥接是否已初始化
*/
public get isInitialized(): boolean {
return this._backend.isInitialized;
}
/**
* Initialize the bridge with a canvas
* 使用画布初始化桥接
*/
public async initialize(canvas: HTMLCanvasElement): Promise<boolean> {
return this._backend.initialize(canvas);
}
/**
* Begin a new frame
* 开始新帧
*/
public beginFrame(): void {
this._currentFrame++;
this._clipStack.length = 0;
this._batchBuffer.length = 0;
this._backend.beginFrame();
}
/**
* End the current frame
* 结束当前帧
*/
public endFrame(): void {
this.flushBatch();
this._backend.endFrame();
this.evictOldTextures();
}
/**
* Render primitives from a collector
* 渲染收集器中的图元
*/
public render(collector: IRenderCollector): void {
const primitives = collector.getPrimitives();
for (const primitive of primitives) {
this.processPrimitive(primitive);
}
}
/**
* Render a single primitive
* 渲染单个图元
*/
public renderPrimitive(primitive: IRenderPrimitive): void {
this.processPrimitive(primitive);
}
/**
* Push a clip rectangle
* 压入裁剪矩形
*/
public pushClipRect(rect: IRectangle): void {
if (this._clipStack.length > 0) {
const current = this._clipStack[this._clipStack.length - 1];
const intersected = this.intersectRects(current, rect);
this._clipStack.push(intersected);
} else {
this._clipStack.push({ ...rect });
}
this.flushBatch();
this._backend.setClipRect(this._clipStack[this._clipStack.length - 1]);
}
/**
* Pop the current clip rectangle
* 弹出当前裁剪矩形
*/
public popClipRect(): void {
if (this._clipStack.length > 0) {
this._clipStack.pop();
this.flushBatch();
this._backend.setClipRect(
this._clipStack.length > 0 ? this._clipStack[this._clipStack.length - 1] : null
);
}
}
/**
* Load or get cached texture
* 加载或获取缓存的纹理
*/
public async loadTexture(
url: string,
source?: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): Promise<ITextureHandle | null> {
// Check cache first
const cached = this._textureCache.get(url);
if (cached) {
cached.lastUsedFrame = this._currentFrame;
cached.refCount++;
return cached.handle;
}
// Load or create texture
let textureSource = source;
if (!textureSource) {
try {
const response = await fetch(url);
const blob = await response.blob();
textureSource = await createImageBitmap(blob);
} catch (error) {
console.error(`Failed to load texture: ${url}`, error);
return null;
}
}
const handle = this._backend.createTexture(textureSource);
this._textureCache.set(url, {
handle,
lastUsedFrame: this._currentFrame,
refCount: 1
});
return handle;
}
/**
* Release a texture reference
* 释放纹理引用
*/
public releaseTexture(url: string): void {
const cached = this._textureCache.get(url);
if (cached) {
cached.refCount--;
}
}
/**
* Load or get cached font
* 加载或获取缓存的字体
*/
public async loadFont(family: string, url?: string): Promise<IFontHandle> {
const cached = this._fontCache.get(family);
if (cached) {
return cached;
}
const handle = await this._backend.loadFont(family, url);
this._fontCache.set(family, handle);
return handle;
}
/**
* Resize the render target
* 调整渲染目标大小
*/
public resize(width: number, height: number): void {
this._backend.resize(width, height);
}
/**
* Get render statistics
* 获取渲染统计
*/
public getStats(): IRenderStats & { textureCount: number; fontCount: number } {
const backendStats = this._backend.getStats();
return {
...backendStats,
textureCount: this._textureCache.size,
fontCount: this._fontCache.size
};
}
/**
* Dispose the bridge and all resources
* 销毁桥接和所有资源
*/
public dispose(): void {
// Destroy all cached textures
for (const entry of this._textureCache.values()) {
this._backend.destroyTexture(entry.handle);
}
this._textureCache.clear();
this._fontCache.clear();
this._clipStack.length = 0;
this._batchBuffer.length = 0;
this._backend.dispose();
}
private processPrimitive(primitive: IRenderPrimitive): void {
// Check if can batch with previous primitives
if (this._batchBuffer.length > 0) {
const last = this._batchBuffer[this._batchBuffer.length - 1];
if (!this.canBatch(last, primitive)) {
this.flushBatch();
}
}
this._batchBuffer.push(primitive);
}
private canBatch(a: IRenderPrimitive, b: IRenderPrimitive): boolean {
// Can batch if same type and texture
if (a.type !== b.type) return false;
if (a.textureId !== b.textureId) return false;
if (a.blendMode !== b.blendMode) return false;
return true;
}
private flushBatch(): void {
if (this._batchBuffer.length === 0) return;
this._backend.submitPrimitives(this._batchBuffer);
this._batchBuffer.length = 0;
}
private evictOldTextures(): void {
const minFrame = this._currentFrame - this._textureCacheMaxAge;
const toEvict: string[] = [];
for (const [url, entry] of this._textureCache) {
if (entry.refCount <= 0 && entry.lastUsedFrame < minFrame) {
toEvict.push(url);
}
}
for (const url of toEvict) {
const entry = this._textureCache.get(url);
if (entry) {
this._backend.destroyTexture(entry.handle);
this._textureCache.delete(url);
}
}
}
private intersectRects(a: IRectangle, b: IRectangle): IRectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return {
x,
y,
width: Math.max(0, right - x),
height: Math.max(0, bottom - y)
};
}
}

View File

@@ -0,0 +1,136 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderCollector, IRenderPrimitive } from './IRenderCollector';
/**
* RenderCollector
*
* Collects render primitives from UI hierarchy for batch rendering.
* Implements IRenderCollector interface with efficient primitive storage.
*
* 从 UI 层级收集渲染图元用于批量渲染
*/
export class RenderCollector implements IRenderCollector {
private _primitives: IRenderPrimitive[] = [];
private _clipStack: IRectangle[] = [];
private _sortNeeded: boolean = false;
/**
* Add a render primitive
* 添加渲染图元
*/
public addPrimitive(primitive: IRenderPrimitive): void {
this._primitives.push(primitive);
this._sortNeeded = true;
}
/**
* Push a clip rect onto the stack
* 压入裁剪矩形
*/
public pushClipRect(rect: IRectangle): void {
if (this._clipStack.length > 0) {
// Intersect with current clip rect
const current = this._clipStack[this._clipStack.length - 1];
const intersected = this.intersectRects(current, rect);
this._clipStack.push(intersected);
} else {
this._clipStack.push({ ...rect });
}
}
/**
* Pop the current clip rect
* 弹出当前裁剪矩形
*/
public popClipRect(): void {
if (this._clipStack.length > 0) {
this._clipStack.pop();
}
}
/**
* Get current clip rect
* 获取当前裁剪矩形
*/
public getCurrentClipRect(): IRectangle | null {
if (this._clipStack.length > 0) {
return this._clipStack[this._clipStack.length - 1];
}
return null;
}
/**
* Clear all primitives
* 清除所有图元
*/
public clear(): void {
this._primitives.length = 0;
this._clipStack.length = 0;
this._sortNeeded = false;
}
/**
* Get all primitives sorted by sortOrder
* 获取所有按 sortOrder 排序的图元
*/
public getPrimitives(): readonly IRenderPrimitive[] {
if (this._sortNeeded) {
this._primitives.sort((a, b) => a.sortOrder - b.sortOrder);
this._sortNeeded = false;
}
return this._primitives;
}
/**
* Get primitive count
* 获取图元数量
*/
public get primitiveCount(): number {
return this._primitives.length;
}
/**
* Get clip stack depth
* 获取裁剪栈深度
*/
public get clipStackDepth(): number {
return this._clipStack.length;
}
/**
* Calculate intersection of two rectangles
* 计算两个矩形的交集
*/
private intersectRects(a: IRectangle, b: IRectangle): IRectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return {
x,
y,
width: Math.max(0, right - x),
height: Math.max(0, bottom - y)
};
}
/**
* Iterate over primitives with callback
* 遍历图元
*/
public forEach(callback: (primitive: IRenderPrimitive, index: number) => void): void {
const primitives = this.getPrimitives();
for (let i = 0; i < primitives.length; i++) {
callback(primitives[i], i);
}
}
/**
* Filter primitives by type
* 按类型过滤图元
*/
public filterByType(type: string): IRenderPrimitive[] {
return this._primitives.filter((p) => p.type === type);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
/**
* BitmapFont
*
* Bitmap font support for FairyGUI.
* Handles BMFont format from FairyGUI Editor exports.
*
* 位图字体支持
* 处理 FairyGUI 编辑器导出的 BMFont 格式
*/
import type { MSDFFont, IMSDFFontData, IMSDFGlyph } from './MSDFFont';
/**
* FairyGUI bitmap font glyph
* FairyGUI 位图字体字形
*/
export interface IBitmapGlyph {
/** X offset in the glyph | 字形内 X 偏移 */
x: number;
/** Y offset in the glyph | 字形内 Y 偏移 */
y: number;
/** Glyph width | 字形宽度 */
width: number;
/** Glyph height | 字形高度 */
height: number;
/** Horizontal advance | 水平前进量 */
advance: number;
/** Source texture region (if from atlas) | 源纹理区域 */
textureRegion?: {
x: number;
y: number;
width: number;
height: number;
};
/** Texture ID for this glyph | 此字形的纹理 ID */
textureId?: number;
}
/**
* FairyGUI bitmap font data (from UIPackage)
* FairyGUI 位图字体数据(来自 UIPackage
*/
export interface IBitmapFontData {
/** Is TTF (dynamic font) | 是否是 TTF动态字体 */
ttf: boolean;
/** Can be tinted | 可以着色 */
tint: boolean;
/** Font size | 字体大小 */
fontSize: number;
/** Line height | 行高 */
lineHeight: number;
/** Glyphs map (charCode -> glyph) | 字形映射 */
glyphs: Map<number, IBitmapGlyph>;
/** Texture ID for the font atlas | 字体图集纹理 ID */
textureId?: number;
}
/**
* BitmapFont
*
* Adapter for FairyGUI bitmap fonts.
* Can be used for rendering when MSDF fonts are not available.
*
* FairyGUI 位图字体适配器
* 当 MSDF 字体不可用时可用于渲染
*/
export class BitmapFont {
/** Font name | 字体名称 */
public readonly name: string;
/** Texture ID | 纹理 ID */
public textureId: number = 0;
/** Font data | 字体数据 */
private _data: IBitmapFontData;
constructor(name: string, data: IBitmapFontData) {
this.name = name;
this._data = data;
if (data.textureId !== undefined) {
this.textureId = data.textureId;
}
}
/**
* Is this a TTF (dynamic) font
* 是否是 TTF动态字体
*/
public get isTTF(): boolean {
return this._data.ttf;
}
/**
* Can the font be tinted
* 字体是否可以着色
*/
public get canTint(): boolean {
return this._data.tint;
}
/**
* Font size | 字体大小
*/
public get fontSize(): number {
return this._data.fontSize;
}
/**
* Line height | 行高
*/
public get lineHeight(): number {
return this._data.lineHeight;
}
/**
* Get glyph for a character
* 获取字符的字形
*/
public getGlyph(charCode: number): IBitmapGlyph | undefined {
return this._data.glyphs.get(charCode);
}
/**
* Check if font has a glyph
* 检查字体是否有字形
*/
public hasGlyph(charCode: number): boolean {
return this._data.glyphs.has(charCode);
}
/**
* Get all glyphs
* 获取所有字形
*/
public get glyphs(): Map<number, IBitmapGlyph> {
return this._data.glyphs;
}
}
/**
* Bitmap Font Manager
* 位图字体管理器
*/
export class BitmapFontManager {
/** Loaded fonts | 已加载的字体 */
private _fonts: Map<string, BitmapFont> = new Map();
/**
* Register a bitmap font
* 注册位图字体
*/
public registerFont(font: BitmapFont): void {
this._fonts.set(font.name, font);
}
/**
* Get a font by name
* 按名称获取字体
*/
public getFont(name: string): BitmapFont | undefined {
return this._fonts.get(name);
}
/**
* Check if a font is registered
* 检查字体是否已注册
*/
public hasFont(name: string): boolean {
return this._fonts.has(name);
}
/**
* Unload a font
* 卸载字体
*/
public unloadFont(name: string): void {
this._fonts.delete(name);
}
/**
* Clear all fonts
* 清除所有字体
*/
public clear(): void {
this._fonts.clear();
}
/**
* Create from FairyGUI package font data
* 从 FairyGUI 包字体数据创建
*/
public createFromPackageData(name: string, data: IBitmapFontData): BitmapFont {
const font = new BitmapFont(name, data);
this.registerFont(font);
return font;
}
}
/** Global bitmap font manager | 全局位图字体管理器 */
let _bitmapFontManager: BitmapFontManager | null = null;
/**
* Get global bitmap font manager
* 获取全局位图字体管理器
*/
export function getBitmapFontManager(): BitmapFontManager {
if (!_bitmapFontManager) {
_bitmapFontManager = new BitmapFontManager();
}
return _bitmapFontManager;
}
/**
* Convert bitmap font to MSDF-compatible format
* 将位图字体转换为 MSDF 兼容格式
*
* Note: This creates a "fake" MSDF font that uses bitmap rendering.
* The pxRange is set to 0 to disable MSDF processing in the shader.
*
* 注意:这会创建一个使用位图渲染的"伪" MSDF 字体。
* pxRange 设置为 0 以在着色器中禁用 MSDF 处理。
*/
export function convertBitmapToMSDFFormat(
bitmapFont: BitmapFont,
atlasWidth: number,
atlasHeight: number
): IMSDFFontData {
const glyphs: IMSDFGlyph[] = [];
for (const [charCode, glyph] of bitmapFont.glyphs) {
const region = glyph.textureRegion;
if (!region) continue;
glyphs.push({
unicode: charCode,
advance: glyph.advance / bitmapFont.fontSize,
planeBounds: {
left: glyph.x / bitmapFont.fontSize,
bottom: -(glyph.y + glyph.height) / bitmapFont.fontSize,
right: (glyph.x + glyph.width) / bitmapFont.fontSize,
top: -glyph.y / bitmapFont.fontSize
},
atlasBounds: {
left: region.x,
bottom: region.y,
right: region.x + region.width,
top: region.y + region.height
}
});
}
return {
atlas: {
type: 'sdf', // Use simple SDF mode for bitmap
distanceRange: 0, // 0 = disable MSDF processing, use as regular texture
size: bitmapFont.fontSize,
width: atlasWidth,
height: atlasHeight,
yOrigin: 'top'
},
metrics: {
emSize: bitmapFont.fontSize,
lineHeight: bitmapFont.lineHeight / bitmapFont.fontSize,
ascender: 1,
descender: 0
},
glyphs
};
}

View File

@@ -0,0 +1,543 @@
/**
* DynamicFont
*
* Runtime dynamic font atlas generator using Canvas 2D.
* Similar to Unity's Font.RequestCharactersInTexture approach.
*
* 使用 Canvas 2D 的运行时动态字体图集生成器
* 类似于 Unity 的 Font.RequestCharactersInTexture 方法
*
* This is the fallback solution when MSDF fonts are not available.
* Characters are rendered to a texture atlas on demand.
*
* 当 MSDF 字体不可用时的备选方案。
* 字符按需渲染到纹理图集。
*/
import { MSDFFont, getMSDFFontManager } from './MSDFFont';
import type { IMSDFFontData, IMSDFGlyph } from './MSDFFont';
/**
* Glyph info in the dynamic atlas
* 动态图集中的字形信息
*/
interface IDynamicGlyph {
/** Character code | 字符码 */
charCode: number;
/** X position in atlas | 图集中的 X 位置 */
x: number;
/** Y position in atlas | 图集中的 Y 位置 */
y: number;
/** Glyph width | 字形宽度 */
width: number;
/** Glyph height | 字形高度 */
height: number;
/** Horizontal advance | 水平前进量 */
advance: number;
/** Baseline offset | 基线偏移 */
baseline: number;
}
/**
* Dynamic font configuration
* 动态字体配置
*/
export interface IDynamicFontConfig {
/** Font family (e.g., "Arial", "Microsoft YaHei") | 字体家族 */
fontFamily: string;
/** Font size for atlas generation | 图集生成的字体大小 */
fontSize?: number;
/** Atlas width | 图集宽度 */
atlasWidth?: number;
/** Atlas height | 图集高度 */
atlasHeight?: number;
/** Padding around glyphs | 字形周围的边距 */
padding?: number;
/** Pre-render common characters | 预渲染常用字符 */
preloadChars?: string;
}
/**
* Texture upload callback
* 纹理上传回调
*/
export type TextureUploadCallback = (
imageData: ImageData,
x: number,
y: number,
width: number,
height: number
) => void;
/**
* DynamicFont
*
* Generates font atlas dynamically using Canvas 2D.
* Implements character-on-demand rendering similar to Unity.
*
* 使用 Canvas 2D 动态生成字体图集
* 实现类似 Unity 的按需字符渲染
*/
export class DynamicFont {
/** Font name | 字体名称 */
public readonly name: string;
/** Texture ID assigned by engine | 引擎分配的纹理 ID */
public textureId: number = 0;
/** Font family | 字体家族 */
private _fontFamily: string;
/** Base font size | 基础字体大小 */
private _fontSize: number;
/** Atlas dimensions | 图集尺寸 */
private _atlasWidth: number;
private _atlasHeight: number;
/** Padding around glyphs | 字形边距 */
private _padding: number;
/** Canvas for rendering | 渲染用画布 */
private _canvas: HTMLCanvasElement | OffscreenCanvas;
private _ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
/** Glyph cache | 字形缓存 */
private _glyphs: Map<number, IDynamicGlyph> = new Map();
/** Current position in atlas | 图集中的当前位置 */
private _cursorX: number = 0;
private _cursorY: number = 0;
private _rowHeight: number = 0;
/** Line height metrics | 行高度量 */
private _lineHeight: number = 0;
private _ascent: number = 0;
/** Texture needs upload | 纹理需要上传 */
private _dirty: boolean = false;
/** Dirty region for partial upload | 部分上传的脏区域 */
private _dirtyRegion: { x: number; y: number; width: number; height: number } | null = null;
/** Texture upload callback | 纹理上传回调 */
private _onTextureUpload: TextureUploadCallback | null = null;
/** Version number (increments on atlas rebuild) | 版本号 */
public version: number = 0;
constructor(name: string, config: IDynamicFontConfig) {
this.name = name;
this._fontFamily = config.fontFamily;
this._fontSize = config.fontSize ?? 32;
this._atlasWidth = config.atlasWidth ?? 1024;
this._atlasHeight = config.atlasHeight ?? 1024;
this._padding = config.padding ?? 2;
// Create canvas
if (typeof OffscreenCanvas !== 'undefined') {
this._canvas = new OffscreenCanvas(this._atlasWidth, this._atlasHeight);
} else {
this._canvas = document.createElement('canvas');
this._canvas.width = this._atlasWidth;
this._canvas.height = this._atlasHeight;
}
const ctx = this._canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create canvas context');
}
this._ctx = ctx;
// Initialize canvas
this.initCanvas();
// Measure font metrics
this.measureMetrics();
// Preload common characters
if (config.preloadChars) {
this.requestCharacters(config.preloadChars);
}
}
/**
* Set texture upload callback
* 设置纹理上传回调
*/
public setTextureUploadCallback(callback: TextureUploadCallback): void {
this._onTextureUpload = callback;
}
/**
* Get atlas width
* 获取图集宽度
*/
public get atlasWidth(): number {
return this._atlasWidth;
}
/**
* Get atlas height
* 获取图集高度
*/
public get atlasHeight(): number {
return this._atlasHeight;
}
/**
* Get line height
* 获取行高
*/
public get lineHeight(): number {
return this._lineHeight;
}
/**
* Get font size
* 获取字体大小
*/
public get fontSize(): number {
return this._fontSize;
}
/**
* Initialize canvas state
* 初始化画布状态
*/
private initCanvas(): void {
const ctx = this._ctx;
// Clear to transparent
ctx.clearRect(0, 0, this._atlasWidth, this._atlasHeight);
// Set font
ctx.font = `${this._fontSize}px "${this._fontFamily}"`;
ctx.textBaseline = 'top';
ctx.fillStyle = 'white';
}
/**
* Measure font metrics
* 测量字体度量
*/
private measureMetrics(): void {
const ctx = this._ctx;
ctx.font = `${this._fontSize}px "${this._fontFamily}"`;
// Measure using a reference character
const metrics = ctx.measureText('Mgy');
// Estimate ascent and descent
this._ascent = this._fontSize * 0.8;
this._lineHeight = this._fontSize * 1.2;
// Try to use actual metrics if available
if ('actualBoundingBoxAscent' in metrics) {
this._ascent = metrics.actualBoundingBoxAscent;
const descent = metrics.actualBoundingBoxDescent;
this._lineHeight = this._ascent + descent + this._padding * 2;
}
}
/**
* Request characters to be available in the atlas
* 请求字符在图集中可用
*
* Similar to Unity's Font.RequestCharactersInTexture
*/
public requestCharacters(text: string): void {
let hasNew = false;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
// Skip if already cached
if (this._glyphs.has(charCode)) continue;
// Skip control characters
if (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13) continue;
// Render the character
if (this.renderCharacter(charCode)) {
hasNew = true;
}
}
// Upload texture if needed
if (hasNew && this._onTextureUpload && this._dirtyRegion) {
this.uploadTexture();
}
}
/**
* Render a character to the atlas
* 将字符渲染到图集
*/
private renderCharacter(charCode: number): boolean {
const ctx = this._ctx;
const char = String.fromCharCode(charCode);
// Measure character
ctx.font = `${this._fontSize}px "${this._fontFamily}"`;
const metrics = ctx.measureText(char);
const charWidth = Math.ceil(metrics.width);
const charHeight = Math.ceil(this._lineHeight);
// Check if we need a new row
if (this._cursorX + charWidth + this._padding * 2 > this._atlasWidth) {
this._cursorX = 0;
this._cursorY += this._rowHeight + this._padding;
this._rowHeight = 0;
}
// Check if we're out of space
if (this._cursorY + charHeight + this._padding * 2 > this._atlasHeight) {
console.warn(`[DynamicFont] Atlas full, cannot add character: ${char}`);
return false;
}
const x = this._cursorX + this._padding;
const y = this._cursorY + this._padding;
// Render character
ctx.fillStyle = 'white';
ctx.fillText(char, x, y);
// Create glyph info
const glyph: IDynamicGlyph = {
charCode,
x,
y,
width: charWidth,
height: charHeight,
advance: charWidth,
baseline: this._ascent
};
this._glyphs.set(charCode, glyph);
// Update cursor
this._cursorX += charWidth + this._padding * 2;
this._rowHeight = Math.max(this._rowHeight, charHeight);
// Mark dirty region
this.markDirty(x, y, charWidth, charHeight);
return true;
}
/**
* Mark a region as dirty
* 标记区域为脏
*/
private markDirty(x: number, y: number, width: number, height: number): void {
this._dirty = true;
if (!this._dirtyRegion) {
this._dirtyRegion = { x, y, width, height };
} else {
const r = this._dirtyRegion;
const newX = Math.min(r.x, x);
const newY = Math.min(r.y, y);
const newWidth = Math.max(r.x + r.width, x + width) - newX;
const newHeight = Math.max(r.y + r.height, y + height) - newY;
this._dirtyRegion = { x: newX, y: newY, width: newWidth, height: newHeight };
}
}
/**
* Upload texture to GPU
* 上传纹理到 GPU
*/
private uploadTexture(): void {
if (!this._dirty || !this._onTextureUpload || !this._dirtyRegion) return;
const r = this._dirtyRegion;
const imageData = this._ctx.getImageData(r.x, r.y, r.width, r.height);
this._onTextureUpload(imageData, r.x, r.y, r.width, r.height);
this._dirty = false;
this._dirtyRegion = null;
this.version++;
}
/**
* Get full canvas image data (for initial upload)
* 获取完整画布图像数据(用于初始上传)
*/
public getFullImageData(): ImageData {
return this._ctx.getImageData(0, 0, this._atlasWidth, this._atlasHeight);
}
/**
* Get glyph info for a character
* 获取字符的字形信息
*/
public getGlyph(charCode: number): IDynamicGlyph | undefined {
return this._glyphs.get(charCode);
}
/**
* Check if character is available
* 检查字符是否可用
*/
public hasGlyph(charCode: number): boolean {
return this._glyphs.has(charCode);
}
/**
* Convert to MSDF-compatible font data
* 转换为 MSDF 兼容的字体数据
*/
public toMSDFFontData(): IMSDFFontData {
const glyphs: IMSDFGlyph[] = [];
for (const [charCode, glyph] of this._glyphs) {
glyphs.push({
unicode: charCode,
advance: glyph.advance / this._fontSize,
planeBounds: {
left: 0,
bottom: -(glyph.height - glyph.baseline) / this._fontSize,
right: glyph.width / this._fontSize,
top: glyph.baseline / this._fontSize
},
atlasBounds: {
left: glyph.x,
bottom: glyph.y + glyph.height,
right: glyph.x + glyph.width,
top: glyph.y
}
});
}
return {
atlas: {
type: 'sdf',
distanceRange: 0, // 0 = bitmap mode
size: this._fontSize,
width: this._atlasWidth,
height: this._atlasHeight,
yOrigin: 'top'
},
metrics: {
emSize: this._fontSize,
lineHeight: this._lineHeight / this._fontSize,
ascender: this._ascent / this._fontSize,
descender: (this._lineHeight - this._ascent) / this._fontSize
},
glyphs
};
}
/**
* Create and register as MSDFFont
* 创建并注册为 MSDFFont
*/
public registerAsMSDFFont(): MSDFFont {
const fontData = this.toMSDFFontData();
const font = new MSDFFont(this.name, fontData);
font.textureId = this.textureId;
getMSDFFontManager().registerFont(font);
return font;
}
/**
* Clear atlas and reset
* 清除图集并重置
*/
public clear(): void {
this._glyphs.clear();
this._cursorX = 0;
this._cursorY = 0;
this._rowHeight = 0;
this.initCanvas();
this.version++;
}
/**
* Dispose resources
* 释放资源
*/
public dispose(): void {
this._glyphs.clear();
this._onTextureUpload = null;
}
}
/**
* Dynamic Font Manager
* 动态字体管理器
*/
export class DynamicFontManager {
/** Managed fonts | 管理的字体 */
private _fonts: Map<string, DynamicFont> = new Map();
/**
* Create a dynamic font
* 创建动态字体
*/
public createFont(name: string, config: IDynamicFontConfig): DynamicFont {
const font = new DynamicFont(name, config);
this._fonts.set(name, font);
return font;
}
/**
* Get a font by name
* 按名称获取字体
*/
public getFont(name: string): DynamicFont | undefined {
return this._fonts.get(name);
}
/**
* Remove a font
* 移除字体
*/
public removeFont(name: string): void {
const font = this._fonts.get(name);
if (font) {
font.dispose();
this._fonts.delete(name);
}
}
/**
* Clear all fonts
* 清除所有字体
*/
public clear(): void {
for (const font of this._fonts.values()) {
font.dispose();
}
this._fonts.clear();
}
}
/** Global dynamic font manager | 全局动态字体管理器 */
let _dynamicFontManager: DynamicFontManager | null = null;
/**
* Get global dynamic font manager
* 获取全局动态字体管理器
*/
export function getDynamicFontManager(): DynamicFontManager {
if (!_dynamicFontManager) {
_dynamicFontManager = new DynamicFontManager();
}
return _dynamicFontManager;
}
/**
* Common CJK characters for preloading
* 常用中日韩字符用于预加载
*/
export const COMMON_CJK_CHARS = '的一是不了在人有我他这个们中来上大为和国地到以说时要就出会可也你对生能而子那得于着下自之年过发后作里用道行所然家种事成方多经么去法学如都同现当没动面起看定天分还进好小部其些主样理心她本前开但因只从想实日军者意无力它与长把机十民第公此已工使情明性知全三又关点正业外将两高间由问很最重并物手应战向头文体政美相见被利什二等产或新己制身果加西斯月话合回特代内信表化老给世位次度门任常先海通教儿原东声提立及比员解水名真论处走义各入几口认条平系气题活尔更别打女变四神总何电数安少报才结反受目太量再感建务做接必场件计管期市直德资命山金指克许统区保至队形社便空决治展马科司五基眼书非则听白却界达光放强即像难且权思王象完设式色路记南品住告类求据程北边死张该交规万取拉格望觉术领共确传师观清今切院让识候带导争运笑飞风步改收根干造言联持组每济车亲极林服快办议往元英士证近失转夫令准布始怎呢存未远叫台单影具罗字爱击流备兵连调深商算质团集百需价花党华城石级整府离况亚请技际约示复病息究线似官火断精满支视消越器容照须九增研写称企八功吗包片史委乎查轻易早曾除农找装广显吧阿李标谈吃图念六引历首医局突专费号尽另周较注语仅考落青随选列武红响虽推势参希古众构房半节土投某案黑维革划敌致陈律足态护七兴派孩验责营星够章音跟志底站严巴例防族供效续施留讲型料终答紧黄绝奇察母京段依批群项故按河米围江织害斗双境客纪采举杀攻父苏密低朝友诉止细愿千值胜责秘倒注';
/**
* Common ASCII characters for preloading
* 常用 ASCII 字符用于预加载
*/
export const COMMON_ASCII_CHARS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';

View File

@@ -0,0 +1,310 @@
/**
* MSDFFont
*
* MSDF (Multi-channel Signed Distance Field) font data structures and loader.
* Compatible with msdf-atlas-gen output format.
*
* MSDF 字体数据结构和加载器
* 兼容 msdf-atlas-gen 输出格式
*/
/**
* Glyph metrics from MSDF atlas
* MSDF 图集中的字形度量
*/
export interface IMSDFGlyph {
/** Unicode code point | Unicode 码点 */
unicode: number;
/** Advance width (how much to move cursor after this glyph) | 前进宽度 */
advance: number;
/** Plane bounds (position in em units) | 平面边界em单位 */
planeBounds?: {
left: number;
bottom: number;
right: number;
top: number;
};
/** Atlas bounds (position in atlas texture, pixels) | 图集边界(图集纹理中的位置,像素) */
atlasBounds?: {
left: number;
bottom: number;
right: number;
top: number;
};
}
/**
* Kerning pair
* 字偶距对
*/
export interface IMSDFKerning {
/** First character unicode | 第一个字符 Unicode */
unicode1: number;
/** Second character unicode | 第二个字符 Unicode */
unicode2: number;
/** Kerning advance adjustment | 字偶距调整值 */
advance: number;
}
/**
* MSDF font atlas metadata
* MSDF 字体图集元数据
*/
export interface IMSDFFontAtlas {
/** Atlas type (msdf, mtsdf, sdf) | 图集类型 */
type: 'msdf' | 'mtsdf' | 'sdf';
/** Distance field range in pixels | 距离场范围(像素) */
distanceRange: number;
/** Distance field range in pixels (alias) | 距离场范围(像素,别名) */
distanceRangeMiddle?: number;
/** Font size used for generation | 生成时使用的字体大小 */
size: number;
/** Atlas texture width | 图集纹理宽度 */
width: number;
/** Atlas texture height | 图集纹理高度 */
height: number;
/** Y origin (top or bottom) | Y 轴原点 */
yOrigin: 'top' | 'bottom';
}
/**
* MSDF font metrics
* MSDF 字体度量
*/
export interface IMSDFFontMetrics {
/** Em size (units per em) | Em 大小 */
emSize: number;
/** Line height | 行高 */
lineHeight: number;
/** Ascender (above baseline) | 上升部(基线以上) */
ascender: number;
/** Descender (below baseline, usually negative) | 下降部(基线以下,通常为负) */
descender: number;
/** Underline Y position | 下划线 Y 位置 */
underlineY?: number;
/** Underline thickness | 下划线粗细 */
underlineThickness?: number;
}
/**
* Complete MSDF font data (matches msdf-atlas-gen JSON output)
* 完整的 MSDF 字体数据(匹配 msdf-atlas-gen JSON 输出)
*/
export interface IMSDFFontData {
/** Atlas metadata | 图集元数据 */
atlas: IMSDFFontAtlas;
/** Font metrics | 字体度量 */
metrics: IMSDFFontMetrics;
/** Glyphs array | 字形数组 */
glyphs: IMSDFGlyph[];
/** Kerning pairs (optional) | 字偶距对(可选) */
kerning?: IMSDFKerning[];
}
/**
* MSDFFont
*
* Loaded MSDF font with fast glyph lookup.
* 加载的 MSDF 字体,支持快速字形查找
*/
export class MSDFFont {
/** Font name | 字体名称 */
public readonly name: string;
/** Atlas texture ID | 图集纹理 ID */
public textureId: number = 0;
/** Font data | 字体数据 */
private _data: IMSDFFontData;
/** Glyph map for fast lookup | 字形映射用于快速查找 */
private _glyphMap: Map<number, IMSDFGlyph> = new Map();
/** Kerning map (key: unicode1 << 16 | unicode2) | 字偶距映射 */
private _kerningMap: Map<number, number> = new Map();
constructor(name: string, data: IMSDFFontData) {
this.name = name;
this._data = data;
// Build glyph lookup map
for (const glyph of data.glyphs) {
this._glyphMap.set(glyph.unicode, glyph);
}
// Build kerning lookup map
if (data.kerning) {
for (const kern of data.kerning) {
const key = (kern.unicode1 << 16) | kern.unicode2;
this._kerningMap.set(key, kern.advance);
}
}
}
/**
* Get atlas metadata
* 获取图集元数据
*/
public get atlas(): IMSDFFontAtlas {
return this._data.atlas;
}
/**
* Get font metrics
* 获取字体度量
*/
public get metrics(): IMSDFFontMetrics {
return this._data.metrics;
}
/**
* Get pixel range for shader
* 获取着色器使用的像素范围
*/
public get pxRange(): number {
return this._data.atlas.distanceRange;
}
/**
* Get glyph for a character
* 获取字符的字形
*/
public getGlyph(charCode: number): IMSDFGlyph | undefined {
return this._glyphMap.get(charCode);
}
/**
* Get kerning between two characters
* 获取两个字符之间的字偶距
*/
public getKerning(charCode1: number, charCode2: number): number {
const key = (charCode1 << 16) | charCode2;
return this._kerningMap.get(key) ?? 0;
}
/**
* Check if font has a glyph for a character
* 检查字体是否有某字符的字形
*/
public hasGlyph(charCode: number): boolean {
return this._glyphMap.has(charCode);
}
/**
* Get all glyphs
* 获取所有字形
*/
public get glyphs(): readonly IMSDFGlyph[] {
return this._data.glyphs;
}
}
/**
* MSDF Font Manager
* MSDF 字体管理器
*/
export class MSDFFontManager {
/** Loaded fonts | 已加载的字体 */
private _fonts: Map<string, MSDFFont> = new Map();
/** Default font name | 默认字体名称 */
private _defaultFontName: string = '';
/**
* Register a font
* 注册字体
*/
public registerFont(font: MSDFFont): void {
this._fonts.set(font.name, font);
if (!this._defaultFontName) {
this._defaultFontName = font.name;
}
}
/**
* Get a font by name
* 按名称获取字体
*/
public getFont(name: string): MSDFFont | undefined {
return this._fonts.get(name) ?? this._fonts.get(this._defaultFontName);
}
/**
* Set default font
* 设置默认字体
*/
public setDefaultFont(name: string): void {
if (this._fonts.has(name)) {
this._defaultFontName = name;
}
}
/**
* Get default font
* 获取默认字体
*/
public get defaultFont(): MSDFFont | undefined {
return this._fonts.get(this._defaultFontName);
}
/**
* Load font from JSON data and texture
* 从 JSON 数据和纹理加载字体
*/
public loadFont(name: string, jsonData: IMSDFFontData, textureId: number): MSDFFont {
const font = new MSDFFont(name, jsonData);
font.textureId = textureId;
this.registerFont(font);
return font;
}
/**
* Unload a font
* 卸载字体
*/
public unloadFont(name: string): void {
this._fonts.delete(name);
}
/**
* Clear all fonts
* 清除所有字体
*/
public clear(): void {
this._fonts.clear();
this._defaultFontName = '';
}
}
/** Global font manager instance | 全局字体管理器实例 */
let _fontManager: MSDFFontManager | null = null;
/**
* Get global MSDF font manager
* 获取全局 MSDF 字体管理器
*/
export function getMSDFFontManager(): MSDFFontManager {
if (!_fontManager) {
_fontManager = new MSDFFontManager();
}
return _fontManager;
}

View File

@@ -0,0 +1,201 @@
/**
* MSDFFontLoader
*
* Utility for loading MSDF fonts from JSON and texture files.
* Compatible with msdf-atlas-gen output format.
*
* MSDF 字体加载工具
* 兼容 msdf-atlas-gen 输出格式
*
* @example
* ```typescript
* // Load font with texture service
* const font = await loadMSDFFont(
* 'NotoSans',
* '/fonts/NotoSans.json',
* '/fonts/NotoSans.png',
* textureService
* );
*
* // Or use the loader class for more control
* const loader = new MSDFFontLoader(textureService);
* const font = await loader.load('NotoSans', jsonUrl, textureUrl);
* ```
*/
import { MSDFFont, getMSDFFontManager } from './MSDFFont';
import type { IMSDFFontData } from './MSDFFont';
import type { ITextureService } from '../asset/FGUITextureManager';
/**
* Font load result
* 字体加载结果
*/
export interface IFontLoadResult {
/** Loaded font | 加载的字体 */
font: MSDFFont;
/** Font texture ID | 字体纹理 ID */
textureId: number;
/** Font name | 字体名称 */
name: string;
}
/**
* MSDF Font Loader
* MSDF 字体加载器
*/
export class MSDFFontLoader {
private _textureService: ITextureService;
private _fontCache: Map<string, MSDFFont> = new Map();
constructor(textureService: ITextureService) {
this._textureService = textureService;
}
/**
* Load MSDF font from JSON and texture URLs
* 从 JSON 和纹理 URL 加载 MSDF 字体
*
* @param name Font name for registration | 注册用的字体名称
* @param jsonUrl URL to font JSON file | 字体 JSON 文件 URL
* @param textureUrl URL to font atlas texture | 字体图集纹理 URL
* @param bRegisterGlobal Register to global font manager | 是否注册到全局字体管理器
*/
public async load(
name: string,
jsonUrl: string,
textureUrl: string,
bRegisterGlobal: boolean = true
): Promise<IFontLoadResult> {
// Check cache
const cached = this._fontCache.get(name);
if (cached) {
return {
font: cached,
textureId: cached.textureId,
name
};
}
// Load JSON first
const fontData = await this.loadFontData(jsonUrl);
// Load texture (synchronous API - returns ID immediately, loading happens async internally)
const textureId = this._textureService.loadTextureByPath(textureUrl);
// Create font
const font = new MSDFFont(name, fontData);
font.textureId = textureId;
// Cache
this._fontCache.set(name, font);
// Register to global manager
if (bRegisterGlobal) {
const manager = getMSDFFontManager();
manager.registerFont(font);
}
return { font, textureId, name };
}
/**
* Load font data from JSON URL
* 从 JSON URL 加载字体数据
*/
private async loadFontData(jsonUrl: string): Promise<IMSDFFontData> {
const response = await fetch(jsonUrl);
if (!response.ok) {
throw new Error(`Failed to load font JSON: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Preload multiple fonts
* 预加载多个字体
*/
public async preloadFonts(
fonts: Array<{ name: string; jsonUrl: string; textureUrl: string }>
): Promise<IFontLoadResult[]> {
return Promise.all(
fonts.map(f => this.load(f.name, f.jsonUrl, f.textureUrl))
);
}
/**
* Get cached font
* 获取缓存的字体
*/
public getFont(name: string): MSDFFont | undefined {
return this._fontCache.get(name);
}
/**
* Clear font cache
* 清除字体缓存
*/
public clearCache(): void {
this._fontCache.clear();
}
}
/**
* Load MSDF font (convenience function)
* 加载 MSDF 字体(便捷函数)
*/
export async function loadMSDFFont(
name: string,
jsonUrl: string,
textureUrl: string,
textureService: ITextureService
): Promise<MSDFFont> {
const loader = new MSDFFontLoader(textureService);
const result = await loader.load(name, jsonUrl, textureUrl);
return result.font;
}
/**
* Create font data from raw glyph information
* Useful for creating fonts programmatically or from custom formats
*
* 从原始字形信息创建字体数据
* 用于程序化创建字体或从自定义格式创建
*/
export function createFontData(params: {
atlasWidth: number;
atlasHeight: number;
fontSize: number;
pxRange: number;
lineHeight: number;
ascender: number;
descender: number;
glyphs: Array<{
unicode: number;
advance: number;
planeBounds?: { left: number; bottom: number; right: number; top: number };
atlasBounds?: { left: number; bottom: number; right: number; top: number };
}>;
kerning?: Array<{ unicode1: number; unicode2: number; advance: number }>;
}): IMSDFFontData {
return {
atlas: {
type: 'msdf',
distanceRange: params.pxRange,
size: params.fontSize,
width: params.atlasWidth,
height: params.atlasHeight,
yOrigin: 'bottom'
},
metrics: {
emSize: params.fontSize,
lineHeight: params.lineHeight,
ascender: params.ascender,
descender: params.descender
},
glyphs: params.glyphs,
kerning: params.kerning
};
}

View File

@@ -0,0 +1,274 @@
/**
* TextBatch
*
* Batches text glyphs for efficient GPU rendering.
* Converts positioned glyphs to vertex data for the MSDF shader.
*
* 批处理文本字形以实现高效的 GPU 渲染
* 将定位字形转换为 MSDF 着色器的顶点数据
*/
import type { IPositionedGlyph } from './TextLayout';
/**
* Text render batch data
* 文本渲染批次数据
*/
export interface ITextBatchData {
/** Vertex positions [x, y, ...] | 顶点位置 */
positions: Float32Array;
/** Texture coordinates [u, v, ...] | 纹理坐标 */
texCoords: Float32Array;
/** Fill colors [r, g, b, a, ...] | 填充颜色 */
colors: Float32Array;
/** Outline colors [r, g, b, a, ...] | 描边颜色 */
outlineColors: Float32Array;
/** Outline widths | 描边宽度 */
outlineWidths: Float32Array;
/** Indices for indexed drawing | 索引绘制的索引 */
indices: Uint16Array;
/** Number of glyphs | 字形数量 */
glyphCount: number;
/** Font texture ID | 字体纹理 ID */
textureId: number;
/** Pixel range for shader | 着色器像素范围 */
pxRange: number;
}
/**
* Text batch options
* 文本批次选项
*/
export interface ITextBatchOptions {
/** Fill color (RGBA packed) | 填充颜色 */
color: number;
/** Alpha | 透明度 */
alpha: number;
/** Outline color (RGBA packed) | 描边颜色 */
outlineColor?: number;
/** Outline width in pixels | 描边宽度(像素) */
outlineWidth?: number;
/** Offset X | X 偏移 */
offsetX?: number;
/** Offset Y | Y 偏移 */
offsetY?: number;
}
/**
* Unpack color from 32-bit packed RGBA
* 从 32 位打包的 RGBA 解包颜色
*/
function unpackColor(packed: number): [number, number, number, number] {
const r = ((packed >> 24) & 0xff) / 255;
const g = ((packed >> 16) & 0xff) / 255;
const b = ((packed >> 8) & 0xff) / 255;
const a = (packed & 0xff) / 255;
return [r, g, b, a];
}
/**
* Create text batch from positioned glyphs
* 从定位字形创建文本批次
*/
export function createTextBatch(
glyphs: IPositionedGlyph[],
textureId: number,
pxRange: number,
options: ITextBatchOptions
): ITextBatchData {
const glyphCount = glyphs.length;
if (glyphCount === 0) {
return {
positions: new Float32Array(0),
texCoords: new Float32Array(0),
colors: new Float32Array(0),
outlineColors: new Float32Array(0),
outlineWidths: new Float32Array(0),
indices: new Uint16Array(0),
glyphCount: 0,
textureId,
pxRange
};
}
// 4 vertices per glyph, 2 floats per position
const positions = new Float32Array(glyphCount * 4 * 2);
// 4 vertices per glyph, 2 floats per texCoord
const texCoords = new Float32Array(glyphCount * 4 * 2);
// 4 vertices per glyph, 4 floats per color
const colors = new Float32Array(glyphCount * 4 * 4);
const outlineColors = new Float32Array(glyphCount * 4 * 4);
// 4 vertices per glyph, 1 float per outline width
const outlineWidths = new Float32Array(glyphCount * 4);
// 6 indices per glyph (2 triangles)
const indices = new Uint16Array(glyphCount * 6);
const offsetX = options.offsetX ?? 0;
const offsetY = options.offsetY ?? 0;
const [r, g, b, a] = unpackColor(options.color);
const finalAlpha = a * options.alpha;
const hasOutline = (options.outlineWidth ?? 0) > 0;
const [or, og, ob, oa] = hasOutline ? unpackColor(options.outlineColor ?? 0x000000FF) : [0, 0, 0, 0];
const outlineWidth = options.outlineWidth ?? 0;
for (let i = 0; i < glyphCount; i++) {
const glyph = glyphs[i];
const x = glyph.x + offsetX;
const y = glyph.y + offsetY;
const w = glyph.width;
const h = glyph.height;
const [u0, v0, u1, v1] = glyph.uv;
const posIdx = i * 8;
const texIdx = i * 8;
const colIdx = i * 16;
const outIdx = i * 4;
const idxBase = i * 6;
const vertBase = i * 4;
// Vertex positions (quad: top-left, top-right, bottom-right, bottom-left)
// 顶点位置(四边形:左上、右上、右下、左下)
positions[posIdx + 0] = x; // TL x
positions[posIdx + 1] = y; // TL y
positions[posIdx + 2] = x + w; // TR x
positions[posIdx + 3] = y; // TR y
positions[posIdx + 4] = x + w; // BR x
positions[posIdx + 5] = y + h; // BR y
positions[posIdx + 6] = x; // BL x
positions[posIdx + 7] = y + h; // BL y
// Texture coordinates
// 纹理坐标
texCoords[texIdx + 0] = u0; // TL u
texCoords[texIdx + 1] = v0; // TL v
texCoords[texIdx + 2] = u1; // TR u
texCoords[texIdx + 3] = v0; // TR v
texCoords[texIdx + 4] = u1; // BR u
texCoords[texIdx + 5] = v1; // BR v
texCoords[texIdx + 6] = u0; // BL u
texCoords[texIdx + 7] = v1; // BL v
// Colors (same for all 4 vertices)
// 颜色4 个顶点相同)
for (let v = 0; v < 4; v++) {
const ci = colIdx + v * 4;
colors[ci + 0] = r;
colors[ci + 1] = g;
colors[ci + 2] = b;
colors[ci + 3] = finalAlpha;
outlineColors[ci + 0] = or;
outlineColors[ci + 1] = og;
outlineColors[ci + 2] = ob;
outlineColors[ci + 3] = oa;
outlineWidths[outIdx + v] = outlineWidth;
}
// Indices (two triangles: 0-1-2, 2-3-0)
// 索引(两个三角形)
indices[idxBase + 0] = vertBase + 0;
indices[idxBase + 1] = vertBase + 1;
indices[idxBase + 2] = vertBase + 2;
indices[idxBase + 3] = vertBase + 2;
indices[idxBase + 4] = vertBase + 3;
indices[idxBase + 5] = vertBase + 0;
}
return {
positions,
texCoords,
colors,
outlineColors,
outlineWidths,
indices,
glyphCount,
textureId,
pxRange
};
}
/**
* Merge multiple text batches into one
* 将多个文本批次合并为一个
*/
export function mergeTextBatches(batches: ITextBatchData[]): ITextBatchData | null {
if (batches.length === 0) return null;
if (batches.length === 1) return batches[0];
// All batches must have same texture
const textureId = batches[0].textureId;
const pxRange = batches[0].pxRange;
let totalGlyphs = 0;
for (const batch of batches) {
if (batch.textureId !== textureId) {
console.warn('Cannot merge text batches with different textures');
return null;
}
totalGlyphs += batch.glyphCount;
}
const positions = new Float32Array(totalGlyphs * 4 * 2);
const texCoords = new Float32Array(totalGlyphs * 4 * 2);
const colors = new Float32Array(totalGlyphs * 4 * 4);
const outlineColors = new Float32Array(totalGlyphs * 4 * 4);
const outlineWidths = new Float32Array(totalGlyphs * 4);
const indices = new Uint16Array(totalGlyphs * 6);
let posOffset = 0;
let texOffset = 0;
let colOffset = 0;
let outOffset = 0;
let idxOffset = 0;
let vertOffset = 0;
for (const batch of batches) {
const glyphCount = batch.glyphCount;
positions.set(batch.positions, posOffset);
texCoords.set(batch.texCoords, texOffset);
colors.set(batch.colors, colOffset);
outlineColors.set(batch.outlineColors, colOffset);
outlineWidths.set(batch.outlineWidths, outOffset);
// Adjust indices
for (let i = 0; i < batch.indices.length; i++) {
indices[idxOffset + i] = batch.indices[i] + vertOffset;
}
posOffset += glyphCount * 4 * 2;
texOffset += glyphCount * 4 * 2;
colOffset += glyphCount * 4 * 4;
outOffset += glyphCount * 4;
idxOffset += glyphCount * 6;
vertOffset += glyphCount * 4;
}
return {
positions,
texCoords,
colors,
outlineColors,
outlineWidths,
indices,
glyphCount: totalGlyphs,
textureId,
pxRange
};
}

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