feat(grid): apply elevation to movement cost and high ground

This commit is contained in:
2026-09-05 16:18:56 +02:00
parent 30f8905a3e
commit ab99ab67e3
6 changed files with 427 additions and 15 deletions
+42 -7
View File
@@ -32,17 +32,39 @@ _DIRECTIONS: tuple[Pos, ...] = (
class Grid:
"""Terrain view of a MapSpec with movement and distance queries."""
"""Terrain view of a MapSpec with movement and distance queries.
def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None:
An optional elevation layer stores a height per cell; climbing one level
costs one extra square of movement (PF1e), descending is free.
"""
def __init__(
self,
cells: tuple[tuple[TerrainType, ...], ...],
elevation: tuple[tuple[int, ...], ...] | None = None,
) -> None:
self._cells = cells
self._height = len(cells)
self._width = len(cells[0]) if cells else 0
self._elevation = elevation
@classmethod
def from_spec(cls, spec: MapSpec) -> Grid:
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
return cls(cells)
elevation = None
if spec.elevation is not None:
height = len(cells)
width = len(cells[0]) if cells else 0
if len(spec.elevation) != height or (
height and len(spec.elevation[0]) != width
):
msg = "elevation layer dimensions do not match terrain"
raise ValueError(msg)
elevation = tuple(
tuple(0 if ch == "." else int(ch) for ch in row)
for row in spec.elevation
)
return cls(cells, elevation=elevation)
@property
def height(self) -> int:
@@ -63,6 +85,13 @@ class Grid:
def passable(self, pos: Pos) -> bool:
return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None
def elevation_at(self, pos: Pos) -> int:
"""Height of a cell; 0 when no elevation layer or out of bounds."""
if self._elevation is None or not self.in_bounds(pos):
return 0
row, col = pos
return self._elevation[row][col]
def distance(self, a: Pos, b: Pos) -> int:
"""Distance in squares under the 5-10-5-10 diagonal rule."""
d_row = abs(a[0] - b[0])
@@ -70,17 +99,23 @@ class Grid:
return max(d_row, d_col) + min(d_row, d_col) // 2
def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int:
"""Cost in squares of one step; diagonals_used is the count so far."""
"""Cost in squares of one step; diagonals_used is the count so far.
Climbing one elevation level adds one square per level; descending is
free and never reduces the terrain cost.
"""
cost = self.terrain_at(to).move_cost
if cost is None:
msg = f"cannot step into impassable square {to}"
raise ValueError(msg)
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
if not is_diagonal:
return cost
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
if cost >= _DIFFICULT_TERRAIN_COST:
return 3
return 1 + (diagonals_used % 2)
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
return 1 + (diagonals_used % 2) + max(
0, self.elevation_at(to) - self.elevation_at(frm)
)
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
flank_a = (frm[0], to[1])