feat(combat): support partial and improved cover levels
This commit is contained in:
+100
-1
@@ -21,6 +21,8 @@ if TYPE_CHECKING:
|
||||
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
|
||||
_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0
|
||||
|
||||
_STANDARD_COVER_BONUS = 4 # PF1e: standard cover, and soft cover from a creature
|
||||
|
||||
|
||||
def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
|
||||
row, col = pos
|
||||
@@ -75,11 +77,13 @@ class _LineGrid:
|
||||
exclude: frozenset[Pos],
|
||||
blocking: Callable[[TerrainType], bool],
|
||||
occupied: frozenset[Pos] = frozenset(),
|
||||
bonus_of: Callable[[TerrainType], int] | None = None,
|
||||
) -> None:
|
||||
self._grid = grid
|
||||
self._exclude = exclude
|
||||
self._blocking = blocking
|
||||
self._occupied = occupied
|
||||
self._bonus_of = bonus_of
|
||||
|
||||
def blocked(self, p1: Pt, p2: Pt) -> bool:
|
||||
min_x = min(p1[0], p2[0])
|
||||
@@ -97,9 +101,49 @@ class _LineGrid:
|
||||
return True
|
||||
return False
|
||||
|
||||
def bonus(self, p1: Pt, p2: Pt) -> int:
|
||||
"""Highest cover bonus among the blocking cells the segment p1->p2 hits.
|
||||
|
||||
0 when the segment crosses no blocking cell (or when this view was
|
||||
built without a bonus function, i.e. for plain line-of-effect).
|
||||
Occupied cells always grant soft cover (+4, CRB).
|
||||
"""
|
||||
if self._bonus_of is None:
|
||||
return 0
|
||||
min_x = min(p1[0], p2[0])
|
||||
max_x = max(p1[0], p2[0])
|
||||
min_y = min(p1[1], p2[1])
|
||||
max_y = max(p1[1], p2[1])
|
||||
best = 0
|
||||
for col in range(min_x - 1, max_x + 1):
|
||||
for row in range(min_y - 1, max_y + 1):
|
||||
pos = (row, col)
|
||||
if pos in self._exclude or not self._grid.in_bounds(pos):
|
||||
continue
|
||||
terrain = self._grid.terrain_at(pos)
|
||||
is_occupied = pos in self._occupied
|
||||
if not (is_occupied or self._blocking(terrain)):
|
||||
continue
|
||||
if not _segment_hits_cell(p1, p2, row, col):
|
||||
continue
|
||||
value = self._bonus_of(terrain)
|
||||
if is_occupied:
|
||||
value = max(value, _STANDARD_COVER_BONUS)
|
||||
best = max(best, value)
|
||||
return best
|
||||
|
||||
|
||||
def terrain_cover_bonus(terrain: TerrainType) -> int:
|
||||
"""Cover bonus a terrain square grants: its level, else legacy +4 for cover."""
|
||||
if terrain.cover_bonus > 0:
|
||||
return terrain.cover_bonus
|
||||
if terrain.cover or terrain.blocks_los:
|
||||
return _STANDARD_COVER_BONUS
|
||||
return 0
|
||||
|
||||
|
||||
def _grants_cover(terrain: TerrainType) -> bool:
|
||||
return terrain.cover or terrain.blocks_los
|
||||
return terrain_cover_bonus(terrain) > 0
|
||||
|
||||
|
||||
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
|
||||
@@ -179,3 +223,58 @@ def has_cover_cells(
|
||||
for cell_a in attacker_cells
|
||||
for corner_a in _corners(cell_a)
|
||||
)
|
||||
|
||||
|
||||
def cover_bonus(
|
||||
grid: Grid,
|
||||
attacker: Pos,
|
||||
target: Pos,
|
||||
*,
|
||||
ranged: bool,
|
||||
occupied: frozenset[Pos] = frozenset(),
|
||||
) -> int:
|
||||
"""Effective cover bonus (0/2/4/8) for an attack between two single cells."""
|
||||
return cover_bonus_cells(
|
||||
grid,
|
||||
frozenset({attacker}),
|
||||
frozenset({target}),
|
||||
ranged=ranged,
|
||||
occupied=occupied,
|
||||
)
|
||||
|
||||
|
||||
def cover_bonus_cells(
|
||||
grid: Grid,
|
||||
attacker_cells: frozenset[Pos],
|
||||
target_cells: frozenset[Pos],
|
||||
*,
|
||||
ranged: bool,
|
||||
occupied: frozenset[Pos] = frozenset(),
|
||||
) -> int:
|
||||
"""Effective cover bonus (0/2/4/8) for an attack between two sets of cells.
|
||||
|
||||
0 when there is no cover (see has_cover_cells for the corner rule);
|
||||
otherwise the highest cover level among the cells blocking the
|
||||
attacker-corner to target-corner lines. Interposed creatures grant soft
|
||||
cover (+4) on ranged attacks only, exactly as in has_cover_cells.
|
||||
"""
|
||||
if not attacker_cells or not target_cells:
|
||||
return 0
|
||||
if not has_cover_cells(
|
||||
grid, attacker_cells, target_cells, ranged=ranged, occupied=occupied
|
||||
):
|
||||
return 0
|
||||
lines = _LineGrid(
|
||||
grid,
|
||||
attacker_cells | target_cells,
|
||||
_grants_cover,
|
||||
occupied if ranged else frozenset(),
|
||||
bonus_of=terrain_cover_bonus,
|
||||
)
|
||||
return max(
|
||||
lines.bonus(corner_a, corner_t)
|
||||
for cell_a in attacker_cells
|
||||
for corner_a in _corners(cell_a)
|
||||
for cell_t in target_cells
|
||||
for corner_t in _corners(cell_t)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user