feat(combat): grant soft cover (+4 AC) to ranged attacks through creatures
This commit is contained in:
@@ -15,11 +15,12 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
- Line of effect gates all attacks: a target fully behind blocking terrain
|
||||
cannot be attacked, and the policy moves to gain sight instead.
|
||||
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
|
||||
ranged reuse the same corner rule.
|
||||
ranged reuse the same corner rule. Soft cover: active creatures between
|
||||
attacker and target also grant +4 AC on ranged attacks (CRB soft cover);
|
||||
melee attacks ignore creatures.
|
||||
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
|
||||
range increments; the penalty applies to attack and crit-confirm rolls.
|
||||
Thrown weapons (5 increments max) are not distinguished; soft cover from
|
||||
creatures is not modeled.
|
||||
Thrown weapons (5 increments max) are not distinguished.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -221,7 +222,16 @@ class CombatEngine:
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
total = roll + weapon.attack_bonus + penalty
|
||||
ac = target.combatant.ac.total
|
||||
if has_cover(self._grid, attacker.pos, target.pos, ranged=weapon.kind == "ranged"):
|
||||
occupied = frozenset(
|
||||
s.pos for s in self._states if s.active and s is not attacker and s is not target
|
||||
)
|
||||
if has_cover(
|
||||
self._grid,
|
||||
attacker.pos,
|
||||
target.pos,
|
||||
ranged=weapon.kind == "ranged",
|
||||
occupied=occupied,
|
||||
):
|
||||
ac += _COVER_AC_BONUS
|
||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
||||
crit = False
|
||||
|
||||
@@ -75,10 +75,12 @@ class _LineGrid:
|
||||
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])
|
||||
@@ -90,9 +92,9 @@ class _LineGrid:
|
||||
pos = (row, col)
|
||||
if pos in self._exclude or not self._grid.in_bounds(pos):
|
||||
continue
|
||||
if self._blocking(self._grid.terrain_at(pos)) and _segment_hits_cell(
|
||||
p1, p2, row, col
|
||||
):
|
||||
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
|
||||
|
||||
@@ -113,16 +115,30 @@ def has_line_of_effect(grid: Grid, a: Pos, b: Pos) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_cover(grid: Grid, attacker: Pos, target: Pos, *, ranged: bool) -> bool:
|
||||
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.
|
||||
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
|
||||
_ = ranged
|
||||
lines = _LineGrid(grid, attacker, target, _grants_cover)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user