@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.
@@ -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
- * (TURN_PENALTY < 1 / 最大步数),不改变步数最优性,但保证步数相同时
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
- // 步数代价 + 极小转向惩罚:延续父节点的行进方向记 1,
234
- // 换方向记 1 + TURN_PENALTY。总惩罚远小于 1 步,
235
- // 不改变步数最优性,只在等长路径中优先拐弯最少(最顺直)的
255
+ // 步数代价 + 极小附加代价(总附加远小于 1 步,不改变步数最优性,只用于平局消解)
236
256
  let stepCost = 1;
237
- if (current.parent) {
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.useSixDirection = useSixDirection;
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(this.moveDuration, { worldPosition: PathfindingModule.GetMapPosition(next.row, next.col) })
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.useSixDirection ? padding_left : 0) : 0) - start_x,
98
- -(this.item_height * i + space_y - (this.useSixDirection ? top : 0)) + start_y,
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cc-component/cc-pathfinding",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "engine": ">=3.8.6",
5
5
  "description": "系统组件添加常用扩展方法",
6
6
  "main": "index.ts",