import * as Phaser from "phaser"; import { safePlaySound } from "../../utils/SoundUtils"; import { MOVE_STEP_MS, PATH_LINE_WIDTH_RATIO, ARROW_SIZE_RATIO, } from "../../GameConfig"; import { ENABLE_ERROR_PATH_HIGHLIGHT } from "../../DebugConfig"; import type { PathTrackCell, PathRenderer } from "../../utils/PathRenderers"; import { fillRoundedTriangle } from "../../utils/PathRenderers"; import type { ParsedLevel, LevelPath } from "../../levels/LevelTypes"; /** * 描述 AnimationManager 从 Game 场景中所需内容的最小接口。 * 避免模块级别的循环导入。 */ export interface AnimationGameRef extends Phaser.Scene { level: ParsedLevel | null; boardContainer: Phaser.GameObjects.Container; cellSize: number; cellWidth: number; cellHeight: number; visualSize: number; zoom: number; minZoom: number; maxZoom: number; boardOffsetX: number; boardOffsetY: number; removedPaths: Set; blockedPaths: Set; animatingPaths: Set; isPlayingWinDiamond: boolean; shouldDrawEliminationTrail: boolean; zoomTween: Phaser.Tweens.Tween | null; settings: { soundOn: boolean }; pathCornerStyle: "rounded" | "square"; // AnimationManager 需要的 Game 方法 cellCenter(col: number, row: number): { x: number; y: number }; buildPathTrack(path: LevelPath): PathTrackCell[]; getPathRendererForLineWidth(lineWidth: number): PathRenderer; getPathBaseColor(pathIndex: number): number; drawArrowHead( g: Phaser.GameObjects.Graphics, cx: number, cy: number, angle: number, color: number, scale?: number, ): void; fillAxisAlignedSegmentRect( g: Phaser.GameObjects.Graphics, x1: number, y1: number, x2: number, y2: number, lineWidth: number, ): void; directionToDelta(direction: string): { dx: number; dy: number }; positionBoardContainer(): void; relayoutBoard(): void; levelLifecycle: { handleLevelSuccess(): void }; } export class AnimationManager { private scene: AnimationGameRef; constructor(scene: AnimationGameRef) { this.scene = scene; } // ── 路径移出棋盘动画 ── /** * 将指定路径以蛇形动画移出棋盘。 * @param pathIndex 路径索引 * @param _dx 方向增量X(未使用,运动沿折线路径轨迹) * @param _dy 方向增量Y(未使用) * @param _steps 步数(未使用) * @param isLastPath 是否为最后一条路径 * @param onComplete 动画完成回调 */ animatePathOffBoard( pathIndex: number, _dx: number, _dy: number, _steps: number, isLastPath: boolean = false, onComplete?: () => void, ): void { const s = this.scene; // dx/dy/steps 在此未使用;运动沿折线路径轨迹进行 if (!s.level || !s.boardContainer) return; const level = s.level; const path = level.paths[pathIndex]!; // 成功消除的路径使用固定的蓝色 const color = 0x4050b6; // 复用 buildPathTrack 构建离散格子轨迹 const baseTrack = s.buildPathTrack(path); if (baseTrack.length === 0) return; // 创建可变副本,以便扩展棋盘外的格子 const track: { x: number; y: number }[] = [...baseTrack]; const baseLength = track.length; // 原始路径格子数量 // 在原始路径的每个格子中心放置静态 normal_dot for (let i = 0; i < baseLength; i++) { const cell = track[i]!; const center = s.cellCenter(cell.x, cell.y); this.spawnTrailDot(center.x, center.y, false); } // 将路径延伸到棋盘外,使线条可以完全退出 let tail = track[track.length - 1]!; const { dx: extDx, dy: extDy } = s.directionToDelta(path.direction); const extraSteps = Math.max(level.cols, level.rows) + baseLength; for (let i = 0; i < extraSteps; i++) { tail = { x: tail.x + extDx, y: tail.y + extDy }; track.push(tail); } const bodyLength = baseLength; // 蛇身长度 = 原始路径长度 if (bodyLength <= 0) return; const g = s.add.graphics(); s.boardContainer.add(g); let headStep = bodyLength - 1; // 箭头从原始路径末尾开始 let lastDotIndex = -1; /** 绘制当前帧 */ const drawFrame = () => { g.clear(); const bodyColor = color; const lineWidth = s.visualSize * PATH_LINE_WIDTH_RATIO; g.fillStyle(bodyColor, 1); const tailStep = headStep - (bodyLength - 1); const first = Math.max(tailStep, 0); const last = Math.min(headStep, track.length - 1); s.getPathRendererForLineWidth(lineWidth).render({ g, track, startSegmentIndex: first, endSegmentIndexExclusive: last, cellSize: s.cellSize, lineWidth, cellCenter: (col, row) => s.cellCenter(col, row), fillAxisAlignedSegmentRect: (g2, x1, y1, x2, y2, w) => s.fillAxisAlignedSegmentRect(g2, x1, y1, x2, y2, w), }); // 绘制跟随 headStep 格子的箭头头部 const headIndex = Math.min(headStep, track.length - 1); const headCell = track[headIndex]!; const headCenter = s.cellCenter(headCell.x, headCell.y); // 胜利时:箭头经过原始路径时放置 win_dot if ( isLastPath && headIndex !== lastDotIndex && headIndex >= 0 && headIndex < baseLength ) { lastDotIndex = headIndex; this.spawnTrailDot(headCenter.x, headCenter.y, isLastPath); } let dirX = extDx; let dirY = extDy; if (headIndex > 0) { const prev = track[headIndex - 1]!; dirX = headCell.x - prev.x; dirY = headCell.y - prev.y; } // 绘制三角形箭头头部 let angle = Math.atan2(dirY, dirX) + Math.PI / 2; if (angle > Math.PI) { angle -= Math.PI * 2; } s.drawArrowHead(g, headCenter.x, headCenter.y, angle, color); }; drawFrame(); const moveEvent = s.time.addEvent({ delay: MOVE_STEP_MS, callback: () => { headStep++; const tailStep = headStep - (bodyLength - 1); // 当整条线(尾部)退出扩展轨迹时,动画结束 if (tailStep > track.length - 1) { g.destroy(); moveEvent.remove(false); if (onComplete) { onComplete(); } return; } drawFrame(); }, callbackScope: this, loop: true, }); } // ── 胜利时重置棋盘视图 ── /** * 胜利时将棋盘视图平滑重置为默认缩放和居中位置。 * @param onComplete 重置完成回调 */ resetBoardViewForWin(onComplete?: () => void): void { const s = this.scene; if (!s.boardContainer) { if (onComplete) { onComplete(); } return; } const targetZoom = s.minZoom; const targetOffsetX = 0; const targetOffsetY = 0; const zoomDelta = Math.abs(s.zoom - targetZoom); const dx = s.boardOffsetX - targetOffsetX; const dy = s.boardOffsetY - targetOffsetY; const distance = Math.sqrt(dx * dx + dy * dy); // 如果已经非常接近默认视图,直接对齐并继续 if (zoomDelta < 0.001 && Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { s.zoom = targetZoom; s.boardOffsetX = targetOffsetX; s.boardOffsetY = targetOffsetY; s.positionBoardContainer(); if (onComplete) { onComplete(); } return; } // 使用基于速度的时长而非固定值: const ZOOM_SPEED = 2.0; const PAN_SPEED = 1200; const zoomDuration = zoomDelta > 0 ? (zoomDelta / ZOOM_SPEED) * 1000 : 0; const panDuration = distance > 0 ? (distance / PAN_SPEED) * 1000 : 0; let duration = Math.max(zoomDuration, panDuration); const MIN_DURATION = 220; const MAX_DURATION = 520; duration = Phaser.Math.Clamp(duration, MIN_DURATION, MAX_DURATION); if (s.zoomTween) { s.zoomTween.stop(); s.zoomTween = null; } const startZoom = s.zoom; const startOffsetX = s.boardOffsetX; const startOffsetY = s.boardOffsetY; const tweenState = { t: 0 }; s.zoomTween = s.tweens.add({ targets: tweenState, t: 1, duration, ease: "Sine.easeInOut", onUpdate: () => { const t = tweenState.t as number; s.zoom = Phaser.Math.Linear(startZoom, targetZoom, t); s.boardOffsetX = Phaser.Math.Linear(startOffsetX, targetOffsetX, t); s.boardOffsetY = Phaser.Math.Linear(startOffsetY, targetOffsetY, t); s.positionBoardContainer(); }, onComplete: () => { s.zoomTween = null; s.zoom = targetZoom; s.boardOffsetX = targetOffsetX; s.boardOffsetY = targetOffsetY; s.positionBoardContainer(); if (onComplete) { onComplete(); } }, }); } // ── 播放胜利钻石动画 ── /** 播放胜利钻石波纹动画,从中心向外扩散。 */ playWinDiamondAnimation(): void { const s = this.scene; if (s.isPlayingWinDiamond) { return; } s.isPlayingWinDiamond = true; if (!s.boardContainer) { s.isPlayingWinDiamond = false; s.levelLifecycle.handleLevelSuccess(); return; } if (s.settings.soundOn) { safePlaySound(s, "win_anim", { volume: 2.0 }); } // 从棋盘容器中收集已有的 normal_dot / win_dot const children = s.boardContainer.list as Phaser.GameObjects.GameObject[]; const dots = children.filter( (child): child is Phaser.GameObjects.Image => child instanceof Phaser.GameObjects.Image && (child.texture.key === "normal_dot" || child.texture.key === "win_dot"), ); if (!dots.length) { s.isPlayingWinDiamond = false; s.levelLifecycle.handleLevelSuccess(); return; } // 根据边界框中心将点位置映射到网格坐标 type DotInfo = { dot: Phaser.GameObjects.Image; gx: number; gy: number }; const infos: DotInfo[] = []; let minGX = Infinity; let maxGX = -Infinity; let minGY = Infinity; let maxGY = -Infinity; dots.forEach((dot) => { const gx = Math.round(dot.x / s.cellSize); const gy = Math.round(dot.y / s.cellSize); infos.push({ dot, gx, gy }); if (gx < minGX) minGX = gx; if (gx > maxGX) maxGX = gx; if (gy < minGY) minGY = gy; if (gy > maxGY) maxGY = gy; }); if (!infos.length || !isFinite(minGX) || !isFinite(minGY)) { s.isPlayingWinDiamond = false; s.levelLifecycle.handleLevelSuccess(); return; } const centerGX = Math.round((minGX + maxGX) / 2); const centerGY = Math.round((minGY + maxGY) / 2); const buckets = new Map(); infos.forEach(({ dot, gx, gy }) => { const dist = Math.abs(gx - centerGX) + Math.abs(gy - centerGY); const arr = buckets.get(dist); if (arr) { arr.push(dot); } else { buckets.set(dist, [dot]); } }); const distKeys = Array.from(buckets.keys()).sort((a, b) => a - b); const totalCount = dots.length; let finishedCount = 0; const spanGX = Math.max(1, maxGX - minGX + 1); const spanGY = Math.max(1, maxGY - minGY + 1); // 根据点的分布调整波纹间隔 const waveInterval = Phaser.Math.Clamp( 900 / Math.max(spanGX, spanGY), 25, 80, ); /** 对指定距离层的点执行动画 */ const animateBucket = (dist: number) => { if (!s.sys || !s.sys.isActive()) return; const group = buckets.get(dist); if (!group) return; group.forEach((dot) => { if (!dot || !dot.scene) return; s.tweens.killTweensOf(dot); const baseScale = (dot.getData("baseScale") as number | undefined) || dot.scaleX || 1; // 将所有点切换为发光的 win_dot 纹理 dot.setTexture("win_dot"); dot.setAlpha(0); dot.setScale(baseScale * 0.5); s.tweens.add({ targets: dot, alpha: 1, scale: baseScale * 1.3, duration: 220, ease: "Sine.easeOut", onComplete: () => { s.tweens.add({ targets: dot, alpha: 0, scale: 0, duration: 260, ease: "Sine.easeIn", onComplete: () => { dot.destroy(); finishedCount += 1; if (finishedCount >= totalCount) { s.isPlayingWinDiamond = false; s.levelLifecycle.handleLevelSuccess(); } }, }); }, }); }); }; distKeys.forEach((d) => { s.time.delayedCall(d * waveInterval, () => { animateBucket(d); }); }); } // ── 播放路径阻挡动画 ── /** * 路径被阻挡时的前进-后退动画。 * @param pathIndex 路径索引 * @param dx 方向增量X * @param dy 方向增量Y * @param steps 阻挡前的步数 */ animateBlockedPath( pathIndex: number, dx: number, dy: number, steps: number, ): void { const s = this.scene; if (!s.level || !s.boardContainer) { s.animatingPaths.delete(pathIndex); return; } s.blockedPaths.add(pathIndex); const level = s.level; const path = level.paths[pathIndex]; if (!path) { s.animatingPaths.delete(pathIndex); s.relayoutBoard(); return; } // 复用 buildPathTrack 构建离散格子轨迹 const baseTrack = s.buildPathTrack(path); if (baseTrack.length === 0) { s.animatingPaths.delete(pathIndex); s.relayoutBoard(); return; } // 创建可变副本,以便扩展前方格子 const track: { x: number; y: number }[] = [...baseTrack]; const baseLength = track.length; const bodyLength = baseLength; if (bodyLength <= 0) { s.animatingPaths.delete(pathIndex); s.relayoutBoard(); return; } let tail = track[track.length - 1]!; for (let i = 0; i < steps; i++) { tail = { x: tail.x + dx, y: tail.y + dy }; track.push(tail); } const g = s.add.graphics(); s.boardContainer.add(g); let headStep = bodyLength - 1; const maxHeadStep = bodyLength - 1 + steps; let reversing = false; /** 绘制当前帧 */ const drawFrame = () => { g.clear(); const lineWidth = s.visualSize * PATH_LINE_WIDTH_RATIO; const baseColor = s.getPathBaseColor(pathIndex); const color = ENABLE_ERROR_PATH_HIGHLIGHT ? 0xff0000 : baseColor; g.fillStyle(color, 1); const tailStep = headStep - (bodyLength - 1); const first = Math.max(tailStep, 0); const last = Math.min(headStep, track.length - 1); s.getPathRendererForLineWidth(lineWidth).render({ g, track, startSegmentIndex: first, endSegmentIndexExclusive: last, cellSize: s.cellSize, lineWidth, cellCenter: (col, row) => s.cellCenter(col, row), fillAxisAlignedSegmentRect: (g2, x1, y1, x2, y2, w) => s.fillAxisAlignedSegmentRect(g2, x1, y1, x2, y2, w), }); const headIndex = Math.min(headStep, track.length - 1); const headCell = track[headIndex]!; const headCenter = s.cellCenter(headCell.x, headCell.y); let dirX = dx; let dirY = dy; if (headIndex > 0) { const prev = track[headIndex - 1]!; dirX = headCell.x - prev.x; dirY = headCell.y - prev.y; } let angle = Math.atan2(dirY, dirX) + Math.PI / 2; if (angle > Math.PI) { angle -= Math.PI * 2; } s.drawArrowHead(g, headCenter.x, headCenter.y, angle, color); }; drawFrame(); const moveEvent = s.time.addEvent({ delay: MOVE_STEP_MS, callback: () => { if (!reversing) { if (headStep < maxHeadStep) { headStep++; } else { reversing = true; } } else { if (headStep > bodyLength - 1) { headStep--; } else { s.animatingPaths.delete(pathIndex); s.blockedPaths.add(pathIndex); s.relayoutBoard(); g.destroy(); moveEvent.remove(false); return; } } drawFrame(); }, callbackScope: this, loop: true, }); } // ── 生成轨迹点 ── /** * 在指定位置生成一个轨迹点(normal_dot 或 win_dot)。 * @param x X坐标 * @param y Y坐标 * @param isWin 是否为胜利点(带动画效果) */ spawnTrailDot(x: number, y: number, isWin: boolean): void { const s = this.scene; if (!s.boardContainer || !s.shouldDrawEliminationTrail) return; const textureKey = isWin ? "win_dot" : "normal_dot"; const dot = s.add.image(x, y, textureKey); s.boardContainer.addAt(dot, 0); dot.setOrigin(0.5, 0.5); dot.setDepth(-5); const targetPixelSize = s.cellSize * 0.4; const texWidth = dot.width || 1; const baseScale = targetPixelSize / texWidth; dot.setData("baseScale", baseScale); if (!isWin) { // 普通消除:静态点,无动画 dot.setScale(baseScale); dot.setAlpha(1); return; } // 胜利:带动画的 win_dot dot.setScale(baseScale * 0.3); dot.setAlpha(0); s.tweens.add({ targets: dot, alpha: { from: 0, to: 1 }, scaleX: { from: baseScale * 0.3, to: baseScale }, scaleY: { from: baseScale * 0.3, to: baseScale }, duration: 550, ease: "Sine.easeOut", yoyo: true, repeat: 1, onComplete: () => { dot.destroy(); }, }); } // ── 播放开场缩放动画 ── /** * 播放关卡开场缩放动画:峰值 -> 0.8 -> 1.0。 * @param targetZoom 目标缩放值 */ playIntroZoomAnimation(targetZoom: number): void { const s = this.scene; if (!s.boardContainer) { return; } const clampedTarget = Phaser.Math.Clamp(targetZoom, s.minZoom, s.maxZoom); // 优化的缩放动画:0.8 -> 峰值 -> 1 const startZoom = 0.8; const peakZoom = Phaser.Math.Clamp( clampedTarget * 1.25, s.minZoom, s.maxZoom, ); const finalZoom = 1.0; // 如果峰值与起始值太接近,跳过动画 if (peakZoom - startZoom < 0.15) { return; } if (s.zoomTween) { s.zoomTween.stop(); s.zoomTween = null; } // 从峰值缩放开始 s.zoom = peakZoom; s.positionBoardContainer(); // 使用场景的 levelIntroDurationMs(默认975ms) const introDurationMs = 975; // 时间分配:缩小到0.8(50%)-> 放大到1(50%) const zoomDownDuration = Math.max(250, Math.floor(introDurationMs * 0.5)); const zoomUpDuration = Math.max(250, introDurationMs - zoomDownDuration); // 阶段1:从峰值缩小到0.8以展示完整棋盘 s.zoomTween = s.tweens.add({ targets: s, zoom: startZoom, duration: zoomDownDuration, ease: "Cubic.easeOut", onUpdate: () => { s.positionBoardContainer(); }, onComplete: () => { // 阶段2:从0.8放大回1.0 s.zoomTween = s.tweens.add({ targets: s, zoom: finalZoom, duration: zoomUpDuration, ease: "Back.easeOut", onUpdate: () => { s.positionBoardContainer(); }, onComplete: () => { s.zoomTween = null; s.zoom = finalZoom; s.positionBoardContainer(); }, }); }, }); } }