feat(grid): apply elevation to movement cost and high ground
This commit is contained in:
@@ -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} "
|
||||
|
||||
@@ -32,17 +32,39 @@ _DIRECTIONS: tuple[Pos, ...] = (
|
||||
|
||||
|
||||
class Grid:
|
||||
"""Terrain view of a MapSpec with movement and distance queries."""
|
||||
"""Terrain view of a MapSpec with movement and distance queries.
|
||||
|
||||
def __init__(self, cells: tuple[tuple[TerrainType, ...], ...]) -> None:
|
||||
An optional elevation layer stores a height per cell; climbing one level
|
||||
costs one extra square of movement (PF1e), descending is free.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cells: tuple[tuple[TerrainType, ...], ...],
|
||||
elevation: tuple[tuple[int, ...], ...] | None = None,
|
||||
) -> None:
|
||||
self._cells = cells
|
||||
self._height = len(cells)
|
||||
self._width = len(cells[0]) if cells else 0
|
||||
self._elevation = elevation
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: MapSpec) -> Grid:
|
||||
cells = tuple(tuple(spec.legend[ch] for ch in row) for row in spec.terrain)
|
||||
return cls(cells)
|
||||
elevation = None
|
||||
if spec.elevation is not None:
|
||||
height = len(cells)
|
||||
width = len(cells[0]) if cells else 0
|
||||
if len(spec.elevation) != height or (
|
||||
height and len(spec.elevation[0]) != width
|
||||
):
|
||||
msg = "elevation layer dimensions do not match terrain"
|
||||
raise ValueError(msg)
|
||||
elevation = tuple(
|
||||
tuple(0 if ch == "." else int(ch) for ch in row)
|
||||
for row in spec.elevation
|
||||
)
|
||||
return cls(cells, elevation=elevation)
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
@@ -63,6 +85,13 @@ class Grid:
|
||||
def passable(self, pos: Pos) -> bool:
|
||||
return self.in_bounds(pos) and self.terrain_at(pos).move_cost is not None
|
||||
|
||||
def elevation_at(self, pos: Pos) -> int:
|
||||
"""Height of a cell; 0 when no elevation layer or out of bounds."""
|
||||
if self._elevation is None or not self.in_bounds(pos):
|
||||
return 0
|
||||
row, col = pos
|
||||
return self._elevation[row][col]
|
||||
|
||||
def distance(self, a: Pos, b: Pos) -> int:
|
||||
"""Distance in squares under the 5-10-5-10 diagonal rule."""
|
||||
d_row = abs(a[0] - b[0])
|
||||
@@ -70,17 +99,23 @@ class Grid:
|
||||
return max(d_row, d_col) + min(d_row, d_col) // 2
|
||||
|
||||
def step_cost(self, frm: Pos, to: Pos, diagonals_used: int) -> int:
|
||||
"""Cost in squares of one step; diagonals_used is the count so far."""
|
||||
"""Cost in squares of one step; diagonals_used is the count so far.
|
||||
|
||||
Climbing one elevation level adds one square per level; descending is
|
||||
free and never reduces the terrain cost.
|
||||
"""
|
||||
cost = self.terrain_at(to).move_cost
|
||||
if cost is None:
|
||||
msg = f"cannot step into impassable square {to}"
|
||||
raise ValueError(msg)
|
||||
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
|
||||
if not is_diagonal:
|
||||
return cost
|
||||
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
||||
if cost >= _DIFFICULT_TERRAIN_COST:
|
||||
return 3
|
||||
return 1 + (diagonals_used % 2)
|
||||
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
||||
return 1 + (diagonals_used % 2) + max(
|
||||
0, self.elevation_at(to) - self.elevation_at(frm)
|
||||
)
|
||||
|
||||
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
||||
flank_a = (frm[0], to[1])
|
||||
|
||||
Reference in New Issue
Block a user