Du willst also ein altes BASIC-Programm ausführen, so mit Zeilen wie diesen hier?
4300 REM***************************
4310 REM SUBROUTINE TO CALCULATE HIT PTS
4320 REM***************************
4330 Y = Y + 1
4340 PS = INT((RND(1) * HF) + 1)
4350 PS(Y) = PS
4360 IF Y <> LL THEN 4330
4370 FOR Y = 1 TO LL
4380 PS = PS + PS(Y)
4390 NEXT Y
4400 PT = PS + PF
4410 IF PT <= 0 THEN 4430
4420 GOTO 4440
4430 PT = LL
4440 PRINT "YOUR CHARACTER WOULD HAVE"; PT; " HIT POINTS!"
4450 PRINT
4460 Y = 0
4470 RETURN
Bauen wir dafür einen BASIC-Interpreter in JavaScript.
Wir zerlegen das als String vorliegende Programm in eine Folge von einfacher zu verarbeitenden Tokens, die so eine Folge von BASIC-Befehlen (z.B. FOR
oder IF
) bilden. Alle Token stehen in einem JavaScript-Array und ein Index zeigt auf das aktuelle Token.
Um Befehle bequem nacheinander ausführen zu können, wollen wir die Zeilennummern aus der Token-Folge heraushalten und parallel zu dem Token-Array eine Abbildung von Zeilennummern auf Indizes in das Array erzeugen. Dazu benutzen wir ein JavaScript-Objekt. An Stelle der Zeilennummer fügen wir ein :
ein, dieses Zeichen trennt innerhalb einer Zeile mehrere Befehle und dient jetzt dazu, einen Befehl abzuschließen.
Der Interpreter wird nun für jeden Befehl, auf den der Index zeigt, eine JavaScript-Methode aufrufen, die den Befehl implementiert und dabei sicherstellt, dass er abgeschlossen ist. Danach passiert das selbe für den nächsten Befehl. Sprung-Befehle manipulieren den Index, indem sie die Abbildung von Zeilennummern auf Indizes benutzen. Wir verwalten außerdem Variablen und einen Stack für GOSUB
und FOR
-Schleifen.
Ich möchte einen regulären Ausdruck nutzen, um Token zu finden.
- Mit
/\d+/
finde ich Ganzzahlen (z.B. Zeilennummern). - Mit
/\d+(\.\d*)?|\.\d+/
finde ich auch Fließkommazahlen, z.B. ".5
". - Mit nachgestelltem
/\w+/
finde ich dann alle Namen, die keine Zahlen sind, z.B. "A9
" oder "PRINT
". Mit/[a-zA-Z]\w*/
wäre es eindeutig, aber ich liebe meine regulären Ausdrücke kurz und knackig. - Da String-Variablen und String-Funktionen ein nachgestelltes
$
haben, benutze ich/\w+\$?/
, da es mir einfacher erscheint, das$
als Teil des Namens zu betrachen. - Mit
/"[^"]*"/
finde ich String-Literale. - Mit
/[-+*/():;,=]|<[=>]?|>=?/
finde ich alle Operatoren (Grundrechenarten und Vergleiche) und sonstige Syntax. /REM.*$/m
erkennt alle Kommenatare, was ich vor dem Finden von Namen machen muss.
Die folgende JavaScript-Funktion kombiniert alle Teilausdrücke und kürzt Kommentare auf REM
, damit ich sieht später einfacher ignorieren kann. Ich "missbrauche" replace()
, um einen String bequem zu iterieren:
function tokenize(source) {
const tokens = [];
source.replace(/\d+(\.\d*)?|\.\d+|REM.*$|\w+\$?|"[^"]*"|[-+*/():;,=]|<[=>]?|>=?/gm, m => {
tokens.push(/^REM/.test(m) ? "REM" : m);
});
return tokens;
}
So kann ich mir das Ergebnis anschauen:
console.log(tokensize(source));
Hinweis: Als Gegenprobe, ob ich nichts vergessen habe, kann ich in der anonymen Funktion, die ich replace
übergebe, einfach mal ""
zurückgeben und mir dann anschauen, was das Ergebnis von replace
ist, wenn ich auch noch trim
aufrufe. Ist das ein Leerstring, habe ich keine unbekannten Zeichen, die ich nicht mit meinem regulären Ausdruck erwische.
Ich muss Zeilennummern erkennen, um einen Index aufzubauen. Dazu nutze ich aus, das jeder Zeilennummer (außer der ersten, wo ich das einfach erwinge) ein Zeilenumbruch vorausgeht. Ich sorge außerdem dafür, dass Befehle immer mit :
beendet werden. Hier ist die angepasste tokenize
-Funktion:
function tokenize(source) {
const tokens = [], lines = {};
source.replace(/\n\d+|\d+(\.\d*)?|\.\d+|REM.*$|\w+\$?|"[^"]*"|[-+*/():;,=]|<[=>]?|>=?/gm, m => {
if (/^\n/.test(m)) {
if (tokens.length) { tokens.push(":"); }
lines[m.slice(1)] = tokens.length;
} else {
tokens.push(/^REM/.test(m) ? "REM" : m);
}
});
return {tokens, lines};
}
Leider beherrscht Node 5.4 immer noch nicht vollständig ES2015, sodass mein geplantes destructing assignment nicht funktioniert und ich schreibe tokenize
als Methode einer Klasse um:
class Basic {
constructor(source) {
this.tokens = [];
this.lines = {};
// ...
this.tokenize(source);
}
tokenize(source) {
source.replace(/\n\d+|\d+(\.\d*)?|\.\d+|REM.*$|\w+\$?|"[^"]*"|[-+*/():;=]|<[=>]?|>=?/gm, m => {
if (/^\n/.test(m)) {
if (this.tokens.length) { this.tokens.push(":"); }
this.lines[m.slice(1)] = this.tokens.length;
} else {
this.tokens.push(/^REM/.test(m) ? "REM" : m);
}
});
}
}
Damit entsteht so etwas:
tokens: [
'REM', ':',
'REM', ':',
'REM', ':',
'Y', '=', 'Y', '+', '1', ':',
'PS', '=', 'INT', '(', '(', 'RND', '(', '1', ')', '*', 'HF', ')', '+', '1', ')', ':',
...
]
lines: {
'4300': 0,
'4310': 2,
'4320': 4,
'4330': 6,
'4340': 12,
...
}
Über eine index
-Variable werde ich nun auf ein Token zugreifen. Dazu definiere ich mir vier neue Methoden. Die Methode next
liefert das nächste Token und zählt index
hoch. Die Methode back
geht einen Schritt zurück; sie liefert this
, um danach bequem eine andere Methode aufrufen zu können. Die Methode at
prüft, ob das nächste Token dem übergebenen entspricht oder geht sonst wieder zurück. Die Methode expect
funktioniert wie at
, wirft aber einen Fehler, wenn das Token nicht vorliegt.
class Basic {
constructor(source) {
this.tokens = [];
this.lines = {};
this.index = 0;
// ...
this.tokenize(source);
}
next() {
return this.tokens[this.index++];
}
back() {
--this.index; return this;
}
at(token) {
return this.next() === token ? true : this.back(), false;
}
expect(token) {
this.at(token) || this.error(`expected ${token} but found ${this.next()}`);
}
error(message) {
throw new Error(message);
}
...
So kann ich dann das in Tokens zerlegte BASIC-Programm ausführen:
run() {
while (true) { this[this.next()].call(this); }
}
Dies sucht zu jedem Befehl eine gleichnamige Methode und ruft sie auf.
Wir können nun den Interpreter laufen lassen und inkrementell jeden Befehl so weit implementieren, wir es nötig ist, damit das Programm läuft. Das scheitert natürlich sofort, da es keine Methode REM
gibt.
Eine Abschätzung, welche Befehle wir implementieren müssen, ist auf jedes Token nach einem :
zu schauen, dabei aber alle Zuweisungen zu ignorieren:
let commands = {};
for (let i = 0; i < this.tokens.length; i++) {
if (this.tokens[i] === ":" && this.tokens[i + 2] !== "=") {
commands[this.tokens[i + 1]] = true;
}
}
console.log(Object.keys(commands));
Dies ergibt für das vollständige Programm:
[ 'REM', 'DIM', 'RANDOMIZE', 'PRINT', 'INPUT', 'ON', 'IF', 'GOTO',
'LET', 'GOSUB', 'CLEAR', 'END', 'PS', 'FOR', 'NEXT', 'RETURN' ]
Beginnen wir mit dem ersten Befehl.
Für REM
ist nicht mehr zu tun, als sicher zu stellen, dass der Befehl zuende ist. Den Text hinter REM hatten wir ja schon in tokenize
entfernt:
REM() {
this.expect(":");
}
Der nächste Befehl im eigentlichen Programm ist DIM
:
1140 DIM PS(7)
Ich muss einen Namen und einen geklammerten Ausdruck einlesen und dann ein Array mit entsprechend vielen Einträgen anlegen. Es müsste alles Nullen (oder Leerstrings, wenn die Variable auf $
endet) enthalten, aber das regle ich später beim Zugriff auf eine Variable. Hier ist die Implementierung:
DIM() {
const name = this.name();
const size = this.evaluate();
this.expect(":");
this.variables[name + "()"] = Array(size + 1);
}
Die Methode name
stellt sicher, dass das nächste Token auch ein Name ist:
name() {
const token = this.next();
if (/^[a-zA-Z]/.test(token)) { return token; }
this.error(`expected name but found ${token}`);
}
Die Methode evaluate
wertet einen beliebig komplexen Ausdruck aus. Tatsächlich müsste ich sicherstellen, dass dieser in Klammern steht, aber egal, ich gehe einfach einmal davon aus, dass das BASIC-Programm syntaktisch korrekt ist:
evaluate() {
return this.evalTerm(); // TODO
}
evalTerm() {
return this.evalFactor(); // TODO
}
evalFactor() {
if (this.at("(")) {
const value = this.evaluate();
this.expect(")");
return value;
}
const token = this.next();
if (/^\d+(\.\d*)?|^\.\d+/.test(token)) return +token;
// ...
this.error(`expected number, but found ${token}`);
}
Die drei Methoden sind noch nicht komplett sondern enthalten erst einmal nur das nötigste, um den Ausdruck (7)
auszuwerten. Ich werde sie später noch ergänzen müssen, um arithmetische Operationen mit Punkt- vor Strichrechnung zu implementieren und um String-Literale oder Variablenzugriffe zu unterstützen.
Da Array-Variablen bei BASIC einen eigenen Namensraum bilden, füge ich dem Namen noch ()
hinzu. Dann lege ich das Array in variables
, einem Objekt, über das ich alle Variablen implementieren werden ab. Ich muss noch 1 addieren, weil DIM A(10)
ein Array mit den Indizes 0 bis 10 anlegt, also eines mit 11 Elementen.
Diesen Befehl können wir ignorieren, denn ich kann in JavaScript nicht explizit den Pseudozufallszahlengenerator initialisieren:
RANDOMIZE() {
this.expect(":");
}
Der nächste interessante Befehl ist PRINT
. Hier einige Beispiele:
1160 PRINT CHR$(12)
1170 PRINT
1180 PRINT " DM'S PERSONNEL SERVICE"
...
1630 PRINT "*ADD"; SF; "TO ROLLS TO HIT,DAMAGE,OPEN DOORS"
PRINT
kann eine durch ;
getrennte Liste von Ausdrucken als Strings anzeigen. Ich meine mich zu erinnern, das positive Zahlen automatisch ein Leerzeichen vorangestellt bekommen. Mit CHR$
kann ich eine Zahl in ein Zeichen bzw. einen String mit der Länge 1 umwandeln, in diesem Fall ein Zeichen, dass den Bildschirm löschen soll. Da mein Terminal auf ASCII 12 = \f
so überhaupt nicht reagiert, ersetze ich das durch die Sequenz \x1b[H\x1b[J
, was das VT100-Kommando ist, um den Cursor nach links oben zu setzen und dann den Bildschirm zu löschen. Eigentlich müsste \x1b[2J
reichen, tut es aber nicht.
PRINT
folgt der folgenden EBNF-Grammatik:
print = "PRINT" [expression {";" expression} [";"]]
Ohne Argumente wird einfach ein Zeilenumbruch ausgegeben. Ein oder mehrere Ausdrücke werden zu Strings konvertiert und ausgegeben, vielleicht mit einer intelligenten Regel, wie Leerzeichen eingefügt werden, die ich aber nicht kenne. Ich weiß, dass ,
bei BASIC noch einen anderen Effekt hat, aber das kommt in diesem Programm glücklicherweise nicht vor. Nur wenn der Befehl nicht mit einem ;
endet (was in diesem Programm wohl auch nicht vorkommt, aber egal), wird dann ein Zeilenumbruch ausgegeben.
PRINT() {
if (!this.at(":")) {
print(this.evaluate());
while (this.at(";")) {
if (this.at(":")) return;
print(this.evaluate());
}
this.expect(":");
}
print("\n");
function print(o) {
Console.write(typeof o === "string" ? o : o < 0 ? o : " " + o);
}
}
Jetzt muss ich evalFactor
erweitern, damit erstens CHR$
funktioniert, damit ich zweitens auf Variablen zugreifen kann, und damit ich drittens String-Literale verstehe:
...
const token = this.next();
if (/^"/.test(token)) return token.substring(1, token.length - 1);
if (/^\d+(\.\d*)?|^\.\d+/.test(token)) return +token;
if (/^[a-zA-Z]/.test(token)) {
if (token === "CHR$") {
const value = this.evalFactor();
return value === 12 ? "\x1b[H\x1b[J" : String.fromCharCode(value);
}
// ...
if (this.at("(")) {
const index = this.back().evalFactor();
return this.variables[token + "()"][index] || def(token);
}
return this.variables[token] || def(token);
}
this.error(`expected number, string, variable or function, but found ${token}`);
function def(name) {
return /\$$/.test(name) ? "" : 0;
}
}
Die lokale Funktion def
kümmert sich darum, ob eine noch nicht explizit gesetzte Variable wohl 0 oder einen Leerstring als Standardwert hat.
Mit INPUT C
kann ich einen Wert in eine Variable einlesen. Optional kann der Befehl noch einen Prompt anzeigen, d.h. INPUT "foo"; C
entspricht PRINT "foo";:INPUT C
. Es kann eine Zahl oder ein String eingelesen werden.
Die EBNF-Grammatik sieht so aus:
input = "INPUT" [string ";"] variable
variable = name ["$"]
Eigentlich könnte INPUT
auch mehr Werte in mehr als eine Variable einlesen oder auch in indizierte Variablen, aber das braucht dieses Programm glücklicherweise nicht. Daher sieht die Implementierung so aus:
INPUT() {
const token = this.next();
this.back();
if (/^"/.test(token)) {
Console.write(this.evalFactor());
this.expect(";");
}
const name = this.name();
this.expect(":");
Console.write("? ");
const input = Console.read();
this.variables[name] = /\$$/.test(name) ? input : +input;
}
Der nächste Befehl, den mein immer umfangreicher werdender BASIC-Interpreter nicht versteht, ist ein bedingter Sprung. An dieser Stelle im Programm ist er sogar auch noch total überflüssig, aber egal, ich werde ihn implementieren:
// "ON" expression "GOTO" number {"," number}
ON() {
const value = this.evaluate();
this.expect("GOTO");
const lines = [this.next()];
while (this.at(",")) lines.push(this.next());
this.expect(":");
if (value >= 1 && value <= lines.length) {
this.index = this.lines[lines[value - 1]];
}
}
Ich lese einen Wert, das Schlüsselwort GOTO
(die Variante mit GOSUB
kommt glücklicherweise nicht vor) und eine Liste von Zeilennummern ein. Liegt der Wert zwischen 1 und der Anzahl der Zeilennummern, wähle ich die passende aus, schaue nach, welchen Token-Index diese Zeile hat und setze index
auf diesen Wert, um so an dieser Stelle im Programm weiter zu machen.
Der nächste Befehl ist eigentlich gar keiner, es ist eine Zuweisung, die man optional mit LET
einleiten kann, es aber nicht braucht, also von der Annahme abweicht, das jeder Befehl mit einem Schlüsselwort beginnt:
1360 L = L + 1
Dazu muss ich run
anpassen:
run() {
while (true) { (this[this.name()] || this["assign"]).call(this); }
}
assign() {
const name = this.back().name();
this.expect("=");
this.variables[name] = this.evaluate();
this.expect(":");
}
Ich muss (für das Beispiel) außerdem +
(und später auch -
) unterstützen:
evaluate() {
let value = this.evalTerm();
while (true) {
if (this.at("+")) value += this.evalTerm();
else if (this.at("-")) value -= this.evalTerm();
else return value;
}
}
Nun ist auch diese Zuweisung nicht mehr schwer zu implementieren:
1370 Z = INT((RND(1) * 6) + 1)
Ich muss *
(und später /
) sowie die Funktionen INT
und RND
implementieren:
evalTerm() {
let value = this.evalFactor();
while (true) {
if (this.at("*")) value *= this.evalFactor();
else if (this.at("/")) value /= this.evalFactor();
else return value;
}
}
evalFactor() {
...
if (/^[a-zA-Z]/.test(token)) {
if (token === "CHR$") {
const value = this.evalFactor();
return value === 12 ? "\x1b[H\x1b[J": String.fromCharCode(value);
}
if (token === "INT") {
return Math.floor(this.evalFactor());
}
if (token === "RND") {
this.evalFactor();
return Math.random();
}
...
}
...
}
Das Argument von RND
kann, nein, muss ich ignorieren, das ist ein historischer Unfall von BASIC, weil so weit ich weiß, es im ersten BASIC-Interpreter nicht möglich war, Funktionen zu definieren, die kein Argument haben.
Mit IF
kann ich eine Bedingung auswerten und abhängig davon zu einer anderen Zeile springen. Der hier vorliegende BASIC-Dialekt erlaubt es alternativ, auch ein oder mehrere durch :
getrennte Befehle auszuführen, was es leider etwas komplizierter macht.
IF() {
const value = this.condition();
this.expect("THEN");
let token = this.next();
if (/^\d/.test(token)) {
this.expect(":");
if (value) this.index = this.lines[token];
} else {
this.back();
if (!value) this.skipToNextLine();
}
}
skipToNextLine() {
let index = Number.MAX_VALUE;
Object.keys(this.lines).forEach(line => {
const i = this.lines[line];
if (i >= this.index) index = Math.min(i, index);
});
this.index = index;
}
condition() {
let value = this.condTerm() ? 1 : 0;
while (this.at("OR")) value |= (this.condTerm() ? 1 : 0);
return value;
}
condTerm() {
let value = this.condFactor() ? 1 : 0;
while (this.at("AND")) value &= (this.condFactor() ? 1 : 0);
return value;
}
condFactor() {
let value = this.evaluate();
if at("=") return value == this.evaluate();
if at("<") return value < this.evaluate();
if at("<=") return value <= this.evaluate();
if at(">") return value > this.evaluate();
if at(">=") return value >= this.evaluate();
if at("<>") return value != this.evaluate();
throw new Error("expected conditional operator");
}
Zu beachten ist, dass BASIC keine Wahrheitswerte kennt, sondern 0 und 1 benutzt und ich daher das Ergebnis der Vergleiche entsprechend konvertiere. Jetzt wo ich diesen Text schreibe, fragt ich mich allerdings, warum ich nicht die Operationen selbst gekapselt habe, sondern es an der Aufrufstelle mache. Jetzt lasse ich's aber so. Glücklicherweise kommt JavaScript auch mit 0 und 1 in einem if
oder while
klar, sodass ich hier nichts weiter machen muss.
Was jetzt noch fehlt sind Sprungbefehle. Für GOSUB
nutze ich einen speziellen Stack, auf den ich den aktuellen index
schreibe, bevor ich ihn ändere. Dann kann RETURN
die Ausführung an dieser Stelle wieder fortsetzen.
GOTO() {
this.index = this.lines[this.next()];
}
GOSUB() {
const index = this.lines[this.next()];
this.expect(":");
this.stack.push(this.index);
this.index = index;
}
RETURN() {
this.index = this.stack.pop();
}
Schließlich kommt an einer Stelle im Programm noch eine FOR
-Schleife vor.
FOR() {
const name = this.name();
this.expect("=");
const start = this.evaluate();
this.expect("TO");
const stop = this.evaluate();
this.expect(":");
this.variables[name] = start;
this.stack.push(name, stop, this.index);
}
NEXT() {
this.next();
this.expect(":");
const index = this.stack.pop();
const stop = this.stack.pop();
const name = this.stack.pop();
this.variables[name] += 1;
if (this.variables[name] <= stop) {
this.index = index;
this.stack.push(name, stop, index);
}
}
Eigentlich unterstützt FOR
noch einen optionalen STEP
und kann damit auch runter zählen. Beides brauche ich für mein Programm nicht. Nachdem ich die angegebene Variable mit dem Startwert initialisiert habe, merke ich mir auf dem selben Stack, den ich auch für GOSUB
und RETURN
benutze, den Namen der Variablen, den Endwert und den index
des Schleifenanfangs. In NEXT
(wo ich die optionale Schleifenvariable ignoriere) hole ich die drei Werte wieder vom Stack, erhöhe die Schleifenvariable und prüfe, ob ich noch einen weiteren Schleifendurchlauf benötige. In diesem Fall setze ich index
auf den Anfang und schreibe die drei Werte wieder auf den Stack – für das nächste NEXT
. Ich hätte mir sogar sparen können, den Namen der Schleifenvariable auf den Stack zu schreiben, sondern ihn vom NEXT
nehmen können. Ich vermute, so musste es beim Ur-BASIC 1964 auch sein.
Nun sollte der BASIC-Interpreter prinzipiell funktionieren. Er kann garantiert nicht jedes Programm ausführen, aber das vorliegende läuft mit dem hier gezeigten Code, wenn ich diese Zeile ausführe:
new Basic(source).run();
Startbildschirm:
DM'S PERSONNEL SERVICE
-----------------------------------------
PRODUCES CHAR. ABILITY SCORES
FOR
DUNGEONS & DRAGONS™
IF YOU ARE READY FOR THE 1ST
CHARACTER, ENTER A ONE (1).
? |
Attribute:
CHARACTER'S STRENGTH IS 17
*ADD 2TO ROLLS TO HIT,DAMAGE,OPEN DOORS
CONSTITUTION SCORE IS 15
CHARACTER'S INTELLIGENCE 12
* LITERATE IN NATIVE TONGUE.
*45% TO KNOW SPELL-MIN/MAX PER LVL:5/7
CHARACTER'S DEXTERITY IS 14
*ADD 1TO MISSILE FIRE ROLLS 'TO HIT'
* SUBTRACT 1 FROM ARMOR CLASS.
CHARACTER'S WISDOM IS 16
*ADD 2TO ROLL-MAGIC BASED SAVING THROW
CHARACTER'S CHARISMA IS 10
*CAN HAVE 4 RETAINERS WITH MORALE OF 4
IF YOU HAVE THIS DATA AND ARE READY TO
PROCEED, ENTER A ONE (1).
? |
Klassenauswahl:
CLASS/RACE LIST
-----------------------------------------
(1) FIGHTER (4) HALFLING
(2) MAGIC USER (5) ELF
(3) CLERIC (6) DWARF
(7) THIEF
SELECT THE RACE/CLASS THAT YOU WISH
YOUR CHARACTER TO HAVE AND ENTER THE
NUMBER FROM THE TABLE ABOVE? |
Anzeige der Trefferpunkte:
YOUR CHARACTER HAS 1 HIT DICE
YOUR CHARACTER WOULD HAVE 9 HIT POINTS!
THIS IS CHARACTER #[ 1]
DO YOU WANT THIS CHARACTER(Y/N)? |
Zusammenfassung:
RECAP OF CHARACTER ABILITIES
NAME.... Udo
RACE.... Human HIT DICE: 1
GENDER.. Male
CLASS... Elf HIT POINTS: 9
LEVEL... 1
N O T E !! COPY THIS AND THE INFORMATION
THAT FOLLOWS TO THE CHARACTER
RECORD SHEET. IT WILL NOT BE
AVAILABLE AGAIN!
TO CONTINUE,ENTER A ONE(1)? |
Zusammenfassung, Teil 2:
0'S STRENGTH IS.. . . . . 17
*ADD 2TO ROLL TO:'HIT,DAMAGE,OPEN DOORS
0'S CONSTITUTION IS.. 15
0'S INTELLIGENCE IS.. 12
* LITERATE IN NATIVE TONGUE.
0'S DEXTERITY IS.... 14
*ADD 1TO MISSILE FIRE ROLLS 'TO HIT'
*SUBTRACT 1 FROM ARMOR CLASS.
0'S WISDOM IS....... 16
*ADD* 2TO ROLL-MAGIC BASED SAVING THROWS
0'S CHARISMA IS...... 10
*CHAR MAY HAVE 4RETAINERS - MORALE OF 4
TO CONTINUE,ENTER A ONE(1)? |
Zusammenfassung, Teil 3:
SAVING THROW TABLE
DEATH : :PARALYSIS: :RODS
RAY OR:MAGIC: OR TURN :DRAGON:STAVES
POISON:WANDS:TO STONE :BREATH:OR SPELLS
------:-----:---------:------:---------
..12....13......13.......15......15
HAS 60' INFRA-VISION.
DETECTS OR SECRET DOORS ON 1-2(1D6).
IMMUNE TO PARALYSIS FROM GHOUL ATTACK.
SPEAKS ELVISH,ORC,HOB-GOBLIN,AND GNOLL.
MAY USE SPELLS AND MAGIC ARTICLES.
THIS CHARACTER HAS[ 50] GOLD PIECES.
ANOTHER CHARACTER (Y/N)? |
Fertig.