Feature/runtime cdn and plugin loader (#240)
* feat(ui): 完善 UI 布局系统和编辑器可视化工具 * refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统 * fix: 修复 CodeQL 警告并提升测试覆盖率 * refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题 * fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤 * docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明 * fix(ci): 修复 type-check 失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖 * fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖 * fix(ci): platform-web 添加缺失的 behavior-tree 依赖 * fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
91
packages/ui/src/UIRuntimeModule.ts
Normal file
91
packages/ui/src/UIRuntimeModule.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* UI Runtime Module (Pure runtime, no editor dependencies)
|
||||
* UI 运行时模块(纯运行时,无编辑器依赖)
|
||||
*/
|
||||
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components';
|
||||
|
||||
import {
|
||||
UITransformComponent,
|
||||
UIRenderComponent,
|
||||
UIInteractableComponent,
|
||||
UITextComponent,
|
||||
UILayoutComponent,
|
||||
UIButtonComponent,
|
||||
UIProgressBarComponent,
|
||||
UISliderComponent,
|
||||
UIScrollViewComponent
|
||||
} from './components';
|
||||
import { UILayoutSystem } from './systems/UILayoutSystem';
|
||||
import { UIInputSystem } from './systems/UIInputSystem';
|
||||
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||
import {
|
||||
UIRenderBeginSystem,
|
||||
UIRectRenderSystem,
|
||||
UITextRenderSystem,
|
||||
UIButtonRenderSystem,
|
||||
UIProgressBarRenderSystem,
|
||||
UISliderRenderSystem,
|
||||
UIScrollViewRenderSystem
|
||||
} from './systems/render';
|
||||
|
||||
/**
|
||||
* UI Runtime Module
|
||||
* UI 运行时模块
|
||||
*/
|
||||
export class UIRuntimeModule implements IRuntimeModuleLoader {
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registry.register(UITransformComponent);
|
||||
registry.register(UIRenderComponent);
|
||||
registry.register(UIInteractableComponent);
|
||||
registry.register(UITextComponent);
|
||||
registry.register(UILayoutComponent);
|
||||
registry.register(UIButtonComponent);
|
||||
registry.register(UIProgressBarComponent);
|
||||
registry.register(UISliderComponent);
|
||||
registry.register(UIScrollViewComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
const layoutSystem = new UILayoutSystem();
|
||||
scene.addSystem(layoutSystem);
|
||||
|
||||
const renderBeginSystem = new UIRenderBeginSystem();
|
||||
scene.addSystem(renderBeginSystem);
|
||||
|
||||
const rectRenderSystem = new UIRectRenderSystem();
|
||||
scene.addSystem(rectRenderSystem);
|
||||
|
||||
const progressBarRenderSystem = new UIProgressBarRenderSystem();
|
||||
scene.addSystem(progressBarRenderSystem);
|
||||
|
||||
const sliderRenderSystem = new UISliderRenderSystem();
|
||||
scene.addSystem(sliderRenderSystem);
|
||||
|
||||
const scrollViewRenderSystem = new UIScrollViewRenderSystem();
|
||||
scene.addSystem(scrollViewRenderSystem);
|
||||
|
||||
const buttonRenderSystem = new UIButtonRenderSystem();
|
||||
scene.addSystem(buttonRenderSystem);
|
||||
|
||||
const textRenderSystem = new UITextRenderSystem();
|
||||
scene.addSystem(textRenderSystem);
|
||||
|
||||
if (context.engineBridge) {
|
||||
textRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
|
||||
context.engineBridge.loadTexture(id, dataUrl);
|
||||
});
|
||||
}
|
||||
|
||||
const uiRenderProvider = new UIRenderDataProvider();
|
||||
const inputSystem = new UIInputSystem();
|
||||
scene.addSystem(inputSystem);
|
||||
|
||||
context.uiLayoutSystem = layoutSystem;
|
||||
context.uiRenderProvider = uiRenderProvider;
|
||||
context.uiInputSystem = inputSystem;
|
||||
context.uiTextRenderSystem = textRenderSystem;
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export class UITransformComponent extends Component {
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Anchor Min X', min: 0, max: 1, step: 0.01 })
|
||||
public anchorMinX: number = 0;
|
||||
public anchorMinX: number = 0.5;
|
||||
|
||||
/**
|
||||
* 锚点 Y 最小值 (0-1),相对于父元素
|
||||
@@ -111,7 +111,7 @@ export class UITransformComponent extends Component {
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Anchor Min Y', min: 0, max: 1, step: 0.01 })
|
||||
public anchorMinY: number = 0;
|
||||
public anchorMinY: number = 0.5;
|
||||
|
||||
/**
|
||||
* 锚点 X 最大值 (0-1),相对于父元素
|
||||
@@ -119,7 +119,7 @@ export class UITransformComponent extends Component {
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Anchor Max X', min: 0, max: 1, step: 0.01 })
|
||||
public anchorMaxX: number = 0;
|
||||
public anchorMaxX: number = 0.5;
|
||||
|
||||
/**
|
||||
* 锚点 Y 最大值 (0-1),相对于父元素
|
||||
@@ -127,7 +127,7 @@ export class UITransformComponent extends Component {
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Anchor Max Y', min: 0, max: 1, step: 0.01 })
|
||||
public anchorMaxY: number = 0;
|
||||
public anchorMaxY: number = 0.5;
|
||||
|
||||
// ===== 轴心 Pivot =====
|
||||
|
||||
|
||||
159
packages/ui/src/editor/UIPlugin.ts
Normal file
159
packages/ui/src/editor/UIPlugin.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* UI 统一插件
|
||||
* UI Unified Plugin
|
||||
*/
|
||||
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IPluginLoader,
|
||||
IRuntimeModuleLoader,
|
||||
PluginDescriptor,
|
||||
SystemContext
|
||||
} from '@esengine/editor-core';
|
||||
|
||||
// Editor imports
|
||||
import { UIEditorModule } from './index';
|
||||
|
||||
// Runtime imports
|
||||
import {
|
||||
UITransformComponent,
|
||||
UIRenderComponent,
|
||||
UIInteractableComponent,
|
||||
UITextComponent,
|
||||
UILayoutComponent,
|
||||
UIButtonComponent,
|
||||
UIProgressBarComponent,
|
||||
UISliderComponent,
|
||||
UIScrollViewComponent
|
||||
} from '../components';
|
||||
import { UILayoutSystem } from '../systems/UILayoutSystem';
|
||||
import { UIInputSystem } from '../systems/UIInputSystem';
|
||||
import { UIRenderDataProvider } from '../systems/UIRenderDataProvider';
|
||||
// Render systems
|
||||
import {
|
||||
UIRenderBeginSystem,
|
||||
UIRectRenderSystem,
|
||||
UITextRenderSystem,
|
||||
UIButtonRenderSystem,
|
||||
UIProgressBarRenderSystem,
|
||||
UISliderRenderSystem,
|
||||
UIScrollViewRenderSystem
|
||||
} from '../systems/render';
|
||||
|
||||
/**
|
||||
* 插件描述符
|
||||
*/
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/ui',
|
||||
name: 'UI System',
|
||||
version: '1.0.0',
|
||||
description: '游戏 UI 系统,支持布局、交互、动画等',
|
||||
category: 'ui',
|
||||
enabledByDefault: true,
|
||||
canContainContent: false,
|
||||
isEnginePlugin: true,
|
||||
modules: [
|
||||
{
|
||||
name: 'UIRuntime',
|
||||
type: 'runtime',
|
||||
loadingPhase: 'default',
|
||||
entry: './src/index.ts'
|
||||
},
|
||||
{
|
||||
name: 'UIEditor',
|
||||
type: 'editor',
|
||||
loadingPhase: 'default',
|
||||
entry: './src/editor/index.ts'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{ id: '@esengine/core', version: '^1.0.0' }
|
||||
],
|
||||
icon: 'LayoutGrid'
|
||||
};
|
||||
|
||||
/**
|
||||
* UI 运行时模块
|
||||
* UI runtime module
|
||||
*/
|
||||
export class UIRuntimeModule implements IRuntimeModuleLoader {
|
||||
registerComponents(registry: typeof ComponentRegistry): void {
|
||||
registry.register(UITransformComponent);
|
||||
registry.register(UIRenderComponent);
|
||||
registry.register(UIInteractableComponent);
|
||||
registry.register(UITextComponent);
|
||||
registry.register(UILayoutComponent);
|
||||
registry.register(UIButtonComponent);
|
||||
registry.register(UIProgressBarComponent);
|
||||
registry.register(UISliderComponent);
|
||||
registry.register(UIScrollViewComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
// UI Layout System (order: 50)
|
||||
const layoutSystem = new UILayoutSystem();
|
||||
scene.addSystem(layoutSystem);
|
||||
|
||||
// UI Render Begin System - clears collector at start of frame (order: 99)
|
||||
const renderBeginSystem = new UIRenderBeginSystem();
|
||||
scene.addSystem(renderBeginSystem);
|
||||
|
||||
// UI Render Systems - collect render data (order: 100-120)
|
||||
const rectRenderSystem = new UIRectRenderSystem();
|
||||
scene.addSystem(rectRenderSystem);
|
||||
|
||||
const progressBarRenderSystem = new UIProgressBarRenderSystem();
|
||||
scene.addSystem(progressBarRenderSystem);
|
||||
|
||||
const sliderRenderSystem = new UISliderRenderSystem();
|
||||
scene.addSystem(sliderRenderSystem);
|
||||
|
||||
const scrollViewRenderSystem = new UIScrollViewRenderSystem();
|
||||
scene.addSystem(scrollViewRenderSystem);
|
||||
|
||||
const buttonRenderSystem = new UIButtonRenderSystem();
|
||||
scene.addSystem(buttonRenderSystem);
|
||||
|
||||
const textRenderSystem = new UITextRenderSystem();
|
||||
scene.addSystem(textRenderSystem);
|
||||
|
||||
// Set up text texture callback to register textures with engine
|
||||
// 设置文本纹理回调以将纹理注册到引擎
|
||||
if (context.engineBridge) {
|
||||
textRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
|
||||
// Load data URL as texture
|
||||
context.engineBridge.loadTexture(id, dataUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// UI Render Data Provider (not a system, just a provider)
|
||||
// Note: Don't call addRenderDataProvider here - UI provider should be set via
|
||||
// setUIRenderDataProvider for proper preview mode support
|
||||
// 注意:不要在这里调用 addRenderDataProvider - UI 提供者应该通过
|
||||
// setUIRenderDataProvider 设置以支持预览模式
|
||||
const uiRenderProvider = new UIRenderDataProvider();
|
||||
|
||||
// UI Input System
|
||||
const inputSystem = new UIInputSystem();
|
||||
scene.addSystem(inputSystem);
|
||||
|
||||
// 保存引用 | Save references
|
||||
context.uiLayoutSystem = layoutSystem;
|
||||
context.uiRenderProvider = uiRenderProvider;
|
||||
context.uiInputSystem = inputSystem;
|
||||
context.uiTextRenderSystem = textRenderSystem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 插件加载器
|
||||
* UI plugin loader
|
||||
*/
|
||||
export const UIPlugin: IPluginLoader = {
|
||||
descriptor,
|
||||
runtimeModule: new UIRuntimeModule(),
|
||||
editorModule: new UIEditorModule(),
|
||||
};
|
||||
|
||||
export default UIPlugin;
|
||||
55
packages/ui/src/editor/gizmos/UITransformGizmo.ts
Normal file
55
packages/ui/src/editor/gizmos/UITransformGizmo.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import type { IGizmoRenderData, IRectGizmoData, GizmoColor } from '@esengine/editor-core';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { UITransformComponent } from '../../components';
|
||||
|
||||
const UI_GIZMO_COLOR: GizmoColor = { r: 0.2, g: 0.6, b: 1, a: 0.8 };
|
||||
const UI_GIZMO_COLOR_UNSELECTED: GizmoColor = { r: 0.2, g: 0.6, b: 1, a: 0.3 };
|
||||
|
||||
function uiTransformGizmoProvider(
|
||||
transform: UITransformComponent,
|
||||
_entity: Entity,
|
||||
isSelected: boolean
|
||||
): IGizmoRenderData[] {
|
||||
if (!transform.visible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use world coordinates (computed by UILayoutSystem) if available
|
||||
// Otherwise fallback to local coordinates
|
||||
// 使用世界坐标(由 UILayoutSystem 计算),如果可用
|
||||
// 否则回退到本地坐标
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
|
||||
// Use bottom-left position with origin at (0, 0)
|
||||
// x, y is bottom-left corner in UITransform coordinate system (Y-up)
|
||||
// This matches Gizmo origin=(0,0) which means reference point is at bottom-left
|
||||
// 使用左下角位置,原点在 (0, 0)
|
||||
// UITransform 坐标系中 x, y 是左下角(Y 向上)
|
||||
// 这与 Gizmo origin=(0,0) 匹配,表示参考点在左下角
|
||||
const gizmo: IRectGizmoData = {
|
||||
type: 'rect',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation: transform.rotation,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
color: isSelected ? UI_GIZMO_COLOR : UI_GIZMO_COLOR_UNSELECTED,
|
||||
showHandles: isSelected
|
||||
};
|
||||
|
||||
return [gizmo];
|
||||
}
|
||||
|
||||
export function registerUITransformGizmo(): void {
|
||||
GizmoRegistry.register(UITransformComponent, uiTransformGizmoProvider);
|
||||
}
|
||||
|
||||
export function unregisterUITransformGizmo(): void {
|
||||
GizmoRegistry.unregister(UITransformComponent);
|
||||
}
|
||||
1
packages/ui/src/editor/gizmos/index.ts
Normal file
1
packages/ui/src/editor/gizmos/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './UITransformGizmo';
|
||||
410
packages/ui/src/editor/index.ts
Normal file
410
packages/ui/src/editor/index.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* UI 编辑器模块入口
|
||||
* UI Editor Module Entry
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LayoutGrid, Square, Type, MousePointer2, Sliders, BarChart3, ScrollText, PanelTop } from 'lucide-react';
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentAction,
|
||||
ComponentInspectorProviderDef
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
ComponentRegistry,
|
||||
ComponentInspectorRegistry
|
||||
} from '@esengine/editor-core';
|
||||
|
||||
// Local imports
|
||||
import {
|
||||
UITransformComponent,
|
||||
UIRenderComponent,
|
||||
UIInteractableComponent,
|
||||
UITextComponent,
|
||||
UILayoutComponent,
|
||||
UILayoutType,
|
||||
UIJustifyContent,
|
||||
UIAlignItems,
|
||||
UIButtonComponent,
|
||||
UIProgressBarComponent,
|
||||
UISliderComponent,
|
||||
UIScrollViewComponent
|
||||
} from '../components';
|
||||
import { UITransformInspector } from './inspectors';
|
||||
import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
||||
|
||||
// Re-exports
|
||||
export { UITransformInspector } from './inspectors';
|
||||
export { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
|
||||
|
||||
/**
|
||||
* UI 编辑器模块
|
||||
* UI Editor Module
|
||||
*/
|
||||
export class UIEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// 注册 UI 组件到编辑器组件注册表 | Register UI components to editor component registry
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
const uiComponents = [
|
||||
{ name: 'UITransform', type: UITransformComponent, category: 'components.category.ui', description: 'UI element positioning and sizing', icon: 'Move' },
|
||||
{ name: 'UIRender', type: UIRenderComponent, category: 'components.category.ui', description: 'UI element visual appearance', icon: 'Palette' },
|
||||
{ name: 'UIInteractable', type: UIInteractableComponent, category: 'components.category.ui', description: 'UI element interaction handling', icon: 'MousePointer2' },
|
||||
{ name: 'UIText', type: UITextComponent, category: 'components.category.ui', description: 'Text rendering component', icon: 'Type' },
|
||||
{ name: 'UILayout', type: UILayoutComponent, category: 'components.category.ui', description: 'Automatic child layout (Flexbox-like)', icon: 'LayoutGrid' },
|
||||
{ name: 'UIButton', type: UIButtonComponent, category: 'components.category.ui.widgets', description: 'Interactive button component', icon: 'RectangleHorizontal' },
|
||||
{ name: 'UIProgressBar', type: UIProgressBarComponent, category: 'components.category.ui.widgets', description: 'Progress indicator component', icon: 'BarChart3' },
|
||||
{ name: 'UISlider', type: UISliderComponent, category: 'components.category.ui.widgets', description: 'Value slider component', icon: 'Sliders' },
|
||||
{ name: 'UIScrollView', type: UIScrollViewComponent, category: 'components.category.ui.widgets', description: 'Scrollable container component', icon: 'ScrollText' },
|
||||
];
|
||||
|
||||
for (const comp of uiComponents) {
|
||||
componentRegistry.register({
|
||||
name: comp.name,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description,
|
||||
icon: comp.icon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 注册自定义组件检视器 | Register custom component inspectors
|
||||
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
|
||||
if (componentInspectorRegistry) {
|
||||
componentInspectorRegistry.register(new UITransformInspector());
|
||||
}
|
||||
|
||||
// 注册 Gizmo | Register gizmo
|
||||
registerUITransformGizmo();
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
unregisterUITransformGizmo();
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
// UI Canvas (Root container)
|
||||
{
|
||||
id: 'create-ui-canvas',
|
||||
label: 'UI Canvas',
|
||||
icon: 'PanelTop',
|
||||
category: 'ui',
|
||||
order: 200,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('UI Canvas', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 1920;
|
||||
transform.height = 1080;
|
||||
transform.anchorMinX = 0;
|
||||
transform.anchorMinY = 0;
|
||||
transform.anchorMaxX = 1;
|
||||
transform.anchorMaxY = 1;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Panel
|
||||
{
|
||||
id: 'create-ui-panel',
|
||||
label: 'Panel',
|
||||
icon: 'Square',
|
||||
category: 'ui',
|
||||
order: 201,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('Panel', (entity) => {
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundColor = 0x2D2D2D;
|
||||
render.backgroundAlpha = 0.9;
|
||||
render.setCornerRadius(8);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Text
|
||||
{
|
||||
id: 'create-ui-text',
|
||||
label: 'Text',
|
||||
icon: 'Type',
|
||||
category: 'ui',
|
||||
order: 202,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('Text', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 30;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
|
||||
const text = new UITextComponent();
|
||||
text.text = 'Hello World';
|
||||
text.fontSize = 16;
|
||||
text.color = 0xFFFFFF;
|
||||
entity.addComponent(text);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Button
|
||||
{
|
||||
id: 'create-ui-button',
|
||||
label: 'Button',
|
||||
icon: 'MousePointer2',
|
||||
category: 'ui',
|
||||
order: 203,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('Button', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 120;
|
||||
transform.height = 40;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.setCornerRadius(4);
|
||||
|
||||
const button = new UIButtonComponent();
|
||||
button.label = 'Button';
|
||||
entity.addComponent(button);
|
||||
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
interactable.cursor = 'pointer';
|
||||
|
||||
const text = new UITextComponent();
|
||||
text.text = 'Button';
|
||||
text.fontSize = 14;
|
||||
text.color = 0xFFFFFF;
|
||||
text.align = 'center';
|
||||
text.verticalAlign = 'middle';
|
||||
entity.addComponent(text);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Slider
|
||||
{
|
||||
id: 'create-ui-slider',
|
||||
label: 'Slider',
|
||||
icon: 'Sliders',
|
||||
category: 'ui',
|
||||
order: 204,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('Slider', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 20;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
if (render) {
|
||||
entity.removeComponent(render);
|
||||
}
|
||||
|
||||
const slider = new UISliderComponent();
|
||||
slider.value = 50;
|
||||
slider.minValue = 0;
|
||||
slider.maxValue = 100;
|
||||
entity.addComponent(slider);
|
||||
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
interactable.cursor = 'pointer';
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Progress Bar
|
||||
{
|
||||
id: 'create-ui-progressbar',
|
||||
label: 'ProgressBar',
|
||||
icon: 'BarChart3',
|
||||
category: 'ui',
|
||||
order: 205,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('ProgressBar', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 20;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
if (render) {
|
||||
entity.removeComponent(render);
|
||||
}
|
||||
|
||||
const progress = new UIProgressBarComponent();
|
||||
progress.value = 50;
|
||||
progress.minValue = 0;
|
||||
progress.maxValue = 100;
|
||||
progress.cornerRadius = 4;
|
||||
entity.addComponent(progress);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI ScrollView
|
||||
{
|
||||
id: 'create-ui-scrollview',
|
||||
label: 'ScrollView',
|
||||
icon: 'ScrollText',
|
||||
category: 'ui',
|
||||
order: 206,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('ScrollView', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 300;
|
||||
transform.height = 400;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundColor = 0x1A1A1A;
|
||||
render.setCornerRadius(4);
|
||||
|
||||
const scrollView = new UIScrollViewComponent();
|
||||
scrollView.verticalScroll = true;
|
||||
scrollView.horizontalScroll = false;
|
||||
scrollView.contentHeight = 800;
|
||||
entity.addComponent(scrollView);
|
||||
|
||||
const interactable = entity.getComponent(UIInteractableComponent)!;
|
||||
interactable.enabled = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Layout Container (Horizontal)
|
||||
{
|
||||
id: 'create-ui-hlayout',
|
||||
label: 'HLayout',
|
||||
icon: 'LayoutGrid',
|
||||
category: 'ui',
|
||||
order: 207,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('HLayout', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 400;
|
||||
transform.height = 100;
|
||||
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Horizontal;
|
||||
layout.gap = 10;
|
||||
layout.justifyContent = UIJustifyContent.Start;
|
||||
layout.alignItems = UIAlignItems.Center;
|
||||
entity.addComponent(layout);
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Layout Container (Vertical)
|
||||
{
|
||||
id: 'create-ui-vlayout',
|
||||
label: 'VLayout',
|
||||
icon: 'LayoutGrid',
|
||||
category: 'ui',
|
||||
order: 208,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('VLayout', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 200;
|
||||
transform.height = 400;
|
||||
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Vertical;
|
||||
layout.gap = 10;
|
||||
layout.justifyContent = UIJustifyContent.Start;
|
||||
layout.alignItems = UIAlignItems.Stretch;
|
||||
entity.addComponent(layout);
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// UI Grid Layout
|
||||
{
|
||||
id: 'create-ui-grid',
|
||||
label: 'Grid',
|
||||
icon: 'LayoutGrid',
|
||||
category: 'ui',
|
||||
order: 209,
|
||||
create: (): number => {
|
||||
return this.createUIEntity('Grid', (entity) => {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
transform.width = 400;
|
||||
transform.height = 400;
|
||||
|
||||
const layout = new UILayoutComponent();
|
||||
layout.type = UILayoutType.Grid;
|
||||
layout.columns = 3;
|
||||
layout.gap = 10;
|
||||
entity.addComponent(layout);
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
render.backgroundAlpha = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 UI 实体的辅助方法
|
||||
* Helper method to create UI entity
|
||||
*/
|
||||
private createUIEntity(baseName: string, configure?: (entity: Entity) => void): number {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const existingCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith(baseName)).length;
|
||||
const entityName = existingCount > 0 ? `${baseName} ${existingCount + 1}` : baseName;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
|
||||
const transform = new UITransformComponent();
|
||||
transform.width = 100;
|
||||
transform.height = 100;
|
||||
entity.addComponent(transform);
|
||||
|
||||
const render = new UIRenderComponent();
|
||||
render.backgroundColor = 0x4A90D9;
|
||||
entity.addComponent(render);
|
||||
|
||||
const interactable = new UIInteractableComponent();
|
||||
entity.addComponent(interactable);
|
||||
|
||||
if (configure) {
|
||||
configure(entity);
|
||||
}
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
|
||||
export const uiEditorModule = new UIEditorModule();
|
||||
|
||||
// Plugin exports
|
||||
export { UIPlugin, UIRuntimeModule } from './UIPlugin';
|
||||
export default uiEditorModule;
|
||||
454
packages/ui/src/editor/inspectors/UITransformInspector.tsx
Normal file
454
packages/ui/src/editor/inspectors/UITransformInspector.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Component } from '@esengine/ecs-framework';
|
||||
import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
|
||||
import { UITransformComponent, AnchorPreset } from '../../components';
|
||||
|
||||
const DraggableNumberInput: React.FC<{
|
||||
axis?: 'x' | 'y' | 'z' | 'w';
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ axis, label, value, onChange, min, max, step = 0.1, readOnly }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, value: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
const sensitivity = e.shiftKey ? 0.01 : step;
|
||||
let newValue = dragStartRef.current.value + delta * sensitivity;
|
||||
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(Math.round(newValue * 1000) / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, onChange, step, min, max]);
|
||||
|
||||
const axisClass = axis ? `property-vector-axis-${axis}` : '';
|
||||
const displayLabel = label || (axis ? axis.toUpperCase() : '');
|
||||
|
||||
return (
|
||||
<div className="property-vector-axis-compact">
|
||||
<span
|
||||
className={`property-vector-axis-label ${axisClass}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number-compact"
|
||||
value={value ?? 0}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Vector2Row: React.FC<{
|
||||
label: string;
|
||||
valueX: number;
|
||||
valueY: number;
|
||||
onChangeX: (value: number) => void;
|
||||
onChangeY: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, valueX, valueY, onChangeX, onChangeY, min, max, step, readOnly }) => (
|
||||
<div className="property-field">
|
||||
<label className="property-label">{label}</label>
|
||||
<div className="property-vector-compact">
|
||||
<DraggableNumberInput axis="x" value={valueX} onChange={onChangeX} min={min} max={max} step={step} readOnly={readOnly} />
|
||||
<DraggableNumberInput axis="y" value={valueY} onChange={onChangeY} min={min} max={max} step={step} readOnly={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NumberRow: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, value, onChange, min, max, step = 0.1, readOnly }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartValue, setDragStartValue] = useState(0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartValue(value);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - dragStartX;
|
||||
const sensitivity = e.shiftKey ? 0.01 : step;
|
||||
let newValue = dragStartValue + delta * sensitivity;
|
||||
|
||||
if (min !== undefined) newValue = Math.max(min, newValue);
|
||||
if (max !== undefined) newValue = Math.min(max, newValue);
|
||||
|
||||
onChange(parseFloat(newValue.toFixed(3)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<div className="property-field">
|
||||
<label
|
||||
className="property-label property-label-draggable"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="property-input property-input-number"
|
||||
value={value ?? 0}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BooleanRow: React.FC<{
|
||||
label: string;
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
}> = ({ label, value, onChange, readOnly }) => (
|
||||
<div className="property-field property-field-boolean">
|
||||
<label className="property-label">{label}</label>
|
||||
<button
|
||||
className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<span className="property-toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnchorPresetGrid: React.FC<{
|
||||
currentPreset: string;
|
||||
onSelect: (preset: AnchorPreset) => void;
|
||||
}> = ({ currentPreset, onSelect }) => {
|
||||
const presets: AnchorPreset[][] = [
|
||||
[AnchorPreset.TopLeft, AnchorPreset.TopCenter, AnchorPreset.TopRight],
|
||||
[AnchorPreset.MiddleLeft, AnchorPreset.MiddleCenter, AnchorPreset.MiddleRight],
|
||||
[AnchorPreset.BottomLeft, AnchorPreset.BottomCenter, AnchorPreset.BottomRight],
|
||||
];
|
||||
|
||||
const getAnchorPosition = (preset: AnchorPreset): { x: number; y: number } => {
|
||||
const positions: Record<AnchorPreset, { x: number; y: number }> = {
|
||||
[AnchorPreset.TopLeft]: { x: 3, y: 3 },
|
||||
[AnchorPreset.TopCenter]: { x: 10, y: 3 },
|
||||
[AnchorPreset.TopRight]: { x: 17, y: 3 },
|
||||
[AnchorPreset.MiddleLeft]: { x: 3, y: 10 },
|
||||
[AnchorPreset.MiddleCenter]: { x: 10, y: 10 },
|
||||
[AnchorPreset.MiddleRight]: { x: 17, y: 10 },
|
||||
[AnchorPreset.BottomLeft]: { x: 3, y: 17 },
|
||||
[AnchorPreset.BottomCenter]: { x: 10, y: 17 },
|
||||
[AnchorPreset.BottomRight]: { x: 17, y: 17 },
|
||||
[AnchorPreset.StretchAll]: { x: 10, y: 10 },
|
||||
};
|
||||
return positions[preset];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-field" style={{ alignItems: 'flex-start' }}>
|
||||
<label className="property-label">Anchor</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 24px)',
|
||||
gridTemplateRows: 'repeat(3, 24px)',
|
||||
gap: '2px',
|
||||
padding: '4px',
|
||||
background: 'var(--color-bg-inset)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}>
|
||||
{presets.flat().map((preset) => {
|
||||
const pos = getAnchorPosition(preset);
|
||||
const isActive = currentPreset === preset;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => onSelect(preset)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
border: '1px solid',
|
||||
borderColor: isActive ? 'var(--color-primary)' : 'var(--color-border-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: isActive ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
title={preset}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20">
|
||||
<rect
|
||||
x="2" y="2" width="16" height="16"
|
||||
fill="none"
|
||||
stroke={isActive ? 'var(--color-primary)' : 'var(--color-text-tertiary)'}
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
<circle
|
||||
cx={pos.x} cy={pos.y} r="3"
|
||||
fill={isActive ? 'var(--color-primary)' : 'var(--color-text-secondary)'}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelect(AnchorPreset.StretchAll)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '22px',
|
||||
padding: '0 8px',
|
||||
border: '1px solid',
|
||||
borderColor: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-border-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
|
||||
color: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
title="Stretch All"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14">
|
||||
<rect x="1" y="1" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" strokeWidth="1.5" />
|
||||
<line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
Stretch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class UITransformInspector implements IComponentInspector<UITransformComponent> {
|
||||
readonly id = 'uitransform-inspector';
|
||||
readonly name = 'UITransform Inspector';
|
||||
readonly priority = 100;
|
||||
readonly targetComponents = ['UITransform', 'UITransformComponent'];
|
||||
|
||||
canHandle(component: Component): component is UITransformComponent {
|
||||
return component instanceof UITransformComponent ||
|
||||
component.constructor.name === 'UITransformComponent';
|
||||
}
|
||||
|
||||
render(context: ComponentInspectorContext): React.ReactElement {
|
||||
const transform = context.component as UITransformComponent;
|
||||
const onChange = context.onChange;
|
||||
|
||||
const handleChange = (prop: string, value: number | boolean | string) => {
|
||||
onChange?.(prop, value);
|
||||
};
|
||||
|
||||
const detectCurrentPreset = (): string => {
|
||||
const { anchorMinX, anchorMinY, anchorMaxX, anchorMaxY } = transform;
|
||||
if (anchorMinX === 0 && anchorMinY === 0 && anchorMaxX === 1 && anchorMaxY === 1) {
|
||||
return AnchorPreset.StretchAll;
|
||||
}
|
||||
if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) {
|
||||
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.TopLeft;
|
||||
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.TopCenter;
|
||||
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.TopRight;
|
||||
if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft;
|
||||
if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter;
|
||||
if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight;
|
||||
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.BottomLeft;
|
||||
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.BottomCenter;
|
||||
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.BottomRight;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: AnchorPreset) => {
|
||||
const presetValues: Record<AnchorPreset, [number, number, number, number]> = {
|
||||
[AnchorPreset.TopLeft]: [0, 0, 0, 0],
|
||||
[AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0],
|
||||
[AnchorPreset.TopRight]: [1, 0, 1, 0],
|
||||
[AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5],
|
||||
[AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5],
|
||||
[AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5],
|
||||
[AnchorPreset.BottomLeft]: [0, 1, 0, 1],
|
||||
[AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1],
|
||||
[AnchorPreset.BottomRight]: [1, 1, 1, 1],
|
||||
[AnchorPreset.StretchAll]: [0, 0, 1, 1],
|
||||
};
|
||||
|
||||
const [minX, minY, maxX, maxY] = presetValues[preset];
|
||||
handleChange('anchorMinX', minX);
|
||||
handleChange('anchorMinY', minY);
|
||||
handleChange('anchorMaxX', maxX);
|
||||
handleChange('anchorMaxY', maxY);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-inspector">
|
||||
<AnchorPresetGrid
|
||||
currentPreset={detectCurrentPreset()}
|
||||
onSelect={handlePresetSelect}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Position"
|
||||
valueX={transform.x}
|
||||
valueY={transform.y}
|
||||
onChangeX={(v) => handleChange('x', v)}
|
||||
onChangeY={(v) => handleChange('y', v)}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Size"
|
||||
valueX={transform.width}
|
||||
valueY={transform.height}
|
||||
onChangeX={(v) => handleChange('width', v)}
|
||||
onChangeY={(v) => handleChange('height', v)}
|
||||
min={0}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Anchor Min"
|
||||
valueX={transform.anchorMinX}
|
||||
valueY={transform.anchorMinY}
|
||||
onChangeX={(v) => handleChange('anchorMinX', v)}
|
||||
onChangeY={(v) => handleChange('anchorMinY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Anchor Max"
|
||||
valueX={transform.anchorMaxX}
|
||||
valueY={transform.anchorMaxY}
|
||||
onChangeX={(v) => handleChange('anchorMaxX', v)}
|
||||
onChangeY={(v) => handleChange('anchorMaxY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Pivot"
|
||||
valueX={transform.pivotX}
|
||||
valueY={transform.pivotY}
|
||||
onChangeX={(v) => handleChange('pivotX', v)}
|
||||
onChangeY={(v) => handleChange('pivotY', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Rotation"
|
||||
value={transform.rotation}
|
||||
onChange={(v) => handleChange('rotation', v)}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<Vector2Row
|
||||
label="Scale"
|
||||
valueX={transform.scaleX}
|
||||
valueY={transform.scaleY}
|
||||
onChangeX={(v) => handleChange('scaleX', v)}
|
||||
onChangeY={(v) => handleChange('scaleY', v)}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Z Index"
|
||||
value={transform.zIndex}
|
||||
onChange={(v) => handleChange('zIndex', Math.round(v))}
|
||||
step={1}
|
||||
/>
|
||||
|
||||
<NumberRow
|
||||
label="Alpha"
|
||||
value={transform.alpha}
|
||||
onChange={(v) => handleChange('alpha', v)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
|
||||
<BooleanRow
|
||||
label="Visible"
|
||||
value={transform.visible}
|
||||
onChange={(v) => handleChange('visible', v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
1
packages/ui/src/editor/inspectors/index.ts
Normal file
1
packages/ui/src/editor/inspectors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './UITransformInspector';
|
||||
@@ -6,15 +6,42 @@
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { UIBuilder, UILayoutSystem, UIInputSystem, UIAnimationSystem } from '@esengine/ui';
|
||||
* import {
|
||||
* UIBuilder,
|
||||
* UILayoutSystem,
|
||||
* UIInputSystem,
|
||||
* UIAnimationSystem,
|
||||
* // ECS Render Systems
|
||||
* UIRectRenderSystem,
|
||||
* UITextRenderSystem,
|
||||
* UIButtonRenderSystem,
|
||||
* UIProgressBarRenderSystem,
|
||||
* UISliderRenderSystem,
|
||||
* UIScrollViewRenderSystem,
|
||||
* getUIRenderCollector
|
||||
* } from '@esengine/ui';
|
||||
*
|
||||
* // 创建 UI Scene
|
||||
* const uiScene = world.createScene('ui');
|
||||
*
|
||||
* // 添加 UI 系统
|
||||
* uiScene.addSystem(new UILayoutSystem());
|
||||
* uiScene.addSystem(new UIInputSystem());
|
||||
* uiScene.addSystem(new UIAnimationSystem());
|
||||
* // 添加 UI 系统(按 updateOrder 自动排序)
|
||||
* // Add UI systems (auto-sorted by updateOrder)
|
||||
* uiScene.addSystem(new UILayoutSystem()); // Layout first
|
||||
* uiScene.addSystem(new UIInputSystem()); // Input handling
|
||||
* uiScene.addSystem(new UIAnimationSystem()); // Animation
|
||||
*
|
||||
* // 添加渲染系统(每个组件类型一个系统)
|
||||
* // Add render systems (one per component type)
|
||||
* uiScene.addSystem(new UIRectRenderSystem()); // Basic rectangles (order: 100)
|
||||
* uiScene.addSystem(new UIProgressBarRenderSystem());// Progress bars (order: 110)
|
||||
* uiScene.addSystem(new UISliderRenderSystem()); // Sliders (order: 111)
|
||||
* uiScene.addSystem(new UIScrollViewRenderSystem()); // Scroll views (order: 112)
|
||||
* uiScene.addSystem(new UIButtonRenderSystem()); // Buttons (order: 113)
|
||||
* uiScene.addSystem(new UITextRenderSystem()); // Text (order: 120)
|
||||
*
|
||||
* // 在渲染前清除收集器
|
||||
* // Clear collector before render
|
||||
* getUIRenderCollector().clear();
|
||||
*
|
||||
* // 使用 UIBuilder 创建元素
|
||||
* const ui = new UIBuilder(uiScene);
|
||||
@@ -92,11 +119,30 @@ export {
|
||||
UIScrollbarVisibility
|
||||
} from './components/widgets/UIScrollViewComponent';
|
||||
|
||||
// Systems
|
||||
// Systems - Core
|
||||
export { UILayoutSystem } from './systems/UILayoutSystem';
|
||||
export { UIInputSystem, MouseButton, type UIInputEvent } from './systems/UIInputSystem';
|
||||
export { UIAnimationSystem, Easing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
|
||||
export { UIRenderDataProvider, type UIRenderData } from './systems/UIRenderDataProvider';
|
||||
export { UIRenderDataProvider, type IRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||
|
||||
// Systems - Render (ECS-compliant render systems)
|
||||
export {
|
||||
// Collector
|
||||
UIRenderCollector,
|
||||
getUIRenderCollector,
|
||||
resetUIRenderCollector,
|
||||
invalidateUIRenderCaches,
|
||||
type UIRenderPrimitive,
|
||||
type ProviderRenderData,
|
||||
// Render systems
|
||||
UIRenderBeginSystem,
|
||||
UIRectRenderSystem,
|
||||
UITextRenderSystem,
|
||||
UIButtonRenderSystem,
|
||||
UIProgressBarRenderSystem,
|
||||
UISliderRenderSystem,
|
||||
UIScrollViewRenderSystem
|
||||
} from './systems/render';
|
||||
|
||||
// Rendering
|
||||
export { WebGLUIRenderer } from './rendering/WebGLUIRenderer';
|
||||
@@ -114,3 +160,9 @@ export {
|
||||
type UIPanelConfig,
|
||||
type UIScrollViewConfig
|
||||
} from './UIBuilder';
|
||||
|
||||
// Runtime module (no editor dependencies)
|
||||
export { UIRuntimeModule } from './UIRuntimeModule';
|
||||
|
||||
// Plugin (for PluginManager - includes editor dependencies)
|
||||
export { UIPlugin } from './editor/UIPlugin';
|
||||
|
||||
111
packages/ui/src/runtime.ts
Normal file
111
packages/ui/src/runtime.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @esengine/ui Runtime Entry Point
|
||||
*
|
||||
* This entry point exports only runtime-related code without any editor dependencies.
|
||||
* Use this for standalone game runtime builds.
|
||||
*
|
||||
* 此入口点仅导出运行时相关代码,不包含任何编辑器依赖。
|
||||
* 用于独立游戏运行时构建。
|
||||
*/
|
||||
|
||||
// Components - Core
|
||||
export {
|
||||
UITransformComponent,
|
||||
AnchorPreset
|
||||
} from './components/UITransformComponent';
|
||||
|
||||
export {
|
||||
UIRenderComponent,
|
||||
UIRenderType,
|
||||
type UIBorderStyle,
|
||||
type UIShadowStyle
|
||||
} from './components/UIRenderComponent';
|
||||
|
||||
export {
|
||||
UIInteractableComponent,
|
||||
type UICursorType
|
||||
} from './components/UIInteractableComponent';
|
||||
|
||||
export {
|
||||
UITextComponent,
|
||||
type UITextAlign,
|
||||
type UITextVerticalAlign,
|
||||
type UITextOverflow,
|
||||
type UIFontWeight
|
||||
} from './components/UITextComponent';
|
||||
|
||||
export {
|
||||
UILayoutComponent,
|
||||
UILayoutType,
|
||||
UIJustifyContent,
|
||||
UIAlignItems,
|
||||
type UIPadding
|
||||
} from './components/UILayoutComponent';
|
||||
|
||||
// Components - Widgets
|
||||
export {
|
||||
UIButtonComponent,
|
||||
type UIButtonStyle,
|
||||
type UIButtonDisplayMode
|
||||
} from './components/widgets/UIButtonComponent';
|
||||
|
||||
export {
|
||||
UIProgressBarComponent,
|
||||
UIProgressDirection,
|
||||
UIProgressFillMode
|
||||
} from './components/widgets/UIProgressBarComponent';
|
||||
|
||||
export {
|
||||
UISliderComponent,
|
||||
UISliderOrientation
|
||||
} from './components/widgets/UISliderComponent';
|
||||
|
||||
export {
|
||||
UIScrollViewComponent,
|
||||
UIScrollbarVisibility
|
||||
} from './components/widgets/UIScrollViewComponent';
|
||||
|
||||
// Systems - Core
|
||||
export { UILayoutSystem } from './systems/UILayoutSystem';
|
||||
export { UIInputSystem, MouseButton, type UIInputEvent } from './systems/UIInputSystem';
|
||||
export { UIAnimationSystem, Easing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
|
||||
export { UIRenderDataProvider, type IRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
|
||||
|
||||
// Systems - Render (ECS-compliant render systems)
|
||||
export {
|
||||
// Collector
|
||||
UIRenderCollector,
|
||||
getUIRenderCollector,
|
||||
resetUIRenderCollector,
|
||||
invalidateUIRenderCaches,
|
||||
type UIRenderPrimitive,
|
||||
type ProviderRenderData,
|
||||
// Render systems
|
||||
UIRenderBeginSystem,
|
||||
UIRectRenderSystem,
|
||||
UITextRenderSystem,
|
||||
UIButtonRenderSystem,
|
||||
UIProgressBarRenderSystem,
|
||||
UISliderRenderSystem,
|
||||
UIScrollViewRenderSystem
|
||||
} from './systems/render';
|
||||
|
||||
// Rendering
|
||||
export { WebGLUIRenderer } from './rendering/WebGLUIRenderer';
|
||||
export { TextRenderer, type TextMeasurement, type TextRenderOptions } from './rendering/TextRenderer';
|
||||
|
||||
// Builder API
|
||||
export {
|
||||
UIBuilder,
|
||||
type UIBaseConfig,
|
||||
type UIButtonConfig,
|
||||
type UITextConfig,
|
||||
type UIImageConfig,
|
||||
type UIProgressBarConfig,
|
||||
type UISliderConfig,
|
||||
type UIPanelConfig,
|
||||
type UIScrollViewConfig
|
||||
} from './UIBuilder';
|
||||
|
||||
// Runtime module
|
||||
export { UIRuntimeModule } from './UIRuntimeModule';
|
||||
@@ -8,32 +8,38 @@ import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from
|
||||
*
|
||||
* 计算 UI 元素的世界坐标和尺寸
|
||||
* Computes world coordinates and sizes for UI elements
|
||||
*
|
||||
* 注意:canvasWidth/canvasHeight 是 UI 设计的参考尺寸,不是实际渲染视口大小
|
||||
* Note: canvasWidth/canvasHeight is the UI design reference size, not the actual render viewport size
|
||||
*/
|
||||
@ECSSystem('UILayout')
|
||||
export class UILayoutSystem extends EntitySystem {
|
||||
/**
|
||||
* 视口宽度
|
||||
* Viewport width
|
||||
* UI 画布宽度(设计尺寸)
|
||||
* UI Canvas width (design size)
|
||||
*/
|
||||
public viewportWidth: number = 1920;
|
||||
public canvasWidth: number = 1920;
|
||||
|
||||
/**
|
||||
* 视口高度
|
||||
* Viewport height
|
||||
* UI 画布高度(设计尺寸)
|
||||
* UI Canvas height (design size)
|
||||
*/
|
||||
public viewportHeight: number = 1080;
|
||||
public canvasHeight: number = 1080;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视口尺寸
|
||||
* Set viewport size
|
||||
* 设置 UI 画布尺寸(设计尺寸)
|
||||
* Set UI canvas size (design size)
|
||||
*
|
||||
* 这是 UI 布局计算的参考尺寸,通常是固定的设计分辨率(如 1920x1080)
|
||||
* This is the reference size for UI layout calculation, usually a fixed design resolution (e.g., 1920x1080)
|
||||
*/
|
||||
public setViewport(width: number, height: number): void {
|
||||
this.viewportWidth = width;
|
||||
this.viewportHeight = height;
|
||||
public setCanvasSize(width: number, height: number): void {
|
||||
this.canvasWidth = width;
|
||||
this.canvasHeight = height;
|
||||
|
||||
// 标记所有元素需要重新布局
|
||||
for (const entity of this.entities) {
|
||||
@@ -44,12 +50,27 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 UI 画布尺寸
|
||||
* Get UI canvas size
|
||||
*/
|
||||
public getCanvasSize(): { width: number; height: number } {
|
||||
return { width: this.canvasWidth, height: this.canvasHeight };
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// 首先处理根元素(没有父元素的)
|
||||
const rootEntities = entities.filter(e => !e.parent || !e.parent.hasComponent(UITransformComponent));
|
||||
|
||||
// 画布中心为原点,Y 轴向上为正
|
||||
// Canvas center is origin, Y axis points up
|
||||
// 左上角是 (-width/2, +height/2),右下角是 (+width/2, -height/2)
|
||||
// Top-left is (-width/2, +height/2), bottom-right is (+width/2, -height/2)
|
||||
const parentX = -this.canvasWidth / 2;
|
||||
const parentY = this.canvasHeight / 2; // Y 轴向上,所以顶部是正值
|
||||
|
||||
for (const entity of rootEntities) {
|
||||
this.layoutEntity(entity, 0, 0, this.viewportWidth, this.viewportHeight, 1);
|
||||
this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +90,16 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (!transform) return;
|
||||
|
||||
// 计算锚点位置
|
||||
// X 轴:向右为正,anchorMinX=0 是左边,anchorMinX=1 是右边
|
||||
// Y 轴:向上为正,anchorMinY=0 是顶部,anchorMinY=1 是底部
|
||||
// X axis: right is positive, anchorMinX=0 is left, anchorMinX=1 is right
|
||||
// Y axis: up is positive, anchorMinY=0 is top, anchorMinY=1 is bottom
|
||||
const anchorMinX = parentX + parentWidth * transform.anchorMinX;
|
||||
const anchorMinY = parentY + parentHeight * transform.anchorMinY;
|
||||
const anchorMaxX = parentX + parentWidth * transform.anchorMaxX;
|
||||
const anchorMaxY = parentY + parentHeight * transform.anchorMaxY;
|
||||
// Y 轴反转:parentY 是顶部(正值),向下减少
|
||||
// Y axis inverted: parentY is top (positive), decreases downward
|
||||
const anchorMinY = parentY - parentHeight * transform.anchorMinY;
|
||||
const anchorMaxY = parentY - parentHeight * transform.anchorMaxY;
|
||||
|
||||
// 计算元素尺寸
|
||||
let width: number;
|
||||
@@ -89,7 +116,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (transform.anchorMinY === transform.anchorMaxY) {
|
||||
height = transform.height;
|
||||
} else {
|
||||
height = anchorMaxY - anchorMinY - transform.y;
|
||||
// 拉伸模式:Y 轴反转,anchorMinY > anchorMaxY
|
||||
// Stretch mode: Y axis inverted, anchorMinY > anchorMaxY
|
||||
height = anchorMinY - anchorMaxY - transform.y;
|
||||
}
|
||||
|
||||
// 应用尺寸约束
|
||||
@@ -98,12 +127,15 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (transform.minHeight > 0) height = Math.max(height, transform.minHeight);
|
||||
if (transform.maxHeight > 0) height = Math.min(height, transform.maxHeight);
|
||||
|
||||
// 计算世界位置
|
||||
// 计算世界位置(左下角,与 Gizmo origin=(0,0) 对应)
|
||||
// Calculate world position (bottom-left corner, matching Gizmo origin=(0,0))
|
||||
let worldX: number;
|
||||
let worldY: number;
|
||||
|
||||
if (transform.anchorMinX === transform.anchorMaxX) {
|
||||
// 固定锚点模式
|
||||
// anchor 位置 + position 偏移 - pivot 偏移
|
||||
// 结果是矩形左边缘的 X 坐标
|
||||
worldX = anchorMinX + transform.x - width * transform.pivotX;
|
||||
} else {
|
||||
// 拉伸模式
|
||||
@@ -111,9 +143,21 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
|
||||
if (transform.anchorMinY === transform.anchorMaxY) {
|
||||
worldY = anchorMinY + transform.y - height * transform.pivotY;
|
||||
// 固定锚点模式:Y 轴向上
|
||||
// Fixed anchor mode: Y axis up
|
||||
// anchorMinY 是锚点 Y 位置(anchor=0 在顶部,Y=+540)
|
||||
// position.y 是从锚点的偏移(正值向上)
|
||||
// pivot 决定元素哪个点对齐到 (anchor + position)
|
||||
// worldY 是元素底部的 Y 坐标(与 Gizmo origin=(0,0) 对应)
|
||||
// pivotY=0 意味着元素顶部对齐,pivotY=1 意味着元素底部对齐
|
||||
const anchorPosY = anchorMinY + transform.y; // anchor 位置 + 偏移
|
||||
// pivotY=0: 顶部对齐,底部 = anchorPos - height
|
||||
// pivotY=0.5: 中心对齐,底部 = anchorPos - height/2
|
||||
// pivotY=1: 底部对齐,底部 = anchorPos
|
||||
worldY = anchorPosY - height * (1 - transform.pivotY);
|
||||
} else {
|
||||
worldY = anchorMinY + transform.y;
|
||||
// 拉伸模式:worldY 是底部
|
||||
worldY = anchorMaxY - transform.y;
|
||||
}
|
||||
|
||||
// 更新计算后的值
|
||||
@@ -131,6 +175,10 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
|
||||
if (children.length === 0) return;
|
||||
|
||||
// 计算子元素的父容器边界
|
||||
// 子元素的 parentY 应该是当前元素的顶部 Y 坐标(worldY 是底部,顶部 = 底部 + 高度)
|
||||
const childParentY = worldY + height;
|
||||
|
||||
// 检查是否有布局组件
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
@@ -141,7 +189,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
worldX,
|
||||
worldY,
|
||||
childParentY,
|
||||
width,
|
||||
height,
|
||||
transform.worldAlpha
|
||||
@@ -160,7 +208,10 @@ export class UILayoutSystem extends EntitySystem {
|
||||
children: Entity[]
|
||||
): void {
|
||||
const contentStartX = parentTransform.worldX + layout.paddingLeft;
|
||||
const contentStartY = parentTransform.worldY + layout.paddingTop;
|
||||
// Y-up 系统:worldY 是底部,顶部 = worldY + height
|
||||
// contentStartY 是内容区域的顶部 Y(从顶部减去 paddingTop)
|
||||
const parentTopY = parentTransform.worldY + parentTransform.computedHeight;
|
||||
const contentStartY = parentTopY - layout.paddingTop;
|
||||
const contentWidth = parentTransform.computedWidth - layout.getHorizontalPadding();
|
||||
const contentHeight = parentTransform.computedHeight - layout.getVerticalPadding();
|
||||
|
||||
@@ -175,12 +226,12 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
||||
break;
|
||||
default:
|
||||
// 默认按正常方式递归
|
||||
// 默认按正常方式递归(传递顶部 Y)
|
||||
for (const child of children) {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
parentTransform.worldX,
|
||||
parentTransform.worldY,
|
||||
parentTopY,
|
||||
parentTransform.computedWidth,
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha
|
||||
@@ -245,30 +296,35 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
|
||||
// 布局每个子元素
|
||||
// startY 是内容区域的顶部 Y(Y-up 系统)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]!;
|
||||
const childTransform = child.getComponent(UITransformComponent)!;
|
||||
const size = childSizes[i]!;
|
||||
|
||||
// 计算 Y 位置(基于 alignItems)
|
||||
let childY = startY;
|
||||
// 计算子元素顶部 Y 位置(基于 alignItems)
|
||||
// startY 是内容区域顶部,向下布局意味着 Y 值减小
|
||||
let childTopY = startY; // 默认从顶部开始
|
||||
let childHeight = size.height;
|
||||
|
||||
switch (layout.alignItems) {
|
||||
case UIAlignItems.Center:
|
||||
childY = startY + (contentHeight - childHeight) / 2;
|
||||
// 在内容区域垂直居中:顶部 Y = startY - (contentHeight - childHeight) / 2
|
||||
childTopY = startY - (contentHeight - childHeight) / 2;
|
||||
break;
|
||||
case UIAlignItems.End:
|
||||
childY = startY + contentHeight - childHeight;
|
||||
// 对齐到底部:顶部 Y = startY - contentHeight + childHeight
|
||||
childTopY = startY - contentHeight + childHeight;
|
||||
break;
|
||||
case UIAlignItems.Stretch:
|
||||
childHeight = contentHeight;
|
||||
break;
|
||||
// UIAlignItems.Start: 默认从顶部开始,不需要修改
|
||||
}
|
||||
|
||||
// 直接设置子元素的世界坐标
|
||||
// 直接设置子元素的世界坐标(worldY 是底部 Y)
|
||||
childTransform.worldX = offsetX;
|
||||
childTransform.worldY = childY;
|
||||
childTransform.worldY = childTopY - childHeight; // 底部 Y = 顶部 Y - 高度
|
||||
childTransform.computedWidth = size.width;
|
||||
childTransform.computedHeight = childHeight;
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
@@ -284,6 +340,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
/**
|
||||
* 垂直布局
|
||||
* Vertical layout
|
||||
* Y-up 系统:startY 是内容区域的顶部,子元素从上往下排列(Y 值递减)
|
||||
*/
|
||||
private layoutVertical(
|
||||
layout: UILayoutComponent,
|
||||
@@ -304,16 +361,19 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const totalGap = layout.gap * (children.length - 1);
|
||||
const totalHeight = totalChildHeight + totalGap;
|
||||
|
||||
// 计算起始位置
|
||||
let offsetY = startY;
|
||||
// 计算第一个子元素的顶部 Y(Y-up 系统,从顶部开始向下)
|
||||
// startY 是内容区域顶部
|
||||
let currentTopY = startY; // 从顶部开始
|
||||
let gap = layout.gap;
|
||||
|
||||
switch (layout.justifyContent) {
|
||||
case UIJustifyContent.Center:
|
||||
offsetY = startY + (contentHeight - totalHeight) / 2;
|
||||
// 垂直居中:第一个元素的顶部 Y = startY - (contentHeight - totalHeight) / 2
|
||||
currentTopY = startY - (contentHeight - totalHeight) / 2;
|
||||
break;
|
||||
case UIJustifyContent.End:
|
||||
offsetY = startY + contentHeight - totalHeight;
|
||||
// 对齐到底部:第一个元素的顶部 Y = startY - contentHeight + totalHeight
|
||||
currentTopY = startY - contentHeight + totalHeight;
|
||||
break;
|
||||
case UIJustifyContent.SpaceBetween:
|
||||
if (children.length > 1) {
|
||||
@@ -324,19 +384,19 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (children.length > 0) {
|
||||
const space = (contentHeight - totalChildHeight) / children.length;
|
||||
gap = space;
|
||||
offsetY = startY + space / 2;
|
||||
currentTopY = startY - space / 2;
|
||||
}
|
||||
break;
|
||||
case UIJustifyContent.SpaceEvenly:
|
||||
if (children.length > 0) {
|
||||
const space = (contentHeight - totalChildHeight) / (children.length + 1);
|
||||
gap = space;
|
||||
offsetY = startY + space;
|
||||
currentTopY = startY - space;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 布局每个子元素
|
||||
// 布局每个子元素(从上往下)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]!;
|
||||
const childTransform = child.getComponent(UITransformComponent)!;
|
||||
@@ -358,8 +418,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
break;
|
||||
}
|
||||
|
||||
// worldY 是底部 Y = 顶部 Y - 高度
|
||||
childTransform.worldX = childX;
|
||||
childTransform.worldY = offsetY;
|
||||
childTransform.worldY = currentTopY - size.height;
|
||||
childTransform.computedWidth = childWidth;
|
||||
childTransform.computedHeight = size.height;
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
@@ -367,13 +428,15 @@ export class UILayoutSystem extends EntitySystem {
|
||||
|
||||
this.processChildrenRecursive(child, childTransform);
|
||||
|
||||
offsetY += size.height + gap;
|
||||
// 移动到下一个元素的顶部位置(向下 = Y 减小)
|
||||
currentTopY -= size.height + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网格布局
|
||||
* Grid layout
|
||||
* Y-up 系统:startY 是内容区域的顶部,网格从上往下、从左往右排列
|
||||
*/
|
||||
private layoutGrid(
|
||||
layout: UILayoutComponent,
|
||||
@@ -404,7 +467,11 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const row = Math.floor(i / columns);
|
||||
|
||||
const x = startX + col * (cellWidth + gapX);
|
||||
const y = startY + row * (cellHeight + gapY);
|
||||
// Y-up 系统:第一行在顶部,行号增加 Y 值减小
|
||||
// 单元格顶部 Y = startY - row * (cellHeight + gapY)
|
||||
// 单元格底部 Y = 顶部 Y - cellHeight
|
||||
const cellTopY = startY - row * (cellHeight + gapY);
|
||||
const y = cellTopY - cellHeight; // worldY 是底部 Y
|
||||
|
||||
childTransform.worldX = x;
|
||||
childTransform.worldY = y;
|
||||
@@ -425,6 +492,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
|
||||
if (children.length === 0) return;
|
||||
|
||||
// 计算子元素的父容器顶部 Y(worldY 是底部,顶部 = 底部 + 高度)
|
||||
const parentTopY = parentTransform.worldY + parentTransform.computedHeight;
|
||||
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
this.layoutChildren(layout, parentTransform, children);
|
||||
@@ -433,7 +503,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
parentTransform.worldX,
|
||||
parentTransform.worldY,
|
||||
parentTopY,
|
||||
parentTransform.computedWidth,
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha
|
||||
|
||||
@@ -1,413 +1,116 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../components/UITransformComponent';
|
||||
import { UIRenderComponent } from '../components/UIRenderComponent';
|
||||
import { UITextComponent } from '../components/UITextComponent';
|
||||
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
|
||||
/**
|
||||
* UI Render Data Provider
|
||||
* UI 渲染数据提供者
|
||||
*
|
||||
* This class serves as a coordinator/facade for the UI render systems.
|
||||
* It provides the IRenderDataProvider interface for EngineRenderSystem.
|
||||
*
|
||||
* 此类作为 UI 渲染系统的协调器/外观。
|
||||
* 它为 EngineRenderSystem 提供 IRenderDataProvider 接口。
|
||||
*
|
||||
* The actual rendering logic is delegated to specialized render systems:
|
||||
* - UIRectRenderSystem: Basic rectangles and images
|
||||
* - UITextRenderSystem: Text rendering
|
||||
* - UIButtonRenderSystem: Button components
|
||||
* - UIProgressBarRenderSystem: Progress bars
|
||||
* - UISliderRenderSystem: Sliders
|
||||
* - UIScrollViewRenderSystem: Scroll views
|
||||
*
|
||||
* 实际的渲染逻辑委托给专门的渲染系统:
|
||||
* - UIRectRenderSystem: 基础矩形和图像
|
||||
* - UITextRenderSystem: 文本渲染
|
||||
* - UIButtonRenderSystem: 按钮组件
|
||||
* - UIProgressBarRenderSystem: 进度条
|
||||
* - UISliderRenderSystem: 滑块
|
||||
* - UIScrollViewRenderSystem: 滚动视图
|
||||
*
|
||||
* Render mode is controlled by EngineRenderSystem.previewMode:
|
||||
* - Editor mode (previewMode=false): UI renders in world space with sprites
|
||||
* - Preview mode (previewMode=true): UI renders as screen overlay
|
||||
*
|
||||
* 渲染模式由 EngineRenderSystem.previewMode 控制:
|
||||
* - 编辑器模式 (previewMode=false): UI 与精灵一起在世界空间渲染
|
||||
* - 预览模式 (previewMode=true): UI 作为屏幕叠加层渲染
|
||||
*/
|
||||
|
||||
export interface UIRenderData {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
backgroundColor: number;
|
||||
backgroundAlpha: number;
|
||||
borderColor: number;
|
||||
borderWidth: number;
|
||||
cornerRadius: number;
|
||||
zIndex: number;
|
||||
visible: boolean;
|
||||
text?: {
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
};
|
||||
}
|
||||
import { getUIRenderCollector, type ProviderRenderData } from './render/UIRenderCollector';
|
||||
|
||||
export interface ProviderRenderData {
|
||||
transforms: Float32Array;
|
||||
textureIds: Uint32Array;
|
||||
uvs: Float32Array;
|
||||
colors: Uint32Array;
|
||||
tileCount: number;
|
||||
sortingOrder: number;
|
||||
texturePath?: string;
|
||||
}
|
||||
// Re-export ProviderRenderData for convenience
|
||||
// 为方便起见重新导出 ProviderRenderData
|
||||
export { type ProviderRenderData } from './render/UIRenderCollector';
|
||||
|
||||
/**
|
||||
* Interface for render data providers
|
||||
* 渲染数据提供者接口
|
||||
*/
|
||||
export interface IRenderDataProvider {
|
||||
getRenderData(): readonly ProviderRenderData[];
|
||||
}
|
||||
|
||||
interface TextTextureCache {
|
||||
textureId: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string | number;
|
||||
italic: boolean;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
lineHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataUrl: string;
|
||||
/**
|
||||
* Interface for UI render data providers
|
||||
* UI 渲染数据提供者接口
|
||||
*/
|
||||
export interface IUIRenderDataProvider extends IRenderDataProvider {
|
||||
/** Check if there is content to render | 检查是否有内容需要渲染 */
|
||||
hasContent(): boolean;
|
||||
}
|
||||
|
||||
export class UIRenderDataProvider implements IRenderDataProvider {
|
||||
private textCanvas: HTMLCanvasElement | null = null;
|
||||
private textCtx: CanvasRenderingContext2D | null = null;
|
||||
private textTextureCache: Map<number, TextTextureCache> = new Map();
|
||||
private nextTextureId = 90000;
|
||||
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
|
||||
|
||||
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
|
||||
this.onTextureCreated = callback;
|
||||
}
|
||||
|
||||
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
||||
if (!this.textCanvas) {
|
||||
this.textCanvas = document.createElement('canvas');
|
||||
this.textCtx = this.textCanvas.getContext('2d');
|
||||
}
|
||||
if (!this.textCtx) return null;
|
||||
return { canvas: this.textCanvas, ctx: this.textCtx };
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Render Data Provider
|
||||
* UI 渲染数据提供者
|
||||
*
|
||||
* This is a facade that collects render data from the UIRenderCollector.
|
||||
* The actual rendering is done by the specialized render systems that run
|
||||
* before this provider's getRenderData() is called.
|
||||
*
|
||||
* 这是一个从 UIRenderCollector 收集渲染数据的外观。
|
||||
* 实际渲染由在调用此提供者的 getRenderData() 之前运行的专门渲染系统完成。
|
||||
*
|
||||
* Usage:
|
||||
* 1. Add all UI render systems to the scene (UIRectRenderSystem, UITextRenderSystem, etc.)
|
||||
* 2. Register this provider with EngineRenderSystem
|
||||
* 3. The render systems populate the collector, and this provider returns the data
|
||||
*
|
||||
* 用法:
|
||||
* 1. 将所有 UI 渲染系统添加到场景(UIRectRenderSystem、UITextRenderSystem 等)
|
||||
* 2. 将此提供者注册到 EngineRenderSystem
|
||||
* 3. 渲染系统填充收集器,此提供者返回数据
|
||||
*/
|
||||
export class UIRenderDataProvider implements IUIRenderDataProvider {
|
||||
/**
|
||||
* Get render data from the collector
|
||||
* 从收集器获取渲染数据
|
||||
*/
|
||||
getRenderData(): readonly ProviderRenderData[] {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const uiEntities: Entity[] = [];
|
||||
for (const entity of scene.entities.buffer) {
|
||||
if (entity.hasComponent(UITransformComponent)) {
|
||||
uiEntities.push(entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (uiEntities.length === 0) return [];
|
||||
|
||||
uiEntities.sort((a, b) => {
|
||||
const ta = a.getComponent(UITransformComponent);
|
||||
const tb = b.getComponent(UITransformComponent);
|
||||
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
|
||||
});
|
||||
|
||||
const renderDataList: ProviderRenderData[] = [];
|
||||
|
||||
for (const entity of uiEntities) {
|
||||
const transform = entity.getComponent(UITransformComponent);
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
const text = entity.getComponent(UITextComponent);
|
||||
const button = entity.getComponent(UIButtonComponent);
|
||||
|
||||
if (!transform || !transform.visible) continue;
|
||||
|
||||
const width = transform.width * transform.scaleX;
|
||||
const height = transform.height * transform.scaleY;
|
||||
const centerX = transform.x + width * transform.pivotX;
|
||||
const centerY = transform.y + height * transform.pivotY;
|
||||
|
||||
// Button with texture support
|
||||
if (button && button.useTexture()) {
|
||||
const texture = button.getStateTexture('normal');
|
||||
if (texture) {
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const a = Math.round(transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
|
||||
|
||||
renderDataList.push({
|
||||
transforms,
|
||||
textureIds: new Uint32Array([0]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 100 + transform.zIndex,
|
||||
texturePath: texture
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Background color rendering (for buttons in 'color' or 'both' mode, or regular UI elements)
|
||||
const shouldRenderColor = button
|
||||
? button.useColor() && render && render.backgroundAlpha > 0
|
||||
: render && render.backgroundAlpha > 0;
|
||||
|
||||
if (shouldRenderColor && render) {
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const bgColor = button ? button.currentColor : render.backgroundColor;
|
||||
const r = (bgColor >> 16) & 0xFF;
|
||||
const g = (bgColor >> 8) & 0xFF;
|
||||
const b = bgColor & 0xFF;
|
||||
const a = Math.round(render.backgroundAlpha * transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
|
||||
renderDataList.push({
|
||||
transforms,
|
||||
textureIds: new Uint32Array([0]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 100 + transform.zIndex
|
||||
});
|
||||
}
|
||||
|
||||
if (text && text.text) {
|
||||
const textRenderData = this.createTextRenderData(
|
||||
entity.id,
|
||||
text,
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
transform
|
||||
);
|
||||
if (textRenderData) {
|
||||
renderDataList.push(textRenderData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderDataList;
|
||||
const collector = getUIRenderCollector();
|
||||
return collector.getRenderData();
|
||||
}
|
||||
|
||||
private createTextRenderData(
|
||||
entityId: number,
|
||||
text: UITextComponent,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
transform: UITransformComponent
|
||||
): ProviderRenderData | null {
|
||||
const canvasData = this.getTextCanvas();
|
||||
if (!canvasData) return null;
|
||||
|
||||
const { canvas, ctx } = canvasData;
|
||||
|
||||
const cacheKey = entityId;
|
||||
const cached = this.textTextureCache.get(cacheKey);
|
||||
|
||||
const needsUpdate = !cached ||
|
||||
cached.text !== text.text ||
|
||||
cached.fontSize !== text.fontSize ||
|
||||
cached.fontFamily !== text.fontFamily ||
|
||||
cached.fontWeight !== text.fontWeight ||
|
||||
cached.italic !== text.italic ||
|
||||
cached.color !== text.color ||
|
||||
cached.alpha !== text.alpha ||
|
||||
cached.align !== text.align ||
|
||||
cached.verticalAlign !== text.verticalAlign ||
|
||||
cached.lineHeight !== text.lineHeight ||
|
||||
cached.width !== Math.ceil(width) ||
|
||||
cached.height !== Math.ceil(height);
|
||||
|
||||
if (needsUpdate) {
|
||||
const canvasWidth = Math.max(1, Math.ceil(width));
|
||||
const canvasHeight = Math.max(1, Math.ceil(height));
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
ctx.font = text.getCSSFont();
|
||||
ctx.fillStyle = text.getCSSColor();
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let textX = 0;
|
||||
if (text.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
textX = canvasWidth / 2;
|
||||
} else if (text.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
textX = canvasWidth;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
textX = 0;
|
||||
}
|
||||
|
||||
const metrics = ctx.measureText(text.text);
|
||||
const textHeight = text.fontSize * text.lineHeight;
|
||||
let textY = 0;
|
||||
|
||||
if (text.verticalAlign === 'middle') {
|
||||
textY = (canvasHeight - textHeight) / 2;
|
||||
} else if (text.verticalAlign === 'bottom') {
|
||||
textY = canvasHeight - textHeight;
|
||||
}
|
||||
|
||||
if (text.wordWrap) {
|
||||
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
|
||||
} else {
|
||||
ctx.fillText(text.text, textX, textY);
|
||||
}
|
||||
|
||||
const textureId = cached?.textureId ?? this.nextTextureId++;
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
if (this.onTextureCreated) {
|
||||
this.onTextureCreated(textureId, dataUrl);
|
||||
}
|
||||
|
||||
this.textTextureCache.set(cacheKey, {
|
||||
textureId,
|
||||
text: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
fontWeight: text.fontWeight,
|
||||
italic: text.italic,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign,
|
||||
lineHeight: text.lineHeight,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
const cachedData = this.textTextureCache.get(cacheKey);
|
||||
if (!cachedData) return null;
|
||||
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const a = Math.round(transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
|
||||
|
||||
return {
|
||||
transforms,
|
||||
textureIds: new Uint32Array([cachedData.textureId]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 101 + transform.zIndex
|
||||
};
|
||||
/**
|
||||
* Check if there is content to render
|
||||
* 检查是否有内容需要渲染
|
||||
*/
|
||||
hasContent(): boolean {
|
||||
const collector = getUIRenderCollector();
|
||||
return !collector.isEmpty;
|
||||
}
|
||||
|
||||
private drawWrappedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number
|
||||
): void {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + word + ' ';
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line !== '') {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
line = word + ' ';
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
}
|
||||
}
|
||||
|
||||
collectUIRenderData(): UIRenderData[] {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const result: UIRenderData[] = [];
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const transform = entity.getComponent(UITransformComponent);
|
||||
if (!transform || !transform.visible) continue;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
const text = entity.getComponent(UITextComponent);
|
||||
|
||||
const data: UIRenderData = {
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
width: transform.width * transform.scaleX,
|
||||
height: transform.height * transform.scaleY,
|
||||
rotation: transform.rotation,
|
||||
originX: transform.pivotX,
|
||||
originY: transform.pivotY,
|
||||
backgroundColor: render?.backgroundColor ?? 0,
|
||||
backgroundAlpha: (render?.backgroundAlpha ?? 0) * transform.alpha,
|
||||
borderColor: render?.borderColor ?? 0,
|
||||
borderWidth: render?.borderWidth ?? 0,
|
||||
cornerRadius: render?.borderRadius?.[0] ?? 0,
|
||||
zIndex: transform.zIndex,
|
||||
visible: transform.visible
|
||||
};
|
||||
|
||||
if (text && text.text) {
|
||||
data.text = {
|
||||
content: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign
|
||||
};
|
||||
}
|
||||
|
||||
result.push(data);
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
clearTextCache(): void {
|
||||
this.textTextureCache.clear();
|
||||
/**
|
||||
* Clear the collector (call at start of frame)
|
||||
* 清除收集器(在帧开始时调用)
|
||||
*/
|
||||
clearCollector(): void {
|
||||
const collector = getUIRenderCollector();
|
||||
collector.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.textCanvas = null;
|
||||
this.textCtx = null;
|
||||
this.textTextureCache.clear();
|
||||
this.onTextureCreated = null;
|
||||
// Nothing to dispose currently
|
||||
// 当前没有需要释放的资源
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,6 @@ export * from './UILayoutSystem';
|
||||
export * from './UIInputSystem';
|
||||
export * from './UIAnimationSystem';
|
||||
export * from './UIRenderDataProvider';
|
||||
|
||||
// Render systems
|
||||
export * from './render';
|
||||
|
||||
159
packages/ui/src/systems/render/UIButtonRenderSystem.ts
Normal file
159
packages/ui/src/systems/render/UIButtonRenderSystem.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* UI Button Render System
|
||||
* UI 按钮渲染系统
|
||||
*
|
||||
* Renders UIButtonComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIButtonComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIButtonComponent } from '../../components/widgets/UIButtonComponent';
|
||||
import { UIRenderComponent } from '../../components/UIRenderComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Button Render System
|
||||
* UI 按钮渲染系统
|
||||
*
|
||||
* Handles rendering of button components including:
|
||||
* - Background color (with state-based color changes)
|
||||
* - Texture support (normal, hover, pressed, disabled)
|
||||
* - Combined color + texture mode
|
||||
*
|
||||
* 处理按钮组件的渲染,包括:
|
||||
* - 背景颜色(带状态变化的颜色)
|
||||
* - 纹理支持(正常、悬停、按下、禁用)
|
||||
* - 颜色 + 纹理组合模式
|
||||
*
|
||||
* Note: Button text is rendered by UITextRenderSystem if UITextComponent is present.
|
||||
* 注意:如果存在 UITextComponent,按钮文本由 UITextRenderSystem 渲染。
|
||||
*/
|
||||
@ECSSystem('UIButtonRender', { updateOrder: 113 })
|
||||
export class UIButtonRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIButtonComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const button = entity.getComponent(UIButtonComponent)!;
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Render texture if in texture or both mode
|
||||
// 如果在纹理或两者模式下,渲染纹理
|
||||
if (button.useTexture()) {
|
||||
const texture = button.getStateTexture('normal');
|
||||
if (texture) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
0xFFFFFF, // White tint for texture
|
||||
alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
texturePath: texture
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render color background if in color or both mode
|
||||
// 如果在颜色或两者模式下,渲染颜色背景
|
||||
if (button.useColor()) {
|
||||
const bgAlpha = render?.backgroundAlpha ?? 1;
|
||||
if (bgAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
button.currentColor,
|
||||
bgAlpha * alpha,
|
||||
baseOrder + (button.useTexture() ? 0.05 : 0),
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render border if UIRenderComponent has border
|
||||
// 如果 UIRenderComponent 有边框,渲染边框
|
||||
if (render && render.borderWidth > 0 && render.borderAlpha > 0) {
|
||||
this.renderBorder(
|
||||
collector,
|
||||
x, y, width, height,
|
||||
render.borderWidth,
|
||||
render.borderColor,
|
||||
render.borderAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
transform.rotation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border using top-left coordinates
|
||||
* 使用左上角坐标渲染边框
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
rotation: number
|
||||
): void {
|
||||
// Top border
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
350
packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
Normal file
350
packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* UI ProgressBar Render System
|
||||
* UI 进度条渲染系统
|
||||
*
|
||||
* Renders UIProgressBarComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIProgressBarComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIProgressBarComponent, UIProgressDirection } from '../../components/widgets/UIProgressBarComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI ProgressBar Render System
|
||||
* UI 进度条渲染系统
|
||||
*
|
||||
* Handles rendering of progress bar components including:
|
||||
* - Background rectangle
|
||||
* - Fill rectangle (based on progress value)
|
||||
* - Support for different directions (LTR, RTL, TTB, BTT)
|
||||
* - Segmented display
|
||||
*
|
||||
* 处理进度条组件的渲染,包括:
|
||||
* - 背景矩形
|
||||
* - 填充矩形(基于进度值)
|
||||
* - 支持不同方向(左到右、右到左、上到下、下到上)
|
||||
* - 分段显示
|
||||
*/
|
||||
@ECSSystem('UIProgressBarRender', { updateOrder: 110 })
|
||||
export class UIProgressBarRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIProgressBarComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const progressBar = entity.getComponent(UIProgressBarComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Render background (x, y is top-left corner)
|
||||
// 渲染背景(x, y 是左上角)
|
||||
if (progressBar.backgroundAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y, width, height,
|
||||
progressBar.backgroundColor,
|
||||
progressBar.backgroundAlpha * alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render border
|
||||
// 渲染边框
|
||||
if (progressBar.borderWidth > 0) {
|
||||
this.renderBorder(
|
||||
collector, x, y, width, height,
|
||||
progressBar.borderWidth,
|
||||
progressBar.borderColor,
|
||||
alpha,
|
||||
baseOrder + 0.2,
|
||||
transform
|
||||
);
|
||||
}
|
||||
|
||||
// Render fill
|
||||
// 渲染填充
|
||||
const progress = progressBar.getProgress();
|
||||
if (progress > 0 && progressBar.fillAlpha > 0) {
|
||||
if (progressBar.showSegments) {
|
||||
this.renderSegmentedFill(
|
||||
collector, x, y, width, height,
|
||||
progress, progressBar, alpha, baseOrder + 0.1, transform
|
||||
);
|
||||
} else {
|
||||
this.renderSolidFill(
|
||||
collector, x, y, width, height,
|
||||
progress, progressBar, alpha, baseOrder + 0.1, transform
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render solid fill rectangle
|
||||
* 渲染实心填充矩形
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderSolidFill(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
progress: number,
|
||||
progressBar: UIProgressBarComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
transform: UITransformComponent
|
||||
): void {
|
||||
let fillX = x;
|
||||
let fillY = y;
|
||||
let fillWidth = width;
|
||||
let fillHeight = height;
|
||||
|
||||
// Calculate fill dimensions based on direction
|
||||
// x, y is top-left corner, so calculations are simpler
|
||||
// 根据方向计算填充尺寸
|
||||
// x, y 是左上角,所以计算更简单
|
||||
switch (progressBar.direction) {
|
||||
case UIProgressDirection.LeftToRight:
|
||||
fillWidth = width * progress;
|
||||
// Fill starts from left (fillX = x, no change)
|
||||
break;
|
||||
|
||||
case UIProgressDirection.RightToLeft:
|
||||
fillWidth = width * progress;
|
||||
// Fill starts from right
|
||||
fillX = x + width - fillWidth;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.BottomToTop:
|
||||
fillHeight = height * progress;
|
||||
// Fill starts from bottom
|
||||
fillY = y + height - fillHeight;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.TopToBottom:
|
||||
fillHeight = height * progress;
|
||||
// Fill starts from top (fillY = y, no change)
|
||||
break;
|
||||
}
|
||||
|
||||
// Determine fill color (gradient or solid)
|
||||
// 确定填充颜色(渐变或实心)
|
||||
let fillColor = progressBar.fillColor;
|
||||
if (progressBar.useGradient) {
|
||||
// Simple linear interpolation between start and end colors
|
||||
// 简单的起始和结束颜色线性插值
|
||||
fillColor = this.lerpColor(
|
||||
progressBar.gradientStartColor,
|
||||
progressBar.gradientEndColor,
|
||||
progress
|
||||
);
|
||||
}
|
||||
|
||||
collector.addRect(
|
||||
fillX, fillY, fillWidth, fillHeight,
|
||||
fillColor,
|
||||
progressBar.fillAlpha * alpha,
|
||||
sortOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render segmented fill
|
||||
* 渲染分段填充
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderSegmentedFill(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
progress: number,
|
||||
progressBar: UIProgressBarComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
transform: UITransformComponent
|
||||
): void {
|
||||
const segments = progressBar.segments;
|
||||
const gap = progressBar.segmentGap;
|
||||
const filledSegments = Math.ceil(progress * segments);
|
||||
|
||||
const isHorizontal = progressBar.direction === UIProgressDirection.LeftToRight ||
|
||||
progressBar.direction === UIProgressDirection.RightToLeft;
|
||||
|
||||
// Calculate segment dimensions
|
||||
// 计算段尺寸
|
||||
let segmentWidth: number;
|
||||
let segmentHeight: number;
|
||||
|
||||
if (isHorizontal) {
|
||||
segmentWidth = (width - gap * (segments - 1)) / segments;
|
||||
segmentHeight = height;
|
||||
} else {
|
||||
segmentWidth = width;
|
||||
segmentHeight = (height - gap * (segments - 1)) / segments;
|
||||
}
|
||||
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
const baseX = x;
|
||||
const baseY = y;
|
||||
|
||||
for (let i = 0; i < filledSegments && i < segments; i++) {
|
||||
let segX: number;
|
||||
let segY: number;
|
||||
|
||||
// Calculate segment position based on direction (using top-left positions)
|
||||
// 根据方向计算段位置(使用左上角位置)
|
||||
switch (progressBar.direction) {
|
||||
case UIProgressDirection.LeftToRight:
|
||||
segX = baseX + i * (segmentWidth + gap);
|
||||
segY = baseY;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.RightToLeft:
|
||||
segX = baseX + width - (i + 1) * segmentWidth - i * gap;
|
||||
segY = baseY;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.TopToBottom:
|
||||
segX = baseX;
|
||||
segY = baseY + i * (segmentHeight + gap);
|
||||
break;
|
||||
|
||||
case UIProgressDirection.BottomToTop:
|
||||
segX = baseX;
|
||||
segY = baseY + height - (i + 1) * segmentHeight - i * gap;
|
||||
break;
|
||||
|
||||
default:
|
||||
segX = baseX + i * (segmentWidth + gap);
|
||||
segY = baseY;
|
||||
}
|
||||
|
||||
// Determine segment color
|
||||
// 确定段颜色
|
||||
let segmentColor = progressBar.fillColor;
|
||||
if (progressBar.useGradient) {
|
||||
const t = segments > 1 ? i / (segments - 1) : 0;
|
||||
segmentColor = this.lerpColor(
|
||||
progressBar.gradientStartColor,
|
||||
progressBar.gradientEndColor,
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
// Use top-left position with pivot 0,0
|
||||
// 使用左上角位置,pivot 0,0
|
||||
collector.addRect(
|
||||
segX, segY,
|
||||
segmentWidth,
|
||||
segmentHeight,
|
||||
segmentColor,
|
||||
progressBar.fillAlpha * alpha,
|
||||
sortOrder + i * 0.001, // Slight offset for each segment
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border
|
||||
* 渲染边框
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
_transform: UITransformComponent
|
||||
): void {
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
|
||||
// Top border
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation between two colors
|
||||
* 两种颜色之间的线性插值
|
||||
*/
|
||||
private lerpColor(color1: number, color2: number, t: number): number {
|
||||
const r1 = (color1 >> 16) & 0xFF;
|
||||
const g1 = (color1 >> 8) & 0xFF;
|
||||
const b1 = color1 & 0xFF;
|
||||
|
||||
const r2 = (color2 >> 16) & 0xFF;
|
||||
const g2 = (color2 >> 8) & 0xFF;
|
||||
const b2 = color2 & 0xFF;
|
||||
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const b = Math.round(b1 + (b2 - b1) * t);
|
||||
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
193
packages/ui/src/systems/render/UIRectRenderSystem.ts
Normal file
193
packages/ui/src/systems/render/UIRectRenderSystem.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* UI Rect Render System
|
||||
* UI 矩形渲染系统
|
||||
*
|
||||
* Renders basic UIRenderComponent entities (those without specialized widget components)
|
||||
* by submitting render primitives to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染基础 UIRenderComponent 实体
|
||||
* (没有专门 widget 组件的实体)。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIRenderComponent } from '../../components/UIRenderComponent';
|
||||
import { UIButtonComponent } from '../../components/widgets/UIButtonComponent';
|
||||
import { UIProgressBarComponent } from '../../components/widgets/UIProgressBarComponent';
|
||||
import { UISliderComponent } from '../../components/widgets/UISliderComponent';
|
||||
import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Rect Render System
|
||||
* UI 矩形渲染系统
|
||||
*
|
||||
* Handles rendering of basic UI elements with UIRenderComponent that don't have
|
||||
* specialized widget components (like buttons, progress bars, etc.).
|
||||
*
|
||||
* This is the "catch-all" renderer for simple rectangles, images, and panels.
|
||||
*
|
||||
* 处理具有 UIRenderComponent 但没有专门 widget 组件(如按钮、进度条等)的基础 UI 元素的渲染。
|
||||
* 这是简单矩形、图像和面板的"兜底"渲染器。
|
||||
*/
|
||||
@ECSSystem('UIRectRender', { updateOrder: 100 })
|
||||
export class UIRectRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIRenderComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
// Skip if entity has specialized widget components
|
||||
// (they have their own render systems)
|
||||
// 如果实体有专门的 widget 组件,跳过(它们有自己的渲染系统)
|
||||
if (entity.hasComponent(UIButtonComponent) ||
|
||||
entity.hasComponent(UIProgressBarComponent) ||
|
||||
entity.hasComponent(UISliderComponent) ||
|
||||
entity.hasComponent(UIScrollViewComponent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Use top-left position with origin at (0, 0)
|
||||
// Like Sprite: x,y is anchor position, origin determines where anchor is on the rect
|
||||
// For UI: x,y is top-left corner, so origin should be (0, 0)
|
||||
// 使用左上角位置,原点在 (0, 0)
|
||||
// 类似 Sprite:x,y 是锚点位置,origin 决定锚点在矩形上的位置
|
||||
// 对于 UI:x,y 是左上角,所以 origin 应该是 (0, 0)
|
||||
|
||||
// Render shadow if enabled
|
||||
// 如果启用,渲染阴影
|
||||
if (render.shadowEnabled && render.shadowAlpha > 0) {
|
||||
collector.addRect(
|
||||
x + render.shadowOffsetX - render.shadowBlur,
|
||||
y + render.shadowOffsetY - render.shadowBlur,
|
||||
width + render.shadowBlur * 2,
|
||||
height + render.shadowBlur * 2,
|
||||
render.shadowColor,
|
||||
render.shadowAlpha * alpha,
|
||||
baseOrder - 0.1,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render texture if present
|
||||
// 如果有纹理,渲染纹理
|
||||
if (render.texture) {
|
||||
const texturePath = typeof render.texture === 'string' ? render.texture : undefined;
|
||||
const textureId = typeof render.texture === 'number' ? render.texture : undefined;
|
||||
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
render.textureTint,
|
||||
alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
textureId,
|
||||
texturePath,
|
||||
uv: render.textureUV
|
||||
? [render.textureUV.u0, render.textureUV.v0, render.textureUV.u1, render.textureUV.v1]
|
||||
: undefined
|
||||
}
|
||||
);
|
||||
}
|
||||
// Render background color if fill is enabled
|
||||
// 如果启用填充,渲染背景颜色
|
||||
else if (render.fillBackground && render.backgroundAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
render.backgroundColor,
|
||||
render.backgroundAlpha * alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render border if present
|
||||
// 如果有边框,渲染边框
|
||||
if (render.borderWidth > 0 && render.borderAlpha > 0) {
|
||||
this.renderBorder(
|
||||
collector,
|
||||
x, y, width, height,
|
||||
render.borderWidth,
|
||||
render.borderColor,
|
||||
render.borderAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
transform.rotation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border using top-left coordinates
|
||||
* 使用左上角坐标渲染边框
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
rotation: number
|
||||
): void {
|
||||
// Top border (from top-left corner)
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
packages/ui/src/systems/render/UIRenderBeginSystem.ts
Normal file
53
packages/ui/src/systems/render/UIRenderBeginSystem.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* UI Render Begin System
|
||||
* UI 渲染开始系统
|
||||
*
|
||||
* This system runs at the beginning of each frame (before other UI render systems)
|
||||
* to clear the UIRenderCollector. This ensures that each frame starts with a fresh
|
||||
* set of render primitives.
|
||||
*
|
||||
* 此系统在每帧开始时运行(在其他 UI 渲染系统之前),以清除 UIRenderCollector。
|
||||
* 这确保每帧都以一组新的渲染原语开始。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Entity, ECSSystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Render Begin System
|
||||
* UI 渲染开始系统
|
||||
*
|
||||
* Runs before all other UI render systems to clear the collector.
|
||||
* 在所有其他 UI 渲染系统之前运行,以清除收集器。
|
||||
*
|
||||
* Update order: 99 (runs before UIRectRenderSystem at 100)
|
||||
*/
|
||||
@ECSSystem('UIRenderBegin', { updateOrder: 99 })
|
||||
export class UIRenderBeginSystem extends EntitySystem {
|
||||
constructor() {
|
||||
// Use Matcher.nothing() to indicate this system doesn't process any entities
|
||||
// It only uses lifecycle methods (onBegin) to clear the collector each frame
|
||||
// 使用 Matcher.nothing() 表明此系统不处理任何实体
|
||||
// 它只使用生命周期方法 (onBegin) 在每帧清除收集器
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at the beginning of each frame
|
||||
* 每帧开始时调用
|
||||
*/
|
||||
protected override onBegin(): void {
|
||||
// Clear the collector for a fresh frame
|
||||
// 清除收集器,准备新的一帧
|
||||
const collector = getUIRenderCollector();
|
||||
collector.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* No entities to process (marker component never exists)
|
||||
* 没有实体需要处理(标记组件永远不存在)
|
||||
*/
|
||||
protected process(_entities: readonly Entity[]): void {
|
||||
// This should never be called since no entity has the marker component
|
||||
}
|
||||
}
|
||||
362
packages/ui/src/systems/render/UIRenderCollector.ts
Normal file
362
packages/ui/src/systems/render/UIRenderCollector.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* UI Render Collector - Shared service for collecting UI render primitives
|
||||
* UI 渲染收集器 - 用于收集 UI 渲染原语的共享服务
|
||||
*
|
||||
* This collector is used by all UI render systems to submit render data.
|
||||
* 此收集器被所有 UI 渲染系统用于提交渲染数据。
|
||||
*
|
||||
* Render mode is controlled by EngineRenderSystem.previewMode:
|
||||
* - Editor mode (previewMode=false): UI renders in world space with sprites
|
||||
* - Preview mode (previewMode=true): UI renders as screen overlay
|
||||
*
|
||||
* 渲染模式由 EngineRenderSystem.previewMode 控制:
|
||||
* - 编辑器模式 (previewMode=false): UI 与精灵一起在世界空间渲染
|
||||
* - 预览模式 (previewMode=true): UI 作为屏幕叠加层渲染
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single render primitive (rectangle with optional texture)
|
||||
* 单个渲染原语(可选带纹理的矩形)
|
||||
*
|
||||
* Coordinate system (same as Sprite rendering):
|
||||
* - x, y: Anchor/origin position of the rectangle
|
||||
* - width, height: Pixel dimensions
|
||||
* - pivotX, pivotY: Where the anchor point is on the rectangle (0-1)
|
||||
* - (0, 0) = x,y is top-left corner
|
||||
* - (0.5, 0.5) = x,y is center
|
||||
* - (1, 1) = x,y is bottom-right corner
|
||||
*
|
||||
* For UI elements (UITransform), x,y is always top-left corner,
|
||||
* so pivotX=0, pivotY=0 should be used.
|
||||
*
|
||||
* 坐标系统(与 Sprite 渲染相同):
|
||||
* - x, y: 矩形的锚点/原点位置
|
||||
* - width, height: 像素尺寸
|
||||
* - pivotX, pivotY: 锚点在矩形上的位置(0-1)
|
||||
* - (0, 0) = x,y 是左上角
|
||||
* - (0.5, 0.5) = x,y 是中心
|
||||
* - (1, 1) = x,y 是右下角
|
||||
*
|
||||
* 对于 UI 元素(UITransform),x,y 始终是左上角,
|
||||
* 因此应使用 pivotX=0, pivotY=0。
|
||||
*/
|
||||
export interface UIRenderPrimitive {
|
||||
/** X position (anchor point) | X 坐标(锚点位置) */
|
||||
x: number;
|
||||
/** Y position (anchor point) | Y 坐标(锚点位置) */
|
||||
y: number;
|
||||
/** Width in pixels | 宽度(像素) */
|
||||
width: number;
|
||||
/** Height in pixels | 高度(像素) */
|
||||
height: number;
|
||||
/** Rotation in radians | 旋转角度(弧度) */
|
||||
rotation: number;
|
||||
/** Pivot/Origin X (0-1, 0=left, 0.5=center, 1=right) | 锚点 X (0-1, 0=左, 0.5=中心, 1=右) */
|
||||
pivotX: number;
|
||||
/** Pivot/Origin Y (0-1, 0=top, 0.5=center, 1=bottom) | 锚点 Y (0-1, 0=上, 0.5=中心, 1=下) */
|
||||
pivotY: number;
|
||||
/** Packed color (0xAABBGGRR) | 打包颜色 */
|
||||
color: number;
|
||||
/** Sort order (lower = rendered first/behind) | 排序顺序 */
|
||||
sortOrder: number;
|
||||
/** Optional texture ID | 可选纹理 ID */
|
||||
textureId?: number;
|
||||
/** Optional texture path | 可选纹理路径 */
|
||||
texturePath?: string;
|
||||
/** UV coordinates [u0, v0, u1, v1] | UV 坐标 */
|
||||
uv?: [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider render data format (compatible with EngineRenderSystem)
|
||||
* 提供者渲染数据格式(兼容 EngineRenderSystem)
|
||||
*/
|
||||
export interface ProviderRenderData {
|
||||
transforms: Float32Array;
|
||||
textureIds: Uint32Array;
|
||||
uvs: Float32Array;
|
||||
colors: Uint32Array;
|
||||
tileCount: number;
|
||||
sortingOrder: number;
|
||||
texturePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Render Collector
|
||||
* UI 渲染收集器
|
||||
*
|
||||
* Collects render primitives from all UI render systems and converts them
|
||||
* to the format expected by EngineRenderSystem.
|
||||
* 从所有 UI 渲染系统收集渲染原语,并转换为 EngineRenderSystem 期望的格式。
|
||||
*/
|
||||
export class UIRenderCollector {
|
||||
/** Collected primitives | 收集的原语 */
|
||||
private primitives: UIRenderPrimitive[] = [];
|
||||
|
||||
private cache: ProviderRenderData[] | null = null;
|
||||
|
||||
/**
|
||||
* Clear all collected primitives (call at start of frame)
|
||||
* 清除所有收集的原语(在帧开始时调用)
|
||||
*/
|
||||
clear(): void {
|
||||
this.primitives.length = 0;
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a rectangle primitive
|
||||
* 添加矩形原语
|
||||
*/
|
||||
addRect(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
options?: {
|
||||
rotation?: number;
|
||||
pivotX?: number;
|
||||
pivotY?: number;
|
||||
textureId?: number;
|
||||
texturePath?: string;
|
||||
uv?: [number, number, number, number];
|
||||
}
|
||||
): void {
|
||||
// Pack color with alpha: 0xAABBGGRR
|
||||
const r = (color >> 16) & 0xFF;
|
||||
const g = (color >> 8) & 0xFF;
|
||||
const b = color & 0xFF;
|
||||
const a = Math.round(alpha * 255);
|
||||
const packedColor = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
|
||||
const primitive: UIRenderPrimitive = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation: options?.rotation ?? 0,
|
||||
pivotX: options?.pivotX ?? 0,
|
||||
pivotY: options?.pivotY ?? 0,
|
||||
color: packedColor,
|
||||
sortOrder,
|
||||
textureId: options?.textureId,
|
||||
texturePath: options?.texturePath,
|
||||
uv: options?.uv
|
||||
};
|
||||
|
||||
this.primitives.push(primitive);
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a primitive with pre-calculated world transform
|
||||
* 添加带预计算世界变换的原语
|
||||
*/
|
||||
addPrimitive(primitive: UIRenderPrimitive): void {
|
||||
this.primitives.push(primitive);
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get render data
|
||||
* 获取渲染数据
|
||||
*/
|
||||
getRenderData(): readonly ProviderRenderData[] {
|
||||
if (this.cache) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
this.cache = this.buildRenderData(this.primitives);
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build render data from primitives
|
||||
* 从原语构建渲染数据
|
||||
*/
|
||||
private buildRenderData(primitives: UIRenderPrimitive[]): ProviderRenderData[] {
|
||||
if (primitives.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by sortOrder
|
||||
// 按 sortOrder 排序
|
||||
primitives.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Group by texture (primitives with same texture can be batched)
|
||||
// 按纹理分组(相同纹理的原语可以批处理)
|
||||
const groups = new Map<string, UIRenderPrimitive[]>();
|
||||
|
||||
for (const prim of primitives) {
|
||||
// Use texture path or 'solid' for solid color rects
|
||||
const key = prim.texturePath ?? (prim.textureId?.toString() ?? 'solid');
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = [];
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.push(prim);
|
||||
}
|
||||
|
||||
// Convert groups to ProviderRenderData
|
||||
// 将分组转换为 ProviderRenderData
|
||||
const result: ProviderRenderData[] = [];
|
||||
|
||||
for (const [key, prims] of groups) {
|
||||
const count = prims.length;
|
||||
const transforms = new Float32Array(count * 7);
|
||||
const textureIds = new Uint32Array(count);
|
||||
const uvs = new Float32Array(count * 4);
|
||||
const colors = new Uint32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = prims[i];
|
||||
const tOffset = i * 7;
|
||||
const uvOffset = i * 4;
|
||||
|
||||
// Unified render transform format (same as SpriteRenderData):
|
||||
// [x, y, rotation, width(pixels), height(pixels), pivotX(0-1), pivotY(0-1)]
|
||||
// 统一渲染变换格式(与 SpriteRenderData 相同)
|
||||
transforms[tOffset] = p.x;
|
||||
transforms[tOffset + 1] = p.y;
|
||||
transforms[tOffset + 2] = p.rotation;
|
||||
transforms[tOffset + 3] = p.width;
|
||||
transforms[tOffset + 4] = p.height;
|
||||
transforms[tOffset + 5] = p.pivotX;
|
||||
transforms[tOffset + 6] = p.pivotY;
|
||||
|
||||
textureIds[i] = p.textureId ?? 0;
|
||||
|
||||
// UV
|
||||
if (p.uv) {
|
||||
uvs[uvOffset] = p.uv[0];
|
||||
uvs[uvOffset + 1] = p.uv[1];
|
||||
uvs[uvOffset + 2] = p.uv[2];
|
||||
uvs[uvOffset + 3] = p.uv[3];
|
||||
} else {
|
||||
uvs[uvOffset] = 0;
|
||||
uvs[uvOffset + 1] = 0;
|
||||
uvs[uvOffset + 2] = 1;
|
||||
uvs[uvOffset + 3] = 1;
|
||||
}
|
||||
|
||||
colors[i] = p.color;
|
||||
}
|
||||
|
||||
// Use the minimum sortOrder from the group as the batch sortingOrder
|
||||
const minSortOrder = Math.min(...prims.map(p => p.sortOrder));
|
||||
|
||||
const renderData: ProviderRenderData = {
|
||||
transforms,
|
||||
textureIds,
|
||||
uvs,
|
||||
colors,
|
||||
tileCount: count,
|
||||
sortingOrder: minSortOrder
|
||||
};
|
||||
|
||||
// Add texture path if not solid color
|
||||
if (key !== 'solid' && isNaN(parseInt(key))) {
|
||||
renderData.texturePath = key;
|
||||
}
|
||||
|
||||
result.push(renderData);
|
||||
}
|
||||
|
||||
// Sort result by sortingOrder
|
||||
result.sort((a, b) => a.sortingOrder - b.sortingOrder);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of primitives collected
|
||||
* 获取收集的原语总数量
|
||||
*/
|
||||
get count(): number {
|
||||
return this.primitives.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collector is empty
|
||||
* 检查收集器是否为空
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return this.primitives.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
// 全局单例实例
|
||||
let globalCollector: UIRenderCollector | null = null;
|
||||
|
||||
// Cache invalidation callbacks
|
||||
// 缓存失效回调
|
||||
type CacheInvalidationCallback = () => void;
|
||||
const cacheInvalidationCallbacks: CacheInvalidationCallback[] = [];
|
||||
|
||||
/**
|
||||
* Get the global UI render collector instance
|
||||
* 获取全局 UI 渲染收集器实例
|
||||
*/
|
||||
export function getUIRenderCollector(): UIRenderCollector {
|
||||
if (!globalCollector) {
|
||||
globalCollector = new UIRenderCollector();
|
||||
}
|
||||
return globalCollector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the global collector (for testing or cleanup)
|
||||
* 重置全局收集器(用于测试或清理)
|
||||
*/
|
||||
export function resetUIRenderCollector(): void {
|
||||
globalCollector = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a cache invalidation callback
|
||||
* 注册缓存失效回调
|
||||
*
|
||||
* UI render systems can register their cache clearing functions here.
|
||||
* When invalidateUIRenderCaches() is called, all registered callbacks will be invoked.
|
||||
*
|
||||
* UI 渲染系统可以在这里注册它们的缓存清除函数。
|
||||
* 当调用 invalidateUIRenderCaches() 时,所有注册的回调将被调用。
|
||||
*/
|
||||
export function registerCacheInvalidationCallback(callback: CacheInvalidationCallback): void {
|
||||
if (!cacheInvalidationCallbacks.includes(callback)) {
|
||||
cacheInvalidationCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a cache invalidation callback
|
||||
* 取消注册缓存失效回调
|
||||
*/
|
||||
export function unregisterCacheInvalidationCallback(callback: CacheInvalidationCallback): void {
|
||||
const index = cacheInvalidationCallbacks.indexOf(callback);
|
||||
if (index >= 0) {
|
||||
cacheInvalidationCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all UI render caches
|
||||
* 使所有 UI 渲染缓存失效
|
||||
*
|
||||
* Call this when the scene is restored or when caches need to be cleared.
|
||||
* 在场景恢复或需要清除缓存时调用此函数。
|
||||
*/
|
||||
export function invalidateUIRenderCaches(): void {
|
||||
for (const callback of cacheInvalidationCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
} catch (e) {
|
||||
console.error('Error invalidating UI render cache:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
194
packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
Normal file
194
packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* UI ScrollView Render System
|
||||
* UI 滚动视图渲染系统
|
||||
*
|
||||
* Renders UIScrollViewComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIScrollViewComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI ScrollView Render System
|
||||
* UI 滚动视图渲染系统
|
||||
*
|
||||
* Handles rendering of scrollview components including:
|
||||
* - Vertical scrollbar track and handle
|
||||
* - Horizontal scrollbar track and handle
|
||||
* - Scrollbar hover states
|
||||
*
|
||||
* 处理滚动视图组件的渲染,包括:
|
||||
* - 垂直滚动条轨道和手柄
|
||||
* - 水平滚动条轨道和手柄
|
||||
* - 滚动条悬停状态
|
||||
*
|
||||
* Note: The scrollview content area and clipping is handled by the layout system.
|
||||
* 注意:滚动视图内容区域和裁剪由布局系统处理。
|
||||
*/
|
||||
@ECSSystem('UIScrollViewRender', { updateOrder: 112 })
|
||||
export class UIScrollViewRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIScrollViewComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const scrollView = entity.getComponent(UIScrollViewComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
const baseX = x;
|
||||
const baseY = y;
|
||||
|
||||
// Render vertical scrollbar
|
||||
// 渲染垂直滚动条
|
||||
if (scrollView.needsVerticalScrollbar(height)) {
|
||||
this.renderVerticalScrollbar(
|
||||
collector,
|
||||
baseX, baseY, width, height,
|
||||
scrollView, alpha, baseOrder
|
||||
);
|
||||
}
|
||||
|
||||
// Render horizontal scrollbar
|
||||
// 渲染水平滚动条
|
||||
if (scrollView.needsHorizontalScrollbar(width)) {
|
||||
this.renderHorizontalScrollbar(
|
||||
collector,
|
||||
baseX, baseY, width, height,
|
||||
scrollView, alpha, baseOrder
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vertical scrollbar
|
||||
* 渲染垂直滚动条
|
||||
*/
|
||||
private renderVerticalScrollbar(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
baseX: number, baseY: number,
|
||||
viewWidth: number, viewHeight: number,
|
||||
scrollView: UIScrollViewComponent,
|
||||
alpha: number,
|
||||
baseOrder: number
|
||||
): void {
|
||||
const scrollbarWidth = scrollView.scrollbarWidth;
|
||||
const hasHorizontal = scrollView.needsHorizontalScrollbar(viewWidth);
|
||||
const trackHeight = hasHorizontal ? viewHeight - scrollbarWidth : viewHeight;
|
||||
|
||||
// Track position (right side of viewport)
|
||||
// 轨道位置(视口右侧)
|
||||
const trackX = baseX + viewWidth - scrollbarWidth / 2;
|
||||
const trackY = baseY + trackHeight / 2;
|
||||
|
||||
// Render track
|
||||
// 渲染轨道
|
||||
if (scrollView.scrollbarTrackAlpha > 0) {
|
||||
collector.addRect(
|
||||
trackX, trackY,
|
||||
scrollbarWidth, trackHeight,
|
||||
scrollView.scrollbarTrackColor,
|
||||
scrollView.scrollbarTrackAlpha * alpha,
|
||||
baseOrder + 0.5,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate handle metrics
|
||||
// 计算手柄尺寸
|
||||
const metrics = scrollView.getVerticalScrollbarMetrics(viewHeight);
|
||||
const handleY = baseY + metrics.position + metrics.size / 2;
|
||||
|
||||
// Handle alpha (different when hovered)
|
||||
// 手柄透明度(悬停时不同)
|
||||
const handleAlpha = scrollView.verticalScrollbarHovered
|
||||
? scrollView.scrollbarHoverAlpha
|
||||
: scrollView.scrollbarAlpha;
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
collector.addRect(
|
||||
trackX, handleY,
|
||||
scrollbarWidth - 2, metrics.size,
|
||||
scrollView.scrollbarColor,
|
||||
handleAlpha * alpha,
|
||||
baseOrder + 0.6,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render horizontal scrollbar
|
||||
* 渲染水平滚动条
|
||||
*/
|
||||
private renderHorizontalScrollbar(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
baseX: number, baseY: number,
|
||||
viewWidth: number, viewHeight: number,
|
||||
scrollView: UIScrollViewComponent,
|
||||
alpha: number,
|
||||
baseOrder: number
|
||||
): void {
|
||||
const scrollbarWidth = scrollView.scrollbarWidth;
|
||||
const hasVertical = scrollView.needsVerticalScrollbar(viewHeight);
|
||||
const trackWidth = hasVertical ? viewWidth - scrollbarWidth : viewWidth;
|
||||
|
||||
// Track position (bottom of viewport)
|
||||
// 轨道位置(视口底部)
|
||||
const trackX = baseX + trackWidth / 2;
|
||||
const trackY = baseY + viewHeight - scrollbarWidth / 2;
|
||||
|
||||
// Render track
|
||||
// 渲染轨道
|
||||
if (scrollView.scrollbarTrackAlpha > 0) {
|
||||
collector.addRect(
|
||||
trackX, trackY,
|
||||
trackWidth, scrollbarWidth,
|
||||
scrollView.scrollbarTrackColor,
|
||||
scrollView.scrollbarTrackAlpha * alpha,
|
||||
baseOrder + 0.5,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate handle metrics
|
||||
// 计算手柄尺寸
|
||||
const metrics = scrollView.getHorizontalScrollbarMetrics(viewWidth);
|
||||
const handleX = baseX + metrics.position + metrics.size / 2;
|
||||
|
||||
// Handle alpha (different when hovered)
|
||||
// 手柄透明度(悬停时不同)
|
||||
const handleAlpha = scrollView.horizontalScrollbarHovered
|
||||
? scrollView.scrollbarHoverAlpha
|
||||
: scrollView.scrollbarAlpha;
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
collector.addRect(
|
||||
handleX, trackY,
|
||||
metrics.size, scrollbarWidth - 2,
|
||||
scrollView.scrollbarColor,
|
||||
handleAlpha * alpha,
|
||||
baseOrder + 0.6,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
277
packages/ui/src/systems/render/UISliderRenderSystem.ts
Normal file
277
packages/ui/src/systems/render/UISliderRenderSystem.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* UI Slider Render System
|
||||
* UI 滑块渲染系统
|
||||
*
|
||||
* Renders UISliderComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UISliderComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UISliderComponent, UISliderOrientation } from '../../components/widgets/UISliderComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Slider Render System
|
||||
* UI 滑块渲染系统
|
||||
*
|
||||
* Handles rendering of slider components including:
|
||||
* - Track (background bar)
|
||||
* - Fill (progress portion)
|
||||
* - Handle (draggable knob)
|
||||
* - Optional ticks
|
||||
*
|
||||
* 处理滑块组件的渲染,包括:
|
||||
* - 轨道(背景条)
|
||||
* - 填充(进度部分)
|
||||
* - 手柄(可拖动的旋钮)
|
||||
* - 可选刻度
|
||||
*/
|
||||
@ECSSystem('UISliderRender', { updateOrder: 111 })
|
||||
export class UISliderRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UISliderComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const slider = entity.getComponent(UISliderComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
const isHorizontal = slider.orientation === UISliderOrientation.Horizontal;
|
||||
const progress = slider.getProgress();
|
||||
|
||||
// Calculate track dimensions and position
|
||||
// 计算轨道尺寸和位置
|
||||
const trackLength = isHorizontal ? width : height;
|
||||
const trackThickness = slider.trackThickness;
|
||||
|
||||
// Calculate center position (x, y is top-left corner)
|
||||
// 计算中心位置(x, y 是左上角)
|
||||
const centerX = x + width / 2;
|
||||
const centerY = y + height / 2;
|
||||
|
||||
// Render track (using center position with pivot 0.5)
|
||||
// 渲染轨道(使用中心位置,pivot 0.5)
|
||||
if (slider.trackAlpha > 0) {
|
||||
if (isHorizontal) {
|
||||
collector.addRect(
|
||||
centerX, centerY,
|
||||
trackLength, trackThickness,
|
||||
slider.trackColor,
|
||||
slider.trackAlpha * alpha,
|
||||
baseOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
} else {
|
||||
collector.addRect(
|
||||
centerX, centerY,
|
||||
trackThickness, trackLength,
|
||||
slider.trackColor,
|
||||
slider.trackAlpha * alpha,
|
||||
baseOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render fill
|
||||
// 渲染填充
|
||||
if (progress > 0 && slider.fillAlpha > 0) {
|
||||
const fillLength = trackLength * progress;
|
||||
|
||||
if (isHorizontal) {
|
||||
// Fill from left
|
||||
const fillX = centerX - trackLength / 2 + fillLength / 2;
|
||||
collector.addRect(
|
||||
fillX, centerY,
|
||||
fillLength, trackThickness,
|
||||
slider.fillColor,
|
||||
slider.fillAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
} else {
|
||||
// Fill from bottom
|
||||
const fillY = centerY + trackLength / 2 - fillLength / 2;
|
||||
collector.addRect(
|
||||
centerX, fillY,
|
||||
trackThickness, fillLength,
|
||||
slider.fillColor,
|
||||
slider.fillAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render ticks
|
||||
// 渲染刻度
|
||||
if (slider.showTicks && slider.tickCount > 0) {
|
||||
this.renderTicks(
|
||||
collector, centerX, centerY,
|
||||
trackLength, trackThickness,
|
||||
slider, alpha, baseOrder + 0.05,
|
||||
isHorizontal
|
||||
);
|
||||
}
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
const handleColor = slider.getCurrentHandleColor();
|
||||
const handleX = isHorizontal
|
||||
? centerX - trackLength / 2 + trackLength * progress
|
||||
: centerX;
|
||||
const handleY = isHorizontal
|
||||
? centerY
|
||||
: centerY + trackLength / 2 - trackLength * progress;
|
||||
|
||||
// Handle shadow (if enabled)
|
||||
// 手柄阴影(如果启用)
|
||||
if (slider.handleShadow) {
|
||||
collector.addRect(
|
||||
handleX + 1, handleY + 2,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
0x000000,
|
||||
0.3 * alpha,
|
||||
baseOrder + 0.15,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle body
|
||||
// 手柄主体
|
||||
collector.addRect(
|
||||
handleX, handleY,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
handleColor,
|
||||
alpha,
|
||||
baseOrder + 0.2,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Handle border (if any)
|
||||
// 手柄边框(如果有)
|
||||
if (slider.handleBorderWidth > 0) {
|
||||
this.renderHandleBorder(
|
||||
collector,
|
||||
handleX, handleY,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
slider.handleBorderWidth,
|
||||
slider.handleBorderColor,
|
||||
alpha,
|
||||
baseOrder + 0.25
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ticks along the slider track
|
||||
* 沿滑块轨道渲染刻度
|
||||
*/
|
||||
private renderTicks(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
centerX: number, centerY: number,
|
||||
trackLength: number, trackThickness: number,
|
||||
slider: UISliderComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
isHorizontal: boolean
|
||||
): void {
|
||||
const tickCount = slider.tickCount + 2; // Include start and end ticks
|
||||
const tickSize = slider.tickSize;
|
||||
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const t = i / (tickCount - 1);
|
||||
|
||||
let tickX: number;
|
||||
let tickY: number;
|
||||
let tickWidth: number;
|
||||
let tickHeight: number;
|
||||
|
||||
if (isHorizontal) {
|
||||
tickX = centerX - trackLength / 2 + trackLength * t;
|
||||
tickY = centerY + trackThickness / 2 + tickSize / 2 + 2;
|
||||
tickWidth = 2;
|
||||
tickHeight = tickSize;
|
||||
} else {
|
||||
tickX = centerX + trackThickness / 2 + tickSize / 2 + 2;
|
||||
tickY = centerY + trackLength / 2 - trackLength * t;
|
||||
tickWidth = tickSize;
|
||||
tickHeight = 2;
|
||||
}
|
||||
|
||||
collector.addRect(
|
||||
tickX, tickY,
|
||||
tickWidth, tickHeight,
|
||||
slider.tickColor,
|
||||
alpha,
|
||||
sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render handle border
|
||||
* 渲染手柄边框
|
||||
*/
|
||||
private renderHandleBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number
|
||||
): void {
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const halfB = borderWidth / 2;
|
||||
|
||||
// Top
|
||||
collector.addRect(
|
||||
x, y - halfH + halfB,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Bottom
|
||||
collector.addRect(
|
||||
x, y + halfH - halfB,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Left
|
||||
collector.addRect(
|
||||
x - halfW + halfB, y,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Right
|
||||
collector.addRect(
|
||||
x + halfW - halfB, y,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
322
packages/ui/src/systems/render/UITextRenderSystem.ts
Normal file
322
packages/ui/src/systems/render/UITextRenderSystem.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* UI Text Render System
|
||||
* UI 文本渲染系统
|
||||
*
|
||||
* Renders UITextComponent entities by generating text textures
|
||||
* and submitting them to the shared UIRenderCollector.
|
||||
* 通过生成文本纹理并提交到共享的 UIRenderCollector 来渲染 UITextComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UITextComponent } from '../../components/UITextComponent';
|
||||
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* Text texture cache entry
|
||||
* 文本纹理缓存条目
|
||||
*/
|
||||
interface TextTextureCache {
|
||||
textureId: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string | number;
|
||||
italic: boolean;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
lineHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Text Render System
|
||||
* UI 文本渲染系统
|
||||
*
|
||||
* Handles rendering of text components by:
|
||||
* 1. Generating text textures using Canvas 2D
|
||||
* 2. Caching textures to avoid regeneration every frame
|
||||
* 3. Submitting texture render primitives to the collector
|
||||
*
|
||||
* 处理文本组件的渲染:
|
||||
* 1. 使用 Canvas 2D 生成文本纹理
|
||||
* 2. 缓存纹理以避免每帧重新生成
|
||||
* 3. 向收集器提交纹理渲染原语
|
||||
*/
|
||||
@ECSSystem('UITextRender', { updateOrder: 120 })
|
||||
export class UITextRenderSystem extends EntitySystem {
|
||||
private textCanvas: HTMLCanvasElement | null = null;
|
||||
private textCtx: CanvasRenderingContext2D | null = null;
|
||||
private textTextureCache: Map<number, TextTextureCache> = new Map();
|
||||
private nextTextureId = 90000;
|
||||
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
|
||||
private cacheInvalidationBound: () => void;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UITextComponent));
|
||||
// Bind the method for cache invalidation callback
|
||||
this.cacheInvalidationBound = this.clearTextCache.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when system is added to scene
|
||||
* 系统添加到场景时调用
|
||||
*/
|
||||
public override initialize(): void {
|
||||
super.initialize();
|
||||
// Register for cache invalidation events
|
||||
registerCacheInvalidationCallback(this.cacheInvalidationBound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when system is destroyed
|
||||
* 系统销毁时调用
|
||||
*/
|
||||
protected override onDestroy(): void {
|
||||
super.onDestroy();
|
||||
// Unregister cache invalidation callback
|
||||
unregisterCacheInvalidationCallback(this.cacheInvalidationBound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when a new text texture is created
|
||||
* 设置创建新文本纹理时的回调
|
||||
*/
|
||||
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
|
||||
this.onTextureCreated = callback;
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const text = entity.getComponent(UITextComponent)!;
|
||||
|
||||
if (!transform.visible || !text.text) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Generate or retrieve cached texture
|
||||
// 生成或获取缓存的纹理
|
||||
const textureId = this.getOrCreateTextTexture(
|
||||
entity.id, text, Math.ceil(width), Math.ceil(height)
|
||||
);
|
||||
|
||||
if (textureId === null) continue;
|
||||
|
||||
// Use top-left position with origin at (0, 0)
|
||||
// 使用左上角位置,原点在 (0, 0)
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
0xFFFFFF, // White tint (color is baked into texture)
|
||||
alpha,
|
||||
baseOrder + 1, // Text renders above background
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
textureId
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create text texture
|
||||
* 获取或创建文本纹理
|
||||
*/
|
||||
private getOrCreateTextTexture(
|
||||
entityId: number,
|
||||
text: UITextComponent,
|
||||
width: number,
|
||||
height: number
|
||||
): number | null {
|
||||
const canvasData = this.getTextCanvas();
|
||||
if (!canvasData) return null;
|
||||
|
||||
const { canvas, ctx } = canvasData;
|
||||
|
||||
const cached = this.textTextureCache.get(entityId);
|
||||
|
||||
// Check if we need to regenerate the texture
|
||||
// 检查是否需要重新生成纹理
|
||||
const needsUpdate = !cached ||
|
||||
cached.text !== text.text ||
|
||||
cached.fontSize !== text.fontSize ||
|
||||
cached.fontFamily !== text.fontFamily ||
|
||||
cached.fontWeight !== text.fontWeight ||
|
||||
cached.italic !== text.italic ||
|
||||
cached.color !== text.color ||
|
||||
cached.alpha !== text.alpha ||
|
||||
cached.align !== text.align ||
|
||||
cached.verticalAlign !== text.verticalAlign ||
|
||||
cached.lineHeight !== text.lineHeight ||
|
||||
cached.width !== width ||
|
||||
cached.height !== height;
|
||||
|
||||
if (needsUpdate) {
|
||||
const canvasWidth = Math.max(1, width);
|
||||
const canvasHeight = Math.max(1, height);
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
ctx.font = text.getCSSFont();
|
||||
ctx.fillStyle = text.getCSSColor();
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
// Handle horizontal alignment
|
||||
// 处理水平对齐
|
||||
let textX = 0;
|
||||
if (text.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
textX = canvasWidth / 2;
|
||||
} else if (text.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
textX = canvasWidth;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
textX = 0;
|
||||
}
|
||||
|
||||
// Handle vertical alignment
|
||||
// 处理垂直对齐
|
||||
const textHeight = text.fontSize * text.lineHeight;
|
||||
let textY = 0;
|
||||
|
||||
if (text.verticalAlign === 'middle') {
|
||||
textY = (canvasHeight - textHeight) / 2;
|
||||
} else if (text.verticalAlign === 'bottom') {
|
||||
textY = canvasHeight - textHeight;
|
||||
}
|
||||
|
||||
// Draw text (with or without word wrap)
|
||||
// 绘制文本(带或不带自动换行)
|
||||
if (text.wordWrap) {
|
||||
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
|
||||
} else {
|
||||
ctx.fillText(text.text, textX, textY);
|
||||
}
|
||||
|
||||
// Get or create texture ID
|
||||
// 获取或创建纹理 ID
|
||||
const textureId = cached?.textureId ?? this.nextTextureId++;
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
// Notify callback of new texture
|
||||
// 通知回调新纹理
|
||||
if (this.onTextureCreated) {
|
||||
this.onTextureCreated(textureId, dataUrl);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
// 更新缓存
|
||||
this.textTextureCache.set(entityId, {
|
||||
textureId,
|
||||
text: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
fontWeight: text.fontWeight,
|
||||
italic: text.italic,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign,
|
||||
lineHeight: text.lineHeight,
|
||||
width,
|
||||
height,
|
||||
dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
return this.textTextureCache.get(entityId)?.textureId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create text canvas
|
||||
* 获取或创建文本画布
|
||||
*/
|
||||
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
||||
if (!this.textCanvas) {
|
||||
this.textCanvas = document.createElement('canvas');
|
||||
this.textCtx = this.textCanvas.getContext('2d');
|
||||
}
|
||||
if (!this.textCtx) return null;
|
||||
return { canvas: this.textCanvas, ctx: this.textCtx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw text with word wrapping
|
||||
* 绘制带自动换行的文本
|
||||
*/
|
||||
private drawWrappedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number
|
||||
): void {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + word + ' ';
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line !== '') {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
line = word + ' ';
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear text texture cache
|
||||
* 清除文本纹理缓存
|
||||
*/
|
||||
clearTextCache(): void {
|
||||
this.textTextureCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific entity
|
||||
* 清除特定实体的缓存
|
||||
*/
|
||||
clearEntityTextCache(entityId: number): void {
|
||||
this.textTextureCache.delete(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.textCanvas = null;
|
||||
this.textCtx = null;
|
||||
this.textTextureCache.clear();
|
||||
this.onTextureCreated = null;
|
||||
}
|
||||
}
|
||||
33
packages/ui/src/systems/render/index.ts
Normal file
33
packages/ui/src/systems/render/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* UI Render Systems
|
||||
* UI 渲染系统
|
||||
*
|
||||
* This module contains all UI render systems that follow ECS architecture.
|
||||
* Each system is responsible for rendering a specific type of UI component.
|
||||
*
|
||||
* 此模块包含所有遵循 ECS 架构的 UI 渲染系统。
|
||||
* 每个系统负责渲染特定类型的 UI 组件。
|
||||
*/
|
||||
|
||||
// Core render infrastructure
|
||||
// 核心渲染基础设施
|
||||
export {
|
||||
UIRenderCollector,
|
||||
getUIRenderCollector,
|
||||
resetUIRenderCollector,
|
||||
registerCacheInvalidationCallback,
|
||||
unregisterCacheInvalidationCallback,
|
||||
invalidateUIRenderCaches,
|
||||
type UIRenderPrimitive,
|
||||
type ProviderRenderData
|
||||
} from './UIRenderCollector';
|
||||
|
||||
// Render systems
|
||||
// 渲染系统
|
||||
export { UIRenderBeginSystem } from './UIRenderBeginSystem';
|
||||
export { UIRectRenderSystem } from './UIRectRenderSystem';
|
||||
export { UITextRenderSystem } from './UITextRenderSystem';
|
||||
export { UIButtonRenderSystem } from './UIButtonRenderSystem';
|
||||
export { UIProgressBarRenderSystem } from './UIProgressBarRenderSystem';
|
||||
export { UISliderRenderSystem } from './UISliderRenderSystem';
|
||||
export { UIScrollViewRenderSystem } from './UIScrollViewRenderSystem';
|
||||
Reference in New Issue
Block a user