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