import * as Phaser from "phaser"; import { PATH_CORNER_RADIUS } from "../DebugConfig"; /** 路径轨迹中的单元格坐标 */ export type PathTrackCell = { x: number; y: number }; /** 路径渲染所需的参数集合 */ export type PathRenderParams = { g: Phaser.GameObjects.Graphics; track: PathTrackCell[]; startSegmentIndex: number; endSegmentIndexExclusive: number; cellSize: number; lineWidth: number; headCell?: PathTrackCell; cellCenter: (col: number, row: number) => { x: number; y: number }; fillAxisAlignedSegmentRect: ( g: Phaser.GameObjects.Graphics, x1: number, y1: number, x2: number, y2: number, lineWidth: number, ) => void; }; /** 路径渲染器接口,定义统一的渲染方法 */ export interface PathRenderer { render(params: PathRenderParams): void; } /** * 判断三个相邻单元格是否构成一个轴对齐的拐弯(即90度直角转弯) * @param prev 前一个单元格 * @param cur 当前单元格(拐角处) * @param next 下一个单元格 * @returns 如果是轴对齐的90度转弯则返回 true */ export function isAxisAlignedTurn( prev: PathTrackCell, cur: PathTrackCell, next: PathTrackCell, ): boolean { const dx1 = cur.x - prev.x; const dy1 = cur.y - prev.y; const dx2 = next.x - cur.x; const dy2 = next.y - cur.y; const inAxis = (dx1 === 0 && dy1 !== 0) || (dy1 === 0 && dx1 !== 0); const outAxis = (dx2 === 0 && dy2 !== 0) || (dy2 === 0 && dx2 !== 0); if (!inAxis || !outAxis) return false; return dx1 * dx2 + dy1 * dy2 === 0; } /** * 计算从一个单元格到另一个单元格的轴对齐单位方向向量 * @param from 起始单元格 * @param to 目标单元格 * @returns 单位方向向量,仅在水平或垂直方向上为 ±1,否则为 {0, 0} */ export function axisUnitDir( from: PathTrackCell, to: PathTrackCell, ): { x: number; y: number } { const dx = to.x - from.x; const dy = to.y - from.y; if (dx !== 0) return { x: dx > 0 ? 1 : -1, y: 0 }; if (dy !== 0) return { x: 0, y: dy > 0 ? 1 : -1 }; return { x: 0, y: 0 }; } /** * 绘制一段环形弧(甜甜圈形状的弧形区域) * 通过外弧和内弧围成的封闭区域来填充,用于绘制有宽度的圆角路径 * @param g Phaser 图形对象 * @param cx 弧心 X 坐标 * @param cy 弧心 Y 坐标 * @param innerR 内圆半径 * @param outerR 外圆半径 * @param startAngle 起始角度(弧度) * @param endAngle 结束角度(弧度) * @param anticlockwise 是否逆时针绘制 */ function fillDonutArc( g: Phaser.GameObjects.Graphics, cx: number, cy: number, innerR: number, outerR: number, startAngle: number, endAngle: number, anticlockwise: boolean, ): void { const TAU = Math.PI * 2; const r = Math.max(1, outerR); let delta = endAngle - startAngle; delta = ((delta % TAU) + TAU) % TAU; if (anticlockwise) { if (delta > 0) delta -= TAU; } else { if (delta < 0) delta += TAU; } const sweep = Math.max(0.0001, Math.abs(delta)); const smallRadius = r < 10; const targetArcLenPerSeg = smallRadius ? 0.85 : 1.15; const minSeg = smallRadius ? 8 : 6; const maxSeg = smallRadius ? 16 : 12; const segments = Phaser.Math.Clamp( Math.ceil((sweep * r) / targetArcLenPerSeg), minSeg, maxSeg, ); const step = delta / segments; g.beginPath(); for (let i = 0; i <= segments; i++) { const a = startAngle + step * i; const x = cx + Math.cos(a) * outerR; const y = cy + Math.sin(a) * outerR; if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); } if (innerR > 0.5) { for (let i = segments; i >= 0; i--) { const a = startAngle + step * i; const x = cx + Math.cos(a) * innerR; const y = cy + Math.sin(a) * innerR; g.lineTo(x, y); } } else { g.lineTo(cx, cy); } g.closePath(); g.fillPath(); } /** 缓存四分之一弧的单位向量(cos/sin 值),避免重复三角函数计算 */ const quarterArcUnitCache = new Map(); /** * 将轴对齐的单位向量转换为对应的标准角度 * @param v 单位方向向量(仅支持四个轴方向) * @returns 对应角度(弧度),非轴方向返回 null */ function axisAngle(v: { x: number; y: number }): number | null { if (v.x === 1 && v.y === 0) return 0; if (v.x === 0 && v.y === 1) return Math.PI / 2; if (v.x === -1 && v.y === 0) return Math.PI; if (v.x === 0 && v.y === -1) return -Math.PI / 2; return null; } /** * 获取四分之一圆弧上各采样点的单位向量(cos/sin),带缓存优化 * @param startAngle 起始角度(弧度) * @param anticlockwise 是否逆时针 * @param segments 弧线分段数 * @returns 包含 cos 和 sin 数组的对象 */ function getQuarterArcUnitVectors( startAngle: number, anticlockwise: boolean, segments: number, ): { cos: number[]; sin: number[]; } { const key = `${segments}:${startAngle}:${anticlockwise ? 1 : 0}`; const cached = quarterArcUnitCache.get(key); if (cached) { return cached; } const cos: number[] = []; const sin: number[] = []; const delta = (anticlockwise ? -1 : 1) * (Math.PI / 2); for (let i = 0; i <= segments; i++) { const a = startAngle + (delta * i) / segments; cos.push(Math.cos(a)); sin.push(Math.sin(a)); } const built = { cos, sin }; quarterArcUnitCache.set(key, built); return built; } /** * 使用缓存的单位向量绘制四分之一环形弧(性能优化版本) * @param g Phaser 图形对象 * @param cx 弧心 X 坐标 * @param cy 弧心 Y 坐标 * @param innerR 内圆半径 * @param outerR 外圆半径 * @param startAngle 起始角度(弧度) * @param anticlockwise 是否逆时针绘制 * @param segments 弧线分段数 */ function fillQuarterDonutArcCached( g: Phaser.GameObjects.Graphics, cx: number, cy: number, innerR: number, outerR: number, startAngle: number, anticlockwise: boolean, segments: number, ): void { const u = getQuarterArcUnitVectors(startAngle, anticlockwise, segments); g.beginPath(); for (let i = 0; i <= segments; i++) { const x = cx + (u.cos[i] ?? 0) * outerR; const y = cy + (u.sin[i] ?? 0) * outerR; if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); } if (innerR > 0.5) { for (let i = segments; i >= 0; i--) { g.lineTo(cx + (u.cos[i] ?? 0) * innerR, cy + (u.sin[i] ?? 0) * innerR); } } else { g.lineTo(cx, cy); } g.closePath(); g.fillPath(); } /** * 方形路径渲染器:使用直角矩形绘制路径段,拐角处用方形填充 * 适用于不需要圆角的简洁路径样式 */ export const SquarePathRenderer: PathRenderer = { render: (params) => { const { g, track, startSegmentIndex, endSegmentIndexExclusive, cellSize, lineWidth, headCell, cellCenter, fillAxisAlignedSegmentRect, } = params; for (let i = startSegmentIndex; i < endSegmentIndexExclusive; i++) { const a = track[i]; const b = track[i + 1]; if (!a || !b) continue; const ca = cellCenter(a.x, a.y); const cb = cellCenter(b.x, b.y); const x1 = ca.x; const y1 = ca.y; let x2 = cb.x; let y2 = cb.y; const isHeadSegment = !!headCell && b.x === headCell.x && b.y === headCell.y; if (isHeadSegment) { const len = Math.abs(x2 - x1) + Math.abs(y2 - y1); if (len > 0.0001) { const shrink = Math.min(cellSize * 0.22, len * 0.5); const sx = x2 === x1 ? 0 : x2 > x1 ? 1 : -1; const sy = y2 === y1 ? 0 : y2 > y1 ? 1 : -1; x2 -= sx * shrink; y2 -= sy * shrink; } } if (Math.abs(x1 - x2) < 0.0001 || Math.abs(y1 - y2) < 0.0001) { fillAxisAlignedSegmentRect(g, x1, y1, x2, y2, lineWidth); } } for (let i = 1; i + 1 < track.length; i++) { const prev = track[i - 1]; const cur = track[i]; const next = track[i + 1]; if (!prev || !cur || !next) continue; const prevSegIndex = i - 1; const nextSegIndex = i; if ( prevSegIndex < startSegmentIndex || nextSegIndex >= endSegmentIndexExclusive ) { continue; } if (!isAxisAlignedTurn(prev, cur, next)) { continue; } if (headCell && cur.x === headCell.x && cur.y === headCell.y) { continue; } const cc = cellCenter(cur.x, cur.y); fillAxisAlignedSegmentRect(g, cc.x, cc.y, cc.x, cc.y, lineWidth); } }, }; /** * 绘制一个带圆角的填充三角形(用于箭头等图形) * 每个顶点处用二次贝塞尔曲线平滑过渡,近似圆弧效果 * @param g Phaser 图形对象 * @param p0 顶点 0(箭头尖端) * @param p1 顶点 1(左下角) * @param p2 顶点 2(右下角) * @param radius 圆角半径(会自动限制在安全最大值内) */ export function fillRoundedTriangle( g: Phaser.GameObjects.Graphics, p0: { x: number; y: number }, p1: { x: number; y: number }, p2: { x: number; y: number }, radius: number, ) { const pts = [p0, p1, p2]; // 限制圆角半径,使其不超过最短边的安全比例 const edgeLen = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)); const minEdge = Math.min(edgeLen(p0, p1), edgeLen(p1, p2), edgeLen(p2, p0)); const r = Math.min(radius, minEdge * 0.35); if (r < 0.5) { // 半径过小,回退为尖角三角形 g.beginPath(); g.moveTo(p0.x, p0.y); g.lineTo(p1.x, p1.y); g.lineTo(p2.x, p2.y); g.closePath(); g.fillPath(); return; } // 用于近似每个圆角弧的线段数量 const arcSegments = 8; g.beginPath(); for (let i = 0; i < 3; i++) { const prev = pts[(i + 2) % 3]!; const curr = pts[i]!; const next = pts[(i + 1) % 3]!; // 从当前顶点指向相邻顶点的单位方向向量 let dPrevX = prev.x - curr.x; let dPrevY = prev.y - curr.y; const lenPrev = Math.sqrt(dPrevX * dPrevX + dPrevY * dPrevY); dPrevX /= lenPrev; dPrevY /= lenPrev; let dNextX = next.x - curr.x; let dNextY = next.y - curr.y; const lenNext = Math.sqrt(dNextX * dNextX + dNextY * dNextY); dNextX /= lenNext; dNextY /= lenNext; // 圆角起点和终点(沿每条边向内缩进 r 的位置) const startX = curr.x + dPrevX * r; const startY = curr.y + dPrevY * r; const endX = curr.x + dNextX * r; const endY = curr.y + dNextY * r; if (i === 0) { g.moveTo(startX, startY); } else { g.lineTo(startX, startY); } // 使用二次贝塞尔曲线插值(控制点为原始顶点)来用线段近似该角的弧线 for (let s = 1; s <= arcSegments; s++) { const t = s / arcSegments; const mt = 1 - t; // 二次贝塞尔曲线: B(t) = (1-t)^2 * start + 2*(1-t)*t * ctrl + t^2 * end const bx = mt * mt * startX + 2 * mt * t * curr.x + t * t * endX; const by = mt * mt * startY + 2 * mt * t * curr.y + t * t * endY; g.lineTo(bx, by); } } g.closePath(); g.fillPath(); } /** * 圆角路径渲染器:路径直线段之间的拐角使用平滑圆弧过渡 * 通过环形弧(donut arc)实现有宽度的圆角效果,视觉更加美观 */ export const RoundedCornerPathRenderer: PathRenderer = { render: (params) => { const { g, track, startSegmentIndex, endSegmentIndexExclusive, cellSize, lineWidth, headCell, cellCenter, fillAxisAlignedSegmentRect, } = params; if (endSegmentIndexExclusive <= startSegmentIndex) { return; } const useSubpixelAA = cellSize < 18; const w = Math.max(1, useSubpixelAA ? lineWidth : Math.round(lineWidth)); const halfW = w * 0.5; const maxR = cellSize * PATH_CORNER_RADIUS; const desiredR = Math.max( halfW + (useSubpixelAA ? 0.75 : 1), useSubpixelAA ? Math.max(w * 1.25, cellSize * 0.32) : Math.round(Math.max(w * 1.25, cellSize * 0.32)), ); const r = Math.min(maxR, desiredR); const innerR = Math.max(0, r - halfW); const outerR = r + halfW; const TAU = Math.PI * 2; // 在非亚像素模式下,fillAxisAlignedSegmentRect 通过 floor/ceil // 将端点扩展了 `half` 的距离,导致几个像素突入圆角弧区域。 // 通过额外裁剪 `halfW` 来补偿,使直线段恰好在弧线起点处停止。 const cornerTrim = useSubpixelAA ? r : r + halfW; // Trim amount for the tail cap: retract the rectangle starting end so // the semicircle cap is visible. In subpixel mode the rect does NOT // extend past the endpoint, so no trim is needed; in non-subpixel mode // floor/ceil extend the rect by ~halfW, which would cover the cap. const tailCapTrim = useSubpixelAA ? 0 : halfW; const arcR = Math.max(1, outerR); const sweep = Math.PI / 2; const smallRadius = arcR < 10; const targetArcLenPerSeg = smallRadius ? 0.85 : 1.15; const minSeg = smallRadius ? 8 : 6; const maxSeg = smallRadius ? 16 : 12; const arcSegments = Phaser.Math.Clamp( Math.ceil((sweep * arcR) / targetArcLenPerSeg), minSeg, maxSeg, ); const snap = (v: number) => { if (useSubpixelAA) { return v; } if (w % 2 === 0) { return Math.round(v); } return Math.round(v - 0.5) + 0.5; }; for (let i = startSegmentIndex; i < endSegmentIndexExclusive; ) { const a0 = track[i]; const b0 = track[i + 1]; if (!a0 || !b0) { i += 1; continue; } const dir0 = axisUnitDir(a0, b0); if (dir0.x === 0 && dir0.y === 0) { i += 1; continue; } let j = i; while (j + 1 < endSegmentIndexExclusive) { const aj = track[j + 1]; const bj = track[j + 2]; if (!aj || !bj) break; const dirj = axisUnitDir(aj, bj); if (dirj.x !== dir0.x || dirj.y !== dir0.y) break; j += 1; } const a = track[i]; const b = track[j + 1]; if (!a || !b) { i = j + 1; continue; } const ca0 = cellCenter(a.x, a.y); const cb0 = cellCenter(b.x, b.y); const ca = { x: snap(ca0.x), y: snap(ca0.y) }; const cb = { x: snap(cb0.x), y: snap(cb0.y) }; const isAxisAligned = Math.abs(ca.x - cb.x) < 0.0001 || Math.abs(ca.y - cb.y) < 0.0001; if (!isAxisAligned) { i = j + 1; continue; } let trimStart = 0; let trimEnd = 0; // Tail cap: retract the first segment so the semicircle is visible const isTailSegment = i === startSegmentIndex; if (isTailSegment) { trimStart = tailCapTrim; } if (i > startSegmentIndex && i - 1 >= 0 && i + 1 < track.length) { const prev = track[i - 1]; const cur = track[i]; const next = track[i + 1]; if (prev && cur && next && isAxisAlignedTurn(prev, cur, next)) { trimStart = cornerTrim; } } if (j + 1 < endSegmentIndexExclusive && j + 2 < track.length) { const prev = track[j]; const cur = track[j + 1]; const next = track[j + 2]; if (prev && cur && next && isAxisAlignedTurn(prev, cur, next)) { trimEnd = cornerTrim; } } const isHeadSegment = !!headCell && b.x === headCell.x && b.y === headCell.y; if (isHeadSegment) { const len = Math.abs(cb.x - ca.x) + Math.abs(cb.y - ca.y); trimEnd += Math.min(cellSize * 0.22, len * 0.5); } const len = Math.abs(cb.x - ca.x) + Math.abs(cb.y - ca.y); const maxTotal = Math.max(0, len - 0.0001); const total = trimStart + trimEnd; if (total > maxTotal && total > 0.0001) { const s = maxTotal / total; trimStart *= s; trimEnd *= s; } let x1 = ca.x; let y1 = ca.y; let x2 = cb.x; let y2 = cb.y; if (Math.abs(x1 - x2) < 0.0001) { const s = y2 > y1 ? 1 : -1; y1 += s * trimStart; y2 -= s * trimEnd; } else { const s = x2 > x1 ? 1 : -1; x1 += s * trimStart; x2 -= s * trimEnd; } if (Math.abs(x2 - x1) + Math.abs(y2 - y1) >= 0.5) { fillAxisAlignedSegmentRect(g, x1, y1, x2, y2, w); } i = j + 1; } // ── Tail rounded cap ── // Draw a semicircle at the tail end (track[startSegmentIndex]) so the // tail looks rounded instead of flat. { const tailCell = track[startSegmentIndex]; const nextCell = track[startSegmentIndex + 1]; if (tailCell && nextCell) { const tc0 = cellCenter(tailCell.x, tailCell.y); const tc = { x: snap(tc0.x), y: snap(tc0.y) }; const tailDir = axisUnitDir(tailCell, nextCell); // The cap faces opposite to the path direction (outward from tail) // startAngle: perpendicular to path dir, sweep π (semicircle) // For dir (1,0) -> cap faces left -> arc from -π/2 to π/2 (centered at angle π) // For dir (-1,0)-> cap faces right -> arc from π/2 to -π/2 (centered at angle 0) // For dir (0,1) -> cap faces up -> arc from π to 0 (centered at angle -π/2) // For dir (0,-1)-> cap faces down -> arc from 0 to π (centered at angle π/2) if (tailDir.x !== 0 || tailDir.y !== 0) { // Angle of the outward direction (opposite to path direction) const outAngle = Math.atan2(-tailDir.y, -tailDir.x); const capStart = outAngle - Math.PI / 2; const capEnd = outAngle + Math.PI / 2; fillDonutArc(g, tc.x, tc.y, 0, halfW, capStart, capEnd, false); } } } for (let k = startSegmentIndex + 1; k < endSegmentIndexExclusive; k++) { const prev = track[k - 1]; const cur = track[k]; const next = track[k + 1]; if (!prev || !cur || !next) continue; if (!isAxisAlignedTurn(prev, cur, next)) continue; const corner0 = cellCenter(cur.x, cur.y); const corner = { x: snap(corner0.x), y: snap(corner0.y) }; const uPrev = axisUnitDir(cur, prev); const uNext = axisUnitDir(cur, next); const cx = corner.x + (uPrev.x + uNext.x) * r; const cy = corner.y + (uPrev.y + uNext.y) * r; const vStart = { x: -uNext.x, y: -uNext.y }; const vEnd = { x: -uPrev.x, y: -uPrev.y }; const aStartAxis = axisAngle(vStart); const aEndAxis = axisAngle(vEnd); if (aStartAxis === null || aEndAxis === null) { const aStart = Math.atan2(vStart.y, vStart.x); const aEnd = Math.atan2(vEnd.y, vEnd.x); const deltaCW = (((aEnd - aStart) % TAU) + TAU) % TAU; const anticlockwise = TAU - deltaCW < deltaCW; fillDonutArc(g, cx, cy, innerR, outerR, aStart, aEnd, anticlockwise); continue; } const deltaCW = (((aEndAxis - aStartAxis) % TAU) + TAU) % TAU; const anticlockwise = TAU - deltaCW < deltaCW; fillQuarterDonutArcCached( g, cx, cy, innerR, outerR, aStartAxis, anticlockwise, arcSegments, ); } }, };