You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
461 lines
13 KiB
461 lines
13 KiB
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,
|
|
);
|
|
}
|
|
|