Update AI and Pathfinding

John McCardle 2026-02-07 23:47:47 +00:00
commit 3be2fec45c

@ -1,386 +1,386 @@
# 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 (`grid.find_path()` returns `AStarPath` object) - A* pathfinding (`grid.find_path()` returns `AStarPath` object)
- Dijkstra maps (`grid.get_dijkstra_map()` returns `DijkstraMap` object) - Dijkstra maps (`grid.get_dijkstra_map()` returns `DijkstraMap` object)
- Field of view (`grid.compute_fov()` / `grid.is_in_fov()`) - Field of view (`grid.compute_fov()` / `grid.is_in_fov()`)
- Per-entity perspective rendering - Per-entity perspective rendering
**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(grid_size=(50, 50), pos=(0, 0), size=(400, 400)) grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400))
# Set tile properties (walkable + transparent) # Set tile properties (walkable + transparent)
for x in range(50): for x in range(50):
for y in range(50): for y in range(50):
grid.at(x, y).transparent = True grid.at(x, y).transparent = True
grid.at(x, y).walkable = True grid.at(x, y).walkable = True
# Add walls (opaque and unwalkable) # Add walls (opaque and unwalkable)
grid.at(10, 10).transparent = False grid.at(10, 10).transparent = False
grid.at(10, 10).walkable = False grid.at(10, 10).walkable = False
# Compute FOV from position with radius # Compute FOV from position with radius
grid.compute_fov((25, 25), radius=10) grid.compute_fov((25, 25), radius=10)
# Query visibility # Query visibility
if grid.is_in_fov((25, 25)): if grid.is_in_fov((25, 25)):
print("Origin is visible") print("Origin is visible")
if not grid.is_in_fov((0, 0)): if not grid.is_in_fov((0, 0)):
print("Far corner not visible") print("Far corner not visible")
``` ```
### Per-Entity Perspective ### Per-Entity Perspective
Each entity can have its own view of the map: Each entity can have its own view of the map:
```python ```python
player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0) player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0)
grid.entities.append(player) grid.entities.append(player)
# 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)
# Clear perspective (show everything) # Clear perspective (show everything)
grid.perspective = None grid.perspective = None
``` ```
### FOV + Fog of War Pattern ### FOV + Fog of War Pattern
Use a ColorLayer to implement fog of war: Use a ColorLayer to implement fog of war:
```python ```python
# Create grid with fog layer # Create grid with fog layer
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400), layers=[]) grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400), layers=[])
fog = mcrfpy.ColorLayer(name="fog", z_index=1) fog = mcrfpy.ColorLayer(name="fog", z_index=1)
grid.add_layer(fog) grid.add_layer(fog)
# Start fully fogged # Start fully fogged
fog.fill(mcrfpy.Color(0, 0, 0, 255)) fog.fill(mcrfpy.Color(0, 0, 0, 255))
# Setup transparency # Setup transparency
for x in range(50): for x in range(50):
for y in range(50): for y in range(50):
grid.at(x, y).transparent = True grid.at(x, y).transparent = True
grid.at(x, y).walkable = True grid.at(x, y).walkable = True
player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0) player = mcrfpy.Entity(grid_pos=(25, 25), sprite_index=0)
grid.entities.append(player) grid.entities.append(player)
def update_fov(): def update_fov():
"""Recompute FOV and update fog layer.""" """Recompute FOV and update fog layer."""
px, py = int(player.grid_x), int(player.grid_y) px, py = int(player.grid_x), int(player.grid_y)
grid.compute_fov((px, py), radius=10) grid.compute_fov((px, py), radius=10)
for x in range(50): for x in range(50):
for y in range(50): for y in range(50):
if grid.is_in_fov((x, y)): if grid.is_in_fov((x, y)):
fog.set((x, y), mcrfpy.Color(0, 0, 0, 0)) # Visible fog.set((x, y), mcrfpy.Color(0, 0, 0, 0)) # Visible
# Explored but not visible: keep at partial opacity # Explored but not visible: keep at partial opacity
# (track explored state separately if needed) # (track explored state separately if needed)
# Initial FOV # Initial FOV
update_fov() update_fov()
# Update on movement # Update on movement
def on_player_move(dx, dy): def on_player_move(dx, dy):
new_x = int(player.grid_x + dx) new_x = int(player.grid_x + dx)
new_y = int(player.grid_y + dy) new_y = int(player.grid_y + dy)
point = grid.at(new_x, new_y) point = grid.at(new_x, new_y)
if point and point.walkable: if point and point.walkable:
player.grid_x = new_x player.grid_x = new_x
player.grid_y = new_y player.grid_y = new_y
update_fov() update_fov()
``` ```
### Movement + FOV Input Handling ### Movement + FOV Input Handling
```python ```python
scene = mcrfpy.Scene("game") scene = mcrfpy.Scene("game")
scene.children.append(grid) scene.children.append(grid)
mcrfpy.current_scene = scene mcrfpy.current_scene = scene
move_map = { move_map = {
mcrfpy.Key.W: (0, -1), mcrfpy.Key.W: (0, -1),
mcrfpy.Key.A: (-1, 0), mcrfpy.Key.A: (-1, 0),
mcrfpy.Key.S: (0, 1), mcrfpy.Key.S: (0, 1),
mcrfpy.Key.D: (1, 0), mcrfpy.Key.D: (1, 0),
} }
def handle_key(key, action): def handle_key(key, action):
if action != mcrfpy.InputState.PRESSED: if action != mcrfpy.InputState.PRESSED:
return return
if key in move_map: if key in move_map:
dx, dy = move_map[key] dx, dy = move_map[key]
on_player_move(dx, dy) on_player_move(dx, dy)
scene.on_key = handle_key scene.on_key = handle_key
``` ```
--- ---
## Pathfinding ## Pathfinding
### A* Pathfinding ### A* Pathfinding
`grid.find_path()` returns an `AStarPath` object: `grid.find_path()` returns an `AStarPath` object:
```python ```python
grid = mcrfpy.Grid(grid_size=(30, 30), pos=(0, 0), size=(400, 400)) grid = mcrfpy.Grid(grid_size=(30, 30), pos=(0, 0), size=(400, 400))
for x in range(30): for x in range(30):
for y in range(30): for y in range(30):
grid.at(x, y).walkable = True grid.at(x, y).walkable = True
# Find path between two points # Find path between two points
path = grid.find_path((10, 10), (20, 20)) path = grid.find_path((10, 10), (20, 20))
if path and len(path) > 0: if path and len(path) > 0:
print(f"Path has {path.remaining} steps") print(f"Path has {path.remaining} steps")
print(f"From: ({path.origin.x}, {path.origin.y})") print(f"From: ({path.origin.x}, {path.origin.y})")
print(f"To: ({path.destination.x}, {path.destination.y})") print(f"To: ({path.destination.x}, {path.destination.y})")
# Walk the path step by step # Walk the path step by step
next_step = path.walk() # Consume and return next step next_step = path.walk() # Consume and return next step
print(f"Next: ({next_step.x}, {next_step.y})") print(f"Next: ({next_step.x}, {next_step.y})")
# Peek without consuming # Peek without consuming
upcoming = path.peek() # Look at next step without consuming upcoming = path.peek() # Look at next step without consuming
``` ```
**AStarPath API:** **AStarPath API:**
| Method/Property | Description | | Method/Property | Description |
|----------------|-------------| |----------------|-------------|
| `path.walk()` | Consume and return next step (Vector) | | `path.walk()` | Consume and return next step (Vector) |
| `path.peek()` | Look at next step without consuming | | `path.peek()` | Look at next step without consuming |
| `path.remaining` | Number of steps left | | `path.remaining` | Number of steps left |
| `len(path)` | Same as `remaining` | | `len(path)` | Same as `remaining` |
| `path.origin` | Start position (Vector) | | `path.origin` | Start position (Vector) |
| `path.destination` | End position (Vector) | | `path.destination` | End position (Vector) |
### Dijkstra Maps ### Dijkstra Maps
Multi-source distance maps for efficient AI: Multi-source distance maps for efficient AI:
```python ```python
# Create Dijkstra map from a single source # Create Dijkstra map from a single source
dm = grid.get_dijkstra_map((15, 15)) dm = grid.get_dijkstra_map((15, 15))
# Query distance from any cell to source # Query distance from any cell to source
d = dm.distance((0, 0)) d = dm.distance((0, 0))
print(f"Distance from (0,0) to (15,15): {d}") print(f"Distance from (0,0) to (15,15): {d}")
# Get full path from a position to the source # Get full path from a position to the source
path_points = dm.path_from((0, 0)) path_points = dm.path_from((0, 0))
print(f"Path length: {len(path_points)}") print(f"Path length: {len(path_points)}")
# Get single next step toward source # Get single next step toward source
next_step = dm.step_from((0, 0)) next_step = dm.step_from((0, 0))
print(f"Next step: ({next_step.x}, {next_step.y})") print(f"Next step: ({next_step.x}, {next_step.y})")
``` ```
**DijkstraMap API:** **DijkstraMap API:**
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| `dm.distance((x, y))` | Distance from cell to source | | `dm.distance((x, y))` | Distance from cell to source |
| `dm.path_from((x, y))` | Full path from cell to source | | `dm.path_from((x, y))` | Full path from cell to source |
| `dm.step_from((x, y))` | Single step toward source | | `dm.step_from((x, y))` | Single step toward source |
| `dm.to_heightmap()` | Convert to HeightMap for visualization | | `dm.to_heightmap()` | Convert to HeightMap for visualization |
--- ---
## AI Patterns ## AI Patterns
### Pattern 1: Chase AI (Dijkstra) ### Pattern 1: Chase AI (Dijkstra)
Enemies chase player using a shared Dijkstra map: Enemies chase player using a shared Dijkstra map:
```python ```python
def update_enemies(grid, player, enemies): def update_enemies(grid, player, enemies):
# Compute Dijkstra map with player as source # Compute Dijkstra map with player as source
dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y))) dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))
for enemy in enemies: for enemy in enemies:
ex, ey = int(enemy.grid_x), int(enemy.grid_y) ex, ey = int(enemy.grid_x), int(enemy.grid_y)
next_step = dm.step_from((ex, ey)) next_step = dm.step_from((ex, ey))
if next_step: if next_step:
nx, ny = int(next_step.x), int(next_step.y) nx, ny = int(next_step.x), int(next_step.y)
if grid.at(nx, ny).walkable: if grid.at(nx, ny).walkable:
enemy.grid_x = nx enemy.grid_x = nx
enemy.grid_y = ny enemy.grid_y = ny
``` ```
**Advantage:** One Dijkstra map serves all enemies (O(n) compute, O(1) per enemy query). **Advantage:** One Dijkstra map serves all enemies (O(n) compute, O(1) per enemy query).
### Pattern 2: Flee AI (Inverted Dijkstra) ### Pattern 2: Flee AI (Inverted Dijkstra)
Enemies flee from danger by moving to higher-distance cells: Enemies flee from danger by moving to higher-distance cells:
```python ```python
def flee_from_player(grid, player, scared_npcs): def flee_from_player(grid, player, scared_npcs):
dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y))) dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))
for npc in scared_npcs: for npc in scared_npcs:
nx, ny = int(npc.grid_x), int(npc.grid_y) nx, ny = int(npc.grid_x), int(npc.grid_y)
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
cx, cy = nx + dx, ny + dy cx, cy = nx + dx, ny + dy
if grid.at(cx, cy).walkable: if grid.at(cx, cy).walkable:
d = dm.distance((cx, cy)) d = dm.distance((cx, cy))
if d > best_distance: if d > best_distance:
best_distance = d best_distance = d
best_pos = (cx, cy) best_pos = (cx, cy)
if best_pos: if best_pos:
npc.grid_x = best_pos[0] npc.grid_x = best_pos[0]
npc.grid_y = best_pos[1] npc.grid_y = best_pos[1]
``` ```
### Pattern 3: Aggro Range with Spatial Queries ### Pattern 3: Aggro Range with Spatial Queries
Use `entities_in_radius()` for aggro detection: Use `entities_in_radius()` for aggro detection:
```python ```python
class Enemy: class Enemy:
def __init__(self, grid, pos, aggro_range=10): def __init__(self, grid, pos, aggro_range=10):
self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=1, name="enemy") self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=1, name="enemy")
self.aggro_range = aggro_range self.aggro_range = aggro_range
grid.entities.append(self.entity) grid.entities.append(self.entity)
def update(self, grid): def update(self, grid):
ex, ey = int(self.entity.grid_x), int(self.entity.grid_y) ex, ey = int(self.entity.grid_x), int(self.entity.grid_y)
nearby = grid.entities_in_radius((ex, ey), self.aggro_range) nearby = grid.entities_in_radius((ex, ey), self.aggro_range)
# Filter for player entities # Filter for player entities
for entity in nearby: for entity in nearby:
if entity.name == "player": if entity.name == "player":
self.chase(grid, entity) self.chase(grid, entity)
return return
def chase(self, grid, target): def chase(self, grid, target):
path = grid.find_path( path = grid.find_path(
(int(self.entity.grid_x), int(self.entity.grid_y)), (int(self.entity.grid_x), int(self.entity.grid_y)),
(int(target.grid_x), int(target.grid_y)) (int(target.grid_x), int(target.grid_y))
) )
if path and len(path) > 0: if path and len(path) > 0:
step = path.walk() step = path.walk()
self.entity.grid_x = int(step.x) self.entity.grid_x = int(step.x)
self.entity.grid_y = int(step.y) self.entity.grid_y = int(step.y)
``` ```
### Pattern 4: State Machine AI ### Pattern 4: State Machine AI
Complex behaviors using states: Complex behaviors using states:
```python ```python
class StateMachineAI: class StateMachineAI:
def __init__(self, grid, pos): def __init__(self, grid, pos):
self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=3, name="ai") self.entity = mcrfpy.Entity(grid_pos=pos, sprite_index=3, name="ai")
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
grid.entities.append(self.entity) grid.entities.append(self.entity)
def update(self, grid, player): def update(self, grid, player):
if self.state == "patrol": if self.state == "patrol":
# Check for player in range # Check for player in range
nearby = grid.entities_in_radius( nearby = grid.entities_in_radius(
(int(self.entity.grid_x), int(self.entity.grid_y)), (int(self.entity.grid_x), int(self.entity.grid_y)),
self.aggro_range self.aggro_range
) )
for e in nearby: for e in nearby:
if e.name == "player": if e.name == "player":
self.state = "chase" self.state = "chase"
return return
elif self.state == "chase": elif self.state == "chase":
if self.health < self.flee_threshold: if self.health < self.flee_threshold:
self.state = "flee" self.state = "flee"
return return
# Chase with A* # Chase with A*
path = grid.find_path( path = grid.find_path(
(int(self.entity.grid_x), int(self.entity.grid_y)), (int(self.entity.grid_x), int(self.entity.grid_y)),
(int(player.grid_x), int(player.grid_y)) (int(player.grid_x), int(player.grid_y))
) )
if path and len(path) > 0: if path and len(path) > 0:
step = path.walk() step = path.walk()
self.entity.grid_x = int(step.x) self.entity.grid_x = int(step.x)
self.entity.grid_y = int(step.y) self.entity.grid_y = int(step.y)
elif self.state == "flee": elif self.state == "flee":
dm = grid.get_dijkstra_map( dm = grid.get_dijkstra_map(
(int(player.grid_x), int(player.grid_y)) (int(player.grid_x), int(player.grid_y))
) )
# Move away from player (maximize distance) # Move away from player (maximize distance)
# ... (see Flee AI pattern above) # ... (see Flee AI pattern above)
``` ```
--- ---
## Performance Considerations ## Performance Considerations
### FOV: Only Recompute on Move ### FOV: Only Recompute on Move
```python ```python
# Don't recompute every frame # Don't recompute every frame
def on_player_move(dx, dy): def on_player_move(dx, dy):
# ... move player ... # ... move player ...
grid.compute_fov((new_x, new_y), radius=10) # Only on move grid.compute_fov((new_x, new_y), radius=10) # Only on move
``` ```
### Pathfinding: Cache Dijkstra Maps ### Pathfinding: Cache Dijkstra Maps
```python ```python
# Compute once per turn, query many times # Compute once per turn, query many times
dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y))) dm = grid.get_dijkstra_map((int(player.grid_x), int(player.grid_y)))
for enemy in enemies: for enemy in enemies:
step = dm.step_from((int(enemy.grid_x), int(enemy.grid_y))) step = dm.step_from((int(enemy.grid_x), int(enemy.grid_y)))
# O(1) per enemy vs O(n log n) for individual A* # O(1) per enemy vs O(n log n) for individual A*
``` ```
### A* vs Dijkstra Trade-offs ### A* vs Dijkstra Trade-offs
| Method | Best For | Cost | | Method | Best For | Cost |
|--------|----------|------| |--------|----------|------|
| `find_path()` (A*) | Single entity, single target | O(n log n) per call | | `find_path()` (A*) | Single entity, single target | O(n log n) per call |
| `get_dijkstra_map()` | Many entities, one target | O(n) compute, O(1) per query | | `get_dijkstra_map()` | Many entities, one target | O(n) compute, O(1) per query |
--- ---
## Related Documentation ## Related Documentation
- [[Grid-System]] - Grid fundamentals, cell properties - [[Grid-System]] - Grid fundamentals, cell properties
- [[Entity-Management]] - Entity creation and movement - [[Entity-Management]] - Entity creation and movement
- [[Animation-System]] - Smooth entity movement animations - [[Animation-System]] - Smooth entity movement animations
- [[Input-and-Events]] - Keyboard handler for movement - [[Input-and-Events]] - Keyboard handler for movement
--- ---
*Last updated: 2026-02-07* *Last updated: 2026-02-07*