Feature/UI input system fix (#243)

* feat(ui): 实现编辑器预览模式下的 UI 输入系统

* feat(platform-web): 为浏览器运行时添加 UI 输入系统绑定
This commit is contained in:
YHH
2025-11-27 22:31:05 +08:00
committed by GitHub
parent b8f05b79b0
commit cabb625a17
8 changed files with 9589 additions and 7594 deletions

View File

@@ -135,8 +135,8 @@ export type EasingName = keyof typeof Easing;
@ECSSystem('UIAnimation')
export class UIAnimationSystem extends EntitySystem {
constructor() {
// 匹配任何可能有动画组件
super(Matcher.empty());
// 匹配任何动画组件的实体
super(Matcher.empty().any(UIButtonComponent, UIProgressBarComponent, UISliderComponent));
}
/**

View File

@@ -3,6 +3,8 @@ import { UITransformComponent } from '../components/UITransformComponent';
import { UIInteractableComponent } from '../components/UIInteractableComponent';
import { UIButtonComponent } from '../components/widgets/UIButtonComponent';
import { UISliderComponent } from '../components/widgets/UISliderComponent';
import { UIScrollViewComponent } from '../components/widgets/UIScrollViewComponent';
import type { UILayoutSystem } from './UILayoutSystem';
/**
* 鼠标按钮
@@ -58,6 +60,23 @@ export class UIInputSystem extends EntitySystem {
private lastClickEntity: Entity | null = null;
private doubleClickThreshold: number = 300; // ms
// ===== 滚轮状态 Wheel State =====
private wheelDeltaX: number = 0;
private wheelDeltaY: number = 0;
private hasWheelEvent: boolean = false;
// ===== ScrollView 拖拽状态 ScrollView Drag State =====
private scrollViewDragTarget: Entity | null = null;
private scrollViewDragStartX: number = 0;
private scrollViewDragStartY: number = 0;
private scrollViewDragStartScrollX: number = 0;
private scrollViewDragStartScrollY: number = 0;
private scrollViewLastDragX: number = 0;
private scrollViewLastDragY: number = 0;
private scrollViewLastDragTime: number = 0;
// ===== 事件监听器 Event Listeners =====
private canvas: HTMLCanvasElement | null = null;
@@ -66,6 +85,11 @@ export class UIInputSystem extends EntitySystem {
private boundMouseUp: (e: MouseEvent) => void;
private boundWheel: (e: WheelEvent) => void;
// ===== UI 布局系统引用 UI Layout System Reference =====
// 用于获取 UI 画布尺寸以进行坐标转换
// Used to get UI canvas size for coordinate conversion
private layoutSystem: UILayoutSystem | null = null;
constructor() {
super(Matcher.empty().all(UITransformComponent, UIInteractableComponent));
@@ -75,6 +99,17 @@ export class UIInputSystem extends EntitySystem {
this.boundWheel = this.onWheel.bind(this);
}
/**
* 设置 UI 布局系统引用
* Set UI layout system reference
*
* 用于获取 UI 画布尺寸以进行坐标转换
* Used to get UI canvas size for coordinate conversion
*/
public setLayoutSystem(layoutSystem: UILayoutSystem): void {
this.layoutSystem = layoutSystem;
}
/**
* 绑定到 Canvas 元素
* Bind to canvas element
@@ -128,7 +163,30 @@ export class UIInputSystem extends EntitySystem {
private onMouseMove(e: MouseEvent): void {
const rect = this.canvas!.getBoundingClientRect();
this.setMousePosition(e.clientX - rect.left, e.clientY - rect.top);
// 屏幕坐标CSS 像素,左上角为 0,0Y 向下)
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
// 获取 UI 画布尺寸
const uiCanvasSize = this.layoutSystem?.getCanvasSize() ?? { width: 1920, height: 1080 };
// 转换为 UI 世界坐标(中心为 0,0Y 向上)
// UI 坐标系:左上角是 (-width/2, +height/2),右下角是 (+width/2, -height/2)
// 屏幕坐标系:左上角是 (0, 0),右下角是 (canvasWidth, canvasHeight)
const canvasWidth = rect.width;
const canvasHeight = rect.height;
// 屏幕坐标归一化到 [0, 1]
const normalizedX = screenX / canvasWidth;
const normalizedY = screenY / canvasHeight;
// 转换为 UI 世界坐标
// X: 0 -> -uiCanvasWidth/2, 1 -> +uiCanvasWidth/2
// Y: 0 -> +uiCanvasHeight/2 (顶部), 1 -> -uiCanvasHeight/2 (底部)
const worldX = (normalizedX - 0.5) * uiCanvasSize.width;
const worldY = (0.5 - normalizedY) * uiCanvasSize.height;
this.setMousePosition(worldX, worldY);
}
private onMouseDown(e: MouseEvent): void {
@@ -139,11 +197,17 @@ export class UIInputSystem extends EntitySystem {
this.setMouseButton(e.button as MouseButton, false);
}
private onWheel(_e: WheelEvent): void {
// TODO: 处理滚轮事件
private onWheel(e: WheelEvent): void {
e.preventDefault();
this.wheelDeltaX = e.deltaX;
this.wheelDeltaY = e.deltaY;
this.hasWheelEvent = true;
}
protected process(entities: readonly Entity[]): void {
// 如果没有绑定 canvas不处理输入
if (!this.canvas) return;
const dt = Time.deltaTime;
// 按 zIndex 从高到低排序,确保上层元素优先处理
@@ -214,6 +278,7 @@ export class UIInputSystem extends EntitySystem {
// 处理特殊控件
this.handleSlider(entity);
this.handleButton(entity, interactable);
this.handleScrollView(entity, transform);
// 阻止事件传递到下层
if (interactable.blockEvents) {
@@ -230,6 +295,9 @@ export class UIInputSystem extends EntitySystem {
if (interactable.pressed && !this.mouseButtons[MouseButton.Left]) {
interactable.pressed = false;
}
// 即使鼠标不在元素上,也需要更新按钮的目标颜色(恢复到 normal
this.handleButton(entity, interactable);
}
}
@@ -291,6 +359,13 @@ export class UIInputSystem extends EntitySystem {
this.lastClickTime = 0;
} else {
interactable.onClick?.();
// 如果是按钮,也调用按钮的 onClick
const button = entity.getComponent(UIButtonComponent);
if (button && !button.disabled) {
button.onClick?.();
}
this.lastClickEntity = entity;
this.lastClickTime = now;
}
@@ -344,8 +419,11 @@ export class UIInputSystem extends EntitySystem {
const button = entity.getComponent(UIButtonComponent);
if (!button || button.disabled) return;
// 更新目标颜色
button.targetColor = button.getStateColor(interactable.getState());
// 更新目标颜色和当前颜色
const stateColor = button.getStateColor(interactable.getState());
button.targetColor = stateColor;
// 直接设置 currentColor 以便立即看到效果(动画系统会平滑过渡)
button.currentColor = stateColor;
// 处理长按
if (interactable.pressed) {
@@ -365,6 +443,220 @@ export class UIInputSystem extends EntitySystem {
}
}
private handleScrollView(entity: Entity, transform: UITransformComponent): void {
const scrollView = entity.getComponent(UIScrollViewComponent);
if (!scrollView) return;
const viewportWidth = transform.computedWidth;
const viewportHeight = transform.computedHeight;
const maxScrollX = scrollView.getMaxScrollX(viewportWidth);
const maxScrollY = scrollView.getMaxScrollY(viewportHeight);
// 处理滚轮事件
if (this.hasWheelEvent) {
let deltaX = 0;
let deltaY = 0;
if (scrollView.verticalScroll && maxScrollY > 0) {
deltaY = this.wheelDeltaY * (scrollView.wheelSpeed / 40);
}
if (scrollView.horizontalScroll && maxScrollX > 0) {
deltaX = this.wheelDeltaX * (scrollView.wheelSpeed / 40);
}
if (deltaX !== 0 || deltaY !== 0) {
if (scrollView.smoothScroll) {
scrollView.targetScrollX = this.clampScroll(scrollView.targetScrollX + deltaX, maxScrollX);
scrollView.targetScrollY = this.clampScroll(scrollView.targetScrollY + deltaY, maxScrollY);
} else {
scrollView.scrollX = this.clampScroll(scrollView.scrollX + deltaX, maxScrollX);
scrollView.scrollY = this.clampScroll(scrollView.scrollY + deltaY, maxScrollY);
scrollView.targetScrollX = scrollView.scrollX;
scrollView.targetScrollY = scrollView.scrollY;
}
scrollView.velocityX = 0;
scrollView.velocityY = 0;
}
this.hasWheelEvent = false;
this.wheelDeltaX = 0;
this.wheelDeltaY = 0;
}
// 处理内容拖拽滚动
const isLeftPressed = this.mouseButtons[MouseButton.Left]!;
const wasLeftPressed = this.prevMouseButtons[MouseButton.Left]!;
if (isLeftPressed && !wasLeftPressed && !scrollView.dragging) {
// 开始拖拽
scrollView.dragging = true;
scrollView.dragStartScrollX = scrollView.scrollX;
scrollView.dragStartScrollY = scrollView.scrollY;
this.scrollViewDragTarget = entity;
this.scrollViewDragStartX = this.mouseX;
this.scrollViewDragStartY = this.mouseY;
this.scrollViewDragStartScrollX = scrollView.scrollX;
this.scrollViewDragStartScrollY = scrollView.scrollY;
this.scrollViewLastDragX = this.mouseX;
this.scrollViewLastDragY = this.mouseY;
this.scrollViewLastDragTime = performance.now();
scrollView.velocityX = 0;
scrollView.velocityY = 0;
}
if (scrollView.dragging && this.scrollViewDragTarget === entity) {
if (isLeftPressed) {
// 拖拽中
const dragDeltaX = this.mouseX - this.scrollViewDragStartX;
const dragDeltaY = this.mouseY - this.scrollViewDragStartY;
let newScrollX = this.scrollViewDragStartScrollX - dragDeltaX;
let newScrollY = this.scrollViewDragStartScrollY - dragDeltaY;
if (scrollView.horizontalScroll) {
if (scrollView.elasticBounds) {
newScrollX = this.applyElasticBounds(newScrollX, maxScrollX, scrollView.elasticity);
} else {
newScrollX = this.clampScroll(newScrollX, maxScrollX);
}
scrollView.scrollX = newScrollX;
scrollView.targetScrollX = newScrollX;
}
if (scrollView.verticalScroll) {
if (scrollView.elasticBounds) {
newScrollY = this.applyElasticBounds(newScrollY, maxScrollY, scrollView.elasticity);
} else {
newScrollY = this.clampScroll(newScrollY, maxScrollY);
}
scrollView.scrollY = newScrollY;
scrollView.targetScrollY = newScrollY;
}
// 计算速度(用于惯性)
const now = performance.now();
const timeDelta = (now - this.scrollViewLastDragTime) / 1000;
if (timeDelta > 0) {
scrollView.velocityX = (this.scrollViewLastDragX - this.mouseX) / timeDelta;
scrollView.velocityY = (this.scrollViewLastDragY - this.mouseY) / timeDelta;
}
this.scrollViewLastDragX = this.mouseX;
this.scrollViewLastDragY = this.mouseY;
this.scrollViewLastDragTime = now;
} else {
// 结束拖拽
scrollView.dragging = false;
this.scrollViewDragTarget = null;
// 应用惯性
if (!scrollView.inertia) {
scrollView.velocityX = 0;
scrollView.velocityY = 0;
}
}
}
// 更新惯性滚动
this.updateScrollViewInertia(scrollView, maxScrollX, maxScrollY);
// 平滑滚动到目标位置
this.updateScrollViewSmooth(scrollView);
}
private clampScroll(value: number, max: number): number {
return Math.max(0, Math.min(max, value));
}
private applyElasticBounds(value: number, max: number, elasticity: number): number {
if (value < 0) {
return value * elasticity;
}
if (value > max) {
return max + (value - max) * elasticity;
}
return value;
}
private updateScrollViewInertia(scrollView: UIScrollViewComponent, maxScrollX: number, maxScrollY: number): void {
if (scrollView.dragging) return;
const dt = Time.deltaTime;
const deceleration = scrollView.decelerationRate;
const minVelocity = 10;
// 应用减速
if (Math.abs(scrollView.velocityX) > minVelocity || Math.abs(scrollView.velocityY) > minVelocity) {
scrollView.velocityX *= Math.pow(deceleration, dt * 60);
scrollView.velocityY *= Math.pow(deceleration, dt * 60);
// 更新滚动位置
if (scrollView.horizontalScroll) {
scrollView.scrollX += scrollView.velocityX * dt;
scrollView.targetScrollX = scrollView.scrollX;
}
if (scrollView.verticalScroll) {
scrollView.scrollY += scrollView.velocityY * dt;
scrollView.targetScrollY = scrollView.scrollY;
}
// 停止条件
if (Math.abs(scrollView.velocityX) < minVelocity) scrollView.velocityX = 0;
if (Math.abs(scrollView.velocityY) < minVelocity) scrollView.velocityY = 0;
}
// 弹回边界
if (scrollView.elasticBounds && !scrollView.dragging) {
const bounceSpeed = 8;
if (scrollView.scrollX < 0) {
scrollView.scrollX += (0 - scrollView.scrollX) * bounceSpeed * dt;
scrollView.targetScrollX = scrollView.scrollX;
if (Math.abs(scrollView.scrollX) < 0.5) scrollView.scrollX = 0;
} else if (scrollView.scrollX > maxScrollX) {
scrollView.scrollX += (maxScrollX - scrollView.scrollX) * bounceSpeed * dt;
scrollView.targetScrollX = scrollView.scrollX;
if (Math.abs(scrollView.scrollX - maxScrollX) < 0.5) scrollView.scrollX = maxScrollX;
}
if (scrollView.scrollY < 0) {
scrollView.scrollY += (0 - scrollView.scrollY) * bounceSpeed * dt;
scrollView.targetScrollY = scrollView.scrollY;
if (Math.abs(scrollView.scrollY) < 0.5) scrollView.scrollY = 0;
} else if (scrollView.scrollY > maxScrollY) {
scrollView.scrollY += (maxScrollY - scrollView.scrollY) * bounceSpeed * dt;
scrollView.targetScrollY = scrollView.scrollY;
if (Math.abs(scrollView.scrollY - maxScrollY) < 0.5) scrollView.scrollY = maxScrollY;
}
} else {
// 硬边界限制
scrollView.scrollX = this.clampScroll(scrollView.scrollX, maxScrollX);
scrollView.scrollY = this.clampScroll(scrollView.scrollY, maxScrollY);
}
}
private updateScrollViewSmooth(scrollView: UIScrollViewComponent): void {
if (scrollView.dragging) return;
if (scrollView.velocityX !== 0 || scrollView.velocityY !== 0) return;
const dt = Time.deltaTime;
const smoothSpeed = 1 / Math.max(0.01, scrollView.smoothScrollDuration);
const diffX = scrollView.targetScrollX - scrollView.scrollX;
const diffY = scrollView.targetScrollY - scrollView.scrollY;
if (Math.abs(diffX) > 0.5) {
scrollView.scrollX += diffX * smoothSpeed * dt;
} else {
scrollView.scrollX = scrollView.targetScrollX;
}
if (Math.abs(diffY) > 0.5) {
scrollView.scrollY += diffY * smoothSpeed * dt;
} else {
scrollView.scrollY = scrollView.targetScrollY;
}
}
private updateCursor(hoveredEntity: Entity | null): void {
if (!this.canvas) return;