IA de enemigos — pathfinding y comportamiento de NPCs

By Carlos Montiel | Especialista en IA Empresarial
Publicado: 2026-07-28 | Por: Carlos Montiel | Lectura: ~4 minutos

Un enemigo que solo camina en línea recta se nota falso de inmediato. Hoy le damos a tus NPCs la capacidad de perseguir al jugador de forma creíble.

Dos niveles de "IA" en un enemigo

En la lección anterior organizamos el flujo del juego con una máquina de estados. Ahora vamos a usar exactamente esa misma idea, pero aplicada a un enemigo individual: qué está "haciendo" en cada momento (patrullar, perseguir, atacar), y por separado, cómo calcula la ruta para llegar hasta el jugador (pathfinding). Vamos a separar estos dos problemas porque mezclarlos es la fuente número uno de enemigos que se comportan de forma errática.

Comportamiento con una máquina de estados simple

enum class EstadoEnemigo { PATRULLANDO, PERSIGUIENDO, ATACANDO }; struct Enemigo { float x, y; EstadoEnemigo estado = EstadoEnemigo::PATRULLANDO; float rango_deteccion = 150.0f; float rango_ataque = 30.0f; }; float distancia(float x1, float y1, float x2, float y2) { float dx = x2 - x1; float dy = y2 - y1; return std::sqrt(dx * dx + dy * dy); } void actualizar_estado_enemigo(Enemigo& e, float jugador_x, float jugador_y) { float dist = distancia(e.x, e.y, jugador_x, jugador_y); if (dist <= e.rango_ataque) { e.estado = EstadoEnemigo::ATACANDO; } else if (dist <= e.rango_deteccion) { e.estado = EstadoEnemigo::PERSIGUIENDO; } else { e.estado = EstadoEnemigo::PATRULLANDO; } }

Aquí sí usamos `sqrt()` (a diferencia de la detección de colisiones en la lección 5) porque necesitamos la distancia real para compararla contra dos umbrales distintos, no solo saber cuál de dos distancias es mayor.

Patrullaje simple entre dos puntos

struct Enemigo { float x, y; float punto_a_x, punto_a_y; float punto_b_x, punto_b_y; bool yendo_hacia_b = true; float velocidad = 80.0f; EstadoEnemigo estado = EstadoEnemigo::PATRULLANDO; }; void patrullar(Enemigo& e, float delta) { float destino_x = e.yendo_hacia_b ? e.punto_b_x : e.punto_a_x; float destino_y = e.yendo_hacia_b ? e.punto_b_y : e.punto_a_y; float dx = destino_x - e.x; float dy = destino_y - e.y; float dist = std::sqrt(dx * dx + dy * dy); if (dist < 5.0f) { e.yendo_hacia_b = !e.yendo_hacia_b; // llegó, invierte dirección return; } e.x += (dx / dist) * e.velocidad * delta; e.y += (dy / dist) * e.velocidad * delta; }

Persecución directa vs. pathfinding

Perseguir al jugador en línea recta (igual que la patrulla, pero apuntando hacia el jugador) funciona en espacios abiertos, pero falla en cuanto hay paredes u obstáculos: el enemigo se queda "pegado" contra una pared en vez de rodearla. Para eso necesitamos pathfinding real, y el algoritmo estándar de la industria para esto es A* (A-estrella).

El algoritmo A* sobre una cuadrícula

A* encuentra el camino más corto entre dos puntos en un mapa dividido en celdas, priorizando explorar las celdas más prometedoras primero (las que están más cerca del destino en línea recta) en vez de explorar a ciegas como haría una búsqueda por amplitud (BFS).

#include <vector> #include <queue> #include <unordered_map> #include <cmath> struct Celda { int x, y; }; bool operator==(const Celda& a, const Celda& b) { return a.x == b.x && a.y == b.y; } struct HashCelda { size_t operator()(const Celda& c) const { return std::hash<int>()(c.x) ^ (std::hash<int>()(c.y) << 1); } }; float heuristica(const Celda& a, const Celda& b) { return std::abs(a.x - b.x) + std::abs(a.y - b.y); // distancia Manhattan }
struct NodoAEstrella { Celda celda; float costo_f; // costo_g (recorrido) + heuristica (estimado al destino) }; struct ComparaNodos { bool operator()(const NodoAEstrella& a, const NodoAEstrella& b) { return a.costo_f > b.costo_f; // min-heap: menor costo_f tiene prioridad } }; std::vector<Celda> obtener_vecinos(const Celda& c, const std::vector<std::vector<bool>>& mapa_bloqueado) { std::vector<Celda> vecinos; const int dx[] = {0, 0, 1, -1}; const int dy[] = {1, -1, 0, 0}; for (int i = 0; i < 4; ++i) { int nx = c.x + dx[i]; int ny = c.y + dy[i]; if (ny >= 0 && ny < (int)mapa_bloqueado.size() && nx >= 0 && nx < (int)mapa_bloqueado[0].size() && !mapa_bloqueado[ny][nx]) { vecinos.push_back({nx, ny}); } } return vecinos; }
std::vector<Celda> buscar_camino_a_estrella( const Celda& inicio, const Celda& destino, const std::vector<std::vector<bool>>& mapa_bloqueado) { std::priority_queue<NodoAEstrella, std::vector<NodoAEstrella>, ComparaNodos> abiertos; std::unordered_map<Celda, Celda, HashCelda> vino_de; std::unordered_map<Celda, float, HashCelda> costo_g; abiertos.push({inicio, heuristica(inicio, destino)}); costo_g[inicio] = 0; while (!abiertos.empty()) { Celda actual = abiertos.top().celda; abiertos.pop(); if (actual == destino) { std::vector<Celda> camino; while (!(actual == inicio)) { camino.push_back(actual); actual = vino_de[actual]; } std::reverse(camino.begin(), camino.end()); return camino; } for (const Celda& vecino : obtener_vecinos(actual, mapa_bloqueado)) { float nuevo_costo = costo_g[actual] + 1.0f; if (costo_g.find(vecino) == costo_g.end() || nuevo_costo < costo_g[vecino]) { costo_g[vecino] = nuevo_costo; float prioridad = nuevo_costo + heuristica(vecino, destino); abiertos.push({vecino, prioridad}); vino_de[vecino] = actual; } } } return {}; // no se encontró camino }

Usando el camino calculado para mover al enemigo

Una vez que A* devuelve la lista de celdas, el enemigo simplemente se mueve hacia la siguiente celda del camino en cada fotograma, recalculando el camino completo solo cada cierto intervalo (no en cada fotograma, que sería muy costoso).

struct EnemigoConPathfinding { float x, y; std::vector<Celda> camino_actual; size_t indice_camino = 0; float tiempo_desde_recalculo = 0.0f; const float INTERVALO_RECALCULO = 0.5f; // recalcula 2 veces por segundo }; void actualizar_persecucion(EnemigoConPathfinding& e, Celda destino, const std::vector<std::vector<bool>>& mapa, float delta) { e.tiempo_desde_recalculo += delta; if (e.tiempo_desde_recalculo >= e.INTERVALO_RECALCULO) { Celda inicio { (int)e.x, (int)e.y }; e.camino_actual = buscar_camino_a_estrella(inicio, destino, mapa); e.indice_camino = 0; e.tiempo_desde_recalculo = 0.0f; } }

Un consejo clave para esta lección

A* es uno de los algoritmos donde más vale la pena usar tu asistente de IA para verificar tu implementación, porque los bugs aquí son sutiles: una heurística mal calculada no rompe la compilación, simplemente hace que el camino encontrado sea subóptimo o que el algoritmo explore muchas más celdas de las necesarias. Pídele a tu asistente de IA que trace a mano, celda por celda, un mapa pequeño de 5x5 con un par de obstáculos, y compare el camino que predice contra el que tu código realmente calcula — es la forma más rápida de encontrar un error de lógica en la heurística o en el manejo de `costo_g`.

En la próxima lección dejamos atrás la IA de enemigos "local" para dar el salto a algo distinto: cómo hacer que dos programas se comuniquen entre sí por red, el primer paso hacia el multijugador.

Carlos Montiel
Arquitecto de Soluciones IA Empresarial
Especialista en LLMs, Agentes y Orquestación
guatemalia.com/#contacto · info@guatemalia.com

¿Necesitas implementar IA en tu empresa?

Carlos Montiel es arquitecto de soluciones IA empresarial. Implementa LLMs, Agentes, RAG y orquestadores en empresas de Guatemala y Latinoamérica. Contáctalo para una consultoría.

Contactar a Carlos Montiel

info@guatemalia.com