@cc-component/cc-pathfinding 1.0.5 → 1.0.8

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.
@@ -59,8 +59,13 @@ declare global {
59
59
  export function InitMovementController(scheduler: Component, options?: { retryDelay?: number; moveDuration?: number }): void
60
60
  /** 设置单位位置 */
61
61
  export function SetPosition(playerNode: Node, position: { row: number, col: number }): void
62
- /** 让单位移动到目标格子 */
63
- export function MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void): void
62
+ /**
63
+ * 让单位移动到目标格子
64
+ * @param allowOverlap 为 true 时该单位进入"允许重叠"模式,
65
+ * 与其他同样开启该开关的单位可以走到同一格(互相穿过,不再互相绕行/互相等待),
66
+ * 与终点是否相同无关
67
+ */
68
+ export function MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap?: boolean): void
64
69
  /** 取消移动 */
65
70
  export function CancelMove(playerNode: Node, preserveCurrent?: boolean): void
66
71
  /** 销毁移动控制器 */
@@ -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
  }
@@ -1,9 +1,9 @@
1
- {
2
- "ver": "4.0.24",
3
- "importer": "typescript",
4
- "imported": true,
5
- "uuid": "c8b2e640-3f1d-42a7-b5e9-6d0f9a3c8e15",
6
- "files": [],
7
- "subMetas": {},
8
- "userData": {}
9
- }
1
+ {
2
+ "ver": "4.0.24",
3
+ "importer": "typescript",
4
+ "imported": true,
5
+ "uuid": "c8b2e640-3f1d-42a7-b5e9-6d0f9a3c8e15",
6
+ "files": [],
7
+ "subMetas": {},
8
+ "userData": {}
9
+ }
@@ -1,9 +1,9 @@
1
- {
2
- "ver": "4.0.24",
3
- "importer": "typescript",
4
- "imported": true,
5
- "uuid": "149ef51b-697e-469e-8ba6-1feb40e9e2b6",
6
- "files": [],
7
- "subMetas": {},
8
- "userData": {}
9
- }
1
+ {
2
+ "ver": "4.0.24",
3
+ "importer": "typescript",
4
+ "imported": true,
5
+ "uuid": "149ef51b-697e-469e-8ba6-1feb40e9e2b6",
6
+ "files": [],
7
+ "subMetas": {},
8
+ "userData": {}
9
+ }
@@ -1,314 +1,330 @@
1
- /**
2
- * 网格数据层(纯逻辑,不依赖任何 cc 视图,可独立单元测试)
3
- *
4
- * 职责:
5
- * 1. 边界 / 占用管理
6
- * 2. 方向规则(八方向 / 六方向蜂窝,蜂窝按行奇偶取对角)
7
- * 3. 距离计算(蜂窝步数 / 切比雪夫 / 曼哈顿)
8
- * 4. 范围查询(目标周围可站点等)
9
- * 5. 包围判断(四方向原逻辑 / 蜂窝逻辑)
10
- */
11
-
12
- /** 格子坐标 */
13
- export type Cell = { row: number; col: number };
14
-
15
- /** 格子的唯一 key:"row,col" */
16
- export function cellKey(row: number, col: number): string {
17
- return `${row},${col}`;
18
- }
19
-
20
- export class GridMap {
21
-
22
- readonly rowCount: number;
23
- readonly colCount: number;
24
- /** 六方向模式:按蜂窝规则(左上/左/左下/右上/右/右下)移动、寻路、判包围 */
25
- readonly useSixDirection: boolean;
26
-
27
- /** 被占用格子的 key 集合 */
28
- private readonly occupied: Set<string> = new Set();
29
-
30
- constructor(rowCount: number, colCount: number, useSixDirection: boolean) {
31
- this.rowCount = rowCount;
32
- this.colCount = colCount;
33
- this.useSixDirection = useSixDirection;
34
- }
35
-
36
- // ==================== 1. 边界 / 占用 ====================
37
-
38
- /** 检查格子是否在地图范围内 */
39
- isWithinBounds(row: number, col: number): boolean {
40
- return row >= 0 && row < this.rowCount && col >= 0 && col < this.colCount;
41
- }
42
-
43
- isOccupied(row: number, col: number): boolean {
44
- return this.occupied.has(cellKey(row, col));
45
- }
46
-
47
- occupy(row: number, col: number): void {
48
- this.occupied.add(cellKey(row, col));
49
- }
50
-
51
- free(row: number, col: number): void {
52
- this.occupied.delete(cellKey(row, col));
53
- }
54
-
55
- freeAll(): void {
56
- this.occupied.clear();
57
- }
58
-
59
- /** 被占用格子的 key 集合(兼容旧接口 GetOccupyNodeNot) */
60
- getOccupiedSet(): Set<string> {
61
- return this.occupied;
62
- }
63
-
64
- // ==================== 2. 方向 / 距离 ====================
65
-
66
- /**
67
- * 六方向(蜂窝)模式下两格之间的步数距离
68
- * 奇数行右移半格的交错布局(odd-r):offset 转 cube 坐标
69
- */
70
- getHexDistance(row1: number, col1: number, row2: number, col2: number): number {
71
- const x1 = col1 - (row1 - (row1 & 1)) / 2;
72
- const z1 = row1;
73
- const y1 = -x1 - z1;
74
- const x2 = col2 - (row2 - (row2 & 1)) / 2;
75
- const z2 = row2;
76
- const y2 = -x2 - z2;
77
- return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2), Math.abs(z1 - z2));
78
- }
79
-
80
- /** 切比雪夫距离(原逻辑的 getManhattanDistance,八方向攻击距离判定用) */
81
- getChebyshevDistance(a: Cell, b: Cell): number {
82
- const row = Math.abs(a.row - b.row);
83
- const col = Math.abs(a.col - b.col);
84
- return row > col ? row : col;
85
- }
86
-
87
- /** 曼哈顿距离(攻击位排序用) */
88
- getManhattanDistance(a: Cell, b: Cell): number {
89
- return Math.abs(a.row - b.row) + Math.abs(a.col - b.col);
90
- }
91
-
92
- /** 当前移动模式下不会高估的“步数距离”下界(A* 启发函数用,保证最优路径) */
93
- getStepDistance(a: Cell, b: Cell): number {
94
- if (this.useSixDirection) {
95
- return this.getHexDistance(a.row, a.col, b.row, b.col);
96
- }
97
- return this.getChebyshevDistance(a, b);
98
- }
99
-
100
- /**
101
- * 获取从 row 行出发的移动方向偏移
102
- * 六方向模式:左上、左、左下、右上、右、右下(对角按行奇偶取)
103
- * 八方向模式:上下左右 + 四个对角
104
- */
105
- getDirections(row: number): [number, number][] {
106
- if (this.useSixDirection) {
107
- const isEvenRow = row % 2 === 0;
108
- return [
109
- isEvenRow ? [-1, -1] : [-1, 0], // 左上
110
- [0, -1], // 左
111
- isEvenRow ? [1, -1] : [1, 0], // 左下
112
- isEvenRow ? [-1, 0] : [-1, 1], // 右上
113
- [0, 1], // 右
114
- isEvenRow ? [1, 0] : [1, 1], // 右下
115
- ];
116
- }
117
- return [
118
- [-1, 0], // 上
119
- [1, 0], // 下
120
- [0, -1], // 左
121
- [0, 1], // 右
122
- [-1, -1], // 左上
123
- [-1, 1], // 右上
124
- [1, -1], // 左下
125
- [1, 1], // 右下
126
- ];
127
- }
128
-
129
- /** 判断两个格子是否相邻(按当前模式的移动规则) */
130
- isNeighborCell(a: Cell, b: Cell): boolean {
131
- return this.getDirections(a.row).some(([dr, dc]) =>
132
- a.row + dr === b.row && a.col + dc === b.col
133
- );
134
- }
135
-
136
- // ==================== 3. 范围查询 ====================
137
-
138
- /**
139
- * 查找目标周围 maxDistance 范围内、在地图内且未被占用的点。
140
- *
141
- * 注意:这里不能用 isClosedLoop 做提前返回——目标的“被包围”语义包含
142
- * “目标自身被占用”,而那正是需要找攻击位的场景(终点被占用/敌人站在终点上)。
143
- * 只按 边界 + 占用 过滤即可,范围内没有可站点时自然返回空数组,
144
- * 由上层 findPathToNearby 继续扩大半径。
145
- */
146
- findAvailablePointsInRange(targetRow: number, targetCol: number, maxDistance: number): Cell[] {
147
- const availablePoints: Cell[] = [];
148
- const visited = new Set<string>();
149
- // 遍历目标点周围 maxDistance 范围内的所有格子
150
- for (let dRow = -maxDistance; dRow <= maxDistance; dRow++) {
151
- for (let dCol = -maxDistance; dCol <= maxDistance; dCol++) {
152
- const newRow = targetRow + dRow;
153
- const newCol = targetCol + dCol;
154
- // 跳过目标点本身
155
- if (newRow === targetRow && newCol === targetCol) {
156
- continue;
157
- }
158
- // 六方向模式:只保留蜂窝距离 ≤ maxDistance 的格子(去掉正方形的四个角)
159
- if (this.useSixDirection
160
- && this.getHexDistance(targetRow, targetCol, newRow, newCol) > maxDistance) {
161
- continue;
162
- }
163
- if (this.isWithinBounds(newRow, newCol) && !this.isOccupied(newRow, newCol)) {
164
- const key = cellKey(newRow, newCol);
165
- if (!visited.has(key)) {
166
- visited.add(key);
167
- availablePoints.push({ row: newRow, col: newCol });
168
- }
169
- }
170
- }
171
- }
172
- return availablePoints;
173
- }
174
-
175
- /** 目标周围 maxDistance 范围内的所有点(不判断占用,仅边界内) */
176
- checkAvailableSurrounding(targetRow: number, targetCol: number, maxDistance: number): Cell[] {
177
- const points: Cell[] = [];
178
- for (let dRow = -maxDistance; dRow <= maxDistance; dRow++) {
179
- for (let dCol = -maxDistance; dCol <= maxDistance; dCol++) {
180
- const newRow = targetRow + dRow;
181
- const newCol = targetCol + dCol;
182
- // 跳过目标点本身
183
- if (newRow === targetRow && newCol === targetCol) {
184
- continue;
185
- }
186
- if (this.isWithinBounds(newRow, newCol)) {
187
- points.push({ row: newRow, col: newCol });
188
- }
189
- }
190
- }
191
- return points;
192
- }
193
-
194
- // ==================== 4. 包围判断 ====================
195
-
196
- /** 判断目标点是否被包围 */
197
- isClosedLoop(targetRow: number, targetCol: number, maxDistance: number): boolean {
198
- if (this.useSixDirection) {
199
- return this.isClosedLoopSix(targetRow, targetCol, maxDistance);
200
- }
201
- return this.isClosedLoopFour(targetRow, targetCol, maxDistance);
202
- }
203
-
204
- /** 原有四方向逻辑:四个面全部被堵住才算被包围 */
205
- private isClosedLoopFour(targetRow: number, targetCol: number, maxDistance: number): boolean {
206
- const value = maxDistance;
207
-
208
- const shang = [
209
- { row: -value, col: -value }, // 左上
210
- { row: -value, col: 0 }, // 上
211
- { row: -value, col: value }, // 右上
212
- ];
213
- const zuo = [
214
- { row: -value, col: -value }, // 左上
215
- { row: 0, col: -value }, // 左
216
- { row: value, col: -value }, // 左下
217
- ];
218
- const xia = [
219
- { row: value, col: -value }, // 左下
220
- { row: value, col: 0 }, // 下
221
- { row: value, col: value }, // 右下
222
- ];
223
- const you = [
224
- { row: -value, col: value }, // 右上
225
- { row: 0, col: value }, // 右
226
- { row: value, col: value }, // 右下
227
- ];
228
-
229
- let is_shang = false;
230
- let is_zuo = false;
231
- let is_xia = false;
232
- let is_you = false;
233
-
234
- let shang_model = this.isMan(targetRow, targetCol, shang, 0, 0);
235
- is_shang = shang_model.is_man
236
- if (!shang_model.is_man) {
237
- shang_model = this.isMan(targetRow, targetCol, shang, -1, 0);
238
- is_shang = shang_model.is_man_bounds
239
- }
240
-
241
- let zuo_model = this.isMan(targetRow, targetCol, zuo, 0, 0);
242
- is_zuo = zuo_model.is_man
243
- if (!zuo_model.is_man) {
244
- zuo_model = this.isMan(targetRow, targetCol, zuo, 0, -1);
245
- is_zuo = zuo_model.is_man_bounds
246
- }
247
-
248
- let xia_model = this.isMan(targetRow, targetCol, xia, 0, 0);
249
- is_xia = xia_model.is_man
250
- if (!xia_model.is_man) {
251
- xia_model = this.isMan(targetRow, targetCol, xia, 1, 0);
252
- is_xia = xia_model.is_man_bounds
253
- }
254
-
255
- let you_model = this.isMan(targetRow, targetCol, you, 0, 0);
256
- is_you = you_model.is_man
257
- if (!you_model.is_man) {
258
- you_model = this.isMan(targetRow, targetCol, you, 0, 1);
259
- is_you = you_model.is_man_bounds
260
- }
261
-
262
- // 四个面全部被堵住才算被包围
263
- return is_shang && is_zuo && is_xia && is_you;
264
- }
265
-
266
- /**
267
- * 六方向(蜂窝)模式的包围判断:
268
- * 目标点周围蜂窝距离 maxDistance 以内(不含自身)的所有格子,
269
- * 只要有一个既在地图内又没被占用,就没被包围;
270
- * 全部被堵住(出界或被占用都算堵住)才算被包围。
271
- */
272
- private isClosedLoopSix(targetRow: number, targetCol: number, maxDistance: number): boolean {
273
- // 目标点本身出界 / 被占用,也算“被围住”
274
- if (!this.isWithinBounds(targetRow, targetCol) || this.isOccupied(targetRow, targetCol)) {
275
- return true;
276
- }
277
- // 蜂窝范围一定落在这个正方形包围盒里,遍历后按蜂窝距离过滤即可
278
- for (let row = targetRow - maxDistance; row <= targetRow + maxDistance; row++) {
279
- for (let col = targetCol - maxDistance; col <= targetCol + maxDistance; col++) {
280
- if (row === targetRow && col === targetCol) {
281
- continue;
282
- }
283
- if (this.getHexDistance(targetRow, targetCol, row, col) > maxDistance) {
284
- continue;
285
- }
286
- // 出界或被占用都算“堵住”;发现一个能站的空格就没被包围
287
- if (this.isWithinBounds(row, col) && !this.isOccupied(row, col)) {
288
- return false;
289
- }
290
- }
291
- }
292
- return true;
293
- }
294
-
295
- /** 检查一组格子是否全部被占据(is_man)或全部出界(is_man_bounds),四方向包围判断用 */
296
- private isMan(targetRow: number, targetCol: number, list: { row: number, col: number }[], row: number, col: number) {
297
- let count = 0
298
- let count_bounds = 0
299
-
300
- for (const dir of list) {
301
- const newRow = targetRow + dir.row + row;
302
- const newCol = targetCol + dir.col + col;
303
- // 出界算被占据
304
- if (!this.isWithinBounds(newRow, newCol)) {
305
- count++
306
- count_bounds++
307
- }
308
- if (this.isOccupied(newRow, newCol)) {
309
- count++
310
- }
311
- }
312
- return { is_man_bounds: count_bounds === list.length, is_man: count === list.length }
313
- }
314
- }
1
+ /**
2
+ * 网格数据层(纯逻辑,不依赖任何 cc 视图,可独立单元测试)
3
+ *
4
+ * 职责:
5
+ * 1. 边界 / 占用管理
6
+ * 2. 方向规则(八方向 / 六方向蜂窝,蜂窝按行奇偶取对角)
7
+ * 3. 距离计算(蜂窝步数 / 切比雪夫 / 曼哈顿)
8
+ * 4. 范围查询(目标周围可站点等)
9
+ * 5. 包围判断(四方向原逻辑 / 蜂窝逻辑)
10
+ */
11
+
12
+ /** 格子坐标 */
13
+ export type Cell = { row: number; col: number };
14
+
15
+ /** 格子的唯一 key:"row,col" */
16
+ export function cellKey(row: number, col: number): string {
17
+ return `${row},${col}`;
18
+ }
19
+
20
+ export class GridMap {
21
+
22
+ readonly rowCount: number;
23
+ readonly colCount: number;
24
+ /** 六方向模式:按蜂窝规则(左上/左/左下/右上/右/右下)移动、寻路、判包围 */
25
+ readonly useSixDirection: boolean;
26
+ /** 四方向模式:只能走 左/下/右/上(优先级高于六方向) */
27
+ readonly useFourDirection: boolean;
28
+
29
+ /** 被占用格子的 key 集合 */
30
+ private readonly occupied: Set<string> = new Set();
31
+
32
+ constructor(rowCount: number, colCount: number, useSixDirection: boolean, useFourDirection = false) {
33
+ this.rowCount = rowCount;
34
+ this.colCount = colCount;
35
+ this.useFourDirection = useFourDirection;
36
+ // 四方向优先级高于六方向:开启四方向后强制按方格规则处理
37
+ this.useSixDirection = useFourDirection ? false : useSixDirection;
38
+ }
39
+
40
+ // ==================== 1. 边界 / 占用 ====================
41
+
42
+ /** 检查格子是否在地图范围内 */
43
+ isWithinBounds(row: number, col: number): boolean {
44
+ return row >= 0 && row < this.rowCount && col >= 0 && col < this.colCount;
45
+ }
46
+
47
+ isOccupied(row: number, col: number): boolean {
48
+ return this.occupied.has(cellKey(row, col));
49
+ }
50
+
51
+ occupy(row: number, col: number): void {
52
+ this.occupied.add(cellKey(row, col));
53
+ }
54
+
55
+ free(row: number, col: number): void {
56
+ this.occupied.delete(cellKey(row, col));
57
+ }
58
+
59
+ freeAll(): void {
60
+ this.occupied.clear();
61
+ }
62
+
63
+ /** 被占用格子的 key 集合(兼容旧接口 GetOccupyNodeNot) */
64
+ getOccupiedSet(): Set<string> {
65
+ return this.occupied;
66
+ }
67
+
68
+ // ==================== 2. 方向 / 距离 ====================
69
+
70
+ /**
71
+ * 六方向(蜂窝)模式下两格之间的步数距离
72
+ * 奇数行右移半格的交错布局(odd-r):offset 转 cube 坐标
73
+ */
74
+ getHexDistance(row1: number, col1: number, row2: number, col2: number): number {
75
+ const x1 = col1 - (row1 - (row1 & 1)) / 2;
76
+ const z1 = row1;
77
+ const y1 = -x1 - z1;
78
+ const x2 = col2 - (row2 - (row2 & 1)) / 2;
79
+ const z2 = row2;
80
+ const y2 = -x2 - z2;
81
+ return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2), Math.abs(z1 - z2));
82
+ }
83
+
84
+ /** 切比雪夫距离(原逻辑的 getManhattanDistance,八方向攻击距离判定用) */
85
+ getChebyshevDistance(a: Cell, b: Cell): number {
86
+ const row = Math.abs(a.row - b.row);
87
+ const col = Math.abs(a.col - b.col);
88
+ return row > col ? row : col;
89
+ }
90
+
91
+ /** 曼哈顿距离(攻击位排序用) */
92
+ getManhattanDistance(a: Cell, b: Cell): number {
93
+ return Math.abs(a.row - b.row) + Math.abs(a.col - b.col);
94
+ }
95
+
96
+ /** 当前移动模式下不会高估的“步数距离”下界(A* 启发函数用,保证最优路径) */
97
+ getStepDistance(a: Cell, b: Cell): number {
98
+ if (this.useFourDirection) {
99
+ return this.getManhattanDistance(a, b);
100
+ }
101
+ if (this.useSixDirection) {
102
+ return this.getHexDistance(a.row, a.col, b.row, b.col);
103
+ }
104
+ return this.getChebyshevDistance(a, b);
105
+ }
106
+
107
+ /**
108
+ * 获取从 row 行出发的移动方向偏移
109
+ * 四方向模式:左、下、右、上
110
+ * 六方向模式:左上、左、左下、右上、右、右下(对角按行奇偶取)
111
+ * 八方向模式(默认):上下左右 + 四个对角
112
+ */
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
+ }
122
+ if (this.useSixDirection) {
123
+ const isEvenRow = row % 2 === 0;
124
+ return [
125
+ isEvenRow ? [-1, -1] : [-1, 0], // 左上
126
+ [0, -1], // 左
127
+ isEvenRow ? [1, -1] : [1, 0], // 左下
128
+ isEvenRow ? [-1, 0] : [-1, 1], // 右上
129
+ [0, 1], // 右
130
+ isEvenRow ? [1, 0] : [1, 1], // 右下
131
+ ];
132
+ }
133
+ return [
134
+ [-1, 0], // 上
135
+ [1, 0], // 下
136
+ [0, -1], // 左
137
+ [0, 1], // 右
138
+ [-1, -1], // 左上
139
+ [-1, 1], // 右上
140
+ [1, -1], // 左下
141
+ [1, 1], // 右下
142
+ ];
143
+ }
144
+
145
+ /** 判断两个格子是否相邻(按当前模式的移动规则) */
146
+ isNeighborCell(a: Cell, b: Cell): boolean {
147
+ return this.getDirections(a.row).some(([dr, dc]) =>
148
+ a.row + dr === b.row && a.col + dc === b.col
149
+ );
150
+ }
151
+
152
+ // ==================== 3. 范围查询 ====================
153
+
154
+ /**
155
+ * 查找目标周围 maxDistance 范围内、在地图内且未被占用的点。
156
+ *
157
+ * 注意:这里不能用 isClosedLoop 做提前返回——目标的“被包围”语义包含
158
+ * “目标自身被占用”,而那正是需要找攻击位的场景(终点被占用/敌人站在终点上)。
159
+ * 只按 边界 + 占用 过滤即可,范围内没有可站点时自然返回空数组,
160
+ * 由上层 findPathToNearby 继续扩大半径。
161
+ */
162
+ findAvailablePointsInRange(targetRow: number, targetCol: number, maxDistance: number): Cell[] {
163
+ const availablePoints: Cell[] = [];
164
+ const visited = new Set<string>();
165
+ // 遍历目标点周围 maxDistance 范围内的所有格子
166
+ for (let dRow = -maxDistance; dRow <= maxDistance; dRow++) {
167
+ for (let dCol = -maxDistance; dCol <= maxDistance; dCol++) {
168
+ const newRow = targetRow + dRow;
169
+ const newCol = targetCol + dCol;
170
+ // 跳过目标点本身
171
+ if (newRow === targetRow && newCol === targetCol) {
172
+ continue;
173
+ }
174
+ // 六方向模式:只保留蜂窝距离 ≤ maxDistance 的格子(去掉正方形的四个角)
175
+ if (this.useSixDirection
176
+ && this.getHexDistance(targetRow, targetCol, newRow, newCol) > maxDistance) {
177
+ continue;
178
+ }
179
+ if (this.isWithinBounds(newRow, newCol) && !this.isOccupied(newRow, newCol)) {
180
+ const key = cellKey(newRow, newCol);
181
+ if (!visited.has(key)) {
182
+ visited.add(key);
183
+ availablePoints.push({ row: newRow, col: newCol });
184
+ }
185
+ }
186
+ }
187
+ }
188
+ return availablePoints;
189
+ }
190
+
191
+ /** 目标周围 maxDistance 范围内的所有点(不判断占用,仅边界内) */
192
+ checkAvailableSurrounding(targetRow: number, targetCol: number, maxDistance: number): Cell[] {
193
+ const points: Cell[] = [];
194
+ for (let dRow = -maxDistance; dRow <= maxDistance; dRow++) {
195
+ for (let dCol = -maxDistance; dCol <= maxDistance; dCol++) {
196
+ const newRow = targetRow + dRow;
197
+ const newCol = targetCol + dCol;
198
+ // 跳过目标点本身
199
+ if (newRow === targetRow && newCol === targetCol) {
200
+ continue;
201
+ }
202
+ if (this.isWithinBounds(newRow, newCol)) {
203
+ points.push({ row: newRow, col: newCol });
204
+ }
205
+ }
206
+ }
207
+ return points;
208
+ }
209
+
210
+ // ==================== 4. 包围判断 ====================
211
+
212
+ /** 判断目标点是否被包围 */
213
+ isClosedLoop(targetRow: number, targetCol: number, maxDistance: number): boolean {
214
+ if (this.useSixDirection) {
215
+ return this.isClosedLoopSix(targetRow, targetCol, maxDistance);
216
+ }
217
+ return this.isClosedLoopFour(targetRow, targetCol, maxDistance);
218
+ }
219
+
220
+ /** 原有四方向逻辑:四个面全部被堵住才算被包围 */
221
+ private isClosedLoopFour(targetRow: number, targetCol: number, maxDistance: number): boolean {
222
+ const value = maxDistance;
223
+
224
+ const shang = [
225
+ { row: -value, col: -value }, // 左上
226
+ { row: -value, col: 0 }, // 上
227
+ { row: -value, col: value }, // 右上
228
+ ];
229
+ const zuo = [
230
+ { row: -value, col: -value }, // 左上
231
+ { row: 0, col: -value }, // 左
232
+ { row: value, col: -value }, // 左下
233
+ ];
234
+ const xia = [
235
+ { row: value, col: -value }, // 左下
236
+ { row: value, col: 0 }, // 下
237
+ { row: value, col: value }, // 右下
238
+ ];
239
+ const you = [
240
+ { row: -value, col: value }, // 右上
241
+ { row: 0, col: value }, // 右
242
+ { row: value, col: value }, // 右下
243
+ ];
244
+
245
+ let is_shang = false;
246
+ let is_zuo = false;
247
+ let is_xia = false;
248
+ let is_you = false;
249
+
250
+ let shang_model = this.isMan(targetRow, targetCol, shang, 0, 0);
251
+ is_shang = shang_model.is_man
252
+ if (!shang_model.is_man) {
253
+ shang_model = this.isMan(targetRow, targetCol, shang, -1, 0);
254
+ is_shang = shang_model.is_man_bounds
255
+ }
256
+
257
+ let zuo_model = this.isMan(targetRow, targetCol, zuo, 0, 0);
258
+ is_zuo = zuo_model.is_man
259
+ if (!zuo_model.is_man) {
260
+ zuo_model = this.isMan(targetRow, targetCol, zuo, 0, -1);
261
+ is_zuo = zuo_model.is_man_bounds
262
+ }
263
+
264
+ let xia_model = this.isMan(targetRow, targetCol, xia, 0, 0);
265
+ is_xia = xia_model.is_man
266
+ if (!xia_model.is_man) {
267
+ xia_model = this.isMan(targetRow, targetCol, xia, 1, 0);
268
+ is_xia = xia_model.is_man_bounds
269
+ }
270
+
271
+ let you_model = this.isMan(targetRow, targetCol, you, 0, 0);
272
+ is_you = you_model.is_man
273
+ if (!you_model.is_man) {
274
+ you_model = this.isMan(targetRow, targetCol, you, 0, 1);
275
+ is_you = you_model.is_man_bounds
276
+ }
277
+
278
+ // 四个面全部被堵住才算被包围
279
+ return is_shang && is_zuo && is_xia && is_you;
280
+ }
281
+
282
+ /**
283
+ * 六方向(蜂窝)模式的包围判断:
284
+ * 目标点周围蜂窝距离 maxDistance 以内(不含自身)的所有格子,
285
+ * 只要有一个既在地图内又没被占用,就没被包围;
286
+ * 全部被堵住(出界或被占用都算堵住)才算被包围。
287
+ */
288
+ private isClosedLoopSix(targetRow: number, targetCol: number, maxDistance: number): boolean {
289
+ // 目标点本身出界 / 被占用,也算“被围住”
290
+ if (!this.isWithinBounds(targetRow, targetCol) || this.isOccupied(targetRow, targetCol)) {
291
+ return true;
292
+ }
293
+ // 蜂窝范围一定落在这个正方形包围盒里,遍历后按蜂窝距离过滤即可
294
+ for (let row = targetRow - maxDistance; row <= targetRow + maxDistance; row++) {
295
+ for (let col = targetCol - maxDistance; col <= targetCol + maxDistance; col++) {
296
+ if (row === targetRow && col === targetCol) {
297
+ continue;
298
+ }
299
+ if (this.getHexDistance(targetRow, targetCol, row, col) > maxDistance) {
300
+ continue;
301
+ }
302
+ // 出界或被占用都算“堵住”;发现一个能站的空格就没被包围
303
+ if (this.isWithinBounds(row, col) && !this.isOccupied(row, col)) {
304
+ return false;
305
+ }
306
+ }
307
+ }
308
+ return true;
309
+ }
310
+
311
+ /** 检查一组格子是否全部被占据(is_man)或全部出界(is_man_bounds),四方向包围判断用 */
312
+ private isMan(targetRow: number, targetCol: number, list: { row: number, col: number }[], row: number, col: number) {
313
+ let count = 0
314
+ let count_bounds = 0
315
+
316
+ for (const dir of list) {
317
+ const newRow = targetRow + dir.row + row;
318
+ const newCol = targetCol + dir.col + col;
319
+ // 出界算被占据
320
+ if (!this.isWithinBounds(newRow, newCol)) {
321
+ count++
322
+ count_bounds++
323
+ }
324
+ if (this.isOccupied(newRow, newCol)) {
325
+ count++
326
+ }
327
+ }
328
+ return { is_man_bounds: count_bounds === list.length, is_man: count === list.length }
329
+ }
330
+ }
@@ -1,9 +1,9 @@
1
- {
2
- "ver": "4.0.24",
3
- "importer": "typescript",
4
- "imported": true,
5
- "uuid": "5d3a8f21-7c4e-4b6a-9f2d-8e1c0a4b7d92",
6
- "files": [],
7
- "subMetas": {},
8
- "userData": {}
9
- }
1
+ {
2
+ "ver": "4.0.24",
3
+ "importer": "typescript",
4
+ "imported": true,
5
+ "uuid": "5d3a8f21-7c4e-4b6a-9f2d-8e1c0a4b7d92",
6
+ "files": [],
7
+ "subMetas": {},
8
+ "userData": {}
9
+ }
@@ -16,6 +16,9 @@ export interface GridMovementOptions {
16
16
  * 负责把节点位置、临时占用、动画回调串起来,避免多个单位相互污染同一张地图状态。
17
17
  */
18
18
  export class GridMovementController {
19
+ /** 允许重叠的单位共用的共享令牌 */
20
+ private static readonly OVERLAP_TOKEN = 'overlap';
21
+
19
22
  private readonly movementVersions = new Map<Node, number>();
20
23
  private readonly movementOwners = new Map<Node, symbol>();
21
24
  private readonly movementRetryCallbacks = new Map<Node, () => void>();
@@ -51,23 +54,27 @@ export class GridMovementController {
51
54
  * 1. 先取消旧移动和重试。
52
55
  * 2. 计算路径,路径无效则延时重试。
53
56
  * 3. 成功后进入逐格动画。
57
+ *
58
+ * allowOverlap:为 true 时该单位进入"允许重叠"模式,
59
+ * 与其他同样开启该开关的单位可以走到同一格(互相穿过、不互相绕行、不互相等待),
60
+ * 与终点是否相同无关;未开启的单位之间、以及开启与未开启之间仍保持互斥。
54
61
  */
55
- moveTo(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void): void {
62
+ moveTo(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
56
63
  const start = this.getPosition(playerNode);
57
64
  if (start.row === target.row && start.col === target.col) {
58
65
  return;
59
66
  }
60
67
 
61
68
  this.cancel(playerNode, true);
62
- const route = PathfindingModule.TracePath(start, target, attackDistance, openColor);
69
+ const route = this.traceRoute(start, target, attackDistance, openColor, allowOverlap, this.movementOwners.get(playerNode));
63
70
  if (!route || route.length === 0 || this.isPathBlocked(start, target, route, attackDistance)) {
64
- this.retryPathfinding(playerNode, target, attackDistance, openColor, callBack);
71
+ this.retryPathfinding(playerNode, target, attackDistance, openColor, callBack, allowOverlap);
65
72
  return;
66
73
  }
67
74
 
68
75
  this.clearPathfindingRetry(playerNode);
69
76
  route.shift();
70
- this.startMovement(playerNode, route, start, target, attackDistance, openColor, callBack);
77
+ this.startMovement(playerNode, route, start, target, attackDistance, openColor, callBack, allowOverlap);
71
78
  }
72
79
 
73
80
  /** 强制中止当前移动,可选择保留当前格子的临时占用。 */
@@ -114,14 +121,15 @@ export class GridMovementController {
114
121
  * 执行逐格移动。
115
122
  * 每次移动前会尝试保留下一格,失败说明被占用,就重新规划路径或延时重试。
116
123
  */
117
- private startMovement(playerNode: Node, route: Cell[], start: Cell, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void): void {
124
+ private startMovement(playerNode: Node, route: Cell[], start: Cell, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
118
125
  const previousOwner = this.movementOwners.get(playerNode);
119
126
  if (previousOwner) {
120
127
  this.pathState.release(previousOwner);
121
128
  }
122
129
  const owner = Symbol(playerNode.name);
123
130
  this.movementOwners.set(playerNode, owner);
124
- this.pathState.reserveCurrent(start, owner);
131
+ const shareToken = this.getShareToken(allowOverlap);
132
+ this.pathState.reserveCurrent(start, owner, shareToken);
125
133
  const version = this.bumpVersion(playerNode);
126
134
  let previous = start;
127
135
  let index = 0;
@@ -131,7 +139,7 @@ export class GridMovementController {
131
139
  const retry = () => {
132
140
  this.clearMovementRetry(playerNode);
133
141
  if (route.length === 0) {
134
- const retryRoute = PathfindingModule.TracePath(previous, target, attackDistance, openColor);
142
+ const retryRoute = this.traceRoute(previous, target, attackDistance, openColor, allowOverlap, owner);
135
143
  if (retryRoute.length <= 1) {
136
144
  if (reachedAttackDistance()) {
137
145
  moveNext();
@@ -166,10 +174,11 @@ export class GridMovementController {
166
174
 
167
175
  const next = route[index];
168
176
  // 只要下一格被占用,就不继续移动,重新规划路线,避免穿透同一格子。
169
- if (!this.pathState.tryReserve(previous, next, owner)) {
177
+ // 同组(终点相同且开启重叠)的单位例外,允许踩到同一格上。
178
+ if (!this.pathState.tryReserve(previous, next, owner, shareToken)) {
170
179
  this.pathState.release(owner);
171
180
  PathfindingModule.OccupyNode(previous.row, previous.col, true);
172
- const newRoute = PathfindingModule.TracePath(previous, target, attackDistance, openColor);
181
+ const newRoute = this.traceRoute(previous, target, attackDistance, openColor, allowOverlap, owner);
173
182
  if (newRoute.length > 1) {
174
183
  newRoute.shift();
175
184
  route.splice(0, route.length, ...newRoute);
@@ -187,7 +196,7 @@ export class GridMovementController {
187
196
  tween(playerNode)
188
197
  .to(this.moveDuration, { worldPosition: PathfindingModule.GetMapPosition(next.row, next.col) })
189
198
  .call(() => {
190
- this.pathState.completeMove(previous, next, owner);
199
+ this.pathState.completeMove(previous, next, owner, shareToken);
191
200
  this.positions.set(playerNode, { row: next.row, col: next.col });
192
201
  previous = next;
193
202
  index++;
@@ -199,12 +208,35 @@ export class GridMovementController {
199
208
  moveNext();
200
209
  }
201
210
 
211
+ /**
212
+ * 寻路。开启重叠时,先把其他同样开启重叠的单位占住的格子临时释放,
213
+ * 让 A* 直接穿过它们而不是绕行;寻路结束立刻还原占用,
214
+ * 这样只放行"动态单位",静态障碍依旧不可通行。
215
+ */
216
+ private traceRoute(start: Cell, target: Cell, attackDistance: AttackDistance, openColor: boolean, allowOverlap: boolean, owner?: symbol): Cell[] {
217
+ const shareToken = this.getShareToken(allowOverlap);
218
+ if (shareToken === null) {
219
+ return PathfindingModule.TracePath(start, target, attackDistance, openColor);
220
+ }
221
+
222
+ const sharedCells = this.pathState.getShareableCells(shareToken, owner);
223
+ sharedCells.forEach(cell => PathfindingModule.FreeNode(cell.row, cell.col));
224
+ const route = PathfindingModule.TracePath(start, target, attackDistance, openColor);
225
+ sharedCells.forEach(cell => PathfindingModule.OccupyNode(cell.row, cell.col, true));
226
+ return route;
227
+ }
228
+
229
+ /** 开启重叠的单位共用一个固定令牌:只要开关打开,彼此之间都允许同格 */
230
+ private getShareToken(allowOverlap: boolean): string | null {
231
+ return allowOverlap ? GridMovementController.OVERLAP_TOKEN : null;
232
+ }
233
+
202
234
  /** 路径规划失败时,做一次延迟重试,避免因为瞬时占用导致立刻失败。 */
203
- private retryPathfinding(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void): void {
235
+ private retryPathfinding(playerNode: Node, target: Cell, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void, allowOverlap = false): void {
204
236
  this.clearPathfindingRetry(playerNode);
205
237
  const retry = () => {
206
238
  this.clearPathfindingRetry(playerNode);
207
- this.moveTo(playerNode, target, attackDistance, openColor, callBack);
239
+ this.moveTo(playerNode, target, attackDistance, openColor, callBack, allowOverlap);
208
240
  };
209
241
  this.movementRetryCallbacks.set(playerNode, retry);
210
242
  this.scheduler.scheduleOnce(retry, this.retryDelay);
@@ -1,9 +1,9 @@
1
- {
2
- "ver": "4.0.24",
3
- "importer": "typescript",
4
- "imported": true,
5
- "uuid": "46edad01-0ea3-4d52-957f-edf0abfd3cae",
6
- "files": [],
7
- "subMetas": {},
8
- "userData": {}
9
- }
1
+ {
2
+ "ver": "4.0.24",
3
+ "importer": "typescript",
4
+ "imported": true,
5
+ "uuid": "46edad01-0ea3-4d52-957f-edf0abfd3cae",
6
+ "files": [],
7
+ "subMetas": {},
8
+ "userData": {}
9
+ }
@@ -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
 
@@ -1,9 +1,9 @@
1
- {
2
- "ver": "4.0.24",
3
- "importer": "typescript",
4
- "imported": true,
5
- "uuid": "760944a7-bf43-418a-ba1b-fc7ba7bf6958",
6
- "files": [],
7
- "subMetas": {},
8
- "userData": {}
9
- }
1
+ {
2
+ "ver": "4.0.24",
3
+ "importer": "typescript",
4
+ "imported": true,
5
+ "uuid": "760944a7-bf43-418a-ba1b-fc7ba7bf6958",
6
+ "files": [],
7
+ "subMetas": {},
8
+ "userData": {}
9
+ }
@@ -6,26 +6,47 @@ export interface PathGridOccupancy {
6
6
  free(row: number, col: number): void;
7
7
  }
8
8
 
9
+ /**
10
+ * 共享令牌:令牌相同且非 null 的单位允许站在同一格上(互相穿过);
11
+ * null 表示独占,即默认行为(不允许任何单位与自己同格)。
12
+ */
13
+ export type ShareToken = string | null;
14
+
9
15
  export class PathGridStateManager {
10
16
  private readonly temporaryCells = new Set<string>();
11
- private readonly reservedCells = new Map<string, Set<symbol>>();
17
+ /** key -> (归属者 -> 共享令牌) */
18
+ private readonly reservedCells = new Map<string, Map<symbol, ShareToken>>();
12
19
 
13
20
  constructor(private readonly occupancy: PathGridOccupancy) {
14
21
  }
15
22
 
16
- reserveCurrent(cell: Cell, owner: symbol): void {
23
+ reserveCurrent(cell: Cell, owner: symbol, shareToken: ShareToken = null): void {
17
24
  this.occupancy.occupy(cell.row, cell.col);
18
25
  this.temporaryCells.add(cellKey(cell.row, cell.col));
19
- this.addReservation(cellKey(cell.row, cell.col), owner);
26
+ this.addReservation(cellKey(cell.row, cell.col), owner, shareToken);
20
27
  }
21
28
 
22
- tryReserve(previous: Cell, next: Cell, owner: symbol): boolean {
29
+ /**
30
+ * 尝试保留下一格。
31
+ * 下一格已被别人占住时:只有双方共享令牌相同(同组)才放行,否则视为被堵。
32
+ */
33
+ tryReserve(previous: Cell, next: Cell, owner: symbol, shareToken: ShareToken = null): boolean {
23
34
  const nextKey = cellKey(next.row, next.col);
24
35
  const nextOwners = this.reservedCells.get(nextKey);
25
- if (nextOwners && [...nextOwners].some((reservedOwner) => reservedOwner !== owner)) {
26
- return false;
36
+ if (nextOwners) {
37
+ for (const [reservedOwner, reservedToken] of nextOwners) {
38
+ if (reservedOwner === owner) {
39
+ continue;
40
+ }
41
+ if (shareToken === null || reservedToken !== shareToken) {
42
+ return false;
43
+ }
44
+ }
27
45
  }
28
- if (this.occupancy.isOccupied(next.row, next.col) && !nextOwners?.has(owner)) {
46
+ // 没有任何归属者却处于占用状态 = 静态障碍,任何单位都不能踩上去
47
+ if (this.occupancy.isOccupied(next.row, next.col)
48
+ && !nextOwners?.has(owner)
49
+ && !this.isShareable(nextOwners, shareToken)) {
29
50
  return false;
30
51
  }
31
52
 
@@ -33,15 +54,16 @@ export class PathGridStateManager {
33
54
  this.occupancy.occupy(next.row, next.col);
34
55
  this.temporaryCells.add(cellKey(previous.row, previous.col));
35
56
  this.temporaryCells.add(nextKey);
36
- this.addReservation(cellKey(previous.row, previous.col), owner);
37
- this.addReservation(nextKey, owner);
57
+ this.addReservation(cellKey(previous.row, previous.col), owner, shareToken);
58
+ this.addReservation(nextKey, owner, shareToken);
38
59
  return true;
39
60
  }
40
61
 
41
- completeMove(previous: Cell, next: Cell, owner: symbol): void {
62
+ completeMove(previous: Cell, next: Cell, owner: symbol, shareToken: ShareToken = null): void {
42
63
  const previousKey = cellKey(previous.row, previous.col);
43
64
  if (this.removeReservation(previousKey, owner)) {
44
65
  this.temporaryCells.delete(previousKey);
66
+ // 还有同组单位站在原地时不释放占用
45
67
  if (!this.reservedCells.has(previousKey)) {
46
68
  this.occupancy.free(previous.row, previous.col);
47
69
  }
@@ -49,7 +71,36 @@ export class PathGridStateManager {
49
71
 
50
72
  this.occupancy.occupy(next.row, next.col);
51
73
  this.temporaryCells.add(cellKey(next.row, next.col));
52
- this.addReservation(cellKey(next.row, next.col), owner);
74
+ this.addReservation(cellKey(next.row, next.col), owner, shareToken);
75
+ }
76
+
77
+ /**
78
+ * 返回与 shareToken 同组、且不属于 excludeOwner 的单位已占用的格子。
79
+ * 供上层在寻路前临时释放,让 A* 不要把同组单位当成障碍绕行。
80
+ */
81
+ getShareableCells(shareToken: ShareToken, excludeOwner?: symbol): Cell[] {
82
+ if (shareToken === null) {
83
+ return [];
84
+ }
85
+ const cells: Cell[] = [];
86
+ for (const [key, owners] of this.reservedCells) {
87
+ let matched = false;
88
+ for (const [reservedOwner, reservedToken] of owners) {
89
+ if (excludeOwner && reservedOwner === excludeOwner) {
90
+ continue;
91
+ }
92
+ if (reservedToken === shareToken) {
93
+ matched = true;
94
+ break;
95
+ }
96
+ }
97
+ if (!matched) {
98
+ continue;
99
+ }
100
+ const [row, col] = key.split(',').map(Number);
101
+ cells.push({ row, col });
102
+ }
103
+ return cells;
53
104
  }
54
105
 
55
106
  release(owner?: symbol): void {
@@ -72,13 +123,26 @@ export class PathGridStateManager {
72
123
  }
73
124
  }
74
125
 
75
- private addReservation(key: string, owner: symbol): void {
126
+ private addReservation(key: string, owner: symbol, shareToken: ShareToken): void {
76
127
  let owners = this.reservedCells.get(key);
77
128
  if (!owners) {
78
- owners = new Set<symbol>();
129
+ owners = new Map<symbol, ShareToken>();
79
130
  this.reservedCells.set(key, owners);
80
131
  }
81
- owners.add(owner);
132
+ owners.set(owner, shareToken);
133
+ }
134
+
135
+ /** 该格上所有归属者的共享令牌都与 shareToken 一致(且非空)时,允许同格重叠 */
136
+ private isShareable(owners: Map<symbol, ShareToken> | undefined, shareToken: ShareToken): boolean {
137
+ if (shareToken === null || !owners || owners.size === 0) {
138
+ return false;
139
+ }
140
+ for (const reservedToken of owners.values()) {
141
+ if (reservedToken !== shareToken) {
142
+ return false;
143
+ }
144
+ }
145
+ return true;
82
146
  }
83
147
 
84
148
  private removeReservation(key: string, owner: symbol): boolean {
@@ -41,8 +41,14 @@ export class PathfindingModule {
41
41
  return this.movementController?.getPosition(playerNode) ?? { row: 0, col: 0 };
42
42
  }
43
43
 
44
- static MoveTo(playerNode: Node, target: { row: number, col: number }, attackDistance: AttackDistance, openColor: boolean, callBack?: () => void): void {
45
- this.movementController?.moveTo(playerNode, target, attackDistance, openColor, callBack);
44
+ /**
45
+ * 让单位移动到目标格子。
46
+ * allowOverlap:为 true 时该单位进入"允许重叠"模式,
47
+ * 与其他同样开启该开关的单位可以走到同一格(互相穿过,不再互相绕行/互相等待),
48
+ * 与终点是否相同无关;未开启的单位之间、以及开启与未开启之间仍保持互斥。
49
+ */
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);
46
52
  }
47
53
 
48
54
  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.5",
3
+ "version": "1.0.8",
4
4
  "engine": ">=3.8.6",
5
5
  "description": "系统组件添加常用扩展方法",
6
6
  "main": "index.ts",