import { Scene } from "phaser"; import * as Phaser from "phaser"; import { parseLevelJson } from "../levels/LevelParser"; import type { ParsedLevel, LevelPath, RawLevelJson, } from "../levels/LevelTypes"; import { safePlaySound } from "../utils/SoundUtils"; import { GameTopUI } from "./GameUI/GameTopUI"; import { DownloadButton } from "./GameUI/DownloadButton"; import { SettingsManager } from "./GameUI/SettingsManager"; import { ThemeManager } from "./GameUI/ThemeManager"; import { GameOverPanel } from "./GameUI/GameOverPanel"; import { ComboManager } from "./GameUI/ComboManager"; import { TutorialManager } from "./GameUI/TutorialManager"; import { LevelLifecycle } from "./GameUI/LevelLifecycle"; import { AnimationManager } from "./GameUI/AnimationManager"; import { BoardLayout } from "./GameUI/BoardLayout"; import { InputHandler } from "./GameUI/InputHandler"; import { PathRendererManager } from "./GameUI/PathRenderer"; import { LevelManager } from "../levels/LevelManager"; import { getActiveRunContext, clearActiveRunContext, } from "../run/RunContextRuntime"; import type { RunFlags } from "../run/RunContextRuntime"; import { ENABLE_TEST_LEVEL_PANEL, CHALLENGE_LEVEL_MODE, DEBUG_FORCED_LEVEL_TIME_SECONDS, AUTO_USE_HINT_ON_START, GAME_CANVAS_BACKGROUND_COLOR, PATH_CORNER_STYLE, } from "../DebugConfig"; import type { PathTrackCell, PathRenderer as IPathRenderer, } from "../utils/PathRenderers"; import { computeUiLayout } from "../utils/UiLayout"; import { getCurrentLevelData, shouldDrawEliminationTrail, } from "../LevelDataManager"; /** * 游戏主场景类。 * 负责关卡加载、棋盘渲染、输入处理、动画播放等核心游戏逻辑的协调。 */ export class Game extends Scene { /** 主相机引用 */ camera!: Phaser.Cameras.Scene2D.Camera; /** 背景图片 */ background!: Phaser.GameObjects.Image; /** 背景矩形,用于确保屏幕完全覆盖 */ public backgroundRect?: Phaser.GameObjects.Rectangle; /** FPS 调试文本 */ private fpsText!: Phaser.GameObjects.Text; /** 当前已解析的关卡数据 */ public level: ParsedLevel | null = null; /** 当前关卡进度索引(从1开始) */ private progressIndex = 1; /** 格子尺寸(用于布局和点击判定,竖屏或正方形格子) */ public cellSize = 32; /** 格子宽度(横屏时可能与高度不同) */ public cellWidth = 32; /** 格子高度(横屏时可能与宽度不同) */ public cellHeight = 32; /** 视觉尺寸(用于箭头和线条渲染,保持原始大小) */ public visualSize = 32; /** 棋盘总宽度(像素) */ public boardWidth = 0; /** 棋盘总高度(像素) */ public boardHeight = 0; /** 棋盘容器(包含棋盘图形和覆盖层) */ public boardContainer!: Phaser.GameObjects.Container; /** 棋盘主绘图对象 */ public boardGraphics!: Phaser.GameObjects.Graphics; /** 提示高亮覆盖层绘图对象 */ public hintOverlayGraphics?: Phaser.GameObjects.Graphics; /** 关卡背景图片 */ public levelBackgroundImage: Phaser.GameObjects.Image | null = null; /** 当前缩放倍数 */ public zoom = 1; /** 最小缩放倍数 */ public readonly minZoom = 1; /** 最大缩放倍数 */ public readonly maxZoom = 2.5; /** 棋盘X轴偏移量 */ public boardOffsetX = 0; /** 棋盘Y轴偏移量 */ public boardOffsetY = 0; /** 路径拐角样式(圆角或方角) */ public pathCornerStyle: "rounded" | "square" = PATH_CORNER_STYLE; /** 路径渲染管理器实例 */ public pathRenderer!: PathRendererManager; /** 委托给 PathRendererManager 构建路径轨迹 */ public buildPathTrack(path: LevelPath): PathTrackCell[] { return this.pathRenderer.buildPathTrack(path); } /** 委托给 PathRendererManager 获取路径渲染器 */ public getPathRendererForLineWidth(_lineWidth: number): IPathRenderer { return this.pathRenderer.getPathRendererForLineWidth(_lineWidth); } /** 记录已经被成功"移动/移除"的路径索引,避免重复绘制与碰撞 */ public removedPaths = new Set(); /** 记录曾经发生过"被阻挡"的路径,使其保持红色,直到成功移除 */ public blockedPaths = new Set(); /** 记录当前正在播放动画的路径,在动画期间不参与静态绘制,避免出现重复箭头 */ public animatingPaths = new Set(); /** 不可移动提示精灵(全屏红色闪光) */ public nomoveHintSprite?: Phaser.GameObjects.Image; /** 不可移动提示动画 */ public nomoveHintTween: Phaser.Tweens.Tween | null = null; /** 缩放动画 */ public zoomTween: Phaser.Tweens.Tween | null = null; /** 连击管理器 */ public comboManager!: ComboManager; /** 在某些动画期间(例如生命扣除后等待弹板)锁定输入,防止误操作 */ public isInputLocked = false; /** 游戏结束面板 */ public gameOverPanel: GameOverPanel | undefined; /** 是否为测试/预览模式 */ public isTestMode = false; /** 运行模式标志 */ public runFlags: RunFlags = { kind: "normal", affectsProgression: true, }; /** 顶部 UI(关卡文字、生命值、计时器等) */ public topUI?: GameTopUI; /** 关卡入场动画持续时间(毫秒),增加50%时间(原650ms),额外时间用于箭头错开出现 */ private readonly levelIntroDurationMs = 975; /** 入场动画已经过的时间(毫秒) */ private introElapsedMs = 0; /** 入场动画是否正在播放 */ public introPlaying = true; /** 最大生命数 */ public readonly maxLives = 3; /** 当前剩余生命数 */ public currentLives = 3; /** 是否为第一关 */ public isLevelOne = false; /** 是否为第二关 */ public isLevelTwo = false; /** 新手引导管理器 */ public tutorialManager!: TutorialManager; /** 是否正在处理过关逻辑 */ public winProcessing = false; /** 设置管理器单例 */ public readonly settings = SettingsManager.instance; /** 关卡生命周期管理器 */ public levelLifecycle!: LevelLifecycle; /** 动画管理器 */ public animationManager!: AnimationManager; /** 棋盘布局管理器 */ public boardLayout!: BoardLayout; /** 输入处理器 */ public inputHandler!: InputHandler; /** 是否正在播放过关钻石动画 */ public isPlayingWinDiamond = false; /** 是否绘制消除轨迹 */ public shouldDrawEliminationTrail: boolean; constructor() { super("Game"); } /** * 场景初始化回调。 * 将 runFlags 重置为默认值(在 create() 中会被正常模式的 getRunFlags 覆盖)。 */ init(): void { this.runFlags = { kind: "normal", affectsProgression: true, }; } /** * 场景创建回调。 * 负责初始化相机、主题、关卡数据、棋盘、UI 和各管理器。 */ create() { // #region 重置状态 // 每次进入关卡时,生命重置为满值,确保 TopUI 显示满血 this.currentLives = this.maxLives; this.isInputLocked = false; // 重置棋盘缩放与偏移状态,确保每一局从默认视图开始 this.zoom = this.minZoom; this.boardOffsetX = 0; this.boardOffsetY = 0; if (this.zoomTween) { this.zoomTween.stop(); this.zoomTween = null; } this.comboManager?.resetState(); if (this.currentLives <= 0) { this.currentLives = this.maxLives; } this.gameOverPanel = undefined; this.removedPaths.clear(); this.blockedPaths.clear(); this.animatingPaths.clear(); this.tutorialManager?.resetState(); // #endregion this.camera = this.cameras.main; const themeManager = ThemeManager.instance; themeManager.init(this); // 若配置了自定义画布背景颜色则使用配置值,否则使用主题颜色 const bgColor = themeManager.getBackgroundColorNumber(); const finalBgColor = GAME_CANVAS_BACKGROUND_COLOR >= 0 ? GAME_CANVAS_BACKGROUND_COLOR : bgColor; this.camera.setBackgroundColor(finalBgColor); // 创建全屏背景矩形,确保在所有情况下背景都能完全覆盖;设置为最底层(深度为负值) const { width, height } = this.scale; this.backgroundRect = this.add .rectangle(width / 2, height / 2, width, height, finalBgColor) .setDepth(-9999) .setScrollFactor(0); // 创建全屏红色闪光覆盖层,用于危险反馈 this.nomoveHintSprite = this.add .image(width / 2, height / 2, "nomove_hint") .setDisplaySize(width, height) .setScrollFactor(0) .setDepth(1600) .setVisible(false) .setAlpha(0); this.settings.applyToSceneSound(this); this.input.once("pointerdown", () => { this.settings.playBgm(this); }); const levelManager = LevelManager.instance; // 挑战模式:使用 CHALLENGE_LEVEL_MODE 指定的关卡编号初始化 if (CHALLENGE_LEVEL_MODE !== 0) { levelManager.setCurrentIndex(CHALLENGE_LEVEL_MODE); } const levelIndex = levelManager.getCurrentIndex(); this.progressIndex = levelIndex; const activeRunContext = getActiveRunContext(); this.isTestMode = this.runFlags.kind === "test"; const testPayload = activeRunContext?.mode === "test" ? activeRunContext : null; // 挑战模式下隐藏关卡文字 let levelDisplayText = CHALLENGE_LEVEL_MODE !== 0 ? "" : `Level ${this.progressIndex}`; let level: ParsedLevel; // 是否绘制消除轨迹 this.shouldDrawEliminationTrail = shouldDrawEliminationTrail(); // #region 加载关卡数据 let raw: RawLevelJson; if (testPayload && testPayload.raw) { // 测试/预览模式:使用来自运行上下文的关卡数据 console.log(`[Game] Loading level from test/preview context`); raw = testPayload.raw; levelDisplayText = testPayload.displayName || "Preview"; } else { const LevelData = getCurrentLevelData(); console.log(`[Game] Loading level, progressIndex=${this.progressIndex}`, { levelDataLength: LevelData?.length, requestedIndex: this.progressIndex - 1, rawType: typeof LevelData?.[this.progressIndex - 1], rawIsArray: Array.isArray(LevelData?.[this.progressIndex - 1]), }); raw = LevelData[this.progressIndex - 1]; console.log(`raw data: ${JSON.stringify(raw)}`); if (!raw) { // All levels completed — show win panel instead of crashing console.warn( `[Game] No level data at index ${ this.progressIndex - 1 }. LevelData has ${LevelData?.length} items. Showing win panel.`, ); this.gameOverPanel = new GameOverPanel(this as any, { type: "win", onClose: () => { this.gameOverPanel = undefined; }, }); return; } } level = parseLevelJson(raw); this.level = level; // #endregion this.isLevelOne = level.index === 1; this.isLevelTwo = level.index === 2; if (!this.tutorialManager) { this.tutorialManager = new TutorialManager(this as any); } this.tutorialManager.resetState(); const initialZoom = this.minZoom; this.zoom = initialZoom; this.introElapsedMs = 0; this.introPlaying = true; this.boardContainer = this.add.container(0, 0); this.boardGraphics = this.add.graphics(); this.boardContainer.add(this.boardGraphics); this.hintOverlayGraphics = this.add.graphics(); this.boardContainer.add(this.hintOverlayGraphics); // 提前初始化各管理器,需在 buildBoard / playIntroZoomAnimation 之前完成 this.pathRenderer = new PathRendererManager(this as any); const themeArrowColor = themeManager.getFixedArrowColorNumber(); this.pathRenderer.setupThemeColors(level, themeArrowColor); this.pathRenderer.initPathTracks(level); this.levelLifecycle = new LevelLifecycle(this as any); this.animationManager = new AnimationManager(this as any); this.boardLayout = new BoardLayout(this as any); this.inputHandler = new InputHandler(this as any); this.inputHandler.resetState(); if (this.settings.soundOn) { this.time.addEvent({ delay: 100, // 每100ms执行一次 callback: () => { safePlaySound(this, "appear", { volume: 0.5 }); }, repeat: 5, }); } this.buildBoard(level, 0); // 初始化时重置偏移 if (!this.isLevelOne && !this.isLevelTwo) { this.animationManager.playIntroZoomAnimation(initialZoom); } // 使棋盘在窗口大小变化时自适应布局 this.scale.on("resize", (gameSize: Phaser.Structs.Size) => { const { width, height } = gameSize; this.cameras.resize(width, height); this.relayoutBoard(); }); // 绑定所有输入事件(滚轮、指针、双指缩放) this.inputHandler.bindEvents(); if (ENABLE_TEST_LEVEL_PANEL) { this.fpsText = this.add .text(10, 10, "FPS: 0", { fontFamily: "Poppins", fontSize: "16px", color: "#000000", }) .setDepth(1000); } const initialSeconds = DEBUG_FORCED_LEVEL_TIME_SECONDS ? DEBUG_FORCED_LEVEL_TIME_SECONDS : level.timeSeconds > 0 ? level.timeSeconds : 4 * 60; this.topUI = new GameTopUI(this, { levelText: levelDisplayText, initialLives: this.currentLives, maxLives: this.maxLives, initialTimeSeconds: initialSeconds, onTimeout: () => this.levelLifecycle.handleTimeOut(), }); // 在测试/预览模式下,显示"← 返回"按钮用于返回编辑器 if (this.isTestMode) { const layout = computeUiLayout(this); const { uiScale, vScale } = layout; const btnX = 60 * uiScale; const btnY = 44 * vScale; const backBg = this.add.graphics(); backBg.fillStyle(0x000000, 0.45); backBg.fillRoundedRect( btnX - 52 * uiScale, btnY - 20 * vScale, 104 * uiScale, 40 * vScale, 10 * uiScale, ); backBg.setScrollFactor(0).setDepth(1700); const backText = this.add.text(btnX, btnY, "← Back", { fontFamily: "Poppins", fontSize: `${22 * uiScale}px`, color: "#ffffff", fontStyle: "bold", }); backText.setOrigin(0.5).setScrollFactor(0).setDepth(1701); const backHit = this.add .rectangle(btnX, btnY, 104 * uiScale, 40 * vScale, 0x000000, 0) .setScrollFactor(0) .setDepth(1702) .setInteractive({ useHandCursor: true }); backHit.on("pointerup", () => { clearActiveRunContext(); this.scene.start("Editor"); }); } // 连击 UI const layout = computeUiLayout(this); this.comboManager = new ComboManager(this); this.comboManager.createUi(layout); // 创建下载按钮 new DownloadButton(this); this.comboManager.maybeShowComboPraiseText( this.comboManager.clickCount, 0, 0, false, ); } /** * 每帧更新回调。 * 处理 FPS 显示、计时器更新、新手引导触发和入场动画播放。 * @param time 当前时间戳(毫秒) * @param delta 上一帧到当前帧的间隔时间(毫秒) */ override update(time: number, delta: number): void { if (ENABLE_TEST_LEVEL_PANEL && this.fpsText) { const fps = this.game.loop.actualFps; this.fpsText.setText(`FPS: ${fps.toFixed(1)}`); } let pausedByUI = false; if (this.topUI) { pausedByUI = this.isInputLocked || !!this.gameOverPanel; if (pausedByUI) { if (this.nomoveHintTween) { this.nomoveHintTween.stop(); this.nomoveHintTween = null; } if (this.nomoveHintSprite) { this.nomoveHintSprite.setVisible(false); this.nomoveHintSprite.setAlpha(0); } } else if (this.topUI) { this.topUI.updateTimer(delta, time); } } if (!pausedByUI && this.level && !this.introPlaying) { const isNormalMode = this.runFlags.kind === "normal"; if ( isNormalMode && this.isLevelTwo && !this.tutorialManager.hasLevelTwoZoomGuideShown ) { this.tutorialManager.showLevelTwoZoomGuide(); } if ( isNormalMode && this.isLevelOne && !this.tutorialManager.isLevelOneTutorialActive ) { this.tutorialManager.startLevelOneTutorial(); } } if (this.introPlaying && this.level) { this.introElapsedMs += delta; const t = Phaser.Math.Clamp( this.introElapsedMs / this.levelIntroDurationMs, 0, 1, ); this.buildBoard(this.level, t); if (t >= 1) { this.introPlaying = false; // 自动使用提示(新手引导) if (AUTO_USE_HINT_ON_START) { this.tutorialManager.autoUseHintOnStart(); } } } } /** * 绘制箭头头部。 * 委托给 PathRendererManager 执行实际绘制。 * @param g 目标 Graphics 对象 * @param cx 中心X坐标 * @param cy 中心Y坐标 * @param angle 旋转角度(弧度,0 = 朝上) * @param color 填充颜色 * @param scale 额外缩放因子(默认1) */ public drawArrowHead( g: Phaser.GameObjects.Graphics, cx: number, cy: number, angle: number, color: number, scale: number = 1, ): void { this.pathRenderer.drawArrowHead(g, cx, cy, angle, color, scale); } /** * 将方向字符串转换为单位增量 { dx, dy }(静态方法)。 * @param direction 方向字符串("left" | "right" | "up" | "down") * @returns 单位增量对象 */ public static directionToDelta(direction: string): { dx: number; dy: number; } { return PathRendererManager.directionToDelta(direction); } /** * 将方向字符串转换为角度(弧度,0 = 朝上)(静态方法)。 * @param direction 方向字符串("left" | "right" | "up" | "down") * @returns 角度(弧度) */ public static directionToAngle(direction: string): number { return PathRendererManager.directionToAngle(direction); } /** * 将方向字符串转换为单位增量 { dx, dy }(实例方法包装)。 * 供 AnimationManager 等模块通过接口调用。 * @param direction 方向字符串 * @returns 单位增量对象 */ public directionToDelta(direction: string): { dx: number; dy: number } { return PathRendererManager.directionToDelta(direction); } /** * 渲染关卡中的所有网格路径。 * 委托给 PathRendererManager 执行实际渲染(含入场错开动画)。 * @param level 关卡数据 * @param clampedProgress 入场动画进度(0-1) */ public renderGridPaths(level: ParsedLevel, clampedProgress: number): void { this.pathRenderer.renderGridPaths(level, clampedProgress); } /** * 构建棋盘。 * 委托给 BoardLayout 管理器执行棋盘布局和路径绘制。 * @param level 关卡数据 * @param drawProgress 绘制进度(0-1,用于入场动画) */ private buildBoard(level: ParsedLevel, drawProgress: number = 1) { this.boardLayout.buildBoard(level, drawProgress); } /** * 渲染提示高亮覆盖层。 * 委托给 PathRendererManager 执行实际渲染(带脉冲缩放效果)。 */ public renderHintOverlay(): void { this.pathRenderer.renderHintOverlay(); } /** * 获取指定路径的基础颜色。 * 委托给 PathRendererManager 根据主题和关卡数据返回颜色值。 * @param pathIndex 路径索引 * @returns 颜色值(十六进制数字) */ public getPathBaseColor(pathIndex: number): number { return this.pathRenderer.getPathBaseColor(pathIndex); } /** * 查找当前可移除的路径索引。 * 遍历所有路径,找到第一条沿方向可以直接滑出棋盘而不被其它路径阻挡的路径。 * @returns 可移除的路径索引,如果没有则返回 null */ public findRemovablePathIndex(): number | null { if (!this.level) return null; const level = this.level; for (let i = 0; i < level.paths.length; i++) { if (this.removedPaths.has(i)) { console.log("[Hint] skip path", i, "because it is already removed"); continue; } if (this.animatingPaths.has(i)) { console.log("[Hint] skip path", i, "because it is animating"); continue; } const path = level.paths[i]!; const { dx, dy } = Game.directionToDelta(path.direction); if (dx === 0 && dy === 0) { console.log("[Hint] skip path", i, "because direction is (0,0)"); continue; } let x = path.head.x; let y = path.head.y; const maxSteps = Math.max(level.cols, level.rows) + 1; for (let step = 0; step < maxSteps; step++) { x += dx; y += dy; // 能直接走出棋盘,说明是可移除路径 if (x < 0 || y < 0 || x >= level.cols || y >= level.rows) { console.log( "[Hint] path", i, "is removable: exits board at step", step + 1, "pos =", x, y, ); return i; } // 被其它路径阻挡,则本路径当前不可直接移除 if (this.isCellOccupiedByAnyPath(x, y, i)) { console.log( "[Hint] path", i, "blocked by another path at cell", x, y, "step", step + 1, ); break; } } } console.log( "[Hint] No removable path found (all paths either removed, animating, or blocked)", ); return null; } /** * 判断指定格子是否被任意路径占据。 * @param col 列号 * @param row 行号 * @param ignorePathIndex 需要忽略的路径索引(可选,用于排除自身) * @returns 如果被占据返回 true,否则返回 false */ public isCellOccupiedByAnyPath( col: number, row: number, ignorePathIndex?: number, ): boolean { if (!this.level) return false; const level = this.level; const idx = row * level.cols + col; return level.paths.some((p, i) => { if (this.removedPaths.has(i)) return false; if (ignorePathIndex !== undefined && i === ignorePathIndex) return false; return p.indices.includes(idx); }); } /** * 计算指定格子的中心坐标。 * 委托给 PathRendererManager 执行计算。 * @param col 列号 * @param row 行号 * @returns 中心坐标 { x, y } */ public cellCenter(col: number, row: number): { x: number; y: number } { return this.pathRenderer.cellCenter(col, row); } /** * 绘制轴对齐的线段矩形(用于路径体渲染)。 * 委托给 PathRendererManager 执行实际绘制,支持亚像素抗锯齿。 * @param g 目标 Graphics 对象 * @param x1 起点X坐标 * @param y1 起点Y坐标 * @param x2 终点X坐标 * @param y2 终点Y坐标 * @param lineWidth 线宽 */ public fillAxisAlignedSegmentRect( g: Phaser.GameObjects.Graphics, x1: number, y1: number, x2: number, y2: number, lineWidth: number, ) { this.pathRenderer.fillAxisAlignedSegmentRect(g, x1, y1, x2, y2, lineWidth); } /** * 重新布局棋盘。 * 在窗口大小变化时调用,委托给 BoardLayout 管理器。 */ public relayoutBoard() { this.boardLayout.relayoutBoard(); } /** * 定位棋盘容器。 * 根据当前缩放和偏移重新计算容器位置,委托给 BoardLayout 管理器。 */ public positionBoardContainer() { this.boardLayout.positionBoardContainer(); } }