Die Spielschleife
Kurz gesagt: Die Spielschleife wiederholt dieselben Schritte, bis das Spiel endet: Sie zeichnet die Welt, liest einen Befehl, verarbeitet ihn und aktualisiert den Zustand, und ein Startwert macht jeden Durchlauf wiederholbar.
Problem
Ein Programm, das endet
Ein gewöhnliches Programm läuft von oben nach unten und endet. Es liest die Zahlen, rechnet, druckt, fertig. Ein Spiel kann so nicht arbeiten. Es muss auf den Spieler warten, und zwischen zwei Befehlen gibt es eine Welt: wo der Held ist, wo der Goblin ist, wie viele Züge vergangen sind. Diese Welt muss bis zum nächsten Befehl überleben.
Das Erste, was einem einfällt, ist ein while (true), das eine Zeile liest und innen alles erledigt. Ein Korridor aus 12 Zellen: Du bist @, der Goblin g jagt dich, der Ausgang ist >:
int hero = 4;int goblin = 0;while (true){ string command = Console.ReadLine()!; if (command == "q") break; if (command == "a") hero--; if (command == "d") hero++; string row = ""; for (int x = 0; x <= 11; x++) { row += x == hero ? "@" : x == goblin ? "g" : x == 11 ? ">" : "."; } Console.WriteLine(row); if (hero == 11) { Console.WriteLine("Ausgang!"); break; } goblin += goblin < hero ? 1 : -1; if (Math.Abs(hero - goblin) <= 1) { Console.WriteLine("Der Goblin hat dich erwischt."); break; }}Der Spieler tippt x (falsche Taste) und dann a:
g...@......>.g.@.......>Der Goblin hat dich erwischt.Drei Dinge sind falsch:
- Vor dem ersten Befehl ist der Bildschirm leer: Das Programm zeichnet erst, nachdem es eine Zeile gelesen hat.
- Der Tippfehler
xist ein Zug: Der Goblin hat einen Schritt gemacht. Das Programm kann „der Spieler hat etwas getan“ nicht von „der Spieler hat die falsche Taste getroffen“ unterscheiden. - Das letzte Bild ist
.g.@: Zwischen euch ist eine freie Zelle, und der Goblin fängt dich. Das Bild wurde nach deinem Schritt und vor dem des Goblins gedruckt. Niemand hat je die Stellung gesehen, in der das Spiel endete.
Und zwei Dinge sind unmöglich. Du kannst den Bildschirm nicht ohne Zug neu zeichnen: Nach der Hilfe mit ? oder nach dem Inventar muss der Zeichencode kopiert werden. Und du kannst die Regel „der Goblin geht auf den Helden zu“ nicht prüfen, ohne Befehle zu tippen und Text zu lesen. Es gibt eine Ursache: Die Welt, die Regeln und das Zeichnen sind an einer Stelle vermischt.
Die Idee
Vier Schritte im Kreis
Wir trennen sie:
- Der Zustand ist alles, was die Welt ist, in einem Objekt: der Held, der Goblin, der Ausgang, der Zugzähler, die Meldung, das Ende. Der Bildschirm gehört nicht dazu. Der Bildschirm wird aus ihm berechnet.
Drawliest den Zustand nur und druckt ein Bild. Du kannst es so oft aufrufen, wie du willst.Handlemacht aus dem Befehl eine Absicht:Moveist -1, 1 oder 0. Es bewegt niemanden.Updateverändert die Welt: den Zug, den Helden, den Goblin, das Ende. Es druckt nichts.
Die Schleife ruft sie im Kreis auf: zeichnen, lesen, verarbeiten, aktualisieren. Das ist die Spielschleife. Jedes Spiel hat eine, von diesem Korridor bis Arkanum.
class State{ public int Hero = 4; public int Goblin = 0; public int Exit = 11; public int Turn = 0; public int Move = 0; public string Message = ""; public bool Over = false;}
var state = new State();while (!state.Over){ Draw(state); string? command = Read(); Handle(state, command); Update(state);}Draw(state);Over beendet die Schleife, und das letzte Draw danach zeigt das Ende: den Ausgang oder den Goblin, mit der Meldung.
Das Lesen ist eine eigene Funktion, weil die Eingabe zu Ende gehen kann:
static string? Read(){ string? line = Console.ReadLine(); if (line != null) Console.WriteLine("> " + line); return line;}Console.ReadLine() gibt am Ende der Eingabe null zurück. Read reicht es weiter, und Handle behandelt es als Ende des Spiels. Das Echo > a ist da, damit die Ausgabe zeigt, welcher Befehl hereingekommen ist. Die Konsole des Spielers zeigt es von selbst.
Genauer, für Neugierige
Ohne die Prüfung auf das Ende der Eingabe geht jede Sprache auf ihre Weise kaputt. Java wirft eine Exception, Python auch. C# ist am heimtückischsten: Console.ReadLine() gibt null zurück, der Vergleich command == "q" ist falsch, und der erste Versuch merkt nicht, dass die Eingabe zu Ende ist: Er druckt Bilder leerer Züge, bis der Goblin von allein beim Helden ist. JavaScript bekommt undefined und macht dasselbe. Eingaben gehen häufiger zu Ende, als du denkst: eine Datei mit Befehlen, ein geschlossenes Terminal, die Prüfung der Seite.
Sieh es dir an
Ein Zug aus der Nähe
Hier sind die drei Funktionen. Das ganze Spiel steckt in ihnen:
static void Draw(State s){ string row = ""; for (int x = 0; x <= s.Exit; x++) { if (x == s.Hero) row += "@"; else if (x == s.Goblin) row += "g"; else if (x == s.Exit) row += ">"; else row += "."; } Console.WriteLine("Zug " + s.Turn + " " + row); if (s.Message != "") Console.WriteLine(s.Message);}
static void Handle(State s, string? command){ s.Move = 0; s.Message = ""; if (command == null) { s.Message = "Die Eingabe ist zu Ende."; s.Over = true; } else if (command == "a") { s.Move = -1; } else if (command == "d") { s.Move = 1; } else if (command == "q") { s.Message = "Du bist gegangen."; s.Over = true; } else { s.Message = "Ich verstehe „" + command + "“ nicht: Der Zug zählt nicht."; }}
static void Update(State s){ if (s.Move == 0) return; s.Turn++; s.Hero = Math.Max(0, s.Hero + s.Move); if (s.Hero == s.Exit) { s.Message = "Ausgang! Du bist nach " + s.Turn + " Zügen entkommen."; s.Over = true; return; } if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1; if (Math.Abs(s.Hero - s.Goblin) <= 1) { s.Message = "Der Goblin hat dich erwischt."; s.Over = true; }}var state = new State();while (!state.Over){ Draw(state); string? command = Read(); Handle(state, command); Update(state);}Draw(state);
static void Draw(State s){ string row = ""; for (int x = 0; x <= s.Exit; x++) { if (x == s.Hero) row += "@"; else if (x == s.Goblin) row += "g"; else if (x == s.Exit) row += ">"; else row += "."; } Console.WriteLine("Zug " + s.Turn + " " + row); if (s.Message != "") Console.WriteLine(s.Message);}
static void Handle(State s, string? command){ s.Move = 0; s.Message = ""; if (command == null) { s.Message = "Die Eingabe ist zu Ende."; s.Over = true; } else if (command == "a") { s.Move = -1; } else if (command == "d") { s.Move = 1; } else if (command == "q") { s.Message = "Du bist gegangen."; s.Over = true; } else { s.Message = "Ich verstehe „" + command + "“ nicht: Der Zug zählt nicht."; }}
static void Update(State s){ if (s.Move == 0) return; s.Turn++; s.Hero = Math.Max(0, s.Hero + s.Move); if (s.Hero == s.Exit) { s.Message = "Ausgang! Du bist nach " + s.Turn + " Zügen entkommen."; s.Over = true; return; } if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1; if (Math.Abs(s.Hero - s.Goblin) <= 1) { s.Message = "Der Goblin hat dich erwischt."; s.Over = true; }}Schritt für Schritt, in Worten
- Zug 1 ist vorbei: Der Held steht auf Zelle 5, der Goblin auf Zelle 1. Die ganze Welt ist das Zustandsobjekt; die Schleife ist wieder oben.
- Draw geht über die 12 Zellen und druckt das Bild .g...@.....>. Es liest nur den Zustand: Im Panel ändert sich nichts.
- Read wartet. Die Welt steht still, bis der Spieler Enter drückt. Die Zeile „a“ kommt an.
- Handle übersetzt den Befehl nur in eine Absicht: Move = -1. Noch hat sich niemand bewegt.
- Update verändert die Welt. Der Zugzähler wird 2, und der Held geht von 5 nach 4. Es ist nicht der Ausgang, also geht der Zug weiter.
- Der Goblin ist 3 Zellen entfernt, also geht er auf den Helden zu: von 1 nach 2. Jetzt sind es 2 Zellen: nicht gefangen, das Spiel geht weiter.
- Wieder oben druckt Draw ..g.@......>: Das Bild kommt nach dem ganzen Zug, deshalb sieht der Spieler, wo der Goblin wirklich ist.
Mit den Befehlen d, a, x und siebenmal d druckt das Programm:
Zug 0 g...@......>> dZug 1 .g...@.....>> aZug 2 ..g.@......>> xZug 2 ..g.@......>Ich verstehe „x“ nicht: Der Zug zählt nicht.> dZug 3 ...g.@.....>> dZug 4 ....g.@....>> dZug 5 .....g.@...>> dZug 6 ......g.@..>> dZug 7 .......g.@.>> dZug 8 ........g.@>> dZug 9 ........g..@Ausgang! Du bist nach 9 Zügen entkommen.Bei x wird das Bild noch einmal gezeichnet, aber der Zug bleibt 2 und der Goblin steht still: Update sieht Move = 0 und tut nichts. Das funktioniert, weil Draw die Welt nicht anfasst. Der Goblin geht nach dem Helden, und das Bild kommt nach dem ganzen Zug, also siehst du genau, wo er ist. Am Ausgang macht der Goblin keinen Schritt: Das Spiel endet vor seinem Schritt.
Jetzt lassen sich die Regeln ohne Bildschirm und ohne Befehle prüfen. Du baust einen Zustand von Hand, rufst Update auf und siehst dir die Zahlen an:
var near = new State { Hero = 5, Goblin = 3, Move = -1 };Update(near);Console.WriteLine(near.Hero + " " + near.Goblin + " " + near.Message);
var far = new State { Move = 1 };Update(far);Console.WriteLine(far.Hero + " " + far.Goblin + " " + far.Turn);4 3 Der Goblin hat dich erwischt.5 1 1Das ist ein Test der Regel „der Goblin fängt dich, wenn er neben dir steht“. Ein echtes Spiel hat Hunderte solcher Prüfungen. Mit dem ersten Versuch könntest du nicht eine einzige schreiben.
Genauer, für Neugierige
Warum bewegt Handle den Helden nicht direkt? Im Korridor könnte es das. Aber in Arkanum bedeutet dasselbe a auf der Karte „links“, im Menü „zurück“ und im Dialog „nichts“. Der Bildschirm versteht den Befehl, und die Regeln verändern die Welt. Zweitens: Ein Befehl, der kein Zug ist (ein Tippfehler, das Inventar öffnen, die Hilfe), darf die Welt nicht bewegen. Deshalb wird die Absicht getrennt gespeichert, und update entscheidet, ob es überhaupt einen Zug gibt.
Probier es aus
Bring die Zeilen in die richtige Reihenfolge
Bring die Zeilen der Schleife in die richtige Reihenfolge:
Handle(state, command);{while (!state.Over)string? command = Read();}Draw(state);Update(state);
Probier es aus
Was kommt heraus?
Dasselbe Programm bekommt die Befehle a, x, a. Das sind die Regeln:
static void Handle(State s, string? command){ s.Move = 0; s.Message = ""; if (command == null) { s.Message = "Die Eingabe ist zu Ende."; s.Over = true; } else if (command == "a") { s.Move = -1; } else if (command == "d") { s.Move = 1; } else if (command == "q") { s.Message = "Du bist gegangen."; s.Over = true; } else { s.Message = "Ich verstehe „" + command + "“ nicht: Der Zug zählt nicht."; }}
static void Update(State s){ if (s.Move == 0) return; s.Turn++; s.Hero = Math.Max(0, s.Hero + s.Move); if (s.Hero == s.Exit) { s.Message = "Ausgang! Du bist nach " + s.Turn + " Zügen entkommen."; s.Over = true; return; } if (Math.Abs(s.Hero - s.Goblin) > 1) s.Goblin += s.Goblin < s.Hero ? 1 : -1; if (Math.Abs(s.Hero - s.Goblin) <= 1) { s.Message = "Der Goblin hat dich erwischt."; s.Over = true; }}Probier es aus
Finde den Fehler
Ein Kollege fand, der Goblin solle sich „in jedem Bild“ bewegen, und verschob seinen Schritt von Update nach Draw. Mit den Befehlen d, x, x, d druckt das Programm:
Zug 0 .g..@......>> dZug 1 ..g..@.....>> xZug 1 ...g.@.....>Ich verstehe „x“ nicht: Der Zug zählt nicht.> xZug 1 ....g@.....>Ich verstehe „x“ nicht: Der Zug zählt nicht.> dZug 2 .....g@....>Zug 2 .....g@....>Die Eingabe ist zu Ende.Schon im Bild 0 steht der Goblin auf Zelle 1. Bei den beiden Tippfehlern bleibt der Zug 1, und der Goblin läuft. Am Ende steht er neben dem Helden, und nichts passiert. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Schritt des Goblins verändert die Welt, und Draw wird in jedem Bild aufgerufen, nicht in jedem Zug: bei einem Tippfehler und am Ende auch. Deshalb bewegt sich der Goblin, wenn es keinen Zug gibt. Und die Prüfung „gefangen“ steht in Update vor dem Schritt, sodass sie den Goblin neben dir nicht sieht. Die Zeile gehört in Update, zwischen den Zug des Helden und die Prüfung.
Die Idee
Zug und Bild
Der Korridor ist rundenbasiert: Die Welt steht still, bis ein Befehl kommt. Ein Zug, ein Bild. Die Konsole übergibt eine ganze Zeile erst nach Enter, und das Lesen wartet, also ist ein Konsolenspiel von Natur aus rundenbasiert. Das sind auch Schach, Roguelikes (Rogue, NetHack) und die Kämpfe in vielen Strategiespielen.
Ein Echtzeitspiel wartet nicht. Die Welt bewegt sich nach der Uhr: Wenn du nichts tust, kommt der Goblin trotzdem. Die Schleife läuft dutzende Male pro Sekunde: Sie prüft, ob eine Taste gedrückt ist (ohne zu warten), aktualisiert die Welt für die vergangene Zeit und zeichnet. Ein Zeichnen ist ein Bild, und ein Schritt der Welt ist etwas anderes.
Die vier Teile sind dieselben. Was sich ändert, ist, wer die Schleife antreibt: die Zeile des Spielers oder die Uhr. Dort ist es noch wichtiger, Draw von Update zu trennen: Ein schneller Monitor zeichnet 144 Bilder pro Sekunde, aber die Welt muss auf jedem Computer im selben Tempo schreiten.
Genauer, für Neugierige
Ein fester Zeitschritt. Wenn die Welt um „so viel Zeit, wie seit dem letzten Bild vergangen ist“ schreitet, hängt die Physik von den Bildern ab: Bei einem langsamen Bild springt der Pfeil über die Wand, und zwei Computer ergeben nie dasselbe Ergebnis. Also schreitet die Welt in gleichen Scheiben, zum Beispiel 50 ms. Die vergangene Zeit wird angesammelt (ein Akkumulator), und die Schleife macht so viele Schritte, wie ganze Scheiben da sind. Der Rest wartet auf das nächste Bild.
Hier ist die Uhr erfunden (eine Liste von Bildzeiten), deshalb ist die Ausgabe immer dieselbe. Der schnelle Computer zeichnet in ungleichen Abständen, der langsame selten:
static void Run(string name, int[] times){ int step = 50; int acc = 0; int x = 0; int last = times[0]; foreach (int now in times) { acc += now - last; last = now; int steps = 0; while (acc >= step) { x += 3; steps++; acc -= step; } Console.WriteLine(name + " " + now + " ms: Schritte " + steps + ", x = " + x + ", Rest " + acc + " ms"); }}
Run("schnell", [0, 16, 40, 45, 110, 126, 150]);Run("langsam", [0, 90, 150]);schnell 0 ms: Schritte 0, x = 0, Rest 0 msschnell 16 ms: Schritte 0, x = 0, Rest 16 msschnell 40 ms: Schritte 0, x = 0, Rest 40 msschnell 45 ms: Schritte 0, x = 0, Rest 45 msschnell 110 ms: Schritte 2, x = 6, Rest 10 msschnell 126 ms: Schritte 0, x = 6, Rest 26 msschnell 150 ms: Schritte 1, x = 9, Rest 0 mslangsam 0 ms: Schritte 0, x = 0, Rest 0 mslangsam 90 ms: Schritte 1, x = 3, Rest 40 mslangsam 150 ms: Schritte 2, x = 9, Rest 0 msBei 150 ms haben beide 3 Schritte gemacht, und der Pfeil steht bei 9. Die Bilder sind verschieden, die Welt ist dieselbe. Die übrige Zeit wird auch fürs Zeichnen genutzt: Bei 10 ms von 50 wird der Pfeil 20 % des Weges zu seiner nächsten Position gezeichnet (Interpolation), damit er nicht ruckelt.
Lesen ohne Warten und rohe Tasten. Das Terminal selbst hält die Zeile zurück, bis du Enter drückst. Das Programm bekommt nichts, bis die ganze Zeile da ist. Für Echtzeit muss das Terminal in den „rohen“ Modus wechseln, in dem jede Taste sofort ankommt, und das Programm muss fragen können „liegt eine Taste an?“, ohne zu warten.
Console.KeyAvailable sagt, ob eine Taste ohne Blockieren bereitliegt, und Console.ReadKey(true) nimmt eine Taste ohne Enter und ohne sie zu drucken. Eine Echtzeitschleife: if (Console.KeyAvailable) { var key = Console.ReadKey(true); … }, dann aktualisieren und zeichnen.Arkanum bleibt rundenbasiert. Derselbe Code muss im Browser laufen, in der Prüfung (die Befehle kommen aus einer Datei) und in jedem Terminal. Eine Textzeile funktioniert überall. Den rohen Modus gibt es für eine Datei nicht.
Die Idee
Startwert und Wiederholbarkeit
Ein Spiel braucht Zufall: Treffer oder Fehlschlag, welche Beute fällt, wohin der Wolf läuft. Aber ein Fehler, der vom Zufall abhängt, lässt sich nicht wiederholen. Deshalb hat der Generator einen Startwert: Derselbe Startwert ergibt dieselben Zahlen.
Das ist der Generator von Arkanum:
// mulberry32: ein kleiner Generator mit Seed. Nur 32-Bit-Ganzzahlen (uint und unchecked), darum// liefert er in jeder Sprache dieselbe Folge.class Random{ public uint State;
public Random(uint seed) { State = seed; }
public uint Next32() { unchecked { State = State + 0x6d2b79f5; uint z = State; z = (z ^ (z >> 15)) * (z | 1); z = z ^ (z + (z ^ (z >> 7)) * (z | 61)); return z ^ (z >> 14); } }
// Von 0 bis n-1, ohne Bruchzahlen. public int NextInt(int n) { return (int)(Next32() % (uint)n); }
// Von min bis max, beide eingeschlossen. public int Range(int min, int max) { return min + NextInt(max - min + 1); }
public bool Chance(int percent) { return NextInt(100) < percent; }}Der Generator merkt sich eine einzige Zahl, State. Jedes Next32() schiebt sie um eine große Konstante weiter und mischt ihre Bits. NextInt(n) nimmt den Rest der Division durch n, also eine Zahl von 0 bis n − 1. Ein W20 ist Range(1, 20). Wir würfeln ihn fünfmal mit dem Startwert 2026, dann noch einmal mit 2026 und dann mit 7:
static void Roll(uint seed){ var dice = new Random(seed); string line = "Startwert " + seed + ":"; for (int i = 0; i < 5; i++) line += " " + dice.Range(1, 20); Console.WriteLine(line);}
Roll(2026);Roll(2026);Roll(7);Startwert 2026: 16 19 3 17 18Startwert 2026: 16 19 3 17 18Startwert 7: 13 11 15 11 16Derselbe Startwert, dieselben Zahlen. In allen vier Sprachen.
public int Range(int min, int max)
{
return min + NextInt();
}Von min bis max einschließlich gibt es max - min + 1 Zahlen. Der Rest liefert 0 bis zu dieser Anzahl minus eins, und das Addieren von min schiebt ihn an die richtige Stelle. Ohne das + 1 kommt die 20 nie.
Genauer, für Neugierige
Warum nicht das eingebaute Random? Jede Sprache hat ihren eigenen Algorithmus. Java benutzt einen linearen Kongruenzgenerator mit 48 Bit, .NET einen anderen, Python den Mersenne Twister, und Math.random() in JavaScript nimmt gar keinen Startwert. Derselbe Startwert ergibt in verschiedenen Sprachen verschiedene Zahlen, und in JavaScript kann man ihn nicht einmal setzen. mulberry32 sind etwa zehn Zeilen, also ist es viermal auf dieselbe Weise geschrieben.
Die Zahlen müssen überall vorzeichenlose 32-Bit-Zahlen sein. C# hat uint, und unchecked sagt, dass der Überlauf Absicht ist: Die obersten Bits fallen einfach weg.
Die Idee
Nur ganze Zahlen
Der Startwert reicht nicht. Nimm dieselbe Division in den vier Sprachen:
Console.WriteLine(-7 / 2);Console.WriteLine(10.0 / 2);-35In den vier Sprachen ist die Ausgabe:
| Sprache | Ganzzahldivision von −7 durch 2 | 10 / 2 als Bruch |
|---|---|---|
| Java | -3 | 5.0 |
| C# | -3 | 5 |
| Python | -4 | 5.0 |
| JavaScript | -3 | 5 |
Python rundet die Ganzzahldivision ab, die anderen zur Null hin. Eine Gleitkommazahl wird mit .0 oder ohne gedruckt. Ein einziges anderes Zeichen in der Ausgabe, und der Kampf in Python ist nicht mehr derselbe wie in Java. Deshalb rechnen die Kanons nur mit ganzen Zahlen, teilen ausdrücklich und teilen nie negative Zahlen. Sortieren hat immer eine zweite Regel, und Sammlungen behalten die Reihenfolge des Einfügens.
Die Belohnung ist groß. Mit dem Startwert, den Befehlen und der Version des Spiels ist ein Durchlauf eine Funktion: dieselbe Eingabe, dieselbe Ausgabe. Ein Spieler schreibt: „In Zug 412 hat der Wolf mich durch die Wand getroffen.“ Wenn das Spiel seinen Startwert und seine Befehle behält, startest du sie und kommst genau zu Zug 412, so oft du willst, in einem Debugger. Die Prüfung der Seite macht dasselbe: Die aufgezeichneten Befehle und der Startwert 2026 ergeben in allen vier Sprachen Byte für Byte dieselbe Ausgabe.
Die Idee
Die echte Schleife von Arkanum
Hier ist die Spielschleife, ohne Kürzungen:
// Die Spielschleife: zeichnen → Befehl lesen → verarbeiten → Welt aktualisieren → Änderungen anwenden// auf den Stapel. Sie wiederholt sich, bis der Stapel leer ist.
// Ein- und Ausgabe: Write druckt, ReadLine liefert die Zeile oder null am Ende, Save und Load halten die Spielstände.interface IGameIo{ void Write(string text);
string? ReadLine();
void Save(string slot, string text);
string? Load(string slot);}
class Options{ public bool Plain; public long Seed = 2026; public string Lang = "bg"; public long Simulate; public bool MemorySaves;}
static partial class Arcanum{ // Main(io, options) ist das Spiel; der Einstiegspunkt Main(args) steht im Boot-Block.#pragma warning disable CS0028 public static void Main(IGameIo io, Options options)#pragma warning restore CS0028 { uint seed = unchecked((uint)options.Seed); if (options.Lang != "bg" && options.Lang != "en" && options.Lang != "de" && options.Lang != "es") { throw new Exception("unknown language: " + options.Lang + " (use bg, en, de or es)"); } LANG = options.Lang; if (options.MemorySaves) io.Save("3", TamperedSave()); LoadData(); long simulateCount = options.Simulate; if (simulateCount > 0) { Simulate(io, (int)simulateCount, seed); return; } ITerminal terminal = options.Plain ? new PlainTerminal(io) : new AnsiTerminal(io); Game game = new Game(io, seed); CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT); game.Stack.Push(new TitleScreen(game)); game.Stack.Apply(); while (!game.Stack.IsEmpty()) { buffer.Clear(); game.Stack.Draw(buffer); terminal.Present(buffer); string? line = io.ReadLine(); if (line == null) { io.Write(T("ui.endOfInput") + "\n"); return; } terminal.Echo(line); game.RawInput = TrimSpaces(line); Screen top = game.Stack.Top()!; top.Handle(game.RawInput.ToLowerInvariant()); top.Update(); game.Stack.Apply(); } io.Write(T("ui.goodbye") + "\n"); }}Zeile für Zeile:
game.Stack.Push(new TitleScreen(game))legt den ersten Bildschirm hin. Die ganze Welt steckt ingame, so wie im Korridor instate.- Die Schleife läuft, solange etwas im Stapel der Bildschirme liegt. Beenden im Menü entfernt alle Bildschirme, und das Spiel endet. Das ist das Thema der nächsten Lektion.
- Zeichnen. Die Bildschirme zeichnen in einen Puffer aus Zellen (ein Zeichen und eine Farbe), und das Terminal entscheidet, wie er gezeigt wird: als einfacher Text oder mit Farben. Das steht in der Lektion über das Zeichnen in der Konsole.
- Lesen. Ist die Eingabe zu Ende, sagt das Spiel „Ende der Eingabe“ und hält an. Dasselbe wie unser
null. Das Echo der Zeile entspricht unserem> a. - Verarbeiten und aktualisieren. Nur der oberste Bildschirm bekommt den Befehl: die Karte, der Kampf oder das Menü.
- Anwenden. Der fünfte Schritt, den der Korridor nicht hat. Bildschirme bitten beim Verarbeiten eines Befehls um Änderungen am Stapel (einen Kampf starten, das Menü schließen), und diese Änderungen werden erst am Ende des Zuges angewendet. Warum nicht sofort, steht in der Lektion über Bildschirme, die eine Antwort zurückgeben.
Wo ist der Zug der Welt? Im Korridor steckt er in Update. In Arkanum ruft die Karte WorldTurn() aus ihrem eigenen handle auf, nur wenn der Held wirklich etwas getan hat: einen Schritt, eine Tür geöffnet, gewartet. Bei einem Tippfehler oder bei i für das Inventar bewegt sich die Welt nicht. Das ist unser „move ist 0, kein Zug“:
// Der Zug der Welt nach dem Zug des Helden: Effekte, langsame Erholung, Feinde, der Hinterhalt.public void WorldTurn(){ Level level = Level()!; Hero hero = Hero; Turn += 1; Stats.Turns += 1; ComputeFov(level, hero.X, hero.Y); TickEffects(hero, this); if (!hero.IsAlive()) { HeroDied(); return; } if (Turn % 3 == 0 && !hero.HasEffect("bleed") && !hero.HasEffect("poison")) { hero.Heal(1); if (hero.Mana < hero.MaxMana()) hero.Mana += 1; } foreach (Enemy enemy in new List<Enemy>(level.Enemies)) { if (!enemy.IsAlive()) continue; EnemyTurnOnMap(this, level, enemy); bool visible = level.Visible[enemy.Y][enemy.X]; TickEffects(enemy, visible ? this : Quiet); if (!enemy.IsAlive()) { enemy.Dead = true; if (visible) Say(T("map.bledOut", ("name", enemy.Name))); // Ein Feind, der außerhalb des Kampfes verblutet, lässt keine Beute fallen: Es war niemand da, um sie zu nehmen. EnemyKilled(enemy, new Spoils { Gold = 0, Drop = null }); } } PruneDead(); ComputeFov(level, hero.X, hero.Y); foreach (Enemy enemy in level.Enemies) { if (enemy.State == "chase" && Distance(enemy.X, enemy.Y, hero.X, hero.Y) <= 1) { StartBattle(enemy); return; } }}Der Zugzähler, die Effekte (Blutung läuft jeden Zug), langsame Erholung jeden dritten Zug, der Zug jedes Gegners, Gegner, die verblutet sind. Und das Ende, genau wie beim Goblin: Ein Gegner, der jagt und neben dem Helden steht, beginnt einen Kampf. Im Kampf folgen die Züge einer anderen Regel, nach Tempo, also entscheidet jeder Bildschirm selbst, wann es einen Zug gibt, und update auf der Karte berechnet nur neu, was zu sehen ist.
Übung
Der schlafende Goblin
Löse das in deinem Editor.
Der schlafende Goblin. Am Anfang schläft der Goblin und wird als z gezeichnet. Er wacht auf, wenn der Held 3 Zellen oder näher entfernt ist. In dem Zug, in dem er aufwacht, bewegt er sich nicht; danach jagt er wie bisher und wird als g gezeichnet. Die Schleife ändert sich nicht: Du brauchst ein Feld im Zustand, ein Zeichen in Draw und ein paar Zeilen in Update.
Die Eingabe:
ddaaaaZug 0 z...@......>> dZug 1 z....@.....>> dZug 2 z.....@....>> aZug 3 z....@.....>> aZug 4 z...@......>> aZug 5 g..@.......>> aZug 6 .g@........>Der Goblin hat dich erwischt.Weitere Übungen
- Warten. Füge einen Befehl
shinzu: Der Held bleibt stehen, aber der Zug vergeht und der Goblin geht einen Schritt. Wie unterscheidest du inHandle„stehen bleiben“ von „Tippfehler“? - Die Befehle aufzeichnen. Halte jeden gelesenen Befehl in einer Liste fest und schreibe sie am Ende in eine Datei, ein Befehl pro Zeile. Starte das Programm noch einmal mit dieser Datei als Eingabe: Die Ausgabe muss dieselbe sein. So funktioniert der Rückblick auf den Kampf in Arkanum.
- Zwei Goblins. Ein zweiter Goblin startet auf Zelle 11 und bewacht den Ausgang. Wer geht zuerst, wenn beide neben dem Helden stehen?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Instanzmethoden · Interfaces · Exceptions · Stack, Queue und verkettete Liste
- Aufgaben zum Thema: Die Chroniken von Arkanum