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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,413 @@
import { Core, Entity } from '@esengine/ecs-framework';
import { UITransformComponent } from '../components/UITransformComponent';
import { UIRenderComponent } from '../components/UIRenderComponent';
import { UITextComponent } from '../components/UITextComponent';
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
export interface UIRenderData {
x: number;
y: number;
width: number;
height: number;
rotation: number;
originX: number;
originY: number;
backgroundColor: number;
backgroundAlpha: number;
borderColor: number;
borderWidth: number;
cornerRadius: number;
zIndex: number;
visible: boolean;
text?: {
content: string;
fontSize: number;
fontFamily: string;
color: number;
alpha: number;
align: string;
verticalAlign: string;
};
}
export interface ProviderRenderData {
transforms: Float32Array;
textureIds: Uint32Array;
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
sortingOrder: number;
texturePath?: string;
}
export interface IRenderDataProvider {
getRenderData(): readonly ProviderRenderData[];
}
interface TextTextureCache {
textureId: number;
text: string;
fontSize: number;
fontFamily: string;
fontWeight: string | number;
italic: boolean;
color: number;
alpha: number;
align: string;
verticalAlign: string;
lineHeight: number;
width: number;
height: number;
dataUrl: string;
}
export class UIRenderDataProvider implements IRenderDataProvider {
private textCanvas: HTMLCanvasElement | null = null;
private textCtx: CanvasRenderingContext2D | null = null;
private textTextureCache: Map<number, TextTextureCache> = new Map();
private nextTextureId = 90000;
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
this.onTextureCreated = callback;
}
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
if (!this.textCanvas) {
this.textCanvas = document.createElement('canvas');
this.textCtx = this.textCanvas.getContext('2d');
}
if (!this.textCtx) return null;
return { canvas: this.textCanvas, ctx: this.textCtx };
}
getRenderData(): readonly ProviderRenderData[] {
const scene = Core.scene;
if (!scene) return [];
const uiEntities: Entity[] = [];
for (const entity of scene.entities.buffer) {
if (entity.hasComponent(UITransformComponent)) {
uiEntities.push(entity);
}
}
if (uiEntities.length === 0) return [];
uiEntities.sort((a, b) => {
const ta = a.getComponent(UITransformComponent);
const tb = b.getComponent(UITransformComponent);
return (ta?.zIndex ?? 0) - (tb?.zIndex ?? 0);
});
const renderDataList: ProviderRenderData[] = [];
for (const entity of uiEntities) {
const transform = entity.getComponent(UITransformComponent);
const render = entity.getComponent(UIRenderComponent);
const text = entity.getComponent(UITextComponent);
const button = entity.getComponent(UIButtonComponent);
if (!transform || !transform.visible) continue;
const width = transform.width * transform.scaleX;
const height = transform.height * transform.scaleY;
const centerX = transform.x + width * transform.pivotX;
const centerY = transform.y + height * transform.pivotY;
// Button with texture support
if (button && button.useTexture()) {
const texture = button.getStateTexture('normal');
if (texture) {
const transforms = new Float32Array(7);
transforms[0] = centerX;
transforms[1] = centerY;
transforms[2] = transform.rotation;
transforms[3] = width;
transforms[4] = height;
transforms[5] = transform.pivotX;
transforms[6] = transform.pivotY;
const colors = new Uint32Array(1);
const a = Math.round(transform.alpha * 255);
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
renderDataList.push({
transforms,
textureIds: new Uint32Array([0]),
uvs: new Float32Array([0, 0, 1, 1]),
colors,
tileCount: 1,
sortingOrder: 100 + transform.zIndex,
texturePath: texture
});
}
}
// Background color rendering (for buttons in 'color' or 'both' mode, or regular UI elements)
const shouldRenderColor = button
? button.useColor() && render && render.backgroundAlpha > 0
: render && render.backgroundAlpha > 0;
if (shouldRenderColor && render) {
const transforms = new Float32Array(7);
transforms[0] = centerX;
transforms[1] = centerY;
transforms[2] = transform.rotation;
transforms[3] = width;
transforms[4] = height;
transforms[5] = transform.pivotX;
transforms[6] = transform.pivotY;
const colors = new Uint32Array(1);
const bgColor = button ? button.currentColor : render.backgroundColor;
const r = (bgColor >> 16) & 0xFF;
const g = (bgColor >> 8) & 0xFF;
const b = bgColor & 0xFF;
const a = Math.round(render.backgroundAlpha * transform.alpha * 255);
colors[0] = ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF);
renderDataList.push({
transforms,
textureIds: new Uint32Array([0]),
uvs: new Float32Array([0, 0, 1, 1]),
colors,
tileCount: 1,
sortingOrder: 100 + transform.zIndex
});
}
if (text && text.text) {
const textRenderData = this.createTextRenderData(
entity.id,
text,
centerX,
centerY,
width,
height,
transform
);
if (textRenderData) {
renderDataList.push(textRenderData);
}
}
}
return renderDataList;
}
private createTextRenderData(
entityId: number,
text: UITextComponent,
centerX: number,
centerY: number,
width: number,
height: number,
transform: UITransformComponent
): ProviderRenderData | null {
const canvasData = this.getTextCanvas();
if (!canvasData) return null;
const { canvas, ctx } = canvasData;
const cacheKey = entityId;
const cached = this.textTextureCache.get(cacheKey);
const needsUpdate = !cached ||
cached.text !== text.text ||
cached.fontSize !== text.fontSize ||
cached.fontFamily !== text.fontFamily ||
cached.fontWeight !== text.fontWeight ||
cached.italic !== text.italic ||
cached.color !== text.color ||
cached.alpha !== text.alpha ||
cached.align !== text.align ||
cached.verticalAlign !== text.verticalAlign ||
cached.lineHeight !== text.lineHeight ||
cached.width !== Math.ceil(width) ||
cached.height !== Math.ceil(height);
if (needsUpdate) {
const canvasWidth = Math.max(1, Math.ceil(width));
const canvasHeight = Math.max(1, Math.ceil(height));
canvas.width = canvasWidth;
canvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.font = text.getCSSFont();
ctx.fillStyle = text.getCSSColor();
ctx.textBaseline = 'top';
let textX = 0;
if (text.align === 'center') {
ctx.textAlign = 'center';
textX = canvasWidth / 2;
} else if (text.align === 'right') {
ctx.textAlign = 'right';
textX = canvasWidth;
} else {
ctx.textAlign = 'left';
textX = 0;
}
const metrics = ctx.measureText(text.text);
const textHeight = text.fontSize * text.lineHeight;
let textY = 0;
if (text.verticalAlign === 'middle') {
textY = (canvasHeight - textHeight) / 2;
} else if (text.verticalAlign === 'bottom') {
textY = canvasHeight - textHeight;
}
if (text.wordWrap) {
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
} else {
ctx.fillText(text.text, textX, textY);
}
const textureId = cached?.textureId ?? this.nextTextureId++;
const dataUrl = canvas.toDataURL('image/png');
if (this.onTextureCreated) {
this.onTextureCreated(textureId, dataUrl);
}
this.textTextureCache.set(cacheKey, {
textureId,
text: text.text,
fontSize: text.fontSize,
fontFamily: text.fontFamily,
fontWeight: text.fontWeight,
italic: text.italic,
color: text.color,
alpha: text.alpha,
align: text.align,
verticalAlign: text.verticalAlign,
lineHeight: text.lineHeight,
width: canvasWidth,
height: canvasHeight,
dataUrl
});
}
const cachedData = this.textTextureCache.get(cacheKey);
if (!cachedData) return null;
const transforms = new Float32Array(7);
transforms[0] = centerX;
transforms[1] = centerY;
transforms[2] = transform.rotation;
transforms[3] = width;
transforms[4] = height;
transforms[5] = transform.pivotX;
transforms[6] = transform.pivotY;
const colors = new Uint32Array(1);
const a = Math.round(transform.alpha * 255);
colors[0] = ((a & 0xFF) << 24) | (0xFF << 16) | (0xFF << 8) | 0xFF;
return {
transforms,
textureIds: new Uint32Array([cachedData.textureId]),
uvs: new Float32Array([0, 0, 1, 1]),
colors,
tileCount: 1,
sortingOrder: 101 + transform.zIndex
};
}
private drawWrappedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
): void {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const testLine = line + word + ' ';
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== '') {
ctx.fillText(line.trim(), x, currentY);
line = word + ' ';
currentY += lineHeight;
} else {
line = testLine;
}
}
if (line.trim()) {
ctx.fillText(line.trim(), x, currentY);
}
}
collectUIRenderData(): UIRenderData[] {
const scene = Core.scene;
if (!scene) return [];
const result: UIRenderData[] = [];
for (const entity of scene.entities.buffer) {
const transform = entity.getComponent(UITransformComponent);
if (!transform || !transform.visible) continue;
const render = entity.getComponent(UIRenderComponent);
const text = entity.getComponent(UITextComponent);
const data: UIRenderData = {
x: transform.x,
y: transform.y,
width: transform.width * transform.scaleX,
height: transform.height * transform.scaleY,
rotation: transform.rotation,
originX: transform.pivotX,
originY: transform.pivotY,
backgroundColor: render?.backgroundColor ?? 0,
backgroundAlpha: (render?.backgroundAlpha ?? 0) * transform.alpha,
borderColor: render?.borderColor ?? 0,
borderWidth: render?.borderWidth ?? 0,
cornerRadius: render?.borderRadius?.[0] ?? 0,
zIndex: transform.zIndex,
visible: transform.visible
};
if (text && text.text) {
data.text = {
content: text.text,
fontSize: text.fontSize,
fontFamily: text.fontFamily,
color: text.color,
alpha: text.alpha,
align: text.align,
verticalAlign: text.verticalAlign
};
}
result.push(data);
}
result.sort((a, b) => a.zIndex - b.zIndex);
return result;
}
clearTextCache(): void {
this.textTextureCache.clear();
}
dispose(): void {
this.textCanvas = null;
this.textCtx = null;
this.textTextureCache.clear();
this.onTextureCreated = null;
}
}

View File

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