Das Spiel, das wir bauen
Kurz gesagt: Die Chroniken von Arkanum ist ein ganzes Konsolen-Rollenspiel, das Lektion für Lektion entsteht: eine Karte mit Sichtfeld, Kämpfe nach Tempo, Wunden, Gegenstände, Quests und Speichern, in einer Datei pro Sprache, in allen vier Sprachen.
Problem
Ein Spiel, das du spielen willst
Bisher waren die Spiele Übungen: Völker, die von Character erben, ein Kampf über zehn Züge, Räume mit Befehlen. Sie haben die Sprache beigebracht, und das Spiel war nur der Anlass. Niemand würde sie ein zweites Mal spielen.
Jetzt machen wir das Gegenteil: ein ganzes Spiel, das man spielt, und jede Technik kommt dazu, weil das Spiel sie braucht. Eine Karte, die sich beim Gehen öffnet. Ein Kampf, in dem das Tempo die Reihenfolge bestimmt und eine Wunde auch in den nächsten Zügen blutet. Gegner, die dich jagen und fliehen, wenn es ihnen schlecht geht. Gegenstände, ein Laden, Stufen, Dialoge, Quests, Speichern. Und darunter ein Stapel von Bildschirmen, der alles in Ordnung hält.
Es heißt „Die Chroniken von Arkanum“. Die Bergleute der alten Mine unter Graufurt verschwinden, und auf dem Weg dorthin ist Blut. Du steigst drei Ebenen hinab. Ganz unten wartet der Wächter der Tiefe.
Probier es aus
Spiel es selbst
Das ist das echte Spiel: die JavaScript-Version, direkt im Browser. Wähle eine Klasse, sprich mit dem Ältesten, kauf etwas und steig in die Mine hinab. Auf der Karte bist du @, und die Buchstaben sind Gegner. Läufst du auf einen Gegner zu, beginnt ein Kampf.
Die Chroniken von Arkanum
Die Mine unter Graufurt schweigt seit drei Tagen. Auf dem Weg ist Blut.
Klicke auf den Bildschirm, um mit den Tasten zu spielen (Pfeile oder WASD, I, M, Ziffern); Enter springt in die Befehlszeile. Spielstände bleiben in diesem Browser.
Auf der Karte gelten w a s d (oder die Pfeiltasten, nachdem du auf den Bildschirm geklickt hast), g nimmt etwas, i ist das Inventar, c der Held, m das Menü mit Speichern und Laden. Im Kampf: 1 Angriff, 2 Fähigkeit, 3 Gegenstand, 4 Flucht, und das Ziel wählst du mit einer Ziffer. ? zeigt die Hilfe.
Genauer, für Neugierige
Das Spiel im Browser ist dieselbe Datei, die du in den Lektionen siehst und auf deinem Computer startest. Nur der Block, der in Node die Argumente liest und die Spielstände in Dateien schreibt, ist ersetzt: Hier kommt die Eingabe aus dem Feld unter dem Bildschirm, und die Spielstände liegen im Speicher des Browsers (localStorage). Jedes neue Spiel hat einen neuen Startwert, deshalb sieht die Welt jedes Mal anders aus.
Die Idee
Was darin steckt
- Drei Klassen. Der Barbar (viel Leben, eine Axt, deren Wunden bluten, „Wut“), die Amazone (ein Speer, mehr kritische Treffer, „Doppelschuss“) und die Hexe (wenig Leben, Mana, Feuer, Gift und Heilung). Jede hat einen Fähigkeitsbaum mit zwei Zweigen.
- Eine Karte mit Sichtfeld. Du siehst nur, wohin du eine gerade Linie hast, und das Gesehene bleibt im Gedächtnis (Nebel). Drei Ebenen, Treppen, Wasser, Türen, Gegenstände auf dem Boden.
- Kämpfe, in denen das Tempo die Reihenfolge bestimmt. Treffer gegen Ausweichen, Schaden aus Waffe, Stärke und Abwehr, kritische Treffer, Schadensarten: schneidend, stechend, stumpf, Feuer, Gift.
- Wunden und Effekte. Eine schneidende Waffe schlägt eine Wunde, die jeden Zug blutet, und Wunden addieren sich. Ein blutender Gegner hinterlässt eine Spur auf der Karte, der du bis zu seinem Versteck folgen kannst. Dazu kommen Gift, Brennen, Betäubung, Regeneration, Wut und Schild.
- Gegner, die denken. Auf der Karte schlafen sie, streifen herum, jagen dich auf dem kürzesten Weg und fliehen, wenn es ihnen schlecht geht. Im Kampf wägen sie ab, was ihnen am meisten bringt: ein Angriff, eine Fähigkeit oder Heilung.
- Gegenstände aus Tabellen. Waffen, Rüstungen, Ringe und Tränke, Beute mit Gewichten, ein Laden, ein Inventar mit Plätzen.
- Stufen, Dialoge, drei Quests, ein Event-Bus, Speichern und Laden (ein kaputter Spielstand wird mit einer Erklärung abgelehnt), Rückblick auf den Kampf und eine Simulation für die Balance.
Der ganze Durchlauf, mit dem das Spiel geprüft wird, sind 230 Befehle und 4395 Zeilen Ausgabe: vom Anlegen des Helden bis zum Sieg über den Boss.
Die Idee
Die Karte des Codes
Das Spiel ist eine Datei mit etwa 5500 Zeilen in jeder Sprache. Eine Datei, damit man es mit einem Befehl startet. Innen ist sie in Teile (Regionen) geteilt, und jeder Teil liegt in einer von sechs Schichten:
| Schicht | Teile | Zeilen |
|---|---|---|
| Daten und Texte | text items-data enemies-data skills-data dialogs-data maps-data data-load map-load data-load-all | 1549 |
| Eingabe, Ausgabe, Zeichnen | random io-text buffer terminal hud | 308 |
| Die Bildschirme und der Stapel | screen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays | 1259 |
| Die Welt und die Regeln | event-bus entity inventory equip loot xp-levelup quests dialog level fov game save load | 1389 |
| Kampf, Effekte, KI | effects bleeding combat-order attack actions replay ai-battle pathfind ai-map | 857 |
| Start und Balance | simulate main node | 176 |
Die Zahlen stammen aus der JavaScript-Version und werden beim Bauen der Seite berechnet. Fast die Hälfte der Zeilen sind Daten und Texte: Gegenstände, Gegner, Karten und Dialoge sind Tabellen, und jeder Text des Spiels steht in vier Sprachen. Die Logik kennt den Namen keines einzigen Schwerts.
Genauer, für Neugierige
In einem echten Projekt liegen die Teile in eigenen Dateien und Paketen: screens/, world/, combat/, data/. Hier stehen sie wegen der Lektionen in einer Datei, aber die Grenzen sind dieselben: Der Code eines Bildschirms rührt die Kampfregeln nicht an, und der Kampf druckt selbst nichts. Wenn du es aufteilen willst, schneidest du an den Regionen.
Die Idee
Die Spielschleife
Das Herz jedes Spiels ist eine Schleife, die läuft, bis das Spiel zu Ende ist. Hier liegt sie an zwei Stellen: Der Stapel der Bildschirme sagt, welcher Bildschirm oben liegt, und die Schleife zeichnet ihn, liest einen Befehl und gibt ihn weiter:
// Die Spielschleife: zeichnen → Befehl lesen → verarbeiten → Welt aktualisieren → Änderungen anwenden// auf den Stapel. Sie wiederholt sich, bis der Stapel leer ist.
// Ein- und Ausgabe: io mit vier Methoden. Der Boot-Block gibt die Konsole, die Seite ihr eigenes io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // Der Seed ist eine vorzeichenlose 32-Bit-Zahl (in JavaScript Number(...) >>> 0); hier sind es dieselben 32 Bit in einem int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal 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.toLowerCase(Locale.ROOT)); top.update(); game.stack.apply(); } io.write(t("ui.goodbye") + "\n");}Jeder Befehl ist ein Zug. Die Schleife weiß nichts über Karten, Kämpfe und Läden: Sie ruft draw, handle und update auf dem obersten Bildschirm auf und wendet danach die Änderungen am Stapel an. Der Bildschirm entscheidet alles andere: Die Karte bewegt den Helden, der Kampf führt einen Angriff aus, das Menü speichert. Deshalb rührt ein neuer Bildschirm die Schleife nicht an.
Hier ist ein echter Zug des Durchlaufs, von innen verfolgt: Der Befehl „1“ im Kampf gegen den Goblin geht durch die sechs Schichten der Codekarte nach unten und kommt als neues Bild wieder nach oben.
// Die Spielschleife: zeichnen → Befehl lesen → verarbeiten → Welt aktualisieren → Änderungen anwenden// auf den Stapel. Sie wiederholt sich, bis der Stapel leer ist.
// Ein- und Ausgabe: io mit vier Methoden. Der Boot-Block gibt die Konsole, die Seite ihr eigenes io.interface Io { void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();}
static void main(Io io, Options options) { // Der Seed ist eine vorzeichenlose 32-Bit-Zahl (in JavaScript Number(...) >>> 0); hier sind es dieselben 32 Bit in einem int. int seed = (int) (long) options.seed; if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) { throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)"); } LANG = options.lang; if (options.memorySaves) io.save("3", tamperedSave()); loadData(); int simulateCount = (int) options.simulate; if (simulateCount > 0) { simulate(io, simulateCount, seed); return; } Terminal 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.toLowerCase(Locale.ROOT)); top.update(); game.stack.apply(); } io.write(t("ui.goodbye") + "\n");}Schritt für Schritt, in Worten
- Befehl 56 des Durchlaufs: Iva (30/48) kämpft gegen einen Speergoblin (16/16). Der Spieler tippt „1“: Angriff.
- Eingabe, Ausgabe, Zeichnen: io.readLine() gibt „1“ zurück. Für diese Schicht ist das nur Text: Sie weiß nichts vom Kampf.
- Die Bildschirme und der Stapel: Oben liegt der Kampf, darunter die Karte. Der oberste Bildschirm bekommt „1“ und ruft bei nur einem Gegner battle.heroAct(new AttackAction(…)) auf.
- Noch in top.handle: die Regeln. Der Würfel mit Startwert für die Handaxt (4–7 in der Tabelle) ergibt 6. 6 + Stärke 7 − Abwehr 1 = 12: „Iva zerschmettert Speergoblin (12).“
- Die Axt schneidet und öffnet eine Wunde (35 % Chance): „Speergoblin blutet.“ Am Zug des Goblins nimmt sie 1 (16 → 4 → 3): „Speergoblin ist schwer verwundet!“ Er schlägt zurück: „Speergoblin kratzt Iva (1).“
- update und apply: Niemand hat push oder pop aufgerufen, also bleibt der Kampf oben. Der Kampf hat nichts ausgegeben: Seine 5 neuen Zeilen warten in seinem Protokoll.
- Nächste Runde: Der Bildschirm zeichnet in den Puffer 60 × 18 (Iva 29/48, Goblin 3/16, „Blut:2“ = noch 2 Züge, die letzten 7 Protokollzeilen), das Terminal gibt ihn aus.
- Ein Befehl, sechs Schichten, jede mit ihrer eigenen Aufgabe. Der Kampf weiß nichts vom Bildschirm, deshalb kann er auch ganz ohne ihn laufen: Das ist die Simulation.
Die Schleife wartet auf die Zeile und tut sonst nichts (Lesen blockiert). Wenn die Eingabe zu Ende ist (keine Zeilen mehr), endet das Spiel sauber.
Die Idee
Ein Code, vier Sprachen, eine Ausgabe
Das Spiel ist viermal geschrieben: in Java, C#, Python und JavaScript, Teil für Teil und mit denselben Namen. Die Prüfung der Seite startet es in allen vier Sprachen mit den aufgezeichneten 230 Befehlen und verlangt Byte für Byte dieselbe Ausgabe. Würfelt eine Version anders, nimmt der Kampf einen anderen Weg, und die Prüfung schlägt fehl.
Wie geht das, wenn jede Sprache ihr eigenes Random hat? Das Spiel benutzt den eingebauten Generator nicht. Es hat einen eigenen, kleinen und überall gleichen, nur mit ganzen Zahlen:
// mulberry32: ein kleiner Generator mit Seed. Nur 32-Bit-Ganzzahlen (int und >>>), darum// liefert er in jeder Sprache dieselbe Folge.static class Random { int state;
Random(int seed) { this.state = seed; }
int next32() { this.state = this.state + 0x6d2b79f5; int z = this.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. int nextInt(int n) { return (int) (Integer.toUnsignedLong(this.next32()) % n); }
// Von min bis max, beide eingeschlossen. int range(int min, int max) { return min + this.nextInt(max - min + 1); }
boolean chance(int percent) { return this.nextInt(100) < percent; }}Derselbe Startwert ergibt dieselben Zahlen, und dieselben Zahlen ergeben denselben Kampf. Das nützt nicht nur der Prüfung. Ein Fehler, den ein Spieler meldet („in Zug 412 hat der Wolf mich durch eine Wand getroffen“), wiederholt sich genau, wenn du den Startwert und die Befehle hast. So funktioniert auch der Rückblick auf den Kampf im Spiel.
Genauer, für Neugierige
Die Regeln für dieselbe Ausgabe: Division nur ganzzahlig; Sammlungen behalten die Reihenfolge des Einfügens; Sortieren hat immer eine zweite Regel (zwei Gegner mit gleichem Tempo werden nach Nummer geordnet), weil nicht jede Sortierung stabil ist. Zahlen werden von Hand aufgefüllt, ohne Formatierung nach der Sprache des Systems. Das wirkt klein, aber jede dieser Regeln hat schon einen Unterschied zwischen den Versionen aufgedeckt.
Ein Bild aus dem Durchlauf, auf der zweiten Ebene der Mine:
#.# #####.##### Silberadern . ~~~~~~.~~~~~~ Ebene 2 von 3 ~~~~~~.~~~~~~ ~~~~~~.~~~~~~ Ringsum: #####.##### G Erzgolem ###########.############# ..................#......# ..................#......# .G....@...........'......# ..................#......# ..................#....!.# ..................#......# ################# #######Iva, Barbar St.4 EP 148/170 Gold 37L [##########--] 74/85 M [########] 7/7Du nimmst: Silbererz.Silber für die Schmiedin: Bring das Erz zu Schmiedin Vela.Erzgolem wacht auf!Und der Kampf gegen den Boss, nachdem er seine Helfer gerufen hat:
=== Kampf ===Iva, Barbar St.6 L [#######-----] 63/103 M [###-----] 4/91) Wächter der Tiefe [###-----] 53/120 Raser.2) Steindiener [########] 14/143) Steindiener [########] 14/14 Wächter der Tiefe kratzt Iva (7). Iva trifft Wächter der Tiefe (17). Wächter der Tiefe verfehlt Iva. Iva trifft Wächter der Tiefe (14). Wächter der Tiefe bricht auf und brüllt. Zwei Steindiener klettern aus den Rissen! Schild lässt bei Iva nach.1 Angriff 2 Fähigkeit 3 Gegenstand 4 FluchtDie Idee
Auf deinem Computer starten
Die ganze Datei liegt hier: [Arcanum.java](/games/arcanum/de/Arcanum.java), [Arcanum.cs](/games/arcanum/de/Arcanum.cs), [arcanum.py](/games/arcanum/de/arcanum.py), [arcanum.js](/games/arcanum/de/arcanum.js). Leg sie in einen leeren Ordner und starte sie:
java Arcanum.javaIn einem Terminal löscht das Spiel den Bildschirm und färbt: Blut ist rot, Mana blau, Gift grün. Mit --plain druckt es die Bilder als einfachen Text. Mit --seed=42 ist die Welt immer dieselbe, und mit --lang=de ist das Spiel auf Deutsch (bg, en und es gibt es auch). Die Spielstände liegen in einem Ordner saves neben dem Spiel.
javac.Die Idee
Wie wir es bauen
Jede weitere Lektion nimmt einen Teil des Spiels, erklärt, warum er so ist, und baut ihn mit kleineren Programmen, die du starten kannst. Danach zeigt sie denselben Teil im echten Spiel.
- Die Spielschleife: Eingabe, Aktualisieren, Zeichnen, Zug gegen Bild, Startwert.
- Ein Stapel von Bildschirmen: der Bildschirm als Objekt, push, pop und replace, durchsichtige Bildschirme.
- Bildschirme, die eine Antwort zurückgeben: ein Ziel wählen, „Bist du sicher?“, aufgeschobene Änderungen.
- In der Konsole zeichnen: ein Puffer aus Zellen, Rahmen, Balken, Farben.
- Die Karte und das Sichtfeld: eine Karte aus Text, Kollision, Kamera, Sichtfeld.
- Kreaturen und Werte: Komposition, Werte in Schichten.
- Das Kampfsystem: Reihenfolge nach Tempo, Treffer, Schaden, kritischer Treffer.
- Blut, Wunden und Effekte: Blutung, Gift, Brennen, Addieren, die Spur.
- Fähigkeiten und Aktionen als Befehle: Mana, Abklingzeit, Rückblick auf den Kampf.
- Gegner, die denken: endlicher Automat, Wegsuche, Nutzen.
- Gegenstände, Inventar, Beute: Daten aus Tabellen, Ausrüstung, Laden.
- Stufen, Dialoge, Quests: Erfahrung, Fähigkeitsbaum, Event-Bus.
- Speichern und Laden: Serialisierung, Version, Prüfung.
- Das ganze Spiel und die Balance: die Simulation, die Zahlen, Ideen zum Weitermachen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Listen · Stack, Queue und verkettete Liste · HashMap und HashSet