Startrek in C
Ein Retro-Startrek-Spiel im Stil der 80er.
1 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 Einsprungfunktionint 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 Funktionvoid galaxy_init()
ein. Sie wird später das Weltall initialisieren. Alle Funktionen ingalaxy.c
fangen mitgalaxy_
an, um sie gleich zuordnen zu können.
- Damit Funktionen von außerhalb von
galaxy.c
wissen, welche Funktionengalaxy.c
mitbringt, erstelle die Dateigalaxy.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 mitinclude
in die Dateimain.c
ein. - Nun kannst du in
main.c
zwischen Start- und Ende-Meldung die Weltall-Initialisierunggalaxy_init
aufrufen.
2 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 alsgets()
, 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 inmain.c
solltest du dem Programmcode von inmain.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.
3 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 (einenum
(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 sollrunning
aufFALSE
setzen, damit sich die Schleife beenden kann. Denk dran, die Statements einescase
desswitch
-Befehls mitbreak
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.
4 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 struct
s (=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
(*u).x = x;
dereferenziert den Pointer und verwendet die bekannte Schreibweise für einstruct
-Elementu->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.
5 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 umshort_range_scan
. - die Funktion
parse_command(...)
soll das neue Kommando erkennen und das richtigeenum
zurückgeben. - der
switch
in der Hauptschleife soll auf das neue Kommando mit Aufruf vondo_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 8x8 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 8x8-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 |
space | unbelegt |
Das Ganze könnte dann so aussehen:

6 Long Range Scan
Unser Spiel lernt das Kommando lrs ("Long Range Scan" ). Dazu
- erweitern wir
parse_command()
umlrs
- 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 3x3-Array vonlrs_entry
s
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 3x3-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:

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