@cc-component/cc-pathfinding 1.0.4 → 1.0.7

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
  /** 销毁移动控制器 */
@@ -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,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
- /** 被占用格子的 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
+ /** 被占用格子的 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,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
+ }
@@ -341,7 +341,7 @@ export class MapBoxView extends Component {
341
341
  this.seat_model_list.forEach(list => {
342
342
  list.forEach(item => {
343
343
  if (!this.grid.isOccupied(item.row, item.col)) {
344
- item.node_chose_sp.node.active = visible;
344
+ // item.node_chose_sp.node.active = visible;
345
345
  if (!visible) {
346
346
  item.node_chose_sp.color = Color.WHITE;
347
347
  }
@@ -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.4",
3
+ "version": "1.0.7",
4
4
  "engine": ">=3.8.6",
5
5
  "description": "系统组件添加常用扩展方法",
6
6
  "main": "index.ts",