146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
"""Corner-rule line of effect and cover.
|
|
|
|
Squares are unit cells on an integer lattice: cell (row, col) spans
|
|
x in [col, col+1], y in [row, row+1]. A line is blocked when its RELATIVE
|
|
INTERIOR touches a blocking square's closure (interior, border, or vertex);
|
|
lines merely starting or ending on a blocking vertex do not count. All math
|
|
is exact integer arithmetic: parameter t is kept as (num, den) pairs, no
|
|
floats anywhere.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import Pos, TerrainType
|
|
|
|
Pt = tuple[int, int] # lattice point (x, y) = (col, row)
|
|
_Rat = tuple[int, int] # exact rational as (numerator, denominator), den > 0
|
|
|
|
|
|
def _corners(pos: Pos) -> tuple[Pt, Pt, Pt, Pt]:
|
|
row, col = pos
|
|
return ((col, row), (col + 1, row), (col, row + 1), (col + 1, row + 1))
|
|
|
|
|
|
def _norm(num: int, den: int) -> _Rat:
|
|
return (num, den) if den > 0 else (-num, -den)
|
|
|
|
|
|
def _le(a: _Rat, b: _Rat) -> bool:
|
|
return a[0] * b[1] <= b[0] * a[1]
|
|
|
|
|
|
def _lt(a: _Rat, b: _Rat) -> bool:
|
|
return a[0] * b[1] < b[0] * a[1]
|
|
|
|
|
|
def _slab(p1: Pt, delta: Pt, lo: int, hi: int, axis: int) -> tuple[_Rat, _Rat] | None:
|
|
"""Closed t-interval where p1 + t*delta lies within [lo, hi] on `axis`."""
|
|
start = p1[axis]
|
|
step = delta[axis]
|
|
if step == 0:
|
|
return ((0, 1), (1, 1)) if lo <= start <= hi else None
|
|
t1 = _norm(lo - start, step)
|
|
t2 = _norm(hi - start, step)
|
|
return (t1, t2) if _le(t1, t2) else (t2, t1)
|
|
|
|
|
|
def _segment_hits_cell(p1: Pt, p2: Pt, row: int, col: int) -> bool:
|
|
"""True iff the relative interior of p1->p2 touches the closed cell."""
|
|
delta = (p2[0] - p1[0], p2[1] - p1[1])
|
|
if delta == (0, 0):
|
|
return False
|
|
interval_x = _slab(p1, delta, col, col + 1, 0)
|
|
if interval_x is None:
|
|
return False
|
|
interval_y = _slab(p1, delta, row, row + 1, 1)
|
|
if interval_y is None:
|
|
return False
|
|
t_low = interval_x[0] if _le(interval_y[0], interval_x[0]) else interval_y[0]
|
|
t_high = interval_x[1] if _le(interval_x[1], interval_y[1]) else interval_y[1]
|
|
return _le(t_low, t_high) and _lt((0, 1), t_high) and _lt(t_low, (1, 1))
|
|
|
|
|
|
class _LineGrid:
|
|
"""Bound view for corner-line checks between two squares of one grid."""
|
|
|
|
def __init__(
|
|
self,
|
|
grid: Grid,
|
|
a: Pos,
|
|
b: Pos,
|
|
blocking: Callable[[TerrainType], bool],
|
|
occupied: frozenset[Pos] = frozenset(),
|
|
) -> None:
|
|
self._grid = grid
|
|
self._exclude = frozenset({a, b})
|
|
self._blocking = blocking
|
|
self._occupied = occupied
|
|
|
|
def blocked(self, p1: Pt, p2: Pt) -> bool:
|
|
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])
|
|
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
|
|
if (
|
|
pos in self._occupied or self._blocking(self._grid.terrain_at(pos))
|
|
) and _segment_hits_cell(p1, p2, row, col):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _grants_cover(terrain: TerrainType) -> bool:
|
|
return terrain.cover or terrain.blocks_los
|
|
|
|
|
|
def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
|
|
"""LoE iff some corner of a reaches some corner of b unblocked."""
|
|
if a == b:
|
|
return True
|
|
lines = _LineGrid(grid, a, b, lambda terrain: terrain.blocks_los)
|
|
return any(
|
|
not lines.blocked(corner_a, corner_b)
|
|
for corner_a in _corners(a)
|
|
for corner_b in _corners(b)
|
|
)
|
|
|
|
|
|
def has_cover(
|
|
grid: Grid,
|
|
attacker: Pos,
|
|
target: Pos,
|
|
*,
|
|
ranged: bool,
|
|
occupied: frozenset[Pos] = frozenset(),
|
|
) -> bool:
|
|
"""Cover iff every attacker corner has at least one blocked target corner.
|
|
|
|
The attacker picks their single best corner (PF1e ranged cover rule);
|
|
Phase 0 documented deviation: melee reuses the same corner rule. For
|
|
ranged attacks, squares occupied by other creatures also block the
|
|
lines (soft cover, CRB); melee ignores creatures.
|
|
"""
|
|
if attacker == target:
|
|
return False
|
|
lines = _LineGrid(
|
|
grid,
|
|
attacker,
|
|
target,
|
|
_grants_cover,
|
|
occupied if ranged else frozenset(),
|
|
)
|
|
return not any(
|
|
all(not lines.blocked(corner_a, corner_t) for corner_t in _corners(target))
|
|
for corner_a in _corners(attacker)
|
|
)
|