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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user