feat(rules): 3D flight over ground terrain - airborne creatures (fly >= land, V4 budget from fly speed) move on Grid.airborne_view: ground difficult terrain costs 1 and cells impassable to walkers (pits, water, rubble, low obstacles) are crossable at cost 1, EXCEPT blocks_los cells (solid barriers, assumed full height) which still stop flight; run/charge/5-foot-step gates and diagonal corner rule follow the same view (flying past a pit is legal, past a wall corner is not); elevation climb cost still applies (step heights, not wall heights - documented); line of effect, cover and high ground use the unchanged real grid so combat geometry is unaffected by flight (SRD Movement, Position and Distance: flying creatures not hampered by difficult terrain, able to avoid most obstacles), 11 tests

This commit is contained in:
2026-09-20 22:21:40 +02:00
parent 1257bb3849
commit 2ae86ab29f
3 changed files with 461 additions and 66 deletions
+36
View File
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from pf1e_simulator.map import MapSpec, Pos, TerrainType
_DIFFICULT_TERRAIN_COST = 2 # rubble, water, etc. count as 2 squares (PF1e)
_AIRBORNE_TERRAIN_COST = 1 # flying is not hampered by ground terrain (PF1e)
_DIRECTIONS: tuple[Pos, ...] = (
(-1, -1),
@@ -48,6 +49,41 @@ class Grid:
self._width = len(cells[0]) if cells else 0
self._elevation = elevation
@staticmethod
def _airborne_terrain(terrain: TerrainType) -> TerrainType:
"""The same square seen from the air (PF1e flight over ground terrain).
SRD "Movement, Position, and Distance" (Terrain and Obstacles):
"Flying and incorporeal creatures are not hampered by difficult
terrain" and "Flying and incorporeal creatures are able to avoid most
obstacles" — so a cell that only slows walking (move_cost > 1) or
stops it (pits, rubble, water: move_cost None) becomes a plain
cost-1 cell in the air view. The exception is a SOLID BARRIER
(`blocks_los`, the engine's encoding of CRB "Aiming a Spell — Line of
Effect": "A line of effect is canceled by a solid barrier"), which is
assumed full height and still stops flight. Cover flags and the
terrain name are preserved; only the walking cost is normalized.
"""
if terrain.blocks_los:
return terrain
cost = terrain.move_cost
if cost is None or cost > _AIRBORNE_TERRAIN_COST:
return terrain.model_copy(update={"move_cost": _AIRBORNE_TERRAIN_COST})
return terrain
def airborne_view(self) -> Grid:
"""A terrain view for airborne creatures (see ``_airborne_terrain``).
Elevation is preserved: the layer stores STEP heights of the ground,
not wall or cliff heights, so the climbing cost still applies to
flight (documented simplification — true 3D flight is not modeled).
"""
cells = tuple(
tuple(self._airborne_terrain(terrain) for terrain in row)
for row in self._cells
)
return Grid(cells, elevation=self._elevation)
@classmethod
def from_spec(cls, spec: MapSpec) -> Grid:
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)