import { _decorator, Component, Graphics, Vec2, Vec3, Color, } from 'cc'; const { ccclass, property } = _decorator; /** * 箭头流向线:支持多条路径。 * 主接口 setPaths:传入多条折线(本地坐标),每条线自动使用不同颜色。 */ @ccclass('ArrowFlowLine') export class ArrowFlowLine extends Component { @property({ type: [Vec2], tooltip: '单条路径(本地);代码调用 setPaths 后以此为空则忽略' }) pathPoints: Vec2[] = []; @property({ tooltip: '仅一条路径且未用 setPaths 时,作为该线箭头颜色', type: Color }) arrowColor: Color = new Color(0, 180, 255, 255); @property({ tooltip: '箭头长度(像素)', min: 2, max: 80 }) arrowLength: number = 16; @property({ tooltip: '箭头宽度(像素)', min: 1, max: 40 }) arrowWidth: number = 8; @property({ tooltip: '箭头头尾比例(0~1)', min: 0, max: 1, step: 0.05 }) arrowSharpness: number = 0.6; @property({ tooltip: '箭头间距比例(相对箭头长度)', min: 0, max: 5, step: 0.1 }) gapRatio: number = 0.5; @property({ tooltip: '拐点平滑采样数', min: 2, max: 30 }) smoothSamples: number = 10; @property({ tooltip: 'Catmull-Rom 张力', min: 0, max: 1, step: 0.05 }) tension: number = 0.85; @property({ tooltip: '启用流动动画' }) enableFlow: boolean = true; @property({ tooltip: '流动速度(像素/秒)', min: 1, max: 500 }) flowSpeed: number = 60; @property({ tooltip: 'true=正向' }) flowForward: boolean = true; @property({ tooltip: '流动时箭头重绘上限(帧/秒),0 表示不限制;移动端建议 20~30' }) flowMaxRedrawFps: number = 30; private _graphics: Graphics | null = null; private _flowOffset: number = 0; private _dirty: boolean = true; /** 路径几何变化后需至少重绘一次(静态线只画一次) */ private _geometryChanged: boolean = true; /** 缓存路径总长,避免每帧遍历 _built */ private _cachedMaxPathLen: number = 0; /** 流动重绘节流:累计时间 */ private _flowRedrawAccum: number = 0; /** sampleAtDistance / _drawArrow 复用,避免每箭头 new Vec2 */ private _samplePos = new Vec2(); private _sampleDir = new Vec2(); private _lastArrowFillColor: Color | null = null; /** 代码设置的路线;非 null 时优先于 pathPoints */ private _pathList: Vec2[][] | null = null; /** 与 _pathList 等长,每条线颜色 */ private _perRouteColors: Color[] = []; private _built: BuiltRoute[] = []; onLoad() { this._ensureGraphics(); } onEnable() { this._dirty = true; this._geometryChanged = true; } update(dt: number) { if (this._dirty) { this._rebuildPath(); this._dirty = false; this._geometryChanged = true; this._recomputeCachedMaxLen(); } const g = this._graphics; if (!g) return; if (this._built.length === 0) { g.clear(); this._lastArrowFillColor = null; return; } const period = this.arrowLength * (1 + this.gapRatio); if (this.enableFlow && this._cachedMaxPathLen > 0 && period >= 1) { const dir = this.flowForward ? 1 : -1; this._flowOffset += dir * this.flowSpeed * dt; if (this._flowOffset > period) this._flowOffset -= period; if (this._flowOffset < -period) this._flowOffset += period; } let shouldRedraw = false; if (this._geometryChanged) { shouldRedraw = true; } else if (!this.enableFlow) { shouldRedraw = false; } else { const maxFps = this.flowMaxRedrawFps; if (maxFps <= 0) { shouldRedraw = true; } else { this._flowRedrawAccum += dt; const interval = 1 / maxFps; if (this._flowRedrawAccum >= interval) { this._flowRedrawAccum -= interval; shouldRedraw = true; } } } if (shouldRedraw) { this._drawArrows(); this._geometryChanged = false; } } private _recomputeCachedMaxLen() { let maxLen = 0; for (const b of this._built) { if (b.totalLength > maxLen) maxLen = b.totalLength; } this._cachedMaxPathLen = maxLen; } /** * 设置多条路径(本地空间 Vec2/Vec3)。每条折线至少 2 个点;每条线颜色自动区分。 */ setPaths(paths: (Vec2 | Vec3)[][]) { const list: Vec2[][] = []; for (const way of paths) { if (!way?.length) continue; const pts = way.map(p => new Vec2(p.x, p.y)); if (pts.length >= 2) list.push(pts); } if (list.length === 0) { this._pathList = null; this._perRouteColors = []; } else { this._pathList = list; // 使用固定颜色数组,超出长度则循环使用 this._perRouteColors = list.map((_, i) => fixedRouteColor(i)); } this._dirty = true; } /** 单条路径;整线使用 arrowColor */ setPath(points: (Vec2 | Vec3)[]) { const pts = points.map(p => new Vec2(p.x, p.y)); if (pts.length < 2) { this._pathList = null; this._perRouteColors = []; } else { this._pathList = [pts]; this._perRouteColors = [this.arrowColor.clone()]; } this._dirty = true; } /** * 旧版:所有起点接所有终点连成一条线(本地坐标),颜色为 arrowColor */ setRoute(starts: (Vec2 | Vec3)[], ends: (Vec2 | Vec3)[]) { const pts: Vec2[] = []; for (const p of starts) pts.push(new Vec2(p.x, p.y)); for (const p of ends) pts.push(new Vec2(p.x, p.y)); this.setPath(pts); } private _ensureGraphics() { this._graphics = this.getComponent(Graphics); if (!this._graphics) { this._graphics = this.addComponent(Graphics); } this._graphics.lineWidth = 0; } private _collectPathInputs(): { points: Vec2[]; color: Color }[] { if (this._pathList !== null && this._pathList.length > 0) { const out: { points: Vec2[]; color: Color }[] = []; for (let i = 0; i < this._pathList.length; i++) { const color = this._perRouteColors[i]?.clone() ?? this.arrowColor.clone(); out.push({ points: this._pathList[i].map(p => p.clone()), color, }); } return out; } if (this.pathPoints.length >= 2) { return [{ points: this.pathPoints.map(p => p.clone()), color: this.arrowColor.clone(), }]; } return []; } private _rebuildPath() { this._built = []; const inputs = this._collectPathInputs(); for (const { points: pts, color } of inputs) { const smoothPts = this._catmullRomSmooth(pts); if (smoothPts.length < 2) continue; const segments: PathSegment[] = []; let accLen = 0; for (let i = 0; i < smoothPts.length - 1; i++) { const a = smoothPts[i]; const b = smoothPts[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; const len = Math.sqrt(dx * dx + dy * dy); if (len < 0.01) continue; segments.push({ start: a, end: b, length: len, startAccLen: accLen, dirX: dx / len, dirY: dy / len, }); accLen += len; } if (segments.length === 0) continue; this._built.push({ segments, totalLength: accLen, color }); } } private _catmullRomSmooth(points: Vec2[]): Vec2[] { const n = points.length; if (n < 2) return points.slice(); if (n === 2) return points.slice(); const result: Vec2[] = []; const t = this.tension; const samples = this.smoothSamples; const getPoint = (i: number): Vec2 => { if (i < 0) { const dx = points[0].x - points[1].x; const dy = points[0].y - points[1].y; return new Vec2(points[0].x + dx, points[0].y + dy); } if (i >= n) { const dx = points[n - 1].x - points[n - 2].x; const dy = points[n - 1].y - points[n - 2].y; return new Vec2(points[n - 1].x + dx, points[n - 1].y + dy); } return points[i]; }; for (let seg = 0; seg < n - 1; seg++) { const p0 = getPoint(seg - 1); const p1 = getPoint(seg); const p2 = getPoint(seg + 1); const p3 = getPoint(seg + 2); for (let s = 0; s < samples; s++) { const tt = s / samples; const pos = catmullRomPoint(p0, p1, p2, p3, tt, t); if (result.length > 0) { const last = result[result.length - 1]; const dx = pos.x - last.x; const dy = pos.y - last.y; if (dx * dx + dy * dy < 0.01) continue; } result.push(pos); } } result.push(points[n - 1].clone()); return result; } private _drawArrows() { const g = this._graphics; if (!g) return; g.clear(); this._lastArrowFillColor = null; const period = this.arrowLength * (1 + this.gapRatio); if (period < 1) return; let dist0 = this._flowOffset; dist0 = ((dist0 % period) + period) % period; for (const br of this._built) { if (br.totalLength < 1 || br.segments.length === 0) continue; let dist = dist0; while (dist < br.totalLength) { if (this._sampleAtDistanceInto(br.segments, br.totalLength, dist, this._samplePos, this._sampleDir)) { this._drawArrow(g, this._samplePos, this._sampleDir, br.color); } dist += period; } } } private _sampleAtDistanceInto( segments: PathSegment[], totalLength: number, d: number, outPos: Vec2, outDir: Vec2, ): boolean { if (d < 0 || d > totalLength) return false; for (const seg of segments) { const localD = d - seg.startAccLen; if (localD >= 0 && localD <= seg.length) { const t = seg.length > 0 ? localD / seg.length : 0; outPos.x = seg.start.x + (seg.end.x - seg.start.x) * t; outPos.y = seg.start.y + (seg.end.y - seg.start.y) * t; outDir.x = seg.dirX; outDir.y = seg.dirY; return true; } } return false; } private _drawArrow(g: Graphics, pos: Vec2, dir: Vec2, color: Color) { // 简化为截图中那种小实心三角箭头 const len = this.arrowLength; const halfW = this.arrowWidth / 2; const dx = dir.x; const dy = dir.y; const nx = -dy; const ny = dx; const cx = pos.x; const cy = pos.y; // 箭头尖端:沿着 dir 方向 const tipX = cx + dx * len * 0.5; const tipY = cy + dy * len * 0.5; // 箭头底边中心:反向一点 const baseX = cx - dx * len * 0.3; const baseY = cy - dy * len * 0.3; const leftX = baseX + nx * halfW; const leftY = baseY + ny * halfW; const rightX = baseX - nx * halfW; const rightY = baseY - ny * halfW; if (!this._lastArrowFillColor || !Color.equals(this._lastArrowFillColor, color)) { g.fillColor = color; g.strokeColor = color; if (!this._lastArrowFillColor) { this._lastArrowFillColor = new Color(); } this._lastArrowFillColor.set(color); } g.moveTo(tipX, tipY); g.lineTo(leftX, leftY); g.lineTo(rightX, rightY); g.close(); g.fill(); } } interface PathSegment { start: Vec2; end: Vec2; length: number; startAccLen: number; dirX: number; dirY: number; } interface BuiltRoute { segments: PathSegment[]; totalLength: number; color: Color; } const kRouteColors: Color[] = [ Color.WHITE, Color.BLACK, Color.RED, Color.GREEN, Color.CYAN, Color.MAGENTA, Color.YELLOW, ]; function fixedRouteColor(index: number): Color { if (kRouteColors.length === 0) { return Color.WHITE; } const c = kRouteColors[index % kRouteColors.length]; return c.clone(); } function hsvToColor(h: number, s: number, v: number): Color { const hh = (h % 1 + 1) % 1 * 6; const i = Math.floor(hh); const f = hh - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); let r = 0; let g = 0; let b = 0; switch (i % 6) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; default: r = v; g = p; b = q; break; } return new Color(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), 255); } function catmullRomPoint( p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number, tension: number, ): Vec2 { const s = (1 - tension) / 2; const t2 = t * t; const t3 = t2 * t; const ax = -s * p0.x + (2 - s) * p1.x + (s - 2) * p2.x + s * p3.x; const bx = 2 * s * p0.x + (s - 3) * p1.x + (3 - 2 * s) * p2.x - s * p3.x; const cx = -s * p0.x + s * p2.x; const dx = p1.x; const ay = -s * p0.y + (2 - s) * p1.y + (s - 2) * p2.y + s * p3.y; const by = 2 * s * p0.y + (s - 3) * p1.y + (3 - 2 * s) * p2.y - s * p3.y; const cy = -s * p0.y + s * p2.y; const dy = p1.y; return new Vec2( ax * t3 + bx * t2 + cx * t + dx, ay * t3 + by * t2 + cy * t + dy, ); }