Percepción Intermedio 32 min de lectura

Influence Maps avanzados: capas, propagación y predicción

Mapas de influencia multicapa para que los NPCs entiendan el territorio: dónde es peligroso, dónde hay oportunidad, dónde estará el enemigo en 3 segundos.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Tu enemigo te ve y te dispara. Tu enemigo te pierde y va a tu última posición conocida. Tu enemigo es un poco más listo y patrulla un cono. Tu enemigo, aun así, no entiende el territorio: ataca desde donde está, no desde donde le conviene. Cruza por el centro de la sala cuando hay cobertura a tres metros. Avanza solo cuando tiene dos aliados muertos al lado.

El parche clásico para esto se llama influence map. Lo usaron Total War, Supreme Commander y Civilization para razonar sobre territorios completos; Into the Breach lo usa para que veas, frame a frame, qué casilla va a ser peligrosa el turno siguiente. Es la misma estructura: una grid 2D donde cada celda guarda un número.

¿Qué es un influence map?

Un influence map es una grid 2D donde cada celda guarda un valor que representa la “fuerza” de algo (peligro, control, atracción) en ese punto del mundo. Cuando combinas varias capas, el NPC puede razonar tácticamente sobre el espacio.

Sin influence map, un NPC solo sabe dónde está él y dónde está su objetivo. Con influence map, sabe cómo se reparte el mundo entero según las dimensiones que te interesen.

Lo que cubre este tutorial

  • Cómo una unidad propaga su influencia a las celdas vecinas.
  • Cómo combinar varias capas (peligro, control, oportunidad) con pesos por rol.
  • Cómo proyectar el futuro: dónde estará el enemigo en 3 segundos.
  • Cómo enchufar el mapa a tu A* para que el NPC prefiera caminos seguros, no cortos.

No cubre: pathfinding base (mira A* y flow fields) ni decisión táctica de alto nivel (eso es GOAP o utility AI leyendo el mapa).

1. Demo

Influence map multicapa Mueve unidades aliadas y enemigas. Activa capas (danger, control, opportunity) y ajusta pesos. La combinada actualiza en vivo.

2. ¿Cómo funcionan los influence maps?

2.1 La capa básica: una unidad propagando influencia

Empieza con lo más simple: una sola unidad, una sola celda con valor 1.0. Eso ya es un influence map degenerado, pero el valor cae a cero a un metro de distancia y no sirve de nada — el NPC necesita saber que la casilla vecina a un enemigo también es peligrosa.

La unidad propaga su influencia hacia las celdas vecinas con un falloff (caída con la distancia). Los dos modelos típicos son:

  • Falloff lineal: valor = source * (1 - dist / radio). Simple, predecible.
  • Falloff exponencial: valor = source * exp(-k * dist). La influencia nunca llega a cero del todo pero decae rápido.

Visualmente, una sola fuente con radio 4 y falloff lineal parece un degradado radial:

Capa de danger con 1 enemigo en (2,2)
11123432134543123432111enemigo en (2,2) → valor fuente 5, decae con la distancia

Radio 4, falloff lineal. La opacidad de cada celda es proporcional a su valor; el número está dibujado encima. Valores redondeados; en el grid real son floats.

2.2 ¿Cómo se propaga la influencia exactamente?

Hay tres formas comunes de propagar, en orden de costo creciente:

  • Stamping: para cada fuente, recorres todas las celdas dentro del radio y aplicas el falloff con distancia euclidiana real. O(fuentes × celdas_en_radio). Bien si hay pocas fuentes y radios moderados.
  • BFS desde la fuente: empiezas en la celda de la fuente y multiplicas por un decay < 1 en cada vecino. Distancia en pasos de grid. La influencia no atraviesa muros — exactamente lo que quieres para la peligrosidad de un enemigo encerrado en otra sala.
  • Gaussian blur: stampeas un valor puntual y aplicas un blur 2D al grid entero. Elegante y rápido en GPU, pero atraviesa paredes y suaviza demasiado las puntas.

2.3 Multi-capa: el mundo no es solo “peligro”

Una sola capa es útil pero limitada. Lo que separa un influence map de juguete de uno utilizable es mantener varias capas independientes y combinarlas según el rol del NPC. Las cuatro capas estándar:

  • Danger — emitida por enemigos. Radio amplio, falloff suave. Las unidades de asalto pesado emiten más; los francotiradores emiten un radio enorme pero finito.
  • Control — emitida por aliados. “Mi escuadra controla esta zona.” Sirve para que el NPC sepa si está en territorio propio o en tierra de nadie.
  • Opportunity — emitida por items, cobertura, puntos de captura, flancos abiertos. Lo “deseable” del terreno sin contar enemigos.
  • Scent / rastro — la última posición conocida del jugador, decayendo con el tiempo. Conecta directo con la memoria de percepción de NPCs.

Tres unidades, tres capas separadas:

Tres capas independientes sobre el mismo terreno
danger (enemigos)12321125432112595321124543591232355111232control (aliados)554321543214321321211opportunity (item)353357533532 enemigos (rojo intenso)aliado en (0,0)item en (5,3)cada celda guarda 3 valores independientes; el NPC lee la(s) que le interesen

Danger (2 enemigos), control (1 aliado en la esquina superior izquierda) y opportunity (1 item al este). Cada celda guarda los tres valores en paralelo.

Cada celda termina con tres valores independientes. El NPC los lee según le interese.

2.4 Combinación con pesos: la capa “táctica”

La fórmula básica es una suma ponderada:

tactical[cell] = w_control * control[cell]
               - w_danger  * danger[cell]
               + w_opp     * opportunity[cell]

Los pesos los define el rol del NPC:

  • Asalto: w_danger = 1.0, w_opp = 1.5. Va donde haya oportunidad aunque sea peligroso.
  • Médico: w_danger = 3.0, w_opp = 0.5. Evita peligro a toda costa.
  • Francotirador: w_danger = 2.0, w_opp = 0.0, w_control = -0.5. Busca terreno sin control aliado (flancos solitarios) lejos del peligro.
  • Carne de cañón: w_danger = -1.0. Sí, negativo. El NPC corre hacia el peligro porque su rol es absorber daño.

La capa combinada para un asalto en el ejemplo anterior queda:

Capa combinada para un asalto
552−1−4−3−152−1−8−7−5−2−12−1−8−16−7−3−1−11−2−4−5−4−143−2−1−2−30495−1−2−1−11353argmax → moverse aquí (cerca del item, lejos del peligro)

Fórmula: control − 2·danger + 0.5·opportunity. Celdas rellenas = valor positivo (atractivo); celdas con sólo borde = negativo (a evitar). El máximo del grid (9) marca la celda destino: argmax.

El máximo del grid (9) le dice al asalto a qué celda moverse: cerca del item, lejos de los enemigos. Sin combinación, el NPC tendría que mirar tres capas a la vez y decidir él. Con la combinada, la decisión es un argmax.

2.5 ¿Cuándo recalcular y a qué resolución?

Esta es la pregunta que decide si tu influence map es un feature o un cuello de botella.

Resolución: cuanto más fina, más cara cada propagación. Reglas decentes:

EscenarioResolución típicaPor qué
Sala de combate cerrada16×16 a 32×32El NPC solo tiene ~20 celdas alrededor; más detalle no se nota
Mapa de RTS pequeño64×64Suficiente para tácticas de pelotón
Mapa de RTS grande128×128Empieza a doler; considera multi-resolución
World map de 4X64×64 hexA escala de provincia, no de unidad

Recálculo: event-driven, no per-frame. Recalculas la capa cuando una fuente se mueve más de N celdas, nace, muere, o cambia la topología (pared destruida). Entre eventos la capa se queda quieta. Si no aguantas la latencia, dos trucos: incremental (restas la contribución vieja de la fuente y la sumas en la celda nueva, solo tocas su radio), o a slices (divides el trabajo en N frames; la capa va un frame por detrás, pero el coste por frame es 1/N).

2.6 ¿Cómo predecir dónde estará el enemigo en 3 segundos?

Aquí es donde los influence maps dejan de ser “mapa estático del presente” y se vuelven interesantes. La idea es brutalmente simple: proyectas la posición futura de cada fuente y la usas como fuente extra.

predicted_pos = enemy.position + enemy.velocity * lookAhead

Stampas el danger en predicted_pos con un peso menor (porque la predicción es incierta) y obtienes una danger map del futuro. Combinándola con la del presente, el NPC puede:

  • Moverse a un punto que ahora es seguro y en 3 segundos también.
  • Evitar la trayectoria del enemigo, no su posición actual.
  • Disparar con lead (apuntar adonde va a estar la cabeza, no donde está).

Un truco de producción: emite varias capas predichas a distintos horizontes (1s, 3s, 6s) y combínalas con pesos decrecientes. El NPC ve el futuro como un degradado de incertidumbre, no como una foto.

2.7 ¿Cómo se usa el mapa en pathfinding?

El uso más limpio es pathfinding influenciado: tu A* sigue siendo el mismo, pero el coste por celda se infla con el peligro.

cost(cell) = baseCost(cell) + dangerMap[cell] * weight

Con weight = 0, A* encuentra el camino más corto, como siempre. Con weight grande, A* prefiere caminos largos que rodeen el peligro. El NPC sale por la puerta de atrás aunque la principal sea más corta. El Dijkstra aplicado tiene el mismo truco con grids de coste heterogéneo.

Ojo con un detalle: si dangerMap[cell] * weight > baseCost, el A* puede romper su garantía de optimalidad si la heurística no se actualiza. La fix es usar Dijkstra puro sobre el coste compuesto, o asegurar que la heurística sigue siendo admisible respecto al coste inflado.

3. Pseudocódigo

function propagate(sources: List<Source>, gridSize: Vec2, decay: Float) -> Grid<Float>
    grid = newGrid(gridSize, 0)
    for each src in sources
        spreadInfluence(src.cell, src.value, grid, decay)
    return grid

function spreadInfluence(origin: Cell, value: Float, grid: Grid, decay: Float)
    # BFS con propagación multiplicativa; respeta obstáculos
    queue = priorityQueue()
    queue.push((origin, value))
    while queue.notEmpty
        cell, val = queue.pop()
        if val <= grid[cell]: continue        # ya hay mejor influencia
        grid[cell] = val
        for each neighbor in walkableNeighbors(cell)
            queue.push((neighbor, val * decay))

function combine(layers: Dict<String, Grid>, weights: Dict<String, Float>) -> Grid
    out = newGrid(...)
    for each cell
        out[cell] = sum(layers[k][cell] * weights[k] for k in layers)
    return out

function predictiveDanger(enemies: List<Enemy>, lookAhead: Float, decay: Float) -> Grid
    sources = []
    for each e in enemies
        future = e.position + e.velocity * lookAhead
        sources.add(Source(future, e.threatValue * 0.6))   # peso menor: incierto
    return propagate(sources, gridSize, decay)

El patrón es siempre el mismo: propaga, combina, lee. La decisión del NPC se reduce a leer una celda o a hacer argmax sobre una región.

4. Implementación en Unity / C#

Snippet representativo de la clase InfluenceMap. Cubre fuente, propagación BFS, sample en world coordinates y combinación con otra capa.

using System.Collections.Generic;
using UnityEngine;

public class InfluenceMap {
    public int Cols, Rows;
    public float CellSize;
    public Vector2 Origin;          // esquina inferior izquierda en world coords
    float[,] grid;

    public InfluenceMap(int cols, int rows, float cellSize, Vector2 origin) {
        Cols = cols; Rows = rows; CellSize = cellSize; Origin = origin;
        grid = new float[cols, rows];
    }

    public void Clear() {
        for (int x = 0; x < Cols; x++)
            for (int y = 0; y < Rows; y++)
                grid[x, y] = 0f;
    }

    public Vector2Int WorldToCell(Vector3 worldPos) {
        int cx = Mathf.Clamp((int)((worldPos.x - Origin.x) / CellSize), 0, Cols - 1);
        int cy = Mathf.Clamp((int)((worldPos.z - Origin.y) / CellSize), 0, Rows - 1);
        return new Vector2Int(cx, cy);
    }

    public void AddSource(Vector2Int cell, float value, float decay, System.Func<Vector2Int, bool> walkable) {
        // BFS con propagación multiplicativa; respeta walkable() para no atravesar muros
        var queue = new Queue<(Vector2Int c, float v)>();
        queue.Enqueue((cell, value));
        var dirs = new[] { new Vector2Int(1,0), new Vector2Int(-1,0), new Vector2Int(0,1), new Vector2Int(0,-1) };

        while (queue.Count > 0) {
            var (c, v) = queue.Dequeue();
            if (c.x < 0 || c.x >= Cols || c.y < 0 || c.y >= Rows) continue;
            if (v <= grid[c.x, c.y]) continue;
            grid[c.x, c.y] = Mathf.Max(grid[c.x, c.y], v);
            foreach (var d in dirs) {
                var n = c + d;
                if (n.x < 0 || n.x >= Cols || n.y < 0 || n.y >= Rows) continue;
                if (!walkable(n)) continue;
                float nv = v * decay;
                if (nv > 0.05f && nv > grid[n.x, n.y])
                    queue.Enqueue((n, nv));
            }
        }
    }

    public float Sample(Vector3 worldPos) {
        var c = WorldToCell(worldPos);
        return grid[c.x, c.y];
    }

    public void CombineInto(InfluenceMap other, float weightSelf, float weightOther, InfluenceMap output) {
        for (int x = 0; x < Cols; x++)
            for (int y = 0; y < Rows; y++)
                output.grid[x, y] = grid[x, y] * weightSelf + other.grid[x, y] * weightOther;
    }
}

Uso típico cada vez que algo importante se mueva:

danger.Clear();
foreach (var e in enemies)
    danger.AddSource(danger.WorldToCell(e.position), e.threat, 0.75f, IsWalkable);

control.Clear();
foreach (var a in allies)
    control.AddSource(control.WorldToCell(a.position), 1f, 0.85f, IsWalkable);

// Sample en runtime
float threatHere = danger.Sample(npc.transform.position);

5. En otros engines

  • Godot: usa un TileMap para la topología y un Array 2D paralelo (PackedFloat32Array por fila) para los valores. La propagación BFS es idéntica. Para visualizar, dibuja un Image y muéstralo como textura encima del tile map.
  • Unreal: el EQS (Environment Query System) ofrece queries espaciales con pesos parecidos a una capa combinada, pero no es un influence map propagado. Para mapas reales, usa un TArray2D<float> y propagación manual; EQS lo consume como Generator custom.
  • JavaScript / TypeScript: cualquier Float32Array indexado por y * cols + x te basta. La lib del sitio implementa la propagación BFS directamente sobre Float32Array con cola plana.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu danger map se siente pixelado y los NPCs zigzaguean cuando intentan rodear a un enemigo. ¿Qué subes primero?

  2. Combinas control y danger con pesos iguales (1.0 y 1.0). El NPC nunca avanza. ¿Por qué?

  3. ¿Cuándo recalcular el mapa entero vs solo afectar celdas cercanas a la fuente que se movió?

  4. Quieres que tu NPC esquive *donde va a estar* el enemigo en 3 segundos, no donde está ahora. ¿Cuál es el cambio mínimo?

  5. Configuras pathfinding con cost = baseCost + danger * weight. Con weight muy alto, A* deja de encontrar rutas obvias. ¿Qué pasa?

7. Siguientes pasos

Los influence maps son la capa de “lectura del mundo” que conecta percepción con decisión. Si quieres que tu NPC además planifique sobre esa lectura, el siguiente eslabón es GOAP o utility AI consumiendo la capa combinada como otra consideration.