@cc-component/cc-pathfinding 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/pathfinding/Interface.ts +3 -1
- package/assets/pathfinding/base/AStarPathfinder.ts +51 -13
- package/assets/pathfinding/base/GridMap.ts +19 -3
- package/assets/pathfinding/base/GridMovementController.ts +15 -7
- package/assets/pathfinding/base/MapBoxView.ts +11 -3
- package/assets/pathfinding/base/PathfindingModule.ts +4 -2
- package/package.json +1 -1
|
@@ -64,8 +64,10 @@ declare global {
|
|
|
64
64
|
* @param allowOverlap 为 true 时该单位进入"允许重叠"模式,
|
|
65
65
|
* 与其他同样开启该开关的单位可以走到同一格(互相穿过,不再互相绕行/互相等待),
|
|
66
66
|
* 与终点是否相同无关
|
|
67
|
+
* @param moveDuration 本次移动的"单格移动时长",单位秒(越小越快);
|
|
68
|
+
* 不传或传 <= 0 时使用 InitMovementController 配置的默认值
|
|
67
69
|
*/
|
|
68
|
-
export function MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap?: boolean): void
|
|
70
|
+
export function MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap?: boolean, moveDuration?: number): void
|
|
69
71
|
/** 取消移动 */
|
|
70
72
|
export function CancelMove(playerNode: Node, preserveCurrent?: boolean): void
|
|
71
73
|
/** 销毁移动控制器 */
|
|
@@ -87,6 +87,14 @@ export class AStarPathfinder {
|
|
|
87
87
|
/** 转向惩罚:极小值,只用于等长路径的平局消解,不影响步数最优性 */
|
|
88
88
|
private static readonly TURN_PENALTY = 0.001;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* 偏离直线惩罚:极小值,四方向模式下替代转向惩罚做平局消解。
|
|
92
|
+
* 四方向里所有单调路径步数相同,若按"拐弯最少"消解会得到先横到底再竖到底的直角路线;
|
|
93
|
+
* 改成按"偏离 start→target 直线的程度"消解,路线就会贴着对角线走成阶梯状。
|
|
94
|
+
* 单步惩罚上限 0.5 * 该值,远小于 1 步,不改变步数最优性。
|
|
95
|
+
*/
|
|
96
|
+
private static readonly LINE_DEVIATION_PENALTY = 0.0001;
|
|
97
|
+
|
|
90
98
|
constructor(private readonly grid: GridMap) { }
|
|
91
99
|
|
|
92
100
|
/**
|
|
@@ -98,17 +106,18 @@ export class AStarPathfinder {
|
|
|
98
106
|
if (!this.grid.isWithinBounds(start.row, start.col) || !this.grid.isWithinBounds(end.row, end.col)) {
|
|
99
107
|
return [];
|
|
100
108
|
}
|
|
101
|
-
return this.search(start, [end]);
|
|
109
|
+
return this.search(start, [end], end);
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/**
|
|
105
113
|
* 多目标 A*:一次搜索,命中 targets 中任意一个格子即返回。
|
|
106
114
|
* A* 按真实路径长度扩展,所以第一个命中的目标一定是“实际走过去最近”的那个,
|
|
107
115
|
* 比按直线距离逐个试 A* 更优,且只搜一次。
|
|
116
|
+
* lineTarget:仅用于四方向模式的平局消解(参考直线终点),不传则不启用。
|
|
108
117
|
*/
|
|
109
|
-
findPathToNearest(start: Cell, targets: Cell[]): Cell[] {
|
|
118
|
+
findPathToNearest(start: Cell, targets: Cell[], lineTarget?: Cell): Cell[] {
|
|
110
119
|
const validTargets = targets.filter(t => this.grid.isWithinBounds(t.row, t.col));
|
|
111
|
-
return this.search(start, validTargets);
|
|
120
|
+
return this.search(start, validTargets, lineTarget);
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
/**
|
|
@@ -125,7 +134,7 @@ export class AStarPathfinder {
|
|
|
125
134
|
findPathToNearby(start: Cell, target: Cell, maxDistance: number = 1): Cell[] {
|
|
126
135
|
// 终点本身可站:优先直达,避免绕到终点旁边再折返
|
|
127
136
|
if (this.grid.isWithinBounds(target.row, target.col) && !this.grid.isOccupied(target.row, target.col)) {
|
|
128
|
-
const direct = this.search(start, [target]);
|
|
137
|
+
const direct = this.search(start, [target], target);
|
|
129
138
|
if (direct.length > 0) {
|
|
130
139
|
return direct;
|
|
131
140
|
}
|
|
@@ -138,7 +147,7 @@ export class AStarPathfinder {
|
|
|
138
147
|
while (radius <= maxRadius) {
|
|
139
148
|
const availablePoints = this.grid.findAvailablePointsInRange(target.row, target.col, radius);
|
|
140
149
|
if (availablePoints.length > 0) {
|
|
141
|
-
const path = this.findPathToNearest(start, availablePoints);
|
|
150
|
+
const path = this.findPathToNearest(start, availablePoints, target);
|
|
142
151
|
if (path.length > 0) {
|
|
143
152
|
return path;
|
|
144
153
|
}
|
|
@@ -157,15 +166,28 @@ export class AStarPathfinder {
|
|
|
157
166
|
* A* 按 g + h 从小到大扩展,第一个出堆的目标必为真实路径最短的目标。
|
|
158
167
|
*
|
|
159
168
|
* 路径质量(平局消解):步数相同的路径代价完全相等时,A* 会按入堆顺序
|
|
160
|
-
* 随机取一条,可能返回“之”字形等长路径。这里给 g
|
|
161
|
-
*
|
|
162
|
-
*
|
|
169
|
+
* 随机取一条,可能返回“之”字形等长路径。这里给 g 加一个极小的附加代价
|
|
170
|
+
* (远小于 1 步),不改变步数最优性,只决定等长路径里选哪一条:
|
|
171
|
+
* - 四方向:按偏离 start→lineTarget 直线的程度(LINE_DEVIATION_PENALTY),
|
|
172
|
+
* 路线贴着对角线走成阶梯状,避免“先横到底再竖到底”的直角路线
|
|
173
|
+
* - 其他模式:按转向次数(TURN_PENALTY),优先拐弯最少(最顺直)的那条
|
|
163
174
|
*/
|
|
164
|
-
private search(start: Cell, targets: Cell[]): Cell[] {
|
|
175
|
+
private search(start: Cell, targets: Cell[], lineTarget?: Cell): Cell[] {
|
|
165
176
|
if (targets.length === 0) {
|
|
166
177
|
return [];
|
|
167
178
|
}
|
|
168
179
|
|
|
180
|
+
// 四方向平局消解参考直线:start → lineTarget
|
|
181
|
+
let lineDRow = 0;
|
|
182
|
+
let lineDCol = 0;
|
|
183
|
+
let lineLenSq = 0;
|
|
184
|
+
if (lineTarget) {
|
|
185
|
+
lineDRow = lineTarget.row - start.row;
|
|
186
|
+
lineDCol = lineTarget.col - start.col;
|
|
187
|
+
lineLenSq = lineDRow * lineDRow + lineDCol * lineDCol;
|
|
188
|
+
}
|
|
189
|
+
const useLineDeviation = this.grid.useFourDirection && lineLenSq > 0;
|
|
190
|
+
|
|
169
191
|
const targetKeys = new Set<string>();
|
|
170
192
|
for (const t of targets) {
|
|
171
193
|
targetKeys.add(`${t.row},${t.col}`);
|
|
@@ -230,11 +252,14 @@ export class AStarPathfinder {
|
|
|
230
252
|
continue;
|
|
231
253
|
}
|
|
232
254
|
|
|
233
|
-
// 步数代价 +
|
|
234
|
-
// 换方向记 1 + TURN_PENALTY。总惩罚远小于 1 步,
|
|
235
|
-
// 不改变步数最优性,只在等长路径中优先拐弯最少(最顺直)的
|
|
255
|
+
// 步数代价 + 极小附加代价(总附加远小于 1 步,不改变步数最优性,只用于平局消解)
|
|
236
256
|
let stepCost = 1;
|
|
237
|
-
if (
|
|
257
|
+
if (useLineDeviation) {
|
|
258
|
+
// 偏离 start→lineTarget 直线越远,代价越高 → 等长路径里选最贴对角线的那条
|
|
259
|
+
stepCost += AStarPathfinder.LINE_DEVIATION_PENALTY
|
|
260
|
+
* this.getLineDeviation(newRow, newCol, start, lineDRow, lineDCol, lineLenSq);
|
|
261
|
+
} else if (current.parent) {
|
|
262
|
+
// 延续父节点的行进方向记 1,换方向记 1 + TURN_PENALTY → 优先拐弯最少(最顺直)的
|
|
238
263
|
const keptDirection =
|
|
239
264
|
current.row - current.parent.row === dRow &&
|
|
240
265
|
current.col - current.parent.col === dCol;
|
|
@@ -261,4 +286,17 @@ export class AStarPathfinder {
|
|
|
261
286
|
// 开放列表耗尽仍未命中任何目标:无可达路径
|
|
262
287
|
return [];
|
|
263
288
|
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 格子到参考直线(from → from + (dRow, dCol))的归一化偏离度。
|
|
292
|
+
* 用叉积 / |直线|² 表示,等价于垂直距离 / |直线|,值域 [0, 0.5],
|
|
293
|
+
* 与地图尺寸无关,保证附加代价上限恒为 0.5 * LINE_DEVIATION_PENALTY。
|
|
294
|
+
*/
|
|
295
|
+
private getLineDeviation(
|
|
296
|
+
row: number, col: number,
|
|
297
|
+
from: Cell, dRow: number, dCol: number, lenSq: number,
|
|
298
|
+
): number {
|
|
299
|
+
const cross = Math.abs(dRow * (col - from.col) - dCol * (row - from.row));
|
|
300
|
+
return cross / lenSq;
|
|
301
|
+
}
|
|
264
302
|
}
|
|
@@ -23,14 +23,18 @@ export class GridMap {
|
|
|
23
23
|
readonly colCount: number;
|
|
24
24
|
/** 六方向模式:按蜂窝规则(左上/左/左下/右上/右/右下)移动、寻路、判包围 */
|
|
25
25
|
readonly useSixDirection: boolean;
|
|
26
|
+
/** 四方向模式:只能走 左/下/右/上(优先级高于六方向) */
|
|
27
|
+
readonly useFourDirection: boolean;
|
|
26
28
|
|
|
27
29
|
/** 被占用格子的 key 集合 */
|
|
28
30
|
private readonly occupied: Set<string> = new Set();
|
|
29
31
|
|
|
30
|
-
constructor(rowCount: number, colCount: number, useSixDirection: boolean) {
|
|
32
|
+
constructor(rowCount: number, colCount: number, useSixDirection: boolean, useFourDirection = false) {
|
|
31
33
|
this.rowCount = rowCount;
|
|
32
34
|
this.colCount = colCount;
|
|
33
|
-
this.
|
|
35
|
+
this.useFourDirection = useFourDirection;
|
|
36
|
+
// 四方向优先级高于六方向:开启四方向后强制按方格规则处理
|
|
37
|
+
this.useSixDirection = useFourDirection ? false : useSixDirection;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// ==================== 1. 边界 / 占用 ====================
|
|
@@ -91,6 +95,9 @@ export class GridMap {
|
|
|
91
95
|
|
|
92
96
|
/** 当前移动模式下不会高估的“步数距离”下界(A* 启发函数用,保证最优路径) */
|
|
93
97
|
getStepDistance(a: Cell, b: Cell): number {
|
|
98
|
+
if (this.useFourDirection) {
|
|
99
|
+
return this.getManhattanDistance(a, b);
|
|
100
|
+
}
|
|
94
101
|
if (this.useSixDirection) {
|
|
95
102
|
return this.getHexDistance(a.row, a.col, b.row, b.col);
|
|
96
103
|
}
|
|
@@ -99,10 +106,19 @@ export class GridMap {
|
|
|
99
106
|
|
|
100
107
|
/**
|
|
101
108
|
* 获取从 row 行出发的移动方向偏移
|
|
109
|
+
* 四方向模式:左、下、右、上
|
|
102
110
|
* 六方向模式:左上、左、左下、右上、右、右下(对角按行奇偶取)
|
|
103
|
-
*
|
|
111
|
+
* 八方向模式(默认):上下左右 + 四个对角
|
|
104
112
|
*/
|
|
105
113
|
getDirections(row: number): [number, number][] {
|
|
114
|
+
if (this.useFourDirection) {
|
|
115
|
+
return [
|
|
116
|
+
[0, -1], // 左
|
|
117
|
+
[1, 0], // 下
|
|
118
|
+
[0, 1], // 右
|
|
119
|
+
[-1, 0], // 上
|
|
120
|
+
];
|
|
121
|
+
}
|
|
106
122
|
if (this.useSixDirection) {
|
|
107
123
|
const isEvenRow = row % 2 === 0;
|
|
108
124
|
return [
|
|
@@ -58,8 +58,9 @@ export class GridMovementController {
|
|
|
58
58
|
* allowOverlap:为 true 时该单位进入"允许重叠"模式,
|
|
59
59
|
* 与其他同样开启该开关的单位可以走到同一格(互相穿过、不互相绕行、不互相等待),
|
|
60
60
|
* 与终点是否相同无关;未开启的单位之间、以及开启与未开启之间仍保持互斥。
|
|
61
|
+
* moveDuration:本次移动的"单格移动时长",单位秒;不传或传非法值时用控制器的默认值。
|
|
61
62
|
*/
|
|
62
|
-
moveTo(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
|
|
63
|
+
moveTo(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false, moveDuration?: number): void {
|
|
63
64
|
const start = this.getPosition(playerNode);
|
|
64
65
|
if (start.row === target.row && start.col === target.col) {
|
|
65
66
|
return;
|
|
@@ -68,13 +69,13 @@ export class GridMovementController {
|
|
|
68
69
|
this.cancel(playerNode, true);
|
|
69
70
|
const route = this.traceRoute(start, target, attackDistance, openColor, allowOverlap, this.movementOwners.get(playerNode));
|
|
70
71
|
if (!route || route.length === 0 || this.isPathBlocked(start, target, route, attackDistance)) {
|
|
71
|
-
this.retryPathfinding(playerNode, target, attackDistance, openColor, callBack, allowOverlap);
|
|
72
|
+
this.retryPathfinding(playerNode, target, attackDistance, openColor, callBack, allowOverlap, moveDuration);
|
|
72
73
|
return;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
this.clearPathfindingRetry(playerNode);
|
|
76
77
|
route.shift();
|
|
77
|
-
this.startMovement(playerNode, route, start, target, attackDistance, openColor, callBack, allowOverlap);
|
|
78
|
+
this.startMovement(playerNode, route, start, target, attackDistance, openColor, callBack, allowOverlap, moveDuration);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/** 强制中止当前移动,可选择保留当前格子的临时占用。 */
|
|
@@ -121,7 +122,7 @@ export class GridMovementController {
|
|
|
121
122
|
* 执行逐格移动。
|
|
122
123
|
* 每次移动前会尝试保留下一格,失败说明被占用,就重新规划路径或延时重试。
|
|
123
124
|
*/
|
|
124
|
-
private startMovement(playerNode: Node, route: Cell[], start: Cell, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
|
|
125
|
+
private startMovement(playerNode: Node, route: Cell[], start: Cell, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false, moveDuration?: number): void {
|
|
125
126
|
const previousOwner = this.movementOwners.get(playerNode);
|
|
126
127
|
if (previousOwner) {
|
|
127
128
|
this.pathState.release(previousOwner);
|
|
@@ -130,6 +131,8 @@ export class GridMovementController {
|
|
|
130
131
|
this.movementOwners.set(playerNode, owner);
|
|
131
132
|
const shareToken = this.getShareToken(allowOverlap);
|
|
132
133
|
this.pathState.reserveCurrent(start, owner, shareToken);
|
|
134
|
+
// 单格移动时长:本次指定优先,否则用控制器默认值
|
|
135
|
+
const duration = this.resolveMoveDuration(moveDuration);
|
|
133
136
|
const version = this.bumpVersion(playerNode);
|
|
134
137
|
let previous = start;
|
|
135
138
|
let index = 0;
|
|
@@ -194,7 +197,7 @@ export class GridMovementController {
|
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
tween(playerNode)
|
|
197
|
-
.to(
|
|
200
|
+
.to(duration, { worldPosition: PathfindingModule.GetMapPosition(next.row, next.col) })
|
|
198
201
|
.call(() => {
|
|
199
202
|
this.pathState.completeMove(previous, next, owner, shareToken);
|
|
200
203
|
this.positions.set(playerNode, { row: next.row, col: next.col });
|
|
@@ -231,12 +234,17 @@ export class GridMovementController {
|
|
|
231
234
|
return allowOverlap ? GridMovementController.OVERLAP_TOKEN : null;
|
|
232
235
|
}
|
|
233
236
|
|
|
237
|
+
/** 单格移动时长:未指定或非法时回退到控制器默认值(单位秒) */
|
|
238
|
+
private resolveMoveDuration(moveDuration?: number): number {
|
|
239
|
+
return (typeof moveDuration === 'number' && moveDuration > 0) ? moveDuration : this.moveDuration;
|
|
240
|
+
}
|
|
241
|
+
|
|
234
242
|
/** 路径规划失败时,做一次延迟重试,避免因为瞬时占用导致立刻失败。 */
|
|
235
|
-
private retryPathfinding(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
|
|
243
|
+
private retryPathfinding(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false, moveDuration?: number): void {
|
|
236
244
|
this.clearPathfindingRetry(playerNode);
|
|
237
245
|
const retry = () => {
|
|
238
246
|
this.clearPathfindingRetry(playerNode);
|
|
239
|
-
this.moveTo(playerNode, target, attackDistance, openColor, callBack, allowOverlap);
|
|
247
|
+
this.moveTo(playerNode, target, attackDistance, openColor, callBack, allowOverlap, moveDuration);
|
|
240
248
|
};
|
|
241
249
|
this.movementRetryCallbacks.set(playerNode, retry);
|
|
242
250
|
this.scheduler.scheduleOnce(retry, this.retryDelay);
|
|
@@ -39,6 +39,9 @@ export class MapBoxView extends Component {
|
|
|
39
39
|
/** 六方向模式:开启后按蜂窝规则(左上/左/左下/右上/右/右下)移动、寻路、判包围 */
|
|
40
40
|
@property({ tooltip: '开启后按蜂窝(六方向)规则寻路:左上/左/左下/右上/右/右下' })
|
|
41
41
|
useSixDirection = true
|
|
42
|
+
/** 四方向模式:开启后只能走 左/下/右/上(优先级高于六方向) */
|
|
43
|
+
@property({ tooltip: '开启后按四方向规则寻路:左/下/右/上(优先级高于六方向)' })
|
|
44
|
+
useFourDirection = false
|
|
42
45
|
|
|
43
46
|
// ==================== 2. 内部数据 ====================
|
|
44
47
|
|
|
@@ -54,11 +57,16 @@ export class MapBoxView extends Component {
|
|
|
54
57
|
|
|
55
58
|
protected onLoad(): void {
|
|
56
59
|
this.content_tf = this.node.getComponent(UITransform);
|
|
57
|
-
this.grid = new GridMap(this.row, this.col, this.useSixDirection);
|
|
60
|
+
this.grid = new GridMap(this.row, this.col, this.useSixDirection, this.useFourDirection);
|
|
58
61
|
this.pathfinder = new AStarPathfinder(this.grid);
|
|
59
62
|
PathfindingModule.Init(this);
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/** 是否按蜂窝(六方向)布局:四方向优先级更高,开启后按普通方格布局 */
|
|
66
|
+
private get isHexLayout(): boolean {
|
|
67
|
+
return this.useSixDirection && !this.useFourDirection;
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
onDestroy() {
|
|
63
71
|
PathfindingModule.Destroy();
|
|
64
72
|
}
|
|
@@ -94,8 +102,8 @@ export class MapBoxView extends Component {
|
|
|
94
102
|
mapItem.col = j
|
|
95
103
|
|
|
96
104
|
const pos = v3(
|
|
97
|
-
this.item_width * j + space_x + (i % 2 === 1 ? (this.
|
|
98
|
-
-(this.item_height * i + space_y - (this.
|
|
105
|
+
this.item_width * j + space_x + (i % 2 === 1 ? (this.isHexLayout ? padding_left : 0) : 0) - start_x,
|
|
106
|
+
-(this.item_height * i + space_y - (this.isHexLayout ? top : 0)) + start_y,
|
|
99
107
|
0);
|
|
100
108
|
|
|
101
109
|
|
|
@@ -46,9 +46,11 @@ export class PathfindingModule {
|
|
|
46
46
|
* allowOverlap:为 true 时该单位进入"允许重叠"模式,
|
|
47
47
|
* 与其他同样开启该开关的单位可以走到同一格(互相穿过,不再互相绕行/互相等待),
|
|
48
48
|
* 与终点是否相同无关;未开启的单位之间、以及开启与未开启之间仍保持互斥。
|
|
49
|
+
* moveDuration:本次移动的"单格移动时长",单位秒(数字越小走得越快)。
|
|
50
|
+
* 不传或传 <= 0 时使用 InitMovementController 配置的默认值。
|
|
49
51
|
*/
|
|
50
|
-
static MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
|
|
51
|
-
this.movementController?.moveTo(playerNode, target, attackDistance, openColor, callBack, allowOverlap);
|
|
52
|
+
static MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false, moveDuration?: number): void {
|
|
53
|
+
this.movementController?.moveTo(playerNode, target, attackDistance, openColor, callBack, allowOverlap, moveDuration);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
static CancelMove(playerNode: Node, preserveCurrent = false): void {
|