Procedural Intermedio 11 min de lectura

Civilization-style territory growth

Cómo crece el territorio de una civilización. Fronteras orgánicas, presión cultural por peso y distancia.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Civilization, Total War, Old World, Crusader Kings — cualquier juego con territorio que crece y choca contra otros usa una de estas tres herramientas:

PatrónCuándo
Dijkstra bounded desde la capitalTerritorios que crecen por gameplay (cultura, expansión militar)
Voronoi + ruidoMapas pre-generados con fronteras naturales
Presión cultural (campo continuo)Sistemas con conflictos suaves entre culturas

Este tutorial es aplicación de tanda 5 y 6 sobre un caso específico: simular el crecimiento territorial de civilizaciones. Si querés generar mapas tipo Civ VI, Old World o Risk, este es el conjunto de herramientas.

1. Expansión por Dijkstra — el modelo de Civ V/VI

Cuatro civilizaciones expanden territorio con Dijkstra bounded desde su capital. Cada celda pertenece a la civilización que llega con menor costo. Sube el slider de cultura y mira los imperios crecer.

Figura 1 — Cuatro civilizaciones expanden territorio con Dijkstra desde su capital, limitado por presupuesto cultural. Cada celda pertenece a la civ que llega con menor costo. Sube el slider para que crezcan.

1.1 La idea

function civExpansion(grid, civs, culturePerCiv):
    distance[civ] = dijkstra(grid, civ.capital)
    owner = Map.fillWith(null)
    for each cell c:
        bestCiv = null
        bestD = Infinity
        for each civ:
            if distance[civ][c] <= culturePerCiv and distance[civ][c] < bestD:
                bestD = distance[civ][c]
                bestCiv = civ
        owner[c] = bestCiv
    return owner

Cada civ tiene un presupuesto cultural (cuántas celdas puede alcanzar). Dentro de su presupuesto, expande hasta donde llegue. Donde dos civs llegan parejo, gana la más cercana (gradient descent).

1.2 Costos por terreno

El barro y la montaña cuestan más. Eso le da forma natural al territorio:

  • Las civs que arrancan en llanura crecen rápido y redondas.
  • Las que arrancan rodeadas de montañas crecen lento y angostas.
  • Los pasos angostos se vuelven puntos de control.
costs = {
    plains: 1,
    forest: 2,
    hills: 3,
    mountains: 5,    // o ∞ si son intransitables
    desert: 4,
}

1.3 Política de fronteras

Cuando dos civs se encuentran, ¿quién gana la celda contestada? Tres reglas comunes:

  • First come, first served: la civ que llegó primero (gScore más bajo) se queda con la celda.
  • Más fuerte gana: la civ con mayor cultura desplaza a la débil.
  • Ambas pierden: la celda queda neutral / contestada (terreno disputado).

Civilization V usaba “first come”; VI complica con presión cultural (siguiente sección).

2. Fronteras Voronoi orgánicas — Civ VI / Old World

Fronteras estilo Civ VI: Voronoi entre capitales + ruido. Sin ruido las fronteras serían rectas (estilo Civ V/Risk); con ruido se ven naturales (Civ VI / Old World). Arrastra las capitales.

Figura 2 — Voronoi entre capitales con ruido. Sin ruido las fronteras serían rectas como en Civ V o Risk; con ruido se ven naturales como en Civ VI o Old World.

2.1 Por qué Voronoi y no Dijkstra

Dijkstra respeta el terreno (montañas son barreras). Voronoi puro ignora el terreno: solo importa la distancia recta.

Cuándo usar Voronoi:

  • Generación inicial del mapa (antes de que las civs hayan jugado).
  • Mapas teóricos / abstractos (Risk, Diplomacy).
  • Cuando querés fronteras predecibles para gameplay simétrico.

Cuándo usar Dijkstra:

  • Crecimiento dinámico durante el juego.
  • Cuando el terreno debe importar.
  • Cuando el path importa, no solo la distancia.

2.2 El mejor de ambos: Voronoi + ruido + post-process

Pipeline canónico de Civ VI:

1. Voronoi entre capitales → fronteras base.
2. Aplicar ruido Perlin a las fronteras → orgánicas.
3. Post-process: cada celda ajusta según terreno (montañas siempre frontera, ríos suelen serlo).
4. Recalcular cuando cambia algo (nueva ciudad, conquista).

2.3 Caso histórico

Civ V usaba Dijkstra puro: las fronteras eran rectas y “feas”. Civ VI introdujo el sistema de “loyalty pressure” y fronteras con Perlin distortion. Old World lleva el sistema más allá con presión por línea de visión y ruido por bioma.

3. Presión cultural — campo continuo

Presión cultural decreciente con distancia². La civilización con mayor presión en cada celda la controla. Donde tres civilizaciones presionan parejo, la zona queda disputada (color tenue). Sliders ajustan el peso de cada cultura.

Figura 3 — Tres civilizaciones, cada una con peso. La presión cultural en cada celda es weight / distancia². La civ con mayor presión gana; donde están parejas, queda zona disputada (color tenue).

3.1 La fórmula clásica

pressure[civ][c] = civ.weight / distance(civ.capital, c)²
  • Peso = “cultura” (tamaño del imperio, riquezas, monumentos, etc.).
  • Distancia² en el denominador modela la difusión: cerca de la capital la presión es enorme, lejos se atenúa rápido.

Cada celda elige la civ con mayor presión como dueña. Si dos civs presionan parejo (diferencia < threshold), queda contestada.

3.2 Cuándo aplicar este modelo

  • Civilization VI: pressure system para “loyalty” — ciudades pueden flippear a la civ vecina si tiene más presión.
  • Crusader Kings: cultura/religión se difunde por presión similar.
  • Stellaris: pop pressure determina migración entre planetas.

3.3 Composiciones útiles

  • Suma de presiones de aliados: dos civs aliadas suman su presión sobre celdas neutrales.
  • Modificadores: el terreno reduce la presión (montañas resisten cultura), la religión la aumenta (ciudades sagradas).
  • Decay temporal: si una civ pierde una ciudad, su presión decae con el tiempo (no se evapora instantáneamente).

4. Pseudocódigo unificado — los tres sistemas

// MODELO 1: Dijkstra desde capital, presupuesto cultural
function dijkstraExpansion(grid, civs):
    owner = Map.fillWith(null)
    for civ in civs:
        distances[civ] = dijkstra(grid, civ.capital)
    for cell in grid:
        bestCiv = argmin_civ(distances[civ][cell] if distances[civ][cell] <= civ.budget else Infinity)
        owner[cell] = bestCiv
    return owner

// MODELO 2: Voronoi + ruido (no respeta terreno, es geometría pura)
function voronoiExpansion(width, height, capitals, noiseAmp):
    owner = Array2D(width, height)
    for (x, y) in pixels:
        nx = x + perlinNoise(x, y) * noiseAmp
        ny = y + perlinNoise(x, y, offset = 100) * noiseAmp
        owner[x, y] = argmin_civ(dist(capitals[civ], (nx, ny)))
    return owner

// MODELO 3: Presión cultural, suma continua
function culturePressure(grid, civs):
    owner = Map.fillWith(null)
    for cell in grid:
        pressures = []
        for civ in civs:
            d2 = max(1, distSq(civ.capital, cell))
            pressures.append((civ.weight / d2, civ))
        sorted = pressures.sortDesc()
        if sorted[0].p > sorted[1].p * threshold:
            owner[cell] = sorted[0].civ
        else:
            owner[cell] = "disputed"
    return owner

5. Implementación en Unity / C#

using System.Collections.Generic;
using UnityEngine;

public class CivTerritoryManager : MonoBehaviour {
    public List<Civ> civs;
    public PathfindingGrid grid;

    public int[,] ComputeOwnership(float globalCulture) {
        int W = grid.Cols, H = grid.Rows;
        var distances = new float[civs.Count, W * H];
        for (int i = 0; i < civs.Count; i++) {
            var dist = Dijkstra.Find(grid, civs[i].capital);
            for (int j = 0; j < W * H; j++) distances[i, j] = dist[j];
        }

        var owner = new int[W, H];
        for (int y = 0; y < H; y++)
            for (int x = 0; x < W; x++) {
                int idx = y * W + x;
                int bestCiv = -1;
                float bestD = float.PositiveInfinity;
                for (int i = 0; i < civs.Count; i++) {
                    float d = distances[i, idx];
                    if (d > globalCulture) continue;
                    if (d < bestD) { bestD = d; bestCiv = i; }
                }
                owner[x, y] = bestCiv;
            }
        return owner;
    }
}

[System.Serializable]
public class Civ {
    public string name;
    public Color color;
    public Vector2Int capital;
    public float culture;
}

6. En otros engines

  • Godot: TileMap + cálculo en script. Para visualización, TileMap.set_cell por celda con tinte por civ.
  • Unreal: gameplay grids como UDataAsset. Cálculo por C++ o Blueprints, render con UInstancedStaticMeshComponent para escala.
  • JavaScript / TypeScript: la lib del sitio (~/lib/viz/sim/pathfinding) y ~/lib/viz/sim/voronoi cubren los tres modelos.

7. Quiz

Pon a prueba lo que entendiste

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

  1. Civ V usaba fronteras 'feas' (rectas/predecibles); Civ VI las hizo 'naturales'. ¿Qué cambio técnico explica la diferencia?

  2. Para gameplay donde la montaña debe ser barrera real (no solo decoración), ¿Voronoi o Dijkstra?

  3. Tu sistema de presión cultural usa peso/distancia² (ley del cuadrado inverso). ¿Por qué cuadrado y no lineal?

  4. Cuatro civilizaciones expanden con Dijkstra. Dos chocan en una zona. ¿Cómo resuelve el conflicto?

  5. Querés que tu sistema de territorio se actualice rápido cuando una capital cambia de civ (conquista). ¿Qué optimización hacés?

8. Siguientes pasos