feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)

This commit is contained in:
YHH
2025-11-26 11:08:10 +08:00
committed by GitHub
parent 3fb6f919f8
commit 7b14fa2da4
62 changed files with 8745 additions and 235 deletions

44
packages/ui/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "@esengine/ui",
"version": "1.0.0",
"description": "ECS-based UI system with WebGL rendering for games",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/ecs-framework": "workspace:*"
},
"peerDependencies": {
"@esengine/ecs-framework": ">=2.0.0"
},
"devDependencies": {
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.7.0",
"rimraf": "^5.0.5"
},
"keywords": [
"ecs",
"ui",
"webgl",
"game-ui"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,436 @@
import { Entity, Scene } from '@esengine/ecs-framework';
import { UITransformComponent, AnchorPreset } from './components/UITransformComponent';
import { UIRenderComponent, UIRenderType } from './components/UIRenderComponent';
import { UIInteractableComponent } from './components/UIInteractableComponent';
import { UITextComponent } from './components/UITextComponent';
import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from './components/UILayoutComponent';
import { UIButtonComponent } from './components/widgets/UIButtonComponent';
import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent';
import { UISliderComponent } from './components/widgets/UISliderComponent';
import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent';
/**
* 基础 UI 配置
* Base UI configuration
*/
export interface UIBaseConfig {
name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
anchor?: AnchorPreset;
visible?: boolean;
alpha?: number;
zIndex?: number;
}
/**
* 按钮配置
* Button configuration
*/
export interface UIButtonConfig extends UIBaseConfig {
label: string;
onClick?: () => void;
onLongPress?: () => void;
normalColor?: number;
hoverColor?: number;
pressedColor?: number;
textColor?: number;
fontSize?: number;
borderRadius?: number;
disabled?: boolean;
}
/**
* 文本配置
* Text configuration
*/
export interface UITextConfig extends UIBaseConfig {
text: string;
fontSize?: number;
fontFamily?: string;
color?: number;
align?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
wordWrap?: boolean;
}
/**
* 图片配置
* Image configuration
*/
export interface UIImageConfig extends UIBaseConfig {
texture: string | number;
tint?: number;
}
/**
* 进度条配置
* Progress bar configuration
*/
export interface UIProgressBarConfig extends UIBaseConfig {
value?: number;
maxValue?: number;
fillColor?: number;
backgroundColor?: number;
borderRadius?: number;
showText?: boolean;
transitionDuration?: number;
}
/**
* 滑块配置
* Slider configuration
*/
export interface UISliderConfig extends UIBaseConfig {
value?: number;
minValue?: number;
maxValue?: number;
step?: number;
onChange?: (value: number) => void;
trackColor?: number;
fillColor?: number;
handleColor?: number;
}
/**
* 面板配置
* Panel configuration
*/
export interface UIPanelConfig extends UIBaseConfig {
backgroundColor?: number;
backgroundAlpha?: number;
borderWidth?: number;
borderColor?: number;
borderRadius?: number;
padding?: number | { top: number; right: number; bottom: number; left: number };
layout?: 'none' | 'horizontal' | 'vertical' | 'grid';
gap?: number;
justifyContent?: UIJustifyContent;
alignItems?: UIAlignItems;
}
/**
* 滚动视图配置
* Scroll view configuration
*/
export interface UIScrollViewConfig extends UIBaseConfig {
contentWidth?: number;
contentHeight?: number;
horizontalScroll?: boolean;
verticalScroll?: boolean;
backgroundColor?: number;
}
/**
* UI 构建器
* UI Builder - Simplified API for creating UI elements
*
* 提供简化的 API 来创建常用 UI 元素
* Provides simplified API for creating common UI elements
*/
export class UIBuilder {
private scene: Scene;
private idCounter: number = 0;
constructor(scene: Scene) {
this.scene = scene;
}
/**
* 创建基础 UI 实体
* Create base UI entity with transform
*/
private createBase(config: UIBaseConfig, defaultName: string): Entity {
const entity = this.scene.createEntity(config.name ?? `${defaultName}_${this.idCounter++}`);
const transform = entity.addComponent(new UITransformComponent());
transform.x = config.x ?? 0;
transform.y = config.y ?? 0;
transform.width = config.width ?? 100;
transform.height = config.height ?? 30;
transform.visible = config.visible ?? true;
transform.alpha = config.alpha ?? 1;
transform.zIndex = config.zIndex ?? 0;
if (config.anchor) {
transform.setAnchorPreset(config.anchor);
}
return entity;
}
/**
* 创建按钮
* Create button
*/
public button(config: UIButtonConfig): Entity {
const entity = this.createBase(config, 'Button');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.RoundedRect;
render.backgroundColor = config.normalColor ?? 0x4A90D9;
render.setCornerRadius(config.borderRadius ?? 4);
// 交互组件
const interactable = entity.addComponent(new UIInteractableComponent());
interactable.cursor = 'pointer';
interactable.onClick = config.onClick;
// 按钮组件
const button = entity.addComponent(new UIButtonComponent());
button.label = config.label;
button.onClick = config.onClick;
button.onLongPress = config.onLongPress;
button.disabled = config.disabled ?? false;
if (config.normalColor !== undefined) button.normalColor = config.normalColor;
if (config.hoverColor !== undefined) button.hoverColor = config.hoverColor;
if (config.pressedColor !== undefined) button.pressedColor = config.pressedColor;
if (config.textColor !== undefined) button.textColor = config.textColor;
button.currentColor = button.normalColor;
button.targetColor = button.normalColor;
// 文本组件
const text = entity.addComponent(new UITextComponent());
text.text = config.label;
text.fontSize = config.fontSize ?? 14;
text.color = config.textColor ?? 0xFFFFFF;
text.align = 'center';
text.verticalAlign = 'middle';
return entity;
}
/**
* 创建文本
* Create text label
*/
public text(config: UITextConfig): Entity {
const entity = this.createBase(config, 'Text');
const text = entity.addComponent(new UITextComponent());
text.text = config.text;
text.fontSize = config.fontSize ?? 14;
text.fontFamily = config.fontFamily ?? 'Arial, sans-serif';
text.color = config.color ?? 0x000000;
text.align = config.align ?? 'left';
text.verticalAlign = config.verticalAlign ?? 'middle';
text.wordWrap = config.wordWrap ?? false;
return entity;
}
/**
* 创建图片
* Create image
*/
public image(config: UIImageConfig): Entity {
const entity = this.createBase(config, 'Image');
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Image;
render.texture = config.texture;
render.textureTint = config.tint ?? 0xFFFFFF;
return entity;
}
/**
* 创建进度条
* Create progress bar
*/
public progressBar(config: UIProgressBarConfig): Entity {
const entity = this.createBase({
...config,
height: config.height ?? 20
}, 'ProgressBar');
// 渲染组件(背景)
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.RoundedRect;
render.backgroundColor = config.backgroundColor ?? 0x333333;
render.setCornerRadius(config.borderRadius ?? 4);
// 进度条组件
const progress = entity.addComponent(new UIProgressBarComponent());
progress.value = config.value ?? 0;
progress.targetValue = config.value ?? 0;
progress.displayValue = config.value ?? 0;
progress.maxValue = config.maxValue ?? 100;
progress.fillColor = config.fillColor ?? 0x4CAF50;
progress.backgroundColor = config.backgroundColor ?? 0x333333;
progress.cornerRadius = config.borderRadius ?? 4;
progress.showText = config.showText ?? false;
progress.transitionDuration = config.transitionDuration ?? 0.3;
return entity;
}
/**
* 创建滑块
* Create slider
*/
public slider(config: UISliderConfig): Entity {
const entity = this.createBase({
...config,
height: config.height ?? 20
}, 'Slider');
// 渲染组件(轨道背景)
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.RoundedRect;
render.backgroundColor = config.trackColor ?? 0x444444;
render.setCornerRadius(2);
// 交互组件
const interactable = entity.addComponent(new UIInteractableComponent());
interactable.cursor = 'pointer';
// 滑块组件
const slider = entity.addComponent(new UISliderComponent());
slider.value = config.value ?? 0;
slider.targetValue = config.value ?? 0;
slider.displayValue = config.value ?? 0;
slider.minValue = config.minValue ?? 0;
slider.maxValue = config.maxValue ?? 100;
slider.step = config.step ?? 0;
slider.onChange = config.onChange;
if (config.trackColor !== undefined) slider.trackColor = config.trackColor;
if (config.fillColor !== undefined) slider.fillColor = config.fillColor;
if (config.handleColor !== undefined) slider.handleColor = config.handleColor;
return entity;
}
/**
* 创建面板/容器
* Create panel/container
*/
public panel(config: UIPanelConfig): Entity {
const entity = this.createBase(config, 'Panel');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = config.borderRadius ? UIRenderType.RoundedRect : UIRenderType.Rect;
render.backgroundColor = config.backgroundColor ?? 0xFFFFFF;
render.backgroundAlpha = config.backgroundAlpha ?? 1;
if (config.borderWidth) {
render.setBorder(config.borderWidth, config.borderColor ?? 0x000000);
}
if (config.borderRadius) {
render.setCornerRadius(config.borderRadius);
}
// 布局组件
if (config.layout && config.layout !== 'none') {
const layout = entity.addComponent(new UILayoutComponent());
switch (config.layout) {
case 'horizontal':
layout.type = UILayoutType.Horizontal;
break;
case 'vertical':
layout.type = UILayoutType.Vertical;
break;
case 'grid':
layout.type = UILayoutType.Grid;
break;
}
if (config.gap !== undefined) {
layout.setGap(config.gap);
}
if (config.padding !== undefined) {
layout.setPadding(config.padding);
}
if (config.justifyContent !== undefined) {
layout.justifyContent = config.justifyContent;
}
if (config.alignItems !== undefined) {
layout.alignItems = config.alignItems;
}
}
return entity;
}
/**
* 创建滚动视图
* Create scroll view
*/
public scrollView(config: UIScrollViewConfig): Entity {
const entity = this.createBase(config, 'ScrollView');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Rect;
render.backgroundColor = config.backgroundColor ?? 0xF0F0F0;
// 交互组件
entity.addComponent(new UIInteractableComponent());
// 滚动视图组件
const scrollView = entity.addComponent(new UIScrollViewComponent());
scrollView.contentWidth = config.contentWidth ?? (config.width ?? 100);
scrollView.contentHeight = config.contentHeight ?? (config.height ?? 100);
scrollView.horizontalScroll = config.horizontalScroll ?? false;
scrollView.verticalScroll = config.verticalScroll ?? true;
return entity;
}
/**
* 创建分隔线
* Create divider/separator
*/
public divider(config: UIBaseConfig & { color?: number; horizontal?: boolean }): Entity {
const isHorizontal = config.horizontal ?? true;
const entity = this.createBase({
...config,
width: isHorizontal ? (config.width ?? 100) : 1,
height: isHorizontal ? 1 : (config.height ?? 100)
}, 'Divider');
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Rect;
render.backgroundColor = config.color ?? 0xCCCCCC;
return entity;
}
/**
* 创建空白占位
* Create spacer
*/
public spacer(config: UIBaseConfig): Entity {
const entity = this.createBase(config, 'Spacer');
// 空白占位不需要渲染组件
return entity;
}
/**
* 将子元素添加到父元素
* Add child to parent
*/
public addChild(parent: Entity, child: Entity): Entity {
parent.addChild(child);
return child;
}
/**
* 批量添加子元素
* Add multiple children to parent
*/
public addChildren(parent: Entity, children: Entity[]): Entity[] {
for (const child of children) {
parent.addChild(child);
}
return children;
}
}

View File

@@ -0,0 +1,230 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 光标类型
* Cursor types for interactive elements
*/
export type UICursorType =
| 'default'
| 'pointer'
| 'text'
| 'move'
| 'not-allowed'
| 'grab'
| 'grabbing'
| 'ew-resize'
| 'ns-resize'
| 'nesw-resize'
| 'nwse-resize';
/**
* UI 交互组件
* UI Interactable Component - Handles input interaction state
*
* 管理元素的交互状态(悬停、按下、焦点等)
* Manages element interaction state (hover, pressed, focus, etc.)
*/
@ECSComponent('UIInteractable')
@Serializable({ version: 1, typeId: 'UIInteractable' })
export class UIInteractableComponent extends Component {
/**
* 是否启用交互
* Whether interaction is enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Enabled' })
public enabled: boolean = true;
/**
* 是否阻止事件冒泡
* Whether to block event propagation
*/
@Serialize()
@Property({ type: 'boolean', label: 'Block Events' })
public blockEvents: boolean = true;
// ===== 状态 State (由 UIInputSystem 更新) =====
/**
* 是否被鼠标悬停
* Whether mouse is hovering over this element
*/
public hovered: boolean = false;
/**
* 是否被按下
* Whether element is being pressed
*/
public pressed: boolean = false;
/**
* 是否获得焦点
* Whether element has focus
*/
public focused: boolean = false;
/**
* 是否被拖拽
* Whether element is being dragged
*/
public dragging: boolean = false;
// ===== 配置 Configuration =====
/**
* 是否可以获得焦点
* Whether element can receive focus
*/
@Serialize()
@Property({ type: 'boolean', label: 'Focusable' })
public focusable: boolean = false;
/**
* 是否可以被拖拽
* Whether element can be dragged
*/
@Serialize()
@Property({ type: 'boolean', label: 'Draggable' })
public draggable: boolean = false;
/**
* Tab 索引(用于键盘导航)
* Tab index for keyboard navigation
*/
@Serialize()
@Property({ type: 'integer', label: 'Tab Index' })
public tabIndex: number = 0;
/**
* 光标类型
* Cursor type when hovering
*/
@Serialize()
@Property({
type: 'enum',
label: 'Cursor',
options: [
{ value: 'default', label: 'Default' },
{ value: 'pointer', label: 'Pointer' },
{ value: 'text', label: 'Text' },
{ value: 'move', label: 'Move' },
{ value: 'not-allowed', label: 'Not Allowed' },
{ value: 'grab', label: 'Grab' },
{ value: 'grabbing', label: 'Grabbing' }
]
})
public cursor: UICursorType = 'pointer';
/**
* 悬停延迟(毫秒,用于 tooltip
* Hover delay in ms (for tooltips)
*/
@Serialize()
@Property({ type: 'number', label: 'Hover Delay', min: 0 })
public hoverDelay: number = 0;
/**
* 悬停计时器
* Internal hover timer
*/
public hoverTimer: number = 0;
/**
* 是否悬停足够长时间
* Whether hovered long enough (past hoverDelay)
*/
public hoverReady: boolean = false;
// ===== 事件回调 Event Callbacks =====
/**
* 点击回调
* Click callback
*/
public onClick?: () => void;
/**
* 双击回调
* Double-click callback
*/
public onDoubleClick?: () => void;
/**
* 鼠标进入回调
* Mouse enter callback
*/
public onMouseEnter?: () => void;
/**
* 鼠标离开回调
* Mouse leave callback
*/
public onMouseLeave?: () => void;
/**
* 按下回调
* Press down callback
*/
public onPressDown?: () => void;
/**
* 释放回调
* Press up callback
*/
public onPressUp?: () => void;
/**
* 获得焦点回调
* Focus callback
*/
public onFocus?: () => void;
/**
* 失去焦点回调
* Blur callback
*/
public onBlur?: () => void;
/**
* 拖拽开始回调
* Drag start callback
*/
public onDragStart?: (x: number, y: number) => void;
/**
* 拖拽中回调
* Drag move callback
*/
public onDragMove?: (x: number, y: number, deltaX: number, deltaY: number) => void;
/**
* 拖拽结束回调
* Drag end callback
*/
public onDragEnd?: (x: number, y: number) => void;
/**
* 获取当前交互状态名称(用于样式切换)
* Get current interaction state name (for style switching)
*/
public getState(): 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal' {
if (!this.enabled) return 'disabled';
if (this.pressed) return 'pressed';
if (this.hovered) return 'hovered';
if (this.focused) return 'focused';
return 'normal';
}
/**
* 重置所有状态
* Reset all interaction states
*/
public resetState(): void {
this.hovered = false;
this.pressed = false;
this.focused = false;
this.dragging = false;
this.hoverTimer = 0;
this.hoverReady = false;
}
}

View File

@@ -0,0 +1,373 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 布局类型
* Layout types for automatic child positioning
*/
export enum UILayoutType {
/** 无自动布局 No automatic layout */
None = 'none',
/** 水平排列 Horizontal arrangement */
Horizontal = 'horizontal',
/** 垂直排列 Vertical arrangement */
Vertical = 'vertical',
/** 网格布局 Grid layout */
Grid = 'grid',
/** 流式布局 Flow/Wrap layout */
Flow = 'flow'
}
/**
* 主轴对齐方式
* Main axis alignment
*/
export enum UIJustifyContent {
/** 起始对齐 Align to start */
Start = 'start',
/** 居中 Center */
Center = 'center',
/** 末尾对齐 Align to end */
End = 'end',
/** 两端对齐 Space between */
SpaceBetween = 'space-between',
/** 均匀分布 Space around */
SpaceAround = 'space-around',
/** 平均分布 Space evenly */
SpaceEvenly = 'space-evenly'
}
/**
* 交叉轴对齐方式
* Cross axis alignment
*/
export enum UIAlignItems {
/** 起始对齐 Align to start */
Start = 'start',
/** 居中 Center */
Center = 'center',
/** 末尾对齐 Align to end */
End = 'end',
/** 拉伸 Stretch to fill */
Stretch = 'stretch',
/** 基线对齐 Baseline alignment */
Baseline = 'baseline'
}
/**
* 内边距
* Padding configuration
*/
export interface UIPadding {
top: number;
right: number;
bottom: number;
left: number;
}
/**
* UI 布局组件
* UI Layout Component - Defines automatic child layout behavior
*
* 类似 CSS Flexbox 的布局系统
* Flexbox-like layout system
*/
@ECSComponent('UILayout')
@Serializable({ version: 1, typeId: 'UILayout' })
export class UILayoutComponent extends Component {
/**
* 布局类型
* Layout type
*/
@Serialize()
@Property({
type: 'enum',
label: 'Type',
options: [
{ value: 'none', label: 'None' },
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
{ value: 'grid', label: 'Grid' },
{ value: 'flow', label: 'Flow' }
]
})
public type: UILayoutType = UILayoutType.None;
// ===== 间距 Spacing =====
/**
* 子元素间距
* Gap between children
*/
@Serialize()
@Property({ type: 'number', label: 'Gap', min: 0 })
public gap: number = 0;
/**
* 水平间距Grid 布局)
* Horizontal gap (for Grid layout)
*/
@Serialize()
@Property({ type: 'number', label: 'Gap X', min: 0 })
public gapX: number = 0;
/**
* 垂直间距Grid 布局)
* Vertical gap (for Grid layout)
*/
@Serialize()
@Property({ type: 'number', label: 'Gap Y', min: 0 })
public gapY: number = 0;
/**
* 内边距
* Padding
*/
@Serialize()
@Property({ type: 'number', label: 'Padding Top', min: 0 })
public paddingTop: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Padding Right', min: 0 })
public paddingRight: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Padding Bottom', min: 0 })
public paddingBottom: number = 0;
@Serialize()
@Property({ type: 'number', label: 'Padding Left', min: 0 })
public paddingLeft: number = 0;
// ===== 对齐 Alignment =====
/**
* 主轴对齐
* Main axis alignment (justify-content)
*/
@Serialize()
@Property({
type: 'enum',
label: 'Justify Content',
options: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
{ value: 'space-between', label: 'Space Between' },
{ value: 'space-around', label: 'Space Around' },
{ value: 'space-evenly', label: 'Space Evenly' }
]
})
public justifyContent: UIJustifyContent = UIJustifyContent.Start;
/**
* 交叉轴对齐
* Cross axis alignment (align-items)
*/
@Serialize()
@Property({
type: 'enum',
label: 'Align Items',
options: [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
{ value: 'stretch', label: 'Stretch' },
{ value: 'baseline', label: 'Baseline' }
]
})
public alignItems: UIAlignItems = UIAlignItems.Start;
// ===== 网格配置 Grid Configuration =====
/**
* 网格列数
* Number of columns (Grid layout)
*/
@Serialize()
@Property({ type: 'integer', label: 'Columns', min: 1 })
public columns: number = 1;
/**
* 网格行数0 = 自动)
* Number of rows (Grid layout, 0 = auto)
*/
@Serialize()
@Property({ type: 'integer', label: 'Rows', min: 0 })
public rows: number = 0;
/**
* 网格单元格宽度0 = 自动)
* Grid cell width (0 = auto)
*/
@Serialize()
@Property({ type: 'number', label: 'Cell Width', min: 0 })
public cellWidth: number = 0;
/**
* 网格单元格高度0 = 自动)
* Grid cell height (0 = auto)
*/
@Serialize()
@Property({ type: 'number', label: 'Cell Height', min: 0 })
public cellHeight: number = 0;
// ===== 流式布局配置 Flow Configuration =====
/**
* 是否换行
* Whether to wrap items
*/
@Serialize()
@Property({ type: 'boolean', label: 'Wrap' })
public wrap: boolean = false;
/**
* 换行时的行间距
* Gap between wrapped rows
*/
@Serialize()
@Property({ type: 'number', label: 'Wrap Gap', min: 0 })
public wrapGap: number = 0;
// ===== 方向 Direction =====
/**
* 是否反转方向
* Whether to reverse direction
*/
@Serialize()
@Property({ type: 'boolean', label: 'Reverse' })
public reverse: boolean = false;
// ===== 尺寸控制 Size Control =====
/**
* 是否根据内容调整自身尺寸
* Whether to fit size to content
*/
@Serialize()
@Property({ type: 'boolean', label: 'Fit Content' })
public fitContent: boolean = false;
/**
* 内容最小宽度
* Minimum content width
*/
@Serialize()
@Property({ type: 'number', label: 'Content Min Width', min: 0 })
public contentMinWidth: number = 0;
/**
* 内容最小高度
* Minimum content height
*/
@Serialize()
@Property({ type: 'number', label: 'Content Min Height', min: 0 })
public contentMinHeight: number = 0;
/**
* 设置布局类型
* Set layout type
*/
public setType(type: UILayoutType): this {
this.type = type;
return this;
}
/**
* 设置间距
* Set gap
*/
public setGap(gap: number, gapY?: number): this {
this.gap = gap;
this.gapX = gap;
this.gapY = gapY ?? gap;
return this;
}
/**
* 设置内边距
* Set padding (uniform or per-side)
*/
public setPadding(padding: number | UIPadding): this {
if (typeof padding === 'number') {
this.paddingTop = padding;
this.paddingRight = padding;
this.paddingBottom = padding;
this.paddingLeft = padding;
} else {
this.paddingTop = padding.top;
this.paddingRight = padding.right;
this.paddingBottom = padding.bottom;
this.paddingLeft = padding.left;
}
return this;
}
/**
* 设置对齐方式
* Set alignment
*/
public setAlignment(justify: UIJustifyContent, align: UIAlignItems): this {
this.justifyContent = justify;
this.alignItems = align;
return this;
}
/**
* 设置网格配置
* Set grid configuration
*/
public setGrid(columns: number, cellWidth?: number, cellHeight?: number): this {
this.type = UILayoutType.Grid;
this.columns = columns;
if (cellWidth !== undefined) this.cellWidth = cellWidth;
if (cellHeight !== undefined) this.cellHeight = cellHeight;
return this;
}
/**
* 获取有效的水平间距
* Get effective horizontal gap
*/
public getHorizontalGap(): number {
return this.gapX || this.gap;
}
/**
* 获取有效的垂直间距
* Get effective vertical gap
*/
public getVerticalGap(): number {
return this.gapY || this.gap;
}
/**
* 获取内容区域起始 X
* Get content area start X
*/
public getContentStartX(): number {
return this.paddingLeft;
}
/**
* 获取内容区域起始 Y
* Get content area start Y
*/
public getContentStartY(): number {
return this.paddingTop;
}
/**
* 获取内边距水平总和
* Get total horizontal padding
*/
public getHorizontalPadding(): number {
return this.paddingLeft + this.paddingRight;
}
/**
* 获取内边距垂直总和
* Get total vertical padding
*/
public getVerticalPadding(): number {
return this.paddingTop + this.paddingBottom;
}
}

View File

@@ -0,0 +1,303 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 渲染类型
* Render types for different visual elements
*/
export enum UIRenderType {
/** 纯色矩形 Solid color rectangle */
Rect = 'rect',
/** 图片 Image/Texture */
Image = 'image',
/** 九宫格图片 Nine-patch/Nine-slice image */
NinePatch = 'ninepatch',
/** 圆形 Circle */
Circle = 'circle',
/** 圆角矩形 Rounded rectangle */
RoundedRect = 'rounded-rect'
}
/**
* 边框样式
* Border style configuration
*/
export interface UIBorderStyle {
width: number;
color: number;
alpha: number;
}
/**
* 阴影样式
* Shadow style configuration
*/
export interface UIShadowStyle {
offsetX: number;
offsetY: number;
blur: number;
color: number;
alpha: number;
}
/**
* UI 渲染组件
* UI Render Component - Handles visual appearance of UI elements
*
* 定义元素的视觉属性,如颜色、纹理、边框等
* Defines visual properties like color, texture, border, etc.
*/
@ECSComponent('UIRender')
@Serializable({ version: 1, typeId: 'UIRender' })
export class UIRenderComponent extends Component {
/**
* 渲染类型
* Type of rendering
*/
@Serialize()
@Property({
type: 'enum',
label: 'Type',
options: [
{ value: 'rect', label: 'Rectangle' },
{ value: 'image', label: 'Image' },
{ value: 'ninepatch', label: 'Nine Patch' },
{ value: 'circle', label: 'Circle' },
{ value: 'rounded-rect', label: 'Rounded Rect' }
]
})
public type: UIRenderType = UIRenderType.Rect;
// ===== 颜色 Colors =====
/**
* 背景颜色 (0xRRGGBB)
* Background color in hex format
*/
@Serialize()
@Property({ type: 'color', label: 'Background Color' })
public backgroundColor: number = 0xFFFFFF;
/**
* 背景透明度 (0-1)
* Background alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Background Alpha', min: 0, max: 1, step: 0.01 })
public backgroundAlpha: number = 1;
/**
* 是否填充背景
* Whether to fill background
*/
@Serialize()
@Property({ type: 'boolean', label: 'Fill Background' })
public fillBackground: boolean = true;
// ===== 纹理 Texture =====
/**
* 纹理路径或 ID
* Texture path or runtime ID
*/
@Serialize()
@Property({ type: 'asset', label: 'Texture', assetType: 'texture' })
public texture: string | number | null = null;
/**
* 纹理 UV 坐标 (用于图集)
* Texture UV coordinates (for atlas)
*/
public textureUV: { u0: number; v0: number; u1: number; v1: number } | null = null;
/**
* 纹理色调 (0xRRGGBB)
* Texture tint color
*/
public textureTint: number = 0xFFFFFF;
// ===== 九宫格 Nine-Patch =====
/**
* 九宫格边距 [top, right, bottom, left]
* Nine-patch margins
*/
public ninePatchMargins: [number, number, number, number] = [0, 0, 0, 0];
// ===== 边框 Border =====
/**
* 边框宽度
* Border width
*/
@Property({ type: 'number', label: 'Border Width', min: 0 })
public borderWidth: number = 0;
/**
* 边框颜色
* Border color
*/
@Property({ type: 'color', label: 'Border Color' })
public borderColor: number = 0x000000;
/**
* 边框透明度
* Border alpha
*/
@Property({ type: 'number', label: 'Border Alpha', min: 0, max: 1, step: 0.01 })
public borderAlpha: number = 1;
/**
* 圆角半径 [topLeft, topRight, bottomRight, bottomLeft]
* Corner radius for each corner
*/
public borderRadius: [number, number, number, number] = [0, 0, 0, 0];
// ===== 阴影 Shadow =====
/**
* 是否启用阴影
* Whether shadow is enabled
*/
@Property({ type: 'boolean', label: 'Shadow Enabled' })
public shadowEnabled: boolean = false;
/**
* 阴影 X 偏移
* Shadow X offset
*/
@Property({ type: 'number', label: 'Shadow Offset X' })
public shadowOffsetX: number = 0;
/**
* 阴影 Y 偏移
* Shadow Y offset
*/
@Property({ type: 'number', label: 'Shadow Offset Y' })
public shadowOffsetY: number = 2;
/**
* 阴影模糊半径
* Shadow blur radius
*/
@Property({ type: 'number', label: 'Shadow Blur', min: 0 })
public shadowBlur: number = 4;
/**
* 阴影颜色
* Shadow color
*/
@Property({ type: 'color', label: 'Shadow Color' })
public shadowColor: number = 0x000000;
/**
* 阴影透明度
* Shadow alpha
*/
@Property({ type: 'number', label: 'Shadow Alpha', min: 0, max: 1, step: 0.01 })
public shadowAlpha: number = 0.3;
// ===== 渐变 Gradient =====
/**
* 渐变类型
* Gradient type
*/
public gradientType: 'none' | 'linear' | 'radial' = 'none';
/**
* 渐变角度(线性渐变)
* Gradient angle for linear gradient
*/
public gradientAngle: number = 0;
/**
* 渐变颜色停止点 [[position, color, alpha], ...]
* Gradient color stops
*/
public gradientStops: Array<[number, number, number]> = [];
/**
* 设置纯色背景
* Set solid color background
*/
public setColor(color: number, alpha: number = 1): this {
this.backgroundColor = color;
this.backgroundAlpha = alpha;
this.fillBackground = true;
return this;
}
/**
* 设置图片
* Set image texture
*/
public setImage(texture: string | number): this {
this.type = UIRenderType.Image;
this.texture = texture;
return this;
}
/**
* 设置九宫格
* Set nine-patch image
*/
public setNinePatch(texture: string | number, margins: [number, number, number, number]): this {
this.type = UIRenderType.NinePatch;
this.texture = texture;
this.ninePatchMargins = margins;
return this;
}
/**
* 设置边框
* Set border style
*/
public setBorder(width: number, color: number, alpha: number = 1): this {
this.borderWidth = width;
this.borderColor = color;
this.borderAlpha = alpha;
return this;
}
/**
* 设置圆角
* Set corner radius (uniform or per-corner)
*/
public setCornerRadius(radius: number | [number, number, number, number]): this {
if (typeof radius === 'number') {
this.borderRadius = [radius, radius, radius, radius];
} else {
this.borderRadius = radius;
}
const hasRadius = typeof radius === 'number' ? radius > 0 : radius.some(r => r > 0);
if (hasRadius) {
this.type = UIRenderType.RoundedRect;
}
return this;
}
/**
* 设置阴影
* Set shadow style
*/
public setShadow(offsetX: number, offsetY: number, blur: number, color: number, alpha: number = 0.3): this {
this.shadowEnabled = true;
this.shadowOffsetX = offsetX;
this.shadowOffsetY = offsetY;
this.shadowBlur = blur;
this.shadowColor = color;
this.shadowAlpha = alpha;
return this;
}
/**
* 设置线性渐变
* Set linear gradient
*/
public setLinearGradient(angle: number, stops: Array<[number, number, number]>): this {
this.gradientType = 'linear';
this.gradientAngle = angle;
this.gradientStops = stops;
return this;
}
}

View File

@@ -0,0 +1,344 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 文本对齐方式
* Text alignment options
*/
export type UITextAlign = 'left' | 'center' | 'right';
/**
* 文本垂直对齐方式
* Text vertical alignment options
*/
export type UITextVerticalAlign = 'top' | 'middle' | 'bottom';
/**
* 文本溢出处理
* Text overflow handling
*/
export type UITextOverflow = 'visible' | 'hidden' | 'ellipsis' | 'clip';
/**
* 字体粗细
* Font weight options
*/
export type UIFontWeight = 'normal' | 'bold' | 'lighter' | 'bolder' | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
/**
* UI 文本组件
* UI Text Component - Handles text rendering
*
* 定义文本内容和样式
* Defines text content and style
*/
@ECSComponent('UIText')
@Serializable({ version: 1, typeId: 'UIText' })
export class UITextComponent extends Component {
/**
* 文本内容
* Text content
*/
@Serialize()
@Property({ type: 'string', label: 'Text' })
public text: string = '';
// ===== 字体 Font =====
/**
* 字体大小(像素)
* Font size in pixels
*/
@Serialize()
@Property({ type: 'number', label: 'Font Size', min: 1 })
public fontSize: number = 14;
/**
* 字体族
* Font family
*/
@Serialize()
@Property({ type: 'string', label: 'Font Family' })
public fontFamily: string = 'Arial, sans-serif';
/**
* 字体粗细
* Font weight
*/
@Serialize()
@Property({
type: 'enum',
label: 'Font Weight',
options: [
{ value: 'normal', label: 'Normal' },
{ value: 'bold', label: 'Bold' },
{ value: 'lighter', label: 'Lighter' },
{ value: 'bolder', label: 'Bolder' }
]
})
public fontWeight: UIFontWeight = 'normal';
/**
* 是否斜体
* Whether italic
*/
@Serialize()
@Property({ type: 'boolean', label: 'Italic' })
public italic: boolean = false;
// ===== 颜色 Color =====
/**
* 文本颜色 (0xRRGGBB)
* Text color
*/
@Serialize()
@Property({ type: 'color', label: 'Color' })
public color: number = 0x000000;
/**
* 文本透明度
* Text alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
public alpha: number = 1;
// ===== 对齐 Alignment =====
/**
* 水平对齐
* Horizontal alignment
*/
@Serialize()
@Property({
type: 'enum',
label: 'Align',
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' }
]
})
public align: UITextAlign = 'left';
/**
* 垂直对齐
* Vertical alignment
*/
@Serialize()
@Property({
type: 'enum',
label: 'Vertical Align',
options: [
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' }
]
})
public verticalAlign: UITextVerticalAlign = 'middle';
// ===== 换行 Wrapping =====
/**
* 是否自动换行
* Whether to wrap text
*/
@Serialize()
@Property({ type: 'boolean', label: 'Word Wrap' })
public wordWrap: boolean = false;
/**
* 换行宽度0 = 使用父元素宽度)
* Wrap width (0 = use parent width)
*/
@Serialize()
@Property({ type: 'number', label: 'Wrap Width', min: 0 })
public wrapWidth: number = 0;
/**
* 行高倍数1 = 正常)
* Line height multiplier
*/
@Serialize()
@Property({ type: 'number', label: 'Line Height', min: 0.1, step: 0.1 })
public lineHeight: number = 1.2;
/**
* 字间距
* Letter spacing
*/
@Serialize()
@Property({ type: 'number', label: 'Letter Spacing' })
public letterSpacing: number = 0;
// ===== 溢出 Overflow =====
/**
* 文本溢出处理
* Text overflow handling
*/
@Property({
type: 'enum',
label: 'Overflow',
options: [
{ value: 'visible', label: 'Visible' },
{ value: 'hidden', label: 'Hidden' },
{ value: 'ellipsis', label: 'Ellipsis' },
{ value: 'clip', label: 'Clip' }
]
})
public overflow: UITextOverflow = 'visible';
/**
* 最大显示行数0 = 无限制)
* Maximum number of lines (0 = unlimited)
*/
@Property({ type: 'integer', label: 'Max Lines', min: 0 })
public maxLines: number = 0;
// ===== 装饰 Decoration =====
/**
* 下划线
* Underline
*/
@Property({ type: 'boolean', label: 'Underline' })
public underline: boolean = false;
/**
* 删除线
* Strikethrough
*/
@Property({ type: 'boolean', label: 'Strikethrough' })
public strikethrough: boolean = false;
// ===== 描边 Stroke =====
/**
* 描边宽度
* Stroke width
*/
@Property({ type: 'number', label: 'Stroke Width', min: 0 })
public strokeWidth: number = 0;
/**
* 描边颜色
* Stroke color
*/
@Property({ type: 'color', label: 'Stroke Color' })
public strokeColor: number = 0x000000;
// ===== 阴影 Shadow =====
/**
* 文本阴影启用
* Text shadow enabled
*/
@Property({ type: 'boolean', label: 'Shadow' })
public shadowEnabled: boolean = false;
/**
* 阴影 X 偏移
* Shadow X offset
*/
public shadowOffsetX: number = 1;
/**
* 阴影 Y 偏移
* Shadow Y offset
*/
public shadowOffsetY: number = 1;
/**
* 阴影颜色
* Shadow color
*/
public shadowColor: number = 0x000000;
/**
* 阴影透明度
* Shadow alpha
*/
public shadowAlpha: number = 0.5;
// ===== 计算属性 Computed =====
/**
* 计算后的文本行(由渲染系统填充)
* Computed text lines (filled by render system)
*/
public computedLines: string[] = [];
/**
* 计算后的文本宽度
* Computed text width
*/
public computedWidth: number = 0;
/**
* 计算后的文本高度
* Computed text height
*/
public computedHeight: number = 0;
/**
* 文本是否需要重新计算
* Whether text needs recomputation
*/
public dirty: boolean = true;
/**
* 设置文本
* Set text content
*/
public setText(text: string): this {
if (this.text !== text) {
this.text = text;
this.dirty = true;
}
return this;
}
/**
* 设置字体
* Set font properties
*/
public setFont(size: number, family?: string, weight?: UIFontWeight): this {
this.fontSize = size;
if (family !== undefined) this.fontFamily = family;
if (weight !== undefined) this.fontWeight = weight;
this.dirty = true;
return this;
}
/**
* 设置颜色
* Set text color
*/
public setColor(color: number, alpha: number = 1): this {
this.color = color;
this.alpha = alpha;
return this;
}
/**
* 获取 CSS 字体字符串
* Get CSS font string
*/
public getCSSFont(): string {
const style = this.italic ? 'italic ' : '';
const weight = typeof this.fontWeight === 'number' ? this.fontWeight : this.fontWeight;
return `${style}${weight} ${this.fontSize}px ${this.fontFamily}`;
}
/**
* 获取颜色的 CSS 字符串
* Get color as CSS string
*/
public getCSSColor(): string {
const r = (this.color >> 16) & 0xFF;
const g = (this.color >> 8) & 0xFF;
const b = this.color & 0xFF;
return `rgba(${r}, ${g}, ${b}, ${this.alpha})`;
}
}

View File

@@ -0,0 +1,335 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 锚点预设
* Anchor presets for common positioning scenarios
*/
export enum AnchorPreset {
TopLeft = 'top-left',
TopCenter = 'top-center',
TopRight = 'top-right',
MiddleLeft = 'middle-left',
MiddleCenter = 'middle-center',
MiddleRight = 'middle-right',
BottomLeft = 'bottom-left',
BottomCenter = 'bottom-center',
BottomRight = 'bottom-right',
StretchAll = 'stretch-all'
}
/**
* UI 变换组件
* UI Transform Component - Handles position, size, and hierarchy for UI elements
*
* 基于父元素的相对定位系统,支持锚点、轴心点和拉伸模式
* Relative positioning system based on parent, supports anchors, pivots, and stretch modes
*/
@ECSComponent('UITransform')
@Serializable({ version: 1, typeId: 'UITransform' })
export class UITransformComponent extends Component {
// ===== 位置 Position =====
/**
* 相对于锚点的 X 偏移
* X offset relative to anchor point
*/
@Serialize()
@Property({ type: 'number', label: 'X' })
public x: number = 0;
/**
* 相对于锚点的 Y 偏移
* Y offset relative to anchor point
*/
@Serialize()
@Property({ type: 'number', label: 'Y' })
public y: number = 0;
// ===== 尺寸 Size =====
/**
* 宽度(像素或百分比,取决于 widthMode
* Width in pixels or percentage depending on widthMode
*/
@Serialize()
@Property({ type: 'number', label: 'Width', min: 0 })
public width: number = 100;
/**
* 高度(像素或百分比,取决于 heightMode
* Height in pixels or percentage depending on heightMode
*/
@Serialize()
@Property({ type: 'number', label: 'Height', min: 0 })
public height: number = 30;
/**
* 最小宽度限制
* Minimum width constraint
*/
@Serialize()
@Property({ type: 'number', label: 'Min Width', min: 0 })
public minWidth: number = 0;
/**
* 最大宽度限制0 = 无限制)
* Maximum width constraint (0 = no limit)
*/
@Serialize()
@Property({ type: 'number', label: 'Max Width', min: 0 })
public maxWidth: number = 0;
/**
* 最小高度限制
* Minimum height constraint
*/
@Serialize()
@Property({ type: 'number', label: 'Min Height', min: 0 })
public minHeight: number = 0;
/**
* 最大高度限制0 = 无限制)
* Maximum height constraint (0 = no limit)
*/
@Serialize()
@Property({ type: 'number', label: 'Max Height', min: 0 })
public maxHeight: number = 0;
// ===== 锚点 Anchors =====
/**
* 锚点 X 最小值 (0-1),相对于父元素
* Anchor X minimum (0-1), relative to parent
*/
@Serialize()
@Property({ type: 'number', label: 'Anchor Min X', min: 0, max: 1, step: 0.01 })
public anchorMinX: number = 0;
/**
* 锚点 Y 最小值 (0-1),相对于父元素
* Anchor Y minimum (0-1), relative to parent
*/
@Serialize()
@Property({ type: 'number', label: 'Anchor Min Y', min: 0, max: 1, step: 0.01 })
public anchorMinY: number = 0;
/**
* 锚点 X 最大值 (0-1),相对于父元素
* Anchor X maximum (0-1), relative to parent
*/
@Serialize()
@Property({ type: 'number', label: 'Anchor Max X', min: 0, max: 1, step: 0.01 })
public anchorMaxX: number = 0;
/**
* 锚点 Y 最大值 (0-1),相对于父元素
* Anchor Y maximum (0-1), relative to parent
*/
@Serialize()
@Property({ type: 'number', label: 'Anchor Max Y', min: 0, max: 1, step: 0.01 })
public anchorMaxY: number = 0;
// ===== 轴心 Pivot =====
/**
* 轴心点 X (0-1),元素自身的旋转/缩放中心
* Pivot X (0-1), element's own rotation/scale center
*/
@Serialize()
@Property({ type: 'number', label: 'Pivot X', min: 0, max: 1, step: 0.01 })
public pivotX: number = 0.5;
/**
* 轴心点 Y (0-1),元素自身的旋转/缩放中心
* Pivot Y (0-1), element's own rotation/scale center
*/
@Serialize()
@Property({ type: 'number', label: 'Pivot Y', min: 0, max: 1, step: 0.01 })
public pivotY: number = 0.5;
// ===== 变换 Transform =====
/**
* 旋转角度(弧度)
* Rotation angle in radians
*/
@Serialize()
@Property({ type: 'number', label: 'Rotation', step: 0.01 })
public rotation: number = 0;
/**
* X 轴缩放
* Scale on X axis
*/
@Serialize()
@Property({ type: 'number', label: 'Scale X', step: 0.01 })
public scaleX: number = 1;
/**
* Y 轴缩放
* Scale on Y axis
*/
@Serialize()
@Property({ type: 'number', label: 'Scale Y', step: 0.01 })
public scaleY: number = 1;
// ===== 显示 Display =====
/**
* 是否可见
* Visibility flag
*/
@Serialize()
@Property({ type: 'boolean', label: 'Visible' })
public visible: boolean = true;
/**
* 渲染层级,值越大越靠前
* Render order, higher values render on top
*/
@Serialize()
@Property({ type: 'integer', label: 'Z Index' })
public zIndex: number = 0;
/**
* 透明度 (0-1)
* Opacity (0-1)
*/
@Serialize()
@Property({ type: 'number', label: 'Alpha', min: 0, max: 1, step: 0.01 })
public alpha: number = 1;
// ===== 计算后的世界坐标(由 UILayoutSystem 填充)=====
// Computed world coordinates (filled by UILayoutSystem)
/**
* 计算后的世界 X 坐标
* Computed world X position
*/
public worldX: number = 0;
/**
* 计算后的世界 Y 坐标
* Computed world Y position
*/
public worldY: number = 0;
/**
* 计算后的实际宽度
* Computed actual width
*/
public computedWidth: number = 0;
/**
* 计算后的实际高度
* Computed actual height
*/
public computedHeight: number = 0;
/**
* 计算后的世界透明度(考虑父元素透明度)
* Computed world alpha (considering parent alpha)
*/
public worldAlpha: number = 1;
/**
* 布局是否需要更新
* Flag indicating layout needs update
*/
public layoutDirty: boolean = true;
/**
* 设置锚点预设
* Set anchor preset for quick positioning
*/
public setAnchorPreset(preset: AnchorPreset): this {
switch (preset) {
case AnchorPreset.TopLeft:
this.anchorMinX = 0; this.anchorMinY = 0;
this.anchorMaxX = 0; this.anchorMaxY = 0;
break;
case AnchorPreset.TopCenter:
this.anchorMinX = 0.5; this.anchorMinY = 0;
this.anchorMaxX = 0.5; this.anchorMaxY = 0;
break;
case AnchorPreset.TopRight:
this.anchorMinX = 1; this.anchorMinY = 0;
this.anchorMaxX = 1; this.anchorMaxY = 0;
break;
case AnchorPreset.MiddleLeft:
this.anchorMinX = 0; this.anchorMinY = 0.5;
this.anchorMaxX = 0; this.anchorMaxY = 0.5;
break;
case AnchorPreset.MiddleCenter:
this.anchorMinX = 0.5; this.anchorMinY = 0.5;
this.anchorMaxX = 0.5; this.anchorMaxY = 0.5;
break;
case AnchorPreset.MiddleRight:
this.anchorMinX = 1; this.anchorMinY = 0.5;
this.anchorMaxX = 1; this.anchorMaxY = 0.5;
break;
case AnchorPreset.BottomLeft:
this.anchorMinX = 0; this.anchorMinY = 1;
this.anchorMaxX = 0; this.anchorMaxY = 1;
break;
case AnchorPreset.BottomCenter:
this.anchorMinX = 0.5; this.anchorMinY = 1;
this.anchorMaxX = 0.5; this.anchorMaxY = 1;
break;
case AnchorPreset.BottomRight:
this.anchorMinX = 1; this.anchorMinY = 1;
this.anchorMaxX = 1; this.anchorMaxY = 1;
break;
case AnchorPreset.StretchAll:
this.anchorMinX = 0; this.anchorMinY = 0;
this.anchorMaxX = 1; this.anchorMaxY = 1;
break;
}
this.layoutDirty = true;
return this;
}
/**
* 设置位置
* Set position
*/
public setPosition(x: number, y: number): this {
this.x = x;
this.y = y;
this.layoutDirty = true;
return this;
}
/**
* 设置尺寸
* Set size
*/
public setSize(width: number, height: number): this {
this.width = width;
this.height = height;
this.layoutDirty = true;
return this;
}
/**
* 设置轴心点
* Set pivot point
*/
public setPivot(x: number, y: number): this {
this.pivotX = x;
this.pivotY = y;
this.layoutDirty = true;
return this;
}
/**
* 检测点是否在元素内
* Test if a point is inside this element
*/
public containsPoint(worldX: number, worldY: number): boolean {
return worldX >= this.worldX &&
worldX <= this.worldX + this.computedWidth &&
worldY >= this.worldY &&
worldY <= this.worldY + this.computedHeight;
}
}

View File

@@ -0,0 +1,9 @@
// Core components
export * from './UITransformComponent';
export * from './UIRenderComponent';
export * from './UIInteractableComponent';
export * from './UITextComponent';
export * from './UILayoutComponent';
// Widget components
export * from './widgets';

View File

@@ -0,0 +1,311 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 按钮状态样式
* Button state style configuration
*/
export interface UIButtonStyle {
backgroundColor: number;
backgroundAlpha: number;
textColor: number;
borderColor: number;
borderWidth: number;
texture?: string;
}
/**
* 按钮显示模式
* Button display mode
*/
export type UIButtonDisplayMode = 'color' | 'texture' | 'both';
/**
* UI 按钮组件
* UI Button Component - Button-specific state and callbacks
*/
@ECSComponent('UIButton')
@Serializable({ version: 1, typeId: 'UIButton' })
export class UIButtonComponent extends Component {
/**
* 按钮文本
* Button label text
*/
@Serialize()
@Property({ type: 'string', label: 'Label' })
public label: string = 'Button';
// ===== 显示模式 Display Mode =====
/**
* 显示模式:纯颜色、纯纹理、或两者结合
* Display mode: color only, texture only, or both
*/
@Serialize()
@Property({
type: 'enum',
label: 'Display Mode',
options: ['color', 'texture', 'both']
})
public displayMode: UIButtonDisplayMode = 'color';
// ===== 状态纹理 State Textures =====
/**
* 正常状态纹理
* Normal state texture
*/
@Serialize()
@Property({ type: 'asset', label: 'Normal Texture', assetType: 'texture' })
public normalTexture: string = '';
/**
* 悬停状态纹理
* Hover state texture
*/
@Serialize()
@Property({ type: 'asset', label: 'Hover Texture', assetType: 'texture' })
public hoverTexture: string = '';
/**
* 按下状态纹理
* Pressed state texture
*/
@Serialize()
@Property({ type: 'asset', label: 'Pressed Texture', assetType: 'texture' })
public pressedTexture: string = '';
/**
* 禁用状态纹理
* Disabled state texture
*/
@Serialize()
@Property({ type: 'asset', label: 'Disabled Texture', assetType: 'texture' })
public disabledTexture: string = '';
// ===== 状态样式 State Styles =====
/**
* 正常状态颜色
* Normal state background color
*/
@Serialize()
@Property({ type: 'color', label: 'Normal Color' })
public normalColor: number = 0x4A90D9;
/**
* 悬停状态颜色
* Hover state background color
*/
@Serialize()
@Property({ type: 'color', label: 'Hover Color' })
public hoverColor: number = 0x5BA0E9;
/**
* 按下状态颜色
* Pressed state background color
*/
@Serialize()
@Property({ type: 'color', label: 'Pressed Color' })
public pressedColor: number = 0x3A80C9;
/**
* 禁用状态颜色
* Disabled state background color
*/
@Serialize()
@Property({ type: 'color', label: 'Disabled Color' })
public disabledColor: number = 0x888888;
/**
* 聚焦状态颜色
* Focused state background color
*/
@Serialize()
@Property({ type: 'color', label: 'Focused Color' })
public focusedColor: number = 0x4A90D9;
/**
* 文本颜色
* Text color
*/
@Serialize()
@Property({ type: 'color', label: 'Text Color' })
public textColor: number = 0xFFFFFF;
/**
* 禁用时文本颜色
* Disabled text color
*/
@Serialize()
@Property({ type: 'color', label: 'Disabled Text Color' })
public disabledTextColor: number = 0xCCCCCC;
// ===== 动画 Animation =====
/**
* 颜色过渡时长(秒)
* Color transition duration in seconds
*/
@Serialize()
@Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 })
public transitionDuration: number = 0.1;
/**
* 当前显示颜色(动画插值用)
* Current display color (for animation)
*/
public currentColor: number = 0x4A90D9;
/**
* 目标颜色
* Target color
*/
public targetColor: number = 0x4A90D9;
// ===== 回调 Callbacks =====
/**
* 点击回调
* Click callback
*/
public onClick?: () => void;
/**
* 长按回调
* Long press callback
*/
public onLongPress?: () => void;
/**
* 长按阈值(毫秒)
* Long press threshold in milliseconds
*/
@Serialize()
@Property({ type: 'number', label: 'Long Press Threshold', min: 0 })
public longPressThreshold: number = 500;
/**
* 长按计时器
* Long press timer
*/
public pressTimer: number = 0;
/**
* 是否已触发长按
* Whether long press has been triggered
*/
public longPressTriggered: boolean = false;
// ===== 配置 Configuration =====
/**
* 是否禁用
* Whether button is disabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Disabled' })
public disabled: boolean = false;
/**
* 是否显示涟漪效果
* Whether to show ripple effect
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Ripple' })
public showRipple: boolean = false;
/**
* 涟漪颜色
* Ripple color
*/
@Serialize()
@Property({ type: 'color', label: 'Ripple Color' })
public rippleColor: number = 0xFFFFFF;
/**
* 涟漪透明度
* Ripple alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Ripple Alpha', min: 0, max: 1, step: 0.01 })
public rippleAlpha: number = 0.3;
/**
* 获取当前应该显示的背景颜色
* Get the background color that should be displayed based on state
*/
public getStateColor(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): number {
if (this.disabled) return this.disabledColor;
switch (state) {
case 'pressed': return this.pressedColor;
case 'hovered': return this.hoverColor;
case 'focused': return this.focusedColor;
default: return this.normalColor;
}
}
/**
* 获取当前应该显示的纹理
* Get the texture that should be displayed based on state
*/
public getStateTexture(state: 'disabled' | 'pressed' | 'hovered' | 'focused' | 'normal'): string {
if (this.disabled && this.disabledTexture) return this.disabledTexture;
switch (state) {
case 'pressed': return this.pressedTexture || this.normalTexture;
case 'hovered': return this.hoverTexture || this.normalTexture;
case 'focused': return this.normalTexture;
default: return this.normalTexture;
}
}
/**
* 是否使用纹理渲染
* Whether to use texture for rendering
*/
public useTexture(): boolean {
return (this.displayMode === 'texture' || this.displayMode === 'both') && !!this.normalTexture;
}
/**
* 是否使用颜色渲染
* Whether to use color for rendering
*/
public useColor(): boolean {
return this.displayMode === 'color' || this.displayMode === 'both';
}
/**
* 获取当前应该显示的文本颜色
* Get the text color that should be displayed based on state
*/
public getTextColor(): number {
return this.disabled ? this.disabledTextColor : this.textColor;
}
/**
* 设置颜色主题
* Set color theme
*/
public setColors(normal: number, hover: number, pressed: number, disabled?: number): this {
this.normalColor = normal;
this.hoverColor = hover;
this.pressedColor = pressed;
if (disabled !== undefined) this.disabledColor = disabled;
this.currentColor = normal;
this.targetColor = normal;
return this;
}
/**
* 设置纹理
* Set textures for different states
*/
public setTextures(normal: string, hover?: string, pressed?: string, disabled?: string): this {
this.normalTexture = normal;
if (hover) this.hoverTexture = hover;
if (pressed) this.pressedTexture = pressed;
if (disabled) this.disabledTexture = disabled;
this.displayMode = 'texture';
return this;
}
}

View File

@@ -0,0 +1,337 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 进度条方向
* Progress bar direction
*/
export enum UIProgressDirection {
/** 从左到右 Left to right */
LeftToRight = 'left-to-right',
/** 从右到左 Right to left */
RightToLeft = 'right-to-left',
/** 从下到上 Bottom to top */
BottomToTop = 'bottom-to-top',
/** 从上到下 Top to bottom */
TopToBottom = 'top-to-bottom'
}
/**
* 进度条填充模式
* Progress bar fill mode
*/
export enum UIProgressFillMode {
/** 水平填充 Horizontal fill */
Horizontal = 'horizontal',
/** 垂直填充 Vertical fill */
Vertical = 'vertical',
/** 圆形填充 Radial fill */
Radial = 'radial'
}
/**
* UI 进度条组件
* UI ProgressBar Component - Progress indicator
*/
@ECSComponent('UIProgressBar')
@Serializable({ version: 1, typeId: 'UIProgressBar' })
export class UIProgressBarComponent extends Component {
// ===== 数值 Values =====
/**
* 当前值
* Current value
*/
@Serialize()
@Property({ type: 'number', label: 'Value' })
public value: number = 0;
/**
* 最小值
* Minimum value
*/
@Serialize()
@Property({ type: 'number', label: 'Min Value' })
public minValue: number = 0;
/**
* 最大值
* Maximum value
*/
@Serialize()
@Property({ type: 'number', label: 'Max Value' })
public maxValue: number = 100;
/**
* 目标值(用于动画)
* Target value (for animation)
*/
public targetValue: number = 0;
/**
* 显示值(动画插值后的值)
* Display value (interpolated for animation)
*/
public displayValue: number = 0;
// ===== 样式 Style =====
/**
* 填充颜色
* Fill color
*/
@Serialize()
@Property({ type: 'color', label: 'Fill Color' })
public fillColor: number = 0x4CAF50;
/**
* 填充透明度
* Fill alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Fill Alpha', min: 0, max: 1, step: 0.01 })
public fillAlpha: number = 1;
/**
* 背景颜色
* Background color
*/
@Serialize()
@Property({ type: 'color', label: 'Background Color' })
public backgroundColor: number = 0x333333;
/**
* 背景透明度
* Background alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Background Alpha', min: 0, max: 1, step: 0.01 })
public backgroundAlpha: number = 1;
/**
* 边框颜色
* Border color
*/
@Serialize()
@Property({ type: 'color', label: 'Border Color' })
public borderColor: number = 0x000000;
/**
* 边框宽度
* Border width
*/
@Serialize()
@Property({ type: 'number', label: 'Border Width', min: 0 })
public borderWidth: number = 0;
/**
* 圆角半径
* Corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Corner Radius', min: 0 })
public cornerRadius: number = 0;
// ===== 方向和填充 Direction & Fill =====
/**
* 进度方向
* Progress direction
*/
@Serialize()
@Property({
type: 'enum',
label: 'Direction',
options: [
{ value: 'left-to-right', label: 'Left to Right' },
{ value: 'right-to-left', label: 'Right to Left' },
{ value: 'bottom-to-top', label: 'Bottom to Top' },
{ value: 'top-to-bottom', label: 'Top to Bottom' }
]
})
public direction: UIProgressDirection = UIProgressDirection.LeftToRight;
/**
* 填充模式
* Fill mode
*/
@Serialize()
@Property({
type: 'enum',
label: 'Fill Mode',
options: [
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
{ value: 'radial', label: 'Radial' }
]
})
public fillMode: UIProgressFillMode = UIProgressFillMode.Horizontal;
// ===== 动画 Animation =====
/**
* 过渡时长(秒)
* Transition duration in seconds
*/
@Serialize()
@Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 })
public transitionDuration: number = 0.3;
/**
* 缓动函数
* Easing function name
*/
@Serialize()
@Property({ type: 'string', label: 'Easing' })
public easing: string = 'easeOut';
// ===== 分段 Segments =====
/**
* 是否分段显示
* Whether to show segments
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Segments' })
public showSegments: boolean = false;
/**
* 分段数量
* Number of segments
*/
@Serialize()
@Property({ type: 'integer', label: 'Segments', min: 1 })
public segments: number = 10;
/**
* 分段间隙
* Gap between segments
*/
@Serialize()
@Property({ type: 'number', label: 'Segment Gap', min: 0 })
public segmentGap: number = 2;
// ===== 渐变 Gradient =====
/**
* 是否使用渐变
* Whether to use gradient fill
*/
@Serialize()
@Property({ type: 'boolean', label: 'Use Gradient' })
public useGradient: boolean = false;
/**
* 渐变起始颜色
* Gradient start color
*/
@Serialize()
@Property({ type: 'color', label: 'Gradient Start Color' })
public gradientStartColor: number = 0x4CAF50;
/**
* 渐变结束颜色
* Gradient end color
*/
@Serialize()
@Property({ type: 'color', label: 'Gradient End Color' })
public gradientEndColor: number = 0x8BC34A;
// ===== 文本 Text =====
/**
* 是否显示文本
* Whether to show text
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Text' })
public showText: boolean = false;
/**
* 文本格式({value}, {percent}, {min}, {max}
* Text format template
*/
@Serialize()
@Property({ type: 'string', label: 'Text Format' })
public textFormat: string = '{percent}%';
/**
* 文本颜色
* Text color
*/
@Serialize()
@Property({ type: 'color', label: 'Text Color' })
public textColor: number = 0xFFFFFF;
/**
* 获取进度百分比 (0-1)
* Get progress as percentage (0-1)
*/
public getProgress(): number {
const range = this.maxValue - this.minValue;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (this.displayValue - this.minValue) / range));
}
/**
* 获取格式化的文本
* Get formatted text
*/
public getFormattedText(): string {
const percent = Math.round(this.getProgress() * 100);
return this.textFormat
.replace('{value}', this.displayValue.toFixed(0))
.replace('{percent}', percent.toString())
.replace('{min}', this.minValue.toString())
.replace('{max}', this.maxValue.toString());
}
/**
* 设置值(带动画)
* Set value (with animation)
*/
public setValue(value: number, animate: boolean = true): this {
this.targetValue = Math.max(this.minValue, Math.min(this.maxValue, value));
if (!animate) {
this.value = this.targetValue;
this.displayValue = this.targetValue;
}
return this;
}
/**
* 设置颜色
* Set colors
*/
public setColors(fill: number, background: number): this {
this.fillColor = fill;
this.backgroundColor = background;
return this;
}
/**
* 设置渐变
* Set gradient colors
*/
public setGradient(startColor: number, endColor: number): this {
this.useGradient = true;
this.gradientStartColor = startColor;
this.gradientEndColor = endColor;
return this;
}
/**
* 增加值
* Increase value
*/
public increase(amount: number = 1): this {
return this.setValue(this.targetValue + amount);
}
/**
* 减少值
* Decrease value
*/
public decrease(amount: number = 1): this {
return this.setValue(this.targetValue - amount);
}
}

View File

@@ -0,0 +1,370 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 滚动条可见性
* Scrollbar visibility mode
*/
export enum UIScrollbarVisibility {
/** 总是显示 Always visible */
Always = 'always',
/** 自动显示内容超出时Auto show when content exceeds */
Auto = 'auto',
/** 总是隐藏 Always hidden */
Hidden = 'hidden'
}
/**
* UI 滚动视图组件
* UI ScrollView Component - Scrollable container
*/
@ECSComponent('UIScrollView')
@Serializable({ version: 1, typeId: 'UIScrollView' })
export class UIScrollViewComponent extends Component {
// ===== 滚动位置 Scroll Position =====
/**
* 水平滚动位置
* Horizontal scroll position
*/
public scrollX: number = 0;
/**
* 垂直滚动位置
* Vertical scroll position
*/
public scrollY: number = 0;
/**
* 目标水平滚动位置(动画用)
* Target horizontal scroll position (for animation)
*/
public targetScrollX: number = 0;
/**
* 目标垂直滚动位置(动画用)
* Target vertical scroll position (for animation)
*/
public targetScrollY: number = 0;
// ===== 内容尺寸 Content Size =====
/**
* 内容宽度
* Content width
*/
public contentWidth: number = 0;
/**
* 内容高度
* Content height
*/
public contentHeight: number = 0;
// ===== 滚动配置 Scroll Configuration =====
/**
* 是否启用水平滚动
* Whether horizontal scroll is enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Horizontal Scroll' })
public horizontalScroll: boolean = false;
/**
* 是否启用垂直滚动
* Whether vertical scroll is enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Vertical Scroll' })
public verticalScroll: boolean = true;
/**
* 滚动条可见性
* Scrollbar visibility mode
*/
@Serialize()
@Property({
type: 'enum',
label: 'Scrollbar Visibility',
options: [
{ value: 'always', label: 'Always' },
{ value: 'auto', label: 'Auto' },
{ value: 'hidden', label: 'Hidden' }
]
})
public scrollbarVisibility: UIScrollbarVisibility = UIScrollbarVisibility.Auto;
/**
* 是否启用惯性滚动
* Whether inertia scrolling is enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Inertia' })
public inertia: boolean = true;
/**
* 惯性减速率
* Inertia deceleration rate
*/
@Serialize()
@Property({ type: 'number', label: 'Deceleration Rate', min: 0, max: 1, step: 0.001 })
public decelerationRate: number = 0.135;
/**
* 是否启用弹性边界
* Whether elastic bounds are enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Elastic Bounds' })
public elasticBounds: boolean = true;
/**
* 弹性系数
* Elasticity coefficient
*/
@Serialize()
@Property({ type: 'number', label: 'Elasticity', min: 0, max: 1, step: 0.01 })
public elasticity: number = 0.1;
// ===== 滚动条样式 Scrollbar Style =====
/**
* 滚动条宽度
* Scrollbar width
*/
@Serialize()
@Property({ type: 'number', label: 'Scrollbar Width', min: 1 })
public scrollbarWidth: number = 8;
/**
* 滚动条颜色
* Scrollbar color
*/
@Serialize()
@Property({ type: 'color', label: 'Scrollbar Color' })
public scrollbarColor: number = 0x888888;
/**
* 滚动条透明度
* Scrollbar alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Scrollbar Alpha', min: 0, max: 1, step: 0.01 })
public scrollbarAlpha: number = 0.5;
/**
* 滚动条悬停透明度
* Scrollbar hover alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Scrollbar Hover Alpha', min: 0, max: 1, step: 0.01 })
public scrollbarHoverAlpha: number = 0.8;
/**
* 滚动条圆角
* Scrollbar corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Scrollbar Radius', min: 0 })
public scrollbarRadius: number = 4;
/**
* 滚动条轨道颜色
* Scrollbar track color
*/
@Serialize()
@Property({ type: 'color', label: 'Scrollbar Track Color' })
public scrollbarTrackColor: number = 0x333333;
/**
* 滚动条轨道透明度
* Scrollbar track alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Scrollbar Track Alpha', min: 0, max: 1, step: 0.01 })
public scrollbarTrackAlpha: number = 0.3;
// ===== 交互状态 Interaction State =====
/**
* 是否正在拖拽滚动
* Whether currently dragging to scroll
*/
public dragging: boolean = false;
/**
* 拖拽起始滚动位置 X
* Drag start scroll X
*/
public dragStartScrollX: number = 0;
/**
* 拖拽起始滚动位置 Y
* Drag start scroll Y
*/
public dragStartScrollY: number = 0;
/**
* 滚动速度 X用于惯性
* Scroll velocity X (for inertia)
*/
public velocityX: number = 0;
/**
* 滚动速度 Y用于惯性
* Scroll velocity Y (for inertia)
*/
public velocityY: number = 0;
/**
* 水平滚动条是否被悬停
* Whether horizontal scrollbar is hovered
*/
public horizontalScrollbarHovered: boolean = false;
/**
* 垂直滚动条是否被悬停
* Whether vertical scrollbar is hovered
*/
public verticalScrollbarHovered: boolean = false;
/**
* 是否正在拖拽滚动条
* Whether dragging scrollbar
*/
public draggingScrollbar: boolean = false;
// ===== 滚轮配置 Wheel Configuration =====
/**
* 滚轮滚动速度
* Mouse wheel scroll speed
*/
@Serialize()
@Property({ type: 'number', label: 'Wheel Speed', min: 1 })
public wheelSpeed: number = 40;
/**
* 是否平滑滚动
* Whether to use smooth scrolling
*/
@Serialize()
@Property({ type: 'boolean', label: 'Smooth Scroll' })
public smoothScroll: boolean = true;
/**
* 平滑滚动时长(秒)
* Smooth scroll duration in seconds
*/
@Serialize()
@Property({ type: 'number', label: 'Smooth Scroll Duration', min: 0, step: 0.01 })
public smoothScrollDuration: number = 0.2;
/**
* 获取最大水平滚动位置
* Get maximum horizontal scroll position
*/
public getMaxScrollX(viewportWidth: number): number {
return Math.max(0, this.contentWidth - viewportWidth);
}
/**
* 获取最大垂直滚动位置
* Get maximum vertical scroll position
*/
public getMaxScrollY(viewportHeight: number): number {
return Math.max(0, this.contentHeight - viewportHeight);
}
/**
* 设置滚动位置
* Set scroll position
*/
public setScroll(x: number, y: number, animate: boolean = true): this {
this.targetScrollX = x;
this.targetScrollY = y;
if (!animate) {
this.scrollX = x;
this.scrollY = y;
}
return this;
}
/**
* 滚动到顶部
* Scroll to top
*/
public scrollToTop(animate: boolean = true): this {
return this.setScroll(this.scrollX, 0, animate);
}
/**
* 滚动到底部
* Scroll to bottom
*/
public scrollToBottom(viewportHeight: number, animate: boolean = true): this {
return this.setScroll(this.scrollX, this.getMaxScrollY(viewportHeight), animate);
}
/**
* 滚动到指定位置(百分比)
* Scroll to position by percentage
*/
public scrollToPercent(percentX: number, percentY: number, viewportWidth: number, viewportHeight: number, animate: boolean = true): this {
const x = this.getMaxScrollX(viewportWidth) * Math.max(0, Math.min(1, percentX));
const y = this.getMaxScrollY(viewportHeight) * Math.max(0, Math.min(1, percentY));
return this.setScroll(x, y, animate);
}
/**
* 是否需要显示水平滚动条
* Whether horizontal scrollbar should be visible
*/
public needsHorizontalScrollbar(viewportWidth: number): boolean {
if (!this.horizontalScroll) return false;
if (this.scrollbarVisibility === UIScrollbarVisibility.Hidden) return false;
if (this.scrollbarVisibility === UIScrollbarVisibility.Always) return true;
return this.contentWidth > viewportWidth;
}
/**
* 是否需要显示垂直滚动条
* Whether vertical scrollbar should be visible
*/
public needsVerticalScrollbar(viewportHeight: number): boolean {
if (!this.verticalScroll) return false;
if (this.scrollbarVisibility === UIScrollbarVisibility.Hidden) return false;
if (this.scrollbarVisibility === UIScrollbarVisibility.Always) return true;
return this.contentHeight > viewportHeight;
}
/**
* 获取垂直滚动条手柄尺寸和位置
* Get vertical scrollbar handle size and position
*/
public getVerticalScrollbarMetrics(viewportHeight: number): { size: number; position: number } {
const maxScroll = this.getMaxScrollY(viewportHeight);
if (maxScroll <= 0) return { size: viewportHeight, position: 0 };
const size = Math.max(20, (viewportHeight / this.contentHeight) * viewportHeight);
const availableTrack = viewportHeight - size;
const position = (this.scrollY / maxScroll) * availableTrack;
return { size, position };
}
/**
* 获取水平滚动条手柄尺寸和位置
* Get horizontal scrollbar handle size and position
*/
public getHorizontalScrollbarMetrics(viewportWidth: number): { size: number; position: number } {
const maxScroll = this.getMaxScrollX(viewportWidth);
if (maxScroll <= 0) return { size: viewportWidth, position: 0 };
const size = Math.max(20, (viewportWidth / this.contentWidth) * viewportWidth);
const availableTrack = viewportWidth - size;
const position = (this.scrollX / maxScroll) * availableTrack;
return { size, position };
}
}

View File

@@ -0,0 +1,390 @@
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* 滑块方向
* Slider orientation
*/
export enum UISliderOrientation {
Horizontal = 'horizontal',
Vertical = 'vertical'
}
/**
* UI 滑块组件
* UI Slider Component - Value slider with handle
*/
@ECSComponent('UISlider')
@Serializable({ version: 1, typeId: 'UISlider' })
export class UISliderComponent extends Component {
// ===== 数值 Values =====
/**
* 当前值
* Current value
*/
@Serialize()
@Property({ type: 'number', label: 'Value' })
public value: number = 0;
/**
* 最小值
* Minimum value
*/
@Serialize()
@Property({ type: 'number', label: 'Min Value' })
public minValue: number = 0;
/**
* 最大值
* Maximum value
*/
@Serialize()
@Property({ type: 'number', label: 'Max Value' })
public maxValue: number = 100;
/**
* 步进值0 = 连续)
* Step value (0 = continuous)
*/
@Serialize()
@Property({ type: 'number', label: 'Step', min: 0 })
public step: number = 0;
/**
* 目标值(用于动画)
* Target value (for animation)
*/
public targetValue: number = 0;
/**
* 显示值(动画插值后)
* Display value (interpolated)
*/
public displayValue: number = 0;
// ===== 方向 Orientation =====
/**
* 滑块方向
* Slider orientation
*/
@Serialize()
@Property({
type: 'enum',
label: 'Orientation',
options: [
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' }
]
})
public orientation: UISliderOrientation = UISliderOrientation.Horizontal;
// ===== 轨道样式 Track Style =====
/**
* 轨道颜色
* Track color
*/
@Serialize()
@Property({ type: 'color', label: 'Track Color' })
public trackColor: number = 0x444444;
/**
* 轨道透明度
* Track alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Track Alpha', min: 0, max: 1, step: 0.01 })
public trackAlpha: number = 1;
/**
* 轨道高度(水平)或宽度(垂直)
* Track thickness
*/
@Serialize()
@Property({ type: 'number', label: 'Track Thickness', min: 1 })
public trackThickness: number = 4;
/**
* 轨道圆角
* Track corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Track Radius', min: 0 })
public trackRadius: number = 2;
// ===== 填充样式 Fill Style =====
/**
* 填充颜色(已滑过的部分)
* Fill color (passed portion)
*/
@Serialize()
@Property({ type: 'color', label: 'Fill Color' })
public fillColor: number = 0x4A90D9;
/**
* 填充透明度
* Fill alpha
*/
@Serialize()
@Property({ type: 'number', label: 'Fill Alpha', min: 0, max: 1, step: 0.01 })
public fillAlpha: number = 1;
// ===== 手柄样式 Handle Style =====
/**
* 手柄宽度
* Handle width
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Width', min: 1 })
public handleWidth: number = 16;
/**
* 手柄高度
* Handle height
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Height', min: 1 })
public handleHeight: number = 16;
/**
* 手柄颜色
* Handle color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Color' })
public handleColor: number = 0xFFFFFF;
/**
* 手柄悬停颜色
* Handle hover color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Hover Color' })
public handleHoverColor: number = 0xE0E0E0;
/**
* 手柄按下颜色
* Handle pressed color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Pressed Color' })
public handlePressedColor: number = 0xCCCCCC;
/**
* 手柄圆角
* Handle corner radius
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Radius', min: 0 })
public handleRadius: number = 8;
/**
* 手柄边框宽度
* Handle border width
*/
@Serialize()
@Property({ type: 'number', label: 'Handle Border Width', min: 0 })
public handleBorderWidth: number = 0;
/**
* 手柄边框颜色
* Handle border color
*/
@Serialize()
@Property({ type: 'color', label: 'Handle Border Color' })
public handleBorderColor: number = 0x000000;
/**
* 手柄阴影
* Handle shadow enabled
*/
@Serialize()
@Property({ type: 'boolean', label: 'Handle Shadow' })
public handleShadow: boolean = true;
// ===== 交互状态 Interaction State =====
/**
* 手柄是否被悬停
* Whether handle is hovered
*/
public handleHovered: boolean = false;
/**
* 是否正在拖拽
* Whether currently dragging
*/
public dragging: boolean = false;
/**
* 拖拽起始值
* Drag start value
*/
public dragStartValue: number = 0;
/**
* 拖拽起始位置
* Drag start position
*/
public dragStartPosition: number = 0;
// ===== 动画 Animation =====
/**
* 过渡时长(秒)
* Transition duration in seconds
*/
@Serialize()
@Property({ type: 'number', label: 'Transition Duration', min: 0, step: 0.01 })
public transitionDuration: number = 0.1;
// ===== 刻度 Ticks =====
/**
* 是否显示刻度
* Whether to show ticks
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Ticks' })
public showTicks: boolean = false;
/**
* 刻度数量(不包括首尾)
* Number of ticks (excluding ends)
*/
@Serialize()
@Property({ type: 'integer', label: 'Tick Count', min: 0 })
public tickCount: number = 4;
/**
* 刻度颜色
* Tick color
*/
@Serialize()
@Property({ type: 'color', label: 'Tick Color' })
public tickColor: number = 0x666666;
/**
* 刻度大小
* Tick size
*/
@Serialize()
@Property({ type: 'number', label: 'Tick Size', min: 1 })
public tickSize: number = 4;
// ===== 文本 Text =====
/**
* 是否显示值文本
* Whether to show value text
*/
@Serialize()
@Property({ type: 'boolean', label: 'Show Value' })
public showValue: boolean = false;
/**
* 值文本格式
* Value text format
*/
@Serialize()
@Property({ type: 'string', label: 'Value Format' })
public valueFormat: string = '{value}';
/**
* 小数位数
* Decimal places
*/
@Serialize()
@Property({ type: 'integer', label: 'Decimal Places', min: 0 })
public decimalPlaces: number = 0;
// ===== 回调 Callbacks =====
/**
* 值改变回调
* Value change callback
*/
public onChange?: (value: number) => void;
/**
* 拖拽开始回调
* Drag start callback
*/
public onDragStart?: (value: number) => void;
/**
* 拖拽结束回调
* Drag end callback
*/
public onDragEnd?: (value: number) => void;
/**
* 获取进度百分比 (0-1)
* Get progress as percentage (0-1)
*/
public getProgress(): number {
const range = this.maxValue - this.minValue;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (this.displayValue - this.minValue) / range));
}
/**
* 从百分比设置值
* Set value from percentage
*/
public setProgress(progress: number): this {
const range = this.maxValue - this.minValue;
return this.setValue(this.minValue + range * Math.max(0, Math.min(1, progress)));
}
/**
* 设置值
* Set value
*/
public setValue(value: number, animate: boolean = true): this {
let newValue = Math.max(this.minValue, Math.min(this.maxValue, value));
// 应用步进
if (this.step > 0) {
newValue = Math.round((newValue - this.minValue) / this.step) * this.step + this.minValue;
}
this.targetValue = newValue;
if (!animate) {
this.value = newValue;
this.displayValue = newValue;
}
return this;
}
/**
* 获取格式化的值文本
* Get formatted value text
*/
public getFormattedValue(): string {
const formattedValue = this.displayValue.toFixed(this.decimalPlaces);
return this.valueFormat.replace('{value}', formattedValue);
}
/**
* 计算手柄位置(归一化 0-1
* Calculate handle position (normalized 0-1)
*/
public getHandlePosition(): number {
return this.getProgress();
}
/**
* 获取当前手柄颜色
* Get current handle color based on state
*/
public getCurrentHandleColor(): number {
if (this.dragging) return this.handlePressedColor;
if (this.handleHovered) return this.handleHoverColor;
return this.handleColor;
}
}

View File

@@ -0,0 +1,4 @@
export * from './UIButtonComponent';
export * from './UIProgressBarComponent';
export * from './UISliderComponent';
export * from './UIScrollViewComponent';

116
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* @esengine/ui - ECS-based UI System
*
* 基于 ECS 架构的 UI 系统,支持 WebGL 渲染
* ECS-based UI system with WebGL rendering support
*
* @example
* ```typescript
* import { UIBuilder, UILayoutSystem, UIInputSystem, UIAnimationSystem } from '@esengine/ui';
*
* // 创建 UI Scene
* const uiScene = world.createScene('ui');
*
* // 添加 UI 系统
* uiScene.addSystem(new UILayoutSystem());
* uiScene.addSystem(new UIInputSystem());
* uiScene.addSystem(new UIAnimationSystem());
*
* // 使用 UIBuilder 创建元素
* const ui = new UIBuilder(uiScene);
*
* const button = ui.button({
* x: 100, y: 100,
* width: 120, height: 40,
* label: 'Click Me',
* onClick: () => console.log('Clicked!')
* });
*
* const progressBar = ui.progressBar({
* x: 100, y: 160,
* width: 200, height: 20,
* value: 75,
* maxValue: 100
* });
* ```
*/
// 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
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';
// 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';

View File

@@ -0,0 +1,299 @@
/**
* 文本渲染器
* Text Renderer - Renders text to textures for WebGL
*
* 使用 Canvas 2D API 渲染文本到纹理
* Uses Canvas 2D API to render text to textures
*/
export interface TextMeasurement {
width: number;
height: number;
lines: string[];
lineHeights: number[];
}
export interface TextRenderOptions {
fontSize: number;
fontFamily: string;
fontWeight: string | number;
italic: boolean;
color: number;
alpha: number;
align: 'left' | 'center' | 'right';
verticalAlign: 'top' | 'middle' | 'bottom';
wordWrap: boolean;
wrapWidth: number;
lineHeight: number;
letterSpacing: number;
strokeWidth: number;
strokeColor: number;
shadowEnabled: boolean;
shadowOffsetX: number;
shadowOffsetY: number;
shadowColor: number;
shadowAlpha: number;
}
export class TextRenderer {
private gl: WebGLRenderingContext;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private textureCache: Map<string, WebGLTexture> = new Map();
constructor(gl: WebGLRenderingContext) {
this.gl = gl;
// 创建离屏 Canvas
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d')!;
}
/**
* 测量文本尺寸
* Measure text dimensions
*/
public measureText(text: string, options: Partial<TextRenderOptions>): TextMeasurement {
const opts = this.getDefaultOptions(options);
this.setupContext(opts);
let lines: string[];
if (opts.wordWrap && opts.wrapWidth > 0) {
lines = this.wrapText(text, opts.wrapWidth);
} else {
lines = text.split('\n');
}
const lineHeight = opts.fontSize * opts.lineHeight;
let maxWidth = 0;
for (const line of lines) {
const metrics = this.ctx.measureText(line);
maxWidth = Math.max(maxWidth, metrics.width);
}
return {
width: maxWidth,
height: lines.length * lineHeight,
lines,
lineHeights: lines.map(() => lineHeight)
};
}
/**
* 渲染文本到纹理
* Render text to texture
*/
public renderToTexture(
text: string,
options: Partial<TextRenderOptions>,
width?: number,
height?: number
): WebGLTexture | null {
const opts = this.getDefaultOptions(options);
const measurement = this.measureText(text, options);
// 使用指定尺寸或测量尺寸
const canvasWidth = Math.ceil(width ?? measurement.width) + opts.strokeWidth * 2;
const canvasHeight = Math.ceil(height ?? measurement.height) + opts.strokeWidth * 2;
if (canvasWidth <= 0 || canvasHeight <= 0) return null;
// 调整 Canvas 尺寸
this.canvas.width = canvasWidth;
this.canvas.height = canvasHeight;
// 清除背景
this.ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 设置绘制样式
this.setupContext(opts);
// 计算起始位置
const lineHeight = opts.fontSize * opts.lineHeight;
let startY = opts.strokeWidth;
if (opts.verticalAlign === 'middle') {
startY = (canvasHeight - measurement.height) / 2;
} else if (opts.verticalAlign === 'bottom') {
startY = canvasHeight - measurement.height - opts.strokeWidth;
}
// 绘制每行
for (let i = 0; i < measurement.lines.length; i++) {
const line = measurement.lines[i]!;
let x = opts.strokeWidth;
if (opts.align === 'center') {
const lineWidth = this.ctx.measureText(line).width;
x = (canvasWidth - lineWidth) / 2;
} else if (opts.align === 'right') {
const lineWidth = this.ctx.measureText(line).width;
x = canvasWidth - lineWidth - opts.strokeWidth;
}
const y = startY + (i + 0.8) * lineHeight;
// 绘制阴影
if (opts.shadowEnabled) {
this.ctx.save();
this.ctx.fillStyle = this.colorToCSS(opts.shadowColor, opts.shadowAlpha);
this.ctx.fillText(line, x + opts.shadowOffsetX, y + opts.shadowOffsetY);
this.ctx.restore();
}
// 绘制描边
if (opts.strokeWidth > 0) {
this.ctx.strokeStyle = this.colorToCSS(opts.strokeColor, opts.alpha);
this.ctx.lineWidth = opts.strokeWidth;
this.ctx.strokeText(line, x, y);
}
// 绘制文本
this.ctx.fillStyle = this.colorToCSS(opts.color, opts.alpha);
this.ctx.fillText(line, x, y);
}
// 创建纹理
return this.createTextureFromCanvas();
}
/**
* 从缓存获取或创建纹理
* Get from cache or create texture
*/
public getOrCreateTexture(
text: string,
options: Partial<TextRenderOptions>,
width?: number,
height?: number
): WebGLTexture | null {
const cacheKey = this.getCacheKey(text, options, width, height);
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const texture = this.renderToTexture(text, options, width, height);
if (texture) {
this.textureCache.set(cacheKey, texture);
}
return texture;
}
/**
* 清除纹理缓存
* Clear texture cache
*/
public clearCache(): void {
for (const texture of this.textureCache.values()) {
this.gl.deleteTexture(texture);
}
this.textureCache.clear();
}
/**
* 从缓存移除指定纹理
* Remove specific texture from cache
*/
public invalidateCache(text: string, options: Partial<TextRenderOptions>): void {
const cacheKey = this.getCacheKey(text, options);
const texture = this.textureCache.get(cacheKey);
if (texture) {
this.gl.deleteTexture(texture);
this.textureCache.delete(cacheKey);
}
}
private getDefaultOptions(options: Partial<TextRenderOptions>): TextRenderOptions {
return {
fontSize: options.fontSize ?? 14,
fontFamily: options.fontFamily ?? 'Arial, sans-serif',
fontWeight: options.fontWeight ?? 'normal',
italic: options.italic ?? false,
color: options.color ?? 0x000000,
alpha: options.alpha ?? 1,
align: options.align ?? 'left',
verticalAlign: options.verticalAlign ?? 'top',
wordWrap: options.wordWrap ?? false,
wrapWidth: options.wrapWidth ?? 0,
lineHeight: options.lineHeight ?? 1.2,
letterSpacing: options.letterSpacing ?? 0,
strokeWidth: options.strokeWidth ?? 0,
strokeColor: options.strokeColor ?? 0x000000,
shadowEnabled: options.shadowEnabled ?? false,
shadowOffsetX: options.shadowOffsetX ?? 1,
shadowOffsetY: options.shadowOffsetY ?? 1,
shadowColor: options.shadowColor ?? 0x000000,
shadowAlpha: options.shadowAlpha ?? 0.5
};
}
private setupContext(opts: TextRenderOptions): void {
const style = opts.italic ? 'italic ' : '';
const weight = opts.fontWeight;
this.ctx.font = `${style}${weight} ${opts.fontSize}px ${opts.fontFamily}`;
this.ctx.textBaseline = 'top';
}
private wrapText(text: string, maxWidth: number): string[] {
const lines: string[] = [];
const paragraphs = text.split('\n');
for (const paragraph of paragraphs) {
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = this.ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
}
return lines;
}
private colorToCSS(color: number, alpha: number): string {
const r = (color >> 16) & 0xFF;
const g = (color >> 8) & 0xFF;
const b = color & 0xFF;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
private createTextureFromCanvas(): WebGLTexture | null {
const gl = this.gl;
const texture = gl.createTexture();
if (!texture) return null;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.canvas);
// 设置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
private getCacheKey(text: string, options: Partial<TextRenderOptions>, width?: number, height?: number): string {
return JSON.stringify({ text, options, width, height });
}
public dispose(): void {
this.clearCache();
}
}

View File

@@ -0,0 +1,471 @@
/**
* WebGL UI 渲染器
* WebGL UI Renderer - Low-level WebGL rendering for UI elements
*
* 支持批处理渲染以提高性能
* Supports batch rendering for better performance
*/
/**
* 顶点数据结构
* Vertex data structure
* position (2) + texcoord (2) + color (4)
*/
const VERTEX_SIZE = 8;
const VERTICES_PER_QUAD = 4;
const INDICES_PER_QUAD = 6;
const MAX_BATCH_QUADS = 2000;
/**
* 着色器源码
* Shader sources
*/
const VERTEX_SHADER_SOURCE = `
attribute vec2 a_position;
attribute vec2 a_texcoord;
attribute vec4 a_color;
uniform mat4 u_projection;
varying vec2 v_texcoord;
varying vec4 v_color;
void main() {
gl_Position = u_projection * vec4(a_position, 0.0, 1.0);
v_texcoord = a_texcoord;
v_color = a_color;
}
`;
const FRAGMENT_SHADER_SOURCE = `
precision mediump float;
varying vec2 v_texcoord;
varying vec4 v_color;
uniform sampler2D u_texture;
uniform bool u_useTexture;
void main() {
if (u_useTexture) {
gl_FragColor = texture2D(u_texture, v_texcoord) * v_color;
} else {
gl_FragColor = v_color;
}
}
`;
export class WebGLUIRenderer {
private gl: WebGLRenderingContext;
private program: WebGLProgram | null = null;
// Buffers
private vertexBuffer: WebGLBuffer | null = null;
private indexBuffer: WebGLBuffer | null = null;
private vertexData: Float32Array;
private indexData: Uint16Array;
// Batch state
private quadCount: number = 0;
private currentTexture: WebGLTexture | null = null;
// Uniform locations
private projectionLocation: WebGLUniformLocation | null = null;
private textureLocation: WebGLUniformLocation | null = null;
private useTextureLocation: WebGLUniformLocation | null = null;
// Attribute locations
private positionLocation: number = -1;
private texcoordLocation: number = -1;
private colorLocation: number = -1;
// Viewport
private viewportWidth: number = 0;
private viewportHeight: number = 0;
// 白色纹理(用于纯色绘制)
private whiteTexture: WebGLTexture | null = null;
constructor(gl: WebGLRenderingContext) {
this.gl = gl;
// 分配顶点和索引数据
this.vertexData = new Float32Array(MAX_BATCH_QUADS * VERTICES_PER_QUAD * VERTEX_SIZE);
this.indexData = new Uint16Array(MAX_BATCH_QUADS * INDICES_PER_QUAD);
// 预填充索引数据
for (let i = 0; i < MAX_BATCH_QUADS; i++) {
const vi = i * 4;
const ii = i * 6;
this.indexData[ii + 0] = vi + 0;
this.indexData[ii + 1] = vi + 1;
this.indexData[ii + 2] = vi + 2;
this.indexData[ii + 3] = vi + 2;
this.indexData[ii + 4] = vi + 3;
this.indexData[ii + 5] = vi + 0;
}
this.initShaders();
this.initBuffers();
this.createWhiteTexture();
}
private initShaders(): void {
const gl = this.gl;
// 编译着色器
const vertexShader = this.compileShader(gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE);
if (!vertexShader || !fragmentShader) {
throw new Error('Failed to compile shaders');
}
// 链接程序
this.program = gl.createProgram();
if (!this.program) {
throw new Error('Failed to create shader program');
}
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
throw new Error('Failed to link shader program: ' + gl.getProgramInfoLog(this.program));
}
// 获取 attribute 位置
this.positionLocation = gl.getAttribLocation(this.program, 'a_position');
this.texcoordLocation = gl.getAttribLocation(this.program, 'a_texcoord');
this.colorLocation = gl.getAttribLocation(this.program, 'a_color');
// 获取 uniform 位置
this.projectionLocation = gl.getUniformLocation(this.program, 'u_projection');
this.textureLocation = gl.getUniformLocation(this.program, 'u_texture');
this.useTextureLocation = gl.getUniformLocation(this.program, 'u_useTexture');
}
private compileShader(type: number, source: string): WebGLShader | null {
const gl = this.gl;
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
private initBuffers(): void {
const gl = this.gl;
// 创建顶点缓冲
this.vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this.vertexData, gl.DYNAMIC_DRAW);
// 创建索引缓冲
this.indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indexData, gl.STATIC_DRAW);
}
private createWhiteTexture(): void {
const gl = this.gl;
this.whiteTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.whiteTexture);
// 1x1 白色像素
const pixel = new Uint8Array([255, 255, 255, 255]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
/**
* 设置视口尺寸
* Set viewport size
*/
public setViewport(width: number, height: number): void {
this.viewportWidth = width;
this.viewportHeight = height;
}
/**
* 开始渲染批次
* Begin render batch
*/
public begin(): void {
const gl = this.gl;
gl.viewport(0, 0, this.viewportWidth, this.viewportHeight);
// 启用混合
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 禁用深度测试
gl.disable(gl.DEPTH_TEST);
// 使用程序
gl.useProgram(this.program);
// 设置投影矩阵(正交投影)
const projection = this.createOrthographicMatrix(0, this.viewportWidth, this.viewportHeight, 0, -1, 1);
gl.uniformMatrix4fv(this.projectionLocation, false, projection);
// 绑定纹理单元
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(this.textureLocation, 0);
// 绑定缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
// 设置顶点属性
const stride = VERTEX_SIZE * 4;
gl.enableVertexAttribArray(this.positionLocation);
gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(this.texcoordLocation);
gl.vertexAttribPointer(this.texcoordLocation, 2, gl.FLOAT, false, stride, 8);
gl.enableVertexAttribArray(this.colorLocation);
gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, stride, 16);
this.quadCount = 0;
this.currentTexture = null;
}
/**
* 结束渲染批次
* End render batch
*/
public end(): void {
this.flush();
}
/**
* 刷新当前批次
* Flush current batch
*/
public flush(): void {
if (this.quadCount === 0) return;
const gl = this.gl;
// 上传顶点数据
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertexData.subarray(0, this.quadCount * VERTICES_PER_QUAD * VERTEX_SIZE));
// 绑定纹理
if (this.currentTexture) {
gl.bindTexture(gl.TEXTURE_2D, this.currentTexture);
gl.uniform1i(this.useTextureLocation, 1);
} else {
gl.bindTexture(gl.TEXTURE_2D, this.whiteTexture);
gl.uniform1i(this.useTextureLocation, 0);
}
// 绘制
gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_SHORT, 0);
this.quadCount = 0;
}
/**
* 绘制矩形
* Draw rectangle
*/
public drawRect(
x: number,
y: number,
width: number,
height: number,
color: number,
alpha: number = 1
): void {
this.drawQuad(x, y, width, height, 0, 0, 1, 1, color, alpha, null);
}
/**
* 绘制纹理
* Draw texture
*/
public drawTexture(
texture: WebGLTexture,
x: number,
y: number,
width: number,
height: number,
u0: number = 0,
v0: number = 0,
u1: number = 1,
v1: number = 1,
tint: number = 0xFFFFFF,
alpha: number = 1
): void {
this.drawQuad(x, y, width, height, u0, v0, u1, v1, tint, alpha, texture);
}
/**
* 绘制四边形
* Draw quad
*/
private drawQuad(
x: number,
y: number,
width: number,
height: number,
u0: number,
v0: number,
u1: number,
v1: number,
color: number,
alpha: number,
texture: WebGLTexture | null
): void {
// 检查是否需要刷新
if (this.quadCount >= MAX_BATCH_QUADS) {
this.flush();
}
if (texture !== this.currentTexture) {
this.flush();
this.currentTexture = texture;
}
// 颜色分解
const r = ((color >> 16) & 0xFF) / 255;
const g = ((color >> 8) & 0xFF) / 255;
const b = (color & 0xFF) / 255;
const a = alpha;
// 计算顶点
const x2 = x + width;
const y2 = y + height;
// 填充顶点数据
const offset = this.quadCount * VERTICES_PER_QUAD * VERTEX_SIZE;
// 左上
this.vertexData[offset + 0] = x;
this.vertexData[offset + 1] = y;
this.vertexData[offset + 2] = u0;
this.vertexData[offset + 3] = v0;
this.vertexData[offset + 4] = r;
this.vertexData[offset + 5] = g;
this.vertexData[offset + 6] = b;
this.vertexData[offset + 7] = a;
// 右上
this.vertexData[offset + 8] = x2;
this.vertexData[offset + 9] = y;
this.vertexData[offset + 10] = u1;
this.vertexData[offset + 11] = v0;
this.vertexData[offset + 12] = r;
this.vertexData[offset + 13] = g;
this.vertexData[offset + 14] = b;
this.vertexData[offset + 15] = a;
// 右下
this.vertexData[offset + 16] = x2;
this.vertexData[offset + 17] = y2;
this.vertexData[offset + 18] = u1;
this.vertexData[offset + 19] = v1;
this.vertexData[offset + 20] = r;
this.vertexData[offset + 21] = g;
this.vertexData[offset + 22] = b;
this.vertexData[offset + 23] = a;
// 左下
this.vertexData[offset + 24] = x;
this.vertexData[offset + 25] = y2;
this.vertexData[offset + 26] = u0;
this.vertexData[offset + 27] = v1;
this.vertexData[offset + 28] = r;
this.vertexData[offset + 29] = g;
this.vertexData[offset + 30] = b;
this.vertexData[offset + 31] = a;
this.quadCount++;
}
/**
* 创建正交投影矩阵
* Create orthographic projection matrix
*/
private createOrthographicMatrix(
left: number,
right: number,
bottom: number,
top: number,
near: number,
far: number
): Float32Array {
const matrix = new Float32Array(16);
const lr = 1 / (left - right);
const bt = 1 / (bottom - top);
const nf = 1 / (near - far);
matrix[0] = -2 * lr;
matrix[1] = 0;
matrix[2] = 0;
matrix[3] = 0;
matrix[4] = 0;
matrix[5] = -2 * bt;
matrix[6] = 0;
matrix[7] = 0;
matrix[8] = 0;
matrix[9] = 0;
matrix[10] = 2 * nf;
matrix[11] = 0;
matrix[12] = (left + right) * lr;
matrix[13] = (top + bottom) * bt;
matrix[14] = (far + near) * nf;
matrix[15] = 1;
return matrix;
}
/**
* 销毁渲染器
* Dispose renderer
*/
public dispose(): void {
const gl = this.gl;
if (this.program) {
gl.deleteProgram(this.program);
this.program = null;
}
if (this.vertexBuffer) {
gl.deleteBuffer(this.vertexBuffer);
this.vertexBuffer = null;
}
if (this.indexBuffer) {
gl.deleteBuffer(this.indexBuffer);
this.indexBuffer = null;
}
if (this.whiteTexture) {
gl.deleteTexture(this.whiteTexture);
this.whiteTexture = null;
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './WebGLUIRenderer';
export * from './TextRenderer';

View File

@@ -0,0 +1,282 @@
import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-framework';
import { UIProgressBarComponent } from '../components/widgets/UIProgressBarComponent';
import { UISliderComponent } from '../components/widgets/UISliderComponent';
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
/**
* 缓动函数类型
* Easing function type
*/
export type EasingFunction = (t: number) => number;
/**
* 预定义缓动函数
* Predefined easing functions
*/
export const Easing = {
linear: (t: number) => t,
// Quad
easeInQuad: (t: number) => t * t,
easeOutQuad: (t: number) => t * (2 - t),
easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
// Cubic
easeInCubic: (t: number) => t * t * t,
easeOutCubic: (t: number) => (--t) * t * t + 1,
easeInOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
// Quart
easeInQuart: (t: number) => t * t * t * t,
easeOutQuart: (t: number) => 1 - (--t) * t * t * t,
easeInOutQuart: (t: number) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t,
// Quint
easeInQuint: (t: number) => t * t * t * t * t,
easeOutQuint: (t: number) => 1 + (--t) * t * t * t * t,
easeInOutQuint: (t: number) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t,
// Sine
easeInSine: (t: number) => 1 - Math.cos(t * Math.PI / 2),
easeOutSine: (t: number) => Math.sin(t * Math.PI / 2),
easeInOutSine: (t: number) => -(Math.cos(Math.PI * t) - 1) / 2,
// Expo
easeInExpo: (t: number) => t === 0 ? 0 : Math.pow(2, 10 * (t - 1)),
easeOutExpo: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
easeInOutExpo: (t: number) => {
if (t === 0) return 0;
if (t === 1) return 1;
if (t < 0.5) return Math.pow(2, 20 * t - 10) / 2;
return (2 - Math.pow(2, -20 * t + 10)) / 2;
},
// Circ
easeInCirc: (t: number) => 1 - Math.sqrt(1 - t * t),
easeOutCirc: (t: number) => Math.sqrt(1 - (--t) * t),
easeInOutCirc: (t: number) => t < 0.5
? (1 - Math.sqrt(1 - 4 * t * t)) / 2
: (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2,
// Back
easeInBack: (t: number) => {
const c1 = 1.70158;
const c3 = c1 + 1;
return c3 * t * t * t - c1 * t * t;
},
easeOutBack: (t: number) => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeInOutBack: (t: number) => {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
// Elastic
easeInElastic: (t: number) => {
if (t === 0) return 0;
if (t === 1) return 1;
return -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3));
},
easeOutElastic: (t: number) => {
if (t === 0) return 0;
if (t === 1) return 1;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1;
},
easeInOutElastic: (t: number) => {
if (t === 0) return 0;
if (t === 1) return 1;
const c5 = (2 * Math.PI) / 4.5;
return t < 0.5
? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
: (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1;
},
// Bounce
easeInBounce: (t: number) => 1 - Easing.easeOutBounce(1 - t),
easeOutBounce: (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
},
easeInOutBounce: (t: number) => t < 0.5
? (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
: (1 + Easing.easeOutBounce(2 * t - 1)) / 2,
// 简化别名
easeIn: (t: number) => t * t,
easeOut: (t: number) => t * (2 - t),
easeInOut: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
};
/**
* 缓动函数名称映射
* Easing function name mapping
*/
export type EasingName = keyof typeof Easing;
/**
* UI 动画系统
* UI Animation System - Handles value interpolation and animations
*/
@ECSSystem('UIAnimation')
export class UIAnimationSystem extends EntitySystem {
constructor() {
// 匹配任何可能有动画的组件
super(Matcher.empty());
}
/**
* 获取缓动函数
* Get easing function by name
*/
public getEasingFunction(name: string): EasingFunction {
return (Easing as Record<string, EasingFunction>)[name] ?? Easing.linear;
}
protected process(entities: readonly Entity[]): void {
const dt = Time.deltaTime;
for (const entity of entities) {
// 处理进度条动画
this.updateProgressBar(entity, dt);
// 处理滑块动画
this.updateSlider(entity, dt);
// 处理按钮颜色动画
this.updateButtonColor(entity, dt);
}
}
/**
* 更新进度条动画
* Update progress bar animation
*/
private updateProgressBar(entity: Entity, dt: number): void {
const progress = entity.getComponent(UIProgressBarComponent);
if (!progress) return;
// 如果目标值和显示值不同,进行插值
if (progress.displayValue !== progress.targetValue) {
const easingFn = this.getEasingFunction(progress.easing);
const range = progress.maxValue - progress.minValue;
const speed = range / progress.transitionDuration;
const diff = progress.targetValue - progress.displayValue;
const direction = Math.sign(diff);
const step = Math.min(Math.abs(diff), speed * dt);
progress.displayValue += direction * step;
// 接近目标时直接设置
if (Math.abs(progress.displayValue - progress.targetValue) < 0.01) {
progress.displayValue = progress.targetValue;
}
progress.value = progress.displayValue;
}
}
/**
* 更新滑块动画
* Update slider animation
*/
private updateSlider(entity: Entity, dt: number): void {
const slider = entity.getComponent(UISliderComponent);
if (!slider) return;
// 如果正在拖拽,直接设置(不做动画)
if (slider.dragging) {
slider.displayValue = slider.targetValue;
slider.value = slider.targetValue;
return;
}
// 平滑插值
if (slider.displayValue !== slider.targetValue) {
const range = slider.maxValue - slider.minValue;
const speed = range / slider.transitionDuration;
const diff = slider.targetValue - slider.displayValue;
const direction = Math.sign(diff);
const step = Math.min(Math.abs(diff), speed * dt);
slider.displayValue += direction * step;
if (Math.abs(slider.displayValue - slider.targetValue) < 0.01) {
slider.displayValue = slider.targetValue;
}
slider.value = slider.displayValue;
}
}
/**
* 更新按钮颜色动画
* Update button color animation
*/
private updateButtonColor(entity: Entity, dt: number): void {
const button = entity.getComponent(UIButtonComponent);
if (!button) return;
if (button.currentColor !== button.targetColor) {
// 颜色插值
button.currentColor = this.lerpColor(
button.currentColor,
button.targetColor,
Math.min(1, dt / button.transitionDuration)
);
}
}
/**
* 颜色线性插值
* Linear interpolate between two colors
*/
private lerpColor(from: number, to: number, t: number): number {
const fromR = (from >> 16) & 0xFF;
const fromG = (from >> 8) & 0xFF;
const fromB = from & 0xFF;
const toR = (to >> 16) & 0xFF;
const toG = (to >> 8) & 0xFF;
const toB = to & 0xFF;
const r = Math.round(fromR + (toR - fromR) * t);
const g = Math.round(fromG + (toG - fromG) * t);
const b = Math.round(fromB + (toB - fromB) * t);
return (r << 16) | (g << 8) | b;
}
/**
* 数值线性插值
* Linear interpolate between two values
*/
public lerp(from: number, to: number, t: number): number {
return from + (to - from) * t;
}
/**
* 应用缓动的插值
* Interpolate with easing
*/
public ease(from: number, to: number, t: number, easing: EasingName = 'linear'): number {
const easingFn = this.getEasingFunction(easing);
return this.lerp(from, to, easingFn(t));
}
}

View File

@@ -0,0 +1,435 @@
import { EntitySystem, Matcher, Entity, Time, ECSSystem } from '@esengine/ecs-framework';
import { UITransformComponent } from '../components/UITransformComponent';
import { UIInteractableComponent } from '../components/UIInteractableComponent';
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
import { UISliderComponent } from '../components/widgets/UISliderComponent';
/**
* 鼠标按钮
* Mouse buttons
*/
export enum MouseButton {
Left = 0,
Middle = 1,
Right = 2
}
/**
* 输入事件数据
* Input event data
*/
export interface UIInputEvent {
x: number;
y: number;
button: MouseButton;
deltaX?: number;
deltaY?: number;
wheelDelta?: number;
}
/**
* UI 输入系统
* UI Input System - Handles mouse/touch input for UI elements
*/
@ECSSystem('UIInput')
export class UIInputSystem extends EntitySystem {
// ===== 鼠标状态 Mouse State =====
private mouseX: number = 0;
private mouseY: number = 0;
private prevMouseX: number = 0;
private prevMouseY: number = 0;
private mouseButtons: boolean[] = [false, false, false];
private prevMouseButtons: boolean[] = [false, false, false];
// ===== 拖拽状态 Drag State =====
private dragStartX: number = 0;
private dragStartY: number = 0;
private dragTarget: Entity | null = null;
// ===== 焦点状态 Focus State =====
private focusedEntity: Entity | null = null;
// ===== 双击检测 Double Click Detection =====
private lastClickTime: number = 0;
private lastClickEntity: Entity | null = null;
private doubleClickThreshold: number = 300; // ms
// ===== 事件监听器 Event Listeners =====
private canvas: HTMLCanvasElement | null = null;
private boundMouseMove: (e: MouseEvent) => void;
private boundMouseDown: (e: MouseEvent) => void;
private boundMouseUp: (e: MouseEvent) => void;
private boundWheel: (e: WheelEvent) => void;
constructor() {
super(Matcher.empty().all(UITransformComponent, UIInteractableComponent));
this.boundMouseMove = this.onMouseMove.bind(this);
this.boundMouseDown = this.onMouseDown.bind(this);
this.boundMouseUp = this.onMouseUp.bind(this);
this.boundWheel = this.onWheel.bind(this);
}
/**
* 绑定到 Canvas 元素
* Bind to canvas element
*/
public bindToCanvas(canvas: HTMLCanvasElement): void {
this.unbind();
this.canvas = canvas;
canvas.addEventListener('mousemove', this.boundMouseMove);
canvas.addEventListener('mousedown', this.boundMouseDown);
canvas.addEventListener('mouseup', this.boundMouseUp);
canvas.addEventListener('wheel', this.boundWheel);
// 阻止右键菜单
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
/**
* 解绑事件
* Unbind events
*/
public unbind(): void {
if (this.canvas) {
this.canvas.removeEventListener('mousemove', this.boundMouseMove);
this.canvas.removeEventListener('mousedown', this.boundMouseDown);
this.canvas.removeEventListener('mouseup', this.boundMouseUp);
this.canvas.removeEventListener('wheel', this.boundWheel);
this.canvas = null;
}
}
/**
* 手动设置鼠标位置(用于非 DOM 环境)
* Manually set mouse position (for non-DOM environments)
*/
public setMousePosition(x: number, y: number): void {
this.prevMouseX = this.mouseX;
this.prevMouseY = this.mouseY;
this.mouseX = x;
this.mouseY = y;
}
/**
* 手动设置鼠标按钮状态
* Manually set mouse button state
*/
public setMouseButton(button: MouseButton, pressed: boolean): void {
this.prevMouseButtons[button] = this.mouseButtons[button]!;
this.mouseButtons[button] = pressed;
}
private onMouseMove(e: MouseEvent): void {
const rect = this.canvas!.getBoundingClientRect();
this.setMousePosition(e.clientX - rect.left, e.clientY - rect.top);
}
private onMouseDown(e: MouseEvent): void {
this.setMouseButton(e.button as MouseButton, true);
}
private onMouseUp(e: MouseEvent): void {
this.setMouseButton(e.button as MouseButton, false);
}
private onWheel(_e: WheelEvent): void {
// TODO: 处理滚轮事件
}
protected process(entities: readonly Entity[]): void {
const dt = Time.deltaTime;
// 按 zIndex 从高到低排序,确保上层元素优先处理
const sorted = [...entities].sort((a, b) => {
const ta = a.getComponent(UITransformComponent)!;
const tb = b.getComponent(UITransformComponent)!;
return tb.zIndex - ta.zIndex;
});
let consumed = false;
let hoveredEntity: Entity | null = null;
// 处理悬停和点击
for (const entity of sorted) {
const transform = entity.getComponent(UITransformComponent)!;
const interactable = entity.getComponent(UIInteractableComponent)!;
// 跳过不可见或禁用的元素
if (!transform.visible || !interactable.enabled) {
// 如果之前悬停,触发离开
if (interactable.hovered) {
this.handleMouseLeave(entity, interactable);
}
continue;
}
// 更新悬停计时器
if (interactable.hovered && interactable.hoverDelay > 0) {
interactable.hoverTimer += dt * 1000;
if (interactable.hoverTimer >= interactable.hoverDelay && !interactable.hoverReady) {
interactable.hoverReady = true;
}
}
// 命中测试
const hit = !consumed && transform.containsPoint(this.mouseX, this.mouseY);
if (hit) {
hoveredEntity = entity;
// 处理鼠标进入
if (!interactable.hovered) {
this.handleMouseEnter(entity, interactable);
}
interactable.hovered = true;
// 处理按下状态
const wasPressed = interactable.pressed;
interactable.pressed = this.mouseButtons[MouseButton.Left]!;
// 处理按下事件
if (!wasPressed && interactable.pressed) {
this.handlePressDown(entity, interactable);
}
// 处理释放事件(点击)
if (wasPressed && !interactable.pressed) {
this.handlePressUp(entity, interactable);
this.handleClick(entity, interactable);
}
// 处理拖拽
if (interactable.draggable) {
this.handleDrag(entity, interactable);
}
// 处理特殊控件
this.handleSlider(entity);
this.handleButton(entity, interactable);
// 阻止事件传递到下层
if (interactable.blockEvents) {
consumed = true;
}
} else {
// 鼠标不在元素上
if (interactable.hovered) {
this.handleMouseLeave(entity, interactable);
}
interactable.hovered = false;
// 如果按下状态但鼠标移开,保持按下直到释放
if (interactable.pressed && !this.mouseButtons[MouseButton.Left]) {
interactable.pressed = false;
}
}
}
// 更新光标
this.updateCursor(hoveredEntity);
// 保存上一帧状态
this.prevMouseButtons = [...this.mouseButtons];
}
private handleMouseEnter(entity: Entity, interactable: UIInteractableComponent): void {
interactable.hoverTimer = 0;
interactable.hoverReady = false;
interactable.onMouseEnter?.();
}
private handleMouseLeave(_entity: Entity, interactable: UIInteractableComponent): void {
interactable.hovered = false;
interactable.hoverTimer = 0;
interactable.hoverReady = false;
interactable.onMouseLeave?.();
}
private handlePressDown(entity: Entity, interactable: UIInteractableComponent): void {
interactable.onPressDown?.();
// 设置焦点
if (interactable.focusable) {
this.setFocus(entity);
}
// 开始拖拽
if (interactable.draggable) {
this.dragTarget = entity;
this.dragStartX = this.mouseX;
this.dragStartY = this.mouseY;
interactable.dragging = true;
interactable.onDragStart?.(this.mouseX, this.mouseY);
}
}
private handlePressUp(_entity: Entity, interactable: UIInteractableComponent): void {
interactable.onPressUp?.();
// 结束拖拽
if (interactable.dragging) {
interactable.dragging = false;
interactable.onDragEnd?.(this.mouseX, this.mouseY);
this.dragTarget = null;
}
}
private handleClick(entity: Entity, interactable: UIInteractableComponent): void {
// 检测双击
const now = Date.now();
if (this.lastClickEntity === entity && now - this.lastClickTime < this.doubleClickThreshold) {
interactable.onDoubleClick?.();
this.lastClickEntity = null;
this.lastClickTime = 0;
} else {
interactable.onClick?.();
this.lastClickEntity = entity;
this.lastClickTime = now;
}
}
private handleDrag(entity: Entity, interactable: UIInteractableComponent): void {
if (interactable.dragging && this.dragTarget === entity) {
const deltaX = this.mouseX - this.prevMouseX;
const deltaY = this.mouseY - this.prevMouseY;
if (deltaX !== 0 || deltaY !== 0) {
interactable.onDragMove?.(this.mouseX, this.mouseY, deltaX, deltaY);
}
}
}
private handleSlider(entity: Entity): void {
const slider = entity.getComponent(UISliderComponent);
if (!slider) return;
const transform = entity.getComponent(UITransformComponent)!;
// 更新手柄悬停状态
// TODO: 更精确的手柄命中测试
// 处理拖拽
if (this.mouseButtons[MouseButton.Left] && transform.containsPoint(this.mouseX, this.mouseY)) {
if (!slider.dragging) {
slider.dragging = true;
slider.dragStartValue = slider.value;
slider.dragStartPosition = this.mouseX;
slider.onDragStart?.(slider.value);
}
// 计算新值
const relativeX = this.mouseX - transform.worldX;
const progress = Math.max(0, Math.min(1, relativeX / transform.computedWidth));
const newValue = slider.minValue + progress * (slider.maxValue - slider.minValue);
if (newValue !== slider.targetValue) {
slider.setValue(newValue);
slider.onChange?.(slider.targetValue);
}
} else if (slider.dragging && !this.mouseButtons[MouseButton.Left]) {
slider.dragging = false;
slider.onDragEnd?.(slider.value);
}
}
private handleButton(entity: Entity, interactable: UIInteractableComponent): void {
const button = entity.getComponent(UIButtonComponent);
if (!button || button.disabled) return;
// 更新目标颜色
button.targetColor = button.getStateColor(interactable.getState());
// 处理长按
if (interactable.pressed) {
button.pressTimer += Time.deltaTime * 1000;
if (button.pressTimer >= button.longPressThreshold && !button.longPressTriggered) {
button.longPressTriggered = true;
button.onLongPress?.();
}
} else {
button.pressTimer = 0;
button.longPressTriggered = false;
}
// 处理点击
if (interactable.getState() === 'normal' && this.prevMouseButtons[MouseButton.Left] && !this.mouseButtons[MouseButton.Left]) {
// 点击在 handleClick 中处理
}
}
private updateCursor(hoveredEntity: Entity | null): void {
if (!this.canvas) return;
if (hoveredEntity) {
const interactable = hoveredEntity.getComponent(UIInteractableComponent);
if (interactable) {
this.canvas.style.cursor = interactable.cursor;
return;
}
}
this.canvas.style.cursor = 'default';
}
/**
* 设置焦点到指定元素
* Set focus to specified element
*/
public setFocus(entity: Entity | null): void {
// 移除旧焦点
if (this.focusedEntity && this.focusedEntity !== entity) {
const oldInteractable = this.focusedEntity.getComponent(UIInteractableComponent);
if (oldInteractable) {
oldInteractable.focused = false;
oldInteractable.onBlur?.();
}
}
this.focusedEntity = entity;
// 设置新焦点
if (entity) {
const interactable = entity.getComponent(UIInteractableComponent);
if (interactable && interactable.focusable) {
interactable.focused = true;
interactable.onFocus?.();
}
}
}
/**
* 获取当前焦点元素
* Get currently focused element
*/
public getFocusedEntity(): Entity | null {
return this.focusedEntity;
}
/**
* 获取鼠标位置
* Get mouse position
*/
public getMousePosition(): { x: number; y: number } {
return { x: this.mouseX, y: this.mouseY };
}
/**
* 检查鼠标按钮是否按下
* Check if mouse button is pressed
*/
public isMouseButtonPressed(button: MouseButton): boolean {
return this.mouseButtons[button] ?? false;
}
protected onDestroy(): void {
this.unbind();
}
}

View File

@@ -0,0 +1,444 @@
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
import { UITransformComponent } from '../components/UITransformComponent';
import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from '../components/UILayoutComponent';
/**
* UI 布局系统
* UI Layout System - Computes layout for UI elements
*
* 计算 UI 元素的世界坐标和尺寸
* Computes world coordinates and sizes for UI elements
*/
@ECSSystem('UILayout')
export class UILayoutSystem extends EntitySystem {
/**
* 视口宽度
* Viewport width
*/
public viewportWidth: number = 1920;
/**
* 视口高度
* Viewport height
*/
public viewportHeight: number = 1080;
constructor() {
super(Matcher.empty().all(UITransformComponent));
}
/**
* 设置视口尺寸
* Set viewport size
*/
public setViewport(width: number, height: number): void {
this.viewportWidth = width;
this.viewportHeight = height;
// 标记所有元素需要重新布局
for (const entity of this.entities) {
const transform = entity.getComponent(UITransformComponent);
if (transform) {
transform.layoutDirty = true;
}
}
}
protected process(entities: readonly Entity[]): void {
// 首先处理根元素(没有父元素的)
const rootEntities = entities.filter(e => !e.parent || !e.parent.hasComponent(UITransformComponent));
for (const entity of rootEntities) {
this.layoutEntity(entity, 0, 0, this.viewportWidth, this.viewportHeight, 1);
}
}
/**
* 递归布局实体及其子元素
* Recursively layout entity and its children
*/
private layoutEntity(
entity: Entity,
parentX: number,
parentY: number,
parentWidth: number,
parentHeight: number,
parentAlpha: number
): void {
const transform = entity.getComponent(UITransformComponent);
if (!transform) return;
// 计算锚点位置
const anchorMinX = parentX + parentWidth * transform.anchorMinX;
const anchorMinY = parentY + parentHeight * transform.anchorMinY;
const anchorMaxX = parentX + parentWidth * transform.anchorMaxX;
const anchorMaxY = parentY + parentHeight * transform.anchorMaxY;
// 计算元素尺寸
let width: number;
let height: number;
// 如果锚点 min 和 max 相同,使用固定尺寸
if (transform.anchorMinX === transform.anchorMaxX) {
width = transform.width;
} else {
// 拉伸模式:尺寸由锚点决定
width = anchorMaxX - anchorMinX - transform.x;
}
if (transform.anchorMinY === transform.anchorMaxY) {
height = transform.height;
} else {
height = anchorMaxY - anchorMinY - transform.y;
}
// 应用尺寸约束
if (transform.minWidth > 0) width = Math.max(width, transform.minWidth);
if (transform.maxWidth > 0) width = Math.min(width, transform.maxWidth);
if (transform.minHeight > 0) height = Math.max(height, transform.minHeight);
if (transform.maxHeight > 0) height = Math.min(height, transform.maxHeight);
// 计算世界位置
let worldX: number;
let worldY: number;
if (transform.anchorMinX === transform.anchorMaxX) {
// 固定锚点模式
worldX = anchorMinX + transform.x - width * transform.pivotX;
} else {
// 拉伸模式
worldX = anchorMinX + transform.x;
}
if (transform.anchorMinY === transform.anchorMaxY) {
worldY = anchorMinY + transform.y - height * transform.pivotY;
} else {
worldY = anchorMinY + transform.y;
}
// 更新计算后的值
transform.worldX = worldX;
transform.worldY = worldY;
transform.computedWidth = width;
transform.computedHeight = height;
transform.worldAlpha = parentAlpha * transform.alpha;
transform.layoutDirty = false;
// 如果元素不可见,跳过子元素
if (!transform.visible) return;
// 处理子元素布局
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
if (children.length === 0) return;
// 检查是否有布局组件
const layout = entity.getComponent(UILayoutComponent);
if (layout && layout.type !== UILayoutType.None) {
this.layoutChildren(layout, transform, children);
} else {
// 无布局组件,直接递归处理子元素
for (const child of children) {
this.layoutEntity(
child,
worldX,
worldY,
width,
height,
transform.worldAlpha
);
}
}
}
/**
* 根据布局组件布局子元素
* Layout children according to layout component
*/
private layoutChildren(
layout: UILayoutComponent,
parentTransform: UITransformComponent,
children: Entity[]
): void {
const contentStartX = parentTransform.worldX + layout.paddingLeft;
const contentStartY = parentTransform.worldY + layout.paddingTop;
const contentWidth = parentTransform.computedWidth - layout.getHorizontalPadding();
const contentHeight = parentTransform.computedHeight - layout.getVerticalPadding();
switch (layout.type) {
case UILayoutType.Horizontal:
this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
break;
case UILayoutType.Vertical:
this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
break;
case UILayoutType.Grid:
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
break;
default:
// 默认按正常方式递归
for (const child of children) {
this.layoutEntity(
child,
parentTransform.worldX,
parentTransform.worldY,
parentTransform.computedWidth,
parentTransform.computedHeight,
parentTransform.worldAlpha
);
}
}
}
/**
* 水平布局
* Horizontal layout
*/
private layoutHorizontal(
layout: UILayoutComponent,
parentTransform: UITransformComponent,
children: Entity[],
startX: number,
startY: number,
contentWidth: number,
contentHeight: number
): void {
// 计算总子元素宽度
const childSizes = children.map(child => {
const t = child.getComponent(UITransformComponent)!;
return { entity: child, width: t.width, height: t.height };
});
const totalChildWidth = childSizes.reduce((sum, c) => sum + c.width, 0);
const totalGap = layout.gap * (children.length - 1);
const totalWidth = totalChildWidth + totalGap;
// 计算起始位置(基于 justifyContent
let offsetX = startX;
let gap = layout.gap;
switch (layout.justifyContent) {
case UIJustifyContent.Center:
offsetX = startX + (contentWidth - totalWidth) / 2;
break;
case UIJustifyContent.End:
offsetX = startX + contentWidth - totalWidth;
break;
case UIJustifyContent.SpaceBetween:
if (children.length > 1) {
gap = (contentWidth - totalChildWidth) / (children.length - 1);
}
break;
case UIJustifyContent.SpaceAround:
if (children.length > 0) {
const space = (contentWidth - totalChildWidth) / children.length;
gap = space;
offsetX = startX + space / 2;
}
break;
case UIJustifyContent.SpaceEvenly:
if (children.length > 0) {
const space = (contentWidth - totalChildWidth) / (children.length + 1);
gap = space;
offsetX = startX + space;
}
break;
}
// 布局每个子元素
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;
let childHeight = size.height;
switch (layout.alignItems) {
case UIAlignItems.Center:
childY = startY + (contentHeight - childHeight) / 2;
break;
case UIAlignItems.End:
childY = startY + contentHeight - childHeight;
break;
case UIAlignItems.Stretch:
childHeight = contentHeight;
break;
}
// 直接设置子元素的世界坐标
childTransform.worldX = offsetX;
childTransform.worldY = childY;
childTransform.computedWidth = size.width;
childTransform.computedHeight = childHeight;
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
childTransform.layoutDirty = false;
// 递归处理子元素的子元素
this.processChildrenRecursive(child, childTransform);
offsetX += size.width + gap;
}
}
/**
* 垂直布局
* Vertical layout
*/
private layoutVertical(
layout: UILayoutComponent,
parentTransform: UITransformComponent,
children: Entity[],
startX: number,
startY: number,
contentWidth: number,
contentHeight: number
): void {
// 计算总子元素高度
const childSizes = children.map(child => {
const t = child.getComponent(UITransformComponent)!;
return { entity: child, width: t.width, height: t.height };
});
const totalChildHeight = childSizes.reduce((sum, c) => sum + c.height, 0);
const totalGap = layout.gap * (children.length - 1);
const totalHeight = totalChildHeight + totalGap;
// 计算起始位置
let offsetY = startY;
let gap = layout.gap;
switch (layout.justifyContent) {
case UIJustifyContent.Center:
offsetY = startY + (contentHeight - totalHeight) / 2;
break;
case UIJustifyContent.End:
offsetY = startY + contentHeight - totalHeight;
break;
case UIJustifyContent.SpaceBetween:
if (children.length > 1) {
gap = (contentHeight - totalChildHeight) / (children.length - 1);
}
break;
case UIJustifyContent.SpaceAround:
if (children.length > 0) {
const space = (contentHeight - totalChildHeight) / children.length;
gap = space;
offsetY = startY + space / 2;
}
break;
case UIJustifyContent.SpaceEvenly:
if (children.length > 0) {
const space = (contentHeight - totalChildHeight) / (children.length + 1);
gap = space;
offsetY = startY + space;
}
break;
}
// 布局每个子元素
for (let i = 0; i < children.length; i++) {
const child = children[i]!;
const childTransform = child.getComponent(UITransformComponent)!;
const size = childSizes[i]!;
// 计算 X 位置
let childX = startX;
let childWidth = size.width;
switch (layout.alignItems) {
case UIAlignItems.Center:
childX = startX + (contentWidth - childWidth) / 2;
break;
case UIAlignItems.End:
childX = startX + contentWidth - childWidth;
break;
case UIAlignItems.Stretch:
childWidth = contentWidth;
break;
}
childTransform.worldX = childX;
childTransform.worldY = offsetY;
childTransform.computedWidth = childWidth;
childTransform.computedHeight = size.height;
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
childTransform.layoutDirty = false;
this.processChildrenRecursive(child, childTransform);
offsetY += size.height + gap;
}
}
/**
* 网格布局
* Grid layout
*/
private layoutGrid(
layout: UILayoutComponent,
parentTransform: UITransformComponent,
children: Entity[],
startX: number,
startY: number,
contentWidth: number,
_contentHeight: number
): void {
const columns = layout.columns;
const gapX = layout.getHorizontalGap();
const gapY = layout.getVerticalGap();
// 计算单元格尺寸
const cellWidth = layout.cellWidth > 0
? layout.cellWidth
: (contentWidth - gapX * (columns - 1)) / columns;
const cellHeight = layout.cellHeight > 0
? layout.cellHeight
: cellWidth; // 默认正方形
for (let i = 0; i < children.length; i++) {
const child = children[i]!;
const childTransform = child.getComponent(UITransformComponent)!;
const col = i % columns;
const row = Math.floor(i / columns);
const x = startX + col * (cellWidth + gapX);
const y = startY + row * (cellHeight + gapY);
childTransform.worldX = x;
childTransform.worldY = y;
childTransform.computedWidth = cellWidth;
childTransform.computedHeight = cellHeight;
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
childTransform.layoutDirty = false;
this.processChildrenRecursive(child, childTransform);
}
}
/**
* 递归处理子元素
* Recursively process children
*/
private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent): void {
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
if (children.length === 0) return;
const layout = entity.getComponent(UILayoutComponent);
if (layout && layout.type !== UILayoutType.None) {
this.layoutChildren(layout, parentTransform, children);
} else {
for (const child of children) {
this.layoutEntity(
child,
parentTransform.worldX,
parentTransform.worldY,
parentTransform.computedWidth,
parentTransform.computedHeight,
parentTransform.worldAlpha
);
}
}
}
}

View File

@@ -0,0 +1,413 @@
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';
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;
};
}
export interface ProviderRenderData {
transforms: Float32Array;
textureIds: Uint32Array;
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
sortingOrder: number;
texturePath?: string;
}
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;
}
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 };
}
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;
}
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
};
}
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();
}
dispose(): void {
this.textCanvas = null;
this.textCtx = null;
this.textTextureCache.clear();
this.onTextureCreated = null;
}
}

View File

@@ -0,0 +1,4 @@
export * from './UILayoutSystem';
export * from './UIInputSystem';
export * from './UIAnimationSystem';
export * from './UIRenderDataProvider';

24
packages/ui/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,33 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
include: ['src'],
outDir: 'dist',
rollupTypes: true
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: () => 'index.js'
},
rollupOptions: {
external: [
'@esengine/ecs-framework',
/^@esengine\//
],
output: {
exports: 'named',
preserveModules: false
}
},
target: 'es2020',
minify: false,
sourcemap: true
}
});