Combat moves defined as TOML content files in content/combat/, not engine code. State machine (IDLE > TELEGRAPH > WINDOW > RESOLVE) processes timing-based exchanges. Counter relationships, stamina costs, damage formulas all tunable from data files. Moves: punch right/left, roundhouse, sweep, dodge right/left, parry high/low, duck, jump. Combat ends on knockout (PL <= 0) or exhaustion (stamina <= 0).
124 lines
3.7 KiB
Python
124 lines
3.7 KiB
Python
"""Combat encounter and state machine."""
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from mudlib.combat.moves import CombatMove
|
|
from mudlib.entity import Entity
|
|
|
|
|
|
class CombatState(Enum):
|
|
"""States of the combat state machine."""
|
|
|
|
IDLE = "idle"
|
|
TELEGRAPH = "telegraph"
|
|
WINDOW = "window"
|
|
RESOLVE = "resolve"
|
|
|
|
|
|
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
|
|
TELEGRAPH_DURATION = 0.3
|
|
|
|
|
|
@dataclass
|
|
class CombatEncounter:
|
|
"""Represents an active combat encounter between two entities."""
|
|
|
|
attacker: Entity
|
|
defender: Entity
|
|
state: CombatState = CombatState.IDLE
|
|
current_move: CombatMove | None = None
|
|
move_started_at: float = 0.0
|
|
pending_defense: CombatMove | None = None
|
|
|
|
def attack(self, move: CombatMove) -> None:
|
|
"""Initiate an attack move.
|
|
|
|
Args:
|
|
move: The attack move to execute
|
|
"""
|
|
self.current_move = move
|
|
self.state = CombatState.TELEGRAPH
|
|
self.move_started_at = time.monotonic()
|
|
|
|
# Apply stamina cost
|
|
self.attacker.stamina -= move.stamina_cost
|
|
|
|
def defend(self, move: CombatMove) -> None:
|
|
"""Queue a defense move.
|
|
|
|
Args:
|
|
move: The defense move to attempt
|
|
"""
|
|
self.pending_defense = move
|
|
|
|
# Apply stamina cost
|
|
self.defender.stamina -= move.stamina_cost
|
|
|
|
def tick(self, now: float) -> None:
|
|
"""Advance the state machine based on current time.
|
|
|
|
Args:
|
|
now: Current time from monotonic clock
|
|
"""
|
|
if self.state == CombatState.TELEGRAPH:
|
|
# Check if telegraph phase is over
|
|
elapsed = now - self.move_started_at
|
|
if elapsed >= TELEGRAPH_DURATION:
|
|
self.state = CombatState.WINDOW
|
|
|
|
elif self.state == CombatState.WINDOW:
|
|
# Check if timing window has expired
|
|
if self.current_move is None:
|
|
return
|
|
|
|
elapsed = now - self.move_started_at
|
|
window_seconds = self.current_move.timing_window_ms / 1000.0
|
|
total_time = TELEGRAPH_DURATION + window_seconds
|
|
|
|
if elapsed >= total_time:
|
|
self.state = CombatState.RESOLVE
|
|
|
|
def resolve(self) -> tuple[str, bool]:
|
|
"""Resolve the combat exchange and return result message.
|
|
|
|
Returns:
|
|
Tuple of (result message, combat_ended flag)
|
|
"""
|
|
if self.current_move is None:
|
|
return ("No active move to resolve.", False)
|
|
|
|
# Check if defense counters attack
|
|
defense_succeeds = (
|
|
self.pending_defense
|
|
and self.pending_defense.name in self.current_move.countered_by
|
|
)
|
|
if defense_succeeds:
|
|
# Successful counter - no damage
|
|
result = f"{self.defender.name} countered the attack!"
|
|
elif self.pending_defense:
|
|
# Wrong defense - normal damage
|
|
damage = self.attacker.pl * self.current_move.damage_pct
|
|
self.defender.pl -= damage
|
|
result = (
|
|
f"{self.attacker.name} hit {self.defender.name} "
|
|
f"for {damage:.1f} damage!"
|
|
)
|
|
else:
|
|
# No defense - increased damage
|
|
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
|
self.defender.pl -= damage
|
|
result = (
|
|
f"{self.defender.name} took the hit full force for {damage:.1f} damage!"
|
|
)
|
|
|
|
# Check for combat end conditions
|
|
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
|
|
|
|
# Reset to IDLE
|
|
self.state = CombatState.IDLE
|
|
self.current_move = None
|
|
self.pending_defense = None
|
|
|
|
return (result, combat_ended)
|