Ir al contenido
wannacode

Las flotas y la batalla espacial

En resumen: Una flota es un array con el número de naves por rol. Los roles son piedra, papel o tijera (un multiplicador en porcentaje), cada grupo dispara al mejor objetivo, el daño que no alcanza para una nave entera queda como herida, y las dos andanadas de una ronda se calculan con las cantidades del inicio de la ronda y golpean a la vez. Con los movimientos pasa igual: primero salen todos, después llegan todos.

Problema

¿Quién dispara primero?

Dos flotas se encuentran en un sistema. La primera respuesta es por turnos: dispara el primero, después el segundo. Pero entonces el primer bando gana solo por el orden en el código: las naves que ha destruido ya no responden. El juego quiere otra cosa: en una ronda los dos bandos disparan a la vez, y las flotas también se mueven entre los sistemas todas de una vez.

Aquí hay una imagen del juego (el comando battle, del recorrido con el que se comprueba el juego): en Ismar, cuatro interceptores contra dos bombarderos y dos fragatas. Las líneas vacías de la pantalla están acortadas.

Turno 28/150 Imperio Terrano (A) puntos 147
M3+27 G203+36 C11+24 U52+5 ciencia +16
Investiga: Torpedos 28/31
Batalla en Ismar: Imperio Terrano contra Hegemonía de Orión
Ronda 1: 4I | 2B 2F pérdidas 2 / 2
Ronda 2: 2I | 2F pérdidas 2 / 0
Victoria de Hegemonía de Orión. Quedan: 2F.
Enter o map: al mapa help: comandos

El juego pliega los espacios de las líneas (battle las reacomoda al ancho de la pantalla), por eso «Ronda 1: 4I | 2B 2F» no tiene columnas. Los programas de la lección imprimen las mismas palabras.

Para esta imagen hacen falta cinco cosas: una flota (número de naves por rol), roles (piedra, papel o tijera), una andanada (cada grupo dispara al mejor objetivo), una herida (el daño que no alcanza para una nave entera no se pierde) y simultaneidad (de andanadas y de movimientos). La lección las construye una a una con programas pequeños y al final las junta en la misma batalla. En la lección sobre el sistema de combate los golpes van uno a uno y con un dado. Aquí no hay dado: la batalla es una función pura, las mismas dos flotas dan el mismo resultado.

La idea

Una flota es un número

Una flota no es una lista de naves, sino cinco números: cuántos interceptores (I), bombarderos (B), fragatas (F), acorazados (D) y fortalezas (#) tiene un imperio en un sistema. Todas las naves de un rol son iguales, así que no hace falta un objeto por nave: una batalla con 500 naves son los mismos cinco números. Las fortalezas de los planetas de allí combaten como un quinto rol.

Los valores de los roles son dos tablas indexadas por rol: casco y ataque. La tercera tabla es el multiplicador, que es el paso siguiente:

static final int KINDS = 5;
static final int ROUNDS = 6;
static final String LETTERS = "IBFD#";
static final int[] HP = {10, 16, 30, 90, 25};
static final int[] ATK = {5, 7, 6, 18, 5};
static final int[][] MULT = {
{100, 175, 50, 50, 60},
{50, 100, 175, 80, 150},
{175, 50, 100, 80, 100},
{110, 110, 110, 100, 110},
{100, 100, 100, 100, 100},
};

Dos funciones pequeñas se usan en todas partes: la suma de las naves y el texto «3I 2B 2F» que el juego escribe para una flota (una vacía es «-»). El poder de un rol es casco × ataque / 10, y el de una flota, la suma por roles. El juego lo usa para comparar flotas (la inteligencia artificial, las próximas lecciones).

static int total(int[] counts) {
int sum = 0;
for (int n : counts) sum += n;
return sum;
}
static String countsText(int[] counts) {
String text = "";
for (int k = 0; k < KINDS; k++) {
if (counts[k] > 0) text += (text.isEmpty() ? "" : " ") + counts[k] + LETTERS.charAt(k);
}
return text.isEmpty() ? "-" : text;
}
static int unitPower(int kind) {
return HP[kind] * ATK[kind] / 10;
}
static int power(int[] counts) {
int sum = 0;
for (int k = 0; k < KINDS; k++) sum += counts[k] * unitPower(k);
return sum;
}

El programa muestra la flota 3I 2B 2F: texto, cantidad, poder y una flota vacía.

int[] fleet = {3, 2, 2, 0, 0};
System.out.println("Flota: " + countsText(fleet));
System.out.println("Naves: " + total(fleet));
System.out.println("Poder: " + power(fleet));
System.out.println("Flota vacía: " + countsText(new int[KINDS]));
Salida
Flota: 3I 2B 2F
Naves: 7
Poder: 73
Flota vacía: -

El poder es 3 × 5 + 2 × 11 + 2 × 18 = 73. El interceptor es 5, el bombardero 11, la fragata 18 y el acorazado 162.

En Java HP[kind] * ATK[kind] / 10 es una división entera porque los tres son int.

En el juego las flotas están en una lista world.fleets, ordenada por imperio y después por sistema. fleetAt busca la flota de un imperio en un sistema, y addShips la crea (en su sitio del orden) o añade naves a la que ya existe:

static Fleet fleetAt(World world, int owner, int systemId) {
for (Fleet fleet : world.fleets) {
if (fleet.owner == owner && fleet.systemId == systemId) return fleet;
}
return null;
}
// Añade naves a la flota del imperio en el sistema; una flota nueva ocupa su lugar en el orden.
static void addShips(World world, int owner, int systemId, int[] counts) {
Fleet fleet = fleetAt(world, owner, systemId);
if (fleet == null) {
fleet = new Fleet(owner, systemId, new int[] {0, 0, 0, 0});
int at = world.fleets.size();
while (at > 0) {
Fleet before = world.fleets.get(at - 1);
if (before.owner < owner || (before.owner == owner && before.systemId < systemId)) break;
at--;
}
world.fleets.add(at, fleet);
}
for (int k = 0; k < SHIP_KIND_COUNT; k++) fleet.ships[k] += counts[k];
}

La idea

Piedra, papel o tijera

Si solo importara el poder, siempre compensaría construir la nave más fuerte. Por eso los roles combaten entre sí de forma distinta. El multiplicador es un porcentaje del daño: la fila es el atacante, la columna es el objetivo. El interceptor vence al bombardero (175%), el bombardero vence a la fragata (175%), la fragata vence al interceptor (175%), contra el rol que lo vence todos golpean a la mitad (50%), y contra su propio rol con 100%. El acorazado golpea a todos con 100–110%, y todos lo golpean débilmente a él (50–80%): es fuerte por sí mismo, no por su rol.

// El multiplicador del daño en porcentaje: la fila es el atacante, la columna es el objetivo (interceptor, bombardero,
// fragata, acorazado, fortaleza).
static final int[][] MULT = {
{100, 175, 50, 50, 60},
{50, 100, 175, 80, 150},
{175, 50, 100, 80, 100},
{110, 110, 110, 100, 110},
{100, 100, 100, 100, 100},
};

Cada grupo (todas las naves de un rol) dispara a un objetivo: el rol, entre los enemigos vivos, contra el que el multiplicador es el mayor. En caso de empate gana el primero del orden: la comparación es estrictamente «mayor», así que un rol posterior con el mismo multiplicador no reemplaza al ya elegido.

static int chooseTarget(int kind, int[] enemy) {
int best = -1;
for (int k = 0; k < KINDS; k++) {
if (enemy[k] > 0 && (best < 0 || MULT[kind][k] > MULT[kind][best])) best = k;
}
return best;
}
static int chooseTarget(int kind, int[] enemy) {
    int best = -1;
    for (int k = 0; k < KINDS; k++) {
        if (enemy[k] > 0 && (best < 0 || MULT[kind][k]  MULT[kind][best])) best = k;
    }
    return best;
}

El programa muestra los objetivos de los cinco roles contra una flota completa y un caso en el que falta el rol más favorable:

int[] all = {1, 1, 1, 1, 1};
for (int kind = 0; kind < KINDS; kind++) {
int target = chooseTarget(kind, all);
System.out.println(NAMES[kind] + " -> " + NAMES[target] + " " + MULT[kind][target] + "%");
}
int[] noFrigates = {1, 1, 0, 1, 1};
int alt = chooseTarget(1, noFrigates);
System.out.println("Sin fragatas: " + NAMES[1] + " -> " + NAMES[alt] + " " + MULT[1][alt] + "%");
Salida
interceptor -> bombardero 175%
bombardero -> fragata 175%
fragata -> interceptor 175%
acorazado -> interceptor 110%
fortaleza -> interceptor 100%
Sin fragatas: bombardero -> fortaleza 150%

Las tres primeras líneas son el triángulo. El acorazado y la fortaleza no tienen un rol contra el que sean más fuertes: su mayor multiplicador (110% y 100%) está en el primer lugar del orden, el interceptor. La última línea es la más interesante: el bombardero quiere fragatas y no hay, así que elige el siguiente mejor, la fortaleza (150%), no el interceptor (50%) ni el acorazado (80%).

Esta es la función en el juego:

static int chooseTarget(int kind, int[] enemy) {
int best = -1;
for (int k = 0; k < KIND_COUNT; k++) {
if (enemy[k] > 0 && (best < 0 || MULT[kind][k] > MULT[kind][best])) best = k;
}
return best;
}

La idea

Andanada y herida

El grupo que ha elegido un objetivo hace daño = cantidad × ataque × multiplicador / 100. La división es entera y recorta: 6 bombarderos de 7 contra fragatas son 6 × 7 × 175 / 100 = 73 (no 73,5). El daño de todos los grupos de un bando se suma por el rol del objetivo.

static int[] volley(int[] mine, int[] enemy) {
int[] damage = new int[KINDS];
for (int k = 0; k < KINDS; k++) {
if (mine[k] == 0) continue;
int target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += mine[k] * ATK[k] * MULT[k][target] / 100;
}
return damage;
}

Después el daño se aplica. El daño sobre un rol se divide entre el casco de una nave: caen tantas naves enteras como quepan. Si caen todas, el rol desaparece. Si no, el resto (el daño que no alcanza para una nave más) se anota como herida y se suma a la siguiente andanada. Sin heridas el fuego lento nunca mataría una nave: 9 de daño sobre una nave con casco 10 no son nada, pero la segunda vez son 18 y cae una nave.

static void applyDamage(int[] counts, int[] wound, int[] damage) {
for (int k = 0; k < KINDS; k++) {
if (damage[k] == 0 || counts[k] == 0) continue;
int sum = wound[k] + damage[k];
int killed = sum / HP[k];
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= killed;
wound[k] = sum - killed * HP[k];
}
}
}

El programa hace la primera ronda de la batalla de Ismar: las dos andanadas (las columnas son I, B, F, D, #), y después la aplicación. La pequeña función numbers imprime un array con espacios:

static String numbers(int[] values) {
String text = "";
for (int v : values) text += (text.isEmpty() ? "" : " ") + v;
return text;
}

El programa:

int[] a = {4, 0, 0, 0, 0};
int[] b = {0, 2, 2, 0, 0};
int[] hitB = volley(a, b);
int[] hitA = volley(b, a);
System.out.println("a -> b: " + numbers(hitB));
System.out.println("b -> a: " + numbers(hitA));
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
System.out.println("a: " + countsText(a) + ", heridas " + numbers(woundA));
System.out.println("b: " + countsText(b) + ", heridas " + numbers(woundB));
Salida
a -> b: 0 35 0 0 0
b -> a: 28 0 0 0 0
a: 2I, heridas 8 0 0 0 0
b: 2F, heridas 0 0 0 0 0

El bando a golpea solo a los bombarderos: 4 × 5 × 175 / 100 = 35, y dos bombarderos son 32 de casco, así que caen los dos (35 / 16 = 2). El bando b golpea solo a los interceptores: a los bombarderos con 50% (2 × 7 × 50 / 100 = 7), a las fragatas con 175% (2 × 6 × 175 / 100 = 21), 28 en total. 28 / 10 = 2 interceptores caen, y 8 quedan como herida del tercero. A las fragatas no las alcanzan en la primera ronda: los interceptores disparan solo a los bombarderos.

Estas son las dos funciones en el juego. Además de las cantidades de naves y el daño reciben stats (los bonos de las tecnologías); aquí no están, por eso los números del programa son los de un imperio sin tecnologías. En Java el daño es un long, porque el número de naves de una partida guardada puede ser grande y un int se desbordaría:

// El daño que un bando causa al otro, por papel del objetivo. El cálculo es en long: el número de
// naves de una partida guardada puede ser grande, y JavaScript no desborda.
static long[] volley(SideStats stats, int[] mine, int[] enemy) {
long[] damage = new long[KIND_COUNT];
for (int k = 0; k < KIND_COUNT; k++) {
if (mine[k] == 0) continue;
int target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += ((long) mine[k] * stats.atk[k] * MULT[k][target]) / 100;
}
return damage;
}
// El daño se acumula: las unidades pierden casco, pero el primer superviviente guarda su herida para la ronda siguiente.
static void applyDamage(SideStats stats, int[] counts, int[] wound, long[] damage) {
for (int k = 0; k < KIND_COUNT; k++) {
if (damage[k] == 0 || counts[k] == 0) continue;
long total = wound[k] + damage[k];
long killed = total / stats.hp[k];
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= (int) killed;
wound[k] = (int) (total - killed * stats.hp[k]);
}
}
}

Míralo

La ronda entera, de una vez

Ahora todo se junta. En cada ronda el bucle de la batalla hace lo siguiente:

  1. Si un bando no tiene naves, termina.
  2. Copia las cantidades de los dos bandos: es la «foto» del inicio de la ronda.
  3. Calcula las dos andanadas a partir de las fotos: la andanada de a sobre b y la de b sobre a.
  4. Aplica el daño sobre b, después sobre a. El orden no importa, porque las andanadas ya están calculadas.
  5. Escribe la línea de la ronda: cuántas naves tenían los dos bandos al inicio y cuántas perdieron.

Tras seis rondas, si todavía quedan naves en los dos bandos, no hay vencedor. Esta es la función entera y el programa que libra la batalla de Ismar:

static void runBattle(int[] a, int[] b, String nameA, String nameB, List<String> log) {
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
for (int round = 1; round <= ROUNDS; round++) {
if (total(a) == 0 || total(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
int[] hitB = volley(startA, startB);
int[] hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.add("Ronda " + round + ": " + countsText(startA) + " | " + countsText(startB) + " pérdidas " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) == 0) log.add("Victoria de " + nameA + ". Quedan: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) == 0) log.add("Victoria de " + nameB + ". Quedan: " + countsText(b) + ".");
else if (total(a) == 0 && total(b) == 0) log.add("Las dos flotas quedan destruidas.");
else log.add("Sin vencedor tras seis rondas: " + countsText(a) + " | " + countsText(b) + ".");
}
int[] a = {4, 0, 0, 0, 0};
int[] b = {0, 2, 2, 0, 0};
String where = "Ismar";
String nameA = "Imperio Terrano";
String nameB = "Hegemonía de Orión";
List<String> log = new ArrayList<>();
runBattle(a, b, nameA, nameB, log);
System.out.println("Batalla en " + where + ": " + nameA + " contra " + nameB);
for (String line : log) System.out.println(line);
Salida
Batalla en Ismar: Imperio Terrano contra Hegemonía de Orión
Ronda 1: 4I | 2B 2F pérdidas 2 / 2
Ronda 2: 2I | 2F pérdidas 2 / 0
Victoria de Hegemonía de Orión. Quedan: 2F.

Estas son exactamente las líneas del juego. La segunda ronda es corta: de los cuatro interceptores quedan dos (con una herida de 8), y de los bombarderos no hay rastro. Los dos interceptores golpean a las fragatas con solo 50% (2 × 5 × 50 / 100 = 5 de daño, menos que el casco de una nave), y las fragatas golpean a los interceptores con 175%: 2 × 6 × 175 / 100 = 21, más la herida 8 son 29. Caen los dos interceptores. Las fragatas ganan sin perder una nave. Sigue las rondas en la escena: las flechas y los números son simultáneos.

static void runBattle(int[] a, int[] b, String nameA, String nameB, List<String> log) {
int[] woundA = new int[KINDS];
int[] woundB = new int[KINDS];
for (int round = 1; round <= ROUNDS; round++) {
if (total(a) == 0 || total(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
int[] hitB = volley(startA, startB);
int[] hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.add("Ronda " + round + ": " + countsText(startA) + " | " + countsText(startB) + " pérdidas " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) == 0) log.add("Victoria de " + nameA + ". Quedan: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) == 0) log.add("Victoria de " + nameB + ". Quedan: " + countsText(b) + ".");
else if (total(a) == 0 && total(b) == 0) log.add("Las dos flotas quedan destruidas.");
else log.add("Sin vencedor tras seis rondas: " + countsText(a) + " | " + countsText(b) + ".");
}
Paso a paso, en palabras
  1. Dos flotas en Ismar: a tiene 4I, b tiene 2B 2F. Una flota es solo el número de naves por rol, y la batalla es una función de dos arrays así.
  2. Ronda 1. Cada bando copia sus cantidades al empezar la ronda. Las dos andanadas se calcularán con esas copias.
  3. Cada rol elige el objetivo con el mayor multiplicador. a: 4I → B (175%). b: 2B → I (50%), 2F → I (175%). Piedra, papel o tijera.
  4. Las andanadas: a: 4 × 5 × 175 / 100 = 35 sobre los bombarderos. b: 2 × 7 × 50 / 100 = 7; 2 × 6 × 175 / 100 = 21 sobre los interceptores, 28 en total. Golpean a la vez.
  5. Se aplica el daño: 35 / 16 = 2 bombarderos caen. 28 / 10 = 2 interceptores caen, el resto, 8, es una herida. El registro: pérdidas 2 / 2.
  6. Ronda 2: 2I contra 2F. Los bombarderos ya no están, así que los objetivos se eligen de nuevo.
  7. Los interceptores golpean a las fragatas con solo 50 %: 2 × 5 × 50 / 100 = 5. Las fragatas golpean a los interceptores con 175 %: 2 × 6 × 175 / 100 = 21.
  8. El daño sobre los interceptores es 21 más la herida 8 = 29: caen los dos. Las fragatas reciben 5, menos que el casco 30: herida de 5, ninguna cae.
  9. a no tiene naves, b tiene 2F: Victoria de Hegemonía de Orión. Quedan: 2F. Así es también en el juego. La batalla es una función pura.

En el juego la batalla es la misma función, solo que con más parámetros (stats para las tecnologías), y devuelve una lista de rondas, mientras que otra función escribe el texto. Aquí está:

static BattleResult runBattle(SideStats statsA, int[] countsA, SideStats statsB, int[] countsB) {
int[] a = countsA.clone();
int[] b = countsB.clone();
int[] woundA = emptyCounts();
int[] woundB = emptyCounts();
List<Round> rounds = new ArrayList<>();
for (int round = 1; round <= BATTLE_ROUNDS; round++) {
if (totalOf(a) == 0 || totalOf(b) == 0) break;
int[] startA = a.clone();
int[] startB = b.clone();
long[] hitB = volley(statsA, startA, startB);
long[] hitA = volley(statsB, startB, startA);
applyDamage(statsB, b, woundB, hitB);
applyDamage(statsA, a, woundA, hitA);
int[] lostA = emptyCounts();
int[] lostB = emptyCounts();
for (int k = 0; k < KIND_COUNT; k++) {
lostA[k] = startA[k] - a[k];
lostB[k] = startB[k] - b[k];
}
rounds.add(new Round(startA, startB, lostA, lostB));
}
String winner = "draw";
if (totalOf(a) > 0 && totalOf(b) == 0) winner = "a";
else if (totalOf(b) > 0 && totalOf(a) == 0) winner = "b";
else if (totalOf(a) == 0 && totalOf(b) == 0) winner = "none";
return new BattleResult(rounds, a, b, winner);
}
Más precisamente, para curiosos

La batalla no es aleatoria, por eso es una función pura: las mismas dos flotas dan el mismo resultado, siempre y en los cuatro lenguajes. No se lanza ningún dado (a diferencia de la lección sobre el sistema de combate, donde cada golpe tiene una probabilidad). Así la batalla se prueba con una llamada y una comprobación del resultado. Los piratas de los eventos también combaten con la misma función (contra una flota sin tecnologías), y una decisión como «¿ataco?» podría jugar la batalla en su cabeza sobre una copia de las flotas. La inteligencia artificial del juego es más sencilla: compara los poderes de las flotas (como arriba), y cómo decide es el tema de la lección sobre los rivales.

Las rondas son como máximo seis (BATTLE_ROUNDS): si las dos flotas no pueden destruirse, por ejemplo una flota pequeña contra una fortaleza grande, el combate termina sin vencedor y el juego escribe «sin vencedor tras seis rondas». Así una batalla nunca queda colgada para siempre.

La idea

También los movimientos, a la vez

El mismo principio vale para el movimiento. En el juego el jugador (y la inteligencia artificial) da órdenes, por ejemplo go 1 2 3 2 2 0 («del sistema 1 al 2: tres interceptores, dos bombarderos, dos fragatas»), y las flotas salen solo en la fase de movimiento. Viajan una ruta por turno por el camino más corto, y la orden se renueva sola hasta que llegan al objetivo.

El movimiento tiene dos fases: primero salen todos de sus sistemas, solo después llegan todos. Por eso, por ejemplo, dos flotas enemigas que se intercambian los sistemas no se encuentran: mientras una sale, la otra todavía no ha llegado. Las batallas vienen después, solo donde tras el movimiento hay en un sistema las dos flotas.

El programa mueve tres flotas (X, Y y Z) por una cadena de sistemas 0, 1, 2, 3 (órdenes hacia 3, 0 y 1) durante dos turnos e imprime dónde están y quiénes se encuentran. Después hace lo mismo mal: mueve flota por flota y comprueba si hay encuentro justo después de cada movimiento.

static int stepToward(int pos, int dest) {
if (pos < dest) return pos + 1;
if (pos > dest) return pos - 1;
return pos;
}
static void report(int turn, String[] names, int[] pos) {
String line = "Turno " + turn + ": ";
for (int i = 0; i < pos.length; i++) line += (i > 0 ? ", " : "") + names[i] + " en " + pos[i];
System.out.println(line);
for (int s = 0; s < 4; s++) {
String here = "";
int count = 0;
for (int i = 0; i < pos.length; i++) {
if (pos[i] == s) here += (count++ > 0 ? ", " : "") + names[i];
}
if (count > 1) System.out.println("encuentro en " + s + ": " + here);
}
}
String[] names = {"X", "Y", "Z"};
int[] dest = {3, 0, 1};
int[] pos = {0, 1, 2};
for (int turn = 1; turn <= 2; turn++) {
int[] next = new int[pos.length];
for (int i = 0; i < pos.length; i++) next[i] = stepToward(pos[i], dest[i]);
pos = next;
report(turn, names, pos);
}
int[] live = {0, 1, 2};
for (int i = 0; i < live.length; i++) {
live[i] = stepToward(live[i], dest[i]);
for (int j = 0; j < live.length; j++) {
if (j != i && live[j] == live[i]) System.out.println("Uno por uno: " + names[i] + " se encuentra con " + names[j]);
}
}
Salida
Turno 1: X en 1, Y en 0, Z en 1
encuentro en 1: X, Z
Turno 2: X en 2, Y en 0, Z en 1
Uno por uno: X se encuentra con Y
Uno por uno: Z se encuentra con X

En el primer turno X (de 0 a 3) e Y (de 1 a 0) se intercambian los sistemas y no se encuentran, pero X y Z (de 2 a 1) están a la vez en el sistema 1: un encuentro. La forma incorrecta ve también un «encuentro» entre X e Y: X llega a 1 mientras Y sigue allí y justo sale. La comprobación dentro del bucle ve medio turno. La forma correcta calcula primero dónde estará cada una (next) a partir del estado antiguo y solo después las coloca allí (pos = next).

Este es el movimiento en el juego. La lista moving está «en el aire»: las naves ya han dejado su flota, pero todavía no han llegado:

static void resolveMovement(World world) {
List<Move> moving = new ArrayList<>();
for (Empire empire : world.empires) {
if (!empire.alive) continue;
for (Order order : empire.orders) {
Fleet fleet = fleetAt(world, empire.id, order.from);
if (fleet == null) continue;
int[] ships = {0, 0, 0, 0};
for (int k = 0; k < SHIP_KIND_COUNT; k++) {
ships[k] = minOf(order.ships[k], fleet.ships[k]);
fleet.ships[k] -= ships[k];
}
if (totalOf(ships) > 0) moving.add(new Move(empire.id, order.to, order.dest, ships));
}
empire.orders = new ArrayList<>();
}
for (Move move : moving) {
addShips(world, move.owner, move.to, move.ships);
// el camino largo continúa solo: una orden nueva desde el lugar donde llegaron
if (move.to != move.dest) {
world.empires.get(move.owner).orders.add(new Order(move.to, nextStep(world.systems, move.to, move.dest), move.dest, move.ships.clone()));
}
}
removeEmptyFleets(world);
}

La idea

Simular una batalla

La batalla es una función pura, así que no hace falta mirarla para entenderla: puedes ejecutarla con muchas flotas y leer una tabla. Así se ajusta el equilibrio del juego. El programa compara flotas iguales de 6 naves en varias parejas de roles:

int[][] pairs = {{0, 1}, {1, 2}, {0, 2}, {0, 3}};
for (int[] pair : pairs) {
int[] a = new int[KINDS];
int[] b = new int[KINDS];
a[pair[0]] = 6;
b[pair[1]] = 6;
String head = countsText(a) + " contra " + countsText(b) + ": ";
runBattle(a, b, "a", "b", new ArrayList<>());
if (total(a) > 0 && total(b) == 0) System.out.println(head + "gana " + LETTERS.charAt(pair[0]) + ", quedan " + countsText(a));
else if (total(b) > 0 && total(a) == 0) System.out.println(head + "gana " + LETTERS.charAt(pair[1]) + ", quedan " + countsText(b));
else System.out.println(head + "sin vencedor");
}
Salida
6I contra 6B: gana I, quedan 3I
6B contra 6F: gana B, quedan 4B
6I contra 6F: gana F, quedan 6F
6I contra 6D: gana D, quedan 6D

El triángulo se ve en los números. Seis interceptores vencen a seis bombarderos y pierden tres. Seis bombarderos vencen a seis fragatas y pierden dos. Seis fragatas destruyen a seis interceptores sin una sola pérdida: los interceptores caen ya en la primera ronda. El acorazado (casco 90, ataque 18) es otro caso: seis acorazados destruyen a seis interceptores, también sin pérdidas, porque cada uno aguanta mucho más (en el juego necesita una tecnología y más recursos). «¿Cuál es más fuerte?» no tiene una sola respuesta: depende del rol del enemigo. Por eso también el poder de una flota (la suma de los poderes de los roles) es solo una estimación aproximada.

El juego hace lo mismo con partidas enteras: --simulate=200 ejecuta doscientas partidas sin pantalla e imprime quién gana. Ese es el tema de la última lección. Las batallas en ellas son esta función, ejecutada miles de veces.

Pruébalo

Predice la salida

Tres bombarderos contra tres interceptores. ¿Quién gana y cuántas rondas dura la batalla? No ejecutes el programa: calcula las rondas a mano con las tablas de la lección (casco 10 y ataque 5 para el interceptor, 16 y 7 para el bombardero) y no olvides las heridas.

int[] a = {0, 3, 0, 0, 0};
int[] b = {3, 0, 0, 0, 0};
List<String> log = new ArrayList<>();
runBattle(a, b, "Imperio Terrano", "Hegemonía de Orión", log);
for (String line : log) System.out.println(line);
Respuesta

Pruébalo

Encuentra el error

Un compañero decidió «ordenar» la ronda: en cuanto la andanada de a está calculada, la aplica enseguida y solo después calcula la de b. Así no necesita las copias para las andanadas. Un duelo de dos interceptores contra dos bombarderos imprime:

int[] a = {2, 0, 0, 0, 0};
int[] b = {0, 2, 0, 0, 0};
List<String> log = new ArrayList<>();
runBattle(a, b, "a", "b", log);
for (String line : log) System.out.println(line);
Salida
Ronda 1: 2I | 2B pérdidas 0 / 1
Ronda 2: 2I | 1B pérdidas 0 / 1
Victoria de a. Quedan: 2I.

La segunda ronda termina con «pérdidas 0 / 1», y la victoria deja dos interceptores. Antes del cambio la segunda ronda es «pérdidas 1 / 1» y queda un interceptor (Ronda 2: 2I | 1B pérdidas 1 / 1). ¿Qué línea se ejecuta demasiado pronto?

Elige la línea con el error.

Más precisamente

Lo que este sistema deja fuera

La batalla de la lección es el núcleo; en el juego hay algunas cosas más que no cambian el principio.

Las tecnologías dan bonos al ataque y al casco (y otros adicionales a los bombarderos y a las fragatas). Entran por stats de volley y applyDamage: el mismo array con el número de naves combate de forma distinta para dos imperios. Por eso el parámetro está en el canon y aquí no.

Las fortalezas son un quinto rol (FORT_KIND): los planetas de un imperio en un sistema le dan estaciones que disparan y caen como naves. Tras la batalla las estaciones supervivientes se conservan hasta el final del turno, y al inicio del siguiente vuelven a estar enteras.

Más de dos imperios en un sistema combaten por parejas: por cada pareja en guerra que tenga allí naves o estaciones se llama a una batalla. El orden de las parejas es fijo, por eso el resultado está determinado.

No hay retirada en mitad de la batalla ni elección de objetivo. Una retirada es una orden de movimiento, que se cumple antes de la batalla. Un grupo siempre dispara al mejor rol; una versión más rica le daría al jugador un comando «concentrar en un rol», pero entonces la batalla ya no sería solo una función de las dos flotas.

Las pérdidas son naves enteras. Una herida solo se conserva dentro de la batalla: después una nave herida está sana, porque una flota es solo un número. Un juego con reparaciones guardaría la herida en la flota entre turnos.

Ejercicio

Una batalla a partir de la entrada

Resuélvelo en tu editor.

Una batalla a partir de la entrada. El programa lee de la entrada dos líneas de cinco números: las cantidades de I, B, F, D y # de la flota a, y después lo mismo de b. Imprime las líneas de la batalla como en la lección: una línea por ronda y la última línea con la victoria (con los nombres «a» y «b»; sin título).

Con la entrada 5 0 4 0 0 | 0 6 2 0 0 (una flota por línea) la salida es:

Salida
Ronda 1: 5I 4F | 6B 2F pérdidas 4 / 2
Ronda 2: 3I 2F | 4B 2F pérdidas 4 / 3
Ronda 3: 1I | 2B 1F pérdidas 1 / 0
Victoria de b. Quedan: 2B 1F.

Fíjate en la tercera ronda: a se queda con un interceptor contra dos bombarderos y una fragata, y la victoria es de b, no de a, aunque a empieza con más naves.

Más ejercicios

  • ¿Cuántos interceptores hacen falta? ¿A partir de cuántos interceptores (de 1 a 30) vencen a 4 fragatas? Imprime el primer número con el que gana a. Prueba también con bombarderos contra 4 fragatas.
  • Cambiar el orden. Ejecuta la misma batalla como (a, b) y como (b, a). ¿Es lo mismo? ¿Por qué?
  • Acorazado. Añade un acorazado a a. ¿Cuántos interceptores necesita b para destruirlo?
  • Flota vacía. ¿Qué hace el programa si un bando no tiene naves? No debe fallar.

Test

Ponte a prueba

1. ¿Por qué las dos andanadas de una ronda se calculan con el número de naves al inicio de la ronda, y no una después de la otra?
2. 6 bombarderos (ataque 7) disparan a fragatas. El multiplicador del bombardero contra una fragata es 175%. ¿Cuánto daño hacen?
3. Tres interceptores (casco 10 cada uno) reciben 25 de daño en total y no tienen herida antigua. ¿Qué queda?
4. Los interceptores no tienen delante ni bombarderos ni interceptores, solo fragatas y acorazados. Sus multiplicadores son 50 contra una fragata y 50 contra un acorazado. ¿A cuál eligen?
5. La flota x está en el sistema 1 y tiene una orden hacia el sistema 2. Una flota enemiga y está en el sistema 2 y tiene una orden hacia el sistema 1. Los dos bandos están en guerra. ¿Qué pasa en el turno?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·