feat(grid): apply elevation to movement cost and high ground

This commit is contained in:
2026-09-05 16:18:56 +02:00
parent 30f8905a3e
commit ab99ab67e3
6 changed files with 427 additions and 15 deletions
+23 -5
View File
@@ -121,6 +121,7 @@ _RANGE_INCREMENT_PENALTY = 2 # PF1e: -2 per full range increment beyond the fir
_MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments
_MAX_TOUCH_RANGE_INCREMENTS = 5 # PF1e: thrown/firearm touch weapons shoot up to 5 increments
_FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
_HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target
_ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous
_MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1)
_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon.
@@ -318,6 +319,7 @@ class AttackResult:
ac: int
penalty: int = 0
flank_bonus: int = 0
high_ground_bonus: int = 0
base_bonus: int = 0
@@ -591,6 +593,10 @@ class CombatEngine:
for cell_b in cells_b
)
def _elevation(self, state: CombatantState) -> int:
"""Height of the creature's anchor cell (0 when no elevation layer)."""
return self._grid.elevation_at(state.pos)
def _cells_free(self, cells: Iterable[Pos], mover: CombatantState) -> bool:
"""True if every cell is in bounds, passable, and unoccupied by others."""
blocked = self._blocked(mover)
@@ -842,6 +848,12 @@ class CombatEngine:
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
penalty = self.range_penalty(weapon, dist_ft)
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
high_ground = (
_HIGH_GROUND_BONUS
if weapon.kind == "melee"
and self._elevation(attacker) > self._elevation(target)
else 0
)
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
atk_mods = self._stat_modifiers(
attacker, "attack", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
@@ -850,7 +862,7 @@ class CombatEngine:
atk_mods.append(StatModifier(target="attack", value=-4))
attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
total = roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
# Check if target denies DEX to AC (flat-footed)
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
full_ac_base = (
@@ -895,7 +907,9 @@ class CombatEngine:
if hit:
if roll != _NATURAL_ONE and roll >= weapon.crit_range:
confirm = self._rng.d20()
confirm_total = confirm + base_bonus + penalty + flank + attack_mod - pa_amt
confirm_total = (
confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
)
crit = confirm != _NATURAL_ONE and confirm_total >= ac
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
@@ -909,7 +923,8 @@ class CombatEngine:
target.hp -= damage
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, base_bonus=base_bonus,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus,
)
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
@@ -2521,10 +2536,13 @@ class CombatEngine:
target_live.damage_taken += result.damage
outcome = "CRIT" if result.crit else "HIT" if result.hit else "MISS"
flank_str = f"+{result.flank_bonus}(flank)" if result.flank_bonus else ""
high_ground_str = (
f"+{result.high_ground_bonus}(high)" if result.high_ground_bonus else ""
)
bonus_str = (
f"{result.base_bonus}{result.penalty:+d}{flank_str}"
f"{result.base_bonus}{result.penalty:+d}{flank_str}{high_ground_str}"
if result.penalty
else f"{result.base_bonus}{flank_str}"
else f"{result.base_bonus}{flank_str}{high_ground_str}"
)
line = (
f"round {self._current_round} {attacker.combatant.id}: {label} "