1 AI-and-Pathfinding
John McCardle edited this page 2025-10-25 22:40:17 +00:00

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

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:

# 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

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:

# 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:

# 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

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:

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:

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:

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:

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

# 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

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

# 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

  • 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 - Grid-Entity-GridPointState TCOD Updates
  • #115 - SpatialHash (improves AI spatial queries)