import * as THREE from "three"; /** * Raycaster 射线检测命中结果 */ export interface HitResult { /** 命中物体所属的路径索引(来自 userData.pathIndex) */ pathIndex: number; /** 命中物体所属的格子索引(来自 userData.cellIndex,可选) */ cellIndex: number; /** 命中点的世界坐标 */ worldPoint: THREE.Vector3; /** 命中的原始 Object3D */ object: THREE.Object3D; } export type OnCellTapCallback = (hit: HitResult) => void; /** * Three.js 3D 输入处理器配置 */ export interface InputHandler3DConfig { /** Tap 判定阈值:pointer 移动小于此像素数才算 tap(默认 8) */ tapThreshold?: number; /** Tap 最大时长:pointerdown→pointerup 小于此毫秒才算 tap(默认 500) */ tapTimeMax?: number; } const DEFAULT_CONFIG: Required = { tapThreshold: 8, tapTimeMax: 500, }; /** * Three.js Raycaster 输入处理器。 * * 核心功能: * - Pointer 事件 → Raycaster 射线检测 → 命中回调 * - **Tap/Drag 区分**:记录 pointerdown 位置,只有移动 < tapThreshold * 且时间 < tapTimeMax 时才视为 tap,避免与 OrbitControls 拖动冲突 * - enabled 开关用于 inputLocked 场景 * * 与 OrbitControls 共存模式: * - OrbitControls 绑定在 canvas 上处理拖动/旋转 * - InputHandler3D 绑定 pointerdown+pointerup,只在 tap 时触发 raycast * - 两者互不干扰 */ export class InputHandler3D { private camera: THREE.PerspectiveCamera; private container: HTMLElement; private config: Required; private raycaster = new THREE.Raycaster(); private mouse = new THREE.Vector2(); private selectableObjects: THREE.Object3D[] = []; private onCellTap: OnCellTapCallback | null = null; private enabled = true; // Tap detection state private pointerDownPos = { x: 0, y: 0 }; private pointerDownTime = 0; // Bound event handlers (for cleanup) private boundPointerDown: ((e: PointerEvent) => void) | null = null; private boundPointerUp: ((e: PointerEvent) => void) | null = null; constructor( camera: THREE.PerspectiveCamera, container: HTMLElement, config?: InputHandler3DConfig, ) { this.camera = camera; this.container = container; this.config = { ...DEFAULT_CONFIG, ...config }; } /** * 设置可点击的物体列表。 * 每个物体(或其祖先 Group)的 userData 需包含 { pathIndex: number }。 * 可选包含 { cellIndex: number }。 */ setSelectableObjects(objects: THREE.Object3D[]): void { this.selectableObjects = objects; } /** * 注册 tap 回调 */ setOnCellTap(callback: OnCellTapCallback): void { this.onCellTap = callback; } /** * 绑定事件监听(pointerdown + pointerup) */ bindEvents(): void { this.boundPointerDown = (e: PointerEvent) => this.handlePointerDown(e); this.boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); this.container.addEventListener("pointerdown", this.boundPointerDown); this.container.addEventListener("pointerup", this.boundPointerUp); } /** * 启用/禁用输入(用于 inputLocked 等场景) */ setEnabled(enabled: boolean): void { this.enabled = enabled; } /** * 获取启用状态 */ isEnabled(): boolean { return this.enabled; } /** * 执行射线检测(可直接调用进行手动 raycast) * @param screenX 屏幕 X 坐标 (clientX) * @param screenY 屏幕 Y 坐标 (clientY) * @returns 命中结果或 null */ raycast(screenX: number, screenY: number): HitResult | null { const rect = this.container.getBoundingClientRect(); this.mouse.x = ((screenX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((screenY - rect.top) / rect.height) * 2 + 1; this.raycaster.setFromCamera(this.mouse, this.camera); // Collect all Meshes from selectable objects (traverse Groups) const meshes: THREE.Mesh[] = []; for (const obj of this.selectableObjects) { obj.traverse((child) => { if (child instanceof THREE.Mesh) { meshes.push(child); } }); } const intersects = this.raycaster.intersectObjects(meshes, false); if (intersects.length === 0) return null; const hit = intersects[0]; // Walk up the parent chain to find userData with pathIndex let current: THREE.Object3D | null = hit.object; while (current) { if (current.userData && typeof current.userData.pathIndex === "number") { return { pathIndex: current.userData.pathIndex, cellIndex: current.userData.cellIndex ?? -1, worldPoint: hit.point.clone(), object: current, }; } current = current.parent; } return null; } /** * 销毁事件监听 */ destroy(): void { if (this.boundPointerDown) { this.container.removeEventListener("pointerdown", this.boundPointerDown); this.boundPointerDown = null; } if (this.boundPointerUp) { this.container.removeEventListener("pointerup", this.boundPointerUp); this.boundPointerUp = null; } this.selectableObjects = []; this.onCellTap = null; } // ── 内部方法 ── private handlePointerDown(e: PointerEvent): void { this.pointerDownPos = { x: e.clientX, y: e.clientY }; this.pointerDownTime = performance.now(); } private handlePointerUp(e: PointerEvent): void { if (!this.enabled) return; // Tap/Drag discrimination const dx = e.clientX - this.pointerDownPos.x; const dy = e.clientY - this.pointerDownPos.y; const distance = Math.sqrt(dx * dx + dy * dy); const elapsed = performance.now() - this.pointerDownTime; // Only treat as tap if pointer barely moved and was quick if (distance > this.config.tapThreshold || elapsed > this.config.tapTimeMax) { return; // It was a drag/orbit, not a tap } // It's a tap → raycast const hit = this.raycast(e.clientX, e.clientY); if (hit && this.onCellTap) { this.onCellTap(hit); } } }