pathfinding and ai

🕹️ ebitengineer

Pathfinding & Enemy AI Reference (A* Grid & Steering Behaviors) #

This module covers A* (A-Star) grid pathfinding, Breadth-First Search (BFS), Steering Behaviors, and AI Decision State Machines for Ebitengine games.


1. A* (A-Star) Grid Pathfinding #

A* calculates the shortest path on a tile grid using heuristic distance (f = g + h):

go
type Node struct {
	X, Y   int
	G, H   float64 // G = cost from start, H = heuristic to goal
	F      float64 // F = G + H
	Parent *Node
}

func HeuristicManhattan(x1, y1, x2, y2 int) float64 {
	return math.Abs(float64(x1-x2)) + math.Abs(float64(y1-y2))
}

func FindPathAStar(grid [][]bool, startX, startY, goalX, goalY int) [][2]int {
	width := len(grid[0])
	height := len(grid)

	if startX < 0 || startX >= width || startY < 0 || startY >= height ||
		goalX < 0 || goalX >= width || goalY < 0 || goalY >= height {
		return nil
	}

	openSet := []*Node{{X: startX, Y: startY, G: 0, H: HeuristicManhattan(startX, startY, goalX, goalY)}}
	openSet[0].F = openSet[0].G + openSet[0].H

	closedSet := make(map[int]bool)

	dirs := [][2]int{{0, -1}, {0, 1}, {-1, 0}, {1, 0}} // 4-directional cardinal movement

	for len(openSet) > 0 {
		// Find node with lowest F score
		bestIdx := 0
		for i := 1; i < len(openSet); i++ {
			if openSet[i].F < openSet[bestIdx].F {
				bestIdx = i
			}
		}

		current := openSet[bestIdx]

		// Reached goal? Reconstruct path
		if current.X == goalX && current.Y == goalY {
			path := [][2]int{}
			curr := current
			for curr != nil {
				path = append([][2]int{{curr.X, curr.Y}}, path...)
				curr = curr.Parent
			}
			return path
		}

		// Remove current from openSet and add to closedSet
		openSet = append(openSet[:bestIdx], openSet[bestIdx+1:]...)
		key := current.Y*width + current.X
		closedSet[key] = true

		for _, d := range dirs {
			nx, ny := current.X+d[0], current.Y+d[1]

			if nx < 0 || nx >= width || ny < 0 || ny >= height || grid[ny][nx] {
				continue // Walkable check
			}

			nKey := ny*width + nx
			if closedSet[nKey] { continue }

			gScore := current.G + 1.0

			// Check if neighbor in openSet
			var neighbor *Node
			for _, node := range openSet {
				if node.X == nx && node.Y == ny {
					neighbor = node
					break
				}
			}

			if neighbor == nil {
				neighbor = &Node{
					X: nx, Y: ny,
					G: gScore,
					H: HeuristicManhattan(nx, ny, goalX, goalY),
					Parent: current,
				}
				neighbor.F = neighbor.G + neighbor.H
				openSet = append(openSet, neighbor)
			} else if gScore < neighbor.G {
				neighbor.G = gScore
				neighbor.F = neighbor.G + neighbor.H
				neighbor.Parent = current
			}
		}
	}

	return nil // No path found
}

2. Steering Behaviors (Seek, Arrive, Wander) #

Steering behaviors calculate smooth continuous acceleration vectors:

2.1 Seek & Arrive (Deceleration) #

go
func Seek(posX, posY, targetX, targetY, currentVX, currentVY, maxSpeed float64) (float64, float64) {
	dx := targetX - posX
	dy := targetY - posY
	dist := math.Sqrt(dx*dx + dy*dy)
	if dist == 0 { return 0, 0 }

	desiredVX := (dx / dist) * maxSpeed
	desiredVY := (dy / dist) * maxSpeed

	// Steering force = Desired Velocity - Current Velocity
	return desiredVX - currentVX, desiredVY - currentVY
}

func Arrive(posX, posY, targetX, targetY, currentVX, currentVY, maxSpeed, slowRadius float64) (float64, float64) {
	dx := targetX - posX
	dy := targetY - posY
	dist := math.Sqrt(dx*dx + dy*dy)
	if dist == 0 { return 0, 0 }

	targetSpeed := maxSpeed
	if dist < slowRadius {
		targetSpeed = maxSpeed * (dist / slowRadius) // Smooth deceleration
	}

	desiredVX := (dx / dist) * targetSpeed
	desiredVY := (dy / dist) * targetSpeed

	return desiredVX - currentVX, desiredVY - currentVY
}

3. Enemy AI Decision State Machine #

Model enemy states (Patrol, Chase, Attack, Flee) with perception range checks:

go
type AIState int

const (
	StatePatrol AIState = iota
	StateChase
	StateAttack
	StateFlee
)

type EnemyAI struct {
	State         AIState
	PatrolPoints  [][2]float64
	PatrolIndex   int
	DetectionDist float64
	AttackDist    float64
}

func (ai *EnemyAI) Update(enemyX, enemyY, playerX, playerY, hpPercent float64) AIState {
	dist := math.Hypot(playerX-enemyX, playerY-enemyY)

	// Flee if HP critically low (< 20%)
	if hpPercent < 0.20 {
		ai.State = StateFlee
		return ai.State
	}

	switch ai.State {
	case StatePatrol:
		if dist <= ai.DetectionDist {
			ai.State = StateChase
		}
	case StateChase:
		if dist <= ai.AttackDist {
			ai.State = StateAttack
		} else if dist > ai.DetectionDist*1.5 {
			ai.State = StatePatrol
		}
	case StateAttack:
		if dist > ai.AttackDist {
			ai.State = StateChase
		}
	case StateFlee:
		// Flee logic
	}

	return ai.State
}