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

View File

@@ -0,0 +1,342 @@
/**
* TextLayout
*
* Text layout engine for MSDF text rendering.
* Handles line breaking, alignment, and glyph positioning.
*
* MSDF 文本渲染的文本布局引擎
* 处理换行、对齐和字形定位
*/
import type { MSDFFont, IMSDFGlyph } from './MSDFFont';
import { EAlignType, EVertAlignType } from '../core/FieldTypes';
/**
* Positioned glyph for rendering
* 用于渲染的定位字形
*/
export interface IPositionedGlyph {
/** Glyph data | 字形数据 */
glyph: IMSDFGlyph;
/** X position in pixels | X 位置(像素) */
x: number;
/** Y position in pixels | Y 位置(像素) */
y: number;
/** Glyph width in pixels | 字形宽度(像素) */
width: number;
/** Glyph height in pixels | 字形高度(像素) */
height: number;
/** UV coordinates [u0, v0, u1, v1] | UV 坐标 */
uv: [number, number, number, number];
}
/**
* Layout line
* 布局行
*/
interface ILayoutLine {
/** Glyphs in this line | 此行中的字形 */
glyphs: IPositionedGlyph[];
/** Line width in pixels | 行宽(像素) */
width: number;
/** Line start Y position | 行起始 Y 位置 */
y: number;
}
/**
* Text layout options
* 文本布局选项
*/
export interface ITextLayoutOptions {
/** Font to use | 使用的字体 */
font: MSDFFont;
/** Text content | 文本内容 */
text: string;
/** Font size in pixels | 字体大小(像素) */
fontSize: number;
/** Maximum width (for word wrap) | 最大宽度(用于换行) */
maxWidth?: number;
/** Maximum height | 最大高度 */
maxHeight?: number;
/** Horizontal alignment | 水平对齐 */
align?: EAlignType;
/** Vertical alignment | 垂直对齐 */
valign?: EVertAlignType;
/** Line height multiplier | 行高倍数 */
lineHeight?: number;
/** Letter spacing in pixels | 字间距(像素) */
letterSpacing?: number;
/** Word wrap enabled | 是否启用换行 */
wordWrap?: boolean;
/** Single line mode | 单行模式 */
singleLine?: boolean;
}
/**
* Text layout result
* 文本布局结果
*/
export interface ITextLayoutResult {
/** Positioned glyphs ready for rendering | 准备渲染的定位字形 */
glyphs: IPositionedGlyph[];
/** Total width of laid out text | 布局文本的总宽度 */
width: number;
/** Total height of laid out text | 布局文本的总高度 */
height: number;
/** Number of lines | 行数 */
lineCount: number;
}
/**
* Layout text into positioned glyphs
* 将文本布局为定位字形
*/
export function layoutText(options: ITextLayoutOptions): ITextLayoutResult {
const {
font,
text,
fontSize,
maxWidth = Infinity,
maxHeight = Infinity,
align = EAlignType.Left,
valign = EVertAlignType.Top,
lineHeight = 1.2,
letterSpacing = 0,
wordWrap = false,
singleLine = false
} = options;
if (!text || !font) {
return { glyphs: [], width: 0, height: 0, lineCount: 0 };
}
const metrics = font.metrics;
const atlas = font.atlas;
// Calculate scale from em units to pixels
const scale = fontSize / metrics.emSize;
const lineHeightPx = fontSize * lineHeight;
// Atlas dimensions for UV calculation
const atlasWidth = atlas.width;
const atlasHeight = atlas.height;
const yFlip = atlas.yOrigin === 'bottom';
const lines: ILayoutLine[] = [];
let currentLine: IPositionedGlyph[] = [];
let currentX = 0;
let currentY = 0;
let maxLineWidth = 0;
let prevCharCode = 0;
// Process each character
for (let i = 0; i < text.length; i++) {
const char = text[i];
const charCode = char.charCodeAt(0);
// Handle newline
if (char === '\n') {
if (singleLine) continue;
lines.push({
glyphs: currentLine,
width: currentX,
y: currentY
});
maxLineWidth = Math.max(maxLineWidth, currentX);
currentLine = [];
currentX = 0;
currentY += lineHeightPx;
prevCharCode = 0;
continue;
}
// Handle carriage return
if (char === '\r') continue;
// Get glyph
const glyph = font.getGlyph(charCode);
if (!glyph) {
// Try space as fallback
const spaceGlyph = font.getGlyph(32);
if (spaceGlyph) {
currentX += spaceGlyph.advance * scale + letterSpacing;
}
prevCharCode = charCode;
continue;
}
// Apply kerning
if (prevCharCode) {
currentX += font.getKerning(prevCharCode, charCode) * scale;
}
// Check word wrap
const glyphAdvance = glyph.advance * scale + letterSpacing;
if (wordWrap && !singleLine && currentX + glyphAdvance > maxWidth && currentLine.length > 0) {
// Word wrap - start new line
lines.push({
glyphs: currentLine,
width: currentX,
y: currentY
});
maxLineWidth = Math.max(maxLineWidth, currentX);
currentLine = [];
currentX = 0;
currentY += lineHeightPx;
// Check max height
if (currentY + lineHeightPx > maxHeight) {
break;
}
}
// Position glyph if it has atlas bounds
if (glyph.planeBounds && glyph.atlasBounds) {
const pb = glyph.planeBounds;
const ab = glyph.atlasBounds;
// Calculate glyph position and size
const glyphX = currentX + pb.left * scale;
const glyphY = currentY + (metrics.ascender - pb.top) * scale;
const glyphWidth = (pb.right - pb.left) * scale;
const glyphHeight = (pb.top - pb.bottom) * scale;
// Calculate UV coordinates
let u0 = ab.left / atlasWidth;
let v0 = ab.bottom / atlasHeight;
let u1 = ab.right / atlasWidth;
let v1 = ab.top / atlasHeight;
// Flip V if Y origin is top
if (!yFlip) {
v0 = 1 - v0;
v1 = 1 - v1;
[v0, v1] = [v1, v0];
}
currentLine.push({
glyph,
x: glyphX,
y: glyphY,
width: glyphWidth,
height: glyphHeight,
uv: [u0, v0, u1, v1]
});
}
currentX += glyphAdvance;
prevCharCode = charCode;
}
// Add last line
if (currentLine.length > 0 || lines.length === 0) {
lines.push({
glyphs: currentLine,
width: currentX,
y: currentY
});
maxLineWidth = Math.max(maxLineWidth, currentX);
}
const totalHeight = currentY + lineHeightPx;
const lineCount = lines.length;
// Apply horizontal alignment
for (const line of lines) {
let offsetX = 0;
if (align === EAlignType.Center) {
offsetX = (maxWidth === Infinity ? 0 : (maxWidth - line.width) / 2);
} else if (align === EAlignType.Right) {
offsetX = maxWidth === Infinity ? 0 : (maxWidth - line.width);
}
for (const glyph of line.glyphs) {
glyph.x += offsetX;
}
}
// Apply vertical alignment
let offsetY = 0;
if (valign === EVertAlignType.Middle) {
offsetY = (maxHeight === Infinity ? 0 : (maxHeight - totalHeight) / 2);
} else if (valign === EVertAlignType.Bottom) {
offsetY = maxHeight === Infinity ? 0 : (maxHeight - totalHeight);
}
if (offsetY !== 0) {
for (const line of lines) {
for (const glyph of line.glyphs) {
glyph.y += offsetY;
}
}
}
// Flatten glyphs
const allGlyphs: IPositionedGlyph[] = [];
for (const line of lines) {
allGlyphs.push(...line.glyphs);
}
return {
glyphs: allGlyphs,
width: maxLineWidth,
height: totalHeight,
lineCount
};
}
/**
* Measure text dimensions without full layout
* 测量文本尺寸(不进行完整布局)
*/
export function measureText(font: MSDFFont, text: string, fontSize: number, letterSpacing: number = 0): { width: number; height: number } {
if (!text || !font) {
return { width: 0, height: 0 };
}
const metrics = font.metrics;
const scale = fontSize / metrics.emSize;
let width = 0;
let prevCharCode = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
const glyph = font.getGlyph(charCode);
if (glyph) {
if (prevCharCode) {
width += font.getKerning(prevCharCode, charCode) * scale;
}
width += glyph.advance * scale + letterSpacing;
}
prevCharCode = charCode;
}
return {
width,
height: fontSize * 1.2
};
}

View File

@@ -0,0 +1,11 @@
/**
* Text rendering module
* 文本渲染模块
*/
export * from './MSDFFont';
export * from './TextLayout';
export * from './TextBatch';
export * from './MSDFFontLoader';
export * from './BitmapFont';
export * from './DynamicFont';

View File

@@ -0,0 +1,235 @@
/**
* Easing function types
* 缓动函数类型
*/
export const enum EEaseType {
Linear = 0,
SineIn = 1,
SineOut = 2,
SineInOut = 3,
QuadIn = 4,
QuadOut = 5,
QuadInOut = 6,
CubicIn = 7,
CubicOut = 8,
CubicInOut = 9,
QuartIn = 10,
QuartOut = 11,
QuartInOut = 12,
QuintIn = 13,
QuintOut = 14,
QuintInOut = 15,
ExpoIn = 16,
ExpoOut = 17,
ExpoInOut = 18,
CircIn = 19,
CircOut = 20,
CircInOut = 21,
ElasticIn = 22,
ElasticOut = 23,
ElasticInOut = 24,
BackIn = 25,
BackOut = 26,
BackInOut = 27,
BounceIn = 28,
BounceOut = 29,
BounceInOut = 30,
Custom = 31
}
const PI = Math.PI;
const HALF_PI = PI / 2;
/**
* Evaluate easing function
* 计算缓动函数值
*/
export function evaluateEase(
easeType: EEaseType,
time: number,
duration: number,
overshootOrAmplitude: number = 1.70158,
period: number = 0
): number {
if (duration <= 0) return 1;
let t = time / duration;
switch (easeType) {
case EEaseType.Linear:
return t;
case EEaseType.SineIn:
return -Math.cos(t * HALF_PI) + 1;
case EEaseType.SineOut:
return Math.sin(t * HALF_PI);
case EEaseType.SineInOut:
return -0.5 * (Math.cos(PI * t) - 1);
case EEaseType.QuadIn:
return t * t;
case EEaseType.QuadOut:
return -t * (t - 2);
case EEaseType.QuadInOut:
if ((t *= 2) < 1) return 0.5 * t * t;
return -0.5 * (--t * (t - 2) - 1);
case EEaseType.CubicIn:
return t * t * t;
case EEaseType.CubicOut:
return (t -= 1) * t * t + 1;
case EEaseType.CubicInOut:
if ((t *= 2) < 1) return 0.5 * t * t * t;
return 0.5 * ((t -= 2) * t * t + 2);
case EEaseType.QuartIn:
return t * t * t * t;
case EEaseType.QuartOut:
return -((t -= 1) * t * t * t - 1);
case EEaseType.QuartInOut:
if ((t *= 2) < 1) return 0.5 * t * t * t * t;
return -0.5 * ((t -= 2) * t * t * t - 2);
case EEaseType.QuintIn:
return t * t * t * t * t;
case EEaseType.QuintOut:
return (t -= 1) * t * t * t * t + 1;
case EEaseType.QuintInOut:
if ((t *= 2) < 1) return 0.5 * t * t * t * t * t;
return 0.5 * ((t -= 2) * t * t * t * t + 2);
case EEaseType.ExpoIn:
return t === 0 ? 0 : Math.pow(2, 10 * (t - 1));
case EEaseType.ExpoOut:
return t === 1 ? 1 : -Math.pow(2, -10 * t) + 1;
case EEaseType.ExpoInOut:
if (t === 0) return 0;
if (t === 1) return 1;
if ((t *= 2) < 1) return 0.5 * Math.pow(2, 10 * (t - 1));
return 0.5 * (-Math.pow(2, -10 * --t) + 2);
case EEaseType.CircIn:
return -(Math.sqrt(1 - t * t) - 1);
case EEaseType.CircOut:
return Math.sqrt(1 - (t -= 1) * t);
case EEaseType.CircInOut:
if ((t *= 2) < 1) return -0.5 * (Math.sqrt(1 - t * t) - 1);
return 0.5 * (Math.sqrt(1 - (t -= 2) * t) + 1);
case EEaseType.ElasticIn: {
if (t === 0) return 0;
if (t === 1) return 1;
if (period === 0) period = duration * 0.3;
let s: number;
if (overshootOrAmplitude < 1) {
overshootOrAmplitude = 1;
s = period / 4;
} else {
s = (period / (2 * PI)) * Math.asin(1 / overshootOrAmplitude);
}
return -(
overshootOrAmplitude *
Math.pow(2, 10 * (t -= 1)) *
Math.sin(((t * duration - s) * (2 * PI)) / period)
);
}
case EEaseType.ElasticOut: {
if (t === 0) return 0;
if (t === 1) return 1;
if (period === 0) period = duration * 0.3;
let s: number;
if (overshootOrAmplitude < 1) {
overshootOrAmplitude = 1;
s = period / 4;
} else {
s = (period / (2 * PI)) * Math.asin(1 / overshootOrAmplitude);
}
return (
overshootOrAmplitude *
Math.pow(2, -10 * t) *
Math.sin(((t * duration - s) * (2 * PI)) / period) +
1
);
}
case EEaseType.ElasticInOut: {
if (t === 0) return 0;
if ((t *= 2) === 2) return 1;
if (period === 0) period = duration * 0.45;
let s: number;
if (overshootOrAmplitude < 1) {
overshootOrAmplitude = 1;
s = period / 4;
} else {
s = (period / (2 * PI)) * Math.asin(1 / overshootOrAmplitude);
}
if (t < 1) {
return (
-0.5 *
(overshootOrAmplitude *
Math.pow(2, 10 * (t -= 1)) *
Math.sin(((t * duration - s) * (2 * PI)) / period))
);
}
return (
overshootOrAmplitude *
Math.pow(2, -10 * (t -= 1)) *
Math.sin(((t * duration - s) * (2 * PI)) / period) *
0.5 +
1
);
}
case EEaseType.BackIn:
return t * t * ((overshootOrAmplitude + 1) * t - overshootOrAmplitude);
case EEaseType.BackOut:
return (t -= 1) * t * ((overshootOrAmplitude + 1) * t + overshootOrAmplitude) + 1;
case EEaseType.BackInOut:
if ((t *= 2) < 1) {
return 0.5 * (t * t * (((overshootOrAmplitude *= 1.525) + 1) * t - overshootOrAmplitude));
}
return 0.5 * ((t -= 2) * t * (((overshootOrAmplitude *= 1.525) + 1) * t + overshootOrAmplitude) + 2);
case EEaseType.BounceIn:
return 1 - bounceOut(1 - t);
case EEaseType.BounceOut:
return bounceOut(t);
case EEaseType.BounceInOut:
if (t < 0.5) return (1 - bounceOut(1 - 2 * t)) * 0.5;
return bounceOut(2 * t - 1) * 0.5 + 0.5;
default:
return t;
}
}
function bounceOut(t: number): number {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
} else if (t < 2.5 / 2.75) {
return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
} else {
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
}

View File

@@ -0,0 +1,159 @@
import { GTweener } from './GTweener';
import { TweenManager } from './TweenManager';
import { TweenValue } from './TweenValue';
/**
* GTween
*
* Main entry point for the tween system.
* Provides static factory methods for creating tweens.
*
* 补间系统的主入口点
* 提供创建补间的静态工厂方法
*
* @example
* ```typescript
* // Simple tween
* GTween.to(0, 100, 0.5)
* .setTarget(sprite, 'x')
* .setEase(EEaseType.QuadOut);
*
* // Vector tween
* GTween.to2(0, 0, 100, 200, 0.5)
* .setTarget(sprite)
* .onUpdate((tweener) => {
* sprite.x = tweener.value.x;
* sprite.y = tweener.value.y;
* });
*
* // Delayed call
* GTween.delayedCall(1.0)
* .onComplete(() => console.log('Done!'));
* ```
*/
export class GTween {
/**
* Catch all uncaught tween callback exceptions
* 捕获所有未捕获的补间回调异常
*/
public static catchCallbackExceptions: boolean = true;
/**
* Create a tween from start to end value
* 创建从起始值到结束值的补间
*/
public static to(start: number, end: number, duration: number): GTweener {
return TweenManager.createTween()._to(start, end, duration);
}
/**
* Create a 2D tween
* 创建2D补间
*/
public static to2(
startX: number,
startY: number,
endX: number,
endY: number,
duration: number
): GTweener {
return TweenManager.createTween()._to2(startX, startY, endX, endY, duration);
}
/**
* Create a 3D tween
* 创建3D补间
*/
public static to3(
startX: number,
startY: number,
startZ: number,
endX: number,
endY: number,
endZ: number,
duration: number
): GTweener {
return TweenManager.createTween()._to3(startX, startY, startZ, endX, endY, endZ, duration);
}
/**
* Create a 4D tween
* 创建4D补间
*/
public static to4(
startX: number,
startY: number,
startZ: number,
startW: number,
endX: number,
endY: number,
endZ: number,
endW: number,
duration: number
): GTweener {
return TweenManager.createTween()._to4(
startX,
startY,
startZ,
startW,
endX,
endY,
endZ,
endW,
duration
);
}
/**
* Create a color tween
* 创建颜色补间
*/
public static toColor(start: number, end: number, duration: number): GTweener {
return TweenManager.createTween()._toColor(start, end, duration);
}
/**
* Create a delayed call
* 创建延迟调用
*/
public static delayedCall(delay: number): GTweener {
return TweenManager.createTween().setDelay(delay);
}
/**
* Create a shake tween
* 创建震动补间
*/
public static shake(
startX: number,
startY: number,
amplitude: number,
duration: number
): GTweener {
return TweenManager.createTween()._shake(startX, startY, amplitude, duration);
}
/**
* Check if target is being tweened
* 检查目标是否正在被补间
*/
public static isTweening(target: any, propType?: any): boolean {
return TweenManager.isTweening(target, propType);
}
/**
* Kill all tweens on target
* 终止目标上的所有补间
*/
public static kill(target: any, bComplete?: boolean, propType?: any): void {
TweenManager.killTweens(target, bComplete, propType);
}
/**
* Get tween for target
* 获取目标的补间
*/
public static getTween(target: any, propType?: any): GTweener | null {
return TweenManager.getTween(target, propType);
}
}

View File

@@ -0,0 +1,532 @@
import { TweenValue } from './TweenValue';
import { EEaseType, evaluateEase } from './EaseType';
/**
* Tween callback type
* 补间回调类型
*/
export type TweenCallback = (tweener: GTweener) => void;
/**
* GTweener
*
* Individual tween instance with fluent API.
*
* 单独的补间实例,支持流式 API
*
* Features:
* - Fluent configuration API
* - Multiple easing types
* - Repeat and yoyo modes
* - Callbacks for start, update, complete
*/
export class GTweener {
public _target: any = null;
public _propType: any = null;
public _killed: boolean = false;
public _paused: boolean = false;
private _delay: number = 0;
private _duration: number = 0;
private _breakpoint: number = -1;
private _easeType: EEaseType = EEaseType.QuadOut;
private _easeOvershootOrAmplitude: number = 1.70158;
private _easePeriod: number = 0;
private _repeat: number = 0;
private _yoyo: boolean = false;
private _timeScale: number = 1;
private _snapping: boolean = false;
private _userData: any = null;
private _onUpdate: TweenCallback | null = null;
private _onStart: TweenCallback | null = null;
private _onComplete: TweenCallback | null = null;
private _startValue: TweenValue;
private _endValue: TweenValue;
private _value: TweenValue;
private _deltaValue: TweenValue;
private _valueSize: number = 0;
private _started: boolean = false;
private _ended: number = 0;
private _elapsedTime: number = 0;
private _normalizedTime: number = 0;
constructor() {
this._startValue = new TweenValue();
this._endValue = new TweenValue();
this._value = new TweenValue();
this._deltaValue = new TweenValue();
this._reset();
}
// Fluent configuration
public setDelay(value: number): GTweener {
this._delay = value;
return this;
}
public get delay(): number {
return this._delay;
}
public setDuration(value: number): GTweener {
this._duration = value;
return this;
}
public get duration(): number {
return this._duration;
}
public setBreakpoint(value: number): GTweener {
this._breakpoint = value;
return this;
}
public setEase(value: EEaseType): GTweener {
this._easeType = value;
return this;
}
public setEasePeriod(value: number): GTweener {
this._easePeriod = value;
return this;
}
public setEaseOvershootOrAmplitude(value: number): GTweener {
this._easeOvershootOrAmplitude = value;
return this;
}
public setRepeat(repeat: number, yoyo: boolean = false): GTweener {
this._repeat = repeat;
this._yoyo = yoyo;
return this;
}
public get repeat(): number {
return this._repeat;
}
public setTimeScale(value: number): GTweener {
this._timeScale = value;
return this;
}
public setSnapping(value: boolean): GTweener {
this._snapping = value;
return this;
}
public setTarget(value: any, propType?: any): GTweener {
this._target = value;
this._propType = propType;
return this;
}
public get target(): any {
return this._target;
}
public setUserData(value: any): GTweener {
this._userData = value;
return this;
}
public get userData(): any {
return this._userData;
}
public onUpdate(callback: TweenCallback): GTweener {
this._onUpdate = callback;
return this;
}
public onStart(callback: TweenCallback): GTweener {
this._onStart = callback;
return this;
}
public onComplete(callback: TweenCallback): GTweener {
this._onComplete = callback;
return this;
}
// Value accessors
public get startValue(): TweenValue {
return this._startValue;
}
public get endValue(): TweenValue {
return this._endValue;
}
public get value(): TweenValue {
return this._value;
}
public get deltaValue(): TweenValue {
return this._deltaValue;
}
public get normalizedTime(): number {
return this._normalizedTime;
}
public get completed(): boolean {
return this._ended !== 0;
}
public get allCompleted(): boolean {
return this._ended === 1;
}
// Control
public setPaused(paused: boolean): GTweener {
this._paused = paused;
return this;
}
public seek(time: number): void {
if (this._killed) return;
this._elapsedTime = time;
if (this._elapsedTime < this._delay) {
if (this._started) {
this._elapsedTime = this._delay;
} else {
return;
}
}
this.update();
}
public kill(complete?: boolean): void {
if (this._killed) return;
if (complete) {
if (this._ended === 0) {
if (this._breakpoint >= 0) {
this._elapsedTime = this._delay + this._breakpoint;
} else if (this._repeat >= 0) {
this._elapsedTime = this._delay + this._duration * (this._repeat + 1);
} else {
this._elapsedTime = this._delay + this._duration * 2;
}
this.update();
}
this.callCompleteCallback();
}
this._killed = true;
}
// Internal setup methods
public _to(start: number, end: number, duration: number): GTweener {
this._valueSize = 1;
this._startValue.x = start;
this._endValue.x = end;
this._value.x = start;
this._duration = duration;
return this;
}
public _to2(start: number, start2: number, end: number, end2: number, duration: number): GTweener {
this._valueSize = 2;
this._startValue.x = start;
this._endValue.x = end;
this._startValue.y = start2;
this._endValue.y = end2;
this._value.x = start;
this._value.y = start2;
this._duration = duration;
return this;
}
public _to3(
start: number,
start2: number,
start3: number,
end: number,
end2: number,
end3: number,
duration: number
): GTweener {
this._valueSize = 3;
this._startValue.x = start;
this._endValue.x = end;
this._startValue.y = start2;
this._endValue.y = end2;
this._startValue.z = start3;
this._endValue.z = end3;
this._value.x = start;
this._value.y = start2;
this._value.z = start3;
this._duration = duration;
return this;
}
public _to4(
start: number,
start2: number,
start3: number,
start4: number,
end: number,
end2: number,
end3: number,
end4: number,
duration: number
): GTweener {
this._valueSize = 4;
this._startValue.x = start;
this._endValue.x = end;
this._startValue.y = start2;
this._endValue.y = end2;
this._startValue.z = start3;
this._endValue.z = end3;
this._startValue.w = start4;
this._endValue.w = end4;
this._value.x = start;
this._value.y = start2;
this._value.z = start3;
this._value.w = start4;
this._duration = duration;
return this;
}
public _toColor(start: number, end: number, duration: number): GTweener {
this._valueSize = 5;
this._startValue.color = start;
this._endValue.color = end;
this._value.color = start;
this._duration = duration;
return this;
}
public _shake(startX: number, startY: number, amplitude: number, duration: number): GTweener {
this._valueSize = 6;
this._startValue.x = startX;
this._startValue.y = startY;
this._startValue.w = amplitude;
this._duration = duration;
return this;
}
public _init(): void {
this._delay = 0;
this._duration = 0;
this._breakpoint = -1;
this._easeType = EEaseType.QuadOut;
this._timeScale = 1;
this._easePeriod = 0;
this._easeOvershootOrAmplitude = 1.70158;
this._snapping = false;
this._repeat = 0;
this._yoyo = false;
this._valueSize = 0;
this._started = false;
this._paused = false;
this._killed = false;
this._elapsedTime = 0;
this._normalizedTime = 0;
this._ended = 0;
}
public _reset(): void {
this._target = null;
this._propType = null;
this._userData = null;
this._onStart = null;
this._onUpdate = null;
this._onComplete = null;
}
public _update(dt: number): void {
if (this._timeScale !== 1) {
dt *= this._timeScale;
}
if (dt === 0) return;
if (this._ended !== 0) {
this.callCompleteCallback();
this._killed = true;
return;
}
this._elapsedTime += dt;
this.update();
if (this._ended !== 0) {
if (!this._killed) {
this.callCompleteCallback();
this._killed = true;
}
}
}
private update(): void {
this._ended = 0;
if (this._valueSize === 0) {
// DelayedCall
if (this._elapsedTime >= this._delay + this._duration) {
this._ended = 1;
}
return;
}
if (!this._started) {
if (this._elapsedTime < this._delay) return;
this._started = true;
this.callStartCallback();
if (this._killed) return;
}
let reversed = false;
let tt = this._elapsedTime - this._delay;
if (this._breakpoint >= 0 && tt >= this._breakpoint) {
tt = this._breakpoint;
this._ended = 2;
}
if (this._repeat !== 0) {
const round = Math.floor(tt / this._duration);
tt -= this._duration * round;
if (this._yoyo) {
reversed = round % 2 === 1;
}
if (this._repeat > 0 && this._repeat - round < 0) {
if (this._yoyo) {
reversed = this._repeat % 2 === 1;
}
tt = this._duration;
this._ended = 1;
}
} else if (tt >= this._duration) {
tt = this._duration;
this._ended = 1;
}
this._normalizedTime = evaluateEase(
this._easeType,
reversed ? this._duration - tt : tt,
this._duration,
this._easeOvershootOrAmplitude,
this._easePeriod
);
this._value.setZero();
this._deltaValue.setZero();
if (this._valueSize === 6) {
// Shake
if (this._ended === 0) {
const r = this._startValue.w * (1 - this._normalizedTime);
const rx = r * (Math.random() > 0.5 ? 1 : -1);
const ry = r * (Math.random() > 0.5 ? 1 : -1);
this._deltaValue.x = rx;
this._deltaValue.y = ry;
this._value.x = this._startValue.x + rx;
this._value.y = this._startValue.y + ry;
} else {
this._value.x = this._startValue.x;
this._value.y = this._startValue.y;
}
} else {
const cnt = Math.min(this._valueSize, 4);
for (let i = 0; i < cnt; i++) {
const n1 = this._startValue.getField(i);
const n2 = this._endValue.getField(i);
let f = n1 + (n2 - n1) * this._normalizedTime;
if (this._snapping) {
f = Math.round(f);
}
this._deltaValue.setField(i, f - this._value.getField(i));
this._value.setField(i, f);
}
}
// Apply to target
if (this._target && this._propType) {
if (typeof this._propType === 'function') {
switch (this._valueSize) {
case 1:
this._propType.call(this._target, this._value.x);
break;
case 2:
this._propType.call(this._target, this._value.x, this._value.y);
break;
case 3:
this._propType.call(this._target, this._value.x, this._value.y, this._value.z);
break;
case 4:
this._propType.call(
this._target,
this._value.x,
this._value.y,
this._value.z,
this._value.w
);
break;
case 5:
this._propType.call(this._target, this._value.color);
break;
case 6:
this._propType.call(this._target, this._value.x, this._value.y);
break;
}
} else {
if (this._valueSize === 5) {
this._target[this._propType] = this._value.color;
} else {
this._target[this._propType] = this._value.x;
}
}
}
this.callUpdateCallback();
}
private callStartCallback(): void {
if (this._onStart) {
try {
this._onStart(this);
} catch (err) {
console.warn('FairyGUI: error in tween start callback', err);
}
}
}
private callUpdateCallback(): void {
if (this._onUpdate) {
try {
this._onUpdate(this);
} catch (err) {
console.warn('FairyGUI: error in tween update callback', err);
}
}
}
private callCompleteCallback(): void {
if (this._onComplete) {
try {
this._onComplete(this);
} catch (err) {
console.warn('FairyGUI: error in tween complete callback', err);
}
}
}
}

View File

@@ -0,0 +1,146 @@
import { GTweener } from './GTweener';
import { Timer } from '../core/Timer';
/**
* TweenManager
*
* Manages all active tweens and updates them.
*
* 管理所有活动的补间并更新它们
*/
export class TweenManager {
private static _activeTweens: GTweener[] = [];
private static _tweenPool: GTweener[] = [];
private static _totalActiveTweens: number = 0;
private static _lastTime: number = 0;
private static _inited: boolean = false;
/**
* Create a new tween
* 创建新补间
*/
public static createTween(): GTweener {
if (!TweenManager._inited) {
TweenManager.init();
}
let tweener: GTweener;
if (TweenManager._tweenPool.length > 0) {
tweener = TweenManager._tweenPool.pop()!;
} else {
tweener = new GTweener();
}
tweener._init();
TweenManager._activeTweens[TweenManager._totalActiveTweens++] = tweener;
return tweener;
}
/**
* Check if target is being tweened
* 检查目标是否正在被补间
*/
public static isTweening(target: any, propType?: any): boolean {
if (!target) return false;
for (let i = 0; i < TweenManager._totalActiveTweens; i++) {
const tweener = TweenManager._activeTweens[i];
if (tweener && tweener._target === target && !tweener._killed) {
if (!propType || tweener._propType === propType) {
return true;
}
}
}
return false;
}
/**
* Kill all tweens on target
* 终止目标上的所有补间
*/
public static killTweens(target: any, complete?: boolean, propType?: any): boolean {
if (!target) return false;
let result = false;
for (let i = 0; i < TweenManager._totalActiveTweens; i++) {
const tweener = TweenManager._activeTweens[i];
if (tweener && tweener._target === target && !tweener._killed) {
if (!propType || tweener._propType === propType) {
tweener.kill(complete);
result = true;
}
}
}
return result;
}
/**
* Get tween for target
* 获取目标的补间
*/
public static getTween(target: any, propType?: any): GTweener | null {
if (!target) return null;
for (let i = 0; i < TweenManager._totalActiveTweens; i++) {
const tweener = TweenManager._activeTweens[i];
if (tweener && tweener._target === target && !tweener._killed) {
if (!propType || tweener._propType === propType) {
return tweener;
}
}
}
return null;
}
private static init(): void {
TweenManager._inited = true;
TweenManager._lastTime = Timer.time;
Timer.add(TweenManager.update, TweenManager);
}
private static update(): void {
const currentTime = Timer.time;
let dt = currentTime - TweenManager._lastTime;
TweenManager._lastTime = currentTime;
if (dt > 100) {
dt = 100;
}
// Convert to seconds
dt /= 1000;
let freePosStart = -1;
for (let i = 0; i < TweenManager._totalActiveTweens; i++) {
const tweener = TweenManager._activeTweens[i];
if (!tweener) {
if (freePosStart === -1) {
freePosStart = i;
}
} else if (tweener._killed) {
tweener._reset();
TweenManager._tweenPool.push(tweener);
TweenManager._activeTweens[i] = null as any;
if (freePosStart === -1) {
freePosStart = i;
}
} else {
if (!tweener._paused) {
tweener._update(dt);
}
if (freePosStart !== -1) {
TweenManager._activeTweens[freePosStart] = tweener;
TweenManager._activeTweens[i] = null as any;
freePosStart++;
}
}
}
if (freePosStart !== -1) {
TweenManager._totalActiveTweens = freePosStart;
}
}
}

View File

@@ -0,0 +1,96 @@
/**
* TweenValue
*
* Container for tween interpolation values.
* Supports up to 4 numeric values and color values.
*
* 补间插值容器,支持最多 4 个数值和颜色值
*/
export class TweenValue {
public x: number = 0;
public y: number = 0;
public z: number = 0;
public w: number = 0;
private _color: number = 0;
/**
* Get/set color value (packed ARGB)
* 获取/设置颜色值(打包的 ARGB
*/
public get color(): number {
return this._color;
}
public set color(value: number) {
this._color = value;
// Unpack color from 0xRRGGBBAA to x, y, z, w (r, g, b, a)
this.x = (value >> 24) & 0xff;
this.y = (value >> 16) & 0xff;
this.z = (value >> 8) & 0xff;
this.w = value & 0xff;
}
/**
* Get field by index (0=x, 1=y, 2=z, 3=w)
* 根据索引获取字段
*/
public getField(index: number): number {
switch (index) {
case 0:
return this.x;
case 1:
return this.y;
case 2:
return this.z;
case 3:
return this.w;
default:
return 0;
}
}
/**
* Set field by index (0=x, 1=y, 2=z, 3=w)
* 根据索引设置字段
*/
public setField(index: number, value: number): void {
switch (index) {
case 0:
this.x = value;
break;
case 1:
this.y = value;
break;
case 2:
this.z = value;
break;
case 3:
this.w = value;
break;
}
}
/**
* Reset all values to zero
* 重置所有值为零
*/
public setZero(): void {
this.x = 0;
this.y = 0;
this.z = 0;
this.w = 0;
}
/**
* Copy from another TweenValue
* 从另一个 TweenValue 复制
*/
public copyFrom(source: TweenValue): void {
this.x = source.x;
this.y = source.y;
this.z = source.z;
this.w = source.w;
this._color = source._color;
}
}

View File

@@ -0,0 +1,5 @@
export { EEaseType, evaluateEase } from './EaseType';
export { TweenValue } from './TweenValue';
export { GTweener, TweenCallback } from './GTweener';
export { TweenManager } from './TweenManager';
export { GTween } from './GTween';

View File

@@ -0,0 +1,395 @@
/**
* ByteBuffer
*
* Binary data reader for parsing FairyGUI package files.
*
* 二进制数据读取器,用于解析 FairyGUI 包文件
*/
export class ByteBuffer {
private _data: DataView;
private _position: number = 0;
private _littleEndian: boolean = false;
private _stringTable: string[] = [];
private _version: number = 0;
constructor(buffer: ArrayBuffer, offset: number = 0, length?: number) {
length = length ?? buffer.byteLength - offset;
this._data = new DataView(buffer, offset, length);
}
/**
* Get buffer length
* 获取缓冲区长度
*/
public get length(): number {
return this._data.byteLength;
}
/**
* Get current position
* 获取当前位置
*/
public get position(): number {
return this._position;
}
/**
* Set current position
* 设置当前位置
*/
public set position(value: number) {
this._position = value;
}
/**
* Get version
* 获取版本
*/
public get version(): number {
return this._version;
}
/**
* Set version
* 设置版本
*/
public set version(value: number) {
this._version = value;
}
/**
* Check if can read more bytes
* 检查是否可以读取更多字节
*/
public get bytesAvailable(): number {
return this._data.byteLength - this._position;
}
/**
* Skip bytes
* 跳过字节
*/
public skip(count: number): void {
this._position += count;
}
/**
* Seek to position
* 定位到指定位置
*/
public seek(indexTablePos: number, blockIndex: number): boolean {
const tmp = this._position;
this._position = indexTablePos;
const segCount = this.getUint8();
if (blockIndex < segCount) {
const useShort = this.getUint8() === 1;
let newPos: number;
if (useShort) {
this._position = indexTablePos + 2 + 2 * blockIndex;
newPos = this.getUint16();
} else {
this._position = indexTablePos + 2 + 4 * blockIndex;
newPos = this.getUint32();
}
if (newPos > 0) {
this._position = indexTablePos + newPos;
return true;
} else {
this._position = tmp;
return false;
}
} else {
this._position = tmp;
return false;
}
}
// Read methods | 读取方法
public getUint8(): number {
const value = this._data.getUint8(this._position);
this._position += 1;
return value;
}
public getInt8(): number {
const value = this._data.getInt8(this._position);
this._position += 1;
return value;
}
public getUint16(): number {
const value = this._data.getUint16(this._position, this._littleEndian);
this._position += 2;
return value;
}
public getInt16(): number {
const value = this._data.getInt16(this._position, this._littleEndian);
this._position += 2;
return value;
}
public getUint32(): number {
const value = this._data.getUint32(this._position, this._littleEndian);
this._position += 4;
return value;
}
public getInt32(): number {
const value = this._data.getInt32(this._position, this._littleEndian);
this._position += 4;
return value;
}
public getFloat32(): number {
const value = this._data.getFloat32(this._position, this._littleEndian);
this._position += 4;
return value;
}
public getFloat64(): number {
const value = this._data.getFloat64(this._position, this._littleEndian);
this._position += 8;
return value;
}
/**
* Read boolean
* 读取布尔值
*/
public readBool(): boolean {
return this.getUint8() === 1;
}
/**
* Read byte
* 读取字节
*/
public readByte(): number {
return this.getUint8();
}
/**
* Read short
* 读取短整数
*/
public readShort(): number {
return this.getInt16();
}
/**
* Read unsigned short
* 读取无符号短整数
*/
public readUshort(): number {
return this.getUint16();
}
/**
* Read int
* 读取整数
*/
public readInt(): number {
return this.getInt32();
}
/**
* Read unsigned int
* 读取无符号整数
*/
public readUint(): number {
return this.getUint32();
}
/**
* Read float
* 读取浮点数
*/
public readFloat(): number {
return this.getFloat32();
}
/**
* Read string from string table
* 从字符串表读取字符串
*/
public readS(): string {
const index = this.getUint16();
if (index === 65535) {
return '';
}
return this._stringTable[index] || '';
}
/**
* Read string with length prefix
* 读取带长度前缀的字符串
*/
public readString(): string {
const len = this.getUint16();
if (len === 0) {
return '';
}
return this.readStringWithLength(len);
}
private readStringWithLength(len: number): string {
const bytes = new Uint8Array(this._data.buffer, this._data.byteOffset + this._position, len);
this._position += len;
return new TextDecoder('utf-8').decode(bytes);
}
/**
* Read color as packed u32 (0xRRGGBBAA format)
* 读取颜色为打包的 u320xRRGGBBAA 格式)
*/
public readColor(bHasAlpha: boolean = false): number {
const r = this.getUint8();
const g = this.getUint8();
const b = this.getUint8();
const a = this.getUint8();
return ((r << 24) | (g << 16) | (b << 8) | (bHasAlpha ? a : 0xFF)) >>> 0;
}
/**
* Read color as CSS string (always reads 4 bytes: r, g, b, a)
* 读取颜色为 CSS 字符串(总是读取 4 字节r, g, b, a
*
* FairyGUI 二进制格式颜色存储顺序为 R, G, B, A
*/
public readColorS(bHasAlpha: boolean = false): string {
const byte0 = this.getUint8();
const byte1 = this.getUint8();
const byte2 = this.getUint8();
const byte3 = this.getUint8();
// FairyGUI stores colors as R, G, B, A
const r = byte0;
const g = byte1;
const b = byte2;
const a = byte3;
if (bHasAlpha && a !== 255) {
return `rgba(${r},${g},${b},${(a / 255).toFixed(2)})`;
} else {
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
}
/**
* Read bytes
* 读取字节数组
*/
public readBytes(length: number): Uint8Array {
const bytes = new Uint8Array(this._data.buffer, this._data.byteOffset + this._position, length);
this._position += length;
return bytes;
}
/**
* Set string table
* 设置字符串表
*/
public set stringTable(value: string[]) {
this._stringTable = value;
}
/**
* Get string table
* 获取字符串表
*/
public get stringTable(): string[] {
return this._stringTable;
}
/**
* Alias for position getter
* position getter 别名
*/
public get pos(): number {
return this._position;
}
/**
* Alias for position setter
* position setter 别名
*/
public set pos(value: number) {
this._position = value;
}
/**
* Get underlying buffer
* 获取底层缓冲区
*/
public get buffer(): ArrayBuffer {
return this._data.buffer as ArrayBuffer;
}
/**
* Read UTF string (length-prefixed)
* 读取 UTF 字符串(带长度前缀)
*/
public readUTFString(): string {
const len = this.getUint16();
if (len === 0) {
return '';
}
return this.readStringWithLength(len);
}
/**
* Read string array
* 读取字符串数组
*/
public readSArray(count: number): string[] {
const arr: string[] = [];
for (let i = 0; i < count; i++) {
arr.push(this.readS());
}
return arr;
}
/**
* Read custom string with specified length
* 读取指定长度的自定义字符串
*/
public getCustomString(len: number): string {
const bytes = new Uint8Array(this._data.buffer, this._data.byteOffset + this._position, len);
this._position += len;
return new TextDecoder('utf-8').decode(bytes);
}
/**
* Read sub-buffer
* 读取子缓冲区
*/
public readBuffer(): ByteBuffer {
const len = this.getUint32();
const buffer = new ByteBuffer(this._data.buffer as ArrayBuffer, this._data.byteOffset + this._position, len);
buffer.version = this._version;
buffer.stringTable = this._stringTable;
this._position += len;
return buffer;
}
/**
* Read Int32 (alias)
* 读取 Int32别名
*/
public readInt32(): number {
return this.getInt32();
}
/**
* Read Uint16 (alias)
* 读取 Uint16别名
*/
public readUint16(): number {
return this.getUint16();
}
}

View File

@@ -0,0 +1,192 @@
/**
* Point interface
* 2D point
*/
export interface IPoint {
x: number;
y: number;
}
/**
* Rectangle interface
* 2D rectangle
*/
export interface IRectangle {
x: number;
y: number;
width: number;
height: number;
}
/**
* Point class
* 2D point with utility methods
*/
export class Point implements IPoint {
public x: number;
public y: number;
constructor(x: number = 0, y: number = 0) {
this.x = x;
this.y = y;
}
public set(x: number, y: number): this {
this.x = x;
this.y = y;
return this;
}
public copyFrom(source: IPoint): this {
this.x = source.x;
this.y = source.y;
return this;
}
public clone(): Point {
return new Point(this.x, this.y);
}
public distance(target: IPoint): number {
const dx = target.x - this.x;
const dy = target.y - this.y;
return Math.sqrt(dx * dx + dy * dy);
}
public equals(other: IPoint): boolean {
return this.x === other.x && this.y === other.y;
}
public static readonly ZERO: Readonly<Point> = new Point(0, 0);
}
/**
* Rectangle class
* 2D rectangle with utility methods
*/
export class Rectangle implements IRectangle {
public x: number;
public y: number;
public width: number;
public height: number;
constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public get right(): number {
return this.x + this.width;
}
public get bottom(): number {
return this.y + this.height;
}
public set(x: number, y: number, width: number, height: number): this {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
return this;
}
public copyFrom(source: IRectangle): this {
this.x = source.x;
this.y = source.y;
this.width = source.width;
this.height = source.height;
return this;
}
public clone(): Rectangle {
return new Rectangle(this.x, this.y, this.width, this.height);
}
public contains(x: number, y: number): boolean {
return x >= this.x && x < this.right && y >= this.y && y < this.bottom;
}
public containsPoint(point: IPoint): boolean {
return this.contains(point.x, point.y);
}
public intersects(other: IRectangle): boolean {
return !(
other.x >= this.right ||
other.x + other.width <= this.x ||
other.y >= this.bottom ||
other.y + other.height <= this.y
);
}
public intersection(other: IRectangle, out?: Rectangle): Rectangle | null {
const x = Math.max(this.x, other.x);
const y = Math.max(this.y, other.y);
const right = Math.min(this.right, other.x + other.width);
const bottom = Math.min(this.bottom, other.y + other.height);
if (right <= x || bottom <= y) {
return null;
}
out = out || new Rectangle();
return out.set(x, y, right - x, bottom - y);
}
public union(other: IRectangle, out?: Rectangle): Rectangle {
const x = Math.min(this.x, other.x);
const y = Math.min(this.y, other.y);
const right = Math.max(this.right, other.x + other.width);
const bottom = Math.max(this.bottom, other.y + other.height);
out = out || new Rectangle();
return out.set(x, y, right - x, bottom - y);
}
public isEmpty(): boolean {
return this.width <= 0 || this.height <= 0;
}
public setEmpty(): this {
this.x = 0;
this.y = 0;
this.width = 0;
this.height = 0;
return this;
}
public static readonly EMPTY: Readonly<Rectangle> = new Rectangle();
}
/**
* Margin class
* Represents margins/padding (left, top, right, bottom)
*/
export class Margin {
public left: number;
public top: number;
public right: number;
public bottom: number;
constructor(left: number = 0, top: number = 0, right: number = 0, bottom: number = 0) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
public copyFrom(source: Margin): this {
this.left = source.left;
this.top = source.top;
this.right = source.right;
this.bottom = source.bottom;
return this;
}
public clone(): Margin {
return new Margin(this.left, this.top, this.right, this.bottom);
}
}

View File

@@ -0,0 +1,662 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { Controller } from '../core/Controller';
import { GTextField } from './GTextField';
import { FGUIEvents } from '../events/Events';
import { EButtonMode, EObjectPropID } from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* GButton
*
* Button component with states: up, down, over, selected, disabled.
*
* 按钮组件,支持状态:正常、按下、悬停、选中、禁用
*/
export class GButton extends GComponent {
protected _titleObject: GObject | null = null;
protected _iconObject: GObject | null = null;
private _mode: EButtonMode = EButtonMode.Common;
private _selected: boolean = false;
private _title: string = '';
private _selectedTitle: string = '';
private _icon: string = '';
private _selectedIcon: string = '';
private _sound: string = '';
private _soundVolumeScale: number = 1;
private _buttonController: Controller | null = null;
private _relatedController: Controller | null = null;
private _relatedPageId: string = '';
private _changeStateOnClick: boolean = true;
private _linkedPopup: GObject | null = null;
private _downEffect: number = 0;
private _downEffectValue: number = 0.8;
private _downScaled: boolean = false;
private _down: boolean = false;
private _over: boolean = false;
public static readonly UP: string = 'up';
public static readonly DOWN: string = 'down';
public static readonly OVER: string = 'over';
public static readonly SELECTED_OVER: string = 'selectedOver';
public static readonly DISABLED: string = 'disabled';
public static readonly SELECTED_DISABLED: string = 'selectedDisabled';
constructor() {
super();
}
/**
* Get/set icon URL
* 获取/设置图标 URL
*/
public get icon(): string {
return this._icon;
}
public set icon(value: string) {
this._icon = value;
const v = this._selected && this._selectedIcon ? this._selectedIcon : this._icon;
if (this._iconObject) {
this._iconObject.icon = v;
}
this.updateGear(7);
}
/**
* Get/set selected icon URL
* 获取/设置选中图标 URL
*/
public get selectedIcon(): string {
return this._selectedIcon;
}
public set selectedIcon(value: string) {
this._selectedIcon = value;
const v = this._selected && this._selectedIcon ? this._selectedIcon : this._icon;
if (this._iconObject) {
this._iconObject.icon = v;
}
}
/**
* Get/set title text
* 获取/设置标题文本
*/
public get title(): string {
return this._title;
}
public set title(value: string) {
this._title = value;
if (this._titleObject) {
this._titleObject.text =
this._selected && this._selectedTitle ? this._selectedTitle : this._title;
}
this.updateGear(6);
}
/**
* Get/set text (alias for title)
* 获取/设置文本title 的别名)
*/
public get text(): string {
return this.title;
}
public set text(value: string) {
this.title = value;
}
/**
* Get/set selected title text
* 获取/设置选中标题文本
*/
public get selectedTitle(): string {
return this._selectedTitle;
}
public set selectedTitle(value: string) {
this._selectedTitle = value;
if (this._titleObject) {
this._titleObject.text =
this._selected && this._selectedTitle ? this._selectedTitle : this._title;
}
}
/**
* Get/set title color
* 获取/设置标题颜色
*/
public get titleColor(): string {
const tf = this.getTextField();
if (tf) {
return tf.color;
}
return '#000000';
}
public set titleColor(value: string) {
const tf = this.getTextField();
if (tf) {
tf.color = value;
}
this.updateGear(4);
}
/**
* Get/set title font size
* 获取/设置标题字体大小
*/
public get titleFontSize(): number {
const tf = this.getTextField();
if (tf) {
return tf.fontSize;
}
return 0;
}
public set titleFontSize(value: number) {
const tf = this.getTextField();
if (tf) {
tf.fontSize = value;
}
}
/**
* Get/set sound URL
* 获取/设置声音 URL
*/
public get sound(): string {
return this._sound;
}
public set sound(value: string) {
this._sound = value;
}
/**
* Get/set sound volume scale
* 获取/设置声音音量缩放
*/
public get soundVolumeScale(): number {
return this._soundVolumeScale;
}
public set soundVolumeScale(value: number) {
this._soundVolumeScale = value;
}
/**
* Get/set selected state
* 获取/设置选中状态
*/
public get selected(): boolean {
return this._selected;
}
public set selected(value: boolean) {
if (this._mode === EButtonMode.Common) {
return;
}
if (this._selected !== value) {
this._selected = value;
if (
this.grayed &&
this._buttonController &&
this._buttonController.hasPage(GButton.DISABLED)
) {
if (this._selected) {
this.setState(GButton.SELECTED_DISABLED);
} else {
this.setState(GButton.DISABLED);
}
} else {
if (this._selected) {
this.setState(this._over ? GButton.SELECTED_OVER : GButton.DOWN);
} else {
this.setState(this._over ? GButton.OVER : GButton.UP);
}
}
if (this._selectedTitle && this._titleObject) {
this._titleObject.text = this._selected ? this._selectedTitle : this._title;
}
if (this._selectedIcon) {
const str = this._selected ? this._selectedIcon : this._icon;
if (this._iconObject) {
this._iconObject.icon = str;
}
}
if (
this._relatedController &&
this._parent &&
!this._parent._buildingDisplayList
) {
if (this._selected) {
this._relatedController.selectedPageId = this._relatedPageId;
} else if (
this._mode === EButtonMode.Check &&
this._relatedController.selectedPageId === this._relatedPageId
) {
// Deselect if in check mode
}
}
}
}
/**
* Get/set button mode
* 获取/设置按钮模式
*/
public get mode(): EButtonMode {
return this._mode;
}
public set mode(value: EButtonMode) {
if (this._mode !== value) {
if (value === EButtonMode.Common) {
this.selected = false;
}
this._mode = value;
}
}
/**
* Get/set related controller
* 获取/设置关联控制器
*/
public get relatedController(): Controller | null {
return this._relatedController;
}
public set relatedController(value: Controller | null) {
if (value !== this._relatedController) {
this._relatedController = value;
this._relatedPageId = '';
}
}
/**
* Get/set related page ID
* 获取/设置关联页面 ID
*/
public get relatedPageId(): string {
return this._relatedPageId;
}
public set relatedPageId(value: string) {
this._relatedPageId = value;
}
/**
* Get/set change state on click
* 获取/设置点击时是否改变状态
*/
public get changeStateOnClick(): boolean {
return this._changeStateOnClick;
}
public set changeStateOnClick(value: boolean) {
this._changeStateOnClick = value;
}
/**
* Get/set linked popup
* 获取/设置关联弹出窗口
*/
public get linkedPopup(): GObject | null {
return this._linkedPopup;
}
public set linkedPopup(value: GObject | null) {
this._linkedPopup = value;
}
/**
* Get text field from title object
* 从标题对象获取文本字段
*/
public getTextField(): GTextField | null {
if (this._titleObject instanceof GTextField) {
return this._titleObject;
} else if (this._titleObject instanceof GButton) {
return this._titleObject.getTextField();
}
return null;
}
/**
* Fire a click event programmatically
* 程序化触发点击事件
*/
public fireClick(bDownEffect: boolean = true): void {
if (bDownEffect && this._mode === EButtonMode.Common) {
this.setState(GButton.OVER);
setTimeout(() => this.setState(GButton.DOWN), 100);
setTimeout(() => this.setState(GButton.UP), 200);
}
this.handleClick();
}
/**
* Set button state
* 设置按钮状态
*/
protected setState(value: string): void {
if (this._buttonController) {
this._buttonController.selectedPage = value;
}
if (this._downEffect === 1) {
const cnt = this.numChildren;
if (
value === GButton.DOWN ||
value === GButton.SELECTED_OVER ||
value === GButton.SELECTED_DISABLED
) {
const r = Math.round(this._downEffectValue * 255);
const color = '#' + ((r << 16) + (r << 8) + r).toString(16).padStart(6, '0');
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (!(obj instanceof GTextField)) {
obj.setProp(EObjectPropID.Color, color);
}
}
} else {
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (!(obj instanceof GTextField)) {
obj.setProp(EObjectPropID.Color, '#FFFFFF');
}
}
}
} else if (this._downEffect === 2) {
if (
value === GButton.DOWN ||
value === GButton.SELECTED_OVER ||
value === GButton.SELECTED_DISABLED
) {
if (!this._downScaled) {
this.setScale(
this.scaleX * this._downEffectValue,
this.scaleY * this._downEffectValue
);
this._downScaled = true;
}
} else {
if (this._downScaled) {
this.setScale(
this.scaleX / this._downEffectValue,
this.scaleY / this._downEffectValue
);
this._downScaled = false;
}
}
}
}
public handleControllerChanged(c: Controller): void {
super.handleControllerChanged(c);
if (this._relatedController === c) {
this.selected = this._relatedPageId === c.selectedPageId;
}
}
protected handleGrayedChanged(): void {
if (
this._buttonController &&
this._buttonController.hasPage(GButton.DISABLED)
) {
if (this.grayed) {
if (
this._selected &&
this._buttonController.hasPage(GButton.SELECTED_DISABLED)
) {
this.setState(GButton.SELECTED_DISABLED);
} else {
this.setState(GButton.DISABLED);
}
} else if (this._selected) {
this.setState(GButton.DOWN);
} else {
this.setState(GButton.UP);
}
} else {
super.handleGrayedChanged();
}
}
public getProp(index: number): any {
switch (index) {
case EObjectPropID.Color:
return this.titleColor;
case EObjectPropID.OutlineColor:
const tf = this.getTextField();
if (tf) {
return tf.strokeColor;
}
return '#000000';
case EObjectPropID.FontSize:
return this.titleFontSize;
case EObjectPropID.Selected:
return this.selected;
default:
return super.getProp(index);
}
}
public setProp(index: number, value: any): void {
switch (index) {
case EObjectPropID.Color:
this.titleColor = value;
break;
case EObjectPropID.OutlineColor:
const tf = this.getTextField();
if (tf) {
tf.strokeColor = value;
}
break;
case EObjectPropID.FontSize:
this.titleFontSize = value;
break;
case EObjectPropID.Selected:
this.selected = value;
break;
default:
super.setProp(index, value);
break;
}
}
protected constructExtension(buffer: ByteBuffer): void {
buffer.seek(0, 6);
this._mode = buffer.readByte();
const str = buffer.readS();
if (str) {
this._sound = str;
}
this._soundVolumeScale = buffer.getFloat32();
this._downEffect = buffer.readByte();
this._downEffectValue = buffer.getFloat32();
if (this._downEffect === 2) {
this.setPivot(0.5, 0.5, this.pivotAsAnchor);
}
this._buttonController = this.getController('button');
this._titleObject = this.getChild('title');
this._iconObject = this.getChild('icon');
if (this._titleObject) {
this._title = this._titleObject.text || '';
}
if (this._iconObject) {
this._icon = this._iconObject.icon || '';
}
if (this._mode === EButtonMode.Common) {
this.setState(GButton.UP);
}
this.on(FGUIEvents.ROLL_OVER, this.handleRollOver, this);
this.on(FGUIEvents.ROLL_OUT, this.handleRollOut, this);
this.on(FGUIEvents.TOUCH_BEGIN, this.handleTouchBegin, this);
this.on(FGUIEvents.CLICK, this.handleClick, this);
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
if (!buffer.seek(beginPos, 6)) {
return;
}
if (buffer.readByte() !== this.packageItem?.objectType) {
return;
}
let str = buffer.readS();
if (str) {
this.title = str;
}
str = buffer.readS();
if (str) {
this.selectedTitle = str;
}
str = buffer.readS();
if (str) {
this.icon = str;
}
str = buffer.readS();
if (str) {
this.selectedIcon = str;
}
if (buffer.readBool()) {
this.titleColor = buffer.readS();
}
const iv = buffer.getInt32();
if (iv !== 0) {
this.titleFontSize = iv;
}
const controllerIndex = buffer.getInt16();
if (controllerIndex >= 0 && this.parent) {
this._relatedController = this.parent.getControllerAt(controllerIndex);
}
this._relatedPageId = buffer.readS();
str = buffer.readS();
if (str) {
this._sound = str;
}
if (buffer.readBool()) {
this._soundVolumeScale = buffer.getFloat32();
}
this.selected = buffer.readBool();
}
private handleRollOver(): void {
if (
!this._buttonController ||
!this._buttonController.hasPage(GButton.OVER)
) {
return;
}
this._over = true;
if (this._down) {
return;
}
if (this.grayed && this._buttonController.hasPage(GButton.DISABLED)) {
return;
}
this.setState(this._selected ? GButton.SELECTED_OVER : GButton.OVER);
}
private handleRollOut(): void {
if (
!this._buttonController ||
!this._buttonController.hasPage(GButton.OVER)
) {
return;
}
this._over = false;
if (this._down) {
return;
}
if (this.grayed && this._buttonController.hasPage(GButton.DISABLED)) {
return;
}
this.setState(this._selected ? GButton.DOWN : GButton.UP);
}
private handleTouchBegin(): void {
this._down = true;
if (this._mode === EButtonMode.Common) {
if (
this.grayed &&
this._buttonController &&
this._buttonController.hasPage(GButton.DISABLED)
) {
this.setState(GButton.SELECTED_DISABLED);
} else {
this.setState(GButton.DOWN);
}
}
// Listen for touch end globally
this.root?.on(FGUIEvents.TOUCH_END, this.handleTouchEnd, this);
}
private handleTouchEnd(): void {
if (this._down) {
this.root?.off(FGUIEvents.TOUCH_END, this.handleTouchEnd, this);
this._down = false;
if (!this._displayObject) {
return;
}
if (this._mode === EButtonMode.Common) {
if (
this.grayed &&
this._buttonController &&
this._buttonController.hasPage(GButton.DISABLED)
) {
this.setState(GButton.DISABLED);
} else if (this._over) {
this.setState(GButton.OVER);
} else {
this.setState(GButton.UP);
}
}
}
}
private handleClick(): void {
if (this._mode === EButtonMode.Check) {
if (this._changeStateOnClick) {
this.selected = !this._selected;
this.emit(FGUIEvents.STATE_CHANGED);
}
} else if (this._mode === EButtonMode.Radio) {
if (this._changeStateOnClick && !this._selected) {
this.selected = true;
this.emit(FGUIEvents.STATE_CHANGED);
}
} else {
if (this._relatedController) {
this._relatedController.selectedPageId = this._relatedPageId;
}
}
}
}

View File

@@ -0,0 +1,330 @@
import { GObject } from '../core/GObject';
import { Graph } from '../display/Graph';
import { EGraphType, EObjectPropID } from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* GGraph - FairyGUI 图形显示对象
*
* Supports rect, ellipse, polygon, and regular polygon shapes.
* 支持矩形、椭圆、多边形和正多边形
*/
export class GGraph extends GObject {
private _graph!: Graph;
private _type: EGraphType = EGraphType.Empty;
private _lineSize: number = 1;
private _lineColor: string = '#000000';
private _fillColor: string = '#FFFFFF';
private _cornerRadius: number[] | null = null;
private _sides: number = 3;
private _startAngle: number = 0;
private _polygonPoints: number[] | null = null;
private _distances: number[] | null = null;
constructor() {
super();
this.ensureGraph();
}
private ensureGraph(): void {
if (!this._graph) {
this.createDisplayObject();
}
}
protected createDisplayObject(): void {
this._displayObject = this._graph = new Graph();
this._graph.touchable = false;
this._displayObject.gOwner = this;
}
public get type(): EGraphType {
return this._type;
}
public get polygonPoints(): number[] | null {
return this._polygonPoints;
}
public get fillColor(): string {
return this._fillColor;
}
public set fillColor(value: string) {
if (value === this._fillColor) return;
this._fillColor = value;
this.updateGraph();
}
public get lineColor(): string {
return this._lineColor;
}
public set lineColor(value: string) {
if (value === this._lineColor) return;
this._lineColor = value;
this.updateGraph();
}
public get color(): string {
return this._fillColor;
}
public set color(value: string) {
this._fillColor = value;
this.updateGear(4);
if (this._type !== EGraphType.Empty) {
this.updateGraph();
}
}
public get distances(): number[] | null {
return this._distances;
}
public set distances(value: number[] | null) {
this._distances = value;
if (this._type === EGraphType.RegularPolygon) {
this.updateGraph();
}
}
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;
this.updateGraph();
}
public drawEllipse(lineSize: number, lineColor: string, fillColor: string): void {
this._type = EGraphType.Ellipse;
this._lineSize = lineSize;
this._lineColor = lineColor;
this._fillColor = fillColor;
this.updateGraph();
}
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;
this.updateGraph();
}
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;
this.updateGraph();
}
private updateGraph(): void {
this.ensureGraph();
if (!this._graph) return;
this._graph.touchable = this.touchable;
const w = this.width;
const h = this.height;
if (w === 0 || h === 0) {
this._graph.clear();
return;
}
switch (this._type) {
case EGraphType.Rect:
this._graph.drawRect(
this._lineSize,
this._lineColor,
this._fillColor,
this._cornerRadius || undefined
);
break;
case EGraphType.Ellipse:
this._graph.drawEllipse(this._lineSize, this._lineColor, this._fillColor);
break;
case EGraphType.Polygon:
if (this._polygonPoints) {
this._graph.drawPolygon(
this._lineSize,
this._lineColor,
this._fillColor,
this._polygonPoints
);
}
break;
case EGraphType.RegularPolygon:
this.generateRegularPolygonPoints();
if (this._polygonPoints) {
this._graph.drawPolygon(
this._lineSize,
this._lineColor,
this._fillColor,
this._polygonPoints
);
}
break;
default:
this._graph.clear();
break;
}
this._graph.width = w;
this._graph.height = h;
}
private generateRegularPolygonPoints(): void {
const radius = Math.min(this._width, this._height) / 2;
this._polygonPoints = [];
const angle = (this._startAngle * Math.PI) / 180;
const deltaAngle = (2 * Math.PI) / this._sides;
for (let i = 0; i < this._sides; i++) {
let dist = 1;
if (this._distances && this._distances[i] !== undefined) {
dist = this._distances[i];
if (isNaN(dist)) dist = 1;
}
const xv = radius + radius * dist * Math.cos(angle + deltaAngle * i);
const yv = radius + radius * dist * Math.sin(angle + deltaAngle * i);
this._polygonPoints.push(xv, yv);
}
}
public replaceMe(target: GObject): void {
if (!this._parent) {
throw new Error('parent not set');
}
target.name = this.name;
target.alpha = this.alpha;
target.rotation = this.rotation;
target.visible = this.visible;
target.touchable = this.touchable;
target.grayed = this.grayed;
target.setXY(this.x, this.y);
target.setSize(this.width, this.height);
const index = this._parent.getChildIndex(this);
this._parent.addChildAt(target, index);
target.relations.copyFrom(this.relations);
this._parent.removeChild(this, true);
}
public addBeforeMe(target: GObject): void {
if (!this._parent) {
throw new Error('parent not set');
}
const index = this._parent.getChildIndex(this);
this._parent.addChildAt(target, index);
}
/**
* Add target after this object
* 在此对象后添加目标
*/
public addAfterMe(target: GObject): void {
if (!this._parent) {
throw new Error('parent not set');
}
const index = this._parent.getChildIndex(this);
this._parent.addChildAt(target, index + 1);
}
public getProp(index: number): any {
if (index === EObjectPropID.Color) {
return this.color;
}
return super.getProp(index);
}
public setProp(index: number, value: any): void {
if (index === EObjectPropID.Color) {
this.color = value;
} else {
super.setProp(index, value);
}
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
if (this._type !== EGraphType.Empty) {
this.updateGraph();
}
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
this._type = buffer.readByte();
if (this._type !== EGraphType.Empty) {
this._lineSize = buffer.getInt32();
this._lineColor = buffer.readColorS(true);
this._fillColor = buffer.readColorS(true);
if (buffer.readBool()) {
this._cornerRadius = [];
for (let i = 0; i < 4; i++) {
this._cornerRadius[i] = buffer.getFloat32();
}
}
if (this._type === EGraphType.Polygon) {
const cnt = buffer.getInt16();
this._polygonPoints = [];
for (let i = 0; i < cnt; i++) {
this._polygonPoints[i] = buffer.getFloat32();
}
} else if (this._type === EGraphType.RegularPolygon) {
this._sides = buffer.getInt16();
this._startAngle = buffer.getFloat32();
const cnt = buffer.getInt16();
if (cnt > 0) {
this._distances = [];
for (let i = 0; i < cnt; i++) {
this._distances[i] = buffer.getFloat32();
}
}
}
}
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
if (this._type !== EGraphType.Empty) {
this.updateGraph();
}
}
}

View File

@@ -0,0 +1,232 @@
import { GObject } from '../core/GObject';
import { Image } from '../display/Image';
import { Rectangle } from '../utils/MathTypes';
import { EFlipType, EFillMethod, EObjectPropID } from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
import type { PackageItem } from '../package/PackageItem';
/**
* GImage
*
* Image display object for FairyGUI.
*
* FairyGUI 图像显示对象
*/
export class GImage extends GObject {
private _image!: Image;
private _flip: EFlipType = EFlipType.None;
private _contentItem: PackageItem | null = null;
constructor() {
super();
// Ensure _image is initialized - super() calls createDisplayObject() but
// class field initializers run after super(), which may cause issues
this.ensureImage();
}
private ensureImage(): void {
if (!this._image) {
this.createDisplayObject();
}
}
/**
* Get the internal image display object
* 获取内部图像显示对象
*/
public get image(): Image {
return this._image;
}
/**
* Get/set color tint
* 获取/设置颜色着色
*/
public get color(): string {
return this._image.color;
}
public set color(value: string) {
if (this._image && this._image.color !== value) {
this._image.color = value;
this.updateGear(4);
}
}
/**
* Get/set flip type
* 获取/设置翻转类型
*/
public get flip(): EFlipType {
return this._flip;
}
public set flip(value: EFlipType) {
if (this._flip !== value) {
this._flip = value;
let sx = 1;
let sy = 1;
if (this._flip === EFlipType.Horizontal || this._flip === EFlipType.Both) {
sx = -1;
}
if (this._flip === EFlipType.Vertical || this._flip === EFlipType.Both) {
sy = -1;
}
this.setScale(sx, sy);
this.handleXYChanged();
}
}
/**
* Get/set fill method
* 获取/设置填充方法
*/
public get fillMethod(): EFillMethod {
return this._image.fillMethod;
}
public set fillMethod(value: EFillMethod) {
if (this._image) {
this._image.fillMethod = value;
}
}
/**
* Get/set fill origin
* 获取/设置填充起点
*/
public get fillOrigin(): number {
return this._image.fillOrigin;
}
public set fillOrigin(value: number) {
if (this._image) {
this._image.fillOrigin = value;
}
}
/**
* Get/set fill clockwise
* 获取/设置填充顺时针方向
*/
public get fillClockwise(): boolean {
return this._image.fillClockwise;
}
public set fillClockwise(value: boolean) {
if (this._image) {
this._image.fillClockwise = value;
}
}
/**
* Get/set fill amount (0-1)
* 获取/设置填充量0-1
*/
public get fillAmount(): number {
return this._image.fillAmount;
}
public set fillAmount(value: number) {
if (this._image) {
this._image.fillAmount = value;
}
}
protected createDisplayObject(): void {
this._displayObject = this._image = new Image();
this._image.touchable = false;
this._displayObject.gOwner = this;
}
/**
* Construct from package resource
* 从包资源构建
*/
public constructFromResource(): void {
if (!this.packageItem) return;
this.ensureImage();
this._contentItem = this.packageItem;
this.sourceWidth = this._contentItem.width;
this.sourceHeight = this._contentItem.height;
this.initWidth = this.sourceWidth;
this.initHeight = this.sourceHeight;
this._image.scale9Grid = this._contentItem.scale9Grid
? new Rectangle(
this._contentItem.scale9Grid.x,
this._contentItem.scale9Grid.y,
this._contentItem.scale9Grid.width,
this._contentItem.scale9Grid.height
)
: null;
this._image.scaleByTile = this._contentItem.scaleByTile;
this._image.tileGridIndice = this._contentItem.tileGridIndice;
// Load texture from package (this decodes the sprite info)
if (this._contentItem.owner) {
this._contentItem.owner.getItemAsset(this._contentItem);
}
this._image.texture = this._contentItem.texture;
this.setSize(this.sourceWidth, this.sourceHeight);
}
protected handleXYChanged(): void {
super.handleXYChanged();
if (this._flip !== EFlipType.None) {
if (this.scaleX === -1 && this._image) {
this._image.x += this.width;
}
if (this.scaleY === -1 && this._image) {
this._image.y += this.height;
}
}
}
public getProp(index: number): any {
if (index === EObjectPropID.Color) {
return this.color;
}
return super.getProp(index);
}
public setProp(index: number, value: any): void {
if (index === EObjectPropID.Color) {
this.color = value;
} else {
super.setProp(index, value);
}
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
if (buffer.readBool()) {
this.color = buffer.readS();
}
this.flip = buffer.readByte();
const fillMethodValue = buffer.readByte();
if (this._image) {
this._image.fillMethod = fillMethodValue;
if (this._image.fillMethod !== EFillMethod.None) {
this._image.fillOrigin = buffer.readByte();
this._image.fillClockwise = buffer.readBool();
this._image.fillAmount = buffer.getFloat32();
}
} else if (fillMethodValue !== EFillMethod.None) {
// Skip bytes if _image not ready
buffer.readByte(); // fillOrigin
buffer.readBool(); // fillClockwise
buffer.getFloat32(); // fillAmount
}
}
}

View File

@@ -0,0 +1,750 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { GObjectPool } from '../core/GObjectPool';
import { GButton } from './GButton';
import { Controller } from '../core/Controller';
import { UIPackage } from '../package/UIPackage';
import { FGUIEvents } from '../events/Events';
import { Point, Margin } from '../utils/MathTypes';
import {
EListLayoutType,
EListSelectionMode,
EChildrenRenderOrder,
EOverflowType,
EAlignType,
EVertAlignType
} from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* Item renderer callback
* 项渲染回调
*/
export type ItemRenderer = (index: number, item: GObject) => void;
/**
* Item provider callback
* 项提供者回调
*/
export type ItemProvider = (index: number) => string;
/**
* GList
*
* Scrollable list component with item pooling support.
*
* 带有项池化支持的可滚动列表组件
*
* Features:
* - Multiple layout modes (single column/row, flow, pagination)
* - Item selection (single, multiple)
* - Object pooling for performance
*/
export class GList extends GComponent {
/** Item renderer callback | 项渲染回调 */
public itemRenderer: ItemRenderer | null = null;
/** Item provider callback | 项提供者回调 */
public itemProvider: ItemProvider | null = null;
/** Scroll item to view on click | 点击时滚动项到视图 */
public scrollItemToViewOnClick: boolean = true;
/** Fold invisible items | 折叠不可见项 */
public foldInvisibleItems: boolean = false;
private _layout: EListLayoutType = EListLayoutType.SingleColumn;
private _lineCount: number = 0;
private _columnCount: number = 0;
private _lineGap: number = 0;
private _columnGap: number = 0;
private _defaultItem: string = '';
private _autoResizeItem: boolean = true;
private _selectionMode: EListSelectionMode = EListSelectionMode.Single;
private _align: EAlignType = EAlignType.Left;
private _verticalAlign: EVertAlignType = EVertAlignType.Top;
private _selectionController: Controller | null = null;
private _lastSelectedIndex: number = -1;
private _pool: GObjectPool;
private _listMargin: Margin = new Margin();
constructor() {
super();
this._pool = new GObjectPool();
this._trackBounds = true;
}
public dispose(): void {
this._pool.clear();
super.dispose();
}
// Layout properties
public get layout(): EListLayoutType {
return this._layout;
}
public set layout(value: EListLayoutType) {
if (this._layout !== value) {
this._layout = value;
this.setBoundsChangedFlag();
}
}
public get lineCount(): number {
return this._lineCount;
}
public set lineCount(value: number) {
if (this._lineCount !== value) {
this._lineCount = value;
this.setBoundsChangedFlag();
}
}
public get columnCount(): number {
return this._columnCount;
}
public set columnCount(value: number) {
if (this._columnCount !== value) {
this._columnCount = value;
this.setBoundsChangedFlag();
}
}
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 align(): EAlignType {
return this._align;
}
public set align(value: EAlignType) {
if (this._align !== value) {
this._align = value;
this.setBoundsChangedFlag();
}
}
public get verticalAlign(): EVertAlignType {
return this._verticalAlign;
}
public set verticalAlign(value: EVertAlignType) {
if (this._verticalAlign !== value) {
this._verticalAlign = value;
this.setBoundsChangedFlag();
}
}
public get defaultItem(): string {
return this._defaultItem;
}
public set defaultItem(value: string) {
this._defaultItem = UIPackage.normalizeURL(value);
}
public get autoResizeItem(): boolean {
return this._autoResizeItem;
}
public set autoResizeItem(value: boolean) {
if (this._autoResizeItem !== value) {
this._autoResizeItem = value;
this.setBoundsChangedFlag();
}
}
public get selectionMode(): EListSelectionMode {
return this._selectionMode;
}
public set selectionMode(value: EListSelectionMode) {
this._selectionMode = value;
}
public get selectionController(): Controller | null {
return this._selectionController;
}
public set selectionController(value: Controller | null) {
this._selectionController = value;
}
public get itemPool(): GObjectPool {
return this._pool;
}
// Item pool operations
public getFromPool(url?: string): GObject | null {
if (!url) {
url = this._defaultItem;
}
const obj = this._pool.getObject(url);
if (obj) {
obj.visible = true;
}
return obj;
}
public returnToPool(obj: GObject): void {
this._pool.returnObject(obj);
}
// Item operations
public addChildAt(child: GObject, index: number): GObject {
super.addChildAt(child, index);
if (child instanceof GButton) {
child.selected = false;
child.changeStateOnClick = false;
}
child.on(FGUIEvents.CLICK, this._onClickItem, this);
return child;
}
public addItem(url?: string): GObject | null {
if (!url) {
url = this._defaultItem;
}
const obj = UIPackage.createObjectFromURL(url);
if (obj) {
return this.addChild(obj);
}
return null;
}
public addItemFromPool(url?: string): GObject | null {
const obj = this.getFromPool(url);
if (obj) {
return this.addChild(obj);
}
return null;
}
public removeChildAt(index: number, bDispose?: boolean): GObject {
const child = super.removeChildAt(index, bDispose);
if (!bDispose) {
child.off(FGUIEvents.CLICK, this._onClickItem, this);
}
return child;
}
public removeChildToPoolAt(index: number): void {
const child = super.removeChildAt(index);
this.returnToPool(child);
}
public removeChildToPool(child: GObject): void {
super.removeChild(child);
this.returnToPool(child);
}
public removeChildrenToPool(beginIndex: number = 0, endIndex: number = -1): void {
if (endIndex < 0 || endIndex >= this.numChildren) {
endIndex = this.numChildren - 1;
}
for (let i = beginIndex; i <= endIndex; ++i) {
this.removeChildToPoolAt(beginIndex);
}
}
// Selection
public get selectedIndex(): number {
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton && obj.selected) {
return i;
}
}
return -1;
}
public set selectedIndex(value: number) {
if (value >= 0 && value < this.numChildren) {
if (this._selectionMode !== EListSelectionMode.Single) {
this.clearSelection();
}
this.addSelection(value);
} else {
this.clearSelection();
}
}
public getSelection(result?: number[]): number[] {
if (!result) {
result = [];
}
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton && obj.selected) {
result.push(i);
}
}
return result;
}
public addSelection(index: number, bScrollItToView?: boolean): void {
if (this._selectionMode === EListSelectionMode.None) {
return;
}
if (this._selectionMode === EListSelectionMode.Single) {
this.clearSelection();
}
if (bScrollItToView) {
this.scrollToView(index);
}
this._lastSelectedIndex = index;
const obj = this.getChildAt(index);
if (obj instanceof GButton && !obj.selected) {
obj.selected = true;
this.updateSelectionController(index);
}
}
public removeSelection(index: number): void {
if (this._selectionMode === EListSelectionMode.None) {
return;
}
const obj = this.getChildAt(index);
if (obj instanceof GButton) {
obj.selected = false;
}
}
public clearSelection(): void {
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton) {
obj.selected = false;
}
}
}
public selectAll(): void {
let last = -1;
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton && !obj.selected) {
obj.selected = true;
last = i;
}
}
if (last !== -1) {
this.updateSelectionController(last);
}
}
public selectNone(): void {
this.clearSelection();
}
public selectReverse(): void {
let last = -1;
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton) {
obj.selected = !obj.selected;
if (obj.selected) {
last = i;
}
}
}
if (last !== -1) {
this.updateSelectionController(last);
}
}
// Scroll
public scrollToView(index: number, bAni?: boolean, bSetFirst?: boolean): void {
const obj = this.getChildAt(index);
if (obj && this._scrollPane) {
this._scrollPane.scrollToView(obj, bAni, bSetFirst);
}
}
// Item count
public get numItems(): number {
return this.numChildren;
}
public set numItems(value: number) {
const cnt = this.numChildren;
if (value > cnt) {
for (let i = cnt; i < value; i++) {
if (this.itemProvider) {
this.addItemFromPool(this.itemProvider(i));
} else {
this.addItemFromPool();
}
}
} else {
this.removeChildrenToPool(value, cnt);
}
if (this.itemRenderer) {
for (let i = 0; i < value; i++) {
const child = this.getChildAt(i);
if (child) {
this.itemRenderer(i, child);
}
}
}
}
// Size
public resizeToFit(itemCount?: number, minSize?: number): void {
if (itemCount == null) itemCount = 100000;
if (minSize == null) minSize = 0;
this.ensureBoundsCorrect();
let curCount = this.numItems;
if (itemCount > curCount) {
itemCount = curCount;
}
if (itemCount === 0) {
if (this._layout === EListLayoutType.SingleColumn ||
this._layout === EListLayoutType.FlowHorizontal) {
this.viewHeight = minSize;
} else {
this.viewWidth = minSize;
}
} else {
let i = itemCount - 1;
let obj: GObject | null = null;
while (i >= 0) {
obj = this.getChildAt(i);
if (!this.foldInvisibleItems || obj?.visible) {
break;
}
i--;
}
if (i < 0 || !obj) {
if (this._layout === EListLayoutType.SingleColumn ||
this._layout === EListLayoutType.FlowHorizontal) {
this.viewHeight = minSize;
} else {
this.viewWidth = minSize;
}
} else {
let size = 0;
if (this._layout === EListLayoutType.SingleColumn ||
this._layout === EListLayoutType.FlowHorizontal) {
size = obj.y + obj.height;
if (size < minSize) size = minSize;
this.viewHeight = size;
} else {
size = obj.x + obj.width;
if (size < minSize) size = minSize;
this.viewWidth = size;
}
}
}
}
public getMaxItemWidth(): number {
const cnt = this.numChildren;
let max = 0;
for (let i = 0; i < cnt; i++) {
const child = this.getChildAt(i);
if (child && child.width > max) {
max = child.width;
}
}
return max;
}
// View size helpers
public get viewWidth(): number {
if (this._scrollPane) {
return this._scrollPane.viewWidth;
}
return this.width - this._listMargin.left - this._listMargin.right;
}
public set viewWidth(value: number) {
if (this._scrollPane) {
// Adjust component width
}
this.width = value + this._listMargin.left + this._listMargin.right;
}
public get viewHeight(): number {
if (this._scrollPane) {
return this._scrollPane.viewHeight;
}
return this.height - this._listMargin.top - this._listMargin.bottom;
}
public set viewHeight(value: number) {
if (this._scrollPane) {
// Adjust component height
}
this.height = value + this._listMargin.top + this._listMargin.bottom;
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
this.setBoundsChangedFlag();
}
public handleControllerChanged(c: Controller): void {
super.handleControllerChanged(c);
if (this._selectionController === c) {
this.selectedIndex = c.selectedIndex;
}
}
// Event handlers
private _onClickItem(item: GObject): void {
if (this._scrollPane && this._scrollPane.isDragged) {
return;
}
this.setSelectionOnEvent(item);
if (this._scrollPane && this.scrollItemToViewOnClick) {
this._scrollPane.scrollToView(item, true);
}
this.emit(FGUIEvents.CLICK_ITEM, item);
}
private setSelectionOnEvent(item: GObject): void {
if (!(item instanceof GButton) || this._selectionMode === EListSelectionMode.None) {
return;
}
const index = this.getChildIndex(item);
if (this._selectionMode === EListSelectionMode.Single) {
if (!item.selected) {
this.clearSelectionExcept(item);
item.selected = true;
}
} else {
if (this._selectionMode === EListSelectionMode.MultipleSingleClick) {
item.selected = !item.selected;
} else {
if (!item.selected) {
this.clearSelectionExcept(item);
item.selected = true;
} else {
this.clearSelectionExcept(item);
}
}
}
this._lastSelectedIndex = index;
if (item.selected) {
this.updateSelectionController(index);
}
}
private clearSelectionExcept(g: GObject): void {
const cnt = this.numChildren;
for (let i = 0; i < cnt; i++) {
const obj = this.getChildAt(i);
if (obj instanceof GButton && obj !== g) {
obj.selected = false;
}
}
}
private updateSelectionController(index: number): void {
if (this._selectionController && !this._selectionController.changing &&
index < this._selectionController.pageCount) {
const c = this._selectionController;
this._selectionController = null;
c.selectedIndex = index;
this._selectionController = c;
}
}
// Setup from buffer
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
this._layout = buffer.readByte();
this._selectionMode = buffer.readByte();
const i1 = buffer.readByte();
this._align = i1 === 0 ? EAlignType.Left : (i1 === 1 ? EAlignType.Center : EAlignType.Right);
const i2 = buffer.readByte();
this._verticalAlign = i2 === 0 ? EVertAlignType.Top : (i2 === 1 ? EVertAlignType.Middle : EVertAlignType.Bottom);
this._lineGap = buffer.getInt16();
this._columnGap = buffer.getInt16();
this._lineCount = buffer.getInt16();
this._columnCount = buffer.getInt16();
this._autoResizeItem = buffer.readBool();
this._childrenRenderOrder = buffer.readByte() as EChildrenRenderOrder;
this._apexIndex = buffer.getInt16();
if (buffer.readBool()) {
this._listMargin.top = buffer.getInt32();
this._listMargin.bottom = buffer.getInt32();
this._listMargin.left = buffer.getInt32();
this._listMargin.right = buffer.getInt32();
}
const overflow = buffer.readByte() as EOverflowType;
if (overflow === EOverflowType.Scroll) {
const savedPos = buffer.position;
buffer.seek(beginPos, 7);
this.setupScroll(buffer);
buffer.position = savedPos;
} else {
this.setupOverflow(overflow);
}
if (buffer.readBool()) {
buffer.skip(8); // clipSoftness
}
if (buffer.version >= 2) {
this.scrollItemToViewOnClick = buffer.readBool();
this.foldInvisibleItems = buffer.readBool();
}
buffer.seek(beginPos, 8);
this._defaultItem = buffer.readS();
this.readItems(buffer);
}
protected readItems(buffer: ByteBuffer): void {
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
const nextPos = buffer.getInt16() + buffer.position;
let str = buffer.readS();
if (!str) {
str = this._defaultItem;
if (!str) {
buffer.position = nextPos;
continue;
}
}
const obj = this.getFromPool(str);
if (obj) {
this.addChild(obj);
this.setupItem(buffer, obj);
}
buffer.position = nextPos;
}
}
protected setupItem(buffer: ByteBuffer, obj: GObject): void {
let str = buffer.readS();
if (str) {
obj.text = str;
}
str = buffer.readS();
if (str && obj instanceof GButton) {
obj.selectedTitle = str;
}
str = buffer.readS();
if (str) {
obj.icon = str;
}
str = buffer.readS();
if (str && obj instanceof GButton) {
obj.selectedIcon = str;
}
str = buffer.readS();
if (str) {
obj.name = str;
}
if (obj instanceof GComponent) {
const cnt = buffer.getInt16();
for (let i = 0; i < cnt; i++) {
const cc = obj.getController(buffer.readS());
const pageId = buffer.readS();
if (cc) {
cc.selectedPageId = pageId;
}
}
if (buffer.version >= 2) {
const cnt2 = buffer.getInt16();
for (let i = 0; i < cnt2; i++) {
const target = buffer.readS();
const propertyId = buffer.getInt16();
const value = buffer.readS();
const obj2 = obj.getChildByPath(target);
if (obj2) {
obj2.setProp(propertyId, value);
}
}
}
}
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
buffer.seek(beginPos, 6);
const i = buffer.getInt16();
if (i !== -1 && this._parent) {
this._selectionController = this._parent.getControllerAt(i);
}
}
}

View File

@@ -0,0 +1,741 @@
import { GObject } from '../core/GObject';
import { GComponent } from '../core/GComponent';
import { GObjectPool } from '../core/GObjectPool';
import { MovieClip, type IFrame } from '../display/MovieClip';
import { Container } from '../display/Container';
import type { ISpriteTexture } from '../display/Image';
import { UIPackage } from '../package/UIPackage';
import { getUIConfig } from '../core/UIConfig';
import {
ELoaderFillType,
EAlignType,
EVertAlignType,
EPackageItemType,
EObjectPropID,
EFillMethod
} from '../core/FieldTypes';
import { Rectangle } from '../utils/MathTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
import type { PackageItem } from '../package/PackageItem';
/**
* GLoader
*
* Content loader component for loading images, movie clips, and components.
* Supports various fill modes, alignment, and automatic sizing.
*
* 内容加载器组件,用于加载图像、动画和组件
* 支持多种填充模式、对齐方式和自动尺寸
*
* Features:
* - Load images from package or external URL
* - Load movie clips (animations)
* - Load components as content
* - Multiple fill modes (none, scale, fit, etc.)
* - Alignment control
* - Error sign display
*/
export class GLoader extends GObject {
private _url: string = '';
private _align: EAlignType = EAlignType.Left;
private _valign: EVertAlignType = EVertAlignType.Top;
private _autoSize: boolean = false;
private _fill: ELoaderFillType = ELoaderFillType.None;
private _shrinkOnly: boolean = false;
private _useResize: boolean = false;
private _showErrorSign: boolean = true;
private _contentItem: PackageItem | null = null;
private _content!: MovieClip;
private _errorSign: GObject | null = null;
private _content2: GComponent | null = null;
private _updatingLayout: boolean = false;
private static _errorSignPool: GObjectPool = new GObjectPool();
constructor() {
super();
}
protected createDisplayObject(): void {
this._displayObject = new Container();
this._displayObject.gOwner = this;
this._displayObject.touchable = true;
this._content = new MovieClip();
this._displayObject.addChild(this._content);
}
public dispose(): void {
if (!this._contentItem && this._content?.texture) {
this.freeExternal(this._content.texture);
}
if (this._content2) {
this._content2.dispose();
this._content2 = null;
}
super.dispose();
}
/**
* Get/set resource URL
* 获取/设置资源 URL
*/
public get url(): string {
return this._url;
}
public set url(value: string) {
if (this._url === value) return;
this._url = value;
this.loadContent();
this.updateGear(7);
}
/**
* Icon alias for url
* URL 的图标别名
*/
public get icon(): string {
return this._url;
}
public set icon(value: string) {
this.url = value;
}
/**
* Get/set horizontal alignment
* 获取/设置水平对齐
*/
public get align(): EAlignType {
return this._align;
}
public set align(value: EAlignType) {
if (this._align !== value) {
this._align = value;
this.updateLayout();
}
}
/**
* Get/set vertical alignment
* 获取/设置垂直对齐
*/
public get verticalAlign(): EVertAlignType {
return this._valign;
}
public set verticalAlign(value: EVertAlignType) {
if (this._valign !== value) {
this._valign = value;
this.updateLayout();
}
}
/**
* Get/set fill type
* 获取/设置填充类型
*/
public get fill(): ELoaderFillType {
return this._fill;
}
public set fill(value: ELoaderFillType) {
if (this._fill !== value) {
this._fill = value;
this.updateLayout();
}
}
/**
* Get/set shrink only mode
* 获取/设置仅缩小模式
*/
public get shrinkOnly(): boolean {
return this._shrinkOnly;
}
public set shrinkOnly(value: boolean) {
if (this._shrinkOnly !== value) {
this._shrinkOnly = value;
this.updateLayout();
}
}
/**
* Get/set use resize mode
* 获取/设置使用 resize 模式
*/
public get useResize(): boolean {
return this._useResize;
}
public set useResize(value: boolean) {
if (this._useResize !== value) {
this._useResize = value;
this.updateLayout();
}
}
/**
* Get/set auto size mode
* 获取/设置自动尺寸模式
*/
public get autoSize(): boolean {
return this._autoSize;
}
public set autoSize(value: boolean) {
if (this._autoSize !== value) {
this._autoSize = value;
this.updateLayout();
}
}
/**
* Get/set playing state (for movie clips)
* 获取/设置播放状态(用于动画)
*/
public get playing(): boolean {
return this._content?.playing ?? false;
}
public set playing(value: boolean) {
if (this._content && this._content.playing !== value) {
this._content.playing = value;
this.updateGear(5);
}
}
/**
* Get/set current frame (for movie clips)
* 获取/设置当前帧(用于动画)
*/
public get frame(): number {
return this._content?.frame ?? 0;
}
public set frame(value: number) {
if (this._content && this._content.frame !== value) {
this._content.frame = value;
this.updateGear(5);
}
}
/**
* Get/set color tint
* 获取/设置颜色着色
*/
public get color(): string {
return this._content?.color ?? '#FFFFFF';
}
public set color(value: string) {
if (this._content && this._content.color !== value) {
this._content.color = value;
this.updateGear(4);
}
}
/**
* Get/set fill method
* 获取/设置填充方法
*/
public get fillMethod(): EFillMethod {
return this._content?.fillMethod ?? EFillMethod.None;
}
public set fillMethod(value: EFillMethod) {
if (this._content) {
this._content.fillMethod = value;
}
}
/**
* Get/set fill origin
* 获取/设置填充起点
*/
public get fillOrigin(): number {
return this._content?.fillOrigin ?? 0;
}
public set fillOrigin(value: number) {
if (this._content) {
this._content.fillOrigin = value;
}
}
/**
* Get/set fill clockwise
* 获取/设置顺时针填充
*/
public get fillClockwise(): boolean {
return this._content?.fillClockwise ?? true;
}
public set fillClockwise(value: boolean) {
if (this._content) {
this._content.fillClockwise = value;
}
}
/**
* Get/set fill amount
* 获取/设置填充量
*/
public get fillAmount(): number {
return this._content?.fillAmount ?? 1;
}
public set fillAmount(value: number) {
if (this._content) {
this._content.fillAmount = value;
}
}
/**
* Get/set show error sign
* 获取/设置显示错误标志
*/
public get showErrorSign(): boolean {
return this._showErrorSign;
}
public set showErrorSign(value: boolean) {
this._showErrorSign = value;
}
/**
* Get internal content (MovieClip)
* 获取内部内容MovieClip
*/
public get content(): MovieClip {
return this._content;
}
/**
* Get component content (when loading component)
* 获取组件内容(当加载组件时)
*/
public get component(): GComponent | null {
return this._content2;
}
/**
* Load content based on URL
* 根据 URL 加载内容
*/
protected loadContent(): void {
this.clearContent();
if (!this._url) return;
if (this._url.startsWith('ui://')) {
this.loadFromPackage(this._url);
} else {
this.loadExternal();
}
}
/**
* Load content from package
* 从包加载内容
*/
protected loadFromPackage(itemURL: string): void {
this._contentItem = UIPackage.getItemByURL(itemURL);
if (this._contentItem) {
// Get branch and high resolution versions
const branchItem = this._contentItem.getBranch();
this.sourceWidth = branchItem.width;
this.sourceHeight = branchItem.height;
const hiResItem = branchItem.getHighResolution();
hiResItem.load();
if (this._autoSize) {
this.setSize(this.sourceWidth, this.sourceHeight);
}
if (hiResItem.type === EPackageItemType.Image) {
if (!hiResItem.texture) {
this.setErrorState();
} else {
this._content.texture = hiResItem.texture;
this._content.scale9Grid = hiResItem.scale9Grid
? new Rectangle(
hiResItem.scale9Grid.x,
hiResItem.scale9Grid.y,
hiResItem.scale9Grid.width,
hiResItem.scale9Grid.height
)
: null;
this._content.scaleByTile = hiResItem.scaleByTile || false;
this._content.tileGridIndice = hiResItem.tileGridIndice || 0;
this.sourceWidth = hiResItem.width;
this.sourceHeight = hiResItem.height;
this.updateLayout();
}
} else if (hiResItem.type === EPackageItemType.MovieClip) {
this.sourceWidth = hiResItem.width;
this.sourceHeight = hiResItem.height;
this._content.interval = hiResItem.interval || 0;
this._content.swing = hiResItem.swing || false;
this._content.repeatDelay = hiResItem.repeatDelay || 0;
this._content.frames = hiResItem.frames || [];
this.updateLayout();
} else if (hiResItem.type === EPackageItemType.Component) {
const obj = UIPackage.createObjectFromURL(itemURL);
if (!obj) {
this.setErrorState();
} else if (!(obj instanceof GComponent)) {
obj.dispose();
this.setErrorState();
} else {
this._content2 = obj;
if (this._displayObject && this._content2.displayObject) {
this._displayObject.addChild(this._content2.displayObject);
}
this.updateLayout();
}
} else {
this.setErrorState();
}
} else {
this.setErrorState();
}
}
/**
* Load external resource (to be overridden)
* 加载外部资源(可重写)
*/
protected loadExternal(): void {
// Default implementation: load image via fetch
this.loadExternalImage(this._url);
}
/**
* Load external image
* 加载外部图像
*/
protected async loadExternalImage(url: string): Promise<void> {
try {
const response = await fetch(url);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
// Create texture ID from URL
this.onExternalLoadSuccess(url, bitmap.width, bitmap.height);
} catch (error) {
console.error(`Failed to load external image: ${url}`, error);
this.onExternalLoadFailed();
}
}
/**
* Free external resource
* 释放外部资源
*/
protected freeExternal(_texture: string | number | ISpriteTexture | null): void {
// Override in subclass if needed
}
/**
* Called when external resource loaded successfully
* 外部资源加载成功时调用
*/
protected onExternalLoadSuccess(textureId: string | number, width: number, height: number): void {
this._content.texture = textureId;
this._content.scale9Grid = null;
this._content.scaleByTile = false;
this.sourceWidth = width;
this.sourceHeight = height;
this.updateLayout();
}
/**
* Called when external resource failed to load
* 外部资源加载失败时调用
*/
protected onExternalLoadFailed(): void {
this.setErrorState();
}
/**
* Set error state and show error sign
* 设置错误状态并显示错误标志
*/
private setErrorState(): void {
if (!this._showErrorSign) return;
if (!this._errorSign) {
const errorSignUrl = getUIConfig('loaderErrorSign');
if (errorSignUrl) {
this._errorSign = GLoader._errorSignPool.getObject(errorSignUrl);
}
}
if (this._errorSign) {
this._errorSign.setSize(this.width, this.height);
if (this._displayObject && this._errorSign.displayObject) {
this._displayObject.addChild(this._errorSign.displayObject);
}
}
}
/**
* Clear error state
* 清除错误状态
*/
private clearErrorState(): void {
if (this._errorSign) {
if (this._displayObject && this._errorSign.displayObject) {
this._displayObject.removeChild(this._errorSign.displayObject);
}
GLoader._errorSignPool.returnObject(this._errorSign);
this._errorSign = null;
}
}
/**
* Update content layout
* 更新内容布局
*/
protected updateLayout(): void {
if (!this._content) return;
if (!this._content2 && !this._content.texture && !this._content.frames?.length) {
if (this._autoSize) {
this._updatingLayout = true;
this.setSize(50, 30);
this._updatingLayout = false;
}
return;
}
let cw = this.sourceWidth;
let ch = this.sourceHeight;
if (this._autoSize) {
this._updatingLayout = true;
if (cw === 0) cw = 50;
if (ch === 0) ch = 30;
this.setSize(cw, ch);
this._updatingLayout = false;
if (cw === this._width && ch === this._height) {
if (this._content2) {
this._content2.setXY(0, 0);
if (this._useResize) {
this._content2.setSize(cw, ch);
} else {
this._content2.setScale(1, 1);
}
} else {
this._content.width = cw;
this._content.height = ch;
this._content.x = 0;
this._content.y = 0;
}
return;
}
}
let sx = 1;
let sy = 1;
if (this._fill !== ELoaderFillType.None) {
sx = this.width / this.sourceWidth;
sy = this.height / this.sourceHeight;
if (sx !== 1 || sy !== 1) {
if (this._fill === ELoaderFillType.ScaleMatchHeight) {
sx = sy;
} else if (this._fill === ELoaderFillType.ScaleMatchWidth) {
sy = sx;
} else if (this._fill === ELoaderFillType.Scale) {
if (sx > sy) sx = sy;
else sy = sx;
} else if (this._fill === ELoaderFillType.ScaleNoBorder) {
if (sx > sy) sy = sx;
else sx = sy;
}
if (this._shrinkOnly) {
if (sx > 1) sx = 1;
if (sy > 1) sy = 1;
}
cw = this.sourceWidth * sx;
ch = this.sourceHeight * sy;
}
}
if (this._content2) {
if (this._useResize) {
this._content2.setSize(cw, ch);
} else {
this._content2.setScale(sx, sy);
}
} else {
this._content.width = cw;
this._content.height = ch;
}
// Calculate position based on alignment
let nx = 0;
let ny = 0;
if (this._align === EAlignType.Center) {
nx = Math.floor((this.width - cw) / 2);
} else if (this._align === EAlignType.Right) {
nx = this.width - cw;
}
if (this._valign === EVertAlignType.Middle) {
ny = Math.floor((this.height - ch) / 2);
} else if (this._valign === EVertAlignType.Bottom) {
ny = this.height - ch;
}
if (this._content2) {
this._content2.setXY(nx, ny);
} else {
this._content.x = nx;
this._content.y = ny;
}
}
/**
* Clear content
* 清除内容
*/
private clearContent(): void {
this.clearErrorState();
if (this._content) {
if (!this._contentItem && this._content.texture) {
this.freeExternal(this._content.texture);
}
this._content.texture = null;
this._content.frames = [];
}
if (this._content2) {
this._content2.dispose();
this._content2 = null;
}
this._contentItem = null;
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
if (!this._updatingLayout) {
this.updateLayout();
}
}
public getProp(index: number): any {
switch (index) {
case EObjectPropID.Color:
return this.color;
case EObjectPropID.Playing:
return this.playing;
case EObjectPropID.Frame:
return this.frame;
case EObjectPropID.TimeScale:
return this._content?.timeScale ?? 1;
default:
return super.getProp(index);
}
}
public setProp(index: number, value: any): void {
switch (index) {
case EObjectPropID.Color:
this.color = value;
break;
case EObjectPropID.Playing:
this.playing = value;
break;
case EObjectPropID.Frame:
this.frame = value;
break;
case EObjectPropID.TimeScale:
if (this._content) {
this._content.timeScale = value;
}
break;
case EObjectPropID.DeltaTime:
if (this._content) {
this._content.advance(value);
}
break;
default:
super.setProp(index, value);
break;
}
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
this._url = buffer.readS();
const alignValue = buffer.readByte();
this._align =
alignValue === 0 ? EAlignType.Left : alignValue === 1 ? EAlignType.Center : EAlignType.Right;
const valignValue = buffer.readByte();
this._valign =
valignValue === 0 ? EVertAlignType.Top : valignValue === 1 ? EVertAlignType.Middle : EVertAlignType.Bottom;
this._fill = buffer.readByte();
this._shrinkOnly = buffer.readBool();
this._autoSize = buffer.readBool();
this._showErrorSign = buffer.readBool();
const playingValue = buffer.readBool();
const frameValue = buffer.getInt32();
if (this._content) {
this._content.playing = playingValue;
this._content.frame = frameValue;
}
if (buffer.readBool()) {
this.color = buffer.readS();
}
const fillMethodValue = buffer.readByte();
if (this._content) {
this._content.fillMethod = fillMethodValue;
if (this._content.fillMethod !== EFillMethod.None) {
this._content.fillOrigin = buffer.readByte();
this._content.fillClockwise = buffer.readBool();
this._content.fillAmount = buffer.getFloat32();
}
} else if (fillMethodValue !== EFillMethod.None) {
// Skip bytes if _content not ready
buffer.readByte(); // fillOrigin
buffer.readBool(); // fillClockwise
buffer.getFloat32(); // fillAmount
}
if (buffer.version >= 7) {
this._useResize = buffer.readBool();
}
if (this._url) {
this.loadContent();
}
}
}

View File

@@ -0,0 +1,261 @@
import { GObject } from '../core/GObject';
import { MovieClip, type IFrame } from '../display/MovieClip';
import { EFlipType, EObjectPropID } from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
import type { PackageItem } from '../package/PackageItem';
/**
* GMovieClip
*
* Movie clip display object for FairyGUI animations.
*
* FairyGUI 动画显示对象
*/
export class GMovieClip extends GObject {
private _movieClip!: MovieClip;
private _flip: EFlipType = EFlipType.None;
private _contentItem: PackageItem | null = null;
constructor() {
super();
this.ensureMovieClip();
}
private ensureMovieClip(): void {
if (!this._movieClip) {
this.createDisplayObject();
}
}
/**
* Get the internal movie clip display object
* 获取内部动画显示对象
*/
public get movieClip(): MovieClip {
return this._movieClip;
}
/**
* Get/set playing state
* 获取/设置播放状态
*/
public get playing(): boolean {
return this._movieClip.playing;
}
public set playing(value: boolean) {
if (this._movieClip && this._movieClip.playing !== value) {
this._movieClip.playing = value;
this.updateGear(5);
}
}
/**
* Get/set current frame
* 获取/设置当前帧
*/
public get frame(): number {
return this._movieClip.frame;
}
public set frame(value: number) {
if (this._movieClip && this._movieClip.frame !== value) {
this._movieClip.frame = value;
this.updateGear(5);
}
}
/**
* Get/set color tint
* 获取/设置颜色着色
*/
public get color(): string {
return this._movieClip.color;
}
public set color(value: string) {
if (this._movieClip) {
this._movieClip.color = value;
this.updateGear(4);
}
}
/**
* Get/set flip type
* 获取/设置翻转类型
*/
public get flip(): EFlipType {
return this._flip;
}
public set flip(value: EFlipType) {
if (this._flip !== value) {
this._flip = value;
let sx = 1;
let sy = 1;
if (this._flip === EFlipType.Horizontal || this._flip === EFlipType.Both) {
sx = -1;
}
if (this._flip === EFlipType.Vertical || this._flip === EFlipType.Both) {
sy = -1;
}
this.setScale(sx, sy);
this.handleXYChanged();
}
}
/**
* Get/set time scale
* 获取/设置时间缩放
*/
public get timeScale(): number {
return this._movieClip.timeScale;
}
public set timeScale(value: number) {
if (this._movieClip) {
this._movieClip.timeScale = value;
}
}
/**
* Rewind to beginning
* 回到开始
*/
public rewind(): void {
this._movieClip.rewind();
}
/**
* Sync status with another movie clip
* 同步状态
*/
public syncStatus(anotherMc: GMovieClip): void {
this._movieClip.syncStatus(anotherMc._movieClip);
}
/**
* Advance by time
* 按时间前进
*/
public advance(time: number): void {
this._movieClip.advance(time);
}
/**
* Set play settings
* 设置播放设置
*/
public setPlaySettings(
start: number = 0,
end: number = -1,
times: number = 0,
endAt: number = -1,
endCallback?: () => void
): void {
this._movieClip.setPlaySettings(start, end, times, endAt, endCallback);
}
protected createDisplayObject(): void {
this._displayObject = this._movieClip = new MovieClip();
this._displayObject.gOwner = this;
}
/**
* Construct from package resource
* 从包资源构建
*/
public constructFromResource(): void {
if (!this.packageItem) return;
this.ensureMovieClip();
this._contentItem = this.packageItem;
this.sourceWidth = this._contentItem.width;
this.sourceHeight = this._contentItem.height;
this.initWidth = this.sourceWidth;
this.initHeight = this.sourceHeight;
// Load frames from package
if (this._contentItem.owner) {
this._contentItem.owner.getItemAsset(this._contentItem);
}
if (this._contentItem.frames) {
this._movieClip.interval = this._contentItem.interval;
this._movieClip.swing = this._contentItem.swing;
this._movieClip.repeatDelay = this._contentItem.repeatDelay;
this._movieClip.frames = this._contentItem.frames as IFrame[];
}
this.setSize(this.sourceWidth, this.sourceHeight);
}
protected handleXYChanged(): void {
super.handleXYChanged();
if (this._flip !== EFlipType.None) {
if (this.scaleX === -1 && this._movieClip) {
this._movieClip.x += this.width;
}
if (this.scaleY === -1 && this._movieClip) {
this._movieClip.y += this.height;
}
}
}
public getProp(index: number): any {
switch (index) {
case EObjectPropID.Color:
return this.color;
case EObjectPropID.Playing:
return this.playing;
case EObjectPropID.Frame:
return this.frame;
case EObjectPropID.TimeScale:
return this.timeScale;
default:
return super.getProp(index);
}
}
public setProp(index: number, value: any): void {
switch (index) {
case EObjectPropID.Color:
this.color = value;
break;
case EObjectPropID.Playing:
this.playing = value;
break;
case EObjectPropID.Frame:
this.frame = value;
break;
case EObjectPropID.TimeScale:
this.timeScale = value;
break;
default:
super.setProp(index, value);
break;
}
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
if (buffer.readBool()) {
this.color = buffer.readS();
}
this.flip = buffer.readByte();
const frameValue = buffer.getInt32();
const playingValue = buffer.readBool();
if (this._movieClip) {
this._movieClip.frame = frameValue;
this._movieClip.playing = playingValue;
}
}
}

View File

@@ -0,0 +1,228 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { GImage } from './GImage';
import { EProgressTitleType, EObjectPropID, EFillMethod } from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* GProgressBar
*
* Progress bar component.
*
* 进度条组件
*/
export class GProgressBar extends GComponent {
private _min: number = 0;
private _max: number = 100;
private _value: number = 50;
private _titleType: EProgressTitleType = EProgressTitleType.Percent;
private _reverse: boolean = false;
private _titleObject: GObject | null = null;
private _aniObject: GObject | null = null;
private _barObjectH: GObject | null = null;
private _barObjectV: GObject | null = null;
private _barMaxWidth: number = 0;
private _barMaxHeight: number = 0;
private _barMaxWidthDelta: number = 0;
private _barMaxHeightDelta: number = 0;
private _barStartX: number = 0;
private _barStartY: number = 0;
constructor() {
super();
}
/**
* Get/set title type
* 获取/设置标题类型
*/
public get titleType(): EProgressTitleType {
return this._titleType;
}
public set titleType(value: EProgressTitleType) {
if (this._titleType !== value) {
this._titleType = value;
this.update(this._value);
}
}
/**
* Get/set minimum value
* 获取/设置最小值
*/
public get min(): number {
return this._min;
}
public set min(value: number) {
if (this._min !== value) {
this._min = value;
this.update(this._value);
}
}
/**
* Get/set maximum value
* 获取/设置最大值
*/
public get max(): number {
return this._max;
}
public set max(value: number) {
if (this._max !== value) {
this._max = value;
this.update(this._value);
}
}
/**
* Get/set current value
* 获取/设置当前值
*/
public get value(): number {
return this._value;
}
public set value(value: number) {
if (this._value !== value) {
this._value = value;
this.update(value);
}
}
/**
* Update progress bar display
* 更新进度条显示
*/
public update(newValue: number): void {
let percent = this.clamp01((newValue - this._min) / (this._max - this._min));
if (this._titleObject) {
switch (this._titleType) {
case EProgressTitleType.Percent:
this._titleObject.text = Math.floor(percent * 100) + '%';
break;
case EProgressTitleType.ValueAndMax:
this._titleObject.text =
Math.floor(newValue) + '/' + Math.floor(this._max);
break;
case EProgressTitleType.Value:
this._titleObject.text = '' + Math.floor(newValue);
break;
case EProgressTitleType.Max:
this._titleObject.text = '' + Math.floor(this._max);
break;
}
}
const fullWidth = this.width - this._barMaxWidthDelta;
const fullHeight = this.height - this._barMaxHeightDelta;
if (!this._reverse) {
if (this._barObjectH) {
if (!this.setFillAmount(this._barObjectH, percent)) {
this._barObjectH.width = Math.round(fullWidth * percent);
}
}
if (this._barObjectV) {
if (!this.setFillAmount(this._barObjectV, percent)) {
this._barObjectV.height = Math.round(fullHeight * percent);
}
}
} else {
if (this._barObjectH) {
if (!this.setFillAmount(this._barObjectH, 1 - percent)) {
this._barObjectH.width = Math.round(fullWidth * percent);
this._barObjectH.x = this._barStartX + (fullWidth - this._barObjectH.width);
}
}
if (this._barObjectV) {
if (!this.setFillAmount(this._barObjectV, 1 - percent)) {
this._barObjectV.height = Math.round(fullHeight * percent);
this._barObjectV.y =
this._barStartY + (fullHeight - this._barObjectV.height);
}
}
}
if (this._aniObject) {
this._aniObject.setProp(EObjectPropID.Frame, Math.floor(percent * 100));
}
}
private setFillAmount(bar: GObject, percent: number): boolean {
if (bar instanceof GImage && bar.fillMethod !== EFillMethod.None) {
bar.fillAmount = percent;
return true;
}
return false;
}
private clamp01(value: number): number {
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
protected constructExtension(buffer: ByteBuffer): void {
buffer.seek(0, 6);
this._titleType = buffer.readByte();
this._reverse = buffer.readBool();
this._titleObject = this.getChild('title');
this._barObjectH = this.getChild('bar');
this._barObjectV = this.getChild('bar_v');
this._aniObject = this.getChild('ani');
if (this._barObjectH) {
this._barMaxWidth = this._barObjectH.width;
this._barMaxWidthDelta = this.width - this._barMaxWidth;
this._barStartX = this._barObjectH.x;
}
if (this._barObjectV) {
this._barMaxHeight = this._barObjectV.height;
this._barMaxHeightDelta = this.height - this._barMaxHeight;
this._barStartY = this._barObjectV.y;
}
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
if (this._barObjectH) {
this._barMaxWidth = this.width - this._barMaxWidthDelta;
}
if (this._barObjectV) {
this._barMaxHeight = this.height - this._barMaxHeightDelta;
}
if (!this._underConstruct) {
this.update(this._value);
}
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
if (!buffer.seek(beginPos, 6)) {
this.update(this._value);
return;
}
if (buffer.readByte() !== this.packageItem?.objectType) {
this.update(this._value);
return;
}
this._value = buffer.getInt32();
this._max = buffer.getInt32();
if (buffer.version >= 2) {
this._min = buffer.getInt32();
}
this.update(this._value);
}
}

View File

@@ -0,0 +1,332 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { FGUIEvents } from '../events/Events';
import { EProgressTitleType } from '../core/FieldTypes';
import { Point } from '../utils/MathTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* GSlider
*
* Slider component with draggable grip.
*
* 滑动条组件,支持拖动手柄
*/
export class GSlider extends GComponent {
private _min: number = 0;
private _max: number = 100;
private _value: number = 50;
private _titleType: EProgressTitleType = EProgressTitleType.Percent;
private _reverse: boolean = false;
private _wholeNumbers: boolean = false;
private _titleObject: GObject | null = null;
private _barObjectH: GObject | null = null;
private _barObjectV: GObject | null = null;
private _barMaxWidth: number = 0;
private _barMaxHeight: number = 0;
private _barMaxWidthDelta: number = 0;
private _barMaxHeightDelta: number = 0;
private _gripObject: GObject | null = null;
private _clickPos: Point = new Point();
private _clickPercent: number = 0;
private _barStartX: number = 0;
private _barStartY: number = 0;
/** Allow click on bar to change value | 允许点击条改变值 */
public changeOnClick: boolean = true;
/** Allow dragging | 允许拖动 */
public canDrag: boolean = true;
constructor() {
super();
}
/**
* Get/set title type
* 获取/设置标题类型
*/
public get titleType(): EProgressTitleType {
return this._titleType;
}
public set titleType(value: EProgressTitleType) {
this._titleType = value;
}
/**
* Get/set whole numbers mode
* 获取/设置整数模式
*/
public get wholeNumbers(): boolean {
return this._wholeNumbers;
}
public set wholeNumbers(value: boolean) {
if (this._wholeNumbers !== value) {
this._wholeNumbers = value;
this.update();
}
}
/**
* Get/set minimum value
* 获取/设置最小值
*/
public get min(): number {
return this._min;
}
public set min(value: number) {
if (this._min !== value) {
this._min = value;
this.update();
}
}
/**
* Get/set maximum value
* 获取/设置最大值
*/
public get max(): number {
return this._max;
}
public set max(value: number) {
if (this._max !== value) {
this._max = value;
this.update();
}
}
/**
* Get/set current value
* 获取/设置当前值
*/
public get value(): number {
return this._value;
}
public set value(value: number) {
if (this._value !== value) {
this._value = value;
this.update();
}
}
/**
* Update slider display
* 更新滑动条显示
*/
public update(): void {
this.updateWithPercent(
(this._value - this._min) / (this._max - this._min),
false
);
}
private updateWithPercent(percent: number, bEmitEvent: boolean): void {
percent = this.clamp01(percent);
if (bEmitEvent) {
let newValue = this.clamp(
this._min + (this._max - this._min) * percent,
this._min,
this._max
);
if (this._wholeNumbers) {
newValue = Math.round(newValue);
percent = this.clamp01((newValue - this._min) / (this._max - this._min));
}
if (newValue !== this._value) {
this._value = newValue;
this.emit(FGUIEvents.STATE_CHANGED);
}
}
if (this._titleObject) {
switch (this._titleType) {
case EProgressTitleType.Percent:
this._titleObject.text = Math.floor(percent * 100) + '%';
break;
case EProgressTitleType.ValueAndMax:
this._titleObject.text = this._value + '/' + this._max;
break;
case EProgressTitleType.Value:
this._titleObject.text = '' + this._value;
break;
case EProgressTitleType.Max:
this._titleObject.text = '' + this._max;
break;
}
}
const fullWidth = this.width - this._barMaxWidthDelta;
const fullHeight = this.height - this._barMaxHeightDelta;
if (!this._reverse) {
if (this._barObjectH) {
this._barObjectH.width = Math.round(fullWidth * percent);
}
if (this._barObjectV) {
this._barObjectV.height = Math.round(fullHeight * percent);
}
} else {
if (this._barObjectH) {
this._barObjectH.width = Math.round(fullWidth * percent);
this._barObjectH.x = this._barStartX + (fullWidth - this._barObjectH.width);
}
if (this._barObjectV) {
this._barObjectV.height = Math.round(fullHeight * percent);
this._barObjectV.y =
this._barStartY + (fullHeight - this._barObjectV.height);
}
}
}
private clamp01(value: number): number {
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
private clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
protected constructExtension(buffer: ByteBuffer): void {
buffer.seek(0, 6);
this._titleType = buffer.readByte();
this._reverse = buffer.readBool();
if (buffer.version >= 2) {
this._wholeNumbers = buffer.readBool();
this.changeOnClick = buffer.readBool();
}
this._titleObject = this.getChild('title');
this._barObjectH = this.getChild('bar');
this._barObjectV = this.getChild('bar_v');
this._gripObject = this.getChild('grip');
if (this._barObjectH) {
this._barMaxWidth = this._barObjectH.width;
this._barMaxWidthDelta = this.width - this._barMaxWidth;
this._barStartX = this._barObjectH.x;
}
if (this._barObjectV) {
this._barMaxHeight = this._barObjectV.height;
this._barMaxHeightDelta = this.height - this._barMaxHeight;
this._barStartY = this._barObjectV.y;
}
if (this._gripObject) {
this._gripObject.on(FGUIEvents.TOUCH_BEGIN, this.handleGripTouchBegin, this);
}
this.on(FGUIEvents.TOUCH_BEGIN, this.handleBarTouchBegin, this);
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
if (this._barObjectH) {
this._barMaxWidth = this.width - this._barMaxWidthDelta;
}
if (this._barObjectV) {
this._barMaxHeight = this.height - this._barMaxHeightDelta;
}
if (!this._underConstruct) {
this.update();
}
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
if (!buffer.seek(beginPos, 6)) {
this.update();
return;
}
if (buffer.readByte() !== this.packageItem?.objectType) {
this.update();
return;
}
this._value = buffer.getInt32();
this._max = buffer.getInt32();
if (buffer.version >= 2) {
this._min = buffer.getInt32();
}
this.update();
}
private handleGripTouchBegin(evt: any): void {
this.canDrag = true;
if (evt.stopPropagation) {
evt.stopPropagation();
}
this._clickPos = this.globalToLocal(evt.stageX || 0, evt.stageY || 0);
this._clickPercent = this.clamp01(
(this._value - this._min) / (this._max - this._min)
);
this.root?.on(FGUIEvents.TOUCH_MOVE, this.handleGripTouchMove, this);
this.root?.on(FGUIEvents.TOUCH_END, this.handleGripTouchEnd, this);
}
private handleGripTouchMove(evt: any): void {
if (!this.canDrag) {
return;
}
const pt = this.globalToLocal(evt.stageX || 0, evt.stageY || 0);
let deltaX = pt.x - this._clickPos.x;
let deltaY = pt.y - this._clickPos.y;
if (this._reverse) {
deltaX = -deltaX;
deltaY = -deltaY;
}
let percent: number;
if (this._barObjectH) {
percent = this._clickPercent + deltaX / this._barMaxWidth;
} else {
percent = this._clickPercent + deltaY / this._barMaxHeight;
}
this.updateWithPercent(percent, true);
}
private handleGripTouchEnd(): void {
this.root?.off(FGUIEvents.TOUCH_MOVE, this.handleGripTouchMove, this);
this.root?.off(FGUIEvents.TOUCH_END, this.handleGripTouchEnd, this);
}
private handleBarTouchBegin(evt: any): void {
if (!this.changeOnClick || !this._gripObject) {
return;
}
const pt = this._gripObject.globalToLocal(evt.stageX || 0, evt.stageY || 0);
let percent = this.clamp01((this._value - this._min) / (this._max - this._min));
let delta: number = 0;
if (this._barObjectH) {
delta = pt.x / this._barMaxWidth;
}
if (this._barObjectV) {
delta = pt.y / this._barMaxHeight;
}
if (this._reverse) {
percent -= delta;
} else {
percent += delta;
}
this.updateWithPercent(percent, true);
}
}

View File

@@ -0,0 +1,470 @@
import { GObject } from '../core/GObject';
import { TextField } from '../display/TextField';
import {
EAutoSizeType,
EAlignType,
EVertAlignType,
EObjectPropID
} from '../core/FieldTypes';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* GTextField
*
* Text field display object for FairyGUI.
*
* FairyGUI 文本字段显示对象
*/
export class GTextField extends GObject {
protected _textField!: TextField;
protected _text: string = '';
protected _autoSize: EAutoSizeType = EAutoSizeType.Both;
protected _widthAutoSize: boolean = true;
protected _heightAutoSize: boolean = true;
protected _color: string = '#000000';
protected _singleLine: boolean = false;
constructor() {
super();
// Ensure _textField is initialized - super() calls createDisplayObject() but
// class field initializers run after super(), which may cause issues
this.ensureTextField();
}
private ensureTextField(): void {
if (!this._textField) {
this.createDisplayObject();
}
}
protected createDisplayObject(): void {
this._displayObject = this._textField = new TextField();
this._textField.touchable = false;
this._displayObject.gOwner = this;
}
/**
* Get the internal text field display object
* 获取内部文本字段显示对象
*/
public get textField(): TextField {
return this._textField;
}
/**
* Get/set text content
* 获取/设置文本内容
*/
public get text(): string {
return this._text;
}
public set text(value: string) {
this._text = value;
if (this._textField) {
this._textField.text = value;
this.updateSize();
}
}
/**
* Get/set font
* 获取/设置字体
*/
public get font(): string {
return this._textField.font;
}
public set font(value: string) {
if (this._textField) {
this._textField.font = value;
}
}
/**
* Get/set font size
* 获取/设置字体大小
*/
public get fontSize(): number {
return this._textField.fontSize;
}
public set fontSize(value: number) {
if (this._textField) {
this._textField.fontSize = value;
}
}
/**
* Get/set text color
* 获取/设置文本颜色
*/
public get color(): string {
return this._color;
}
public set color(value: string) {
if (this._color !== value) {
this._color = value;
this.updateGear(4);
if (this.grayed) {
this._textField.color = '#AAAAAA';
} else {
this._textField.color = this._color;
}
}
}
/**
* Get/set horizontal alignment
* 获取/设置水平对齐
*/
public get align(): EAlignType {
return this._textField.align;
}
public set align(value: EAlignType) {
if (this._textField) {
this._textField.align = value;
}
}
/**
* Get/set vertical alignment
* 获取/设置垂直对齐
*/
public get valign(): EVertAlignType {
return this._textField.valign;
}
public set valign(value: EVertAlignType) {
if (this._textField) {
this._textField.valign = value;
}
}
/**
* Get/set leading (line spacing)
* 获取/设置行间距
*/
public get leading(): number {
return this._textField.leading;
}
public set leading(value: number) {
if (this._textField) {
this._textField.leading = value;
}
}
/**
* Get/set letter spacing
* 获取/设置字间距
*/
public get letterSpacing(): number {
return this._textField.letterSpacing;
}
public set letterSpacing(value: number) {
if (this._textField) {
this._textField.letterSpacing = value;
}
}
/**
* Get/set bold
* 获取/设置粗体
*/
public get bold(): boolean {
return this._textField.bold;
}
public set bold(value: boolean) {
if (this._textField) {
this._textField.bold = value;
}
}
/**
* Get/set italic
* 获取/设置斜体
*/
public get italic(): boolean {
return this._textField.italic;
}
public set italic(value: boolean) {
if (this._textField) {
this._textField.italic = value;
}
}
/**
* Get/set underline
* 获取/设置下划线
*/
public get underline(): boolean {
return this._textField.underline;
}
public set underline(value: boolean) {
if (this._textField) {
this._textField.underline = value;
}
}
/**
* Get/set single line mode
* 获取/设置单行模式
*/
public get singleLine(): boolean {
return this._singleLine;
}
public set singleLine(value: boolean) {
this._singleLine = value;
if (this._textField) {
this._textField.singleLine = value;
this._textField.wordWrap = !this._widthAutoSize && !this._singleLine;
}
}
/**
* Get/set stroke width
* 获取/设置描边宽度
*/
public get stroke(): number {
return this._textField.stroke;
}
public set stroke(value: number) {
if (this._textField) {
this._textField.stroke = value;
}
}
/**
* Get/set stroke color
* 获取/设置描边颜色
*/
public get strokeColor(): string {
return this._textField.strokeColor;
}
public set strokeColor(value: string) {
if (this._textField && this._textField.strokeColor !== value) {
this._textField.strokeColor = value;
this.updateGear(4);
}
}
/**
* Get/set UBB enabled
* 获取/设置 UBB 标签启用
*/
public get ubbEnabled(): boolean {
return this._textField.ubbEnabled;
}
public set ubbEnabled(value: boolean) {
if (this._textField) {
this._textField.ubbEnabled = value;
}
}
/**
* Get/set auto size type
* 获取/设置自动尺寸类型
*/
public get autoSize(): EAutoSizeType {
return this._autoSize;
}
public set autoSize(value: EAutoSizeType) {
if (this._autoSize !== value) {
this._autoSize = value;
this._widthAutoSize = this._autoSize === EAutoSizeType.Both;
this._heightAutoSize =
this._autoSize === EAutoSizeType.Both ||
this._autoSize === EAutoSizeType.Height;
this.updateAutoSize();
}
}
protected updateAutoSize(): void {
if (!this._textField) return;
this._textField.wordWrap = !this._widthAutoSize && !this._singleLine;
this._textField.autoSize = this._autoSize;
if (!this._underConstruct) {
if (!this._heightAutoSize) {
this._textField.width = this._width;
this._textField.height = this._height;
} else if (!this._widthAutoSize) {
this._textField.width = this._width;
}
}
}
/**
* Get text width
* 获取文本宽度
*/
public get textWidth(): number {
return this._textField.textWidth;
}
/**
* Get/set template variables
* 获取/设置模板变量
*/
public get templateVars(): Record<string, string> | null {
return this._textField.templateVars;
}
public set templateVars(value: Record<string, string> | null) {
if (this._textField) {
this._textField.templateVars = value;
}
}
/**
* Set a template variable
* 设置模板变量
*/
public setVar(name: string, value: string): GTextField {
if (this._textField) {
this._textField.setVar(name, value);
}
return this;
}
/**
* Flush template variables
* 刷新模板变量
*/
public flushVars(): void {
// Auto flush, nothing needed
}
public ensureSizeCorrect(): void {
// Force layout if needed
}
private updateSize(): void {
if (!this._textField) return;
if (this._widthAutoSize) {
this.setSize(this._textField.textWidth, this._textField.textHeight);
} else if (this._heightAutoSize) {
this.height = this._textField.textHeight;
}
}
protected handleSizeChanged(): void {
super.handleSizeChanged();
if (this._textField) {
this._textField.width = this._width;
this._textField.height = this._height;
}
}
protected handleGrayedChanged(): void {
super.handleGrayedChanged();
if (this._textField) {
if (this.grayed) {
this._textField.color = '#AAAAAA';
} else {
this._textField.color = this._color;
}
}
}
public getProp(index: number): any {
switch (index) {
case EObjectPropID.Color:
return this.color;
case EObjectPropID.OutlineColor:
return this.strokeColor;
case EObjectPropID.FontSize:
return this.fontSize;
default:
return super.getProp(index);
}
}
public setProp(index: number, value: any): void {
switch (index) {
case EObjectPropID.Color:
this.color = value;
break;
case EObjectPropID.OutlineColor:
this.strokeColor = value;
break;
case EObjectPropID.FontSize:
this.fontSize = value;
break;
default:
super.setProp(index, value);
break;
}
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 5);
this.font = buffer.readS();
this.fontSize = buffer.getInt16();
this.color = buffer.readColorS();
const alignValue = buffer.readByte();
this.align =
alignValue === 0
? EAlignType.Left
: alignValue === 1
? EAlignType.Center
: EAlignType.Right;
const valignValue = buffer.readByte();
this.valign =
valignValue === 0
? EVertAlignType.Top
: valignValue === 1
? EVertAlignType.Middle
: EVertAlignType.Bottom;
this.leading = buffer.getInt16();
this.letterSpacing = buffer.getInt16();
this.ubbEnabled = buffer.readBool();
this.autoSize = buffer.readByte();
this.underline = buffer.readBool();
this.italic = buffer.readBool();
this.bold = buffer.readBool();
this.singleLine = buffer.readBool();
if (buffer.readBool()) {
this.strokeColor = buffer.readColorS();
this.stroke = buffer.getFloat32() + 1;
}
if (buffer.readBool()) {
// Shadow - skip for now
buffer.skip(12);
}
if (buffer.readBool()) {
this._textField.templateVars = {};
}
}
public setup_afterAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_afterAdd(buffer, beginPos);
buffer.seek(beginPos, 6);
const str = buffer.readS();
if (str) {
this.text = str;
}
}
}

View File

@@ -0,0 +1,235 @@
import { GTextField } from './GTextField';
import { InputTextField } from '../display/InputTextField';
import { FGUIEvents } from '../events/Events';
import type { ByteBuffer } from '../utils/ByteBuffer';
/**
* Keyboard type constants
* 键盘类型常量
*/
export const enum EKeyboardType {
Default = 'text',
Number = 'number',
Url = 'url',
Email = 'email',
Tel = 'tel',
Password = 'password'
}
/**
* GTextInput
*
* Editable text input component.
*
* 可编辑的文本输入组件
*
* Features:
* - Text input with IME support
* - Password mode
* - Character restriction
* - Max length
* - Placeholder text
*/
export class GTextInput extends GTextField {
protected declare _displayObject: InputTextField;
constructor() {
super();
}
protected createDisplayObject(): void {
const inputField = new InputTextField();
// Set both _displayObject and _textField since parent class uses _textField for color etc.
this._displayObject = inputField;
this._textField = inputField;
this._displayObject.gOwner = this;
// Forward events
inputField.on('input', () => {
this.emit(FGUIEvents.TEXT_CHANGED);
});
inputField.on('submit', () => {
this.emit(FGUIEvents.TEXT_SUBMIT);
});
}
/**
* Get native input element
* 获取原生输入元素
*/
public get nativeInput(): InputTextField {
return this._displayObject;
}
/**
* Get/set password mode
* 获取/设置密码模式
*/
public get password(): boolean {
return this._displayObject.password;
}
public set password(value: boolean) {
if (this._displayObject) {
this._displayObject.password = value;
}
}
/**
* Get/set keyboard type
* 获取/设置键盘类型
*/
public get keyboardType(): string {
return this._displayObject.keyboardType;
}
public set keyboardType(value: string) {
if (this._displayObject) {
this._displayObject.keyboardType = value;
}
}
/**
* Get/set editable state
* 获取/设置可编辑状态
*/
public get editable(): boolean {
return this._displayObject.editable;
}
public set editable(value: boolean) {
if (this._displayObject) {
this._displayObject.editable = value;
}
}
/**
* Get/set max length
* 获取/设置最大长度
*/
public get maxLength(): number {
return this._displayObject.maxLength;
}
public set maxLength(value: number) {
if (this._displayObject) {
this._displayObject.maxLength = value;
}
}
/**
* Get/set placeholder text
* 获取/设置占位符文本
*/
public get promptText(): string {
return this._displayObject.promptText;
}
public set promptText(value: string) {
if (this._displayObject) {
this._displayObject.promptText = value;
}
}
/**
* Get/set placeholder color
* 获取/设置占位符颜色
*/
public get promptColor(): string {
return this._displayObject.promptColor;
}
public set promptColor(value: string) {
if (this._displayObject) {
this._displayObject.promptColor = value;
}
}
/**
* Get/set character restriction pattern
* 获取/设置字符限制模式
*/
public get restrict(): string {
return this._displayObject.restrict;
}
public set restrict(value: string) {
if (this._displayObject) {
this._displayObject.restrict = value;
}
}
/**
* Get/set single line mode
* 获取/设置单行模式
*/
public get singleLine(): boolean {
return this._singleLine;
}
public set singleLine(value: boolean) {
this._singleLine = value;
if (this._displayObject) {
this._displayObject.multiline = !value;
}
}
/**
* Request focus
* 请求焦点
*/
public requestFocus(): void {
this._displayObject.focus();
super.requestFocus();
}
/**
* Clear focus
* 清除焦点
*/
public clearFocus(): void {
this._displayObject.blur();
}
/**
* Select all text
* 选择所有文本
*/
public selectAll(): void {
this._displayObject.selectAll();
}
public setup_beforeAdd(buffer: ByteBuffer, beginPos: number): void {
super.setup_beforeAdd(buffer, beginPos);
buffer.seek(beginPos, 4);
let str = buffer.readS();
if (str) {
this.promptText = str;
}
str = buffer.readS();
if (str) {
this.restrict = str;
}
const iv = buffer.getInt32();
if (iv !== 0) {
this.maxLength = iv;
}
const keyboardTypeValue = buffer.getInt32();
if (keyboardTypeValue !== 0) {
if (keyboardTypeValue === 4) {
this.keyboardType = EKeyboardType.Number;
} else if (keyboardTypeValue === 3) {
this.keyboardType = EKeyboardType.Url;
}
}
if (buffer.readBool()) {
this.password = true;
}
}
}

View File

@@ -0,0 +1,325 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { GRoot } from '../core/GRoot';
import { Controller } from '../core/Controller';
import { getUIConfig } from '../core/UIConfig';
import { UIPackage } from '../package/UIPackage';
import { ERelationType } from '../core/FieldTypes';
import { FGUIEvents } from '../events/Events';
import { Timer } from '../core/Timer';
import { GList } from './GList';
import { GButton } from './GButton';
import type { SimpleHandler } from '../display/MovieClip';
/**
* PopupMenu
*
* Context menu component with item management.
*
* 上下文菜单组件,支持菜单项管理
*
* Features:
* - Add/remove menu items
* - Checkable items
* - Separators
* - Grayed out items
*
* @example
* ```typescript
* const menu = new PopupMenu();
* menu.addItem('Open', () => console.log('Open clicked'));
* menu.addItem('Save', () => console.log('Save clicked'));
* menu.addSeperator();
* menu.addItem('Exit', () => console.log('Exit clicked'));
* menu.show(targetButton);
* ```
*/
export class PopupMenu {
protected _contentPane: GComponent;
protected _list: GList;
constructor(resourceURL?: string) {
if (!resourceURL) {
resourceURL = getUIConfig('popupMenu');
if (!resourceURL) {
throw new Error('UIConfig.popupMenu not defined');
}
}
const obj = UIPackage.createObjectFromURL(resourceURL);
if (!obj || !(obj instanceof GComponent)) {
throw new Error(`Failed to create popup menu from: ${resourceURL}`);
}
this._contentPane = obj;
this._contentPane.on(FGUIEvents.DISPLAY, this.onAddedToStage, this);
const list = this._contentPane.getChild('list');
if (!list || !(list instanceof GList)) {
throw new Error('PopupMenu content pane must have a child named "list" of type GList');
}
this._list = list;
this._list.removeChildrenToPool();
this._list.relations.add(this._contentPane, ERelationType.Width);
this._list.relations.remove(this._contentPane, ERelationType.Height);
this._contentPane.relations.add(this._list, ERelationType.Height);
this._list.on(FGUIEvents.CLICK_ITEM, this.onClickItem, this);
}
/**
* Dispose the menu
* 销毁菜单
*/
public dispose(): void {
this._contentPane.dispose();
}
/**
* Add a menu item
* 添加菜单项
*/
public addItem(caption: string, handler?: SimpleHandler): GButton {
const item = this._list.addItemFromPool();
if (!item || !(item instanceof GButton)) {
throw new Error('Failed to create menu item');
}
item.title = caption;
item.data = handler;
item.grayed = false;
const c = item.getController('checked');
if (c) {
c.selectedIndex = 0;
}
return item;
}
/**
* Add a menu item at specified index
* 在指定索引处添加菜单项
*/
public addItemAt(caption: string, index: number, handler?: SimpleHandler): GButton {
const item = this._list.getFromPool();
if (!item || !(item instanceof GButton)) {
throw new Error('Failed to create menu item');
}
this._list.addChildAt(item, index);
item.title = caption;
item.data = handler;
item.grayed = false;
const c = item.getController('checked');
if (c) {
c.selectedIndex = 0;
}
return item;
}
/**
* Add a separator
* 添加分隔符
*/
public addSeperator(): void {
const seperatorUrl = getUIConfig('popupMenuSeperator');
if (!seperatorUrl) {
throw new Error('UIConfig.popupMenuSeperator not defined');
}
this._list.addItemFromPool(seperatorUrl);
}
/**
* Get item name at index
* 获取指定索引处的菜单项名称
*/
public getItemName(index: number): string {
const item = this._list.getChildAt(index);
return item ? item.name : '';
}
/**
* Set item text by name
* 通过名称设置菜单项文本
*/
public setItemText(name: string, caption: string): void {
const item = this._list.getChild(name);
if (item && item instanceof GButton) {
item.title = caption;
}
}
/**
* Set item visibility by name
* 通过名称设置菜单项可见性
*/
public setItemVisible(name: string, bVisible: boolean): void {
const item = this._list.getChild(name);
if (item && item.visible !== bVisible) {
item.visible = bVisible;
this._list.setBoundsChangedFlag();
}
}
/**
* Set item grayed state by name
* 通过名称设置菜单项灰色状态
*/
public setItemGrayed(name: string, bGrayed: boolean): void {
const item = this._list.getChild(name);
if (item && item instanceof GButton) {
item.grayed = bGrayed;
}
}
/**
* Set item checkable state by name
* 通过名称设置菜单项可选中状态
*/
public setItemCheckable(name: string, bCheckable: boolean): void {
const item = this._list.getChild(name);
if (item && item instanceof GButton) {
const c = item.getController('checked');
if (c) {
if (bCheckable) {
if (c.selectedIndex === 0) {
c.selectedIndex = 1;
}
} else {
c.selectedIndex = 0;
}
}
}
}
/**
* Set item checked state by name
* 通过名称设置菜单项选中状态
*/
public setItemChecked(name: string, bChecked: boolean): void {
const item = this._list.getChild(name);
if (item && item instanceof GButton) {
const c = item.getController('checked');
if (c) {
c.selectedIndex = bChecked ? 2 : 1;
}
}
}
/**
* Check if item is checked by name
* 通过名称检查菜单项是否选中
*/
public isItemChecked(name: string): boolean {
const item = this._list.getChild(name);
if (item && item instanceof GButton) {
const c = item.getController('checked');
if (c) {
return c.selectedIndex === 2;
}
}
return false;
}
/**
* Remove item by name
* 通过名称移除菜单项
*/
public removeItem(name: string): boolean {
const item = this._list.getChild(name);
if (item) {
const index = this._list.getChildIndex(item);
this._list.removeChildToPoolAt(index);
return true;
}
return false;
}
/**
* Clear all items
* 清除所有菜单项
*/
public clearItems(): void {
this._list.removeChildrenToPool();
}
/**
* Get item count
* 获取菜单项数量
*/
public get itemCount(): number {
return this._list.numChildren;
}
/**
* Get content pane
* 获取内容面板
*/
public get contentPane(): GComponent {
return this._contentPane;
}
/**
* Get list component
* 获取列表组件
*/
public get list(): GList {
return this._list;
}
/**
* Show menu
* 显示菜单
*/
public show(target?: GObject, dir?: number): void {
const r = target?.root ?? GRoot.inst;
const popupTarget = target instanceof GRoot ? undefined : target;
r.showPopup(this._contentPane, popupTarget, dir);
}
private onClickItem(itemObject: GObject): void {
Timer.inst.callLater(this, () => this.handleItemClick(itemObject));
}
private handleItemClick(itemObject: GObject): void {
if (!(itemObject instanceof GButton)) {
return;
}
if (itemObject.grayed) {
this._list.selectedIndex = -1;
return;
}
const c = itemObject.getController('checked');
if (c && c.selectedIndex !== 0) {
if (c.selectedIndex === 1) {
c.selectedIndex = 2;
} else {
c.selectedIndex = 1;
}
}
const r = this._contentPane.parent as GRoot | null;
if (r) {
r.hidePopup(this._contentPane);
}
const handler = itemObject.data as SimpleHandler | null;
if (handler) {
if (typeof handler === 'function') {
handler();
} else if (typeof handler.run === 'function') {
handler.run();
}
}
}
private onAddedToStage(): void {
this._list.selectedIndex = -1;
this._list.resizeToFit(100000, 10);
}
}

View File

@@ -0,0 +1,522 @@
import { GComponent } from '../core/GComponent';
import { GObject } from '../core/GObject';
import { GRoot } from '../core/GRoot';
import { GGraph } from './GGraph';
import { getUIConfig } from '../core/UIConfig';
import { UIPackage } from '../package/UIPackage';
import { ERelationType } from '../core/FieldTypes';
import { FGUIEvents } from '../events/Events';
import { Point } from '../utils/MathTypes';
/**
* IUISource
*
* Interface for dynamic UI loading sources
* 动态 UI 加载源接口
*/
export interface IUISource {
/** Source file name | 源文件名 */
fileName: string;
/** Whether the source is loaded | 是否已加载 */
loaded: boolean;
/**
* Load the source
* 加载源
*/
load(callback: () => void, thisObj: any): void;
}
/**
* Window
*
* Base class for popup windows with modal support.
*
* 弹窗基类,支持模态窗口
*
* Features:
* - Content pane management
* - Modal wait indicator
* - Draggable title bar
* - Close button binding
* - Bring to front on click
*
* @example
* ```typescript
* class MyWindow extends Window {
* constructor() {
* super();
* this.contentPane = UIPackage.createObject('pkg', 'MyWindowContent') as GComponent;
* }
*
* protected onInit(): void {
* // Initialize window
* }
*
* protected onShown(): void {
* // Window is shown
* }
*
* protected onHide(): void {
* // Window is hidden
* }
* }
*
* const win = new MyWindow();
* win.show();
* ```
*/
export class Window extends GComponent {
/** Bring window to front when clicked | 点击时将窗口置顶 */
public bringToFrontOnClick: boolean;
protected _requestingCmd: number = 0;
private _contentPane: GComponent | null = null;
private _modalWaitPane: GObject | null = null;
private _closeButton: GObject | null = null;
private _dragArea: GObject | null = null;
private _contentArea: GObject | null = null;
private _frame: GComponent | null = null;
private _modal: boolean = false;
private _uiSources: IUISource[] = [];
private _inited: boolean = false;
private _loading: boolean = false;
constructor() {
super();
this.bringToFrontOnClick = getUIConfig('bringWindowToFrontOnClick');
this.on(FGUIEvents.DISPLAY, this.onWindowShown, this);
this.on(FGUIEvents.REMOVED_FROM_STAGE, this.onWindowHidden, this);
this.on(FGUIEvents.TOUCH_BEGIN, this.onMouseDown, this);
}
/**
* Add UI source for lazy loading
* 添加用于懒加载的 UI 源
*/
public addUISource(source: IUISource): void {
this._uiSources.push(source);
}
/**
* Get content pane
* 获取内容面板
*/
public get contentPane(): GComponent | null {
return this._contentPane;
}
/**
* Set content pane
* 设置内容面板
*/
public set contentPane(value: GComponent | null) {
if (this._contentPane !== value) {
if (this._contentPane) {
this.removeChild(this._contentPane);
}
this._contentPane = value;
if (this._contentPane) {
this.addChild(this._contentPane);
this.setSize(this._contentPane.width, this._contentPane.height);
this._contentPane.relations.add(this, ERelationType.Size);
this._frame = this._contentPane.getChild('frame') as GComponent | null;
if (this._frame) {
this.closeButton = this._frame.getChild('closeButton');
this.dragArea = this._frame.getChild('dragArea');
this.contentArea = this._frame.getChild('contentArea');
}
}
}
}
/**
* Get frame component
* 获取框架组件
*/
public get frame(): GComponent | null {
return this._frame;
}
/**
* Get close button
* 获取关闭按钮
*/
public get closeButton(): GObject | null {
return this._closeButton;
}
/**
* Set close button
* 设置关闭按钮
*/
public set closeButton(value: GObject | null) {
if (this._closeButton) {
this._closeButton.off(FGUIEvents.CLICK, this.closeEventHandler, this);
}
this._closeButton = value;
if (this._closeButton) {
this._closeButton.on(FGUIEvents.CLICK, this.closeEventHandler, this);
}
}
/**
* Get drag area
* 获取拖拽区域
*/
public get dragArea(): GObject | null {
return this._dragArea;
}
/**
* Set drag area
* 设置拖拽区域
*/
public set dragArea(value: GObject | null) {
if (this._dragArea !== value) {
if (this._dragArea) {
this._dragArea.draggable = false;
this._dragArea.off(FGUIEvents.DRAG_START, this.onDragStart, this);
}
this._dragArea = value;
if (this._dragArea) {
if (this._dragArea instanceof GGraph) {
this._dragArea.drawRect(0, 'transparent', 'transparent');
}
this._dragArea.draggable = true;
this._dragArea.on(FGUIEvents.DRAG_START, this.onDragStart, this);
}
}
}
/**
* Get content area
* 获取内容区域
*/
public get contentArea(): GObject | null {
return this._contentArea;
}
/**
* Set content area
* 设置内容区域
*/
public set contentArea(value: GObject | null) {
this._contentArea = value;
}
/**
* Show window on default GRoot
* 在默认 GRoot 上显示窗口
*/
public show(): void {
GRoot.inst.showWindow(this);
}
/**
* Show window on specified GRoot
* 在指定 GRoot 上显示窗口
*/
public showOn(root: GRoot): void {
root.showWindow(this);
}
/**
* Hide window with animation
* 隐藏窗口(带动画)
*/
public hide(): void {
if (this.isShowing) {
this.doHideAnimation();
}
}
/**
* Hide window immediately
* 立即隐藏窗口
*/
public hideImmediately(): void {
const r = this.parent instanceof GRoot ? this.parent : GRoot.inst;
r.hideWindowImmediately(this);
}
/**
* Center window on GRoot
* 在 GRoot 上居中窗口
*/
public centerOn(r: GRoot, bRestraint?: boolean): void {
this.setXY(
Math.round((r.width - this.width) / 2),
Math.round((r.height - this.height) / 2)
);
if (bRestraint) {
this.relations.add(r, ERelationType.CenterCenter);
this.relations.add(r, ERelationType.MiddleMiddle);
}
}
/**
* Toggle window visibility
* 切换窗口可见性
*/
public toggleStatus(): void {
if (this.isTop) {
this.hide();
} else {
this.show();
}
}
/**
* Check if window is showing
* 检查窗口是否正在显示
*/
public get isShowing(): boolean {
return this.parent !== null;
}
/**
* Check if window is on top
* 检查窗口是否在最上层
*/
public get isTop(): boolean {
return (
this.parent !== null &&
this.parent.getChildIndex(this) === this.parent.numChildren - 1
);
}
/**
* Get modal state
* 获取模态状态
*/
public get modal(): boolean {
return this._modal;
}
/**
* Set modal state
* 设置模态状态
*/
public set modal(value: boolean) {
this._modal = value;
}
/**
* Bring window to front
* 将窗口置于最前
*/
public bringToFront(): void {
this.root?.bringToFront(this);
}
/**
* Show modal wait indicator
* 显示模态等待指示器
*/
public showModalWait(requestingCmd?: number): void {
if (requestingCmd !== undefined) {
this._requestingCmd = requestingCmd;
}
const modalWaitingUrl = getUIConfig('windowModalWaiting');
if (modalWaitingUrl) {
if (!this._modalWaitPane) {
this._modalWaitPane = UIPackage.createObjectFromURL(modalWaitingUrl);
}
if (this._modalWaitPane) {
this.layoutModalWaitPane();
this.addChild(this._modalWaitPane);
}
}
}
/**
* Layout modal wait pane
* 布局模态等待面板
*/
protected layoutModalWaitPane(): void {
if (!this._modalWaitPane) return;
if (this._contentArea && this._frame) {
const pt = this._frame.localToGlobal(0, 0);
const localPt = this.globalToLocal(pt.x, pt.y);
this._modalWaitPane.setXY(
localPt.x + this._contentArea.x,
localPt.y + this._contentArea.y
);
this._modalWaitPane.setSize(this._contentArea.width, this._contentArea.height);
} else {
this._modalWaitPane.setSize(this.width, this.height);
}
}
/**
* Close modal wait indicator
* 关闭模态等待指示器
*/
public closeModalWait(requestingCmd?: number): boolean {
if (requestingCmd !== undefined) {
if (this._requestingCmd !== requestingCmd) {
return false;
}
}
this._requestingCmd = 0;
if (this._modalWaitPane?.parent) {
this.removeChild(this._modalWaitPane);
}
return true;
}
/**
* Check if modal waiting
* 检查是否正在模态等待
*/
public get modalWaiting(): boolean {
return this._modalWaitPane?.parent !== null && this._modalWaitPane?.parent !== undefined;
}
/**
* Initialize window
* 初始化窗口
*/
public init(): void {
if (this._inited || this._loading) {
return;
}
if (this._uiSources.length > 0) {
this._loading = false;
for (const source of this._uiSources) {
if (!source.loaded) {
source.load(this.onUILoadComplete.bind(this), this);
this._loading = true;
}
}
if (!this._loading) {
this.doInit();
}
} else {
this.doInit();
}
}
/**
* Called when window is initialized
* 窗口初始化时调用
*/
protected onInit(): void {
// Override in subclass
}
/**
* Called when window is shown
* 窗口显示时调用
*/
protected onShown(): void {
// Override in subclass
}
/**
* Called when window is hidden
* 窗口隐藏时调用
*/
protected onHide(): void {
// Override in subclass
}
/**
* Perform show animation
* 执行显示动画
*/
protected doShowAnimation(): void {
this.onShown();
}
/**
* Perform hide animation
* 执行隐藏动画
*/
protected doHideAnimation(): void {
this.hideImmediately();
}
private onUILoadComplete(): void {
for (const source of this._uiSources) {
if (!source.loaded) {
return;
}
}
this._loading = false;
this.doInit();
}
private doInit(): void {
this._inited = true;
this.onInit();
if (this.isShowing) {
this.doShowAnimation();
}
}
public dispose(): void {
if (this.parent) {
this.hideImmediately();
}
super.dispose();
}
/**
* Close button event handler
* 关闭按钮事件处理
*/
protected closeEventHandler(): void {
this.hide();
}
private onWindowShown(): void {
if (!this._inited) {
this.init();
} else {
this.doShowAnimation();
}
}
private onWindowHidden(): void {
this.closeModalWait();
this.onHide();
}
private onMouseDown(): void {
if (this.isShowing && this.bringToFrontOnClick) {
this.bringToFront();
}
}
private onDragStart(): void {
if (this._dragArea) {
this._dragArea.stopDrag();
}
this.startDrag();
}
}

View File

@@ -0,0 +1,10 @@
export { GImage } from './GImage';
export { GTextField } from './GTextField';
export { GGraph } from './GGraph';
export { GButton } from './GButton';
export { GProgressBar } from './GProgressBar';
export { GSlider } from './GSlider';
export { GLoader } from './GLoader';
export { GList } from './GList';
export type { ItemRenderer, ItemProvider } from './GList';
export { GTextInput, EKeyboardType } from './GTextInput';