Der Zug der Galaxie
Kurz gesagt: Der Zug der Galaxie ist eine Folge von Phasen in fester Reihenfolge; Flotten bewegen sich gleichzeitig (erst brechen alle auf, dann kommen sie an), Ereignisse werden nach Gewicht gezogen, Frieden und Krieg haben eine Regel von mindestens MIN_PEACE Zügen, und die Nachrichten und der Groll hören am Ereignisbus zu, ohne dass der Veröffentlichende von ihnen weiß.
Problem
Alle handeln gleichzeitig, und die Reihenfolge entscheidet
In einem rundenbasierten Strategiespiel tippt der Spieler end (oder wait 3 für drei Züge hintereinander), und die ganze Galaxie bewegt sich: Die Rivalen entscheiden, was sie tun, Flotten brechen auf, Schlachten finden statt, Planeten produzieren, manchmal tauchen Piraten auf. Was für den Spieler ein Tastendruck ist, ist im Code ein Zug: eine Folge von Phasen in fester Reihenfolge.
Die Reihenfolge ist keine Dekoration. Nimm das einfachste Beispiel: drei Flotten in einer Kette, A→B, B→C, C→D, und alle 3 Schiffe sind in A. Verarbeitest du die Befehle nacheinander und verschiebst die Schiffe im selben Array, kommen die Schiffe in einem Zug von A nach D: Der erste Befehl setzt sie nach B, der zweite sieht sie dort und schickt sie nach C weiter, der dritte nach D. Ein Spieler, der das sieht, versteht sofort, dass die Flotten Teleporter sind.
Diese Lektion baut den Zug einer kleinen Galaxie aus drei Reichen (A, B, C) und vier Systemen in einer Reihe, mit denselben vier Ideen wie im Kanon:
- die Phasen eines Zuges und die gleichzeitige Bewegung;
- Zufallsereignisse: wie oft sie kommen und welche häufiger;
- Frieden und Krieg: eine Regel, die verhindert, dass in jedem Zug Krieg erklärt und Frieden geschlossen wird;
- der Ereignisbus: wie die Nachrichten, der Groll und die Warnungen erfahren, was geschehen ist, ohne dass jemand jemanden aufruft.
Am Ende gibt es einen ganzen Zug aus Phasen, der ausgibt, was passiert, und eine Szene, die ihn zeigt.
Die Idee
Phasen und gleichzeitige Bewegung
Ein Zug ist eine Funktion, die Phasen nacheinander aufruft. Im Kanon sind das: die Diplomatie der KI, ihre Befehle, die Bewegung aller Flotten, Schlachten, Eroberung, Produktion und Forschung, das Ereignis, das Ende des Zuges. In unserer kleinen Galaxie sind es fünf: Diplomatie, Bewegung, Schlachten, Produktion, Ereignis.
Die Bewegung wird in zwei geteilt: Zuerst verlassen alle Flotten ihre Systeme, erst dann kommen sie an. Hier die beiden Wege an der Kette von oben. Ein Befehl ist [von, nach, Schiffe]; die 3 Schiffe sind im System 0:
// [von, nach, Schiffe]int[][] orders = [[0, 1, 3], [1, 2, 3], [2, 3, 3]];
int[] MoveOneByOne(int[] start, int[][] orders){ var ships = (int[])start.Clone(); foreach (int[] order in orders) { int take = Math.Min(order[2], ships[order[0]]); ships[order[0]] -= take; ships[order[1]] += take; } return ships;}
int[] MoveTogether(int[] start, int[][] orders){ var ships = (int[])start.Clone(); var takes = new int[orders.Length]; // Zuerst verlassen alle Flotten ihre Systeme... for (int i = 0; i < orders.Length; i++) { takes[i] = Math.Min(orders[i][2], ships[orders[i][0]]); ships[orders[i][0]] -= takes[i]; } // ...und erst dann kommen sie an. for (int i = 0; i < orders.Length; i++) ships[orders[i][1]] += takes[i]; return ships;}int[] start = [3, 0, 0, 0];Console.WriteLine("nacheinander: " + Show(MoveOneByOne(start, orders)));Console.WriteLine("gleichzeitig: " + Show(MoveTogether(start, orders)));nacheinander: 0 0 0 3gleichzeitig: 0 3 0 0Die Version nacheinander ist leise falsch: Das Programm stürzt nicht ab, die Flotten werden einfach schnell. Die gleichzeitige nimmt für jeden Befehl min(Schiffe des Befehls, Schiffe im System zu Beginn) und entfernt die Schiffe (Aufbrechen), danach fügt sie sie am Ziel hinzu (Ankommen). Der zweite Befehl (B→C) sieht in B null Schiffe, weil die ersten noch nicht angekommen sind, und bewegt nichts. Deshalb ist die Kette 0 3 0 0: Die Schiffe sind nur bis B gekommen.
(int[])start.Clone() macht eine Kopie: Ohne sie würde die Funktion das Array des Aufrufers ändern.Genauer, für Neugierige
Dieselbe Regel gilt für alles, was „gleichzeitig“ geschieht: zwei Reiche, die Systeme tauschen, zwei Flotten, die einander passieren. Bewegst du sie nacheinander, sieht die zweite die Welt, die die erste schon verändert hat, und das Ergebnis hängt von der Reihenfolge der Spieler ab. Die Regel der zwei Schritte (erst alles nehmen, dann alles zurücklegen) ist dieselbe wie beim Tauschen zweier Variablen mit einer Hilfsvariable: Man braucht Speicher für das „Alte“.
Im Kanon ist die Bewegung unserer noch ähnlicher, mit einer Ergänzung: Eine lange Reise setzt sich von selbst fort. Der Befehl merkt sich das Endziel (dest), und nach der Ankunft wird ein neuer Befehl für den nächsten Schritt eingetragen. Eine Flotte, die durch fünf Systeme geschickt wird, fliegt also fünf Züge, ein System pro Zug.
Die Idee
Zufallsereignisse nach Gewicht
In jedem Zug tritt mit 40 % Wahrscheinlichkeit ein Ereignis bei einem zufälligen Reich ein. Welches? Hätten alle Arten dieselbe Chance, kämen die Piraten schon im ersten Zug, wenn das Reich noch kein einziges Schiff hat. Deshalb hat jede Art ein Gewicht (wie häufig sie ist) und einen frühesten Zug (ab wann sie erlaubt ist). Hier die Tabelle des Kanons und die Auswahl:
// id, Gewicht, frühester ZugEventDef[] eventDefs =[ new EventDef("meteor", 20, 1), new EventDef("vein", 15, 1), new EventDef("pirates", 20, 6), new EventDef("breakthrough", 15, 1), new EventDef("unrest", 10, 5), new EventDef("boom", 15, 1), new EventDef("flare", 10, 1), new EventDef("derelict", 8, 3)];
int PickWeighted(Rng rng, int[] weights){ int total = 0; foreach (int weight in weights) total += weight; int roll = rng.NextInt(total); for (int i = 0; i < weights.Length; i++) { if (roll < weights[i]) return i; roll -= weights[i]; } return weights.Length - 1;}
Roll? RollEvent(Rng rng, int turn){ if (!rng.Chance(EventChance)) return null; int empire = rng.NextInt(names.Length); var weights = new int[eventDefs.Length]; for (int i = 0; i < weights.Length; i++) { weights[i] = turn >= eventDefs[i].MinTurn ? eventDefs[i].Weight : 0; } return new Roll(PickWeighted(rng, weights), empire);}Die Auswahl nach Gewicht ist ein „Roulette“: Eine Zahl von 0 bis zur Summe der Gewichte wird gezogen, und man geht die Arten entlang und zieht jeweils das Gewicht ab, bis die Zahl kleiner als das aktuelle Gewicht ist. Eine Art, die noch nicht erlaubt ist, bekommt das Gewicht 0 und kommt nie vor, und die Summe schrumpft, sodass die anderen wahrscheinlicher werden. Das Programm lässt zwölf Züge laufen und danach noch tausend, um zu zeigen, wie oft jede Art vorkommt:
var rng = new Rng(7);for (int turn = 1; turn <= 12; turn++){ Roll? roll = RollEvent(rng, turn); if (roll != null) Console.WriteLine("Zug " + turn + ": " + eventDefs[roll.Kind].Id + " → " + names[roll.Empire]);}
var counts = new int[eventDefs.Length];for (int i = 0; i < 1000; i++){ Roll? roll = RollEvent(rng, 99); if (roll != null) counts[roll.Kind]++;}Console.WriteLine("Ereignisse in 1000 Zügen:");for (int i = 0; i < counts.Length; i++){ Console.WriteLine(eventDefs[i].Id + " " + eventDefs[i].Weight + ": " + counts[i]);}Zug 1: boom → Lyra-UnionZug 2: breakthrough → Vega-AkademieZug 3: vein → Vega-AkademieZug 4: boom → Vega-AkademieZug 8: pirates → Lyra-UnionZug 9: breakthrough → Terranisches ReichEreignisse in 1000 Zügen:meteor 20: 70vein 15: 53pirates 20: 78breakthrough 15: 58unrest 10: 30boom 15: 50flare 10: 28derelict 8: 28Die Piraten (frühester Zug 6) zeigen sich erst in Zug 8. In den tausend Zügen kommen die Piraten (78) und die Meteoriten (70) mit Gewicht 20 am häufigsten vor, die Sonneneruption (28) und die verlassenen Schiffe (28) mit den Gewichten 10 und 8 am seltensten. Die Anzahlen sind nicht gleich den Gewichten, weil es Zufall ist, aber ihre Reihenfolge ist dieselbe. Die Chance auf ein Ereignis ist 40 % (EVENT_CHANCE), und der Generator hat einen Startwert: Dieselbe Eingabe gibt in allen vier Sprachen dieselbe Ausgabe, deshalb lässt sich eine Aufzeichnung des Spiels wiederholen.
Genauer, für Neugierige
Im Kanon tut jedes Ereignis etwas anderes: Meteoriten nehmen Metall, eine Ader erhöht den Reichtum eines Planeten, Piraten kämpfen gegen die Flotte des Reiches (und Ressourcen werden gewonnen oder verloren), ein Durchbruch gibt Forschungspunkte, Unruhen senken eine Minenstufe, ein Boom gibt Gas und Kristall, eine Eruption legt die Labore für einen Zug lahm, verlassene Schiffe geben zwei Fregatten. Hier alle (die Region events des Kanons). Sieh dir das Ende von rollEvent an: Das Ergebnis wird nicht auf den Bildschirm geschrieben, es wird veröffentlicht. Was das heißt, steht in der Karte über den Bus.
// Die Zufallsereignisse: in jeder Runde gibt es 40 % Chance, dass eines einem zufälligen Reich geschieht. Die Art ist nach// Gewichten (erst ab ihrer frühesten Runde). Alles wird auf dem Bus veröffentlicht; die Nachrichten, die Statistik// und der Groll sind Abonnenten, und die Ereignisse wissen nichts von ihnen.static partial class Cosmos{ public const int EVENT_CHANCE = 40;
public static Planet? PickEmpirePlanet(World world, Empire empire) { List<Planet> list = OwnedPlanets(world, empire.Id); if (list.Count == 0) return null; return list[world.Rng.NextInt(list.Count)]; }
public static int[] PirateFleet(World world) { int strength = world.Turn / 6; return new int[] { 2 + strength, strength / 2, strength / 3, 0, 0 }; }
public static List<(string Name, object Value)>? ApplyEvent(World world, string kind, Empire empire) { Random rng = world.Rng; List<(string Name, object Value)> vars = new List<(string Name, object Value)> { ("empire", EmpireName(empire)) }; if (kind == "meteor") { Planet? planet = PickEmpirePlanet(world, empire); if (planet == null) return null; int loss = MinOf(empire.Res[0], 8 + rng.NextInt(8)); empire.Res[0] -= loss; vars.Add(("planet", PlanetName(world, planet))); vars.Add(("n", loss)); } else if (kind == "vein") { Planet? planet = PickEmpirePlanet(world, empire); if (planet == null) return null; int r = rng.NextInt(RES_COUNT); int tries = 0; while (planet.Rich[r] >= 5 && tries < RES_COUNT) { r = (r + 1) % RES_COUNT; tries++; } if (planet.Rich[r] >= 5) return null; planet.Rich[r]++; vars.Add(("planet", PlanetName(world, planet))); vars.Add(("res", T("res." + r))); } else if (kind == "pirates") { Planet? planet = PickEmpirePlanet(world, empire); if (planet == null) return null; int systemId = planet.SystemId; int[] mine = EmptyCounts(); Fleet? fleet = FleetAt(world, empire.Id, systemId); for (int k = 0; k < SHIP_KIND_COUNT; k++) mine[k] = fleet == null ? 0 : fleet.Ships[k]; mine[FORT_KIND] = FortsIn(world, empire.Id, systemId); BattleResult result = RunBattle(SideStatsOf(empire), mine, SideStatsOf(null), PirateFleet(world)); for (int k = 0; k < SHIP_KIND_COUNT; k++) { if (fleet != null) fleet.Ships[k] = result.A[k]; } string systemName = world.Systems[systemId].Name; vars.Add(("system", systemName)); vars.Add(("fleet", CountsText(PirateFleet(world)))); if (result.Winner == "a") { empire.Res[0] += 20; empire.Res[1] += 10; vars.Add(("outcome", T("event.pirates.win"))); } else { int m = empire.Res[0] / 5; int g = empire.Res[1] / 5; empire.Res[0] -= m; empire.Res[1] -= g; vars.Add(("outcome", T("event.pirates.lose", ("m", m), ("g", g)))); } if (!world.Quiet) world.LastBattle = BattleLines(systemName, EmpireName(empire), T("pirates"), result); world.Stats.Battles++; world.Stats.Lost += TotalOf(mine) - TotalOf(result.A) + TotalOf(PirateFleet(world)) - TotalOf(result.B); } else if (kind == "breakthrough") { int n = 6 + rng.NextInt(7); empire.Progress += n; vars.Add(("n", n)); } else if (kind == "unrest") { Planet? planet = PickEmpirePlanet(world, empire); if (planet == null || planet.Mines == 0) return null; planet.Mines--; vars.Add(("planet", PlanetName(world, planet))); } else if (kind == "boom") { empire.Res[1] += 20; empire.Res[2] += 10; } else if (kind == "flare") { empire.Flare = 1; } else if (kind == "derelict") { Planet? planet = PickEmpirePlanet(world, empire); if (planet == null) return null; AddShips(world, empire.Id, planet.SystemId, new int[] { 0, 0, 2, 0 }); vars.Add(("system", world.Systems[planet.SystemId].Name)); } return vars; }
public static void RollEvent(World world) { Random rng = world.Rng; if (!rng.Chance(EVENT_CHANCE)) return; List<Empire> living = new List<Empire>(); foreach (Empire e in world.Empires) { if (e.Alive) living.Add(e); } if (living.Count == 0) return; Empire empire = living[rng.NextInt(living.Count)]; List<EventDef> allowed = new List<EventDef>(); List<int> weights = new List<int>(); foreach (EventDef eventDef in EVENT_DEFS) { if (world.Turn >= eventDef.MinTurn) { allowed.Add(eventDef); weights.Add(eventDef.Weight); } } EventDef def = allowed[PickWeighted(rng, weights.ToArray())]; List<(string Name, object Value)>? vars = ApplyEvent(world, def.Id, empire); if (vars == null) return; world.Stats.Events++; world.Bus.Publish(new GameEvent { Type = "event", Kind = def.Id, Empire = empire.Id, Vars = vars }); }
// Die Abonnenten: die Nachrichten für den Spieler, der Groll nach Kämpfen und Eroberung. public static void AddNews(World world, string text) { world.News.Add(world.Turn + ": " + text); while (world.News.Count > 60) world.News.RemoveAt(0); world.TurnLines.Add(text); }
public static void InstallSubscribers(World world, bool quiet) { EventBus bus = world.Bus; Func<int, int, bool> involvesHuman = (a, b) => world.Empires[a].Human || world.Empires[b].Human; bus.Subscribe("battle", e => { if (e.LostA > 0) world.Empires[e.B].Grudge[e.A]++; if (e.LostB > 0) world.Empires[e.A].Grudge[e.B]++; }); bus.Subscribe("conquest", e => { if (e.From >= 0) world.Empires[e.From].Grudge[e.Empire] += 3; }); // Ein Signal an den Spieler: etwas ist geschehen, bei dem „wait“ anhält. bus.Subscribe("battle", e => { if (involvesHuman(e.A, e.B)) world.Alert++; }); bus.Subscribe("conquest", e => { if (world.Empires[e.Empire].Human || (e.From >= 0 && world.Empires[e.From].Human)) world.Alert++; }); bus.Subscribe("war", e => { if (involvesHuman(e.A, e.B)) world.Alert++; }); bus.Subscribe("siege", e => { if (world.Empires[e.From].Human) world.Alert++; }); bus.Subscribe("offer", e => { if (world.Empires[e.To].Human) world.Alert++; }); if (quiet) return; bus.Subscribe("battle", e => { if (involvesHuman(e.A, e.B)) { foreach (string line in e.Lines) AddNews(world, line); } else { AddNews(world, T("news.battle", ("system", world.Systems[e.SystemId].Name), ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B])), ("la", e.LostA), ("lb", e.LostB))); } }); bus.Subscribe("colony", e => { AddNews(world, T("news.colony", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId])))); }); bus.Subscribe("tech", e => { if (world.Empires[e.Empire].Human) AddNews(world, T("news.tech", ("tech", Nm(TECHS.Get(e.TechId))))); }); bus.Subscribe("war", e => { AddNews(world, T("news.war", ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B])))); }); bus.Subscribe("peace", e => { AddNews(world, T("news.peace", ("a", EmpireName(world.Empires[e.A])), ("b", EmpireName(world.Empires[e.B])))); }); bus.Subscribe("offer", e => { AddNews(world, T("news.offer", ("empire", EmpireName(world.Empires[e.From])), ("letter", world.Empires[e.From].Def.Letter))); }); bus.Subscribe("siege", e => { AddNews(world, T("news.siege", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId])))); }); bus.Subscribe("conquest", e => { AddNews(world, T("news.conquest", ("empire", EmpireName(world.Empires[e.Empire])), ("planet", PlanetName(world, world.Planets[e.PlanetId])))); }); bus.Subscribe("eliminated", e => { AddNews(world, T("news.eliminated", ("empire", EmpireName(world.Empires[e.Empire])))); }); bus.Subscribe("event", e => { AddNews(world, T("event." + e.Kind, e.Vars.ToArray())); }); }}Die Idee
Frieden und Krieg: eine Regel gegen das Chaos
Diplomatie im Spiel ist eine Beziehung zwischen zwei Reichen: Ein Paar von Reichen ist im Krieg oder im Frieden (war[a][b]). Was ist daran schwer? Ohne Regel würde ein Reich in einem Zug Krieg erklären, im nächsten Frieden schließen und gleich danach wieder Krieg erklären. Deshalb:
- Frieden gilt mindestens MIN_PEACE =
4Züge, bevor ein neuer Krieg möglich ist (turn − since < MIN_PEACE; am Anfang des Spiels gilt die Regel nicht, damit jemand beginnen kann); - ein Friedensangebot ist frühestens zwei Züge nach dem Wechsel erlaubt;
- die KI nimmt Frieden nach ihrem Wunsch an: wie viel schwächer sie als der andere ist, welchen Charakter sie hat, wie viele andere Kriege sie führt und wie nachtragend sie ist. Ab 90 nimmt sie an.
Im Programm ist der Wunsch vereinfacht: Charakter + (150 − Verhältnis der Schiffe) / 2 + andere Kriege × 15 − Groll × 3, wobei der Charakter bei Orion (Kriegsherr) 40 ist und bei den beiden anderen (Baumeister) 120. Eine Ablehnung erhöht den Groll, deshalb ist der nächste Versuch schwerer. Das Skript ist die Geschichte der Szene: Orion (B) erklärt dem Terranischen Reich (A) in Zug 1 den Krieg, Lyra (C) Orion in Zug 3, und A bietet Frieden an:
class World{ public int Turn = 1; public int[,] Ships = new int[3, 4]; public int[,] War = new int[3, 3]; public int[,] Since = new int[3, 3]; public int[,] Grudge = new int[3, 3];}
bool AtWar(World world, int a, int b){ return world.War[a, b] == 1;}
int Power(World world, int empire){ int total = 0; for (int s = 0; s < 4; s++) total += world.Ships[empire, s]; return total;}
void SetStance(World world, int a, int b, int war){ world.War[a, b] = war; world.War[b, a] = war; world.Since[a, b] = world.Turn; world.Since[b, a] = world.Turn;}
string? DeclareWar(World world, int a, int b){ if (AtWar(world, a, b)) return "alreadyWar"; // Frieden gilt mindestens MIN_PEACE Züge. if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon"; SetStance(world, a, b, 1); return null;}
void MakePeace(World world, int a, int b){ SetStance(world, a, b, 0); world.Grudge[a, b] = 0; world.Grudge[b, a] = 0;}
int PeaceWish(World world, int me, int other){ // Wie viel schwächer das Reich als das andere ist. int ratio = Math.Min(150, Power(world, me) * 100 / (Power(world, other) + 1)); int otherWars = 0; for (int x = 0; x < 3; x++) { if (x != me && x != other && AtWar(world, me, x)) otherWars++; } return Math.Max(0, peace[me] + (150 - ratio) / 2 + otherWars * 15 - world.Grudge[me, other] * 3);}
string? ProposePeace(World world, int a, int b){ if (!AtWar(world, a, b)) return "noWar"; if (world.Turn - world.Since[a, b] < 2) return "tooSoon"; // Ab 90 nimmt das Reich Frieden an. if (PeaceWish(world, b, a) >= 90) { MakePeace(world, a, b); return null; } // Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer. world.Grudge[b, a]++; return "refused";}void Step(World world, int turn, string kind, int a, int b){ world.Turn = turn; int wish = kind == "peace" ? PeaceWish(world, b, a) : 0; string? error = kind == "war" ? DeclareWar(world, a, b) : ProposePeace(world, a, b); string shown = kind == "peace" && (error == null || error == "refused") ? " (Wunsch " + wish + ")" : ""; Console.WriteLine("Zug " + turn + ": " + kind + " " + names[a] + " → " + names[b] + ": " + (error ?? "ok") + shown);}
var world = new World();world.Ships[1, 1] = 1;world.Grudge[1, 0] = 1;Step(world, 1, "war", 1, 0);Step(world, 2, "war", 1, 0);Step(world, 2, "peace", 0, 1);Step(world, 3, "war", 2, 1);Step(world, 3, "peace", 0, 1);world.Ships[1, 1] = 0;Step(world, 4, "peace", 0, 1);Step(world, 5, "war", 1, 0);Step(world, 9, "war", 1, 0);Zug 1: war Orion-Hegemonie → Terranisches Reich: okZug 2: war Orion-Hegemonie → Terranisches Reich: alreadyWarZug 2: peace Terranisches Reich → Orion-Hegemonie: tooSoonZug 3: war Lyra-Union → Orion-Hegemonie: okZug 3: peace Terranisches Reich → Orion-Hegemonie: refused (Wunsch 77)Zug 4: peace Terranisches Reich → Orion-Hegemonie: ok (Wunsch 124)Zug 5: war Orion-Hegemonie → Terranisches Reich: tooSoonZug 9: war Orion-Hegemonie → Terranisches Reich: okZug 2: Orion erklärt demselben Gegner noch einmal den Krieg (alreadyWar), und A bietet Frieden schon einen Zug nach dem Krieg an (tooSoon). In Zug 3 bietet A Frieden an, aber Orions Wunsch ist 40 + 25 + 15 − 3 = 77 (Charakter, ein Schiff gegen keines, ein weiterer Krieg mit Lyra, Groll 1) und liegt unter 90: abgelehnt, und der Groll wird 2. In Zug 4 hat Orion keine Schiffe, und sein Wunsch ist 40 + 75 + 15 − 6 = 124: Es nimmt an. Zug 5: Orion will einen neuen Krieg, aber der Frieden stammt aus Zug 4, und 5 − 4 = 1 ist weniger als 4: tooSoon. In Zug 9 ist es erlaubt (9 − 4 = 5).
bool AtWar(World world, int a, int b)
{
return world.War[a, b] == 1;
}
int Power(World world, int empire)
{
int total = 0;
for (int s = 0; s < 4; s++) total += world.Ships[empire, s];
return total;
}
void SetStance(World world, int a, int b, int war)
{
world.War[a, b] = war;
world.War[b, a] = war;
world.Since[a, b] = world.Turn;
world.Since[b, a] = world.Turn;
}
string? DeclareWar(World world, int a, int b)
{
if (AtWar(world, a, b)) return "alreadyWar";
// Frieden gilt mindestens MIN_PEACE Züge.
if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon";
SetStance(world, a, b, 1);
return null;
}
void MakePeace(World world, int a, int b)
{
SetStance(world, a, b, 0);
world.Grudge[a, b] = 0;
world.Grudge[b, a] = 0;
}
int PeaceWish(World world, int me, int other)
{
// Wie viel schwächer das Reich als das andere ist.
int ratio = Math.Min(150, Power(world, me) * 100 / (Power(world, other) + 1));
int otherWars = 0;
for (int x = 0; x < 3; x++)
{
if (x != me && x != other && AtWar(world, me, x)) otherWars++;
}
return Math.Max(0, peace[me] + (150 - ratio) / 2 + otherWars * 15 - world.Grudge[me, other] * 3);
}
string? ProposePeace(World world, int a, int b)
{
if (!AtWar(world, a, b)) return "noWar";
if (world.Turn - world.Since[a, b] < 2) return "tooSoon";
// Ab 90 nimmt das Reich Frieden an.
if (PeaceWish(world, b, a) 90)
{
MakePeace(world, a, b);
return null;
}
// Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer.
world.Grudge[b, a]++;
return "refused";
}Die Schwelle ist „ab 90“, d. h. 90 genügt: >=. Mit > wäre ein Wunsch von genau 90 eine Ablehnung, während die Regel im Spiel das Gegenteil sagt.
Genauer, für Neugierige
Das Ergebnis dieser Funktionen ist ein String oder nichts: null, wenn der Wechsel stattgefunden hat, und ein Fehlerschlüssel (alreadyWar, tooSoon, refused, noWar), wenn nicht. Die Fehler sind Schlüssel, keine fertigen Sätze, damit der Bildschirm sie übersetzen kann (das Spiel gibt es in vier Sprachen). So bedient dieselbe Funktion den Spieler (der Befehl peace B) und die KI.
Der Kanon hat auch Angebote an den Spieler: Die KI kann mit einem Menschen nicht von selbst Frieden schließen, deshalb trägt sie ein Angebot ein (offers), das drei Züge gilt, und veröffentlicht ein „offer“-Ereignis. Hier die ganze Region diplomacy:
// Diplomatie ist Frieden oder Krieg zwischen zwei Reichen. Frieden gilt mindestens MIN_PEACE Runden vor// einem neuen Krieg. Die künstliche Intelligenz nimmt Frieden nach einem Maß an: wie viel schwächer sie ist, welchen Charakter sie hat// und wie verbittert sie ist. Ein Angebot an den Spieler wird vermerkt und gilt drei Runden.static partial class Cosmos{ public static bool AtWar(World world, int a, int b) { return world.Empires[a].War[b] == 1; }
public static void SetStance(World world, int a, int b, int war) { world.Empires[a].War[b] = war; world.Empires[b].War[a] = war; world.Empires[a].Since[b] = world.Turn; world.Empires[b].Since[a] = world.Turn; }
public static string? DeclareWar(World world, int a, int b) { if (a == b) return "dip.self"; if (!world.Empires[b].Alive) return "dip.dead"; if (AtWar(world, a, b)) return "dip.alreadyWar"; if (world.Turn - world.Empires[a].Since[b] < MIN_PEACE && world.Turn > MIN_PEACE) return "dip.tooSoon"; SetStance(world, a, b, 1); world.Bus.Publish(new GameEvent { Type = "war", A = a, B = b }); return null; }
public static void MakePeace(World world, int a, int b) { SetStance(world, a, b, 0); world.Empires[a].Grudge[b] = 0; world.Empires[b].Grudge[a] = 0; world.Bus.Publish(new GameEvent { Type = "peace", A = a, B = b }); }
// Der kleinste Abstand in Wegen zwischen Systemen mit Planeten der beiden Reiche. public static int EmpireDistance(World world, int a, int b) { int best = 99; foreach (Planet pa in world.Planets) { if (pa.Owner != a) continue; int[] dist = Bfs(world.Systems, pa.SystemId).Dist; foreach (Planet pb in world.Planets) { if (pb.Owner == b && dist[pb.SystemId] >= 0 && dist[pb.SystemId] < best) best = dist[pb.SystemId]; } } return best; }
public static int PowerRatio(World world, int a, int b, int top) { return Clamp(Percent((long)EmpirePower(world, a) * 100, (long)EmpirePower(world, b) * 100 + 100), 0, top); }
// Wie sehr das Reich Frieden mit dem anderen will (0 und mehr; ab 90 nimmt es an). public static int PeaceWish(World world, int me, int other) { Empire e = world.Empires[me]; int weaker = 150 - PowerRatio(world, me, other, 150); int otherWars = 0; foreach (Empire x in world.Empires) { if (x.Alive && x.Id != me && x.Id != other && e.War[x.Id] == 1) otherWars++; } int wish = e.Personality.Peace + weaker / 2 + otherWars * 15 - e.Grudge[other] * 3; return MaxOf(0, wish); }
// Wie sehr das Reich Krieg mit dem anderen will. public static int WarWish(World world, int me, int other) { Empire e = world.Empires[me]; int ratio = PowerRatio(world, me, other, 300); int wish = e.Personality.Attack / 2; if (ratio > 100) wish += (ratio - 100) / 4; else wish -= (100 - ratio) / 2; if (EmpireDistance(world, me, other) <= 3) wish += 25; foreach (Empire x in world.Empires) { if (x.Alive && x.Id != me && e.War[x.Id] == 1) wish -= 30; } return wish; }
public static bool HasOffer(World world, int from, int to) { foreach (Offer offer in world.Offers) { if (offer.From == from && offer.To == to && world.Turn - offer.Turn <= 3) return true; } return false; }
// Ein Friedensangebot von a an b. Gibt einen Fehlerschlüssel oder null zurück (Frieden ist geschlossen). public static string? ProposePeace(World world, int a, int b) { if (a == b) return "dip.self"; if (!world.Empires[b].Alive) return "dip.dead"; if (!AtWar(world, a, b)) return "dip.noWar"; if (world.Turn - world.Empires[a].Since[b] < 2) return "dip.tooSoon"; if (world.Empires[b].Human) { if (HasOffer(world, b, a)) { MakePeace(world, a, b); return null; } if (!HasOffer(world, a, b)) world.Offers.Add(new Offer { From = a, To = b, Turn = world.Turn }); return "dip.sent"; } if (HasOffer(world, b, a) || PeaceWish(world, b, a) >= 90) { MakePeace(world, a, b); return null; } world.Empires[b].Grudge[a]++; return "dip.refused"; }
// Die Diplomatie der künstlichen Intelligenz am Rundenende: Frieden, wenn schwächer, Krieg, wenn stärker. public static void AiDiplomacy(World world, Empire empire) { foreach (Empire other in world.Empires) { if (!other.Alive || other.Id == empire.Id) continue; if (AtWar(world, empire.Id, other.Id)) { if (world.Turn - empire.Since[other.Id] >= 3 && PeaceWish(world, empire.Id, other.Id) >= 90) { if (other.Human) { if (!HasOffer(world, empire.Id, other.Id)) { world.Offers.Add(new Offer { From = empire.Id, To = other.Id, Turn = world.Turn }); world.Bus.Publish(new GameEvent { Type = "offer", From = empire.Id, To = other.Id }); } } else { ProposePeace(world, empire.Id, other.Id); } } } else if (world.Turn - empire.Since[other.Id] >= MIN_PEACE + 2 && world.Turn > MIN_PEACE + 2) { if (WarWish(world, empire.Id, other.Id) >= 95 && world.Rng.Chance(40)) DeclareWar(world, empire.Id, other.Id); } } }}Die Idee
Ein Ereignisbus: Wer veröffentlicht, weiß nicht, wer zuhört
Wird Krieg erklärt, muss viel geschehen: Eine Nachricht muss geschrieben werden, der Groll der beiden Reiche muss wachsen, für den Spieler muss eine Warnung aufleuchten, der Krieg muss in der Statistik gezählt werden. Am einfachsten ruft declareWar all das auf. Dann kennt declareWar die Nachrichten, den Groll, die Warnung und die Statistik, und jede neue Fähigkeit ist eine weitere Zeile darin und ein weiterer Grund, dass es kaputtgeht. Der Bus kehrt die Richtung um: declareWar sagt „zwischen A und B wurde Krieg erklärt“ und ist fertig. Wer sich dafür interessiert, hat „war“ abonniert.
record Event(string Type, int A, int B, int Place, int N){ public Event(string type, int a, int b) : this(type, a, b, 0, 0) { }}
class EventBus{ public Dictionary<string, List<Action<Event>>> Handlers = new Dictionary<string, List<Action<Event>>>();
public void Subscribe(string type, Action<Event> handler) { if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<Event>>(); Handlers[type].Add(handler); }
public void Publish(Event gameEvent) { if (!Handlers.ContainsKey(gameEvent.Type)) return; foreach (Action<Event> handler in new List<Action<Event>>(Handlers[gameEvent.Type])) handler(gameEvent); }}subscribe trägt einen Handler für eine Ereignisart ein, publish ruft alle Handler dieser Art auf. Eine Art ohne Abonnenten ist kein Fehler: Das Ereignis verpufft. Hier die Veröffentlichung an den beiden Stellen, an denen sie geschieht (Kriegserklärung und Friedensschluss). Neu ist nur die Zeile mit publish:
string? DeclareWar(World world, int a, int b){ if (AtWar(world, a, b)) return "alreadyWar"; // Frieden gilt mindestens MIN_PEACE Züge. if (world.Turn - world.Since[a, b] < MinPeace && world.Turn > MinPeace) return "tooSoon"; SetStance(world, a, b, 1); world.Bus.Publish(new Event("war", a, b)); return null;}
void MakePeace(World world, int a, int b){ SetStance(world, a, b, 0); world.Grudge[a, b] = 0; world.Grudge[b, a] = 0; world.Bus.Publish(new Event("peace", a, b));}Und hier das Programm. Die Nachrichten hören auf alle Arten, der Groll nur auf Schlachten, und report veröffentlicht auch die gescheiterten Versuche (refused, tooSoon) als Ereignisse:
var world = new World();world.Ships[1, 1] = 1;world.Grudge[1, 0] = 1;// Die Nachrichten hören auf jede Art von Ereignis, der Groll nur auf Schlachten.foreach (string type in newsTypes){ world.Bus.Subscribe(type, e => Console.WriteLine(NewsText(e)));}world.Bus.Subscribe("battle", e =>{ world.Grudge[e.A, e.B]++; world.Grudge[e.B, e.A]++;});
world.Turn = 1;Report(world, DeclareWar(world, 1, 0), 1, 0);world.Bus.Publish(new Event("battle", 1, 0, 1, 2));world.Turn = 3;Report(world, ProposePeace(world, 0, 1), 0, 1);Report(world, DeclareWar(world, 1, 0), 1, 0);Console.WriteLine("Groll " + names[0] + " → " + names[1] + ": " + world.Grudge[0, 1]);Console.WriteLine("Groll " + names[1] + " → " + names[0] + ": " + world.Grudge[1, 0]);Kriegserklärung: Orion-Hegemonie → Terranisches Reich.Schlacht bei Vesta: Orion-Hegemonie und Terranisches Reich verlieren Schiffe (je 2).Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie.Schon im Krieg: Orion-Hegemonie → Terranisches Reich.Groll Terranisches Reich → Orion-Hegemonie: 1Groll Orion-Hegemonie → Terranisches Reich: 3Der Versuch in Zug 1 gelingt, die Schlacht wird von Hand veröffentlicht, die Ablehnung in Zug 3 ist ein Ereignis (refused), und der wiederholte Krieg ist alreadyWar. Der Groll von A gegenüber B ist 1 (von der Schlacht), der von B gegenüber A ist 3: einer alt, einer von der Schlacht und einer von der Ablehnung (eine Ablehnung erhöht den Groll dessen, der ablehnt, gegenüber dem, der angeboten hat).
Action<Event> ist ein Delegate mit einem Parameter: Der Handler ist ein Lambda, e => ....Genauer, für Neugierige
Ohne Bus braucht alles Neue, das von einem Krieg erfahren soll, eine Änderung in declareWar. Mit dem Bus ist ein neuer Abonnent nur eine neue Zeile beim Erzeugen der Welt, und die Kriegsfunktion wird nicht angefasst. Das ist das Muster Beobachter (Observer) aus dem Modul über Entwurfsmuster.
Ein kleines, aber wichtiges Detail: publish läuft über eine Kopie der Liste der Abonnenten. Ein Handler kann ein anderes Ereignis abonnieren, während er aufgerufen wird; ändert sich die Liste während der Schleife, werden Abonnenten übersprungen oder wiederholt. Die Kopie macht die Regel klar: Ein neuer Abonnent hört ab der nächsten Veröffentlichung.
Die Reihenfolge der Abonnenten ist die Reihenfolge von subscribe. Hier spielt sie keine Rolle (die Nachrichten und der Groll stören einander nicht), aber wenn zwei Abonnenten voneinander abhängen, entscheidet die Reihenfolge über den Ausgang, und das ist ein Grund, sie unabhängig zu halten.
Die Idee
Der ganze Zug
Jetzt alles zusammen. Die Welt ist World: Zug, Phase, Generator, Bus, die Flotten der drei Reiche (nach Systemen), Metall und die Tabellen für Krieg (war), seit welchem Zug (since) und Groll (grudge):
class World{ public int Turn = 1; public string Phase = ""; public Rng Rng; public EventBus Bus = new EventBus(); public int[,] Ships = new int[,] { { 3, 0, 0, 0 }, { 0, 0, 0, 4 }, { 0, 2, 0, 0 } }; public int[] Metal = [50, 50, 50]; public int[,] War = new int[3, 3]; public int[,] Since = new int[3, 3]; public int[,] Grudge = new int[3, 3];
public World(uint seed) { Rng = new Rng(seed); }}Die Befehle und die Diplomatie jedes Zuges sind ein Skript (im Spiel entscheiden sie die KI oder der Spieler): Ein Befehl ist [Zug, Reich, von, nach, Schiffe], ein Diplomatie-Eintrag [Zug, Art, von, nach].
// [Zug, Reich, von, nach, Schiffe]int[][] orders = [[1, 1, 3, 2, 4], [1, 0, 0, 1, 3], [2, 1, 2, 1, 4]];// [Zug, Art (0 Krieg, 1 Frieden), von, nach]int[][] diplo = [[1, 0, 1, 0], [3, 0, 2, 1], [3, 1, 0, 1], [4, 1, 0, 1], [5, 0, 1, 0]];Die Phasen sind Funktionen. Die Diplomatie führt das Skript des Zuges aus und veröffentlicht die Fehler. Die Bewegung sind genau die zwei Schritte aus der ersten Karte. Die Schlachten laufen über die Systeme: Wo zwei Reiche im Krieg Schiffe haben, verliert jedes so viele, wie die kleinere Flotte hat, und eine „battle“ wird veröffentlicht (der Groll abonniert sie):
void ScriptedDiplomacy(World world){ world.Phase = "Diplomatie"; foreach (int[] step in diplo) { if (step[0] != world.Turn) continue; string? error = step[1] == 0 ? DeclareWar(world, step[2], step[3]) : ProposePeace(world, step[2], step[3]); Report(world, error, step[2], step[3]); }}void ResolveMovement(World world){ world.Phase = "Bewegung"; var moving = new List<Move>(); // Zuerst verlassen alle Flotten ihre Systeme... foreach (int[] order in orders) { if (order[0] != world.Turn) continue; int take = Math.Min(order[4], world.Ships[order[1], order[2]]); world.Ships[order[1], order[2]] -= take; if (take > 0) moving.Add(new Move(order[1], order[2], order[3], take)); } // ...und erst dann kommen sie an. foreach (Move move in moving) { world.Ships[move.Empire, move.To] += move.N; world.Bus.Publish(new Event("move", move.Empire, move.From, move.To, move.N)); }}void ResolveBattles(World world){ world.Phase = "Schlachten"; for (int system = 0; system < 4; system++) { for (int a = 0; a < 3; a++) { for (int b = a + 1; b < 3; b++) { if (!AtWar(world, a, b) || world.Ships[a, system] == 0 || world.Ships[b, system] == 0) continue; int lost = Math.Min(world.Ships[a, system], world.Ships[b, system]); world.Ships[a, system] -= lost; world.Ships[b, system] -= lost; world.Bus.Publish(new Event("battle", a, b, system, lost)); } } }}
void Produce(World world){ world.Phase = "Produktion"; for (int empire = 0; empire < 3; empire++) world.Metal[empire] += 10;}Danach kommt das Ereignis: eine Ziehung nach den Gewichten aus der zweiten Karte, mit diesen Wirkungen: Ein Meteorit nimmt Metall, ein Boom gibt Metall, verlassene Schiffe geben zwei Schiffe in der Hauptstadt. Schließlich ruft runTurn sie der Reihe nach auf:
void RollEvent(World world){ world.Phase = "Ereignis"; if (!world.Rng.Chance(EventChance)) return; int empire = world.Rng.NextInt(3); var weights = new int[eventDefs.Length]; for (int i = 0; i < weights.Length; i++) { weights[i] = world.Turn >= eventDefs[i].MinTurn ? eventDefs[i].Weight : 0; } string kind = eventDefs[PickWeighted(world.Rng, weights)].Id; if (kind == "meteor") { int loss = Math.Min(world.Metal[empire], 8 + world.Rng.NextInt(8)); world.Metal[empire] -= loss; world.Bus.Publish(new Event("meteor", empire, 0, 0, loss)); } else if (kind == "boom") { world.Metal[empire] += 20; world.Bus.Publish(new Event("boom", empire, 0, 0, 20)); } else { world.Ships[empire, home[empire]] += 2; world.Bus.Publish(new Event("derelict", empire, 0, home[empire], 2)); }}void RunTurn(World world){ // Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig. ScriptedDiplomacy(world); ResolveMovement(world); ResolveBattles(world); Produce(world); RollEvent(world); world.Turn++;}Das Programm abonniert die Nachrichten (sie setzen vor jede Zeile die Phase, in der das Ereignis geschah) und den Groll, lässt fünf Züge laufen und gibt das Ergebnis aus. Die Zeilen sind genau die Nachrichten des Spiels:
var world = new World(7);world.Bus.Subscribe("battle", e =>{ world.Grudge[e.A, e.B]++; world.Grudge[e.B, e.A]++;});foreach (string type in newsTypes){ world.Bus.Subscribe(type, e => Console.WriteLine(" [" + world.Phase + "] " + NewsText(e)));}
for (int i = 0; i < 5; i++){ Console.WriteLine(Fill("-- Zug {0} --", world.Turn)); RunTurn(world);}Console.WriteLine("-- Ende --");for (int empire = 0; empire < 3; empire++){ Console.WriteLine(Fill("{0}: Schiffe {1}, Metall {2}", names[empire], Power(world, empire), world.Metal[empire]));}-- Zug 1 -- [Diplomatie] Kriegserklärung: Orion-Hegemonie → Terranisches Reich. [Bewegung] Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). [Bewegung] Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3). [Ereignis] Meteoritenschauer: Lyra-Union verliert 14 Metall.-- Zug 2 -- [Bewegung] Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4). [Schlachten] Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).-- Zug 3 -- [Diplomatie] Kriegserklärung: Lyra-Union → Orion-Hegemonie. [Diplomatie] Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie. [Schlachten] Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).-- Zug 4 -- [Diplomatie] Frieden geschlossen: Terranisches Reich und Orion-Hegemonie. [Ereignis] Meteoritenschauer: Lyra-Union verliert 8 Metall.-- Zug 5 -- [Diplomatie] Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich. [Ereignis] Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).-- Ende --Terranisches Reich: Schiffe 0, Metall 100Orion-Hegemonie: Schiffe 2, Metall 100Lyra-Union: Schiffe 1, Metall 78Lies die Ausgabe als Geschichte. Zug 1: Orion erklärt den Krieg, die beiden Flotten (die von Orion und die des Terranischen Reiches) brechen gleichzeitig auf, und Lyra verliert Metall durch einen Meteoriten. Zug 2: Orions Flotte kommt in Vesta an, wo das Terranische Reich schon ist, und beide verlieren 3 Schiffe. Zug 3: Lyra tritt in den Krieg ein, und in der Schlacht bei Vesta verlieren sie je 1; davor bietet A Frieden an und wird abgelehnt. Zug 4: Orion hat keine Schiffe, sein Wunsch ist 124, und der Frieden ist geschlossen. Zug 5: Ein neuer Krieg ist „zu früh“, und Orion findet zwei verlassene Schiffe in seiner Hauptstadt.
Sieh es dir an
Ein Zug der Galaxie
Hier dasselbe Zug für Zug. Oben leuchtet die Phase auf, in der etwas geschehen ist. In der Mitte sind die Systeme und die Flotten der drei Reiche: Bei der Bewegung starten alle Flotten gleichzeitig und landen erst dann. Darunter siehst du, ob jedes Paar im Krieg ist, und ganz unten den Bus: Das veröffentlichte Ereignis lässt den Abonnenten „Nachrichten“ aufleuchten, und wenn es eine Schlacht ist, auch „Groll“.
void RunTurn(World world){ // Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig. ScriptedDiplomacy(world); ResolveMovement(world); ResolveBattles(world); Produce(world); RollEvent(world); world.Turn++;}
class EventBus{ public Dictionary<string, List<Action<Event>>> Handlers = new Dictionary<string, List<Action<Event>>>();
public void Subscribe(string type, Action<Event> handler) { if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<Event>>(); Handlers[type].Add(handler); }
public void Publish(Event gameEvent) { if (!Handlers.ContainsKey(gameEvent.Type)) return; foreach (Action<Event> handler in new List<Action<Event>>(Handlers[gameEvent.Type])) handler(gameEvent); }}Schritt für Schritt, in Worten
- A = Terranisches Reich, B = Orion-Hegemonie, C = Lyra-Union. Vier Systeme in einer Reihe; der Zug läuft die Phasen von oben nach unten durch.
- Zug 1 · Diplomatie: Kriegserklärung: Orion-Hegemonie → Terranisches Reich.
- Zug 1 · Bewegung: Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3).
- Zug 1 · Ereignis: Meteoritenschauer: Lyra-Union verliert 14 Metall.
- Zug 2 · Bewegung: Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4).
- Zug 2 · Schlachten: Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).
- Zug 3 · Diplomatie: Kriegserklärung: Lyra-Union → Orion-Hegemonie. Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie.
- Zug 3 · Schlachten: Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).
- Zug 4 · Diplomatie: Frieden geschlossen: Terranisches Reich und Orion-Hegemonie.
- Zug 4 · Ereignis: Meteoritenschauer: Lyra-Union verliert 8 Metall.
- Zug 5 · Diplomatie: Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich.
- Zug 5 · Ereignis: Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).
- Nach 5 Zügen: Terranisches Reich: Schiffe 0, Metall 100 · Orion-Hegemonie: Schiffe 2, Metall 100 · Lyra-Union: Schiffe 1, Metall 78
Der Code neben der Szene ist runTurn und EventBus aus dem Programm. Es leuchten die Zeilen, die gerade arbeiten.
Die Idee
Im Spiel
Was wir gebaut haben, ist dasselbe wie im Kanon, nur kleiner. Hier der echte Zug (die Region turn). Über runTurn stehen die Bewegung, die dieselben zwei Schritte macht (Aufbrechen, Ankommen), die Schlachten und die Eroberung von Planeten; runTurn ruft die Phasen in fester Reihenfolge auf, und am Ende des Zuges klingt der Groll ab (jeden fünften Zug sinkt er um 1) und alte Friedensangebote werden weggeworfen.
// 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.class Move{ public int Owner; public int To; public int Dest; public int[] Ships = new int[4];}
static partial class Cosmos{ public static void ResolveMovement(World world) { List<Move> moving = new List<Move>(); foreach (Empire empire in world.Empires) { if (!empire.Alive) continue; foreach (Order order in 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 { Owner = empire.Id, To = order.To, Dest = order.Dest, Ships = ships }); } empire.Orders = new List<Order>(); } foreach (Move move in 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.Add(new Order { From = move.To, To = NextStep(world.Systems, move.To, move.Dest), Dest = move.Dest, Ships = (int[])move.Ships.Clone() }); } } RemoveEmptyFleets(world); }
public static bool HasUnits(World world, int owner, int systemId) { Fleet? fleet = FleetAt(world, owner, systemId); return (fleet != null && TotalOf(fleet.Ships) > 0) || FortsNow(world, owner, systemId) > 0; }
public static void ResolveBattles(World world) { foreach (StarSystem system in world.Systems) { for (int a = 0; a < world.Empires.Count; a++) { for (int b = a + 1; b < world.Empires.Count; b++) { if (!world.Empires[a].Alive || !world.Empires[b].Alive || !AtWar(world, a, b)) continue; if (HasUnits(world, a, system.Id) && HasUnits(world, b, system.Id)) FightAt(world, system.Id, a, b); } } } RemoveEmptyFleets(world); }
// Ein Planet fällt, wenn ein Feind mit seinem Besitzer im Krieg ist, Schiffe im System hat und der Besitzer dort // weder Schiffe noch überlebende Festungen hat, SIEGE_TURNS Runden hintereinander: die ersten Runden sind Belagerung, // die letzte ist die Einnahme. Der Verteidiger hat Zeit, eine Flotte heranzuführen. Die Gebäude werden zerstört. public static void ResolveConquest(World world) { foreach (StarSystem system in world.Systems) { foreach (int pid in system.Planets) { Planet planet = world.Planets[pid]; if (planet.Owner < 0) continue; int owner = planet.Owner; int attacker = -1; if (!HasUnits(world, owner, system.Id)) { foreach (Empire e in world.Empires) { if (!e.Alive || e.Id == owner || !AtWar(world, e.Id, owner)) continue; Fleet? fleet = FleetAt(world, e.Id, system.Id); if (fleet != null && TotalOf(fleet.Ships) > 0) { attacker = e.Id; break; } } } if (attacker < 0) { planet.Siege = 0; continue; } planet.Siege++; if (planet.Siege < SIEGE_TURNS) { world.Bus.Publish(new GameEvent { Type = "siege", Empire = attacker, From = owner, PlanetId = planet.Id }); continue; } planet.Siege = 0; planet.Owner = attacker; planet.Mines = MaxOf(0, planet.Mines - 1); planet.Lab = 0; planet.Yard = 0; planet.Fort = 0; world.Bus.Publish(new GameEvent { Type = "conquest", Empire = attacker, From = owner, PlanetId = planet.Id }); } } foreach (Empire empire in world.Empires) { if (!empire.Alive || OwnedPlanets(world, empire.Id).Count > 0) continue; empire.Alive = false; List<Fleet> kept = new List<Fleet>(); foreach (Fleet fleet in world.Fleets) { if (fleet.Owner != empire.Id) kept.Add(fleet); } world.Fleets = kept; empire.Orders = new List<Order>(); world.Bus.Publish(new GameEvent { Type = "eliminated", Empire = empire.Id }); } }
public static void RunTurn(World world) { world.TurnLines = new List<string>(); world.FortLeft = new Dictionary<int, int>(); foreach (Empire empire in world.Empires) { if (empire.Alive && !empire.Human) AiDiplomacy(world, empire); } foreach (Empire empire in world.Empires) { if (empire.Alive && !empire.Human) AiPlay(world, empire); } ResolveMovement(world); ResolveBattles(world); ResolveConquest(world); foreach (Empire empire in world.Empires) { if (!empire.Alive) continue; Produce(world, empire); CompleteResearch(world, empire); } FinishQueue(world); RollEvent(world); CheckVictory(world); world.Turn++; if (world.Turn % 5 == 0) { foreach (Empire empire in world.Empires) { for (int i = 0; i < empire.Grudge.Length; i++) empire.Grudge[i] = MaxOf(0, empire.Grudge[i] - 1); } } List<Offer> fresh = new List<Offer>(); foreach (Offer offer in world.Offers) { if (world.Turn - offer.Turn <= 3) fresh.Add(offer); } world.Offers = fresh; }}Und hier der Bus, auf den die Nachrichten, der Groll und die Warnung an den Spieler hören. Die Ereignisse sind eine Klasse mit mehr Feldern (in JavaScript ein Objekt), und der Bus ist derselbe:
// Der Ereignisbus: wer veröffentlicht, weiß nicht, wer zuhört. Die Nachrichten, der Groll der Reiche und// die Statistik abonnieren selbst, und der Kampf sagt nur „eine Flotte hat bei diesem System gekämpft“.class GameEvent{ public string Type = ""; public int SystemId; public int A; public int B; public int LostA; public int LostB; public string Winner = ""; public List<string> Lines = new List<string>(); public int Empire; public int From; public int To; public int PlanetId; public string TechId = ""; public string Kind = ""; public List<(string Name, object Value)> Vars = new List<(string Name, object Value)>();}
class EventBus{ public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler) { if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>(); Handlers[type].Add(handler); }
public void Publish(GameEvent gameEvent) { if (!Handlers.ContainsKey(gameEvent.Type)) return; foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent); }}Die Unterschiede zu unserem Programm sind viele, und deshalb wichtig:
- Es gibt kein Skript. Befehle und Diplomatie kommen von
aiPlayundaiDiplomacy(die Lektion über die Rivalen) und von den Befehlen des Spielers. - Die Galaxie ist ein Graph (die Lektion über ihre Erzeugung), keine Linie. Ein Befehl merkt sich sein Endziel und setzt sich von selbst fort.
- Die Schlacht (
fightAt, die Lektion über Flotten) hat Schiffsrollen und sechs Runden, nicht „jeder verliert die kleinere Zahl“. Es gibt auch Festungen und die Eroberung eines Planeten (zwei Belagerungen). - Der Friedenswunsch nutzt die Stärke des Reiches (nicht die Zahl der Schiffe), seinen Charakter und seinen Kriegswunsch (
warWish). Ein Angebot an den Spieler wird für drei Züge eingetragen. - Die Produktion ist hier pauschal 10 Metall pro Reich; im Kanon hängt sie von Planeten, Minen und Technologien ab.
- Die Ereignisse sind acht Arten, mit vielfältigeren Wirkungen (Piraten kämpfen mit demselben Schlachtmodell).
- Der Bus trägt auch andere Arten: Eroberung, Belagerung, eine entdeckte Technologie, eine Kolonie, ein zerstörtes Reich. Die Nachrichten und die Warnung an den Spieler hören darauf, und
waithält an, sobald die Warnung wächst.
Probier es aus
Sage die Ausgabe voraus
Zwei Flotten: Der erste Befehl schickt die 3 Schiffe von System 0 nach 1, der zweite 2 Schiffe von System 1 nach 2. Die Befehle werden gleichzeitig verarbeitet. Was gibt das Programm aus?
int[] MoveTogether(int[] start, int[][] orders){ var ships = (int[])start.Clone(); var takes = new int[orders.Length]; // Zuerst verlassen alle Flotten ihre Systeme... for (int i = 0; i < orders.Length; i++) { takes[i] = Math.Min(orders[i][2], ships[orders[i][0]]); ships[orders[i][0]] -= takes[i]; } // ...und erst dann kommen sie an. for (int i = 0; i < orders.Length; i++) ships[orders[i][1]] += takes[i]; return ships;}
string Show(int[] ships){ return string.Join(" ", ships);}
int[][] orders = [[0, 1, 3], [1, 2, 2]];Console.WriteLine(Show(MoveTogether([3, 0, 0, 0], orders)));Zuerst brechen alle auf: Der erste Befehl nimmt 3 Schiffe aus System 0, und der zweite sieht in System 1 null Schiffe (die ersten sind noch nicht angekommen) und nimmt 0. Dann kommen sie an: 3 Schiffe in System 1, 0 in System 2. Die zweite Möglichkeit ist das Ergebnis bei Befehlen nacheinander: 2 der angekommenen Schiffe brechen im selben Zug erneut auf; die dritte ist der Anfangszustand, ohne Bewegung.
Probier es aus
Finde den Fehler
Ein Kollege hat die gleichzeitige Bewegung „repariert“, aber damit fliegen die Schiffe immer noch durch mehrere Systeme pro Zug:
int[] start = [3, 0, 0, 0];Console.WriteLine("gleichzeitig: " + Show(MoveTogether(start, orders)));gleichzeitig: 0 0 0 3Das Ergebnis ist wie bei der Bewegung nacheinander. Welche Zeile schickt die Schiffe zu früh zum Ziel?
Wähle die Zeile mit dem Fehler.
Die Schiffe kommen an (+= am Ziel) schon in der Schleife für das Aufbrechen. Wenn der zweite Befehl kommt, sind die Schiffe des ersten schon da, und er nimmt sie. Richtig: In der ersten Schleife werden sie nur genommen (-=) und in takes gemerkt; das Ankommen ist eine zweite Schleife, nachdem alle aufgebrochen sind.
Genauer
Genauer: Reihenfolge, Fairness und Zufall
Die Reihenfolge der Spieler. Bei gleichzeitiger Bewegung ändert die Reihenfolge der Befehle das Ergebnis nicht, weil alle zuerst vom Beginn des Zuges nehmen. Bei den Schlachten gibt es dagegen eine Reihenfolge: System für System, Paar für Paar (a < b). Sind drei Reiche im Krieg in einem System, kämpft das erste Paar in der Reihenfolge zuerst, und die Überlebenden gehen in den nächsten Kampf. Die Reihenfolge ist fest, deshalb bleibt das Spiel wiederholbar.
Die Phasen sind ein Vertrag. Die Diplomatie kommt vor der Bewegung, denn dann kann ein neu erklärter Krieg im selben Zug eine Schlacht auslösen. Die Produktion kommt nach der Eroberung: Ein in diesem Zug eroberter Planet produziert nicht mehr für seinen alten Herrn. Das Ereignis kommt fast zuletzt und arbeitet mit der Welt, wie sie geworden ist (der Meteorit nimmt von dem Metall, das schon produziert wurde). Vertausche zwei Phasen, und das Spiel bleibt „dasselbe“, aber der Ausgang ist ein anderer. Deshalb steht die Reihenfolge an einer Stelle (runTurn) und liest sich wie eine Liste.
Zufall und Wiederholbarkeit. Alles Zufällige (die Chance auf ein Ereignis, welches Reich, welche Art, wie viel Metall) läuft über einen Generator mit Startwert. Tauschen zwei Phasen den Platz und ziehen beide Zahlen, ist die Folge eine andere. In unserem Programm benutzt nur rollEvent den Generator, deshalb ist die Ausgabe stabil; im Kanon ziehen auch die Diplomatie und die KI Zahlen, und die Reihenfolge der Phasen entscheidet, wer welche Zahl bekommt.
Was nicht in den Bus gehört. Der Bus ist für Nachrichten: „das ist geschehen“. Er ist nicht für Befehle („tu das“): Der Veröffentlichende weiß nicht, ob jemand es gehört hat, und wartet nicht auf eine Antwort. Veröffentliche Tatsachen, keine Anordnungen.
Übung
Eine Warnung für den Spieler
Löse die Aufgabe in deinem Editor. Beginne mit dem Programm für den ganzen Zug.
Warnung. Im Spiel hält wait an, sobald etwas dem Spieler geschieht. Füge einen Abonnenten „Warnung“ hinzu, der die Ereignisse war, peace und battle zählt, an denen Reich 0 (der Spieler) beteiligt ist, als a oder als b. Nach der Zusammenfassung gibt das Programm noch eine Zeile mit den Warnungen aus (Warnungen: …). Fass declareWar, makePeace und resolveBattles nicht an: Der Abonnent ist nur eine neue Zeile beim Erzeugen der Welt und eine Funktion.
Die Ausgabe ist (die letzte Zeile ist die neue):
-- Zug 1 -- [Diplomatie] Kriegserklärung: Orion-Hegemonie → Terranisches Reich. [Bewegung] Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). [Bewegung] Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3). [Ereignis] Meteoritenschauer: Lyra-Union verliert 14 Metall.-- Zug 2 -- [Bewegung] Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4). [Schlachten] Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).-- Zug 3 -- [Diplomatie] Kriegserklärung: Lyra-Union → Orion-Hegemonie. [Diplomatie] Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie. [Schlachten] Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).-- Zug 4 -- [Diplomatie] Frieden geschlossen: Terranisches Reich und Orion-Hegemonie. [Ereignis] Meteoritenschauer: Lyra-Union verliert 8 Metall.-- Zug 5 -- [Diplomatie] Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich. [Ereignis] Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).-- Ende --Terranisches Reich: Schiffe 0, Metall 100Orion-Hegemonie: Schiffe 2, Metall 100Lyra-Union: Schiffe 1, Metall 78Warnungen: 3Weitere Aufgaben
- Statistik. Füge einen weiteren Abonnenten hinzu, der die Schlachten und die Summe der verlorenen Schiffe zählt (jede Schlacht verliert
nauf jeder der beiden Seiten). Er gibt sie nach den Warnungen aus. - Waffenruhe. Senke
MIN_PEACEauf 2 und lass das Programm laufen. Welche Zeilen der Ausgabe ändern sich, und warum ist Zug 5 nicht mehr „zu früh“? - Eine neue Ereignisart. Füge
plaguemit frühestem Zug 4 und Gewicht 10 hinzu: Das Reich verliert 5 Metall. Welche Stellen änderst du: die Tabelle,rollEvent,newsText, die Liste der Arten für die Nachrichten? Und den Bus?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · Dictionary und HashSet · Lambdas und Delegates · record, switch und Muster
- Aufgaben zum Thema: Epic Cosmos: das Imperium