HARALD MELCHER

Startrek in C

Verbesserter Parser

Im bisherigen Code tauchen Abschnitte mehrfach auf – das sind Verbesserungsmöglichkeiten. Gerade die Anweisungen zum

  • Ausgeben eines Fragetextes (ohne Linefeed)
  • Flushen des Ausgabebuffers (der sonst nur bei Linefeed geflusht wird)
  • Einlesen der Antwort der Spielerin
  • Entfernen des schließenden Linefeeds

tauchen mehrfach auf. Lagere sie in eine Funktion void ask(char* question, char* buffer, int bufsize) aus!

Die Funktion parse_command(char* cmd) überprüft bislang alle Möglichkeiten in getrennten if-Anweisungen. Dies ersetzt du durch eine Tabelle mit Kommandonamen und den passenden enums. Damit kannst du auch einfach mehrere unterschiedliche Bezeichnungen für das gleiche Kommando einführen.

Außerdem wird der Code dann das Kommando nur auf soviele Buchstaben vergleichen, wie die Benutzereingabe lang ist.

Zunächst wird eine kleine Hilffunktion int command_length(char *cmd) die Länge des Kommandos ermitteln, indem sie die Zeichen bis zum ersten Leerzeichen, Zeilenende oder Stringende zählt.

Ein typedef für den Typ command_list_entry beschreibt einen einzelnen Eintrag in der Kommandoliste, die ein Array command_list dieser Einträge ist.

typedef struct {
    char* command;
    int command_code;
} command_list_entry ;

In den Initialisierer des (globalen) command_list-Array kommen jetzt Paare aus Kommandos und den dazu passenden enums.

command_list_entry command_list[] = {
        {"exit", exit},
        {"help", help},
        {"srs", short_range_scan},
        {"lrs", long_range_scan}
};

Die Funktion parse_command(char* cmd) muss jetzt nur noch alle Einträge im command_list-Array durchgehen (s.u.) und einen Stringvergleich (mit Längenangabe aus dem Kommando) durchführen. Passt der Vergleich, liefert sie die dazugehörige enum zurück. Passt keiner der Strings, liefert sie das enum für unbekannt zurück.

Die Zahl der Einträge in einem Array errechnet sich aus der Größe des Arrays geteilt durch die Größe eines Array-Eintrags (also beispielsweise des ersten).

int num_entries = sizeof(command_list) / sizeof(command_list[0]);

Jetzt kannst du neue Einträge in die Kommandoliste einbauen, zum Beispiel “quit” als Alternative zu Exit.

Startrek in C

Long Range Scan

Unser Spiel lernt das Kommando lrs (“Long Range Scan” ). Dazu

  • erweitern wir parse_command() um lrs
  • erweitern wir unsere Hauptschleifen-switch um einen eventuellen Aufruf von do_lrs()

In der galaxy.h

  • definieren wir einen neuen Typ lrs_entry als struct mit drei Werten: der Zahl der Klingonen, der Zahl der Sonnen und der Zahl der Starbasen
  • definieren wir einen neuen Typ lrs_result als 3×3-Array von lrs_entrys

galaxy.c erhält die neue Funktion void galaxy_lr_scan(lrs_result scan), die in ein mitgeliefertes Ergebnisarray die Scans des aktuellen Quadranten und seiner 8 Nachbarn einträgt.
Hier hilft die bereits geschriebene Funktion quadrant_id(), die Ids der jeweiligen Quadranten zu finden. (Tipp: die um eine Quadrantenbreite verschobenen Positionswerte des Schiffs liegen im Nachbarquadranten).
Eine doppelte Schleife füllt dann schnell das 3×3-Array mit den Werten der zu schreibenden lrs_entry lr_scan_one_quadrant(int quadrant_to_scan).

rs_entry lr_scan_one_quadrant(int quadrant_to_scan) bereitet einen leeren lrs_entry vor und initialisiert ihn!

  • geht durch alle Units und schaut, ob sie im fraglichen Quadranten liegen
  • falls das zutrifft, schaut sie nach dem Typ der Unit und zählt entweder Klingonen, Sterne oder Sternbasen mit
  • liefert den gefüllten lrs_entry zurück (Wertetyp)

Die Funktion do_lrs() in main.c soll das Long-Range-Scan-Ergebnis anfordern und anzeigen. Dazu

  • Richtet sie eine Variable vom Ergebnistyp ein – glücklicherweise sieht der einfacher als, weil es dazu ein typedef gibt
  • übergibt diese Variable der Funktion galaxy_lr_scan()
  • findet nach deren Beendigung den Ergebnistyp gefüllt mit den Zahlen der Units in den umliegenden Quadranten
  • zeigt diese in zwei verschachtelten Schleifen an

Hierbei könnte (muss aber nicht) eine Funktion char* lr_symbol_of(lrs_entry entry, char *buffer) den jeweiligen Eintrag formatiert in einen übergebenen Ergebnisbuffer schreiben. Dies verkleinert den Code und verbessert seine Lesbarkeit. Die Funktion liefert einen Zeiger auf den Buffer zurück.
(Warum liefert sie den Zeiger auf den Buffer zurück, die aufrufende Funktion kennt doch ihren Buffer?)

Und so könnte der Long-Range-Scan dann aussehen:

Startrek in C

Short Range Scan

Es wird ein Kommando mehr geben: “srs” oder “shortrangescan”. Es soll alle Objekte im All “grafisch” anzeigen, die im gleichen Quadranten wie das Raumschiff liegen.

  • dazu ergänzt du zunächst das enum mit den Kommandos um short_range_scan.
  • die Funktion parse_command(...) soll das neue Kommando erkennen und das richtige enum zurückgeben.
  • der switch in der Hauptschleife soll auf das neue Kommando mit Aufruf von do_srs() reagieren. Diese Funktion führt den eigentlichen Scan-Code aus.

Der Short Range Scan liefert ein Array zurück, das für alle Sectoren im Quadranten des Raumschiffs angibt, was darin enthalten ist. Das sind also 8×8 Elemente – ein zweidimensionales Array. Zur besseren Lesbarkeit dient hier ein enum:

typedef char srs_result[8][8];

Der Code soll nun die zu schreibende Funktion void galaxy_sr_scan(srs_result) aufrufen. Da nach Beendigung dieser Funktion der Verbleib der Werte in der Funktion unbestimmt ist, erzeugt das Programm dieses 8×8-Array in der aufrufenden Funktion do_srs() und übergibt es als Parameter beim Aufruf von galaxy_sr_scan().


void galaxy_sr_scan(srs_result) setzt zunächst alle Positionen im Ergebnisarray auf leer (' ').

Dann berechnet sie mit int quadrant_id(short x, short y) eine eindeutige ID für den Quadranten der Position des Schiffs. Hier empfiehlt sich z.B. Spalte * 8 + Zeile des Quadranten. Sicherheitshalber regelt die quadrant_id()-Funktion auch gleich, was sie mit Parametern macht, die außerhalb des Spielfelds liegen. (Da das Verfahren für x und y das gleiche ist, könnte hier sogar eine Funktion short legalize(short coord) Code vereinfachen helfen.)

Eine einfache Schleife kann nun durch alle Units gehen und für jede:

  • den Quadranten der Unit berechnen
  • vergleichen, ob er mit dem Schiffsquadranten übereinstimmt (dann sind Unit und Schiff im gleichen Quadranten)
  • gegebenenfalls im Ergebnisarray den Typ auf den der aktuellen Unit setzen (Achtung: Koordinatenumrechnung auf “innerhalb des Quadranten”)

Zurück in der do_srs()-Funktion braucht diese Funktion nur noch die Scanwerte anzeigen:

  • eine Zeile mit Koordinatenwerten und etwas Abstand dazwischen
  • dann für jede der 8 Scan-Zeilen eine Zeile mit den 8 Scanwerten aus dieser Zeile. Da die Scanwerte aus einzelnen Buchstaben bestehen, könnte eine Funktion char* symbol_of(char c) die Werte mit einem Case-Statement in schöne Strings (s.u.) umrechnen.
  • und am Ende noch einmal eine Zeile mit Koordinatenwerten zur besseren Orientierung

Als Vorschlag für die Strings:

S<*>Schiff
**Stern
K###Klingone
B>!<Basis
spaceunbelegt


Das Ganze könnte dann so aussehen:

Startrek in C

Schöpfung des Weltalls

Körper im Weltall

Nachdem du bei bool bereits gesehen hast, dass man existierenden C-Type neue Namen geben kann, kombinieren wir das nun mit einem struct.

Ein struct ist ein Behälter, der ein oder mehrere Variable aufnehmen kann und sie mit Namen belegen, die nur in Verbindung mit dem struct gelten.

Der C Compiler behandelt struct wie die eingebauten Werte-Typen (z.B. int oder long). Das heißt, wenn ein struct als Argument einer Funktion auftaucht, übergibt der C-Compiler eine Kopie des struct – und damit kann die aufgerufene Funktion die übergeben Kopie zwar ändern, aber das “Original” bleibt unverändert.

Soll die aufgerufene Funktion auch das Original verändern können, übergibt man statt des structs (=Kopie) einen Zeiger auf das Original. (Genaunommen ist das dann eine Kopie des Zeigers, aber auch die zeigt auf das Original)

Die galaxy.h erhält nun ein struct, das dem typedef als Vorlage für einen neuen Typ-Namen dient:

typedef struct {
    char type;
    short x;
    short y;
} unit;

Auf Variablen in einem struct greift man mit der Punkt-Schreibweise zu, also zum Beispiel

unit klingon;
klingon.type = 'K';
klingon.x = 13;
klingon.y = 27;

Ist die Variable aber ein Pointer auf ein strukt, geht die Pfeilschreibweise, die weiter unten nochmal auftaucht.

unit* actual_unit = &units[4];
actual_unit->x = 16;
actual_unit->y = 3;

Initialisieren des Weltalls II

Aus main.c ruft der Code bereits das galaxy_init() von galaxy.c auf. Dieses Gerüst bekommt nun Leben:

In den Kopf von galaxy.c kommen drei #defines, die die Zahl der Sonnen auf 30, die Zahl der Klingonen auf 17 und die Zahl der Starbases auf 3 festlegen.

Ebenfalls vor die Funktionen kommt das Array units für die Körper im Weltall. Es muss ein Schiff und alle Sonnen, alle Klingonen und alle Starbases aufnehmen können, die alle vom neudefinierten Typ unit sind. Das sind 30+17+3+1 = 51 Units.

Dazu kommt eine Variable units_top_pos, in der das Programm die Zahl der Units mitzählt, die der Code bereits im Array eingefügt hat. Da noch keine Units im Array sind, startet ihr Wert bei 0.


Später werden wir auch so oft auf das Schiff (das dann erstes Element im Array ist) zugreifen, dass wir eine eigene Variable ship, die auch ein Unit ist, einführen. Dadurch vereinfacht sich der Zugriff auf das Schiff, das im ersten Element (index 0) des units-Arrays liegt.

Wenn wir dieser Variablen ship den Wert des ersten Arrayelements zuweisen würden, dann würde dadurch eine Kopie entstehen und wir könnten zwar Schiffspositionen in der Variablen ship (der Kopie) verändern, im Array wäre das erste Element aber unverändert. Daher erhält diese Variable den Typ (unit* ) und zeigt auf das erste Element. Tipp: Die Zuweisung von ship auf die Position des ersten Array-Elements geht sogar schon zu Compilezeit.


void galaxy_init() ruft insgesamt vier mal die Funktion void place_units(int count, char type) auf: Für 1 Ship, 30 Sonnen, 17 Klingonen und 3 Basen. Dabei gibt der erste Parameter die Zahl der zu erzeugenden Units an und der zweite Parameter das Kürzel für ihren Typ:

 S: Ship
*: Sonne
K: Klingone
B: Base

void place_units(int count, char type) wirft zuerst den Zufallszahlengenerator mit srand(time(0)); an. Dann führt sie für jede zu erstellende Unit aus:

  • die Unit in units, die die oberste im Array ist (die neu zu erstellende), erhält den im Parameter gewünschten Typ
  • die Funktion place_unit(&units[units_top_pos]) platziert die Unit auf einen freien Platz im Weltall. Sie erhält eine Referenz auf die zu platzierende Unit übergeben (und keine Kopie), weil sie den Wert der betreffenden Unit im Array ändern soll.
  • die Position des obersten belegten Elements im Array hat sich dadurch verändert…

void place_unit(unit* u) versucht, eine Unit im All unterzubringen. Sie erzeugt dazu in zwei Variablen für die x- und y-Koordinate mit rand() Zufallszahlen, die sie auf den Bereich von 0…63 einschränkt – die Spielfeldgröße. Dies wiederholt sie so lange, bis die Funktion char unit_type_at(x, y) angibt, dass die Position noch frei ist, indem sie ein Leerzeichen zurückgibt. Dann weißt place_unit() den Wert der Variablen x und y den Positionswerten der übergebenen Unit zu. Dazu gibt es zwei Möglichkeiten

  1. (*u).x = x; dereferenziert den Pointer und verwendet die bekannte Schreibweise für ein struct-Element
  2. u->x = x; ist die Pointer-Schreibweise – sie lässt schon eine Objektorientierung ahnen

char unit_type_at(int x, int y) gibt an, welchen Typ die Unit an der angegebenen Position hat. Dazu geht sie alle bis dahin eingetragenen Units durch und vergleicht die Koordinaten. Sind sie gleich wie die angefragten, dann liegt da schon eine Unit und die Funktion liefert dern Typ zurück. Ist bis zum Ende der Units im Array keines an der Position gewesen, dann ist das Feld noch frei und die Funktion liefert ein Leerzeichen (' ') zurück.


Eine probeweise in galaxy_init() eingebaute Schleife, die alle Objekte mit Typ und Position ausgibt, zeigt, ob das Beleben des Weltalls funktioniert hat.

Startrek in C

Kommandos eingeben

Die Hauptschleife des Programms nimmt jeweils ein Kommando entgegen, und arbeitet es ab. So lange, bis das Kommando das “Ende”-Kommando ist. Das klingt schon ziemlich nach einer do...while-Schleife.

Vor die Schleife kommt eine bool-Variable running, die die Ende-Bedingung der Schleife darstellt. Die Schleife arbeitet so lange, bis sie das Ende-Kommand erhält: Der Kommando-Parser in der Schleife setzt dann running auf FALSE und die Schleife beendet sich. Daher ist running mit TRUE initialisiert.

In die Schleife kommt:

  • eine Eingabeaufforderung der Art “Gib Kommando:”, gefolgt von fflush(), um den Puffer zu leeren. Das ist Copy und Paste von oben.
  • Einlesen eines Eingabestrings mit fgets() in einen Buffer command, und Entfernen des abschließenden Newline. Das ist Copy und paste von oben.
  • der Aufruf der zu schreibenden Funktion lowercase(char*) (s.u.), die den String an der Adresse des übergebenen Stringpointers in Kleinbuchstaben umsetzt.
  • der Aufruf der zu schreibenden Funktion int parse_command(char*) (s.u.), die versucht, das Kommando im Argument zu erkennen, und den Commande-Code (ein enum (s.u.)) für das erkannte Kommando zurückgibt.
  • ein switch-Statement, das je nach Kommando etwas anderes tut. Du wirst mit dem Kommando für exit anfangen: es soll running auf FALSE setzen, damit sich die Schleife beenden kann. Denk dran, die Statements eines case des switch-Befehls mit break abzuschließen.

void lowercase(char *) geht Zeichen für Zeichen bis zum Stringende ('\0') durch und konvertiert jedes Zeichen in Kleinbuchstaben – es überschreibt dabei die vorhanden Großbuchstaben.


Enums sind menschenverständliche Zahlenwerte. Vor Einführung der enums gab es in C nur #define als Möglichkeit, Zahlen einen Namen zuzuweisen. Du verwendest für die Kommandos ein Enum, das den enum-Bezeichnungen Werte ab Null aufsteigend zuweist:

enum {unknown, exit, help};

Damit erhält unnown den Wert 0, exit den Wert 1 und help den Wert 2.


int parse_command(char* command) prüft ab, ob die ersten drei Buchstaben des Kommandos mit einem der vorgegebenen Kommandos übereinstimmt und gibt das passende Enum zurück. (Die Funktion void strncmp(char*, char*, long) aus <string.h> ist dein Freund.)

  • wenn das Kommando mit “exi” anfängt, dann gib das Enum exit zurück
  • wenn das Kommando mit “hel” anfängt, dann gib das Enum help zurück
  • hier baust du weitere Kommandos ein

Jetzt müsste das Kommando “exit” bereits funktionieren. Das Kommando “help” ruft die Funktion void do_help() auf, die erklärt, welche Kommandos es gibt – und vielleicht auch ein bisschen, wie das Spiel funktioniert. Mit jedem neuen Kommando erweiterst du die do_help-Funktion um eine Erklärung desselben.

Startrek in C

Schiff benennen

Das Schiff soll nun von Spieler oder Spielerin einen Namen erhalten – der den Namenskonventionen für Raumschiffe folgt und nur Buchstaben, Bindestrich(e) und Zahlen enthält. Eine do...while-Schleife wird so lange Eingaben anfordern, bis der Name “brauchbar” ist.

In der Schleife steht

  • die Aufforderung einen Namen anzugeben, am besten mit fputs(). Danach soll KEIN Zeilenumbruch folgen, damit der Cursor fragend hinter dem Text stehenbleibt.
  • ein fflush() auf den Ausgabebuffer (der in manchen Implementationen sonst erst bei Newline geschrieben würde). Damit wird der Text im Ausgabebuffer (s.o.) gleich angezeigt.
  • ein fgets(), das den String in einen von dir vorbereiteten Puffer liest. fgets() ist aus Sicherheitsgründen hier besser als gets(), weil es eine Überprüfung auf Maximallänge der Benutzereingabe hat.
  • ein Aufruf einer von dir zu schreibenden Routine trim_lf(char *) mit der Adresse des Namenspuffers als Argument, um das mit eingegebene Newline-Zeichen zu löschen. trim_lf() geht einfache alle Zeichen durch, bis es das Newline (oder Stringende-0) gefunden hat, ersetzt es durch '\0' und kehrt zurück. trim_lf() und alle weiteren Funktionen in main.c solltest du dem Programmcode von in main.c dadurch bekannt machen, dass du es am Anfang der Datei als Funktionsrumpf (Rückgabewert, Name, Argumente) deklarierst.

Die Überprüfung auf Gültigkeit des Namens erfolgt in der while-Bedingung der Schleife. Die Schleife soll sich so lange wiederholen, so lange der Name nicht gültig ist. Für die Überprüfung auf Gültigkeit rufst du in der while-Bedingung die Funktion bool galaxy_enter(char *) auf, die du in der galaxy.c Datei unterbringst und in galaxy.h deklarierst. galaxy_enter erhält dabei einen Zeiger auf den zu validierenden String als Argument und liefert TRUE zurück, wenn der Name zum Betreten der Galaxis gültig ist (andernfalls FALSE).


C kennt von Haus aus den Datentyp bool und die Werte TRUE und FALSE nicht. Die definierst du die in galaxy.h:

typedef char bool;
#define TRUE 1
#define FALSE 0

galaxy_enter geht den übergebenen String Zeichen für Zeichen durch und überprüft jedes Zeichen, ob es alfanumerisch ist oder ein Bindestrich. Ist es keins davon, dann ist an dieser Stelle schon klar, dass der Name ungültig ist und die Funktion kann sofort FALSE zurückgeben. Ist die Funktion am Stringende angelangt ('\0'-Zeichen), dann waren wohl alle Zeichen gültig und die Funktion kann TRUE zurückgeben.

Startrek in C

Projekt aufsetzen

  • Starte das IDE deiner Wahl und lege eine neues Projekt “StarTrek” mit der Programmiersprache C an.
  • Lege die erste Datei main.c an (falls das dein IDE nicht schon getan hat)
  • Beginne in main.c mit der Einsprungfunktion int main(). Sie soll 0 (erfolgreich) als Resultat zurückliefern. (Auch das könnte dein IDE schon für dich erledigt haben.)
  • Lass dein IDE diesen Code ausführen.

  • Baue die Startmeldung “Willkommen zu StarTrek” und Endemeldung “Danke, dass du StarTrek gespielt hast” in deinen Code ein. Dazwischen kommt später der weitere Code.

  • Erstelle eine weitere Datei galaxy.c. Sie wird den Code für die Galaxis aufnehmen.
  • Baue in galaxy.c die Funktion void galaxy_init() ein. Sie wird später das Weltall initialisieren. Alle Funktionen in galaxy.c fangen mit galaxy_ an, um sie gleich zuordnen zu können.

  • Damit Funktionen von außerhalb von galaxy.c wissen, welche Funktionen galaxy.c mitbringt, erstelle die Datei galaxy.h.
  • In ihr baust du den “Prototypen” von galaxy_init ein, also den Rückgabewert, Namen und Argumente, aber keinen Implementierungs-Code.

  • Die Datei galaxy.h bindest du mit include in die Datei main.c ein.
  • Nun kannst du in main.c zwischen Start- und Ende-Meldung die Weltall-Initialisierung galaxy_init aufrufen.