feat(combat): support variable melee reach for weapons and creature size

This commit is contained in:
2026-09-05 15:10:21 +02:00
parent 7cb3653750
commit 7f57df16a6
2 changed files with 359 additions and 11 deletions
+73 -11
View File
@@ -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)