Wie können Sie Ihr erstes Roguelike machen?
() translation by (you can also view the original English article)
Roguelikes standen in letzter Zeit im Rampenlicht, mit Spielen wie Dungeons of Dredmor, Spelunky, The Binding of Isaac und FTL, die ein breites Publikum erreichten und von der Kritik hoch gelobt wurden. Roguelike-Elemente, die seit langem von Hardcore-Spielern in einer winzigen Nische genossen werden, tragen jetzt dazu bei, vielen bestehenden Genres mehr Tiefe und Wiederspielbarkeit zu verleihen.



In diesem Tutorial erfahren Sie, wie Sie mit JavaScript und der HTML 5-Game-Engine Phaser ein traditionelles Roguelike erstellen. Am Ende haben Sie ein voll funktionsfähiges, einfaches Roguelike-Spiel, das in Ihrem Browser gespielt werden kann! (Für unsere Zwecke wird ein traditionelles Roguelike als ein zufälliger, rundenbasierter Einzelspieler-Dungeon-Crawler mit Permadeath definiert.)

Hinweis: Obwohl der Code in diesem Tutorial JavaScript, HTML und Phaser verwendet, sollten Sie in fast jeder anderen Codierungssprache und Spiel-Engine dieselbe Technik und dieselben Konzepte verwenden können.
Fertig werden
Für dieses Tutorial benötigen Sie einen Texteditor und einen Browser. Ich verwende Notepad++ und bevorzuge Google Chrome für seine umfangreichen Entwickler-Werkzeugs, aber der Arbetsablauf ist mit jedem von Ihnen ausgewählten Texteditor und Browser nahezu identisch.
Sie sollten dann die Quelldateien herunterladen und mit dem init
-Ordner beginnen. Dies enthält Phaser und die grundlegenden HTML- und JS-Dateien für unser Spiel. Wir werden unseren Spielcode in die aktuell leere Datei rl.js
schreiben.
Die Datei index.html
lädt einfach Phaser und unsere oben genannte Spielcode-Datei:
1 |
<!DOCTYPE html>
|
2 |
<head>
|
3 |
<title>roguelike tutorial</title> |
4 |
<script src="phaser.min.js"></script> |
5 |
<script src="rl.js"></script> |
6 |
</head>
|
7 |
</html>
|
Initialisierung und Definitionen
Vorerst werden wir ASCII-Grafiken für unser Roguelike verwenden - in Zukunft könnten wir diese durch Bitmap-Grafiken ersetzen, aber im Moment erleichtert die Verwendung von einfachem ASCII unser Leben.
Definieren wir einige Konstanten für die Schriftgröße, die Abmessungen unserer Karte (d.h. die Ebene) und wie viele Akteure darin erscheinen:
1 |
// font size
|
2 |
var FONT = 32; |
3 |
|
4 |
// map dimensions
|
5 |
var ROWS = 10; |
6 |
var COLS = 15; |
7 |
|
8 |
// number of actors per level, including player
|
9 |
var ACTORS = 10; |
Lassen Sie uns auch Phaser initialisieren und auf Tastatur-Key-Up-Ereignisse warten, da wir ein rundenbasiertes Spiel erstellen und für jeden Tastendruck einmal handeln möchten:
1 |
// initialize phaser, call create() once done
|
2 |
var game = new Phaser.Game(COLS * FONT * 0.6, ROWS * FONT, Phaser.AUTO, null, { |
3 |
create: create |
4 |
});
|
5 |
|
6 |
function create() { |
7 |
// init keyboard commands
|
8 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
9 |
}
|
10 |
|
11 |
function onKeyUp(event) { |
12 |
switch (event.keyCode) { |
13 |
case Keyboard.LEFT: |
14 |
|
15 |
case Keyboard.RIGHT: |
16 |
|
17 |
case Keyboard.UP: |
18 |
|
19 |
case Keyboard.DOWN: |
20 |
|
21 |
}
|
22 |
}
|
Da Standard-Monospace-Schriftarten in der Regel etwa 60% so breit wie hoch sind, haben wir die Leinwandgröße auf 0.6 * the font size * the number of columns
. Wir sagen Phaser auch, dass es unsere create()
- Funktion sofort nach Abschluss der Initialisierung aufrufen soll. Zu diesem Zeitpunkt initialisieren wir die Tastatursteuerelemente.
Sie können das Spiel bisher hier ansehen - nicht, dass es viel zu sehen gibt!
Die Karte
Die Kachelkarte stellt unseren Spielbereich dar: eine diskrete (im Gegensatz zu einer kontinuierlichen) 2D-Anordnung von Kacheln oder Zellen, die jeweils durch ein ASCII-Zeichen dargestellt werden, das entweder eine Wand (#
: Blockbewegung) oder einen Boden (.
: blockiert nicht die Bewegung):
1 |
// the structure of the map
|
2 |
var map; |
Verwenden wir die einfachste Form der prozeduralen Generierung, um unsere Karten zu erstellen: zufällig entscheiden, welche Zelle eine Wand und welche einen Boden enthalten soll:
1 |
function initMap() { |
2 |
// create a new random map
|
3 |
map = []; |
4 |
for (var y = 0; y < ROWS; y++) { |
5 |
var newRow = []; |
6 |
for (var x = 0; x < COLS; x++) { |
7 |
if (Math.random() > 0.8) |
8 |
newRow.push('#'); |
9 |
else
|
10 |
newRow.push('.'); |
11 |
}
|
12 |
map.push(newRow); |
13 |
}
|
14 |
}
|
Dies sollte uns eine Karte geben, auf der 80% der Zellen Wände und der Rest Böden sind.
Wir initialisieren die neue Karte für unser Spiel in der Funktion create()
, unmittelbar nachdem wir die Listener für Tastaturereignisse eingerichtet haben:
1 |
function create() { |
2 |
// init keyboard commands
|
3 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
4 |
|
5 |
// initialize map
|
6 |
initMap(); |
7 |
}
|
Sie können die Demo hier ansehen - auch hier gibt es nichts zu sehen, da wir die Karte noch nicht gerendert haben.
Der Bildschirm
Es ist Zeit, unsere Karte zu zeichnen! Unser Bildschirm besteht aus einem 2D-Array von Textelementen, die jeweils ein einzelnes Zeichen enthalten:
1 |
// the ascii display, as a 2d array of characters
|
2 |
var asciidisplay; |
Durch das Zeichnen der Karte wird der Inhalt des Bildschirms mit den Werten der Karte gefüllt, da beide einfache ASCII-Zeichen sind:
1 |
function drawMap() { |
2 |
for (var y = 0; y < ROWS; y++) |
3 |
for (var x = 0; x < COLS; x++) |
4 |
asciidisplay[y][x].content = map[y][x]; |
5 |
}
|
Bevor wir die Karte zeichnen, müssen wir den Bildschirm initialisieren. Wir kehren zu unserer Funktion create()
zurück:
1 |
function create() { |
2 |
// init keyboard commands
|
3 |
game.input.keyboard.addCallbacks(null, null, onKeyUp); |
4 |
|
5 |
// initialize map
|
6 |
initMap(); |
7 |
|
8 |
// initialize screen
|
9 |
asciidisplay = []; |
10 |
for (var y = 0; y < ROWS; y++) { |
11 |
var newRow = []; |
12 |
asciidisplay.push(newRow); |
13 |
for (var x = 0; x < COLS; x++) |
14 |
newRow.push( initCell('', x, y) ); |
15 |
}
|
16 |
drawMap(); |
17 |
}
|
18 |
|
19 |
function initCell(chr, x, y) { |
20 |
// add a single cell in a given position to the ascii display
|
21 |
var style = { font: FONT + "px monospace", fill:"#fff"}; |
22 |
return game.add.text(FONT*0.6*x, FONT*y, chr, style); |
23 |
}
|
Sie sollten jetzt eine zufällige Karte sehen, wenn Sie das Projekt ausführen.

Akteure
Als nächstes folgen die Akteure: unser Spielercharakter und die Feinde, die sie besiegen müssen. Jeder Akteur ist ein Objekt mit drei Feldern: x
und y
für seine Position in der Karte und hp
für seine Trefferpunkte.
Wir behalten alle Akteure im actorList
-Array (das erste Element ist der Player). Wir behalten auch ein assoziatives Array mit den Standorten der Akteur als Schlüssel für die schnelle Suche bei, damit wir nicht die gesamte Liste der Akteur durchlaufen müssen, um herauszufinden, welcher Akteur einen bestimmten Ort einnimmt. Dies wird uns helfen, wenn wir die Bewegung und den Kampf codieren.
1 |
// a list of all actors; 0 is the player
|
2 |
var player; |
3 |
var actorList; |
4 |
var livingEnemies; |
5 |
|
6 |
// points to each actor in its position, for quick searching
|
7 |
var actorMap; |
Wir erstellen alle unsere Akteure und weisen jedem eine zufällige freie Position in der Karte zu:
1 |
function randomInt(max) { |
2 |
return Math.floor(Math.random() * max); |
3 |
}
|
4 |
|
5 |
function initActors() { |
6 |
// create actors at random locations
|
7 |
actorList = []; |
8 |
actorMap = {}; |
9 |
for (var e=0; e<ACTORS; e++) { |
10 |
// create new actor
|
11 |
var actor = { x:0, y:0, hp:e == 0?3:1 }; |
12 |
do { |
13 |
// pick a random position that is both a floor and not occupied
|
14 |
actor.y=randomInt(ROWS); |
15 |
actor.x=randomInt(COLS); |
16 |
} while ( map[actor.y][actor.x] == '#' || actorMap[actor.y + "_" + actor.x] != null ); |
17 |
|
18 |
// add references to the actor to the actors list & map
|
19 |
actorMap[actor.y + "_" + actor.x]= actor; |
20 |
actorList.push(actor); |
21 |
}
|
22 |
|
23 |
// the player is the first actor in the list
|
24 |
player = actorList[0]; |
25 |
livingEnemies = ACTORS-1; |
26 |
}
|
Es ist Zeit, die Akteure zu zeigen! Wir werden alle Feinde als e und den Spielercharakter als Anzahl der Trefferpunkte zeichnen:
1 |
function drawActors() { |
2 |
for (var a in actorList) { |
3 |
if (actorList[a].hp > 0) |
4 |
asciidisplay[actorList[a].y][actorList[a].x].content = a == 0?''+player.hp:'e'; |
5 |
}
|
6 |
}
|
Wir verwenden die Funktionen, die wir gerade geschrieben haben, um alle Akteure in unserer Funktion create()
zu initialisieren und zu zeichnen:
1 |
function create() { |
2 |
...
|
3 |
// initialize actors
|
4 |
initActors(); |
5 |
...
|
6 |
drawActors(); |
7 |
}
|
Wir können jetzt sehen, wie sich unser Spielercharakter und unsere Feinde in der Ebene ausbreiten!

Blockierende und begehbare Fliesen
Wir müssen sicherstellen, dass unsere Akteur nicht vom Bildschirm und durch Wände rennen. Fügen wir also diese einfache Überprüfung hinzu, um zu sehen, in welche Richtungen ein bestimmter Schauspieler gehen kann:
1 |
function canGo(actor,dir) { |
2 |
return actor.x+dir.x >= 0 && |
3 |
actor.x+dir.x <= COLS - 1 && |
4 |
actor.y+dir.y >= 0 && |
5 |
actor.y+dir.y <= ROWS - 1 && |
6 |
map[actor.y+dir.y][actor.x +dir.x] == '.'; |
7 |
}
|
Bewegung und Kampf
Wir sind endlich zu einer Interaktion gekommen: Bewegung und Kampf! Da bei klassischen Roguelikes der Basisangriff durch den Wechsel zu einem anderen Akteur ausgelöst wird, behandeln wir beide an derselben Stelle, unserer moveTo()
-Funktion, die einen Akteur und eine Richtung nimmt (die Richtung ist der gewünschte Unterschied in x
und y
zu der Position, in die der Akteur eintritt):
1 |
function moveTo(actor, dir) { |
2 |
// check if actor can move in the given direction
|
3 |
if (!canGo(actor,dir)) |
4 |
return false; |
5 |
|
6 |
// moves actor to the new location
|
7 |
var newKey = (actor.y + dir.y) +'_' + (actor.x + dir.x); |
8 |
// if the destination tile has an actor in it
|
9 |
if (actorMap[newKey] != null) { |
10 |
//decrement hitpoints of the actor at the destination tile
|
11 |
var victim = actorMap[newKey]; |
12 |
victim.hp--; |
13 |
|
14 |
// if it's dead remove its reference
|
15 |
if (victim.hp == 0) { |
16 |
actorMap[newKey]= null; |
17 |
actorList[actorList.indexOf(victim)]=null; |
18 |
if(victim!=player) { |
19 |
livingEnemies--; |
20 |
if (livingEnemies == 0) { |
21 |
// victory message
|
22 |
var victory = game.add.text(game.world.centerX, game.world.centerY, 'Victory!\nCtrl+r to restart', { fill : '#2e2', align: "center" } ); |
23 |
victory.anchor.setTo(0.5,0.5); |
24 |
}
|
25 |
}
|
26 |
}
|
27 |
} else { |
28 |
// remove reference to the actor's old position
|
29 |
actorMap[actor.y + '_' + actor.x]= null; |
30 |
|
31 |
// update position
|
32 |
actor.y+=dir.y; |
33 |
actor.x+=dir.x; |
34 |
|
35 |
// add reference to the actor's new position
|
36 |
actorMap[actor.y + '_' + actor.x]=actor; |
37 |
}
|
38 |
return true; |
39 |
}
|
Grundsätzlich:
- Wir stellen sicher, dass der Akteur versucht, eine gültige Position einzunehmen.
- Wenn sich ein anderer Akteur in dieser Position befindet, greifen wir ihn an (und töten ihn, wenn seine HP-Zahl 0 erreicht).
- Wenn es keinen anderen Akteur in der neuen Position gibt, ziehen wir dorthin.
Beachten Sie, dass wir auch eine einfache Siegesmeldung anzeigen, sobald der letzte Feind getötet wurde, und false
oder true
zurückgeben, je nachdem, ob wir einen gültigen Zug ausgeführt haben oder nicht.
Kehren wir nun zu unserer Funktion onKeyUp()
zurück und ändern Sie sie so, dass wir jedes Mal, wenn der Benutzer eine Taste drückt, die Positionen des vorherigen Akteurs vom Bildschirm löschen (indem Sie die Karte oben zeichnen) und den Spielercharakter auf den neuen verschieben Ort, und zeichnen Sie dann die Akteure neu:
1 |
function onKeyUp(event) { |
2 |
// draw map to overwrite previous actors positions
|
3 |
drawMap(); |
4 |
|
5 |
// act on player input
|
6 |
var acted = false; |
7 |
switch (event.keyCode) { |
8 |
case Phaser.Keyboard.LEFT: |
9 |
acted = moveTo(player, {x:-1, y:0}); |
10 |
break; |
11 |
|
12 |
case Phaser.Keyboard.RIGHT: |
13 |
acted = moveTo(player,{x:1, y:0}); |
14 |
break; |
15 |
|
16 |
case Phaser.Keyboard.UP: |
17 |
acted = moveTo(player, {x:0, y:-1}); |
18 |
break; |
19 |
|
20 |
case Phaser.Keyboard.DOWN: |
21 |
acted = moveTo(player, {x:0, y:1}); |
22 |
break; |
23 |
}
|
24 |
|
25 |
// draw actors in new positions
|
26 |
drawActors(); |
27 |
}
|
Wir werden bald die Variable acted
verwenden, um zu wissen, ob die Feinde nach jeder Spielereingabe handeln sollen.

Grundlegende künstliche Intelligenz
Jetzt, da sich unser Spielercharakter bewegt und angreift, können wir die Chancen ausgleichen, indem wir die Feinde dazu bringen, gemäß einer sehr einfachen Wegfindung zu handeln, solange der Spieler sechs Schritte oder weniger von ihnen entfernt ist. (Wenn der Spieler weiter entfernt ist, geht der Feind zufällig.)
Beachten Sie, dass es unserem Angriffscode egal ist, wen der Akteur angreift. Dies bedeutet, dass sich die Feinde gegenseitig angreifen, wenn Sie sie genau richtig ausrichten, während sie versuchen, den Spielercharakter im Doom-Stil zu verfolgen!
1 |
function aiAct(actor) { |
2 |
var directions = [ { x: -1, y:0 }, { x:1, y:0 }, { x:0, y: -1 }, { x:0, y:1 } ]; |
3 |
var dx = player.x - actor.x; |
4 |
var dy = player.y - actor.y; |
5 |
|
6 |
// if player is far away, walk randomly
|
7 |
if (Math.abs(dx) + Math.abs(dy) > 6) |
8 |
// try to walk in random directions until you succeed once
|
9 |
while (!moveTo(actor, directions[randomInt(directions.length)])) { }; |
10 |
|
11 |
// otherwise walk towards player
|
12 |
if (Math.abs(dx) > Math.abs(dy)) { |
13 |
if (dx < 0) { |
14 |
// left
|
15 |
moveTo(actor, directions[0]); |
16 |
} else { |
17 |
// right
|
18 |
moveTo(actor, directions[1]); |
19 |
}
|
20 |
} else { |
21 |
if (dy < 0) { |
22 |
// up
|
23 |
moveTo(actor, directions[2]); |
24 |
} else { |
25 |
// down
|
26 |
moveTo(actor, directions[3]); |
27 |
}
|
28 |
}
|
29 |
if (player.hp < 1) { |
30 |
// game over message
|
31 |
var gameOver = game.add.text(game.world.centerX, game.world.centerY, 'Game Over\nCtrl+r to restart', { fill : '#e22', align: "center" } ); |
32 |
gameOver.anchor.setTo(0.5,0.5); |
33 |
}
|
34 |
}
|
Wir haben auch eine Game-Over-Nachricht hinzugefügt, die angezeigt wird, wenn einer der Feinde den Spieler tötet.
Jetzt müssen die Feinde nur noch jedes Mal handeln, wenn sich der Spieler bewegt. Dazu müssen Sie am Ende unserer onKeyUp()
-Funktionen Folgendes hinzufügen, bevor Sie die Akteure in ihre neue Position ziehen:
1 |
function onKeyUp(event) { |
2 |
...
|
3 |
// enemies act every time the player does
|
4 |
if (acted) |
5 |
for (var enemy in actorList) { |
6 |
// skip the player
|
7 |
if(enemy==0) |
8 |
continue; |
9 |
|
10 |
var e = actorList[enemy]; |
11 |
if (e != null) |
12 |
aiAct(e); |
13 |
}
|
14 |
|
15 |
// draw actors in new positions
|
16 |
drawActors(); |
17 |
}
|

Bonus: Haxe Version
Ich habe dieses Tutorial ursprünglich in einem Haxe geschrieben, einer großartigen Multi-Plattform-Sprache, die (unter anderem) mit JavaScript kompiliert wird. Obwohl ich die obige Version von Hand übersetzt habe, um sicherzustellen, dass wir eigenwilliges JavaScript erhalten. Wenn Sie wie ich Haxe JavaScript vorziehen, finden Sie die Haxe-Version im Ordner haxe
des Quelldownloads.
Sie müssen zuerst den Haxe-Compiler installieren und können den gewünschten Texteditor verwenden und den Haxe-Code kompilieren, indem Sie haxe build.hxml
aufrufen oder auf die Datei build.hxml
doppelklicken. Ich habe auch ein FlashDevelop-Projekt eingefügt, wenn Sie eine nette IDE einem Texteditor und einer Befehlszeile vorziehen. Öffnen Sie einfach rl.hxproj
und drücken Sie F5, um zu starten.
Zusammenfassung
Das ist es! Wir haben jetzt ein ganz einfaches Roguelike mit zufälliger Kartengenerierung, Bewegung, Kampf, KI und sowohl Gewinn- als auch Verlustbedingungen.
Hier sind einige Ideen für neue Funktionen, die Sie Ihrem Spiel hinzufügen können:
- mehrere Ebenen
- Einschalten
- Inventar
- Verbrauchsmaterial
- Ausrüstung
Genießen Sie!