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:
YHH
2025-11-27 20:42:46 +08:00
committed by GitHub
parent 71869b1a58
commit 107439d70c
367 changed files with 10661 additions and 12473 deletions

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

View File

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

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

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

View File

@@ -0,0 +1 @@
export * from './UITransformGizmo';

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

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

View File

@@ -0,0 +1 @@
export * from './UITransformInspector';

View File

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

View File

@@ -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 是内容区域的顶部 YY-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;
// 计算第一个子元素的顶部 YY-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;
// 计算子元素的父容器顶部 YworldY 是底部,顶部 = 底部 + 高度)
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

View File

@@ -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
// 当前没有需要释放的资源
}
}

View File

@@ -2,3 +2,6 @@ export * from './UILayoutSystem';
export * from './UIInputSystem';
export * from './UIAnimationSystem';
export * from './UIRenderDataProvider';
// Render systems
export * from './render';

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

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

View 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)
// 类似 Spritex,y 是锚点位置origin 决定锚点在矩形上的位置
// 对于 UIx,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 }
);
}
}

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

View 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 元素UITransformx,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);
}
}
}

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

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

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

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