feat(combat): support variable melee reach for weapons and creature size
This commit is contained in:
@@ -138,6 +138,17 @@ _SIZE_MODIFIERS: dict[str, int] = {
|
||||
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
||||
}
|
||||
|
||||
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
||||
# distinction). Unknown sizes fall back to 5 ft (the historical behavior).
|
||||
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
||||
"Small": 5,
|
||||
"Medium": 5,
|
||||
"Large": 10,
|
||||
"Huge": 15,
|
||||
"Gargantuan": 20,
|
||||
"Colossal": 30,
|
||||
}
|
||||
|
||||
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
||||
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
||||
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
||||
@@ -162,6 +173,11 @@ _STEP_DELTAS: tuple[Pos, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int:
|
||||
"""Effective melee reach in feet: max of natural size reach and weapon reach."""
|
||||
return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft)
|
||||
|
||||
|
||||
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
||||
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
||||
r0, c0 = start
|
||||
@@ -547,8 +563,13 @@ class CombatEngine:
|
||||
return None
|
||||
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
||||
for weapon in attacker.usable_attacks():
|
||||
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
|
||||
return weapon
|
||||
if weapon.kind in ("melee", "touch"):
|
||||
effective_reach_ft = _effective_reach_ft(attacker, weapon)
|
||||
if weapon.reach_ft > _SQUARE_FT:
|
||||
if _SQUARE_FT < dist_ft <= effective_reach_ft:
|
||||
return weapon
|
||||
elif dist_ft <= effective_reach_ft:
|
||||
return weapon
|
||||
max_increments = (
|
||||
_MAX_TOUCH_RANGE_INCREMENTS
|
||||
if weapon.kind == "touch"
|
||||
@@ -964,13 +985,42 @@ class CombatEngine:
|
||||
return None
|
||||
|
||||
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
|
||||
"""Squares this combatant threatens with a melee weapon.
|
||||
|
||||
Non-reach weapons (reach_ft == 5) threaten the disk of effective reach;
|
||||
reach weapons (reach_ft > 5) threaten only the ring at exactly their
|
||||
reach (PF1e reach dead zone). The union over usable melee weapons is
|
||||
returned.
|
||||
"""
|
||||
if not state.active:
|
||||
return frozenset()
|
||||
if not any(w.kind == "melee" for w in state.usable_attacks()):
|
||||
melee = [w for w in state.usable_attacks() if w.kind == "melee"]
|
||||
if not melee:
|
||||
return frozenset()
|
||||
threatened: set[Pos] = set()
|
||||
for weapon in melee:
|
||||
threatened |= self._weapon_threatened_squares(state, weapon)
|
||||
return frozenset(threatened)
|
||||
|
||||
def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]:
|
||||
"""Squares one melee weapon threatens: disk for non-reach, ring for reach."""
|
||||
r, c = state.pos
|
||||
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
|
||||
threatened: set[Pos] = set()
|
||||
if weapon.reach_ft > _SQUARE_FT:
|
||||
ring = weapon.reach_ft // _SQUARE_FT
|
||||
for dr in range(-ring, ring + 1):
|
||||
for dc in range(-ring, ring + 1):
|
||||
if self._grid.distance(state.pos, (r + dr, c + dc)) == ring:
|
||||
threatened.add((r + dr, c + dc))
|
||||
else:
|
||||
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
||||
for dr in range(-reach_cells, reach_cells + 1):
|
||||
for dc in range(-reach_cells, reach_cells + 1):
|
||||
if (dr, dc) == (0, 0):
|
||||
continue
|
||||
if self._grid.distance(state.pos, (r + dr, c + dc)) <= reach_cells:
|
||||
threatened.add((r + dr, c + dc))
|
||||
return threatened
|
||||
|
||||
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
||||
@@ -1111,8 +1161,11 @@ class CombatEngine:
|
||||
for weapon in state.usable_attacks():
|
||||
if weapon.kind != "melee":
|
||||
continue
|
||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
||||
end = self._charge_end(state, target, reach_cells, max_cells, blocked)
|
||||
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
||||
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
||||
end = self._charge_end(
|
||||
state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
||||
)
|
||||
if end is not None:
|
||||
return weapon
|
||||
return None
|
||||
@@ -1124,6 +1177,8 @@ class CombatEngine:
|
||||
reach_cells: int,
|
||||
max_cells: int,
|
||||
blocked: frozenset[Pos],
|
||||
*,
|
||||
min_reach_cells: int = 0,
|
||||
) -> Pos | None:
|
||||
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
|
||||
|
||||
@@ -1133,7 +1188,9 @@ class CombatEngine:
|
||||
Ties break toward lower distance from state.
|
||||
"""
|
||||
candidates: list[tuple[int, Pos]] = []
|
||||
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
||||
for end in self._charge_candidates(
|
||||
target, reach_cells, blocked, state.pos, min_reach_cells
|
||||
):
|
||||
path = _bresenham_line(state.pos, end)
|
||||
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
||||
continue
|
||||
@@ -1151,6 +1208,7 @@ class CombatEngine:
|
||||
reach_cells: int,
|
||||
blocked: frozenset[Pos],
|
||||
start: Pos,
|
||||
min_reach_cells: int = 0,
|
||||
) -> list[Pos]:
|
||||
"""Squares within reach of target, passable, unoccupied, not the start."""
|
||||
tr, tc = target.pos
|
||||
@@ -1168,7 +1226,8 @@ class CombatEngine:
|
||||
continue
|
||||
if end in blocked:
|
||||
continue
|
||||
if self._grid.distance(end, target.pos) > reach_cells:
|
||||
dist = self._grid.distance(end, target.pos)
|
||||
if dist > reach_cells or dist < min_reach_cells:
|
||||
continue
|
||||
result.append(end)
|
||||
return result
|
||||
@@ -1194,13 +1253,16 @@ class CombatEngine:
|
||||
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
||||
as for any move action.
|
||||
"""
|
||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
||||
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
|
||||
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
||||
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
||||
max_cells = 2 * speed_cells
|
||||
blocked = frozenset(
|
||||
s.pos for s in self._states if s is not attacker and s is not target and s.active
|
||||
)
|
||||
end = self._charge_end(attacker, target, reach_cells, max_cells, blocked)
|
||||
end = self._charge_end(
|
||||
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
||||
)
|
||||
if end is None:
|
||||
return
|
||||
path = _bresenham_line(attacker.pos, end)
|
||||
|
||||
Reference in New Issue
Block a user