nodes/pathfinder.pyΒΆ
Part of Tanks of Freedom.
1"""Pathfinding + flood-fill helpers.
2
3Both queries are hand-rolled breadth-first searches over the 12x12 grid.
4Every step costs exactly one AP, and the two callbacks (``passable`` for
5terrain, ``blocked`` for occupancy) are re-evaluated per query because the
6answer changes with the selected unit: helicopters fly over water and
7forest, and a friendly unit blocks a tile only for someone else. Diagonal
8movement is disabled (matches upstream).
9"""
10
11from __future__ import annotations
12
13from collections import deque
14
15from .data import MAP_H, MAP_W
16
17
18def neighbours4(x: int, y: int):
19 """4-directional neighbours within map bounds."""
20 for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
21 nx, ny = x + dx, y + dy
22 if 0 <= nx < MAP_W and 0 <= ny < MAP_H:
23 yield nx, ny
24
25
26def reachable_cells(start: tuple[int, int], ap: int, *, passable, blocked):
27 """BFS flood fill: cells reachable from ``start`` within ``ap`` AP.
28
29 Args:
30 start: (x, y) start cell.
31 ap: action-points budget (each step costs 1 here; future refinements
32 could read ``terrain.cost_for(unit)``).
33 passable: callable (x, y) -> bool, terrain alone passable for unit.
34 blocked: callable (x, y) -> bool, there's a unit/obstacle in the way.
35
36 Returns:
37 Dict ``{(x, y): cost}`` of cells reachable at ``cost <= ap``.
38 ``start`` is always included with cost 0.
39 """
40 seen = {start: 0}
41 q: deque[tuple[int, int]] = deque([start])
42 while q:
43 x, y = q.popleft()
44 c = seen[(x, y)]
45 if c >= ap:
46 continue
47 for nx, ny in neighbours4(x, y):
48 if (nx, ny) in seen:
49 continue
50 if not passable(nx, ny):
51 continue
52 if blocked(nx, ny):
53 continue
54 seen[(nx, ny)] = c + 1
55 q.append((nx, ny))
56 return seen
57
58
59def find_path(
60 start: tuple[int, int],
61 goal: tuple[int, int],
62 *,
63 passable,
64 blocked,
65):
66 """Return list of cells [start, ..., goal] or [] if unreachable.
67
68 Uses BFS for unit-cost grids: simple, matches the on-screen ``ap`` budget
69 and stays predictable for the AI.
70 """
71 if start == goal:
72 return [start]
73 parents: dict[tuple[int, int], tuple[int, int]] = {start: start}
74 q: deque[tuple[int, int]] = deque([start])
75 while q:
76 cell = q.popleft()
77 if cell == goal:
78 break
79 x, y = cell
80 for nx, ny in neighbours4(x, y):
81 n = (nx, ny)
82 if n in parents:
83 continue
84 if not passable(nx, ny):
85 continue
86 # Allow the goal cell even if blocked (target/attack square).
87 if blocked(nx, ny) and n != goal:
88 continue
89 parents[n] = cell
90 q.append(n)
91 if goal not in parents:
92 return []
93 # Trace back
94 path = [goal]
95 cur = goal
96 while cur != start:
97 cur = parents[cur]
98 path.append(cur)
99 path.reverse()
100 return path
101
102
103def cells_within_range(cell: tuple[int, int], rng: int):
104 """All cells within Chebyshev range ``rng`` (4-dir Manhattan for our case)."""
105 cx, cy = cell
106 out = []
107 for dy in range(-rng, rng + 1):
108 for dx in range(-rng, rng + 1):
109 if dx == 0 and dy == 0:
110 continue
111 if abs(dx) + abs(dy) > rng:
112 continue
113 x, y = cx + dx, cy + dy
114 if 0 <= x < MAP_W and 0 <= y < MAP_H:
115 out.append((x, y))
116 return out