import * as Phaser from "phaser"; import type { ParsedLevel, LevelPath } from "../../levels/LevelTypes"; import { ARROW_SIZE_RATIO, PATH_LINE_WIDTH_RATIO } from "../../GameConfig"; import { ENABLE_ERROR_PATH_HIGHLIGHT, PATH_CORNER_STYLE, } from "../../DebugConfig"; import { type PathTrackCell, type PathRenderer as IPathRenderer, SquarePathRenderer, RoundedCornerPathRenderer, fillRoundedTriangle, } from "../../utils/PathRenderers"; /** * 描述 PathRenderer 从 Game 场景中所需内容的最小接口。 * 避免模块级别的循环导入。 */ export interface PathRendererGameRef extends Phaser.Scene { level: ParsedLevel | null; boardGraphics: Phaser.GameObjects.Graphics; hintOverlayGraphics?: Phaser.GameObjects.Graphics; cellSize: number; cellWidth: number; cellHeight: number; visualSize: number; removedPaths: Set; blockedPaths: Set; animatingPaths: Set; pathCornerStyle: "rounded" | "square"; tutorialManager: { hintTargetIndex: number | null; hintPulseScale: number; }; } /** * 路径渲染器。 * 负责渲染网格路径、箭头头部、Hint覆盖层等所有与路径视觉相关的内容。 */ export class PathRendererManager { private scene: PathRendererGameRef; /** 路径轨迹缓存 */ private pathTracks: PathTrackCell[][] = []; /** 缓存的箭头出现顺序(基于距离排序) */ private introPathOrder: number[] = []; /** 每条路径的箭头颜色 */ private arrowBaseColor = 0x000000; private pathArrowColors: number[] = []; constructor(scene: PathRendererGameRef) { this.scene = scene; } // ── 路径轨迹构建 ── /** * 根据路径数据构建离散格子轨迹。 * @param path 路径数据 * @returns 轨迹格子数组 */ buildPathTrack(path: LevelPath): PathTrackCell[] { const track: PathTrackCell[] = []; const pushCell = (cx: number, cy: number) => { const last = track[track.length - 1]; if (last && last.x === cx && last.y === cy) return; track.push({ x: cx, y: cy }); }; if (path.points.length > 0) { for (let i = 0; i < path.points.length - 1; i++) { const a = path.points[i]!; const b = path.points[i + 1]!; if (a.x === b.x) { const stepY = b.y > a.y ? 1 : -1; for (let y = a.y; y !== b.y + stepY; y += stepY) { pushCell(a.x, y); } } else if (a.y === b.y) { const stepX = b.x > a.x ? 1 : -1; for (let x = a.x; x !== b.x + stepX; x += stepX) { pushCell(x, a.y); } } else { pushCell(a.x, a.y); pushCell(b.x, b.y); } } } return track; } /** * 获取路径渲染器(圆角或方角)。 * @param _lineWidth 线宽(当前未使用) * @returns 路径渲染器实例 */ getPathRendererForLineWidth(_lineWidth: number): IPathRenderer { return this.scene.pathCornerStyle === "rounded" ? RoundedCornerPathRenderer : SquarePathRenderer; } // ── 基础绘图工具 ── /** * 计算指定格子的中心坐标。 * @param col 列号 * @param row 行号 * @returns 中心坐标 */ cellCenter(col: number, row: number): { x: number; y: number } { const s = this.scene; const x = col * s.cellWidth + s.cellWidth / 2; const y = row * s.cellHeight + s.cellHeight / 2; return { x, y }; } /** * 绘制轴对齐的线段矩形(用于路径体渲染)。 * 支持亚像素抗锯齿模式(小格子时启用)。 */ fillAxisAlignedSegmentRect( g: Phaser.GameObjects.Graphics, x1: number, y1: number, x2: number, y2: number, lineWidth: number, ) { const useSubpixelAA = this.scene.cellSize < 18; const w = Math.max(1, useSubpixelAA ? lineWidth : Math.round(lineWidth)); const half = w / 2; if (useSubpixelAA) { if (Math.abs(x1 - x2) < 0.0001 && Math.abs(y1 - y2) < 0.0001) { g.fillRect(x1 - half, y1 - half, w, w); return; } if (Math.abs(x1 - x2) < 0.0001) { const left = x1 - half; const top = Math.min(y1, y2); const height = Math.abs(y2 - y1); g.fillRect(left, top, w, height); return; } if (Math.abs(y1 - y2) < 0.0001) { const left = Math.min(x1, x2); const top = y1 - half; const width = Math.abs(x2 - x1); g.fillRect(left, top, width, w); } return; } if (Math.abs(x1 - x2) < 0.0001) { const left = Math.floor(x1 - half); const right = Math.ceil(x1 + half); const top = Math.floor(Math.min(y1, y2) - half); const bottom = Math.ceil(Math.max(y1, y2) + half); g.fillRect( left, top, Math.max(1, right - left), Math.max(1, bottom - top), ); return; } if (Math.abs(y1 - y2) < 0.0001) { const left = Math.floor(Math.min(x1, x2) - half); const right = Math.ceil(Math.max(x1, x2) + half); const top = Math.floor(y1 - half); const bottom = Math.ceil(y1 + half); g.fillRect( left, top, Math.max(1, right - left), Math.max(1, bottom - top), ); } } // ── 箭头绘制 ── /** * 绘制圆角三角形箭头头部。 * @param g 目标 Graphics 对象 * @param cx 中心X坐标 * @param cy 中心Y坐标 * @param angle 旋转角度(弧度,0 = 朝上) * @param color 填充颜色 * @param scale 额外缩放因子(默认1) */ drawArrowHead( g: Phaser.GameObjects.Graphics, cx: number, cy: number, angle: number, color: number, scale: number = 1, ): void { const s = this.scene; const arrowWidth = s.visualSize * ARROW_SIZE_RATIO * scale; const arrowHeight = s.visualSize * ARROW_SIZE_RATIO * scale; const cos = Math.cos(angle); const sin = Math.sin(angle); const localPoints = [ { x: 0, y: -arrowHeight / 2 }, { x: -arrowWidth / 2, y: arrowHeight / 2 }, { x: arrowWidth / 2, y: arrowHeight / 2 }, ]; const worldPoints = localPoints.map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos, })); const [p0, p1, p2] = worldPoints; if (!p0 || !p1 || !p2) { return; } g.fillStyle(color, 1); if (s.pathCornerStyle === "square") { g.beginPath(); g.moveTo(p0.x, p0.y); g.lineTo(p1.x, p1.y); g.lineTo(p2.x, p2.y); g.closePath(); g.fillPath(); } else { const cornerRadius = Math.min(arrowWidth, arrowHeight) * 0.15; fillRoundedTriangle(g, p0, p1, p2, cornerRadius); } } // ── 方向工具 ── /** * 将方向字符串转换为单位增量 { dx, dy }。 */ static directionToDelta(direction: string): { dx: number; dy: number } { switch (direction) { case "left": return { dx: -1, dy: 0 }; case "right": return { dx: 1, dy: 0 }; case "up": return { dx: 0, dy: -1 }; case "down": return { dx: 0, dy: 1 }; default: return { dx: 0, dy: 0 }; } } /** * 将方向字符串转换为角度(弧度,0 = 朝上)。 */ static directionToAngle(direction: string): number { switch (direction) { case "up": return 0; case "right": return Math.PI / 2; case "down": return Math.PI; case "left": return -Math.PI / 2; default: return 0; } } /** * 实例方法包装:将方向字符串转换为单位增量。 * 供其他模块通过接口调用。 */ directionToDelta(direction: string): { dx: number; dy: number } { return PathRendererManager.directionToDelta(direction); } // ── 主题颜色 ── /** * 根据当前主题初始化每条路径的基础颜色,用于静态绘制和动画。 * @param level 关卡数据 * @param arrowBaseColor 主题箭头基础颜色 */ setupThemeColors(level: ParsedLevel, arrowBaseColor: number): void { this.arrowBaseColor = arrowBaseColor; const pathCount = level.paths.length; this.pathArrowColors = new Array(pathCount); for (let i = 0; i < pathCount; i++) { this.pathArrowColors[i] = this.arrowBaseColor; } } /** * 获取指定路径的基础颜色。 * @param pathIndex 路径索引 * @returns 颜色值 */ getPathBaseColor(pathIndex: number): number { const pathColor = this.scene.level?.paths[pathIndex]?.color; if (typeof pathColor === "string") { return this.parseColorToNumber(pathColor, this.arrowBaseColor); } return this.pathArrowColors[pathIndex] ?? this.arrowBaseColor; } /** * 解析颜色字符串为数字。 * @param spec 颜色字符串(支持 #RRGGBB 或 0xRRGGBB 格式) * @param fallback 默认值 */ private parseColorToNumber( spec: string | undefined, fallback: number = 0x000000, ): number { if (!spec) return fallback; const s = spec.trim(); if (/^#?[0-9a-fA-F]{6}$/.test(s)) { const hex = s.startsWith("#") ? s.slice(1) : s; return parseInt(hex, 16); } if (/^0x[0-9a-fA-F]{6}$/.test(s)) { return parseInt(s, 16); } return fallback; } // ── 初始化路径轨迹缓存 ── /** * 为关卡所有路径构建轨迹缓存。在 buildBoard 时调用。 * @param level 关卡数据 */ initPathTracks(level: ParsedLevel): void { this.pathTracks = level.paths.map((p) => this.buildPathTrack(p)); } // ── 网格路径渲染 ── /** * 渲染关卡中的所有路径(带 intro 错开动画)。 * @param level 关卡数据 * @param clampedProgress 入场动画进度(0-1) */ renderGridPaths(level: ParsedLevel, clampedProgress: number): void { const s = this.scene; const pathCount = level.paths.length; const staggerRatio = 0.33; const animDuration = 1 - staggerRatio; // 如果是新关卡或顺序为空,计算从随机角落开始的出现顺序 if (this.introPathOrder.length !== pathCount) { const corners = [ { x: 0, y: 0 }, { x: level.cols - 1, y: 0 }, { x: 0, y: level.rows - 1 }, { x: level.cols - 1, y: level.rows - 1 }, ]; const corner = corners[Math.floor(Math.random() * corners.length)]!; const pathDistances = level.paths.map((path, idx) => { const dx = path.head.x - corner.x; const dy = path.head.y - corner.y; return { index: idx, dist: Math.sqrt(dx * dx + dy * dy) }; }); pathDistances.sort((a, b) => a.dist - b.dist); this.introPathOrder = pathDistances.map((p) => p.index); } const orderMap = new Map(); this.introPathOrder.forEach((pathIdx, order) => { orderMap.set(pathIdx, order); }); const staggerStep = pathCount > 1 ? staggerRatio / (pathCount - 1) : 0; level.paths.forEach((path: LevelPath, index: number) => { if (s.removedPaths.has(index)) return; if (s.animatingPaths.has(index)) return; const order = orderMap.get(index) ?? index; const pathStartTime = order * staggerStep; const localProgress = Phaser.Math.Clamp( (clampedProgress - pathStartTime) / animDuration, 0, 1, ); if (localProgress <= 0) return; const baseColor = this.getPathBaseColor(index); const color = ENABLE_ERROR_PATH_HIGHLIGHT && s.blockedPaths.has(index) ? 0xff0000 : baseColor; const headCell = path.head; const track = this.pathTracks[index] ?? []; const trackLength = track.length; if (trackLength === 0) return; const visibleCells = localProgress >= 1 ? trackLength : Math.max(1, Math.floor(trackLength * localProgress)); const maxCellIndex = Math.min(visibleCells - 1, trackLength - 1); const bodyColor = color; if (trackLength > 1) { const lineWidth = s.visualSize * PATH_LINE_WIDTH_RATIO; s.boardGraphics.fillStyle(bodyColor, 1); this.getPathRendererForLineWidth(lineWidth).render({ g: s.boardGraphics, track, startSegmentIndex: 0, endSegmentIndexExclusive: maxCellIndex, cellSize: s.cellSize, lineWidth, headCell, cellCenter: (col, row) => this.cellCenter(col, row), fillAxisAlignedSegmentRect: (g, x1, y1, x2, y2, w) => this.fillAxisAlignedSegmentRect(g, x1, y1, x2, y2, w), }); } if (visibleCells < trackLength) return; const { x: hx, y: hy } = this.cellCenter(headCell.x, headCell.y); const angle = PathRendererManager.directionToAngle(path.direction); this.drawArrowHead(s.boardGraphics, hx, hy, angle, color); }); } // ── Hint 覆盖层渲染 ── /** * 渲染 Hint 高亮覆盖层(带脉冲缩放效果)。 */ renderHintOverlay(): void { const s = this.scene; const g = s.hintOverlayGraphics; if (!g) return; g.clear(); if (!s.level) return; const pathIndex = s.tutorialManager.hintTargetIndex; if (pathIndex === null) return; if (s.removedPaths.has(pathIndex)) return; if (s.animatingPaths.has(pathIndex)) return; const path = s.level.paths[pathIndex]; if (!path) return; const track = this.pathTracks[pathIndex] ?? this.buildPathTrack(path); const trackLength = track.length; if (trackLength <= 0) return; const baseColor = this.getPathBaseColor(pathIndex); const color = ENABLE_ERROR_PATH_HIGHLIGHT && s.blockedPaths.has(pathIndex) ? 0xff0000 : baseColor; if (trackLength > 1) { const lineWidth = s.visualSize * PATH_LINE_WIDTH_RATIO * s.tutorialManager.hintPulseScale; g.fillStyle(color, 1); this.getPathRendererForLineWidth(lineWidth).render({ g, track, startSegmentIndex: 0, endSegmentIndexExclusive: trackLength - 1, cellSize: s.cellSize, lineWidth, headCell: path.head, cellCenter: (col, row) => this.cellCenter(col, row), fillAxisAlignedSegmentRect: (g2, x1, y1, x2, y2, w) => this.fillAxisAlignedSegmentRect(g2, x1, y1, x2, y2, w), }); } const { x: hx, y: hy } = this.cellCenter(path.head.x, path.head.y); const angle = PathRendererManager.directionToAngle(path.direction); this.drawArrowHead( g, hx, hy, angle, color, s.tutorialManager.hintPulseScale, ); } }