Zum Inhalt
wannacode

Flotten und die Weltraumschlacht

Kurz gesagt: Eine Flotte ist ein Array mit der Zahl der Schiffe je Rolle. Die Rollen sind Schere-Stein-Papier (ein Multiplikator in Prozent), jede Gruppe schießt auf das günstigste Ziel, Schaden, der nicht für ein ganzes Schiff reicht, bleibt als Wunde, und die beiden Salven einer Runde werden aus den Zahlen zu Rundenbeginn berechnet und treffen gleichzeitig. Bei den Zügen ist es genauso: Erst brechen alle auf, dann kommen alle an.

Problem

Wer schießt zuerst?

Zwei Flotten treffen in einem System aufeinander. Die erste Antwort lautet: der Reihe nach, erst schießt die erste Seite, dann die zweite. Aber dann gewinnt die erste Seite nur wegen der Reihenfolge im Code: Die Schiffe, die sie zerstört hat, antworten nicht mehr. Das Spiel will etwas anderes: In einer Runde schießen beide Seiten gleichzeitig, und auch die Flotten bewegen sich alle auf einmal zwischen den Systemen.

Hier ist ein Bild aus dem Spiel (der Befehl battle, aus dem Durchlauf, an dem das Spiel geprüft wird): bei Ismar vier Abfangjäger gegen zwei Bomber und zwei Fregatten. Die leeren Zeilen des Bildschirms sind gekürzt.

Runde 28/150 Terranisches Reich (A) Punkte 147
M3+27 G203+36 C11+24 U52+5 Forschung +16
Forscht: Torpedos 28/31
Kampf bei Ismar: Terranisches Reich gegen Orion-Hegemonie
Runde 1: 4I | 2B 2F Verluste 2 / 2
Runde 2: 2I | 2F Verluste 2 / 0
Sieg für Orion-Hegemonie. Übrig: 2F.
Enter oder map: zur Karte help: Befehle

Das Spiel faltet die Leerzeichen in den Zeilen (battle bricht sie auf die Breite des Bildschirms um), darum hat „Runde 1: 4I | 2B 2F“ keine Spalten. Die Programme der Lektion drucken dieselben Wörter.

Für dieses Bild braucht es fünf Dinge: eine Flotte (Schiffszahl je Rolle), Rollen (Schere-Stein-Papier), eine Salve (jede Gruppe schießt auf das günstigste Ziel), eine Wunde (Schaden, der nicht für ein ganzes Schiff reicht, geht nicht verloren) und Gleichzeitigkeit (von Salven und von Zügen). Die Lektion baut sie eins nach dem anderen mit kleinen Programmen und fügt sie am Ende zu derselben Schlacht zusammen. In der Lektion über das Kampfsystem fallen die Schläge einzeln und mit einem Würfel. Hier gibt es keinen Würfel: Die Schlacht ist eine reine Funktion, dieselben zwei Flotten ergeben dasselbe Ergebnis.

Die Idee

Eine Flotte ist eine Zahl

Eine Flotte ist keine Liste von Schiffen, sondern fünf Zahlen: wie viele Abfangjäger (I), Bomber (B), Fregatten (F), Schlachtschiffe (D) und Festungen (#) ein Imperium in einem System hat. Alle Schiffe einer Rolle sind gleich, darum braucht es kein Objekt pro Schiff: Eine Schlacht mit 500 Schiffen sind dieselben fünf Zahlen. Die Festungen der Planeten dort kämpfen als fünfte Rolle.

Die Werte der Rollen sind zwei Tabellen, die nach Rolle indiziert sind: Hülle und Angriff. Die dritte Tabelle ist der Multiplikator, um den es im nächsten Schritt geht:

const KINDS = 5;
const ROUNDS = 6;
const LETTERS = "IBFD#";
const HP = [10, 16, 30, 90, 25];
const ATK = [5, 7, 6, 18, 5];
const 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],
];

Zwei kleine Funktionen braucht man überall: die Summe der Schiffe und den Text „3I 2B 2F“, den das Spiel für eine Flotte schreibt (eine leere ist „-“). Die Macht einer Rolle ist Hülle × Angriff / 10, die einer Flotte die Summe über die Rollen. Das Spiel nutzt sie, um Flotten zu vergleichen (die künstliche Intelligenz, die nächsten Lektionen).

function total(counts) {
let sum = 0;
for (const n of counts) sum += n;
return sum;
}
function countsText(counts) {
const parts = [];
for (let k = 0; k < KINDS; k++) {
if (counts[k] > 0) parts.push(counts[k] + LETTERS[k]);
}
return parts.length === 0 ? "-" : parts.join(" ");
}
function unitPower(kind) {
return Math.floor((HP[kind] * ATK[kind]) / 10);
}
function power(counts) {
let sum = 0;
for (let k = 0; k < KINDS; k++) sum += counts[k] * unitPower(k);
return sum;
}

Das Programm zeigt die Flotte 3I 2B 2F: Text, Anzahl, Macht und eine leere Flotte.

const fleet = [3, 2, 2, 0, 0];
console.log("Flotte: " + countsText(fleet));
console.log("Schiffe: " + total(fleet));
console.log("Macht: " + power(fleet));
console.log("Leere Flotte: " + countsText(new Array(KINDS).fill(0)));
Ausgabe
Flotte: 3I 2B 2F
Schiffe: 7
Macht: 73
Leere Flotte: -

Die Macht ist 3 × 5 + 2 × 11 + 2 × 18 = 73. Der Abfangjäger hat 5, der Bomber 11, die Fregatte 18 und das Schlachtschiff 162.

Math.floor(a / b) ist die Ganzzahldivision: In JavaScript ist / immer eine Division mit Nachkommastellen.

Im Spiel liegen die Flotten in einer Liste world.fleets, geordnet nach Imperium und dann nach System. fleetAt sucht die Flotte eines Imperiums in einem System, und addShips legt sie an (an ihrer Stelle in der Reihenfolge) oder fügt der vorhandenen Schiffe hinzu:

function fleetAt(world, owner, systemId) {
for (const fleet of world.fleets) {
if (fleet.owner === owner && fleet.systemId === systemId) return fleet;
}
return null;
}
// Fügt der Flotte des Reiches im System Schiffe hinzu; eine neue Flotte kommt an ihren Platz in der Reihenfolge.
function addShips(world, owner, systemId, counts) {
let fleet = fleetAt(world, owner, systemId);
if (fleet === null) {
fleet = { owner: owner, systemId: systemId, ships: [0, 0, 0, 0] };
let at = world.fleets.length;
while (at > 0) {
const before = world.fleets[at - 1];
if (before.owner < owner || (before.owner === owner && before.systemId < systemId)) break;
at--;
}
world.fleets.splice(at, 0, fleet);
}
for (let k = 0; k < SHIP_KIND_COUNT; k++) fleet.ships[k] += counts[k];
}

Die Idee

Schere, Stein, Papier

Wenn nur die Macht zählte, würde es sich immer lohnen, das stärkste Schiff zu bauen. Darum kämpfen die Rollen unterschiedlich gegeneinander. Der Multiplikator ist ein Prozentsatz des Schadens: Die Zeile ist der Angreifer, die Spalte das Ziel. Der Abfangjäger schlägt den Bomber (175%), der Bomber schlägt die Fregatte (175%), die Fregatte schlägt den Abfangjäger (175%), gegen die Rolle, die ihn schlägt, trifft jeder nur zur Hälfte (50%), und gegen die eigene Rolle mit 100%. Das Schlachtschiff trifft alle mit 100–110%, und es selbst wird von allen schwach getroffen (50–80%): Es ist aus sich heraus stark, nicht durch seine Rolle.

// Der Schadensfaktor in Prozent: die Zeile ist der Angreifer, die Spalte das Ziel (Abfangjäger, Bomber,
// Fregatte, Schlachtschiff, Festung).
const 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],
];

Jede Gruppe (alle Schiffe einer Rolle) schießt auf ein Ziel: die Rolle unter den lebenden Feinden, gegen die der Multiplikator am größten ist. Bei Gleichstand gewinnt die erste in der Reihenfolge: Der Vergleich ist strikt „größer“, darum ersetzt eine spätere Rolle mit gleichem Multiplikator die bereits gewählte nicht.

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

Das Programm zeigt die Ziele der fünf Rollen gegen eine volle Flotte und einen Fall, in dem die günstigste Rolle fehlt:

const all = [1, 1, 1, 1, 1];
for (let kind = 0; kind < KINDS; kind++) {
const target = chooseTarget(kind, all);
console.log(NAMES[kind] + " -> " + NAMES[target] + " " + MULT[kind][target] + "%");
}
const noFrigates = [1, 1, 0, 1, 1];
const alt = chooseTarget(1, noFrigates);
console.log("Ohne Fregatten: " + NAMES[1] + " -> " + NAMES[alt] + " " + MULT[1][alt] + "%");
Ausgabe
Abfangjäger -> Bomber 175%
Bomber -> Fregatte 175%
Fregatte -> Abfangjäger 175%
Schlachtschiff -> Abfangjäger 110%
Festung -> Abfangjäger 100%
Ohne Fregatten: Bomber -> Festung 150%

Die ersten drei Zeilen sind das Dreieck. Das Schlachtschiff und die Festung haben keine Rolle, gegen die sie stärker sind: Ihr größter Multiplikator (110% und 100%) steht an erster Stelle der Reihenfolge, beim Abfangjäger. Die letzte Zeile ist die interessanteste: Der Bomber will Fregatten, und es gibt keine, also wählt er das nächstbeste Ziel, die Festung (150%), nicht den Abfangjäger (50%) und nicht das Schlachtschiff (80%).

Hier ist die Funktion im Spiel:

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

Die Idee

Salve und Wunde

Die Gruppe, die ein Ziel gewählt hat, richtet Schaden an = Anzahl × Angriff × Multiplikator / 100. Die Division ist eine Ganzzahldivision und schneidet ab: 6 Bomber mit je 7 gegen Fregatten machen 6 × 7 × 175 / 100 = 73 (nicht 73,5). Der Schaden aller Gruppen einer Seite wird nach der Rolle des Ziels addiert.

function volley(mine, enemy) {
const damage = new Array(KINDS).fill(0);
for (let k = 0; k < KINDS; k++) {
if (mine[k] === 0) continue;
const target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += Math.floor((mine[k] * ATK[k] * MULT[k][target]) / 100);
}
return damage;
}

Dann wird der Schaden angewendet. Der Schaden auf eine Rolle wird durch die Hülle eines Schiffs geteilt: So viele ganze Schiffe, wie hineinpassen, fallen. Fallen alle, verschwindet die Rolle. Sonst wird der Rest (Schaden, der für ein weiteres Schiff nicht reicht) als Wunde gespeichert und zur nächsten Salve addiert. Ohne Wunden würde langsames Feuer nie ein Schiff töten: 9 Schaden auf ein Schiff mit Hülle 10 sind nichts, aber beim zweiten Mal sind es 18, und ein Schiff fällt.

function applyDamage(counts, wound, damage) {
for (let k = 0; k < KINDS; k++) {
if (damage[k] === 0 || counts[k] === 0) continue;
const sum = wound[k] + damage[k];
const killed = Math.floor(sum / HP[k]);
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= killed;
wound[k] = sum - killed * HP[k];
}
}
}

Das Programm macht die erste Runde der Schlacht bei Ismar: die beiden Salven (die Spalten sind I, B, F, D, #), dann das Anwenden. Die kleine Funktion numbers druckt ein Array mit Leerzeichen:

function numbers(values) {
return values.join(" ");
}

Das Programm:

const a = [4, 0, 0, 0, 0];
const b = [0, 2, 2, 0, 0];
const hitB = volley(a, b);
const hitA = volley(b, a);
console.log("a -> b: " + numbers(hitB));
console.log("b -> a: " + numbers(hitA));
const woundA = new Array(KINDS).fill(0);
const woundB = new Array(KINDS).fill(0);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
console.log("a: " + countsText(a) + ", Wunden " + numbers(woundA));
console.log("b: " + countsText(b) + ", Wunden " + numbers(woundB));
Ausgabe
a -> b: 0 35 0 0 0
b -> a: 28 0 0 0 0
a: 2I, Wunden 8 0 0 0 0
b: 2F, Wunden 0 0 0 0 0

Die Seite a trifft nur die Bomber: 4 × 5 × 175 / 100 = 35, und zwei Bomber haben 32 Hülle, also fallen beide (35 / 16 = 2). Die Seite b trifft nur die Abfangjäger: die Bomber mit 50% (2 × 7 × 50 / 100 = 7), die Fregatten mit 175% (2 × 6 × 175 / 100 = 21), zusammen 28. 28 / 10 = 2 Abfangjäger fallen, und 8 bleiben als Wunde am dritten. Die Fregatten werden in der ersten Runde nicht getroffen: Die Abfangjäger schießen nur auf die Bomber.

Hier sind die beiden Funktionen im Spiel. Außer den Schiffszahlen und dem Schaden bekommen sie stats (die Boni aus Technologien); die gibt es hier nicht, darum sind die Zahlen des Programms die eines Imperiums ohne Technologien. In Java ist der Schaden ein long, weil die Schiffszahl aus einem Spielstand groß sein kann und ein int überlaufen würde:

// Der Schaden, den eine Seite der anderen zufügt, nach Rolle des Ziels.
function volley(stats, mine, enemy) {
const damage = emptyCounts();
for (let k = 0; k < KIND_COUNT; k++) {
if (mine[k] === 0) continue;
const target = chooseTarget(k, enemy);
if (target < 0) continue;
damage[target] += Math.floor((mine[k] * stats.atk[k] * MULT[k][target]) / 100);
}
return damage;
}
// Der Schaden summiert sich: die Einheiten verlieren Hülle, aber der erste Überlebende behält seine Wunde für die nächste Runde.
function applyDamage(stats, counts, wound, damage) {
for (let k = 0; k < KIND_COUNT; k++) {
if (damage[k] === 0 || counts[k] === 0) continue;
const total = wound[k] + damage[k];
const killed = Math.floor(total / stats.hp[k]);
if (killed >= counts[k]) {
counts[k] = 0;
wound[k] = 0;
} else {
counts[k] -= killed;
wound[k] = total - killed * stats.hp[k];
}
}
}

Sieh es dir an

Die ganze Runde auf einmal

Jetzt kommt alles zusammen. Die Schleife der Schlacht macht in jeder Runde Folgendes:

  1. Hat eine Seite keine Schiffe, ist Schluss.
  2. Sie kopiert die Zahlen beider Seiten: Das ist der „Schnappschuss“ vom Rundenbeginn.
  3. Sie berechnet beide Salven aus den Schnappschüssen: die Salve von a auf b und die Salve von b auf a.
  4. Sie wendet den Schaden auf b an, dann auf a. Die Reihenfolge spielt keine Rolle, denn die Salven sind schon berechnet.
  5. Sie schreibt die Zeile der Runde: Wie viele Schiffe beide Seiten zu Beginn hatten und wie viele sie verloren haben.

Nach sechs Runden gibt es keinen Sieger, wenn noch beide Seiten Schiffe haben. Hier ist die ganze Funktion und das Programm, das die Schlacht bei Ismar schlägt:

function runBattle(a, b, nameA, nameB, log) {
const woundA = new Array(KINDS).fill(0);
const woundB = new Array(KINDS).fill(0);
for (let round = 1; round <= ROUNDS; round++) {
if (total(a) === 0 || total(b) === 0) break;
const startA = [...a];
const startB = [...b];
const hitB = volley(startA, startB);
const hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.push("Runde " + round + ": " + countsText(startA) + " | " + countsText(startB) + " Verluste " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) === 0) log.push("Sieg für " + nameA + ". Übrig: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) === 0) log.push("Sieg für " + nameB + ". Übrig: " + countsText(b) + ".");
else if (total(a) === 0 && total(b) === 0) log.push("Beide Flotten sind vernichtet.");
else log.push("Kein Sieger nach sechs Runden: " + countsText(a) + " | " + countsText(b) + ".");
}
const a = [4, 0, 0, 0, 0];
const b = [0, 2, 2, 0, 0];
const where = "Ismar";
const nameA = "Terranisches Reich";
const nameB = "Orion-Hegemonie";
const log = [];
runBattle(a, b, nameA, nameB, log);
console.log("Kampf bei " + where + ": " + nameA + " gegen " + nameB);
for (const line of log) console.log(line);
Ausgabe
Kampf bei Ismar: Terranisches Reich gegen Orion-Hegemonie
Runde 1: 4I | 2B 2F Verluste 2 / 2
Runde 2: 2I | 2F Verluste 2 / 0
Sieg für Orion-Hegemonie. Übrig: 2F.

Das sind genau die Zeilen des Spiels. Die zweite Runde ist kurz: Von den vier Abfangjägern sind zwei übrig (mit einer Wunde von 8), und von den Bombern gibt es keine Spur. Die zwei Abfangjäger treffen die Fregatten nur mit 50% (2 × 5 × 50 / 100 = 5 Schaden, weniger als die Hülle eines Schiffs), und die Fregatten treffen die Abfangjäger mit 175%: 2 × 6 × 175 / 100 = 21, plus die Wunde 8 ergibt 29. Beide Abfangjäger fallen. Die Fregatten gewinnen, ohne ein Schiff zu verlieren. Verfolge die Runden in der Szene: Die Pfeile und die Zahlen sind gleichzeitig.

function runBattle(a, b, nameA, nameB, log) {
const woundA = new Array(KINDS).fill(0);
const woundB = new Array(KINDS).fill(0);
for (let round = 1; round <= ROUNDS; round++) {
if (total(a) === 0 || total(b) === 0) break;
const startA = [...a];
const startB = [...b];
const hitB = volley(startA, startB);
const hitA = volley(startB, startA);
applyDamage(b, woundB, hitB);
applyDamage(a, woundA, hitA);
log.push("Runde " + round + ": " + countsText(startA) + " | " + countsText(startB) + " Verluste " + (total(startA) - total(a)) + " / " + (total(startB) - total(b)));
}
if (total(a) > 0 && total(b) === 0) log.push("Sieg für " + nameA + ". Übrig: " + countsText(a) + ".");
else if (total(b) > 0 && total(a) === 0) log.push("Sieg für " + nameB + ". Übrig: " + countsText(b) + ".");
else if (total(a) === 0 && total(b) === 0) log.push("Beide Flotten sind vernichtet.");
else log.push("Kein Sieger nach sechs Runden: " + countsText(a) + " | " + countsText(b) + ".");
}
Schritt für Schritt, in Worten
  1. Zwei Flotten bei Ismar: a hat 4I, b hat 2B 2F. Eine Flotte ist nur die Zahl der Schiffe je Rolle, der Kampf eine Funktion zweier solcher Arrays.
  2. Runde 1. Jede Seite kopiert ihre Zahlen zu Rundenbeginn. Beide Salven werden aus diesen Kopien berechnet.
  3. Jede Rolle wählt das Ziel mit dem größten Multiplikator. a: 4I → B (175%). b: 2B → I (50%), 2F → I (175%). Schere-Stein-Papier.
  4. Die Salven: a: 4 × 5 × 175 / 100 = 35 auf die Bomber. b: 2 × 7 × 50 / 100 = 7; 2 × 6 × 175 / 100 = 21 auf die Abfangjäger, zusammen 28. Sie treffen gleichzeitig.
  5. Der Schaden wird angewendet: 35 / 16 = 2 Bomber fallen. 28 / 10 = 2 Abfangjäger fallen, der Rest 8 ist eine Wunde. Das Protokoll: Verluste 2 / 2.
  6. Runde 2: 2I gegen 2F. Die Bomber sind weg, also werden die Ziele neu gewählt.
  7. Die Abfangjäger treffen die Fregatten nur mit 50 %: 2 × 5 × 50 / 100 = 5. Die Fregatten treffen die Abfangjäger mit 175 %: 2 × 6 × 175 / 100 = 21.
  8. Der Schaden auf die Abfangjäger ist 21 plus die Wunde 8 = 29: Beide fallen. Die Fregatten bekommen 5, weniger als Hülle 30: Wunde 5, keine fällt.
  9. a hat keine Schiffe, b hat 2F: Sieg für Orion-Hegemonie. Übrig: 2F. So ist es auch im Spiel. Die Schlacht ist eine reine Funktion.

Im Spiel ist die Schlacht dieselbe Funktion, nur mit mehr Parametern (stats für Technologien), und sie gibt eine Liste von Runden zurück, während eine andere Funktion den Text schreibt. Hier ist sie:

function runBattle(statsA, countsA, statsB, countsB) {
const a = countsA.slice();
const b = countsB.slice();
const woundA = emptyCounts();
const woundB = emptyCounts();
const rounds = [];
for (let round = 1; round <= BATTLE_ROUNDS; round++) {
if (totalOf(a) === 0 || totalOf(b) === 0) break;
const startA = a.slice();
const startB = b.slice();
const hitB = volley(statsA, startA, startB);
const hitA = volley(statsB, startB, startA);
applyDamage(statsB, b, woundB, hitB);
applyDamage(statsA, a, woundA, hitA);
const lostA = emptyCounts();
const lostB = emptyCounts();
for (let k = 0; k < KIND_COUNT; k++) {
lostA[k] = startA[k] - a[k];
lostB[k] = startB[k] - b[k];
}
rounds.push({ a: startA, b: startB, lostA: lostA, lostB: lostB });
}
let 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 { rounds: rounds, a: a, b: b, winner: winner };
}
Genauer, für Neugierige

Die Schlacht ist nicht zufällig, darum ist sie eine reine Funktion: Dieselben zwei Flotten ergeben dasselbe Ergebnis, immer und in allen vier Sprachen. Es wird kein Würfel geworfen (anders als in der Lektion über das Kampfsystem, wo jeder Schlag eine Chance hat). So lässt sich die Schlacht mit einem Aufruf und einer Prüfung des Ergebnisses testen. Auch die Piraten aus den Ereignissen kämpfen mit derselben Funktion (gegen eine Flotte ohne Technologien), und eine Entscheidung wie „soll ich angreifen?“ könnte die Schlacht im Kopf durchspielen, auf einer Kopie der Flotten. Die künstliche Intelligenz des Spiels ist einfacher: Sie vergleicht die Mächte der Flotten (wie oben), und wie sie entscheidet, ist das Thema der Lektion über die Rivalen.

Die Runden sind höchstens sechs (BATTLE_ROUNDS): Können sich die beiden Flotten nicht vernichten, zum Beispiel eine kleine Flotte gegen eine große Festung, endet der Kampf ohne Sieger, und das Spiel schreibt „Kein Sieger nach sechs Runden“. So hängt eine Schlacht nie ewig.

Die Idee

Auch die Züge gleichzeitig

Dasselbe Prinzip gilt für die Bewegung. Im Spiel gibt der Spieler (und die künstliche Intelligenz) Befehle, zum Beispiel go 1 2 3 2 2 0 („von System 1 nach 2: drei Abfangjäger, zwei Bomber, zwei Fregatten“), und die Flotten brechen erst in der Bewegungsphase auf. Sie fliegen eine Verbindung pro Zug auf dem kürzesten Weg, und der Befehl erneuert sich selbst, bis sie das Ziel erreichen.

Die Bewegung hat zwei Phasen: Erst verlassen alle ihre Systeme, dann kommen alle an. Darum treffen sich zum Beispiel zwei feindliche Flotten, die ihre Systeme tauschen, nicht: Während die eine aufbricht, ist die andere noch nicht angekommen. Die Schlachten kommen danach, nur dort, wo nach der Bewegung beide Flotten im selben System stehen.

Das Programm bewegt drei Flotten (X, Y und Z) auf einer Kette von Systemen 0, 1, 2, 3 (Befehle nach 3, 0 und 1) zwei Züge lang und druckt, wo sie stehen und wer sich begegnet. Danach macht es dasselbe falsch: Es bewegt Flotte für Flotte und prüft nach jeder einzelnen Bewegung sofort auf eine Begegnung.

function stepToward(pos, dest) {
if (pos < dest) return pos + 1;
if (pos > dest) return pos - 1;
return pos;
}
function report(turn, names, pos) {
const parts = pos.map((p, i) => names[i] + " bei " + p);
console.log("Zug " + turn + ": " + parts.join(", "));
for (let s = 0; s < 4; s++) {
const here = names.filter((_, i) => pos[i] === s);
if (here.length > 1) console.log("Begegnung bei " + s + ": " + here.join(", "));
}
}
const names = ["X", "Y", "Z"];
const dest = [3, 0, 1];
let pos = [0, 1, 2];
for (let turn = 1; turn <= 2; turn++) {
const next = pos.map((p, i) => stepToward(p, dest[i]));
pos = next;
report(turn, names, pos);
}
const live = [0, 1, 2];
for (let i = 0; i < live.length; i++) {
live[i] = stepToward(live[i], dest[i]);
for (let j = 0; j < live.length; j++) {
if (j !== i && live[j] === live[i]) console.log("Einzeln nacheinander: " + names[i] + " trifft " + names[j]);
}
}
Ausgabe
Zug 1: X bei 1, Y bei 0, Z bei 1
Begegnung bei 1: X, Z
Zug 2: X bei 2, Y bei 0, Z bei 1
Einzeln nacheinander: X trifft Y
Einzeln nacheinander: Z trifft X

Im ersten Zug tauschen X (von 0 nach 3) und Y (von 1 nach 0) die Systeme und begegnen sich nicht, aber X und Z (von 2 nach 1) stehen gleichzeitig in System 1: eine Begegnung. Der falsche Weg sieht auch zwischen X und Y eine „Begegnung“: X kommt in 1 an, während Y noch dort ist und gerade aufbricht. Die Prüfung in der Schleife sieht einen halben Zug. Der richtige Weg berechnet zuerst, wo jeder stehen wird (next), aus dem alten Zustand, und setzt sie erst dann dorthin (pos = next).

Hier ist die Bewegung im Spiel. Die Liste moving ist „in der Luft“: Die Schiffe haben ihre Flotte schon verlassen, sind aber noch nicht angekommen:

// Eine Runde der Galaxie hat Phasen: Diplomatie der künstlichen Intelligenz, ihre Aufträge, Bewegung aller
// Flotten auf einmal, Kämpfe, Eroberung, Produktion und Forschung, ein Ereignis, Ende der Runde.
// Die Bewegung ist gleichzeitig: zuerst verlassen alle Schiffe ihre Systeme, erst dann kommen sie an.
function resolveMovement(world) {
const moving = [];
for (const empire of world.empires) {
if (!empire.alive) continue;
for (const order of empire.orders) {
const fleet = fleetAt(world, empire.id, order.from);
if (fleet === null) continue;
const ships = [0, 0, 0, 0];
for (let 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.push({ owner: empire.id, to: order.to, dest: order.dest, ships: ships });
}
empire.orders = [];
}
for (const move of moving) {
addShips(world, move.owner, move.to, move.ships);
// der lange Weg geht von selbst weiter: ein neuer Auftrag von dem Ort, wo sie angekommen sind
if (move.to !== move.dest) {
world.empires[move.owner].orders.push({ from: move.to, to: nextStep(world.systems, move.to, move.dest), dest: move.dest, ships: move.ships.slice() });
}
}
removeEmptyFleets(world);
}

Die Idee

Eine Schlacht simulieren

Die Schlacht ist eine reine Funktion, darum musst du sie nicht ansehen, um sie zu verstehen: Du kannst sie auf vielen Flotten laufen lassen und eine Tabelle lesen. So wird die Balance des Spiels eingestellt. Das Programm vergleicht gleiche Flotten von je 6 Schiffen auf mehreren Rollenpaaren:

const pairs = [[0, 1], [1, 2], [0, 2], [0, 3]];
for (const [first, second] of pairs) {
const a = new Array(KINDS).fill(0);
const b = new Array(KINDS).fill(0);
a[first] = 6;
b[second] = 6;
const head = countsText(a) + " gegen " + countsText(b) + ": ";
runBattle(a, b, "a", "b", []);
if (total(a) > 0 && total(b) === 0) console.log(head + "gewinnt " + LETTERS[first] + ", übrig " + countsText(a));
else if (total(b) > 0 && total(a) === 0) console.log(head + "gewinnt " + LETTERS[second] + ", übrig " + countsText(b));
else console.log(head + "kein Sieger");
}
Ausgabe
6I gegen 6B: gewinnt I, übrig 3I
6B gegen 6F: gewinnt B, übrig 4B
6I gegen 6F: gewinnt F, übrig 6F
6I gegen 6D: gewinnt D, übrig 6D

Das Dreieck zeigt sich in den Zahlen. Sechs Abfangjäger schlagen sechs Bomber und verlieren drei. Sechs Bomber schlagen sechs Fregatten und verlieren zwei. Sechs Fregatten vernichten sechs Abfangjäger ohne einen einzigen Verlust: Die Abfangjäger fallen schon in der ersten Runde. Das Schlachtschiff (Hülle 90, Angriff 18) ist ein anderer Fall: Sechs Schlachtschiffe vernichten sechs Abfangjäger, ebenfalls ohne Verlust, weil jedes von ihnen viel mehr aushält (im Spiel braucht es eine Technologie und mehr Ressourcen). „Was ist stärker?“ hat keine einzige Antwort: Es hängt von der Rolle des Feindes ab. Darum ist auch die Macht einer Flotte (die Summe der Mächte der Rollen) nur eine grobe Schätzung.

Das Spiel macht dasselbe mit ganzen Spielen: --simulate=200 lässt zweihundert Spiele ohne Bildschirm laufen und druckt, wer gewinnt. Das ist das Thema der letzten Lektion. Die Schlachten darin sind diese Funktion, tausendfach ausgeführt.

Probier es aus

Sage die Ausgabe voraus

Drei Bomber gegen drei Abfangjäger. Wer gewinnt, und wie viele Runden dauert die Schlacht? Starte das Programm nicht: Rechne die Runden von Hand mit den Tabellen der Lektion aus (Hülle 10 und Angriff 5 für den Abfangjäger, 16 und 7 für den Bomber) und vergiss die Wunden nicht.

const a = [0, 3, 0, 0, 0];
const b = [3, 0, 0, 0, 0];
const log = [];
runBattle(a, b, "Terranisches Reich", "Orion-Hegemonie", log);
for (const line of log) console.log(line);
Antwort

Probier es aus

Finde den Fehler

Ein Kollege wollte die Runde „aufräumen“: Sobald die Salve von a berechnet ist, wendet er sie sofort an und berechnet erst danach die Salve von b. So braucht er die Kopien für die Salven nicht. Ein Duell von zwei Abfangjägern gegen zwei Bomber druckt:

const a = [2, 0, 0, 0, 0];
const b = [0, 2, 0, 0, 0];
const log = [];
runBattle(a, b, "a", "b", log);
for (const line of log) console.log(line);
Ausgabe
Runde 1: 2I | 2B Verluste 0 / 1
Runde 2: 2I | 1B Verluste 0 / 1
Sieg für a. Übrig: 2I.

Die zweite Runde endet mit „Verluste 0 / 1“, und der Sieg lässt zwei Abfangjäger übrig. Vor der Änderung lautet die zweite Runde „Verluste 1 / 1“, und es bleibt ein Abfangjäger (Runde 2: 2I | 1B Verluste 1 / 1). Welche Zeile läuft zu früh?

Wähle die Zeile mit dem Fehler.

Genauer

Was dieses System offenlässt

Die Schlacht in der Lektion ist der Kern; im Spiel gibt es noch einiges, das am Prinzip nichts ändert.

Technologien geben Boni auf Angriff und Hülle (und zusätzliche für die Bomber und für die Fregatten). Sie kommen über stats von volley und applyDamage hinein: Dasselbe Array mit Schiffszahlen kämpft für zwei Imperien unterschiedlich. Darum gibt es den Parameter im Kanon und hier nicht.

Festungen sind eine fünfte Rolle (FORT_KIND): Die Planeten eines Imperiums in einem System geben ihm Stationen, die schießen und wie Schiffe fallen. Nach der Schlacht bleiben die überlebenden Stationen bis zum Ende des Zugs erhalten, und zu Beginn des nächsten sind sie wieder voll.

Mehr als zwei Imperien in einem System kämpfen paarweise: Für jedes Paar im Krieg, das dort Schiffe oder Stationen hat, wird eine Schlacht aufgerufen. Die Reihenfolge der Paare ist fest, darum ist das Ergebnis bestimmt.

Es gibt keinen Rückzug mitten in der Schlacht und keine Zielwahl. Ein Rückzug ist ein Bewegungsbefehl, der vor der Schlacht ausgeführt wird. Eine Gruppe schießt immer auf die günstigste Rolle; eine reichere Version würde dem Spieler einen Befehl „Fokus auf eine Rolle“ geben, aber dann wäre die Schlacht nicht mehr allein eine Funktion der zwei Flotten.

Verluste sind ganze Schiffe. Eine Wunde bleibt nur innerhalb der Schlacht: Danach ist ein verwundetes Schiff gesund, weil eine Flotte nur eine Anzahl ist. Ein Spiel mit Reparaturen würde die Wunde an der Flotte zwischen den Zügen behalten.

Übung

Eine Schlacht aus der Eingabe

Löse das in deinem Editor.

Eine Schlacht aus der Eingabe. Das Programm liest aus der Eingabe zwei Zeilen mit je fünf Zahlen: die Anzahl von I, B, F, D und # für die Flotte a, danach dasselbe für b. Es druckt die Zeilen der Schlacht wie in der Lektion: eine Zeile je Runde und die letzte Zeile mit dem Sieg (mit den Namen „a“ und „b“; ohne Überschrift).

Mit der Eingabe 5 0 4 0 0 | 0 6 2 0 0 (je eine Flotte pro Zeile) lautet die Ausgabe:

Ausgabe
Runde 1: 5I 4F | 6B 2F Verluste 4 / 2
Runde 2: 3I 2F | 4B 2F Verluste 4 / 3
Runde 3: 1I | 2B 1F Verluste 1 / 0
Sieg für b. Übrig: 2B 1F.

Achte auf die dritte Runde: a ist mit einem Abfangjäger gegen zwei Bomber und eine Fregatte übrig, und der Sieg gehört b, nicht a, obwohl a mit mehr Schiffen beginnt.

Weitere Aufgaben

  • Wie viele Abfangjäger braucht es? Ab wie vielen Abfangjägern (von 1 bis 30) schlagen sie 4 Fregatten? Drucke die erste Zahl, bei der a gewinnt. Probiere es auch mit Bombern gegen 4 Fregatten.
  • Reihenfolge tauschen. Lass dieselbe Schlacht als (a, b) und als (b, a) laufen. Ist das Ergebnis dasselbe? Warum?
  • Schlachtschiff. Gib a ein Schlachtschiff. Wie viele Abfangjäger braucht b, um es zu vernichten?
  • Leere Flotte. Was macht das Programm, wenn eine Seite keine Schiffe hat? Es darf nicht abstürzen.

Test

Teste dich

1. Warum werden beide Salven einer Runde aus der Schiffszahl zu Rundenbeginn berechnet und nicht nacheinander?
2. 6 Bomber (Angriff 7) schießen auf Fregatten. Der Multiplikator des Bombers gegen eine Fregatte ist 175%. Wie viel Schaden richten sie an?
3. Drei Abfangjäger (Hülle je 10) erhalten insgesamt 25 Schaden und haben keine alte Wunde. Was bleibt übrig?
4. Die Abfangjäger haben weder Bomber noch Abfangjäger vor sich, nur Fregatten und Schlachtschiffe. Ihre Multiplikatoren sind 50 gegen eine Fregatte und 50 gegen ein Schlachtschiff. Welches wählen sie?
5. Flotte x steht in System 1 und hat einen Befehl nach System 2. Eine feindliche Flotte y steht in System 2 und hat einen Befehl nach System 1. Die beiden Seiten sind im Krieg. Was geschieht im Zug?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·