import * as Phaser from "phaser"; import type { ParsedLevel } from "../../levels/LevelTypes"; import { DEFAULT_ZOOM_STEP, GameConfig } from "../../GameConfig"; import { CHALLENGE_SEGMENTS_MODE } from "../../DebugConfig"; import { GAME_TOP_UI_BAR_HEIGHT } from "./GameTopUI"; import { safePlaySound } from "../../utils/SoundUtils"; /** 连击音效最大等级 */ const COMBO_SOUND_MAX_LEVEL = 9; /** * 描述 InputHandler 从 Game 场景中所需内容的最小接口。 * 避免模块级别的循环导入。 */ export interface InputGameRef extends Phaser.Scene { level: ParsedLevel | null; boardContainer: Phaser.GameObjects.Container; boardGraphics: Phaser.GameObjects.Graphics; cellSize: number; cellWidth: number; cellHeight: number; zoom: number; minZoom: number; maxZoom: number; boardOffsetX: number; boardOffsetY: number; removedPaths: Set; blockedPaths: Set; animatingPaths: Set; isInputLocked: boolean; introPlaying: boolean; isLevelOne: boolean; isLevelTwo: boolean; currentLives: number; zoomTween: Phaser.Tweens.Tween | null; gameOverPanel: any; comboManager: { clickCount: number; lastSuccessAtMs: number; chainWindowMs: number; resetState(): void; hideComboUi(): void; handleComboUiOnSuccess(): void; maybeShowComboPraiseText( count: number, x: number, y: number, hasPos: boolean, ): void; }; tutorialManager: { isLevelTwoZoomGuideActive: boolean; isLevelOneTutorialActive: boolean; levelOneTutorialExpectedPathIndex: number | null; hintTargetIndex: number | null; hideLevelTwoZoomGuide(): void; advanceLevelOneTutorial(): void; clearHintHighlight(): void; }; settings: { soundOn: boolean; getDragSpeedMultiplier(): number; }; levelLifecycle: { loseLife(): void; playDangerFrame(): void; handleLevelSuccess(): void; handleTimeOut(): void; }; animationManager: { animatePathOffBoard( pathIndex: number, dx: number, dy: number, stepsToExit: number, isLastPath: boolean, onComplete: () => void, ): void; animateBlockedPath( pathIndex: number, dx: number, dy: number, stepsUntilBlock: number, ): void; resetBoardViewForWin(): void; playWinDiamondAnimation(): void; }; topUI?: { startTimer(): void; }; runFlags: { kind: string }; // InputHandler 需要的 Game 方法 cellCenter(col: number, row: number): { x: number; y: number }; directionToDelta(direction: string): { dx: number; dy: number }; isCellOccupiedByAnyPath( col: number, row: number, ignorePathIndex?: number, ): boolean; positionBoardContainer(): void; relayoutBoard(): void; } /** * 输入事件处理器。 * 负责处理鼠标/触摸的点击、拖拽、缩放等输入事件, * 以及棋盘上箭头路径的命中检测和移动逻辑。 */ export class InputHandler { private scene: InputGameRef; // 指针/拖拽状态 private isPointerDown = false; private isDraggingBoard = false; private dragStartWorldX = 0; private dragStartWorldY = 0; private isPointerDownOnUI = false; // 点击位置(用于连击表扬文本定位) private lastTapScreenX = 0; private lastTapScreenY = 0; private hasLastTapScreenPos = false; // 双指缩放状态 private pinchZoomActive = false; private pinchStartDistance = 0; private pinchStartZoom = 1; private pinchZoomUsedInGesture = false; private lastPinchEndTime = 0; // 引导线 private guideLinesGraphics: Phaser.GameObjects.Graphics | null = null; private guideLinesVisible = false; // 操作追踪 private actionNumbers = 0; private firstInteractionInstallTimer: Phaser.Time.TimerEvent | null = null; // 计时器是否已启动标记 private hasTimerStarted = false; constructor(scene: InputGameRef) { this.scene = scene; } /** * 重置所有输入相关状态。在 create() 开始时调用。 */ resetState(): void { this.isPointerDown = false; this.isDraggingBoard = false; this.isPointerDownOnUI = false; this.pinchZoomActive = false; this.pinchStartDistance = 0; this.pinchZoomUsedInGesture = false; this.lastPinchEndTime = 0; this.hasLastTapScreenPos = false; this.hasTimerStarted = false; this.actionNumbers = 0; this.firstInteractionInstallTimer = null; } /** * 绑定所有输入事件(wheel、pointerdown、pointermove、pointerup、pointerupoutside)。 * 在 create() 中棋盘构建完成后调用。 */ bindEvents(): void { const scene = this.scene; // 鼠标滚轮缩放 scene.input.on( "wheel", ( pointer: Phaser.Input.Pointer, _over: unknown[], _dx: number, dy: number, ) => { if (scene.introPlaying) return; if (this.isPointerOnUI(pointer)) return; const baseStep = DEFAULT_ZOOM_STEP; const step = dy > 0 ? -baseStep : baseStep; const targetZoom = Phaser.Math.Clamp( scene.zoom + step, scene.minZoom, scene.maxZoom, ); this.tweenZoomTo(targetZoom); if (scene.tutorialManager.isLevelTwoZoomGuideActive) { scene.tutorialManager.hideLevelTwoZoomGuide(); } }, ); // 指针按下:开始拖拽或双指缩放 scene.input.on("pointerdown", (pointer: Phaser.Input.Pointer) => { if (scene.introPlaying) return; const allPointers = (scene.input.manager.pointers as Phaser.Input.Pointer[]) || []; const activePointers = allPointers.filter((p) => p.isDown); if (activePointers.length >= 2) { const p1 = activePointers[0]; const p2 = activePointers[1]; if (p1 && p2) { this.pinchZoomActive = true; this.pinchStartDistance = Phaser.Math.Distance.Between( p1.x, p1.y, p2.x, p2.y, ); this.pinchStartZoom = scene.zoom; this.pinchZoomUsedInGesture = true; this.isPointerDown = false; this.isDraggingBoard = false; return; } } this.isPointerDownOnUI = this.isPointerOnUI(pointer); if (this.isPointerDownOnUI) { this.isPointerDown = false; this.isDraggingBoard = false; return; } this.isPointerDown = true; this.isDraggingBoard = false; this.dragStartWorldX = pointer.worldX; this.dragStartWorldY = pointer.worldY; }); // 指针移动:拖拽棋盘或双指缩放 scene.input.on("pointermove", (pointer: Phaser.Input.Pointer) => { const allPointers = (scene.input.manager.pointers as Phaser.Input.Pointer[]) || []; const activePointers = allPointers.filter((p) => p.isDown); if (this.pinchZoomActive && activePointers.length >= 2) { const p1 = activePointers[0]; const p2 = activePointers[1]; if (p1 && p2) { const newDistance = Phaser.Math.Distance.Between( p1.x, p1.y, p2.x, p2.y, ); if (this.pinchStartDistance > 0) { const scaleFactor = newDistance / this.pinchStartDistance; const targetZoom = this.pinchStartZoom * scaleFactor; this.tweenZoomTo(targetZoom); this.pinchZoomUsedInGesture = true; if (scene.tutorialManager.isLevelTwoZoomGuideActive) { scene.tutorialManager.hideLevelTwoZoomGuide(); } } } return; } if (!this.isPointerDown) return; if (!scene.boardContainer) return; const dxWorld = pointer.worldX - this.dragStartWorldX; const dyWorld = pointer.worldY - this.dragStartWorldY; const dragThreshold = 5; if (!this.isDraggingBoard) { if ( Math.abs(dxWorld) < dragThreshold && Math.abs(dyWorld) < dragThreshold ) { return; } this.isDraggingBoard = true; } this.dragStartWorldX = pointer.worldX; this.dragStartWorldY = pointer.worldY; const dragSpeed = scene.settings.getDragSpeedMultiplier(); scene.boardOffsetX += dxWorld * dragSpeed; scene.boardOffsetY += dyWorld * dragSpeed; scene.positionBoardContainer(); }); // 指针释放:结束拖拽或触发点击 scene.input.on("pointerup", (pointer: Phaser.Input.Pointer) => { const allPointers = (scene.input.manager.pointers as Phaser.Input.Pointer[]) || []; const activePointers = allPointers.filter((p) => p.isDown); const wasDragging = this.isDraggingBoard; const wasOnUI = this.isPointerDownOnUI; const wasPinchUsed = this.pinchZoomUsedInGesture; this.isPointerDownOnUI = false; this.isPointerDown = false; this.isDraggingBoard = false; if (activePointers.length < 2) { this.pinchZoomActive = false; this.pinchStartDistance = 0; } // 仅当所有指针都释放时,才认为多指手势结束 if (activePointers.length === 0) { if (wasPinchUsed) { this.lastPinchEndTime = scene.time.now; } this.pinchZoomUsedInGesture = false; } // 来自UI区域的指针不触发棋盘点击 if (wasOnUI || this.isPointerOnUI(pointer)) return; // 如果本次手势使用了双指缩放,跳过棋盘点击 if (wasPinchUsed) return; // 忽略双指缩放结束后立即发生的点击 const SAFE_PINCH_TAP_INTERVAL = 250; if ( this.lastPinchEndTime > 0 && pointer.downTime - this.lastPinchEndTime <= SAFE_PINCH_TAP_INTERVAL ) { return; } // 如果未发生拖拽,视为点击 if (!wasDragging) { this.handlePointerUp(pointer); } }); // 指针在画布外释放 scene.input.on("pointerupoutside", () => { if (this.pinchZoomUsedInGesture) { this.lastPinchEndTime = scene.time.now; } this.isPointerDownOnUI = false; this.isPointerDown = false; this.isDraggingBoard = false; this.pinchZoomActive = false; this.pinchStartDistance = 0; this.pinchZoomUsedInGesture = false; }); } // ── 私有辅助方法 ── /** * 判断指针是否在UI区域上(顶部栏或底部栏)。 * @param pointer 指针对象 * @returns 是否在UI区域上 */ private isPointerOnUI(pointer: Phaser.Input.Pointer): boolean { const scene = this.scene; if (scene.isInputLocked || scene.gameOverPanel) return true; const height = scene.scale.height; const BASE_HEIGHT = 1334; const scaleY = height / BASE_HEIGHT; const topBarHeight = GAME_TOP_UI_BAR_HEIGHT * scaleY; const bottomBarHeight = GameConfig.GAME_BOTTOM_UI_BAR_HEIGHT * scaleY; const y = pointer.y; if (y <= topBarHeight) return true; if (y >= height - bottomBarHeight) return true; return false; } /** * 将指针的世界坐标转换为棋盘本地坐标。 * @param pointer 指针对象 * @returns 本地坐标对象,如果棋盘容器不存在则返回 null */ private getBoardLocalPos( pointer: Phaser.Input.Pointer, ): { localX: number; localY: number } | null { const scene = this.scene; if (!scene.boardContainer) return null; const worldX = pointer.worldX - scene.boardContainer.x; const worldY = pointer.worldY - scene.boardContainer.y; const localX = worldX / scene.zoom; const localY = worldY / scene.zoom; return { localX, localY }; } /** * 根据本地坐标命中检测棋盘上的箭头路径。 * 优先级:箭头头部命中 > 路径体命中 > 最近箭头头部(在半径内)。 * @param level 关卡数据 * @param localX 本地X坐标 * @param localY 本地Y坐标 * @returns 命中的路径索引,未命中返回 null */ private pickGridPathIndexAt( level: ParsedLevel, localX: number, localY: number, ): number | null { const scene = this.scene; const col = Math.floor(localX / scene.cellWidth); const row = Math.floor(localY / scene.cellHeight); if (col < 0 || row < 0 || col >= level.cols || row >= level.rows) { return null; } const cellIndex = row * level.cols + col; let headHitIndex: number | null = null; let bodyHitIndex: number | null = null; let nearestHeadIndex: number | null = null; let nearestHeadDistSq = Number.POSITIVE_INFINITY; for (let i = 0; i < level.paths.length; i++) { if (scene.removedPaths.has(i)) continue; const path = level.paths[i]!; const head = path.head; const headCenter = scene.cellCenter(head.x, head.y); const dxHead = localX - headCenter.x; const dyHead = localY - headCenter.y; const distSqHead = dxHead * dxHead + dyHead * dyHead; if (distSqHead < nearestHeadDistSq) { nearestHeadDistSq = distSqHead; nearestHeadIndex = i; } if (head.x === col && head.y === row) { headHitIndex = i; } if (path.indices.includes(cellIndex)) { bodyHitIndex = bodyHitIndex === null ? i : bodyHitIndex; } } if (headHitIndex !== null) return headHitIndex; if (bodyHitIndex !== null) return bodyHitIndex; if (nearestHeadIndex !== null && Number.isFinite(nearestHeadDistSq)) { const radius = scene.cellSize * 0.9; if (nearestHeadDistSq <= radius * radius) { return nearestHeadIndex; } } return null; } /** * 平滑缩放到目标值。缩放回最小值时自动居中棋盘。 * @param targetZoom 目标缩放值 */ private tweenZoomTo(targetZoom: number): void { const scene = this.scene; const clamped = Phaser.Math.Clamp(targetZoom, scene.minZoom, scene.maxZoom); if (Math.abs(clamped - scene.zoom) < 0.0001) { scene.zoom = clamped; scene.positionBoardContainer(); return; } if (scene.zoomTween) { scene.zoomTween.stop(); scene.zoomTween = null; } // 立即应用缩放以避免视觉延迟 scene.zoom = clamped; scene.positionBoardContainer(); // 当缩放回最小值时,平滑重置棋盘偏移到居中位置 if ( clamped === scene.minZoom && (scene.boardOffsetX !== 0 || scene.boardOffsetY !== 0) ) { scene.tweens.add({ targets: scene, boardOffsetX: 0, boardOffsetY: 0, duration: 150, ease: "Sine.easeInOut", onUpdate: () => { scene.positionBoardContainer(); }, }); } } /** * 处理指针释放事件(点击棋盘)。 * 进行命中检测并尝试移动路径。 * @param pointer 指针对象 */ private handlePointerUp(pointer: Phaser.Input.Pointer): void { const scene = this.scene; if (!scene.level || !scene.boardContainer) return; if (scene.introPlaying) return; this.actionNumbers += 1; // 第二次交互后销毁延迟安装计时器(SDK removed) if (this.actionNumbers >= 2 && this.firstInteractionInstallTimer) { this.firstInteractionInstallTimer.destroy(); this.firstInteractionInstallTimer = null; } const level = scene.level!; const localPos = this.getBoardLocalPos(pointer); if (!localPos) return; const { localX, localY } = localPos; const hitIndex = this.pickGridPathIndexAt(level, localX, localY); if (hitIndex === null) return; this.lastTapScreenX = pointer.x; this.lastTapScreenY = pointer.y; this.hasLastTapScreenPos = true; if (scene.tutorialManager.isLevelTwoZoomGuideActive) { scene.tutorialManager.hideLevelTwoZoomGuide(); } // 第一关教程模式下,只允许点击预期的路径 if ( scene.isLevelOne && scene.tutorialManager.isLevelOneTutorialActive && scene.tutorialManager.levelOneTutorialExpectedPathIndex !== null && hitIndex !== scene.tutorialManager.levelOneTutorialExpectedPathIndex ) { return; } // 用户点击箭头时隐藏引导线 if (this.guideLinesVisible) { this.hideGuideLines(); } const removedBefore = scene.removedPaths.has(hitIndex); this.attemptMovePath(hitIndex); // 第一关教程:成功消除后推进教程 if (scene.isLevelOne && scene.tutorialManager.isLevelOneTutorialActive) { const removedAfter = scene.removedPaths.has(hitIndex); if (!removedBefore && removedAfter) { scene.tutorialManager.advanceLevelOneTutorial(); } } } /** * 尝试沿箭头方向移动路径。 * 判断是否出界(成功消除)或被阻挡(播放阻挡动画并扣血)。 * @param pathIndex 路径索引 */ private attemptMovePath(pathIndex: number): void { const scene = this.scene; if (!scene.level) return; if (scene.currentLives <= 0) return; if (scene.tutorialManager.hintTargetIndex === pathIndex) { scene.tutorialManager.clearHintHighlight(); } const level = scene.level; const path = level.paths[pathIndex]!; // 已消除或正在动画中 if (scene.removedPaths.has(pathIndex)) return; if (scene.animatingPaths.has(pathIndex)) return; const { dx, dy } = scene.directionToDelta(path.direction); if (dx === 0 && dy === 0) return; let x = path.head.x; let y = path.head.y; const maxSteps = Math.max(level.cols, level.rows) + 1; let stepsUntilBlock: number | null = null; for (let step = 0; step < maxSteps; step++) { x += dx; y += dy; // 出界:成功 - 播放移出棋盘动画 if (x < 0 || y < 0 || x >= level.cols || y >= level.rows) { const stepsToExit = step + 1; // 首次成功消除时启动计时器 if (!this.hasTimerStarted) { this.hasTimerStarted = true; if (scene.topUI) { scene.topUI.startTimer(); } } scene.blockedPaths.delete(pathIndex); scene.removedPaths.add(pathIndex); // 处理连击逻辑 { const now = scene.time.now; const chained = scene.comboManager.clickCount > 0 && now - scene.comboManager.lastSuccessAtMs <= scene.comboManager.chainWindowMs; scene.comboManager.clickCount = chained ? scene.comboManager.clickCount + 1 : 1; scene.comboManager.lastSuccessAtMs = now; scene.comboManager.handleComboUiOnSuccess(); scene.comboManager.maybeShowComboPraiseText( scene.comboManager.clickCount, this.lastTapScreenX, this.lastTapScreenY, this.hasLastTapScreenPos, ); } // 播放点击音效 if (scene.settings.soundOn) { const levelIndex = Math.min( COMBO_SOUND_MAX_LEVEL, Math.max(1, scene.comboManager.clickCount), ); if (levelIndex >= COMBO_SOUND_MAX_LEVEL) { const volumeJitter = 0.7 + Math.random() * 0.1; safePlaySound(scene, `click`, { volume: volumeJitter }); } else { safePlaySound(scene, `click`, { volume: 0.7 }); } } const isLastPath = scene.level ? scene.removedPaths.size >= scene.level.paths.length : false; scene.relayoutBoard(); scene.animationManager.animatePathOffBoard( pathIndex, dx, dy, stepsToExit, isLastPath, () => { // 动画完成后检查分段挑战模式 if ( CHALLENGE_SEGMENTS_MODE > 0 && scene.removedPaths.size >= CHALLENGE_SEGMENTS_MODE ) { scene.levelLifecycle.handleLevelSuccess(); return; } }, ); // 最后一条路径:播放胜利动画 if (isLastPath) { scene.animationManager.resetBoardViewForWin(); const WIN_ANIM_DELAY_MS = 220; scene.time.delayedCall(WIN_ANIM_DELAY_MS, () => { scene.animationManager.playWinDiamondAnimation(); }); } return; } // 被其他路径阻挡 if (scene.isCellOccupiedByAnyPath(x, y, pathIndex)) { stepsUntilBlock = step + 1; break; } } // 如果被阻挡,播放阻挡动画 if (stepsUntilBlock !== null && stepsUntilBlock > 0) { const isFirstBlocked = !scene.blockedPaths.has(pathIndex); if (isFirstBlocked) { scene.comboManager.resetState(); scene.comboManager.hideComboUi(); scene.levelLifecycle.loseLife(); scene.levelLifecycle.playDangerFrame(); } if (scene.settings.soundOn) { safePlaySound(scene, "arrow_move_error", { volume: 0.8 }); } scene.animatingPaths.add(pathIndex); scene.relayoutBoard(); scene.animationManager.animateBlockedPath( pathIndex, dx, dy, stepsUntilBlock, ); } } /** 隐藏引导线并销毁图形对象。 */ private hideGuideLines(): void { if (this.guideLinesGraphics) { this.guideLinesGraphics.destroy(); this.guideLinesGraphics = null; } this.guideLinesVisible = false; } }