Update AI and Pathfinding

John McCardle 2025-12-02 03:09:19 +00:00
parent 81436d6673
commit 9bcf59fe85
1 changed files with 465 additions and 465 deletions

@ -1,466 +1,466 @@
# AI and Pathfinding # AI and Pathfinding
Field of view (FOV), pathfinding, and AI systems for creating intelligent game entities. Field of view (FOV), pathfinding, and AI systems for creating intelligent game entities.
## Quick Reference ## Quick Reference
**Systems:** [[Grid-System]], [[Entity-Management]] **Systems:** [[Grid-System]], [[Entity-Management]]
**Key Features:** **Key Features:**
- A* pathfinding - A* pathfinding
- Dijkstra maps - Dijkstra maps
- Field of view (FOV) - Field of view (FOV)
- Per-entity perspective/knowledge - Per-entity perspective/knowledge
**TCOD Integration:** `src/UIGrid.cpp` libtcod bindings **TCOD Integration:** `src/UIGrid.cpp` libtcod bindings
--- ---
## Field of View (FOV) ## Field of View (FOV)
### Basic FOV ### Basic FOV
```python ```python
import mcrfpy import mcrfpy
# Setup grid with transparency # Setup grid with transparency
grid = mcrfpy.Grid(50, 50, 16, 16) grid = mcrfpy.Grid(50, 50, 16, 16)
# Set tile transparency (can entity see through this tile?) # Set tile transparency (can entity see through this tile?)
for x in range(50): for x in range(50):
for y in range(50): for y in range(50):
# Walls are opaque, floor is transparent # Walls are opaque, floor is transparent
is_wall = grid.at((x, y)).tilesprite == WALL_TILE is_wall = grid.at((x, y)).tilesprite == WALL_TILE
grid.transparent((x, y), not is_wall) grid.transparent((x, y), not is_wall)
# Compute FOV from entity position # Compute FOV from entity position
player = mcrfpy.Entity(25, 25, 0) player = mcrfpy.Entity(25, 25, 0)
grid.compute_fov(player.x, player.y, radius=10) grid.compute_fov(player.x, player.y, radius=10)
# FOV is now computed - use grid.perspective for rendering # FOV is now computed - use grid.perspective for rendering
``` ```
**Implementation:** `src/UIGrid.cpp::compute_fov()` - Uses libtcod's FOV algorithm **Implementation:** `src/UIGrid.cpp::compute_fov()` - Uses libtcod's FOV algorithm
### Per-Entity Perspective ### Per-Entity Perspective
Each entity can have its own knowledge of the map: Each entity can have its own knowledge of the map:
```python ```python
# Set which entity's perspective to render # Set which entity's perspective to render
grid.perspective = player grid.perspective = player
# This affects rendering: # This affects rendering:
# - Unexplored tiles: Black (never seen) # - Unexplored tiles: Black (never seen)
# - Explored tiles: Dark (seen before, not visible now) # - Explored tiles: Dark (seen before, not visible now)
# - Visible tiles: Normal (currently in FOV) # - Visible tiles: Normal (currently in FOV)
``` ```
**Three rendering states:** **Three rendering states:**
1. **Unexplored** - Never seen by this entity 1. **Unexplored** - Never seen by this entity
2. **Explored** - Seen before, not currently visible 2. **Explored** - Seen before, not currently visible
3. **Visible** - Currently in field of view 3. **Visible** - Currently in field of view
**Implementation:** **Implementation:**
- `src/UIGrid.cpp::perspective` property - `src/UIGrid.cpp::perspective` property
- `src/UIGridPointState.h` - Per-entity tile knowledge - `src/UIGridPointState.h` - Per-entity tile knowledge
### FOV Example: Fog of War ### FOV Example: Fog of War
```python ```python
import mcrfpy import mcrfpy
# Create game # Create game
mcrfpy.createScene("game") mcrfpy.createScene("game")
grid = mcrfpy.Grid(50, 50, 16, 16) grid = mcrfpy.Grid(50, 50, 16, 16)
grid.texture = mcrfpy.createTexture("tiles.png") grid.texture = mcrfpy.createTexture("tiles.png")
# Create player # Create player
player = mcrfpy.Entity(25, 25, 0) player = mcrfpy.Entity(25, 25, 0)
grid.entities.append(player) grid.entities.append(player)
# Set grid perspective to player # Set grid perspective to player
grid.perspective = player grid.perspective = player
# Update FOV when player moves # Update FOV when player moves
def on_player_move(dx, dy): def on_player_move(dx, dy):
new_x = player.x + dx new_x = player.x + dx
new_y = player.y + dy new_y = player.y + dy
if grid.walkable((new_x, new_y)): if grid.walkable((new_x, new_y)):
player.pos = (new_x, new_y) player.pos = (new_x, new_y)
# Recompute FOV from new position # Recompute FOV from new position
grid.compute_fov(player.x, player.y, radius=10) grid.compute_fov(player.x, player.y, radius=10)
# Input handling # Input handling
def on_keypress(key, pressed): def on_keypress(key, pressed):
if pressed: if pressed:
if key == mcrfpy.Key.Up: if key == mcrfpy.Key.Up:
on_player_move(0, -1) on_player_move(0, -1)
elif key == mcrfpy.Key.Down: elif key == mcrfpy.Key.Down:
on_player_move(0, 1) on_player_move(0, 1)
# ... etc # ... etc
``` ```
--- ---
## Pathfinding ## Pathfinding
### A* Pathfinding ### A* Pathfinding
Finds shortest path from entity to target: Finds shortest path from entity to target:
```python ```python
# Entity pathfinding # Entity pathfinding
player = mcrfpy.Entity(10, 10, 0) player = mcrfpy.Entity(10, 10, 0)
grid.entities.append(player) grid.entities.append(player)
# Find path to target position # Find path to target position
path = player.path_to(30, 25) path = player.path_to(30, 25)
# path is list of (x, y) tuples # path is list of (x, y) tuples
if path: if path:
print(f"Path length: {len(path)}") print(f"Path length: {len(path)}")
for x, y in path: for x, y in path:
print(f" Step: ({x}, {y})") print(f" Step: ({x}, {y})")
# Move along path # Move along path
next_step = path[0] next_step = path[0]
player.pos = next_step player.pos = next_step
``` ```
**Caching:** Paths are automatically cached in entity for performance **Caching:** Paths are automatically cached in entity for performance
**Implementation:** **Implementation:**
- `src/UIEntity.cpp::path_to()` - A* pathfinding - `src/UIEntity.cpp::path_to()` - A* pathfinding
- Uses libtcod's A* implementation - Uses libtcod's A* implementation
- Respects grid walkability - Respects grid walkability
### Dijkstra Maps ### Dijkstra Maps
Multi-target pathfinding for AI: Multi-target pathfinding for AI:
```python ```python
# Create Dijkstra map with multiple goal points # Create Dijkstra map with multiple goal points
goals = [(10, 10), (40, 40), (25, 5)] goals = [(10, 10), (40, 40), (25, 5)]
grid.compute_dijkstra(goals) grid.compute_dijkstra(goals)
# Get distance from any position to nearest goal # Get distance from any position to nearest goal
distance = grid.get_dijkstra_distance(entity.x, entity.y) distance = grid.get_dijkstra_distance(entity.x, entity.y)
# Get path from position to nearest goal # Get path from position to nearest goal
path = grid.get_dijkstra_path(entity.x, entity.y, max_length=20) path = grid.get_dijkstra_path(entity.x, entity.y, max_length=20)
``` ```
**Use cases:** **Use cases:**
- **Chase AI:** Dijkstra map with player as goal - **Chase AI:** Dijkstra map with player as goal
- **Flee AI:** Inverted Dijkstra map (high values = safe) - **Flee AI:** Inverted Dijkstra map (high values = safe)
- **Multi-target:** Pathfind to any of several objectives - **Multi-target:** Pathfind to any of several objectives
**Implementation:** `src/UIGrid.cpp` - Dijkstra map functions **Implementation:** `src/UIGrid.cpp` - Dijkstra map functions
### Pathfinding Example: Smart Enemy AI ### Pathfinding Example: Smart Enemy AI
```python ```python
import mcrfpy import mcrfpy
class SmartEnemy: class SmartEnemy:
def __init__(self, x, y): def __init__(self, x, y):
self.entity = mcrfpy.Entity(x, y, 1) self.entity = mcrfpy.Entity(x, y, 1)
self.state = "idle" self.state = "idle"
self.aggro_range = 15 self.aggro_range = 15
self.last_player_pos = None self.last_player_pos = None
def update(self, player, grid): def update(self, player, grid):
# Calculate distance to player # Calculate distance to player
dx = player.x - self.entity.x dx = player.x - self.entity.x
dy = player.y - self.entity.y dy = player.y - self.entity.y
distance = (dx*dx + dy*dy) ** 0.5 distance = (dx*dx + dy*dy) ** 0.5
if distance < self.aggro_range: if distance < self.aggro_range:
# Can we see the player? # Can we see the player?
if self.can_see(player, grid): if self.can_see(player, grid):
# Use pathfinding to chase # Use pathfinding to chase
self.chase(player) self.chase(player)
self.last_player_pos = (player.x, player.y) self.last_player_pos = (player.x, player.y)
elif self.last_player_pos: elif self.last_player_pos:
# Move to last known position # Move to last known position
self.investigate(self.last_player_pos) self.investigate(self.last_player_pos)
else: else:
# Lost player, go back to idle # Lost player, go back to idle
self.state = "idle" self.state = "idle"
else: else:
self.state = "idle" self.state = "idle"
def can_see(self, player, grid): def can_see(self, player, grid):
# Check if entity can see player using FOV # Check if entity can see player using FOV
grid.compute_fov(self.entity.x, self.entity.y, radius=self.aggro_range) grid.compute_fov(self.entity.x, self.entity.y, radius=self.aggro_range)
# In real implementation, check if player's tile is visible # In real implementation, check if player's tile is visible
# For now, simple line-of-sight check # For now, simple line-of-sight check
return True # Simplified return True # Simplified
def chase(self, player): def chase(self, player):
# Use A* pathfinding # Use A* pathfinding
path = self.entity.path_to(player.x, player.y) path = self.entity.path_to(player.x, player.y)
if path and len(path) > 0: if path and len(path) > 0:
next_step = path[0] next_step = path[0]
self.entity.pos = next_step self.entity.pos = next_step
def investigate(self, target_pos): def investigate(self, target_pos):
# Move to last known position # Move to last known position
if (self.entity.x, self.entity.y) == target_pos: if (self.entity.x, self.entity.y) == target_pos:
self.last_player_pos = None self.last_player_pos = None
self.state = "idle" self.state = "idle"
else: else:
path = self.entity.path_to(*target_pos) path = self.entity.path_to(*target_pos)
if path and len(path) > 0: if path and len(path) > 0:
next_step = path[0] next_step = path[0]
self.entity.pos = next_step self.entity.pos = next_step
``` ```
--- ---
## AI Patterns ## AI Patterns
### Pattern 1: Chase AI (Dijkstra) ### Pattern 1: Chase AI (Dijkstra)
Enemies chase player using Dijkstra map: Enemies chase player using Dijkstra map:
```python ```python
def update_enemies(grid, player, enemies): def update_enemies(grid, player, enemies):
# Compute Dijkstra map with player as goal # Compute Dijkstra map with player as goal
grid.compute_dijkstra([(player.x, player.y)]) grid.compute_dijkstra([(player.x, player.y)])
for enemy in enemies: for enemy in enemies:
# Get path toward player # Get path toward player
path = grid.get_dijkstra_path(enemy.x, enemy.y, max_length=1) path = grid.get_dijkstra_path(enemy.x, enemy.y, max_length=1)
if path and len(path) > 0: if path and len(path) > 0:
next_x, next_y = path[0] next_x, next_y = path[0]
if grid.walkable((next_x, next_y)): if grid.walkable((next_x, next_y)):
enemy.pos = (next_x, next_y) enemy.pos = (next_x, next_y)
``` ```
**Advantage:** Computes paths for all enemies at once - more efficient than individual A* **Advantage:** Computes paths for all enemies at once - more efficient than individual A*
### Pattern 2: Flee AI (Inverted Dijkstra) ### Pattern 2: Flee AI (Inverted Dijkstra)
Enemies flee from danger: Enemies flee from danger:
```python ```python
def flee_from_player(grid, player, scared_npcs): def flee_from_player(grid, player, scared_npcs):
# Compute danger map (player = maximum danger) # Compute danger map (player = maximum danger)
grid.compute_dijkstra([(player.x, player.y)]) grid.compute_dijkstra([(player.x, player.y)])
for npc in scared_npcs: for npc in scared_npcs:
# Find tile furthest from player # Find tile furthest from player
best_pos = None best_pos = None
best_distance = -1 best_distance = -1
for dx in [-1, 0, 1]: for dx in [-1, 0, 1]:
for dy in [-1, 0, 1]: for dy in [-1, 0, 1]:
if dx == 0 and dy == 0: if dx == 0 and dy == 0:
continue continue
check_x = npc.x + dx check_x = npc.x + dx
check_y = npc.y + dy check_y = npc.y + dy
if grid.walkable((check_x, check_y)): if grid.walkable((check_x, check_y)):
distance = grid.get_dijkstra_distance(check_x, check_y) distance = grid.get_dijkstra_distance(check_x, check_y)
if distance > best_distance: if distance > best_distance:
best_distance = distance best_distance = distance
best_pos = (check_x, check_y) best_pos = (check_x, check_y)
if best_pos: if best_pos:
npc.pos = best_pos npc.pos = best_pos
``` ```
### Pattern 3: Guard AI (Patrol Routes) ### Pattern 3: Guard AI (Patrol Routes)
Entities patrol between waypoints: Entities patrol between waypoints:
```python ```python
class Guard: class Guard:
def __init__(self, x, y, waypoints): def __init__(self, x, y, waypoints):
self.entity = mcrfpy.Entity(x, y, 2) self.entity = mcrfpy.Entity(x, y, 2)
self.waypoints = waypoints self.waypoints = waypoints
self.current_waypoint = 0 self.current_waypoint = 0
self.wait_time = 0 self.wait_time = 0
def update(self, grid): def update(self, grid):
if self.wait_time > 0: if self.wait_time > 0:
self.wait_time -= 1 self.wait_time -= 1
return return
# Get current target waypoint # Get current target waypoint
target = self.waypoints[self.current_waypoint] target = self.waypoints[self.current_waypoint]
# Are we there yet? # Are we there yet?
if (self.entity.x, self.entity.y) == target: if (self.entity.x, self.entity.y) == target:
# Wait at waypoint # Wait at waypoint
self.wait_time = 30 # Wait 30 ticks self.wait_time = 30 # Wait 30 ticks
# Move to next waypoint # Move to next waypoint
self.current_waypoint = (self.current_waypoint + 1) % len(self.waypoints) self.current_waypoint = (self.current_waypoint + 1) % len(self.waypoints)
else: else:
# Pathfind to waypoint # Pathfind to waypoint
path = self.entity.path_to(*target) path = self.entity.path_to(*target)
if path and len(path) > 0: if path and len(path) > 0:
next_step = path[0] next_step = path[0]
self.entity.pos = next_step self.entity.pos = next_step
# Create guard with patrol route # Create guard with patrol route
guard = Guard(10, 10, [(10, 10), (30, 10), (30, 30), (10, 30)]) guard = Guard(10, 10, [(10, 10), (30, 10), (30, 30), (10, 30)])
``` ```
### Pattern 4: State Machine AI ### Pattern 4: State Machine AI
Complex behaviors using states: Complex behaviors using states:
```python ```python
class ComplexAI: class ComplexAI:
def __init__(self, x, y): def __init__(self, x, y):
self.entity = mcrfpy.Entity(x, y, 3) self.entity = mcrfpy.Entity(x, y, 3)
self.state = "patrol" self.state = "patrol"
self.health = 100 self.health = 100
self.aggro_range = 10 self.aggro_range = 10
self.flee_threshold = 30 self.flee_threshold = 30
def update(self, player, grid): def update(self, player, grid):
if self.state == "patrol": if self.state == "patrol":
self.do_patrol(grid) self.do_patrol(grid)
# Check for player # Check for player
if self.distance_to(player) < self.aggro_range: if self.distance_to(player) < self.aggro_range:
self.state = "chase" self.state = "chase"
elif self.state == "chase": elif self.state == "chase":
self.do_chase(player) self.do_chase(player)
# Check health # Check health
if self.health < self.flee_threshold: if self.health < self.flee_threshold:
self.state = "flee" self.state = "flee"
elif self.state == "flee": elif self.state == "flee":
self.do_flee(player, grid) self.do_flee(player, grid)
# Check if safe # Check if safe
if self.distance_to(player) > self.aggro_range * 2: if self.distance_to(player) > self.aggro_range * 2:
self.state = "patrol" self.state = "patrol"
def distance_to(self, other_entity): def distance_to(self, other_entity):
dx = self.entity.x - other_entity.x dx = self.entity.x - other_entity.x
dy = self.entity.y - other_entity.y dy = self.entity.y - other_entity.y
return (dx*dx + dy*dy) ** 0.5 return (dx*dx + dy*dy) ** 0.5
def do_patrol(self, grid): def do_patrol(self, grid):
# Patrol logic # Patrol logic
pass pass
def do_chase(self, player): def do_chase(self, player):
# Chase with pathfinding # Chase with pathfinding
path = self.entity.path_to(player.x, player.y) path = self.entity.path_to(player.x, player.y)
if path and len(path) > 0: if path and len(path) > 0:
self.entity.pos = path[0] self.entity.pos = path[0]
def do_flee(self, player, grid): def do_flee(self, player, grid):
# Flee using inverted Dijkstra # Flee using inverted Dijkstra
# (Implementation similar to flee AI above) # (Implementation similar to flee AI above)
pass pass
``` ```
--- ---
## Performance Considerations ## Performance Considerations
### FOV Performance ### FOV Performance
**Cost:** O(cells in radius) **Cost:** O(cells in radius)
- 10 tile radius: ~314 cells to check - 10 tile radius: ~314 cells to check
- 20 tile radius: ~1256 cells to check - 20 tile radius: ~1256 cells to check
**Optimization:** Only recompute when needed **Optimization:** Only recompute when needed
```python ```python
# Don't recompute every frame! # Don't recompute every frame!
def on_player_move(): def on_player_move():
player.pos = new_pos player.pos = new_pos
grid.compute_fov(player.x, player.y, radius=10) # Only on move grid.compute_fov(player.x, player.y, radius=10) # Only on move
``` ```
### Pathfinding Performance ### Pathfinding Performance
**A* Cost:** O(n log n) where n = path length **A* Cost:** O(n log n) where n = path length
- Short paths (< 20 tiles): < 1ms - Short paths (< 20 tiles): < 1ms
- Long paths (> 100 tiles): Can be 5-10ms - Long paths (> 100 tiles): Can be 5-10ms
**Optimization:** Use path caching **Optimization:** Use path caching
```python ```python
class CachedPathfinder: class CachedPathfinder:
def __init__(self, entity): def __init__(self, entity):
self.entity = entity self.entity = entity
self.cached_path = None self.cached_path = None
self.cached_target = None self.cached_target = None
def path_to(self, target_x, target_y): def path_to(self, target_x, target_y):
# Check if we can reuse cached path # Check if we can reuse cached path
if self.cached_target == (target_x, target_y) and self.cached_path: if self.cached_target == (target_x, target_y) and self.cached_path:
# Remove first step if we've reached it # Remove first step if we've reached it
if self.cached_path and self.cached_path[0] == (self.entity.x, self.entity.y): if self.cached_path and self.cached_path[0] == (self.entity.x, self.entity.y):
self.cached_path = self.cached_path[1:] self.cached_path = self.cached_path[1:]
return self.cached_path return self.cached_path
# Compute new path # Compute new path
self.cached_path = self.entity.path_to(target_x, target_y) self.cached_path = self.entity.path_to(target_x, target_y)
self.cached_target = (target_x, target_y) self.cached_target = (target_x, target_y)
return self.cached_path return self.cached_path
``` ```
### Dijkstra Performance ### Dijkstra Performance
**Cost:** O(n) where n = number of cells **Cost:** O(n) where n = number of cells
- 50x50 grid: 2500 cells - 50x50 grid: 2500 cells
- 100x100 grid: 10000 cells - 100x100 grid: 10000 cells
**Best practice:** Compute once, query many times **Best practice:** Compute once, query many times
```python ```python
# Compute Dijkstra map once # Compute Dijkstra map once
grid.compute_dijkstra([(player.x, player.y)]) grid.compute_dijkstra([(player.x, player.y)])
# Use for all enemies (amortized O(1) per enemy) # Use for all enemies (amortized O(1) per enemy)
for enemy in enemies: for enemy in enemies:
path = grid.get_dijkstra_path(enemy.x, enemy.y, max_length=1) path = grid.get_dijkstra_path(enemy.x, enemy.y, max_length=1)
# Move enemy... # Move enemy...
``` ```
--- ---
## Integration with Other Systems ## Integration with Other Systems
### With Grid System ### With Grid System
See [[Grid-System]] for: See [[Grid-System]] for:
- Setting walkability: `grid.walkable((x, y), True)` - Setting walkability: `grid.walkable((x, y), True)`
- Setting transparency: `grid.transparent((x, y), True)` - Setting transparency: `grid.transparent((x, y), True)`
- Grid perspective rendering - Grid perspective rendering
### With Entity Management ### With Entity Management
See [[Entity-Management]] for: See [[Entity-Management]] for:
- Creating entities - Creating entities
- Moving entities along paths - Moving entities along paths
- Entity lifecycle - Entity lifecycle
### With Animation ### With Animation
See [[Animation-System]] for: See [[Animation-System]] for:
- Smoothly animating entity movement - Smoothly animating entity movement
- Visual feedback for AI state changes - Visual feedback for AI state changes
--- ---
## Related Documentation ## Related Documentation
- [[Grid-System]] - Grid fundamentals, TCOD integration - [[Grid-System]] - Grid fundamentals, TCOD integration
- [[Entity-Management]] - Entity creation and movement - [[Entity-Management]] - Entity creation and movement
- `src/UIGrid.cpp` - FOV and pathfinding implementation - `src/UIGrid.cpp` - FOV and pathfinding implementation
- Tutorial Part 6+ - AI and pathfinding examples - Tutorial Part 6+ - AI and pathfinding examples
**Open Issues:** **Open Issues:**
- [#64](../../issues/64) - Grid-Entity-GridPointState TCOD Updates - [#64](../../issues/64) - Grid-Entity-GridPointState TCOD Updates
- [#115](../../issues/115) - SpatialHash (improves AI spatial queries) - [#115](../../issues/115) - SpatialHash (improves AI spatial queries)