Zum Inhalt
wannacode

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 >:

let hero = 4;
let goblin = 0;
while (true) {
const command = (await lines.next()).value;
if (command === "q") break;
if (command === "a") hero--;
if (command === "d") hero++;
let row = "";
for (let x = 0; x <= 11; x++) {
row += x === hero ? "@" : x === goblin ? "g" : x === 11 ? ">" : ".";
}
console.log(row);
if (hero === 11) {
console.log("Ausgang!");
break;
}
goblin += goblin < hero ? 1 : -1;
if (Math.abs(hero - goblin) <= 1) {
console.log("Der Goblin hat dich erwischt.");
break;
}
}
rl.close();

Der Spieler tippt x (falsche Taste) und dann a:

Ausgabe
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 x ist 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.
  • draw liest den Zustand nur und druckt ein Bild. Du kannst es so oft aufrufen, wie du willst.
  • handle macht aus dem Befehl eine Absicht: move ist -1, 1 oder 0. Es bewegt niemanden.
  • update verä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 {
constructor() {
this.hero = 4;
this.goblin = 0;
this.exit = 11;
this.turn = 0;
this.move = 0;
this.message = "";
this.over = false;
}
}
const state = new State();
while (!state.over) {
draw(state);
const command = await read();
handle(state, command);
update(state);
}
draw(state);
rl.close();

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:

async function read() {
const { value, done } = await lines.next();
if (done) return null;
console.log(`> ${value}`);
return value;
}

lines ist der Leser der Eingabezeilen (createInterface aus node:readline am Anfang des Programms). Wenn die Eingabe zu Ende ist, gibt next() done: true zurück. Die Funktion ist async, weil die Zeile kommt, wann sie kommt: await wartet, ohne Node anzuhalten. 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:

function draw(s) {
let row = "";
for (let 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.log(`Zug ${s.turn} ${row}`);
if (s.message !== "") console.log(s.message);
}
function handle(s, 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.`;
}
}
function update(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;
}
}

const state = new State();
while (!state.over) {
draw(state);
const command = await read();
handle(state, command);
update(state);
}
draw(state);
function draw(s) {
let row = "";
for (let 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.log(`Zug ${s.turn} ${row}`);
if (s.message !== "") console.log(s.message);
}
function handle(s, 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.`;
}
}
function update(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
  1. 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.
  2. draw geht über die 12 Zellen und druckt das Bild .g...@.....>. Es liest nur den Zustand: Im Panel ändert sich nichts.
  3. read wartet. Die Welt steht still, bis der Spieler Enter drückt. Die Zeile „a“ kommt an.
  4. handle übersetzt den Befehl nur in eine Absicht: move = -1. Noch hat sich niemand bewegt.
  5. 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.
  6. 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.
  7. 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:

Ausgabe
Zug 0 g...@......>
> d
Zug 1 .g...@.....>
> a
Zug 2 ..g.@......>
> x
Zug 2 ..g.@......>
Ich verstehe „x“ nicht: Der Zug zählt nicht.
> d
Zug 3 ...g.@.....>
> d
Zug 4 ....g.@....>
> d
Zug 5 .....g.@...>
> d
Zug 6 ......g.@..>
> d
Zug 7 .......g.@.>
> d
Zug 8 ........g.@>
> d
Zug 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:

const near = new State();
near.hero = 5;
near.goblin = 3;
near.move = -1;
update(near);
console.log(near.hero, near.goblin, near.message);
const far = new State();
far.move = 1;
update(far);
console.log(far.hero, far.goblin, far.turn);
Ausgabe
4 3 Der Goblin hat dich erwischt.
5 1 1

Das 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:

  1. }
  2. update(state);
  3. draw(state);
  4. const command = await read();
  5. handle(state, command);
  6. while (!state.over) {

Probier es aus

Was kommt heraus?

Dasselbe Programm bekommt die Befehle a, x, a. Das sind die Regeln:

function handle(s, 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.`;
}
}
function update(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;
}
}
Antwort

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:

Ausgabe
Zug 0 .g..@......>
> d
Zug 1 ..g..@.....>
> x
Zug 1 ...g.@.....>
Ich verstehe „x“ nicht: Der Zug zählt nicht.
> x
Zug 1 ....g@.....>
Ich verstehe „x“ nicht: Der Zug zählt nicht.
> d
Zug 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.

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:

function run(name, times) {
const step = 50;
let acc = 0;
let x = 0;
let last = times[0];
for (const now of times) {
acc += now - last;
last = now;
let steps = 0;
while (acc >= step) {
x += 3;
steps++;
acc -= step;
}
console.log(`${name} ${now} ms: Schritte ${steps}, x = ${x}, Rest ${acc} ms`);
}
}
run("schnell", [0, 16, 40, 45, 110, 126, 150]);
run("langsam", [0, 90, 150]);
Ausgabe
schnell 0 ms: Schritte 0, x = 0, Rest 0 ms
schnell 16 ms: Schritte 0, x = 0, Rest 16 ms
schnell 40 ms: Schritte 0, x = 0, Rest 40 ms
schnell 45 ms: Schritte 0, x = 0, Rest 45 ms
schnell 110 ms: Schritte 2, x = 6, Rest 10 ms
schnell 126 ms: Schritte 0, x = 6, Rest 26 ms
schnell 150 ms: Schritte 1, x = 9, Rest 0 ms
langsam 0 ms: Schritte 0, x = 0, Rest 0 ms
langsam 90 ms: Schritte 1, x = 3, Rest 40 ms
langsam 150 ms: Schritte 2, x = 9, Rest 0 ms

Bei 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.

process.stdin.setRawMode(true), und jede Taste kommt als data-Ereignis an. Der Event Loop blockiert nicht, also betreibt das Spiel seine eigene Schleife mit setInterval und liest die letzte Taste aus einer Variablen. Im Browser sind es keydown und requestAnimationFrame.

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 (Math.imul und >>> 0), darum
// liefert er in jeder Sprache dieselbe Folge.
export class Random {
constructor(seed) {
this.state = seed >>> 0;
}
next32() {
this.state = (this.state + 0x6d2b79f5) >>> 0;
let z = this.state;
z = Math.imul(z ^ (z >>> 15), z | 1) >>> 0;
z = (z ^ (z + Math.imul(z ^ (z >>> 7), z | 61))) >>> 0;
return (z ^ (z >>> 14)) >>> 0;
}
// Von 0 bis n-1, ohne Bruchzahlen.
nextInt(n) {
return this.next32() % n;
}
// Von min bis max, beide eingeschlossen.
range(min, max) {
return min + this.nextInt(max - min + 1);
}
chance(percent) {
return this.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:

function roll(seed) {
const dice = new Random(seed);
let line = `Startwert ${seed}:`;
for (let i = 0; i < 5; i++) line += ` ${dice.range(1, 20)}`;
console.log(line);
}
roll(2026);
roll(2026);
roll(7);
Ausgabe
Startwert 2026: 16 19 3 17 18
Startwert 2026: 16 19 3 17 18
Startwert 7: 13 11 15 11 16

Derselbe Startwert, dieselben Zahlen. In allen vier Sprachen.

range(min, max) {
  return min + this.nextInt();
}
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. Eine Zahl in JavaScript ist ein Gleitkommawert, deshalb multipliziert Math.imul als 32-Bit-Ganzzahl, und >>> 0 macht das Ergebnis wieder zu einer vorzeichenlosen 32-Bit-Zahl.

Die Idee

Nur ganze Zahlen

Der Startwert reicht nicht. Nimm dieselbe Division in den vier Sprachen:

console.log(Math.trunc(-7 / 2));
console.log(10 / 2);
Ausgabe
-3
5

In den vier Sprachen ist die Ausgabe:

SpracheGanzzahldivision von −7 durch 210 / 2 als Bruch
Java-35.0
C#-35
Python-45.0
JavaScript-35

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.
export async function main(io, options) {
const seed = options.seed === undefined ? 2026 : Number(options.seed) >>> 0;
const lang = options.lang === undefined ? 'bg' : options.lang;
if (lang !== 'bg' && lang !== 'en' && lang !== 'de' && lang !== 'es') {
throw new Error('unknown language: ' + lang + ' (use bg, en, de or es)');
}
LANG = lang;
if (options.memorySaves) io.save('3', tamperedSave());
loadData();
const simulateCount = options.simulate === undefined ? 0 : Number(options.simulate);
if (simulateCount > 0) {
simulate(io, simulateCount, seed);
return;
}
const terminal = options.plain === true ? new PlainTerminal(io) : new AnsiTerminal(io);
const game = new Game(io, seed);
const 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);
const line = await io.readLine();
if (line === null) {
io.write(t('ui.endOfInput') + '\n');
return;
}
terminal.echo(line);
game.rawInput = trimSpaces(line);
const top = game.stack.top();
top.handle(game.rawInput.toLowerCase());
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 in game, so wie im Korridor in state.
  • 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.
Hier ist main async, und die Zeile ist await io.readLine(): Im Browser kommt der Befehl aus dem Feld unter dem Bildschirm, wenn der Spieler tippt, und die Seite darf bis dahin nicht einfrieren.

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.
worldTurn() {
const level = this.level();
const hero = this.hero;
this.turn += 1;
this.stats.turns += 1;
computeFov(level, hero.x, hero.y);
tickEffects(hero, this);
if (!hero.isAlive()) {
this.heroDied();
return;
}
if (this.turn % 3 === 0 && !hero.hasEffect('bleed') && !hero.hasEffect('poison')) {
hero.heal(1);
if (hero.mana < hero.maxMana()) hero.mana += 1;
}
for (const enemy of level.enemies.slice()) {
if (!enemy.isAlive()) continue;
enemyTurnOnMap(this, level, enemy);
const visible = level.visible[enemy.y][enemy.x];
tickEffects(enemy, visible ? this : this.quiet);
if (!enemy.isAlive()) {
enemy.dead = true;
if (visible) this.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.
this.enemyKilled(enemy, { gold: 0, drop: null });
}
}
this.pruneDead();
computeFov(level, hero.x, hero.y);
for (const enemy of level.enemies) {
if (enemy.state === 'chase' && distance(enemy.x, enemy.y, hero.x, hero.y) <= 1) {
this.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:

Eingabe
d
d
a
a
a
a
Ausgabe
Zug 0 z...@......>
> d
Zug 1 z....@.....>
> d
Zug 2 z.....@....>
> a
Zug 3 z....@.....>
> a
Zug 4 z...@......>
> a
Zug 5 g..@.......>
> a
Zug 6 .g@........>
Der Goblin hat dich erwischt.

Weitere Übungen

  • Warten. Füge einen Befehl s hinzu: Der Held bleibt stehen, aber der Zug vergeht und der Goblin geht einen Schritt. Wie unterscheidest du in handle „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

1. Warum liest draw im Korridor nur den Zustand und ändert nichts?
2. Der Spieler tippt im Korridor x. Was passiert?
3. Warum ist ein Konsolenspiel rundenbasiert?
4. Ein Spieler meldet: „In Zug 412 hat der Wolf mich durch die Wand getroffen.“ Was brauchst du, um dasselbe zu sehen?
5. Eine Echtzeitschleife lässt die Physik alle 50 ms einen Schritt machen. Vom letzten Bild sind 10 ms übrig, und das neue Bild kommt 120 ms später. Wie viele Schritte macht die Physik?

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