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