Feature/runtime cdn and plugin loader (#240)
* feat(ui): 完善 UI 布局系统和编辑器可视化工具 * refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统 * fix: 修复 CodeQL 警告并提升测试覆盖率 * refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题 * fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤 * docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明 * fix(ci): 修复 type-check 失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖 * fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖 * fix(ci): platform-web 添加缺失的 behavior-tree 依赖 * fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
@@ -8,32 +8,38 @@ import { UILayoutComponent, UILayoutType, UIJustifyContent, UIAlignItems } from
|
||||
*
|
||||
* 计算 UI 元素的世界坐标和尺寸
|
||||
* Computes world coordinates and sizes for UI elements
|
||||
*
|
||||
* 注意:canvasWidth/canvasHeight 是 UI 设计的参考尺寸,不是实际渲染视口大小
|
||||
* Note: canvasWidth/canvasHeight is the UI design reference size, not the actual render viewport size
|
||||
*/
|
||||
@ECSSystem('UILayout')
|
||||
export class UILayoutSystem extends EntitySystem {
|
||||
/**
|
||||
* 视口宽度
|
||||
* Viewport width
|
||||
* UI 画布宽度(设计尺寸)
|
||||
* UI Canvas width (design size)
|
||||
*/
|
||||
public viewportWidth: number = 1920;
|
||||
public canvasWidth: number = 1920;
|
||||
|
||||
/**
|
||||
* 视口高度
|
||||
* Viewport height
|
||||
* UI 画布高度(设计尺寸)
|
||||
* UI Canvas height (design size)
|
||||
*/
|
||||
public viewportHeight: number = 1080;
|
||||
public canvasHeight: number = 1080;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视口尺寸
|
||||
* Set viewport size
|
||||
* 设置 UI 画布尺寸(设计尺寸)
|
||||
* Set UI canvas size (design size)
|
||||
*
|
||||
* 这是 UI 布局计算的参考尺寸,通常是固定的设计分辨率(如 1920x1080)
|
||||
* This is the reference size for UI layout calculation, usually a fixed design resolution (e.g., 1920x1080)
|
||||
*/
|
||||
public setViewport(width: number, height: number): void {
|
||||
this.viewportWidth = width;
|
||||
this.viewportHeight = height;
|
||||
public setCanvasSize(width: number, height: number): void {
|
||||
this.canvasWidth = width;
|
||||
this.canvasHeight = height;
|
||||
|
||||
// 标记所有元素需要重新布局
|
||||
for (const entity of this.entities) {
|
||||
@@ -44,12 +50,27 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 UI 画布尺寸
|
||||
* Get UI canvas size
|
||||
*/
|
||||
public getCanvasSize(): { width: number; height: number } {
|
||||
return { width: this.canvasWidth, height: this.canvasHeight };
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
// 首先处理根元素(没有父元素的)
|
||||
const rootEntities = entities.filter(e => !e.parent || !e.parent.hasComponent(UITransformComponent));
|
||||
|
||||
// 画布中心为原点,Y 轴向上为正
|
||||
// Canvas center is origin, Y axis points up
|
||||
// 左上角是 (-width/2, +height/2),右下角是 (+width/2, -height/2)
|
||||
// Top-left is (-width/2, +height/2), bottom-right is (+width/2, -height/2)
|
||||
const parentX = -this.canvasWidth / 2;
|
||||
const parentY = this.canvasHeight / 2; // Y 轴向上,所以顶部是正值
|
||||
|
||||
for (const entity of rootEntities) {
|
||||
this.layoutEntity(entity, 0, 0, this.viewportWidth, this.viewportHeight, 1);
|
||||
this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +90,16 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (!transform) return;
|
||||
|
||||
// 计算锚点位置
|
||||
// X 轴:向右为正,anchorMinX=0 是左边,anchorMinX=1 是右边
|
||||
// Y 轴:向上为正,anchorMinY=0 是顶部,anchorMinY=1 是底部
|
||||
// X axis: right is positive, anchorMinX=0 is left, anchorMinX=1 is right
|
||||
// Y axis: up is positive, anchorMinY=0 is top, anchorMinY=1 is bottom
|
||||
const anchorMinX = parentX + parentWidth * transform.anchorMinX;
|
||||
const anchorMinY = parentY + parentHeight * transform.anchorMinY;
|
||||
const anchorMaxX = parentX + parentWidth * transform.anchorMaxX;
|
||||
const anchorMaxY = parentY + parentHeight * transform.anchorMaxY;
|
||||
// Y 轴反转:parentY 是顶部(正值),向下减少
|
||||
// Y axis inverted: parentY is top (positive), decreases downward
|
||||
const anchorMinY = parentY - parentHeight * transform.anchorMinY;
|
||||
const anchorMaxY = parentY - parentHeight * transform.anchorMaxY;
|
||||
|
||||
// 计算元素尺寸
|
||||
let width: number;
|
||||
@@ -89,7 +116,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (transform.anchorMinY === transform.anchorMaxY) {
|
||||
height = transform.height;
|
||||
} else {
|
||||
height = anchorMaxY - anchorMinY - transform.y;
|
||||
// 拉伸模式:Y 轴反转,anchorMinY > anchorMaxY
|
||||
// Stretch mode: Y axis inverted, anchorMinY > anchorMaxY
|
||||
height = anchorMinY - anchorMaxY - transform.y;
|
||||
}
|
||||
|
||||
// 应用尺寸约束
|
||||
@@ -98,12 +127,15 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (transform.minHeight > 0) height = Math.max(height, transform.minHeight);
|
||||
if (transform.maxHeight > 0) height = Math.min(height, transform.maxHeight);
|
||||
|
||||
// 计算世界位置
|
||||
// 计算世界位置(左下角,与 Gizmo origin=(0,0) 对应)
|
||||
// Calculate world position (bottom-left corner, matching Gizmo origin=(0,0))
|
||||
let worldX: number;
|
||||
let worldY: number;
|
||||
|
||||
if (transform.anchorMinX === transform.anchorMaxX) {
|
||||
// 固定锚点模式
|
||||
// anchor 位置 + position 偏移 - pivot 偏移
|
||||
// 结果是矩形左边缘的 X 坐标
|
||||
worldX = anchorMinX + transform.x - width * transform.pivotX;
|
||||
} else {
|
||||
// 拉伸模式
|
||||
@@ -111,9 +143,21 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
|
||||
if (transform.anchorMinY === transform.anchorMaxY) {
|
||||
worldY = anchorMinY + transform.y - height * transform.pivotY;
|
||||
// 固定锚点模式:Y 轴向上
|
||||
// Fixed anchor mode: Y axis up
|
||||
// anchorMinY 是锚点 Y 位置(anchor=0 在顶部,Y=+540)
|
||||
// position.y 是从锚点的偏移(正值向上)
|
||||
// pivot 决定元素哪个点对齐到 (anchor + position)
|
||||
// worldY 是元素底部的 Y 坐标(与 Gizmo origin=(0,0) 对应)
|
||||
// pivotY=0 意味着元素顶部对齐,pivotY=1 意味着元素底部对齐
|
||||
const anchorPosY = anchorMinY + transform.y; // anchor 位置 + 偏移
|
||||
// pivotY=0: 顶部对齐,底部 = anchorPos - height
|
||||
// pivotY=0.5: 中心对齐,底部 = anchorPos - height/2
|
||||
// pivotY=1: 底部对齐,底部 = anchorPos
|
||||
worldY = anchorPosY - height * (1 - transform.pivotY);
|
||||
} else {
|
||||
worldY = anchorMinY + transform.y;
|
||||
// 拉伸模式:worldY 是底部
|
||||
worldY = anchorMaxY - transform.y;
|
||||
}
|
||||
|
||||
// 更新计算后的值
|
||||
@@ -131,6 +175,10 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
|
||||
if (children.length === 0) return;
|
||||
|
||||
// 计算子元素的父容器边界
|
||||
// 子元素的 parentY 应该是当前元素的顶部 Y 坐标(worldY 是底部,顶部 = 底部 + 高度)
|
||||
const childParentY = worldY + height;
|
||||
|
||||
// 检查是否有布局组件
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
@@ -141,7 +189,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
worldX,
|
||||
worldY,
|
||||
childParentY,
|
||||
width,
|
||||
height,
|
||||
transform.worldAlpha
|
||||
@@ -160,7 +208,10 @@ export class UILayoutSystem extends EntitySystem {
|
||||
children: Entity[]
|
||||
): void {
|
||||
const contentStartX = parentTransform.worldX + layout.paddingLeft;
|
||||
const contentStartY = parentTransform.worldY + layout.paddingTop;
|
||||
// Y-up 系统:worldY 是底部,顶部 = worldY + height
|
||||
// contentStartY 是内容区域的顶部 Y(从顶部减去 paddingTop)
|
||||
const parentTopY = parentTransform.worldY + parentTransform.computedHeight;
|
||||
const contentStartY = parentTopY - layout.paddingTop;
|
||||
const contentWidth = parentTransform.computedWidth - layout.getHorizontalPadding();
|
||||
const contentHeight = parentTransform.computedHeight - layout.getVerticalPadding();
|
||||
|
||||
@@ -175,12 +226,12 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
|
||||
break;
|
||||
default:
|
||||
// 默认按正常方式递归
|
||||
// 默认按正常方式递归(传递顶部 Y)
|
||||
for (const child of children) {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
parentTransform.worldX,
|
||||
parentTransform.worldY,
|
||||
parentTopY,
|
||||
parentTransform.computedWidth,
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha
|
||||
@@ -245,30 +296,35 @@ export class UILayoutSystem extends EntitySystem {
|
||||
}
|
||||
|
||||
// 布局每个子元素
|
||||
// startY 是内容区域的顶部 Y(Y-up 系统)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]!;
|
||||
const childTransform = child.getComponent(UITransformComponent)!;
|
||||
const size = childSizes[i]!;
|
||||
|
||||
// 计算 Y 位置(基于 alignItems)
|
||||
let childY = startY;
|
||||
// 计算子元素顶部 Y 位置(基于 alignItems)
|
||||
// startY 是内容区域顶部,向下布局意味着 Y 值减小
|
||||
let childTopY = startY; // 默认从顶部开始
|
||||
let childHeight = size.height;
|
||||
|
||||
switch (layout.alignItems) {
|
||||
case UIAlignItems.Center:
|
||||
childY = startY + (contentHeight - childHeight) / 2;
|
||||
// 在内容区域垂直居中:顶部 Y = startY - (contentHeight - childHeight) / 2
|
||||
childTopY = startY - (contentHeight - childHeight) / 2;
|
||||
break;
|
||||
case UIAlignItems.End:
|
||||
childY = startY + contentHeight - childHeight;
|
||||
// 对齐到底部:顶部 Y = startY - contentHeight + childHeight
|
||||
childTopY = startY - contentHeight + childHeight;
|
||||
break;
|
||||
case UIAlignItems.Stretch:
|
||||
childHeight = contentHeight;
|
||||
break;
|
||||
// UIAlignItems.Start: 默认从顶部开始,不需要修改
|
||||
}
|
||||
|
||||
// 直接设置子元素的世界坐标
|
||||
// 直接设置子元素的世界坐标(worldY 是底部 Y)
|
||||
childTransform.worldX = offsetX;
|
||||
childTransform.worldY = childY;
|
||||
childTransform.worldY = childTopY - childHeight; // 底部 Y = 顶部 Y - 高度
|
||||
childTransform.computedWidth = size.width;
|
||||
childTransform.computedHeight = childHeight;
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
@@ -284,6 +340,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
/**
|
||||
* 垂直布局
|
||||
* Vertical layout
|
||||
* Y-up 系统:startY 是内容区域的顶部,子元素从上往下排列(Y 值递减)
|
||||
*/
|
||||
private layoutVertical(
|
||||
layout: UILayoutComponent,
|
||||
@@ -304,16 +361,19 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const totalGap = layout.gap * (children.length - 1);
|
||||
const totalHeight = totalChildHeight + totalGap;
|
||||
|
||||
// 计算起始位置
|
||||
let offsetY = startY;
|
||||
// 计算第一个子元素的顶部 Y(Y-up 系统,从顶部开始向下)
|
||||
// startY 是内容区域顶部
|
||||
let currentTopY = startY; // 从顶部开始
|
||||
let gap = layout.gap;
|
||||
|
||||
switch (layout.justifyContent) {
|
||||
case UIJustifyContent.Center:
|
||||
offsetY = startY + (contentHeight - totalHeight) / 2;
|
||||
// 垂直居中:第一个元素的顶部 Y = startY - (contentHeight - totalHeight) / 2
|
||||
currentTopY = startY - (contentHeight - totalHeight) / 2;
|
||||
break;
|
||||
case UIJustifyContent.End:
|
||||
offsetY = startY + contentHeight - totalHeight;
|
||||
// 对齐到底部:第一个元素的顶部 Y = startY - contentHeight + totalHeight
|
||||
currentTopY = startY - contentHeight + totalHeight;
|
||||
break;
|
||||
case UIJustifyContent.SpaceBetween:
|
||||
if (children.length > 1) {
|
||||
@@ -324,19 +384,19 @@ export class UILayoutSystem extends EntitySystem {
|
||||
if (children.length > 0) {
|
||||
const space = (contentHeight - totalChildHeight) / children.length;
|
||||
gap = space;
|
||||
offsetY = startY + space / 2;
|
||||
currentTopY = startY - space / 2;
|
||||
}
|
||||
break;
|
||||
case UIJustifyContent.SpaceEvenly:
|
||||
if (children.length > 0) {
|
||||
const space = (contentHeight - totalChildHeight) / (children.length + 1);
|
||||
gap = space;
|
||||
offsetY = startY + space;
|
||||
currentTopY = startY - space;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 布局每个子元素
|
||||
// 布局每个子元素(从上往下)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]!;
|
||||
const childTransform = child.getComponent(UITransformComponent)!;
|
||||
@@ -358,8 +418,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
break;
|
||||
}
|
||||
|
||||
// worldY 是底部 Y = 顶部 Y - 高度
|
||||
childTransform.worldX = childX;
|
||||
childTransform.worldY = offsetY;
|
||||
childTransform.worldY = currentTopY - size.height;
|
||||
childTransform.computedWidth = childWidth;
|
||||
childTransform.computedHeight = size.height;
|
||||
childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
|
||||
@@ -367,13 +428,15 @@ export class UILayoutSystem extends EntitySystem {
|
||||
|
||||
this.processChildrenRecursive(child, childTransform);
|
||||
|
||||
offsetY += size.height + gap;
|
||||
// 移动到下一个元素的顶部位置(向下 = Y 减小)
|
||||
currentTopY -= size.height + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网格布局
|
||||
* Grid layout
|
||||
* Y-up 系统:startY 是内容区域的顶部,网格从上往下、从左往右排列
|
||||
*/
|
||||
private layoutGrid(
|
||||
layout: UILayoutComponent,
|
||||
@@ -404,7 +467,11 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const row = Math.floor(i / columns);
|
||||
|
||||
const x = startX + col * (cellWidth + gapX);
|
||||
const y = startY + row * (cellHeight + gapY);
|
||||
// Y-up 系统:第一行在顶部,行号增加 Y 值减小
|
||||
// 单元格顶部 Y = startY - row * (cellHeight + gapY)
|
||||
// 单元格底部 Y = 顶部 Y - cellHeight
|
||||
const cellTopY = startY - row * (cellHeight + gapY);
|
||||
const y = cellTopY - cellHeight; // worldY 是底部 Y
|
||||
|
||||
childTransform.worldX = x;
|
||||
childTransform.worldY = y;
|
||||
@@ -425,6 +492,9 @@ export class UILayoutSystem extends EntitySystem {
|
||||
const children = entity.children.filter(c => c.hasComponent(UITransformComponent));
|
||||
if (children.length === 0) return;
|
||||
|
||||
// 计算子元素的父容器顶部 Y(worldY 是底部,顶部 = 底部 + 高度)
|
||||
const parentTopY = parentTransform.worldY + parentTransform.computedHeight;
|
||||
|
||||
const layout = entity.getComponent(UILayoutComponent);
|
||||
if (layout && layout.type !== UILayoutType.None) {
|
||||
this.layoutChildren(layout, parentTransform, children);
|
||||
@@ -433,7 +503,7 @@ export class UILayoutSystem extends EntitySystem {
|
||||
this.layoutEntity(
|
||||
child,
|
||||
parentTransform.worldX,
|
||||
parentTransform.worldY,
|
||||
parentTopY,
|
||||
parentTransform.computedWidth,
|
||||
parentTransform.computedHeight,
|
||||
parentTransform.worldAlpha
|
||||
|
||||
@@ -1,413 +1,116 @@
|
||||
import { Core, Entity } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../components/UITransformComponent';
|
||||
import { UIRenderComponent } from '../components/UIRenderComponent';
|
||||
import { UITextComponent } from '../components/UITextComponent';
|
||||
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
|
||||
/**
|
||||
* UI Render Data Provider
|
||||
* UI 渲染数据提供者
|
||||
*
|
||||
* This class serves as a coordinator/facade for the UI render systems.
|
||||
* It provides the IRenderDataProvider interface for EngineRenderSystem.
|
||||
*
|
||||
* 此类作为 UI 渲染系统的协调器/外观。
|
||||
* 它为 EngineRenderSystem 提供 IRenderDataProvider 接口。
|
||||
*
|
||||
* The actual rendering logic is delegated to specialized render systems:
|
||||
* - UIRectRenderSystem: Basic rectangles and images
|
||||
* - UITextRenderSystem: Text rendering
|
||||
* - UIButtonRenderSystem: Button components
|
||||
* - UIProgressBarRenderSystem: Progress bars
|
||||
* - UISliderRenderSystem: Sliders
|
||||
* - UIScrollViewRenderSystem: Scroll views
|
||||
*
|
||||
* 实际的渲染逻辑委托给专门的渲染系统:
|
||||
* - UIRectRenderSystem: 基础矩形和图像
|
||||
* - UITextRenderSystem: 文本渲染
|
||||
* - UIButtonRenderSystem: 按钮组件
|
||||
* - UIProgressBarRenderSystem: 进度条
|
||||
* - UISliderRenderSystem: 滑块
|
||||
* - UIScrollViewRenderSystem: 滚动视图
|
||||
*
|
||||
* Render mode is controlled by EngineRenderSystem.previewMode:
|
||||
* - Editor mode (previewMode=false): UI renders in world space with sprites
|
||||
* - Preview mode (previewMode=true): UI renders as screen overlay
|
||||
*
|
||||
* 渲染模式由 EngineRenderSystem.previewMode 控制:
|
||||
* - 编辑器模式 (previewMode=false): UI 与精灵一起在世界空间渲染
|
||||
* - 预览模式 (previewMode=true): UI 作为屏幕叠加层渲染
|
||||
*/
|
||||
|
||||
export interface UIRenderData {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
backgroundColor: number;
|
||||
backgroundAlpha: number;
|
||||
borderColor: number;
|
||||
borderWidth: number;
|
||||
cornerRadius: number;
|
||||
zIndex: number;
|
||||
visible: boolean;
|
||||
text?: {
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
};
|
||||
}
|
||||
import { getUIRenderCollector, type ProviderRenderData } from './render/UIRenderCollector';
|
||||
|
||||
export interface ProviderRenderData {
|
||||
transforms: Float32Array;
|
||||
textureIds: Uint32Array;
|
||||
uvs: Float32Array;
|
||||
colors: Uint32Array;
|
||||
tileCount: number;
|
||||
sortingOrder: number;
|
||||
texturePath?: string;
|
||||
}
|
||||
// Re-export ProviderRenderData for convenience
|
||||
// 为方便起见重新导出 ProviderRenderData
|
||||
export { type ProviderRenderData } from './render/UIRenderCollector';
|
||||
|
||||
/**
|
||||
* Interface for render data providers
|
||||
* 渲染数据提供者接口
|
||||
*/
|
||||
export interface IRenderDataProvider {
|
||||
getRenderData(): readonly ProviderRenderData[];
|
||||
}
|
||||
|
||||
interface TextTextureCache {
|
||||
textureId: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string | number;
|
||||
italic: boolean;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
lineHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataUrl: string;
|
||||
/**
|
||||
* Interface for UI render data providers
|
||||
* UI 渲染数据提供者接口
|
||||
*/
|
||||
export interface IUIRenderDataProvider extends IRenderDataProvider {
|
||||
/** Check if there is content to render | 检查是否有内容需要渲染 */
|
||||
hasContent(): boolean;
|
||||
}
|
||||
|
||||
export class UIRenderDataProvider implements IRenderDataProvider {
|
||||
private textCanvas: HTMLCanvasElement | null = null;
|
||||
private textCtx: CanvasRenderingContext2D | null = null;
|
||||
private textTextureCache: Map<number, TextTextureCache> = new Map();
|
||||
private nextTextureId = 90000;
|
||||
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
|
||||
|
||||
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
|
||||
this.onTextureCreated = callback;
|
||||
}
|
||||
|
||||
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
||||
if (!this.textCanvas) {
|
||||
this.textCanvas = document.createElement('canvas');
|
||||
this.textCtx = this.textCanvas.getContext('2d');
|
||||
}
|
||||
if (!this.textCtx) return null;
|
||||
return { canvas: this.textCanvas, ctx: this.textCtx };
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Render Data Provider
|
||||
* UI 渲染数据提供者
|
||||
*
|
||||
* This is a facade that collects render data from the UIRenderCollector.
|
||||
* The actual rendering is done by the specialized render systems that run
|
||||
* before this provider's getRenderData() is called.
|
||||
*
|
||||
* 这是一个从 UIRenderCollector 收集渲染数据的外观。
|
||||
* 实际渲染由在调用此提供者的 getRenderData() 之前运行的专门渲染系统完成。
|
||||
*
|
||||
* Usage:
|
||||
* 1. Add all UI render systems to the scene (UIRectRenderSystem, UITextRenderSystem, etc.)
|
||||
* 2. Register this provider with EngineRenderSystem
|
||||
* 3. The render systems populate the collector, and this provider returns the data
|
||||
*
|
||||
* 用法:
|
||||
* 1. 将所有 UI 渲染系统添加到场景(UIRectRenderSystem、UITextRenderSystem 等)
|
||||
* 2. 将此提供者注册到 EngineRenderSystem
|
||||
* 3. 渲染系统填充收集器,此提供者返回数据
|
||||
*/
|
||||
export class UIRenderDataProvider implements IUIRenderDataProvider {
|
||||
/**
|
||||
* Get render data from the collector
|
||||
* 从收集器获取渲染数据
|
||||
*/
|
||||
getRenderData(): readonly ProviderRenderData[] {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const uiEntities: Entity[] = [];
|
||||
for (const entity of scene.entities.buffer) {
|
||||
if (entity.hasComponent(UITransformComponent)) {
|
||||
uiEntities.push(entity);
|
||||
}
|
||||
}
|
||||
|
||||
if (uiEntities.length === 0) return [];
|
||||
|
||||
uiEntities.sort((a, b) => {
|
||||
const ta = a.getComponent(UITransformComponent);
|
||||
const tb = b.getComponent(UITransformComponent);
|
||||
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
|
||||
});
|
||||
|
||||
const renderDataList: ProviderRenderData[] = [];
|
||||
|
||||
for (const entity of uiEntities) {
|
||||
const transform = entity.getComponent(UITransformComponent);
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
const text = entity.getComponent(UITextComponent);
|
||||
const button = entity.getComponent(UIButtonComponent);
|
||||
|
||||
if (!transform || !transform.visible) continue;
|
||||
|
||||
const width = transform.width * transform.scaleX;
|
||||
const height = transform.height * transform.scaleY;
|
||||
const centerX = transform.x + width * transform.pivotX;
|
||||
const centerY = transform.y + height * transform.pivotY;
|
||||
|
||||
// Button with texture support
|
||||
if (button && button.useTexture()) {
|
||||
const texture = button.getStateTexture('normal');
|
||||
if (texture) {
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const a = Math.round(transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
|
||||
|
||||
renderDataList.push({
|
||||
transforms,
|
||||
textureIds: new Uint32Array([0]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 100 + transform.zIndex,
|
||||
texturePath: texture
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Background color rendering (for buttons in 'color' or 'both' mode, or regular UI elements)
|
||||
const shouldRenderColor = button
|
||||
? button.useColor() && render && render.backgroundAlpha > 0
|
||||
: render && render.backgroundAlpha > 0;
|
||||
|
||||
if (shouldRenderColor && render) {
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const bgColor = button ? button.currentColor : render.backgroundColor;
|
||||
const r = (bgColor >> 16) & 0xFF;
|
||||
const g = (bgColor >> 8) & 0xFF;
|
||||
const b = bgColor & 0xFF;
|
||||
const a = Math.round(render.backgroundAlpha * transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
|
||||
renderDataList.push({
|
||||
transforms,
|
||||
textureIds: new Uint32Array([0]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 100 + transform.zIndex
|
||||
});
|
||||
}
|
||||
|
||||
if (text && text.text) {
|
||||
const textRenderData = this.createTextRenderData(
|
||||
entity.id,
|
||||
text,
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
transform
|
||||
);
|
||||
if (textRenderData) {
|
||||
renderDataList.push(textRenderData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderDataList;
|
||||
const collector = getUIRenderCollector();
|
||||
return collector.getRenderData();
|
||||
}
|
||||
|
||||
private createTextRenderData(
|
||||
entityId: number,
|
||||
text: UITextComponent,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
transform: UITransformComponent
|
||||
): ProviderRenderData | null {
|
||||
const canvasData = this.getTextCanvas();
|
||||
if (!canvasData) return null;
|
||||
|
||||
const { canvas, ctx } = canvasData;
|
||||
|
||||
const cacheKey = entityId;
|
||||
const cached = this.textTextureCache.get(cacheKey);
|
||||
|
||||
const needsUpdate = !cached ||
|
||||
cached.text !== text.text ||
|
||||
cached.fontSize !== text.fontSize ||
|
||||
cached.fontFamily !== text.fontFamily ||
|
||||
cached.fontWeight !== text.fontWeight ||
|
||||
cached.italic !== text.italic ||
|
||||
cached.color !== text.color ||
|
||||
cached.alpha !== text.alpha ||
|
||||
cached.align !== text.align ||
|
||||
cached.verticalAlign !== text.verticalAlign ||
|
||||
cached.lineHeight !== text.lineHeight ||
|
||||
cached.width !== Math.ceil(width) ||
|
||||
cached.height !== Math.ceil(height);
|
||||
|
||||
if (needsUpdate) {
|
||||
const canvasWidth = Math.max(1, Math.ceil(width));
|
||||
const canvasHeight = Math.max(1, Math.ceil(height));
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
ctx.font = text.getCSSFont();
|
||||
ctx.fillStyle = text.getCSSColor();
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let textX = 0;
|
||||
if (text.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
textX = canvasWidth / 2;
|
||||
} else if (text.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
textX = canvasWidth;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
textX = 0;
|
||||
}
|
||||
|
||||
const metrics = ctx.measureText(text.text);
|
||||
const textHeight = text.fontSize * text.lineHeight;
|
||||
let textY = 0;
|
||||
|
||||
if (text.verticalAlign === 'middle') {
|
||||
textY = (canvasHeight - textHeight) / 2;
|
||||
} else if (text.verticalAlign === 'bottom') {
|
||||
textY = canvasHeight - textHeight;
|
||||
}
|
||||
|
||||
if (text.wordWrap) {
|
||||
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
|
||||
} else {
|
||||
ctx.fillText(text.text, textX, textY);
|
||||
}
|
||||
|
||||
const textureId = cached?.textureId ?? this.nextTextureId++;
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
if (this.onTextureCreated) {
|
||||
this.onTextureCreated(textureId, dataUrl);
|
||||
}
|
||||
|
||||
this.textTextureCache.set(cacheKey, {
|
||||
textureId,
|
||||
text: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
fontWeight: text.fontWeight,
|
||||
italic: text.italic,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign,
|
||||
lineHeight: text.lineHeight,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
const cachedData = this.textTextureCache.get(cacheKey);
|
||||
if (!cachedData) return null;
|
||||
|
||||
const transforms = new Float32Array(7);
|
||||
transforms[0] = centerX;
|
||||
transforms[1] = centerY;
|
||||
transforms[2] = transform.rotation;
|
||||
transforms[3] = width;
|
||||
transforms[4] = height;
|
||||
transforms[5] = transform.pivotX;
|
||||
transforms[6] = transform.pivotY;
|
||||
|
||||
const colors = new Uint32Array(1);
|
||||
const a = Math.round(transform.alpha * 255);
|
||||
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
|
||||
|
||||
return {
|
||||
transforms,
|
||||
textureIds: new Uint32Array([cachedData.textureId]),
|
||||
uvs: new Float32Array([0, 0, 1, 1]),
|
||||
colors,
|
||||
tileCount: 1,
|
||||
sortingOrder: 101 + transform.zIndex
|
||||
};
|
||||
/**
|
||||
* Check if there is content to render
|
||||
* 检查是否有内容需要渲染
|
||||
*/
|
||||
hasContent(): boolean {
|
||||
const collector = getUIRenderCollector();
|
||||
return !collector.isEmpty;
|
||||
}
|
||||
|
||||
private drawWrappedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number
|
||||
): void {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + word + ' ';
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line !== '') {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
line = word + ' ';
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
}
|
||||
}
|
||||
|
||||
collectUIRenderData(): UIRenderData[] {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return [];
|
||||
|
||||
const result: UIRenderData[] = [];
|
||||
|
||||
for (const entity of scene.entities.buffer) {
|
||||
const transform = entity.getComponent(UITransformComponent);
|
||||
if (!transform || !transform.visible) continue;
|
||||
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
const text = entity.getComponent(UITextComponent);
|
||||
|
||||
const data: UIRenderData = {
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
width: transform.width * transform.scaleX,
|
||||
height: transform.height * transform.scaleY,
|
||||
rotation: transform.rotation,
|
||||
originX: transform.pivotX,
|
||||
originY: transform.pivotY,
|
||||
backgroundColor: render?.backgroundColor ?? 0,
|
||||
backgroundAlpha: (render?.backgroundAlpha ?? 0) * transform.alpha,
|
||||
borderColor: render?.borderColor ?? 0,
|
||||
borderWidth: render?.borderWidth ?? 0,
|
||||
cornerRadius: render?.borderRadius?.[0] ?? 0,
|
||||
zIndex: transform.zIndex,
|
||||
visible: transform.visible
|
||||
};
|
||||
|
||||
if (text && text.text) {
|
||||
data.text = {
|
||||
content: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign
|
||||
};
|
||||
}
|
||||
|
||||
result.push(data);
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
clearTextCache(): void {
|
||||
this.textTextureCache.clear();
|
||||
/**
|
||||
* Clear the collector (call at start of frame)
|
||||
* 清除收集器(在帧开始时调用)
|
||||
*/
|
||||
clearCollector(): void {
|
||||
const collector = getUIRenderCollector();
|
||||
collector.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.textCanvas = null;
|
||||
this.textCtx = null;
|
||||
this.textTextureCache.clear();
|
||||
this.onTextureCreated = null;
|
||||
// Nothing to dispose currently
|
||||
// 当前没有需要释放的资源
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,6 @@ export * from './UILayoutSystem';
|
||||
export * from './UIInputSystem';
|
||||
export * from './UIAnimationSystem';
|
||||
export * from './UIRenderDataProvider';
|
||||
|
||||
// Render systems
|
||||
export * from './render';
|
||||
|
||||
159
packages/ui/src/systems/render/UIButtonRenderSystem.ts
Normal file
159
packages/ui/src/systems/render/UIButtonRenderSystem.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* UI Button Render System
|
||||
* UI 按钮渲染系统
|
||||
*
|
||||
* Renders UIButtonComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIButtonComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIButtonComponent } from '../../components/widgets/UIButtonComponent';
|
||||
import { UIRenderComponent } from '../../components/UIRenderComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Button Render System
|
||||
* UI 按钮渲染系统
|
||||
*
|
||||
* Handles rendering of button components including:
|
||||
* - Background color (with state-based color changes)
|
||||
* - Texture support (normal, hover, pressed, disabled)
|
||||
* - Combined color + texture mode
|
||||
*
|
||||
* 处理按钮组件的渲染,包括:
|
||||
* - 背景颜色(带状态变化的颜色)
|
||||
* - 纹理支持(正常、悬停、按下、禁用)
|
||||
* - 颜色 + 纹理组合模式
|
||||
*
|
||||
* Note: Button text is rendered by UITextRenderSystem if UITextComponent is present.
|
||||
* 注意:如果存在 UITextComponent,按钮文本由 UITextRenderSystem 渲染。
|
||||
*/
|
||||
@ECSSystem('UIButtonRender', { updateOrder: 113 })
|
||||
export class UIButtonRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIButtonComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const button = entity.getComponent(UIButtonComponent)!;
|
||||
const render = entity.getComponent(UIRenderComponent);
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Render texture if in texture or both mode
|
||||
// 如果在纹理或两者模式下,渲染纹理
|
||||
if (button.useTexture()) {
|
||||
const texture = button.getStateTexture('normal');
|
||||
if (texture) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
0xFFFFFF, // White tint for texture
|
||||
alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
texturePath: texture
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render color background if in color or both mode
|
||||
// 如果在颜色或两者模式下,渲染颜色背景
|
||||
if (button.useColor()) {
|
||||
const bgAlpha = render?.backgroundAlpha ?? 1;
|
||||
if (bgAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
button.currentColor,
|
||||
bgAlpha * alpha,
|
||||
baseOrder + (button.useTexture() ? 0.05 : 0),
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render border if UIRenderComponent has border
|
||||
// 如果 UIRenderComponent 有边框,渲染边框
|
||||
if (render && render.borderWidth > 0 && render.borderAlpha > 0) {
|
||||
this.renderBorder(
|
||||
collector,
|
||||
x, y, width, height,
|
||||
render.borderWidth,
|
||||
render.borderColor,
|
||||
render.borderAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
transform.rotation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border using top-left coordinates
|
||||
* 使用左上角坐标渲染边框
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
rotation: number
|
||||
): void {
|
||||
// Top border
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
350
packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
Normal file
350
packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* UI ProgressBar Render System
|
||||
* UI 进度条渲染系统
|
||||
*
|
||||
* Renders UIProgressBarComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIProgressBarComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIProgressBarComponent, UIProgressDirection } from '../../components/widgets/UIProgressBarComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI ProgressBar Render System
|
||||
* UI 进度条渲染系统
|
||||
*
|
||||
* Handles rendering of progress bar components including:
|
||||
* - Background rectangle
|
||||
* - Fill rectangle (based on progress value)
|
||||
* - Support for different directions (LTR, RTL, TTB, BTT)
|
||||
* - Segmented display
|
||||
*
|
||||
* 处理进度条组件的渲染,包括:
|
||||
* - 背景矩形
|
||||
* - 填充矩形(基于进度值)
|
||||
* - 支持不同方向(左到右、右到左、上到下、下到上)
|
||||
* - 分段显示
|
||||
*/
|
||||
@ECSSystem('UIProgressBarRender', { updateOrder: 110 })
|
||||
export class UIProgressBarRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIProgressBarComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const progressBar = entity.getComponent(UIProgressBarComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Render background (x, y is top-left corner)
|
||||
// 渲染背景(x, y 是左上角)
|
||||
if (progressBar.backgroundAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y, width, height,
|
||||
progressBar.backgroundColor,
|
||||
progressBar.backgroundAlpha * alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render border
|
||||
// 渲染边框
|
||||
if (progressBar.borderWidth > 0) {
|
||||
this.renderBorder(
|
||||
collector, x, y, width, height,
|
||||
progressBar.borderWidth,
|
||||
progressBar.borderColor,
|
||||
alpha,
|
||||
baseOrder + 0.2,
|
||||
transform
|
||||
);
|
||||
}
|
||||
|
||||
// Render fill
|
||||
// 渲染填充
|
||||
const progress = progressBar.getProgress();
|
||||
if (progress > 0 && progressBar.fillAlpha > 0) {
|
||||
if (progressBar.showSegments) {
|
||||
this.renderSegmentedFill(
|
||||
collector, x, y, width, height,
|
||||
progress, progressBar, alpha, baseOrder + 0.1, transform
|
||||
);
|
||||
} else {
|
||||
this.renderSolidFill(
|
||||
collector, x, y, width, height,
|
||||
progress, progressBar, alpha, baseOrder + 0.1, transform
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render solid fill rectangle
|
||||
* 渲染实心填充矩形
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderSolidFill(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
progress: number,
|
||||
progressBar: UIProgressBarComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
transform: UITransformComponent
|
||||
): void {
|
||||
let fillX = x;
|
||||
let fillY = y;
|
||||
let fillWidth = width;
|
||||
let fillHeight = height;
|
||||
|
||||
// Calculate fill dimensions based on direction
|
||||
// x, y is top-left corner, so calculations are simpler
|
||||
// 根据方向计算填充尺寸
|
||||
// x, y 是左上角,所以计算更简单
|
||||
switch (progressBar.direction) {
|
||||
case UIProgressDirection.LeftToRight:
|
||||
fillWidth = width * progress;
|
||||
// Fill starts from left (fillX = x, no change)
|
||||
break;
|
||||
|
||||
case UIProgressDirection.RightToLeft:
|
||||
fillWidth = width * progress;
|
||||
// Fill starts from right
|
||||
fillX = x + width - fillWidth;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.BottomToTop:
|
||||
fillHeight = height * progress;
|
||||
// Fill starts from bottom
|
||||
fillY = y + height - fillHeight;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.TopToBottom:
|
||||
fillHeight = height * progress;
|
||||
// Fill starts from top (fillY = y, no change)
|
||||
break;
|
||||
}
|
||||
|
||||
// Determine fill color (gradient or solid)
|
||||
// 确定填充颜色(渐变或实心)
|
||||
let fillColor = progressBar.fillColor;
|
||||
if (progressBar.useGradient) {
|
||||
// Simple linear interpolation between start and end colors
|
||||
// 简单的起始和结束颜色线性插值
|
||||
fillColor = this.lerpColor(
|
||||
progressBar.gradientStartColor,
|
||||
progressBar.gradientEndColor,
|
||||
progress
|
||||
);
|
||||
}
|
||||
|
||||
collector.addRect(
|
||||
fillX, fillY, fillWidth, fillHeight,
|
||||
fillColor,
|
||||
progressBar.fillAlpha * alpha,
|
||||
sortOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render segmented fill
|
||||
* 渲染分段填充
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderSegmentedFill(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
progress: number,
|
||||
progressBar: UIProgressBarComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
transform: UITransformComponent
|
||||
): void {
|
||||
const segments = progressBar.segments;
|
||||
const gap = progressBar.segmentGap;
|
||||
const filledSegments = Math.ceil(progress * segments);
|
||||
|
||||
const isHorizontal = progressBar.direction === UIProgressDirection.LeftToRight ||
|
||||
progressBar.direction === UIProgressDirection.RightToLeft;
|
||||
|
||||
// Calculate segment dimensions
|
||||
// 计算段尺寸
|
||||
let segmentWidth: number;
|
||||
let segmentHeight: number;
|
||||
|
||||
if (isHorizontal) {
|
||||
segmentWidth = (width - gap * (segments - 1)) / segments;
|
||||
segmentHeight = height;
|
||||
} else {
|
||||
segmentWidth = width;
|
||||
segmentHeight = (height - gap * (segments - 1)) / segments;
|
||||
}
|
||||
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
const baseX = x;
|
||||
const baseY = y;
|
||||
|
||||
for (let i = 0; i < filledSegments && i < segments; i++) {
|
||||
let segX: number;
|
||||
let segY: number;
|
||||
|
||||
// Calculate segment position based on direction (using top-left positions)
|
||||
// 根据方向计算段位置(使用左上角位置)
|
||||
switch (progressBar.direction) {
|
||||
case UIProgressDirection.LeftToRight:
|
||||
segX = baseX + i * (segmentWidth + gap);
|
||||
segY = baseY;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.RightToLeft:
|
||||
segX = baseX + width - (i + 1) * segmentWidth - i * gap;
|
||||
segY = baseY;
|
||||
break;
|
||||
|
||||
case UIProgressDirection.TopToBottom:
|
||||
segX = baseX;
|
||||
segY = baseY + i * (segmentHeight + gap);
|
||||
break;
|
||||
|
||||
case UIProgressDirection.BottomToTop:
|
||||
segX = baseX;
|
||||
segY = baseY + height - (i + 1) * segmentHeight - i * gap;
|
||||
break;
|
||||
|
||||
default:
|
||||
segX = baseX + i * (segmentWidth + gap);
|
||||
segY = baseY;
|
||||
}
|
||||
|
||||
// Determine segment color
|
||||
// 确定段颜色
|
||||
let segmentColor = progressBar.fillColor;
|
||||
if (progressBar.useGradient) {
|
||||
const t = segments > 1 ? i / (segments - 1) : 0;
|
||||
segmentColor = this.lerpColor(
|
||||
progressBar.gradientStartColor,
|
||||
progressBar.gradientEndColor,
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
// Use top-left position with pivot 0,0
|
||||
// 使用左上角位置,pivot 0,0
|
||||
collector.addRect(
|
||||
segX, segY,
|
||||
segmentWidth,
|
||||
segmentHeight,
|
||||
segmentColor,
|
||||
progressBar.fillAlpha * alpha,
|
||||
sortOrder + i * 0.001, // Slight offset for each segment
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border
|
||||
* 渲染边框
|
||||
*
|
||||
* Note: x, y is the top-left corner of the progress bar
|
||||
* 注意:x, y 是进度条的左上角
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number, width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
_transform: UITransformComponent
|
||||
): void {
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
|
||||
// Top border
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation between two colors
|
||||
* 两种颜色之间的线性插值
|
||||
*/
|
||||
private lerpColor(color1: number, color2: number, t: number): number {
|
||||
const r1 = (color1 >> 16) & 0xFF;
|
||||
const g1 = (color1 >> 8) & 0xFF;
|
||||
const b1 = color1 & 0xFF;
|
||||
|
||||
const r2 = (color2 >> 16) & 0xFF;
|
||||
const g2 = (color2 >> 8) & 0xFF;
|
||||
const b2 = color2 & 0xFF;
|
||||
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const b = Math.round(b1 + (b2 - b1) * t);
|
||||
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
193
packages/ui/src/systems/render/UIRectRenderSystem.ts
Normal file
193
packages/ui/src/systems/render/UIRectRenderSystem.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* UI Rect Render System
|
||||
* UI 矩形渲染系统
|
||||
*
|
||||
* Renders basic UIRenderComponent entities (those without specialized widget components)
|
||||
* by submitting render primitives to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染基础 UIRenderComponent 实体
|
||||
* (没有专门 widget 组件的实体)。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIRenderComponent } from '../../components/UIRenderComponent';
|
||||
import { UIButtonComponent } from '../../components/widgets/UIButtonComponent';
|
||||
import { UIProgressBarComponent } from '../../components/widgets/UIProgressBarComponent';
|
||||
import { UISliderComponent } from '../../components/widgets/UISliderComponent';
|
||||
import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Rect Render System
|
||||
* UI 矩形渲染系统
|
||||
*
|
||||
* Handles rendering of basic UI elements with UIRenderComponent that don't have
|
||||
* specialized widget components (like buttons, progress bars, etc.).
|
||||
*
|
||||
* This is the "catch-all" renderer for simple rectangles, images, and panels.
|
||||
*
|
||||
* 处理具有 UIRenderComponent 但没有专门 widget 组件(如按钮、进度条等)的基础 UI 元素的渲染。
|
||||
* 这是简单矩形、图像和面板的"兜底"渲染器。
|
||||
*/
|
||||
@ECSSystem('UIRectRender', { updateOrder: 100 })
|
||||
export class UIRectRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIRenderComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
// Skip if entity has specialized widget components
|
||||
// (they have their own render systems)
|
||||
// 如果实体有专门的 widget 组件,跳过(它们有自己的渲染系统)
|
||||
if (entity.hasComponent(UIButtonComponent) ||
|
||||
entity.hasComponent(UIProgressBarComponent) ||
|
||||
entity.hasComponent(UISliderComponent) ||
|
||||
entity.hasComponent(UIScrollViewComponent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const render = entity.getComponent(UIRenderComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Use top-left position with origin at (0, 0)
|
||||
// Like Sprite: x,y is anchor position, origin determines where anchor is on the rect
|
||||
// For UI: x,y is top-left corner, so origin should be (0, 0)
|
||||
// 使用左上角位置,原点在 (0, 0)
|
||||
// 类似 Sprite:x,y 是锚点位置,origin 决定锚点在矩形上的位置
|
||||
// 对于 UI:x,y 是左上角,所以 origin 应该是 (0, 0)
|
||||
|
||||
// Render shadow if enabled
|
||||
// 如果启用,渲染阴影
|
||||
if (render.shadowEnabled && render.shadowAlpha > 0) {
|
||||
collector.addRect(
|
||||
x + render.shadowOffsetX - render.shadowBlur,
|
||||
y + render.shadowOffsetY - render.shadowBlur,
|
||||
width + render.shadowBlur * 2,
|
||||
height + render.shadowBlur * 2,
|
||||
render.shadowColor,
|
||||
render.shadowAlpha * alpha,
|
||||
baseOrder - 0.1,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render texture if present
|
||||
// 如果有纹理,渲染纹理
|
||||
if (render.texture) {
|
||||
const texturePath = typeof render.texture === 'string' ? render.texture : undefined;
|
||||
const textureId = typeof render.texture === 'number' ? render.texture : undefined;
|
||||
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
render.textureTint,
|
||||
alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
textureId,
|
||||
texturePath,
|
||||
uv: render.textureUV
|
||||
? [render.textureUV.u0, render.textureUV.v0, render.textureUV.u1, render.textureUV.v1]
|
||||
: undefined
|
||||
}
|
||||
);
|
||||
}
|
||||
// Render background color if fill is enabled
|
||||
// 如果启用填充,渲染背景颜色
|
||||
else if (render.fillBackground && render.backgroundAlpha > 0) {
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
render.backgroundColor,
|
||||
render.backgroundAlpha * alpha,
|
||||
baseOrder,
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Render border if present
|
||||
// 如果有边框,渲染边框
|
||||
if (render.borderWidth > 0 && render.borderAlpha > 0) {
|
||||
this.renderBorder(
|
||||
collector,
|
||||
x, y, width, height,
|
||||
render.borderWidth,
|
||||
render.borderColor,
|
||||
render.borderAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
transform.rotation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render border using top-left coordinates
|
||||
* 使用左上角坐标渲染边框
|
||||
*/
|
||||
private renderBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
rotation: number
|
||||
): void {
|
||||
// Top border (from top-left corner)
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
collector.addRect(
|
||||
x, y + height - borderWidth,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Left border (excluding corners)
|
||||
collector.addRect(
|
||||
x, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
|
||||
// Right border (excluding corners)
|
||||
collector.addRect(
|
||||
x + width - borderWidth, y + borderWidth,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ rotation, pivotX: 0, pivotY: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
packages/ui/src/systems/render/UIRenderBeginSystem.ts
Normal file
53
packages/ui/src/systems/render/UIRenderBeginSystem.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* UI Render Begin System
|
||||
* UI 渲染开始系统
|
||||
*
|
||||
* This system runs at the beginning of each frame (before other UI render systems)
|
||||
* to clear the UIRenderCollector. This ensures that each frame starts with a fresh
|
||||
* set of render primitives.
|
||||
*
|
||||
* 此系统在每帧开始时运行(在其他 UI 渲染系统之前),以清除 UIRenderCollector。
|
||||
* 这确保每帧都以一组新的渲染原语开始。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Entity, ECSSystem, Matcher } from '@esengine/ecs-framework';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Render Begin System
|
||||
* UI 渲染开始系统
|
||||
*
|
||||
* Runs before all other UI render systems to clear the collector.
|
||||
* 在所有其他 UI 渲染系统之前运行,以清除收集器。
|
||||
*
|
||||
* Update order: 99 (runs before UIRectRenderSystem at 100)
|
||||
*/
|
||||
@ECSSystem('UIRenderBegin', { updateOrder: 99 })
|
||||
export class UIRenderBeginSystem extends EntitySystem {
|
||||
constructor() {
|
||||
// Use Matcher.nothing() to indicate this system doesn't process any entities
|
||||
// It only uses lifecycle methods (onBegin) to clear the collector each frame
|
||||
// 使用 Matcher.nothing() 表明此系统不处理任何实体
|
||||
// 它只使用生命周期方法 (onBegin) 在每帧清除收集器
|
||||
super(Matcher.nothing());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at the beginning of each frame
|
||||
* 每帧开始时调用
|
||||
*/
|
||||
protected override onBegin(): void {
|
||||
// Clear the collector for a fresh frame
|
||||
// 清除收集器,准备新的一帧
|
||||
const collector = getUIRenderCollector();
|
||||
collector.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* No entities to process (marker component never exists)
|
||||
* 没有实体需要处理(标记组件永远不存在)
|
||||
*/
|
||||
protected process(_entities: readonly Entity[]): void {
|
||||
// This should never be called since no entity has the marker component
|
||||
}
|
||||
}
|
||||
362
packages/ui/src/systems/render/UIRenderCollector.ts
Normal file
362
packages/ui/src/systems/render/UIRenderCollector.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* UI Render Collector - Shared service for collecting UI render primitives
|
||||
* UI 渲染收集器 - 用于收集 UI 渲染原语的共享服务
|
||||
*
|
||||
* This collector is used by all UI render systems to submit render data.
|
||||
* 此收集器被所有 UI 渲染系统用于提交渲染数据。
|
||||
*
|
||||
* Render mode is controlled by EngineRenderSystem.previewMode:
|
||||
* - Editor mode (previewMode=false): UI renders in world space with sprites
|
||||
* - Preview mode (previewMode=true): UI renders as screen overlay
|
||||
*
|
||||
* 渲染模式由 EngineRenderSystem.previewMode 控制:
|
||||
* - 编辑器模式 (previewMode=false): UI 与精灵一起在世界空间渲染
|
||||
* - 预览模式 (previewMode=true): UI 作为屏幕叠加层渲染
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single render primitive (rectangle with optional texture)
|
||||
* 单个渲染原语(可选带纹理的矩形)
|
||||
*
|
||||
* Coordinate system (same as Sprite rendering):
|
||||
* - x, y: Anchor/origin position of the rectangle
|
||||
* - width, height: Pixel dimensions
|
||||
* - pivotX, pivotY: Where the anchor point is on the rectangle (0-1)
|
||||
* - (0, 0) = x,y is top-left corner
|
||||
* - (0.5, 0.5) = x,y is center
|
||||
* - (1, 1) = x,y is bottom-right corner
|
||||
*
|
||||
* For UI elements (UITransform), x,y is always top-left corner,
|
||||
* so pivotX=0, pivotY=0 should be used.
|
||||
*
|
||||
* 坐标系统(与 Sprite 渲染相同):
|
||||
* - x, y: 矩形的锚点/原点位置
|
||||
* - width, height: 像素尺寸
|
||||
* - pivotX, pivotY: 锚点在矩形上的位置(0-1)
|
||||
* - (0, 0) = x,y 是左上角
|
||||
* - (0.5, 0.5) = x,y 是中心
|
||||
* - (1, 1) = x,y 是右下角
|
||||
*
|
||||
* 对于 UI 元素(UITransform),x,y 始终是左上角,
|
||||
* 因此应使用 pivotX=0, pivotY=0。
|
||||
*/
|
||||
export interface UIRenderPrimitive {
|
||||
/** X position (anchor point) | X 坐标(锚点位置) */
|
||||
x: number;
|
||||
/** Y position (anchor point) | Y 坐标(锚点位置) */
|
||||
y: number;
|
||||
/** Width in pixels | 宽度(像素) */
|
||||
width: number;
|
||||
/** Height in pixels | 高度(像素) */
|
||||
height: number;
|
||||
/** Rotation in radians | 旋转角度(弧度) */
|
||||
rotation: number;
|
||||
/** Pivot/Origin X (0-1, 0=left, 0.5=center, 1=right) | 锚点 X (0-1, 0=左, 0.5=中心, 1=右) */
|
||||
pivotX: number;
|
||||
/** Pivot/Origin Y (0-1, 0=top, 0.5=center, 1=bottom) | 锚点 Y (0-1, 0=上, 0.5=中心, 1=下) */
|
||||
pivotY: number;
|
||||
/** Packed color (0xAABBGGRR) | 打包颜色 */
|
||||
color: number;
|
||||
/** Sort order (lower = rendered first/behind) | 排序顺序 */
|
||||
sortOrder: number;
|
||||
/** Optional texture ID | 可选纹理 ID */
|
||||
textureId?: number;
|
||||
/** Optional texture path | 可选纹理路径 */
|
||||
texturePath?: string;
|
||||
/** UV coordinates [u0, v0, u1, v1] | UV 坐标 */
|
||||
uv?: [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider render data format (compatible with EngineRenderSystem)
|
||||
* 提供者渲染数据格式(兼容 EngineRenderSystem)
|
||||
*/
|
||||
export interface ProviderRenderData {
|
||||
transforms: Float32Array;
|
||||
textureIds: Uint32Array;
|
||||
uvs: Float32Array;
|
||||
colors: Uint32Array;
|
||||
tileCount: number;
|
||||
sortingOrder: number;
|
||||
texturePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Render Collector
|
||||
* UI 渲染收集器
|
||||
*
|
||||
* Collects render primitives from all UI render systems and converts them
|
||||
* to the format expected by EngineRenderSystem.
|
||||
* 从所有 UI 渲染系统收集渲染原语,并转换为 EngineRenderSystem 期望的格式。
|
||||
*/
|
||||
export class UIRenderCollector {
|
||||
/** Collected primitives | 收集的原语 */
|
||||
private primitives: UIRenderPrimitive[] = [];
|
||||
|
||||
private cache: ProviderRenderData[] | null = null;
|
||||
|
||||
/**
|
||||
* Clear all collected primitives (call at start of frame)
|
||||
* 清除所有收集的原语(在帧开始时调用)
|
||||
*/
|
||||
clear(): void {
|
||||
this.primitives.length = 0;
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a rectangle primitive
|
||||
* 添加矩形原语
|
||||
*/
|
||||
addRect(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: number,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
options?: {
|
||||
rotation?: number;
|
||||
pivotX?: number;
|
||||
pivotY?: number;
|
||||
textureId?: number;
|
||||
texturePath?: string;
|
||||
uv?: [number, number, number, number];
|
||||
}
|
||||
): void {
|
||||
// Pack color with alpha: 0xAABBGGRR
|
||||
const r = (color >> 16) & 0xFF;
|
||||
const g = (color >> 8) & 0xFF;
|
||||
const b = color & 0xFF;
|
||||
const a = Math.round(alpha * 255);
|
||||
const packedColor = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
|
||||
|
||||
const primitive: UIRenderPrimitive = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation: options?.rotation ?? 0,
|
||||
pivotX: options?.pivotX ?? 0,
|
||||
pivotY: options?.pivotY ?? 0,
|
||||
color: packedColor,
|
||||
sortOrder,
|
||||
textureId: options?.textureId,
|
||||
texturePath: options?.texturePath,
|
||||
uv: options?.uv
|
||||
};
|
||||
|
||||
this.primitives.push(primitive);
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a primitive with pre-calculated world transform
|
||||
* 添加带预计算世界变换的原语
|
||||
*/
|
||||
addPrimitive(primitive: UIRenderPrimitive): void {
|
||||
this.primitives.push(primitive);
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get render data
|
||||
* 获取渲染数据
|
||||
*/
|
||||
getRenderData(): readonly ProviderRenderData[] {
|
||||
if (this.cache) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
this.cache = this.buildRenderData(this.primitives);
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build render data from primitives
|
||||
* 从原语构建渲染数据
|
||||
*/
|
||||
private buildRenderData(primitives: UIRenderPrimitive[]): ProviderRenderData[] {
|
||||
if (primitives.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort by sortOrder
|
||||
// 按 sortOrder 排序
|
||||
primitives.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Group by texture (primitives with same texture can be batched)
|
||||
// 按纹理分组(相同纹理的原语可以批处理)
|
||||
const groups = new Map<string, UIRenderPrimitive[]>();
|
||||
|
||||
for (const prim of primitives) {
|
||||
// Use texture path or 'solid' for solid color rects
|
||||
const key = prim.texturePath ?? (prim.textureId?.toString() ?? 'solid');
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = [];
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.push(prim);
|
||||
}
|
||||
|
||||
// Convert groups to ProviderRenderData
|
||||
// 将分组转换为 ProviderRenderData
|
||||
const result: ProviderRenderData[] = [];
|
||||
|
||||
for (const [key, prims] of groups) {
|
||||
const count = prims.length;
|
||||
const transforms = new Float32Array(count * 7);
|
||||
const textureIds = new Uint32Array(count);
|
||||
const uvs = new Float32Array(count * 4);
|
||||
const colors = new Uint32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = prims[i];
|
||||
const tOffset = i * 7;
|
||||
const uvOffset = i * 4;
|
||||
|
||||
// Unified render transform format (same as SpriteRenderData):
|
||||
// [x, y, rotation, width(pixels), height(pixels), pivotX(0-1), pivotY(0-1)]
|
||||
// 统一渲染变换格式(与 SpriteRenderData 相同)
|
||||
transforms[tOffset] = p.x;
|
||||
transforms[tOffset + 1] = p.y;
|
||||
transforms[tOffset + 2] = p.rotation;
|
||||
transforms[tOffset + 3] = p.width;
|
||||
transforms[tOffset + 4] = p.height;
|
||||
transforms[tOffset + 5] = p.pivotX;
|
||||
transforms[tOffset + 6] = p.pivotY;
|
||||
|
||||
textureIds[i] = p.textureId ?? 0;
|
||||
|
||||
// UV
|
||||
if (p.uv) {
|
||||
uvs[uvOffset] = p.uv[0];
|
||||
uvs[uvOffset + 1] = p.uv[1];
|
||||
uvs[uvOffset + 2] = p.uv[2];
|
||||
uvs[uvOffset + 3] = p.uv[3];
|
||||
} else {
|
||||
uvs[uvOffset] = 0;
|
||||
uvs[uvOffset + 1] = 0;
|
||||
uvs[uvOffset + 2] = 1;
|
||||
uvs[uvOffset + 3] = 1;
|
||||
}
|
||||
|
||||
colors[i] = p.color;
|
||||
}
|
||||
|
||||
// Use the minimum sortOrder from the group as the batch sortingOrder
|
||||
const minSortOrder = Math.min(...prims.map(p => p.sortOrder));
|
||||
|
||||
const renderData: ProviderRenderData = {
|
||||
transforms,
|
||||
textureIds,
|
||||
uvs,
|
||||
colors,
|
||||
tileCount: count,
|
||||
sortingOrder: minSortOrder
|
||||
};
|
||||
|
||||
// Add texture path if not solid color
|
||||
if (key !== 'solid' && isNaN(parseInt(key))) {
|
||||
renderData.texturePath = key;
|
||||
}
|
||||
|
||||
result.push(renderData);
|
||||
}
|
||||
|
||||
// Sort result by sortingOrder
|
||||
result.sort((a, b) => a.sortingOrder - b.sortingOrder);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of primitives collected
|
||||
* 获取收集的原语总数量
|
||||
*/
|
||||
get count(): number {
|
||||
return this.primitives.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if collector is empty
|
||||
* 检查收集器是否为空
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return this.primitives.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
// 全局单例实例
|
||||
let globalCollector: UIRenderCollector | null = null;
|
||||
|
||||
// Cache invalidation callbacks
|
||||
// 缓存失效回调
|
||||
type CacheInvalidationCallback = () => void;
|
||||
const cacheInvalidationCallbacks: CacheInvalidationCallback[] = [];
|
||||
|
||||
/**
|
||||
* Get the global UI render collector instance
|
||||
* 获取全局 UI 渲染收集器实例
|
||||
*/
|
||||
export function getUIRenderCollector(): UIRenderCollector {
|
||||
if (!globalCollector) {
|
||||
globalCollector = new UIRenderCollector();
|
||||
}
|
||||
return globalCollector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the global collector (for testing or cleanup)
|
||||
* 重置全局收集器(用于测试或清理)
|
||||
*/
|
||||
export function resetUIRenderCollector(): void {
|
||||
globalCollector = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a cache invalidation callback
|
||||
* 注册缓存失效回调
|
||||
*
|
||||
* UI render systems can register their cache clearing functions here.
|
||||
* When invalidateUIRenderCaches() is called, all registered callbacks will be invoked.
|
||||
*
|
||||
* UI 渲染系统可以在这里注册它们的缓存清除函数。
|
||||
* 当调用 invalidateUIRenderCaches() 时,所有注册的回调将被调用。
|
||||
*/
|
||||
export function registerCacheInvalidationCallback(callback: CacheInvalidationCallback): void {
|
||||
if (!cacheInvalidationCallbacks.includes(callback)) {
|
||||
cacheInvalidationCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a cache invalidation callback
|
||||
* 取消注册缓存失效回调
|
||||
*/
|
||||
export function unregisterCacheInvalidationCallback(callback: CacheInvalidationCallback): void {
|
||||
const index = cacheInvalidationCallbacks.indexOf(callback);
|
||||
if (index >= 0) {
|
||||
cacheInvalidationCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all UI render caches
|
||||
* 使所有 UI 渲染缓存失效
|
||||
*
|
||||
* Call this when the scene is restored or when caches need to be cleared.
|
||||
* 在场景恢复或需要清除缓存时调用此函数。
|
||||
*/
|
||||
export function invalidateUIRenderCaches(): void {
|
||||
for (const callback of cacheInvalidationCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
} catch (e) {
|
||||
console.error('Error invalidating UI render cache:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
194
packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
Normal file
194
packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* UI ScrollView Render System
|
||||
* UI 滚动视图渲染系统
|
||||
*
|
||||
* Renders UIScrollViewComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UIScrollViewComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UIScrollViewComponent } from '../../components/widgets/UIScrollViewComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI ScrollView Render System
|
||||
* UI 滚动视图渲染系统
|
||||
*
|
||||
* Handles rendering of scrollview components including:
|
||||
* - Vertical scrollbar track and handle
|
||||
* - Horizontal scrollbar track and handle
|
||||
* - Scrollbar hover states
|
||||
*
|
||||
* 处理滚动视图组件的渲染,包括:
|
||||
* - 垂直滚动条轨道和手柄
|
||||
* - 水平滚动条轨道和手柄
|
||||
* - 滚动条悬停状态
|
||||
*
|
||||
* Note: The scrollview content area and clipping is handled by the layout system.
|
||||
* 注意:滚动视图内容区域和裁剪由布局系统处理。
|
||||
*/
|
||||
@ECSSystem('UIScrollViewRender', { updateOrder: 112 })
|
||||
export class UIScrollViewRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UIScrollViewComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const scrollView = entity.getComponent(UIScrollViewComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// x, y is already top-left corner
|
||||
// x, y 已经是左上角
|
||||
const baseX = x;
|
||||
const baseY = y;
|
||||
|
||||
// Render vertical scrollbar
|
||||
// 渲染垂直滚动条
|
||||
if (scrollView.needsVerticalScrollbar(height)) {
|
||||
this.renderVerticalScrollbar(
|
||||
collector,
|
||||
baseX, baseY, width, height,
|
||||
scrollView, alpha, baseOrder
|
||||
);
|
||||
}
|
||||
|
||||
// Render horizontal scrollbar
|
||||
// 渲染水平滚动条
|
||||
if (scrollView.needsHorizontalScrollbar(width)) {
|
||||
this.renderHorizontalScrollbar(
|
||||
collector,
|
||||
baseX, baseY, width, height,
|
||||
scrollView, alpha, baseOrder
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vertical scrollbar
|
||||
* 渲染垂直滚动条
|
||||
*/
|
||||
private renderVerticalScrollbar(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
baseX: number, baseY: number,
|
||||
viewWidth: number, viewHeight: number,
|
||||
scrollView: UIScrollViewComponent,
|
||||
alpha: number,
|
||||
baseOrder: number
|
||||
): void {
|
||||
const scrollbarWidth = scrollView.scrollbarWidth;
|
||||
const hasHorizontal = scrollView.needsHorizontalScrollbar(viewWidth);
|
||||
const trackHeight = hasHorizontal ? viewHeight - scrollbarWidth : viewHeight;
|
||||
|
||||
// Track position (right side of viewport)
|
||||
// 轨道位置(视口右侧)
|
||||
const trackX = baseX + viewWidth - scrollbarWidth / 2;
|
||||
const trackY = baseY + trackHeight / 2;
|
||||
|
||||
// Render track
|
||||
// 渲染轨道
|
||||
if (scrollView.scrollbarTrackAlpha > 0) {
|
||||
collector.addRect(
|
||||
trackX, trackY,
|
||||
scrollbarWidth, trackHeight,
|
||||
scrollView.scrollbarTrackColor,
|
||||
scrollView.scrollbarTrackAlpha * alpha,
|
||||
baseOrder + 0.5,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate handle metrics
|
||||
// 计算手柄尺寸
|
||||
const metrics = scrollView.getVerticalScrollbarMetrics(viewHeight);
|
||||
const handleY = baseY + metrics.position + metrics.size / 2;
|
||||
|
||||
// Handle alpha (different when hovered)
|
||||
// 手柄透明度(悬停时不同)
|
||||
const handleAlpha = scrollView.verticalScrollbarHovered
|
||||
? scrollView.scrollbarHoverAlpha
|
||||
: scrollView.scrollbarAlpha;
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
collector.addRect(
|
||||
trackX, handleY,
|
||||
scrollbarWidth - 2, metrics.size,
|
||||
scrollView.scrollbarColor,
|
||||
handleAlpha * alpha,
|
||||
baseOrder + 0.6,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render horizontal scrollbar
|
||||
* 渲染水平滚动条
|
||||
*/
|
||||
private renderHorizontalScrollbar(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
baseX: number, baseY: number,
|
||||
viewWidth: number, viewHeight: number,
|
||||
scrollView: UIScrollViewComponent,
|
||||
alpha: number,
|
||||
baseOrder: number
|
||||
): void {
|
||||
const scrollbarWidth = scrollView.scrollbarWidth;
|
||||
const hasVertical = scrollView.needsVerticalScrollbar(viewHeight);
|
||||
const trackWidth = hasVertical ? viewWidth - scrollbarWidth : viewWidth;
|
||||
|
||||
// Track position (bottom of viewport)
|
||||
// 轨道位置(视口底部)
|
||||
const trackX = baseX + trackWidth / 2;
|
||||
const trackY = baseY + viewHeight - scrollbarWidth / 2;
|
||||
|
||||
// Render track
|
||||
// 渲染轨道
|
||||
if (scrollView.scrollbarTrackAlpha > 0) {
|
||||
collector.addRect(
|
||||
trackX, trackY,
|
||||
trackWidth, scrollbarWidth,
|
||||
scrollView.scrollbarTrackColor,
|
||||
scrollView.scrollbarTrackAlpha * alpha,
|
||||
baseOrder + 0.5,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate handle metrics
|
||||
// 计算手柄尺寸
|
||||
const metrics = scrollView.getHorizontalScrollbarMetrics(viewWidth);
|
||||
const handleX = baseX + metrics.position + metrics.size / 2;
|
||||
|
||||
// Handle alpha (different when hovered)
|
||||
// 手柄透明度(悬停时不同)
|
||||
const handleAlpha = scrollView.horizontalScrollbarHovered
|
||||
? scrollView.scrollbarHoverAlpha
|
||||
: scrollView.scrollbarAlpha;
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
collector.addRect(
|
||||
handleX, trackY,
|
||||
metrics.size, scrollbarWidth - 2,
|
||||
scrollView.scrollbarColor,
|
||||
handleAlpha * alpha,
|
||||
baseOrder + 0.6,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
277
packages/ui/src/systems/render/UISliderRenderSystem.ts
Normal file
277
packages/ui/src/systems/render/UISliderRenderSystem.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* UI Slider Render System
|
||||
* UI 滑块渲染系统
|
||||
*
|
||||
* Renders UISliderComponent entities by submitting render primitives
|
||||
* to the shared UIRenderCollector.
|
||||
* 通过向共享的 UIRenderCollector 提交渲染原语来渲染 UISliderComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UISliderComponent, UISliderOrientation } from '../../components/widgets/UISliderComponent';
|
||||
import { getUIRenderCollector } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* UI Slider Render System
|
||||
* UI 滑块渲染系统
|
||||
*
|
||||
* Handles rendering of slider components including:
|
||||
* - Track (background bar)
|
||||
* - Fill (progress portion)
|
||||
* - Handle (draggable knob)
|
||||
* - Optional ticks
|
||||
*
|
||||
* 处理滑块组件的渲染,包括:
|
||||
* - 轨道(背景条)
|
||||
* - 填充(进度部分)
|
||||
* - 手柄(可拖动的旋钮)
|
||||
* - 可选刻度
|
||||
*/
|
||||
@ECSSystem('UISliderRender', { updateOrder: 111 })
|
||||
export class UISliderRenderSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UISliderComponent));
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const slider = entity.getComponent(UISliderComponent)!;
|
||||
|
||||
if (!transform.visible) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
const isHorizontal = slider.orientation === UISliderOrientation.Horizontal;
|
||||
const progress = slider.getProgress();
|
||||
|
||||
// Calculate track dimensions and position
|
||||
// 计算轨道尺寸和位置
|
||||
const trackLength = isHorizontal ? width : height;
|
||||
const trackThickness = slider.trackThickness;
|
||||
|
||||
// Calculate center position (x, y is top-left corner)
|
||||
// 计算中心位置(x, y 是左上角)
|
||||
const centerX = x + width / 2;
|
||||
const centerY = y + height / 2;
|
||||
|
||||
// Render track (using center position with pivot 0.5)
|
||||
// 渲染轨道(使用中心位置,pivot 0.5)
|
||||
if (slider.trackAlpha > 0) {
|
||||
if (isHorizontal) {
|
||||
collector.addRect(
|
||||
centerX, centerY,
|
||||
trackLength, trackThickness,
|
||||
slider.trackColor,
|
||||
slider.trackAlpha * alpha,
|
||||
baseOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
} else {
|
||||
collector.addRect(
|
||||
centerX, centerY,
|
||||
trackThickness, trackLength,
|
||||
slider.trackColor,
|
||||
slider.trackAlpha * alpha,
|
||||
baseOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render fill
|
||||
// 渲染填充
|
||||
if (progress > 0 && slider.fillAlpha > 0) {
|
||||
const fillLength = trackLength * progress;
|
||||
|
||||
if (isHorizontal) {
|
||||
// Fill from left
|
||||
const fillX = centerX - trackLength / 2 + fillLength / 2;
|
||||
collector.addRect(
|
||||
fillX, centerY,
|
||||
fillLength, trackThickness,
|
||||
slider.fillColor,
|
||||
slider.fillAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
} else {
|
||||
// Fill from bottom
|
||||
const fillY = centerY + trackLength / 2 - fillLength / 2;
|
||||
collector.addRect(
|
||||
centerX, fillY,
|
||||
trackThickness, fillLength,
|
||||
slider.fillColor,
|
||||
slider.fillAlpha * alpha,
|
||||
baseOrder + 0.1,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render ticks
|
||||
// 渲染刻度
|
||||
if (slider.showTicks && slider.tickCount > 0) {
|
||||
this.renderTicks(
|
||||
collector, centerX, centerY,
|
||||
trackLength, trackThickness,
|
||||
slider, alpha, baseOrder + 0.05,
|
||||
isHorizontal
|
||||
);
|
||||
}
|
||||
|
||||
// Render handle
|
||||
// 渲染手柄
|
||||
const handleColor = slider.getCurrentHandleColor();
|
||||
const handleX = isHorizontal
|
||||
? centerX - trackLength / 2 + trackLength * progress
|
||||
: centerX;
|
||||
const handleY = isHorizontal
|
||||
? centerY
|
||||
: centerY + trackLength / 2 - trackLength * progress;
|
||||
|
||||
// Handle shadow (if enabled)
|
||||
// 手柄阴影(如果启用)
|
||||
if (slider.handleShadow) {
|
||||
collector.addRect(
|
||||
handleX + 1, handleY + 2,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
0x000000,
|
||||
0.3 * alpha,
|
||||
baseOrder + 0.15,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
|
||||
// Handle body
|
||||
// 手柄主体
|
||||
collector.addRect(
|
||||
handleX, handleY,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
handleColor,
|
||||
alpha,
|
||||
baseOrder + 0.2,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Handle border (if any)
|
||||
// 手柄边框(如果有)
|
||||
if (slider.handleBorderWidth > 0) {
|
||||
this.renderHandleBorder(
|
||||
collector,
|
||||
handleX, handleY,
|
||||
slider.handleWidth, slider.handleHeight,
|
||||
slider.handleBorderWidth,
|
||||
slider.handleBorderColor,
|
||||
alpha,
|
||||
baseOrder + 0.25
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ticks along the slider track
|
||||
* 沿滑块轨道渲染刻度
|
||||
*/
|
||||
private renderTicks(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
centerX: number, centerY: number,
|
||||
trackLength: number, trackThickness: number,
|
||||
slider: UISliderComponent,
|
||||
alpha: number,
|
||||
sortOrder: number,
|
||||
isHorizontal: boolean
|
||||
): void {
|
||||
const tickCount = slider.tickCount + 2; // Include start and end ticks
|
||||
const tickSize = slider.tickSize;
|
||||
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const t = i / (tickCount - 1);
|
||||
|
||||
let tickX: number;
|
||||
let tickY: number;
|
||||
let tickWidth: number;
|
||||
let tickHeight: number;
|
||||
|
||||
if (isHorizontal) {
|
||||
tickX = centerX - trackLength / 2 + trackLength * t;
|
||||
tickY = centerY + trackThickness / 2 + tickSize / 2 + 2;
|
||||
tickWidth = 2;
|
||||
tickHeight = tickSize;
|
||||
} else {
|
||||
tickX = centerX + trackThickness / 2 + tickSize / 2 + 2;
|
||||
tickY = centerY + trackLength / 2 - trackLength * t;
|
||||
tickWidth = tickSize;
|
||||
tickHeight = 2;
|
||||
}
|
||||
|
||||
collector.addRect(
|
||||
tickX, tickY,
|
||||
tickWidth, tickHeight,
|
||||
slider.tickColor,
|
||||
alpha,
|
||||
sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render handle border
|
||||
* 渲染手柄边框
|
||||
*/
|
||||
private renderHandleBorder(
|
||||
collector: ReturnType<typeof getUIRenderCollector>,
|
||||
x: number, y: number,
|
||||
width: number, height: number,
|
||||
borderWidth: number,
|
||||
borderColor: number,
|
||||
alpha: number,
|
||||
sortOrder: number
|
||||
): void {
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const halfB = borderWidth / 2;
|
||||
|
||||
// Top
|
||||
collector.addRect(
|
||||
x, y - halfH + halfB,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Bottom
|
||||
collector.addRect(
|
||||
x, y + halfH - halfB,
|
||||
width, borderWidth,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Left
|
||||
collector.addRect(
|
||||
x - halfW + halfB, y,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
|
||||
// Right
|
||||
collector.addRect(
|
||||
x + halfW - halfB, y,
|
||||
borderWidth, height - borderWidth * 2,
|
||||
borderColor, alpha, sortOrder,
|
||||
{ pivotX: 0.5, pivotY: 0.5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
322
packages/ui/src/systems/render/UITextRenderSystem.ts
Normal file
322
packages/ui/src/systems/render/UITextRenderSystem.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* UI Text Render System
|
||||
* UI 文本渲染系统
|
||||
*
|
||||
* Renders UITextComponent entities by generating text textures
|
||||
* and submitting them to the shared UIRenderCollector.
|
||||
* 通过生成文本纹理并提交到共享的 UIRenderCollector 来渲染 UITextComponent 实体。
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
|
||||
import { UITransformComponent } from '../../components/UITransformComponent';
|
||||
import { UITextComponent } from '../../components/UITextComponent';
|
||||
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
|
||||
|
||||
/**
|
||||
* Text texture cache entry
|
||||
* 文本纹理缓存条目
|
||||
*/
|
||||
interface TextTextureCache {
|
||||
textureId: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: string | number;
|
||||
italic: boolean;
|
||||
color: number;
|
||||
alpha: number;
|
||||
align: string;
|
||||
verticalAlign: string;
|
||||
lineHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Text Render System
|
||||
* UI 文本渲染系统
|
||||
*
|
||||
* Handles rendering of text components by:
|
||||
* 1. Generating text textures using Canvas 2D
|
||||
* 2. Caching textures to avoid regeneration every frame
|
||||
* 3. Submitting texture render primitives to the collector
|
||||
*
|
||||
* 处理文本组件的渲染:
|
||||
* 1. 使用 Canvas 2D 生成文本纹理
|
||||
* 2. 缓存纹理以避免每帧重新生成
|
||||
* 3. 向收集器提交纹理渲染原语
|
||||
*/
|
||||
@ECSSystem('UITextRender', { updateOrder: 120 })
|
||||
export class UITextRenderSystem extends EntitySystem {
|
||||
private textCanvas: HTMLCanvasElement | null = null;
|
||||
private textCtx: CanvasRenderingContext2D | null = null;
|
||||
private textTextureCache: Map<number, TextTextureCache> = new Map();
|
||||
private nextTextureId = 90000;
|
||||
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
|
||||
private cacheInvalidationBound: () => void;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(UITransformComponent, UITextComponent));
|
||||
// Bind the method for cache invalidation callback
|
||||
this.cacheInvalidationBound = this.clearTextCache.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when system is added to scene
|
||||
* 系统添加到场景时调用
|
||||
*/
|
||||
public override initialize(): void {
|
||||
super.initialize();
|
||||
// Register for cache invalidation events
|
||||
registerCacheInvalidationCallback(this.cacheInvalidationBound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when system is destroyed
|
||||
* 系统销毁时调用
|
||||
*/
|
||||
protected override onDestroy(): void {
|
||||
super.onDestroy();
|
||||
// Unregister cache invalidation callback
|
||||
unregisterCacheInvalidationCallback(this.cacheInvalidationBound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when a new text texture is created
|
||||
* 设置创建新文本纹理时的回调
|
||||
*/
|
||||
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
|
||||
this.onTextureCreated = callback;
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const collector = getUIRenderCollector();
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(UITransformComponent)!;
|
||||
const text = entity.getComponent(UITextComponent)!;
|
||||
|
||||
if (!transform.visible || !text.text) continue;
|
||||
|
||||
const x = transform.worldX ?? transform.x;
|
||||
const y = transform.worldY ?? transform.y;
|
||||
const width = (transform.computedWidth ?? transform.width) * transform.scaleX;
|
||||
const height = (transform.computedHeight ?? transform.height) * transform.scaleY;
|
||||
const alpha = transform.worldAlpha ?? transform.alpha;
|
||||
const baseOrder = 100 + transform.zIndex;
|
||||
|
||||
// Generate or retrieve cached texture
|
||||
// 生成或获取缓存的纹理
|
||||
const textureId = this.getOrCreateTextTexture(
|
||||
entity.id, text, Math.ceil(width), Math.ceil(height)
|
||||
);
|
||||
|
||||
if (textureId === null) continue;
|
||||
|
||||
// Use top-left position with origin at (0, 0)
|
||||
// 使用左上角位置,原点在 (0, 0)
|
||||
collector.addRect(
|
||||
x, y,
|
||||
width, height,
|
||||
0xFFFFFF, // White tint (color is baked into texture)
|
||||
alpha,
|
||||
baseOrder + 1, // Text renders above background
|
||||
{
|
||||
rotation: transform.rotation,
|
||||
pivotX: 0,
|
||||
pivotY: 0,
|
||||
textureId
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create text texture
|
||||
* 获取或创建文本纹理
|
||||
*/
|
||||
private getOrCreateTextTexture(
|
||||
entityId: number,
|
||||
text: UITextComponent,
|
||||
width: number,
|
||||
height: number
|
||||
): number | null {
|
||||
const canvasData = this.getTextCanvas();
|
||||
if (!canvasData) return null;
|
||||
|
||||
const { canvas, ctx } = canvasData;
|
||||
|
||||
const cached = this.textTextureCache.get(entityId);
|
||||
|
||||
// Check if we need to regenerate the texture
|
||||
// 检查是否需要重新生成纹理
|
||||
const needsUpdate = !cached ||
|
||||
cached.text !== text.text ||
|
||||
cached.fontSize !== text.fontSize ||
|
||||
cached.fontFamily !== text.fontFamily ||
|
||||
cached.fontWeight !== text.fontWeight ||
|
||||
cached.italic !== text.italic ||
|
||||
cached.color !== text.color ||
|
||||
cached.alpha !== text.alpha ||
|
||||
cached.align !== text.align ||
|
||||
cached.verticalAlign !== text.verticalAlign ||
|
||||
cached.lineHeight !== text.lineHeight ||
|
||||
cached.width !== width ||
|
||||
cached.height !== height;
|
||||
|
||||
if (needsUpdate) {
|
||||
const canvasWidth = Math.max(1, width);
|
||||
const canvasHeight = Math.max(1, height);
|
||||
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
ctx.font = text.getCSSFont();
|
||||
ctx.fillStyle = text.getCSSColor();
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
// Handle horizontal alignment
|
||||
// 处理水平对齐
|
||||
let textX = 0;
|
||||
if (text.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
textX = canvasWidth / 2;
|
||||
} else if (text.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
textX = canvasWidth;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
textX = 0;
|
||||
}
|
||||
|
||||
// Handle vertical alignment
|
||||
// 处理垂直对齐
|
||||
const textHeight = text.fontSize * text.lineHeight;
|
||||
let textY = 0;
|
||||
|
||||
if (text.verticalAlign === 'middle') {
|
||||
textY = (canvasHeight - textHeight) / 2;
|
||||
} else if (text.verticalAlign === 'bottom') {
|
||||
textY = canvasHeight - textHeight;
|
||||
}
|
||||
|
||||
// Draw text (with or without word wrap)
|
||||
// 绘制文本(带或不带自动换行)
|
||||
if (text.wordWrap) {
|
||||
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
|
||||
} else {
|
||||
ctx.fillText(text.text, textX, textY);
|
||||
}
|
||||
|
||||
// Get or create texture ID
|
||||
// 获取或创建纹理 ID
|
||||
const textureId = cached?.textureId ?? this.nextTextureId++;
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
// Notify callback of new texture
|
||||
// 通知回调新纹理
|
||||
if (this.onTextureCreated) {
|
||||
this.onTextureCreated(textureId, dataUrl);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
// 更新缓存
|
||||
this.textTextureCache.set(entityId, {
|
||||
textureId,
|
||||
text: text.text,
|
||||
fontSize: text.fontSize,
|
||||
fontFamily: text.fontFamily,
|
||||
fontWeight: text.fontWeight,
|
||||
italic: text.italic,
|
||||
color: text.color,
|
||||
alpha: text.alpha,
|
||||
align: text.align,
|
||||
verticalAlign: text.verticalAlign,
|
||||
lineHeight: text.lineHeight,
|
||||
width,
|
||||
height,
|
||||
dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
return this.textTextureCache.get(entityId)?.textureId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create text canvas
|
||||
* 获取或创建文本画布
|
||||
*/
|
||||
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
||||
if (!this.textCanvas) {
|
||||
this.textCanvas = document.createElement('canvas');
|
||||
this.textCtx = this.textCanvas.getContext('2d');
|
||||
}
|
||||
if (!this.textCtx) return null;
|
||||
return { canvas: this.textCanvas, ctx: this.textCtx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw text with word wrapping
|
||||
* 绘制带自动换行的文本
|
||||
*/
|
||||
private drawWrappedText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number
|
||||
): void {
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
let currentY = y;
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + word + ' ';
|
||||
const metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width > maxWidth && line !== '') {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
line = word + ' ';
|
||||
currentY += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
ctx.fillText(line.trim(), x, currentY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear text texture cache
|
||||
* 清除文本纹理缓存
|
||||
*/
|
||||
clearTextCache(): void {
|
||||
this.textTextureCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific entity
|
||||
* 清除特定实体的缓存
|
||||
*/
|
||||
clearEntityTextCache(entityId: number): void {
|
||||
this.textTextureCache.delete(entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose resources
|
||||
* 释放资源
|
||||
*/
|
||||
dispose(): void {
|
||||
this.textCanvas = null;
|
||||
this.textCtx = null;
|
||||
this.textTextureCache.clear();
|
||||
this.onTextureCreated = null;
|
||||
}
|
||||
}
|
||||
33
packages/ui/src/systems/render/index.ts
Normal file
33
packages/ui/src/systems/render/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* UI Render Systems
|
||||
* UI 渲染系统
|
||||
*
|
||||
* This module contains all UI render systems that follow ECS architecture.
|
||||
* Each system is responsible for rendering a specific type of UI component.
|
||||
*
|
||||
* 此模块包含所有遵循 ECS 架构的 UI 渲染系统。
|
||||
* 每个系统负责渲染特定类型的 UI 组件。
|
||||
*/
|
||||
|
||||
// Core render infrastructure
|
||||
// 核心渲染基础设施
|
||||
export {
|
||||
UIRenderCollector,
|
||||
getUIRenderCollector,
|
||||
resetUIRenderCollector,
|
||||
registerCacheInvalidationCallback,
|
||||
unregisterCacheInvalidationCallback,
|
||||
invalidateUIRenderCaches,
|
||||
type UIRenderPrimitive,
|
||||
type ProviderRenderData
|
||||
} from './UIRenderCollector';
|
||||
|
||||
// Render systems
|
||||
// 渲染系统
|
||||
export { UIRenderBeginSystem } from './UIRenderBeginSystem';
|
||||
export { UIRectRenderSystem } from './UIRectRenderSystem';
|
||||
export { UITextRenderSystem } from './UITextRenderSystem';
|
||||
export { UIButtonRenderSystem } from './UIButtonRenderSystem';
|
||||
export { UIProgressBarRenderSystem } from './UIProgressBarRenderSystem';
|
||||
export { UISliderRenderSystem } from './UISliderRenderSystem';
|
||||
export { UIScrollViewRenderSystem } from './UIScrollViewRenderSystem';
|
||||
Reference in New Issue
Block a user