Adds behavior state tracking to Mob entity with five states: idle, patrol, converse, flee, and working. Each state has specific processing logic: - idle: no-op (existing wander logic handles movement) - patrol: cycles through waypoints with toroidal wrapping support - converse: stationary during player-driven dialogue - flee: moves away from threat coordinates - working: stationary NPC at their post The behavior module is self-contained and testable, ready for integration with mob_ai.py in a later step.
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""NPC behavior state machine."""
|
|
|
|
from mudlib.entity import Mob
|
|
from mudlib.zone import Zone
|
|
|
|
# Valid behavior states
|
|
VALID_STATES = {"idle", "patrol", "converse", "flee", "working"}
|
|
|
|
|
|
def transition_state(mob: Mob, new_state: str, data: dict | None = None) -> bool:
|
|
"""Transition mob to a new behavior state.
|
|
|
|
Args:
|
|
mob: The mob to transition
|
|
new_state: The target state (must be in VALID_STATES)
|
|
data: Optional state-specific data
|
|
|
|
Returns:
|
|
True if transition succeeded, False if state is invalid
|
|
"""
|
|
if new_state not in VALID_STATES:
|
|
return False
|
|
|
|
mob.behavior_state = new_state
|
|
mob.behavior_data = data or {}
|
|
return True
|
|
|
|
|
|
async def process_behavior(mob: Mob, world: Zone) -> None:
|
|
"""Process mob behavior for current tick.
|
|
|
|
Called each game loop tick. Dispatches based on behavior_state:
|
|
- idle: do nothing (existing wander logic handles movement)
|
|
- patrol: move through waypoints
|
|
- converse: do nothing (conversation is player-driven)
|
|
- flee: do nothing (direction computed by get_flee_direction)
|
|
- working: do nothing (stationary NPC)
|
|
|
|
Args:
|
|
mob: The mob to process
|
|
world: The zone the mob is in
|
|
"""
|
|
if mob.behavior_state == "idle":
|
|
# No-op, existing wander logic handles idle movement
|
|
pass
|
|
elif mob.behavior_state == "patrol":
|
|
await _process_patrol(mob, world)
|
|
elif mob.behavior_state == "converse":
|
|
# No-op, mob stays put during conversation
|
|
pass
|
|
elif mob.behavior_state == "flee":
|
|
# No-op, mob_ai uses get_flee_direction for movement
|
|
pass
|
|
elif mob.behavior_state == "working":
|
|
# No-op, mob stays at their post
|
|
pass
|
|
|
|
|
|
async def _process_patrol(mob: Mob, world: Zone) -> None:
|
|
"""Process patrol behavior: advance waypoint if at current target."""
|
|
waypoints = mob.behavior_data.get("waypoints", [])
|
|
if not waypoints:
|
|
return
|
|
|
|
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
|
|
current_waypoint = waypoints[waypoint_index]
|
|
|
|
# Check if at waypoint (use world wrapping for distance)
|
|
if mob.x == current_waypoint["x"] and mob.y == current_waypoint["y"]:
|
|
# Advance to next waypoint (loop back to start if at end)
|
|
waypoint_index = (waypoint_index + 1) % len(waypoints)
|
|
mob.behavior_data["waypoint_index"] = waypoint_index
|
|
|
|
|
|
def get_flee_direction(mob: Mob, world: Zone) -> str | None:
|
|
"""Get the cardinal direction to flee away from threat.
|
|
|
|
Args:
|
|
mob: The mob fleeing
|
|
world: The zone the mob is in
|
|
|
|
Returns:
|
|
Cardinal direction ("north", "south", "east", "west") or None if no threat
|
|
"""
|
|
flee_from = mob.behavior_data.get("flee_from")
|
|
if not flee_from:
|
|
return None
|
|
|
|
threat_x = flee_from["x"]
|
|
threat_y = flee_from["y"]
|
|
|
|
# Calculate direction away from threat
|
|
dx = mob.x - threat_x
|
|
dy = mob.y - threat_y
|
|
|
|
# Handle toroidal wrapping for shortest distance
|
|
if world.toroidal:
|
|
# Adjust dx/dy if wrapping gives shorter distance
|
|
if abs(dx) > world.width // 2:
|
|
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
|
|
if abs(dy) > world.height // 2:
|
|
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
|
|
|
|
# Prefer axis with larger distance (prefer moving away faster)
|
|
if abs(dx) >= abs(dy):
|
|
return "east" if dx > 0 else "west" if dx < 0 else None
|
|
else:
|
|
return "south" if dy > 0 else "north" if dy < 0 else None
|
|
|
|
|
|
def get_patrol_direction(mob: Mob, world: Zone) -> str | None:
|
|
"""Get the cardinal direction to move toward current patrol waypoint.
|
|
|
|
Args:
|
|
mob: The mob on patrol
|
|
world: The zone the mob is in
|
|
|
|
Returns:
|
|
Cardinal direction ("north", "south", "east", "west") or None if at waypoint
|
|
"""
|
|
waypoints = mob.behavior_data.get("waypoints", [])
|
|
if not waypoints:
|
|
return None
|
|
|
|
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
|
|
current_waypoint = waypoints[waypoint_index]
|
|
|
|
target_x = current_waypoint["x"]
|
|
target_y = current_waypoint["y"]
|
|
|
|
# Check if already at waypoint
|
|
if mob.x == target_x and mob.y == target_y:
|
|
return None
|
|
|
|
# Calculate direction to waypoint
|
|
dx = target_x - mob.x
|
|
dy = target_y - mob.y
|
|
|
|
# Handle toroidal wrapping (find shortest path)
|
|
if world.toroidal:
|
|
# Check if wrapping gives a shorter distance on x axis
|
|
if abs(dx) > world.width // 2:
|
|
# Wrapping is shorter, reverse direction
|
|
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
|
|
|
|
# Check if wrapping gives a shorter distance on y axis
|
|
if abs(dy) > world.height // 2:
|
|
# Wrapping is shorter, reverse direction
|
|
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
|
|
|
|
# Prefer axis with larger distance (ties prefer x)
|
|
if abs(dx) >= abs(dy):
|
|
return "east" if dx > 0 else "west"
|
|
else:
|
|
return "south" if dy > 0 else "north"
|