feat(combat): add direction field to Action for directional maneuvers

This commit is contained in:
2026-09-05 18:37:26 +02:00
parent febd408f29
commit 7fbf3988e9
2 changed files with 270 additions and 18 deletions
+57 -18
View File
@@ -366,6 +366,7 @@ class Action:
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
direction: tuple[int, int] | None = None # steering for directional maneuvers
@dataclass(frozen=True)
@@ -1467,7 +1468,7 @@ class CombatEngine:
if kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name)
elif kind == "maneuver":
self._execute_maneuver(state, target, action.maneuver)
self._execute_maneuver(state, target, action.maneuver, direction=action.direction)
elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"):
self._execute_class_ability(state, target, action)
elif kind == "charge":
@@ -1565,7 +1566,12 @@ class CombatEngine:
self._cast_spell(state, target, spell_name)
def _execute_maneuver(
self, state: CombatantState, target: CombatantState | None, maneuver: str | None
self,
state: CombatantState,
target: CombatantState | None,
maneuver: str | None,
*,
direction: tuple[int, int] | None = None,
) -> None:
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
escape, overrun, dirty_trick, steal, reposition, drag)."""
@@ -1595,14 +1601,14 @@ class CombatEngine:
handlers: dict[str, Callable[[], None]] = {
"trip": lambda: self._maneuver_trip(state, target, m),
"disarm": lambda: self._maneuver_disarm(state, target, m),
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m),
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m, direction=direction),
"sunder": lambda: self._maneuver_sunder(state, target, m),
"grapple": lambda: self._maneuver_grapple(state, target, m),
"overrun": lambda: self._maneuver_overrun(state, target, m),
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
"steal": lambda: self._maneuver_steal(state, target, m),
"reposition": lambda: self._maneuver_reposition(state, target, m),
"drag": lambda: self._maneuver_drag(state, target, m),
"reposition": lambda: self._maneuver_reposition(state, target, m, direction=direction),
"drag": lambda: self._maneuver_drag(state, target, m, direction=direction),
}
handlers[maneuver]()
@@ -1658,12 +1664,23 @@ class CombatEngine:
)
def _maneuver_bull_rush(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "bull_rush", m)
return
pushed = self._bull_rush_push(state, target)
if direction is not None:
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
pushed = self._cells_free(self._footprint_at(new_pos, target), target)
if pushed:
target.pos = new_pos
else:
pushed = self._bull_rush_push(state, target)
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
self._log(
f"round {self._current_round} {state.combatant.id}: bull rush vs "
@@ -1805,21 +1822,35 @@ class CombatEngine:
)
def _maneuver_reposition(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "reposition", m)
return
new_pos: Pos | None = None
for dr, dc in _NEIGHBOR_OFFSETS:
pos = (target.pos[0] + dr, target.pos[1] + dc)
if pos == state.pos:
continue
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
continue
if self._cells_free(self._footprint_at(pos, target), target):
if direction is not None:
pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
if (
0 <= pos[0] < self._grid.height
and 0 <= pos[1] < self._grid.width
and self._cells_free(self._footprint_at(pos, target), target)
):
new_pos = pos
break
else:
for dr, dc in _NEIGHBOR_OFFSETS:
pos = (target.pos[0] + dr, target.pos[1] + dc)
if pos == state.pos:
continue
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
continue
if self._cells_free(self._footprint_at(pos, target), target):
new_pos = pos
break
if new_pos is not None:
target.pos = new_pos
outcome = f"SUCCESS (moved to {new_pos})"
@@ -1832,7 +1863,12 @@ class CombatEngine:
)
def _maneuver_drag(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "drag", m)
@@ -1840,7 +1876,10 @@ class CombatEngine:
ar, ac = state.pos
tr, tc = target.pos
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
beyond = self._beyond_cell(state.pos, target.pos)
if direction is not None:
beyond = (tr + direction[0], tc + direction[1])
else:
beyond = self._beyond_cell(state.pos, target.pos)
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
target_old = target.pos
target.pos = beyond