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

@@ -9,7 +9,7 @@ import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystem } from '@esengine/ecs-components';
import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap';
import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree';
import { UIRenderDataProvider, invalidateUIRenderCaches } from '@esengine/ui';
import { UIRenderDataProvider, invalidateUIRenderCaches, UIInputSystem } from '@esengine/ui';
import * as esEngine from '@esengine/engine';
import {
AssetManager,
@@ -37,6 +37,7 @@ export class EngineService {
private tilemapSystem: TilemapRenderingSystem | null = null;
private behaviorTreeSystem: BehaviorTreeExecutionSystem | null = null;
private uiRenderProvider: UIRenderDataProvider | null = null;
private uiInputSystem: UIInputSystem | null = null;
private initialized = false;
private modulesInitialized = false;
private running = false;
@@ -244,6 +245,7 @@ export class EngineService {
this.tilemapSystem = context.tilemapSystem as TilemapRenderingSystem | undefined ?? null;
this.behaviorTreeSystem = context.behaviorTreeSystem as BehaviorTreeExecutionSystem | undefined ?? null;
this.uiRenderProvider = context.uiRenderProvider as UIRenderDataProvider | undefined ?? null;
this.uiInputSystem = context.uiInputSystem as UIInputSystem | undefined ?? null;
// 设置 UI 渲染数据提供者到 EngineRenderSystem
// Set UI render data provider to EngineRenderSystem
@@ -276,12 +278,19 @@ export class EngineService {
pluginManager.clearSceneSystems();
}
// Unbind UI input system before clearing
// 清理前解绑 UI 输入系统
if (this.uiInputSystem) {
this.uiInputSystem.unbind();
}
// 清空本地引用(系统的实际清理由场景管理)
// Clear local references (actual system cleanup is managed by scene)
this.animatorSystem = null;
this.tilemapSystem = null;
this.behaviorTreeSystem = null;
this.uiRenderProvider = null;
this.uiInputSystem = null;
this.modulesInitialized = false;
}
@@ -362,6 +371,15 @@ export class EngineService {
this.renderSystem.setPreviewMode(true);
}
// Bind UI input system to canvas for event handling
// 绑定 UI 输入系统到 canvas 以处理事件
if (this.uiInputSystem && this.canvasId) {
const canvas = document.getElementById(this.canvasId) as HTMLCanvasElement;
if (canvas) {
this.uiInputSystem.bindToCanvas(canvas);
}
}
// Enable animator system and start auto-play animations
// 启用动画系统并启动自动播放的动画
if (this.animatorSystem) {
@@ -435,6 +453,12 @@ export class EngineService {
this.renderSystem.setPreviewMode(false);
}
// Unbind UI input system from canvas
// 从 canvas 解绑 UI 输入系统
if (this.uiInputSystem) {
this.uiInputSystem.unbind();
}
// Disable animator system and stop all animations
// 禁用动画系统并停止所有动画
if (this.animatorSystem) {

View File

@@ -9,7 +9,7 @@ import { EngineBridge, EngineRenderSystem, CameraSystem } from '@esengine/ecs-en
import { TransformComponent, SpriteAnimatorSystem, CoreRuntimeModule } from '@esengine/ecs-components';
import type { SystemContext, IPluginLoader, IRuntimeModuleLoader, PluginDescriptor } from '@esengine/ecs-components';
// Import from /runtime entry points to avoid editor dependencies (React, etc.)
import { UIRuntimeModule, UIRenderDataProvider } from '@esengine/ui/runtime';
import { UIRuntimeModule, UIRenderDataProvider, UIInputSystem } from '@esengine/ui/runtime';
import { TilemapRuntimeModule, TilemapRenderingSystem } from '@esengine/tilemap/runtime';
import { BehaviorTreeRuntimeModule, BehaviorTreeExecutionSystem } from '@esengine/behavior-tree/runtime';
@@ -33,6 +33,8 @@ export interface RuntimeModuleConfig {
enabledPlugins?: string[];
/** 是否为编辑器模式 */
isEditor?: boolean;
/** Canvas ID 用于 UI 输入绑定 */
canvasId?: string;
}
/**
@@ -352,6 +354,15 @@ export function createRuntimeSystems(
scene.addSystem(renderSystem);
// 绑定 UIInputSystem 到 canvas用于 UI 交互)
// Bind UIInputSystem to canvas (for UI interaction)
if (config?.canvasId && context.uiInputSystem) {
const canvas = document.getElementById(config.canvasId) as HTMLCanvasElement;
if (canvas) {
(context.uiInputSystem as UIInputSystem).bindToCanvas(canvas);
}
}
return {
cameraSystem,
animatorSystem: context.animatorSystem as SpriteAnimatorSystem | undefined,

View File

@@ -21,8 +21,10 @@ class BrowserRuntime {
private animationId: number | null = null;
private assetManager: AssetManager;
private engineIntegration: EngineIntegration;
private canvasId: string;
constructor(config: RuntimeConfig) {
this.canvasId = config.canvasId;
if (!Core.Instance) {
Core.create();
}
@@ -58,8 +60,10 @@ class BrowserRuntime {
// 初始化模块系统
await initializeRuntime(Core);
// 创建运行时系统
this.systems = createRuntimeSystems(Core.scene!, this.bridge);
// 创建运行时系统(传入 canvasId 用于 UI 输入绑定)
this.systems = createRuntimeSystems(Core.scene!, this.bridge, {
canvasId: this.canvasId
});
}
async loadScene(sceneUrl: string): Promise<void> {

View File

@@ -20,6 +20,7 @@ import {
} from './components';
import { UILayoutSystem } from './systems/UILayoutSystem';
import { UIInputSystem } from './systems/UIInputSystem';
import { UIAnimationSystem } from './systems/UIAnimationSystem';
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
import {
UIRenderBeginSystem,
@@ -52,6 +53,9 @@ export class UIRuntimeModule implements IRuntimeModuleLoader {
const layoutSystem = new UILayoutSystem();
scene.addSystem(layoutSystem);
const animationSystem = new UIAnimationSystem();
scene.addSystem(animationSystem);
const renderBeginSystem = new UIRenderBeginSystem();
scene.addSystem(renderBeginSystem);
@@ -81,6 +85,7 @@ export class UIRuntimeModule implements IRuntimeModuleLoader {
const uiRenderProvider = new UIRenderDataProvider();
const inputSystem = new UIInputSystem();
inputSystem.setLayoutSystem(layoutSystem);
scene.addSystem(inputSystem);
context.uiLayoutSystem = layoutSystem;

View File

@@ -52,12 +52,16 @@ export class UIScrollViewComponent extends Component {
* 内容宽度
* Content width
*/
@Serialize()
@Property({ type: 'number', label: 'Content Width', min: 0 })
public contentWidth: number = 0;
/**
* 内容高度
* Content height
*/
@Serialize()
@Property({ type: 'number', label: 'Content Height', min: 0 })
public contentHeight: number = 0;
// ===== 滚动配置 Scroll Configuration =====

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;

16821
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff