feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)
This commit is contained in:
282
packages/ui/src/systems/UIAnimationSystem.ts
Normal file
282
packages/ui/src/systems/UIAnimationSystem.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
435
packages/ui/src/systems/UIInputSystem.ts
Normal file
435
packages/ui/src/systems/UIInputSystem.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
444
packages/ui/src/systems/UILayoutSystem.ts
Normal file
444
packages/ui/src/systems/UILayoutSystem.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
413
packages/ui/src/systems/UIRenderDataProvider.ts
Normal file
413
packages/ui/src/systems/UIRenderDataProvider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
packages/ui/src/systems/index.ts
Normal file
4
packages/ui/src/systems/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './UILayoutSystem';
|
||||
export * from './UIInputSystem';
|
||||
export * from './UIAnimationSystem';
|
||||
export * from './UIRenderDataProvider';
|
||||
Reference in New Issue
Block a user