Simple tower defense tutorial, part 19: Writing a parser 1/2
I want to prepare the game for setting up levels (spawn points, enemy types, etc) so it can be dynamically loaded. The fuzzy plan is to be able to set up levels within the game and then exporting them to a file or clipboard. Being able to define the levels in game will allow us to test them easily and make changes without having to recompile the game.
The content of this and the next few parts will be relatively dry and technical (I am sorry about that) until we are able to load the levels from a file and see them in action.
Current data sources
The level setup is currently defined in the Level levels[] array. The structure looks like this:
1 Level levels[] = {
2 [0] = {
3 .initialGold = 500,
4 .waves[0] = {
5 .enemyType = ENEMY_TYPE_SHIELD,
6 .wave = 0,
7 .count = 1,
8 .interval = 2.5f,
9 .delay = 1.0f,
10 .spawnPosition = {2, 6},
11 },
12 .waves[1] = {
13 .enemyType = ENEMY_TYPE_RUNNER,
14 .wave = 0,
15 .count = 5,
16 .interval = 0.5f,
17 .delay = 1.0f,
18 .spawnPosition = {-2, 6},
19 },
20 ...
21 },
22 };
Another data source is the EnemyClassConfig enemyClassConfigs[] array which defines the enemy properties:
1 EnemyClassConfig enemyClassConfigs[] = {
2 [ENEMY_TYPE_MINION] = {
3 .health = 10.0f,
4 .speed = 0.6f,
5 .radius = 0.25f,
6 .maxAcceleration = 1.0f,
7 .explosionDamage = 1.0f,
8 .requiredContactTime = 0.5f,
9 .explosionRange = 1.0f,
10 .explosionPushbackPower = 0.25f,
11 .goldValue = 1,
12 },
13 ...
14 };
And yet another data source is the TowerTypeConfig towerTypeConfigs[] array which defines the tower properties:
1 static TowerTypeConfig towerTypeConfigs[TOWER_TYPE_COUNT] = {
2 [TOWER_TYPE_BASE] = {
3 .name = "Castle",
4 .maxHealth = 10,
5 },
6 [TOWER_TYPE_ARCHER] = {
7 .name = "Archer",
8 .cooldown = 0.5f,
9 .maxUpgradeCooldown = 0.25f,
10 .range = 3.0f,
11 .maxUpgradeRange = 5.0f,
12 .cost = 6,
13 .maxHealth = 10,
14 .projectileSpeed = 4.0f,
15 .projectileType = PROJECTILE_TYPE_ARROW,
16 .hitEffect = {
17 .damage = 3.0f,
18 .maxUpgradeDamage = 6.0f,
19 },
20 },
21 ...
22 };
The plan is to move all this data into a single file that can be loaded and updated at runtime.
Being able to reload this configuration data at runtime will us allow us to edit the game's properties without creating an editor - instead, any text editor will become the game editor.
As for the format, a typical choice would be JSON, but I want to show how to create custom text based file formats. The reason is that for JSON, we would need to include a JSON parser in the game and furthermore, creating a simple format that fits the game's needs is not only a good exercise but it is also not that difficult. Another reason is that while JSON content validation is not too difficult, pointing the user to the location WHERE the content is invalid is only possible if the JSON parser provides line number information for each value (which most parsers don't).
How would the format look like? The easiest approach is to write down the data first into a file and see how it could be represented. For example:
-- this is a comment
Level 0
initialGold: 500
Wave
enemyType: ENEMY_TYPE_SHIELD
wave: 0
count: 1
interval: 2.5
delay: 1.0
spawnPosition: 2 6
Wave
enemyType: ENEMY_TYPE_RUNNER
wave: 0
count: 5
interval: 0.5
delay: 1.0
spawnPosition: -2 6
EnemyClassConfig ENEMY_TYPE_MINION
health: 10.0
speed: 0.6
radius: 0.25
maxAcceleration: 1.0
explosionDamage: 1.0
requiredContactTime: 0.5
explosionRange: 1.0
explosionPushbackPower: 0.25
goldValue: 1
TowerTypeConfig TOWER_TYPE_BASE
maxHealth: 10
TowerTypeConfig TOWER_TYPE_ARCHER
cost: 6
cooldown: 0.5
maxUpgradeCooldown: 0.25
range: 3.0
maxUpgradeRange: 5.0
damage: 3.0
maxUpgradeDamage: 6.0
maxHealth: 10
projectileSpeed: 4.0
projectileType: PROJECTILE_TYPE_ARROW
It isn't difficult to see that the format is pretty much the same as the C struct definitions minus the various extra syntax. The spaces for indentation and the empty lines are optional but it helps humans reading the file.
The lines without a colon are considered to be the start of a new section. Depending on the section, the data is stored in a different struct. Each line is only allowed to have one key and one value. Linebreaks are not allowed within a key or value.
But if we are a bit more lenient, we could also say that the file is just a sequence of key-value pairs. We could ignore the colons and line breaks and we could still parse the file. This simplification will make it easier to write the parser.
The parser should provide proper errors when unidentified keys are encountered or required keys are missing. For example, not fully defining a Wave struct should result in an error message including the line number and the missing key. This is the advantage over JSON that we want to achieve here.
If it works out, we will be able to balance the game without recompiling it and I want to make this even work in the web version of the game.
Writing a parser
Dealing with strings in C is not so straight forward as in other languages. But our input file is pure ASCII (and we should not add texts that are displayed to the player). That helps a lot. So for this tutorial, let's start fresh with a relatively simple file and build up from there. Let's start with a classic:
1 #include <stdio.h>
2
3 int main()
4 {
5 printf("Hello, World!");
6 return 0;
7 }
Hello, World!
So we see the program and the output when running it. The next step is to read a file.
1 #include <raylib.h>
2 #include <stdio.h>
3
4 int main()
5 {
6 char *fileContent = LoadFileText("data/level.txt");
7 if (fileContent == NULL)
8 {
9 printf("Failed to load file\n");
10 return 1;
11 }
12 printf("File content: %s\n", fileContent);
13 UnloadFileText(fileContent);
14 return 0;
15 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully File content: -- this is a comment Level 0 initialGold: 500 Wave enemyType: ENEMY_TYPE_SHIELD wave: 0 count: 1 interval: 2.5 delay: 1.0 spawnPosition: 2 6 Wave enemyType: ENEMY_TYPE_RUNNER wave: 0 count: 5 interval: 0.5 delay: 1.0 spawnPosition: -2 6 EnemyClassConfig ENEMY_TYPE_MINION health: 10.0 speed: 0.6 radius: 0.25 maxAcceleration: 1.0 explosionDamage: 1.0 requiredContactTime: 0.5 explosionRange: 1.0 explosionPushbackPower: 0.25 goldValue: 1 TowerTypeConfig TOWER_TYPE_BASE maxHealth: 10 TowerTypeConfig TOWER_TYPE_ARCHER cost: 6 cooldown: 0.5 maxUpgradeCooldown: 0.25 range: 3.0 maxUpgradeRange: 5.0 damage: 3.0 maxUpgradeDamage: 6.0 maxHealth: 10 projectileSpeed: 4.0 projectileType: PROJECTILE_TYPE_ARROW
Even without initializing raylib, we can still use the LoadFileText and UnloadFileText functions, which is lucky for us. For many functions in raylib, this is not the case! So be careful when using raylib functions in a program that doesn't call raylib's InitWindow function.
Now that we have the file content, we can start thinking about how to parse it. Let's just read the first line and print it out to get a feeling how this could work:
1 #include <raylib.h>
2 #include <stdio.h>
3
4 int main()
5 {
6 char *fileContent = LoadFileText("data/level.txt");
7 if (fileContent == NULL)
8 {
9 printf("Failed to load file\n");
10 return 1;
11 }
12
13 printf("File content length: %d\n", TextLength(fileContent));
14
15 char buffer[256] = {0};
16 // reading in a loop until we hit a line break or the end of the file or the buffer limit
17 for (int i = 0; fileContent[i] != 0 && i < 255; i++)
18 {
19 buffer[i] = fileContent[i];
20 if (buffer[i] == '\n')
21 {
22 // found line break, stop reading and terminate buffer string with 0
23 buffer[i] = 0;
24 break;
25 }
26 }
27
28 printf("First line:\n%s\nEOL", buffer);
29
30 UnloadFileText(fileContent);
31 return 0;
32 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully File content length: 774 First line: -- this is a comment EOL
We use a buffer of 256 bytes to read the first line. We stop reading when we encounter a newline or the end of the file or when we reach the buffer limit minus 1. The reason here is that we HAVE to add a null terminator at the end of the string. Not doing so will lead to undefined behavior (means: likely a crash. When lucky.).
Make sure that you understand every line of code above - this kind of code is what is going to be used in the parser to great extent. Overlooking details here leads to bugs that are hard to find. String operations are notoriously error prone in C and the source of many security vulnerabilities. I made plenty of mistakes when just writing this simple code above alone.
Once all is clear, we can modify the code a little to shorten it a little:
1 #include <raylib.h>
2 #include <stdio.h>
3
4 int main()
5 {
6 char *fileContent = LoadFileText("data/level.txt");
7 if (fileContent == NULL)
8 {
9 printf("Failed to load file\n");
10 return 1;
11 }
12
13 printf("File content length: %d\n", TextLength(fileContent));
14
15 char buffer[256] = {0};
16 // reading in a loop until we hit a line break or the end of the file or the buffer limit
17 for (int i = 0; fileContent[i] != 0 && fileContent[i] != '\n' && i < 255; i++)
18 {
19 buffer[i] = fileContent[i];
20 }
21
22 printf("First line:\n%s\nEOL", buffer);
23
24 UnloadFileText(fileContent);
25 return 0;
26 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully File content length: 774 First line: -- this is a comment EOL
Initializing the buffer with 0 is a good practice and allows us to stop copying the string when we hit the end of the file or the buffer limit or a newline. This way, the loop condition is the only check we need to do, streamlining the code a little. This is greatly improving the readability of the code (in my opinion). Keeping the code simple and compact is well invested time to achieve this.
So what's next? We need now to set up a strategy how to parse the file. Typically the first step is to tokenize the input. This means that we split the input into smaller parts that we can then analyze individually. Let's do the tokenization by hand first; I will insert >| and |< to highlight individual tokens and use «comments» to show meta information:
-- this is a comment «We ignore comments during parsing»
>|Level|< «whitespaces are skipped»>|0|<
>|initialGold|<: «The colon is detected and attributed to the previous token»>|500|<
>|Wave|<
>|enemyType|<: >|ENEMY_TYPE_SHIELD
>|wave|<: >|0|<
>|count|<: >|1|<
>|interval|<: >|2.5|<
>|delay|<: >|1.0|<
>|spawnPosition|<: >|2|< >|6|<
...
This is more or less how we want the first step of our parser to split the input for later processing.
In order to do this, let's first create a parser state struct that holds the current state of the parser. This struct will hold the current token and the current position in the input string. We will also add a function to read the next token.
1 #include <raylib.h>
2 #include <stdio.h>
3
4 typedef struct ParserState
5 {
6 char *input;
7 int position;
8 char nextToken[256];
9 } ParserState;
10
11 int ParserStateReadNextToken(ParserState *state)
12 {
13 int i = 0, pos = state->position;
14 char *input = state->input;
15
16 // skip whitespaces
17 while (input[pos] != 0 && input[pos] <= ' ')
18 {
19 pos++;
20 }
21
22 // read token
23 while (input[pos] != 0 && input[pos] > ' ' && i < 256)
24 {
25 state->nextToken[i] = input[pos];
26 pos++;
27 i++;
28 }
29 state->position = pos;
30
31 if (i == 0 || i == 256)
32 {
33 state->nextToken[0] = 0;
34 return 0;
35 }
36 // terminate the token
37 state->nextToken[i] = 0;
38 return 1;
39 }
40
41 int main()
42 {
43 char *fileContent = LoadFileText("data/level.txt");
44 if (fileContent == NULL)
45 {
46 printf("Failed to load file\n");
47 return 1;
48 }
49
50 ParserState state = {fileContent, 0, {0}};
51 for (int i = 0; i < 10; i++)
52 {
53 if (!ParserStateReadNextToken(&state))
54 {
55 break;
56 }
57 printf("Token: %s\n", state.nextToken);
58 }
59
60 printf("Parser reading stopped at position %d\n", state.position);
61
62 UnloadFileText(fileContent);
63 return 0;
64 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Token: -- Token: this Token: is Token: a Token: comment Token: Level Token: 0 Token: initialGold: Token: 500 Token: Wave Parser reading stopped at position 52
The ParserStateReadNextToken function reads the next token from the input string after skipping whitespaces. It returns 1 if a token was found and 0 if the end of the input string was reached or when the token is too long - we need error handling.
On the output, we also see that the parser does not ignore comments. We can handle this by creating a function that skips comments and whitespaces all at once:
1 #include <raylib.h>
2 #include <stdio.h>
3
4 typedef struct ParserState
5 {
6 char *input;
7 int position;
8 char nextToken[256];
9 } ParserState;
10
11 void ParserStateSkipWhiteSpaces(ParserState *state)
12 {
13 char *input = state->input;
14 int pos = state->position;
15 int skipped = 1;
16 while (skipped)
17 {
18 skipped = 0;
19 if (input[pos] == '-' && input[pos + 1] == '-')
20 {
21 skipped = 1;
22 // skip comments
23 while (input[pos] != 0 && input[pos] != '\n')
24 {
25 pos++;
26 }
27 }
28
29 // skip white spaces
30 while (input[pos] != 0 && input[pos] <= ' ')
31 {
32 skipped = 1;
33 pos++;
34 }
35
36 // repeat until no more white spaces or comments
37 }
38 state->position = pos;
39 }
40
41 int ParserStateReadNextToken(ParserState *state)
42 {
43 ParserStateSkipWhiteSpaces(state);
44
45 int i = 0, pos = state->position;
46 char *input = state->input;
47
48 // read token
49 while (input[pos] != 0 && input[pos] > ' ' && i < 256)
50 {
51 state->nextToken[i] = input[pos];
52 pos++;
53 i++;
54 }
55 state->position = pos;
56
57 if (i == 0 || i == 256)
58 {
59 state->nextToken[0] = 0;
60 return 0;
61 }
62 // terminate the token
63 state->nextToken[i] = 0;
64 return 1;
65 }
66
67 int main()
68 {
69 char *fileContent = LoadFileText("data/level.txt");
70 if (fileContent == NULL)
71 {
72 printf("Failed to load file\n");
73 return 1;
74 }
75
76 ParserState state = {fileContent, 0, {0}};
77 for (int i = 0; i < 10; i++)
78 {
79 if (!ParserStateReadNextToken(&state))
80 {
81 break;
82 }
83 printf("Token: %s\n", state.nextToken);
84 }
85
86 printf("Parser reading stopped at position %d\n", state.position);
87
88 UnloadFileText(fileContent);
89 return 0;
90 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Token: Level Token: 0 Token: initialGold: Token: 500 Token: Wave Token: enemyType: Token: ENEMY_TYPE_SHIELD Token: wave: Token: 0 Token: count: Parser reading stopped at position 103
The ParserStateSkipWhiteSpaces function is a simple helper function that skips whitespaces and comments until a non-whitespace character is found. This way, we get now tokens that look already quite useful. Some tokens end now with a colon; since we split the tokens by whitespaces, we treat the colon the same as any other character. If we would forget in the format to add a space after the colon, the parser would treat the two parts as one token.
We can however modify the parser to treat the colon like a whitespace character; but then the colon would no longer be mandatory - but maybe that is OK.
1 #include <raylib.h>
2 #include <stdio.h>
3
4 typedef struct ParserState
5 {
6 char *input;
7 int position;
8 char nextToken[256];
9 } ParserState;
10
11 void ParserStateSkipWhiteSpaces(ParserState *state)
12 {
13 char *input = state->input;
14 int pos = state->position;
15 int skipped = 1;
16 while (skipped)
17 {
18 skipped = 0;
19 if (input[pos] == '-' && input[pos + 1] == '-')
20 {
21 skipped = 1;
22 // skip comments
23 while (input[pos] != 0 && input[pos] != '\n')
24 {
25 pos++;
26 }
27 }
28
29 // skip white spaces and ignore colons
30 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
31 {
32 skipped = 1;
33 pos++;
34 }
35
36 // repeat until no more white spaces or comments
37 }
38 state->position = pos;
39 }
40
41 int ParserStateReadNextToken(ParserState *state)
42 {
43 ParserStateSkipWhiteSpaces(state);
44
45 int i = 0, pos = state->position;
46 char *input = state->input;
47
48 // read token
49 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
50 {
51 state->nextToken[i] = input[pos];
52 pos++;
53 i++;
54 }
55 state->position = pos;
56
57 if (i == 0 || i == 256)
58 {
59 state->nextToken[0] = 0;
60 return 0;
61 }
62 // terminate the token
63 state->nextToken[i] = 0;
64 return 1;
65 }
66
67 int main()
68 {
69 char *fileContent = LoadFileText("data/level.txt");
70 if (fileContent == NULL)
71 {
72 printf("Failed to load file\n");
73 return 1;
74 }
75
76 ParserState state = {fileContent, 0, {0}};
77 for (int i = 0; i < 10; i++)
78 {
79 if (!ParserStateReadNextToken(&state))
80 {
81 break;
82 }
83 printf("Token: %s\n", state.nextToken);
84 }
85
86 printf("Parser reading stopped at position %d\n", state.position);
87
88 UnloadFileText(fileContent);
89 return 0;
90 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Token: Level Token: 0 Token: initialGold Token: 500 Token: Wave Token: enemyType Token: ENEMY_TYPE_SHIELD Token: wave Token: 0 Token: count Parser reading stopped at position 102
We pretty much have now a working tokenizer that produces tokens that we can use to extract the data from the input file. Our pattern is simple: we read a token, look up what it means and then obtain the tokens that belong to this element - which can be 1, 2 or 3 follow up tokens in our case.
To convert this into meaningful data, we can look at the input like a hierarchical document; the Wave token is a child of the Level token. Other tokens however are unrelated, like for example the EnemyClassConfig token.
Let's start with the Level token:
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 void ParserStateSkipWhiteSpaces(ParserState *state)
13 {
14 char *input = state->input;
15 int pos = state->position;
16 int skipped = 1;
17 while (skipped)
18 {
19 skipped = 0;
20 if (input[pos] == '-' && input[pos + 1] == '-')
21 {
22 skipped = 1;
23 // skip comments
24 while (input[pos] != 0 && input[pos] != '\n')
25 {
26 pos++;
27 }
28 }
29
30 // skip white spaces and ignore colons
31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
32 {
33 skipped = 1;
34 pos++;
35 }
36
37 // repeat until no more white spaces or comments
38 }
39 state->position = pos;
40 }
41
42 int ParserStateReadNextToken(ParserState *state)
43 {
44 ParserStateSkipWhiteSpaces(state);
45
46 int i = 0, pos = state->position;
47 char *input = state->input;
48
49 // read token
50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
51 {
52 state->nextToken[i] = input[pos];
53 pos++;
54 i++;
55 }
56 state->position = pos;
57
58 if (i == 0 || i == 256)
59 {
60 state->nextToken[0] = 0;
61 return 0;
62 }
63 // terminate the token
64 state->nextToken[i] = 0;
65 return 1;
66 }
67
68 int ParserStateReadNextInt(ParserState *state, int *value)
69 {
70 if (!ParserStateReadNextToken(state))
71 {
72 return 0;
73 }
74 // check if the token is a number
75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
76 for (int i = isSigned; state->nextToken[i] != 0; i++)
77 {
78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
79 {
80 return 0;
81 }
82 }
83 *value = TextToInteger(state->nextToken);
84 return 1;
85 }
86
87 typedef struct ParsedGameData
88 {
89 char *parseError;
90 Level levels[32];
91 int lastLevelIndex;
92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
93 EnemyClassConfig enemyClasses[8];
94 } ParsedGameData;
95
96 int ParseGameData(ParserState *state, ParsedGameData *gameData)
97 {
98 *gameData = (ParsedGameData){0};
99 gameData->lastLevelIndex = -1;
100
101 while (ParserStateReadNextToken(state))
102 {
103 if (TextIsEqual(state->nextToken, "Level"))
104 {
105 // read id of level
106 int levelId;
107 if (!ParserStateReadNextInt(state, &levelId))
108 {
109 gameData->parseError = "Failed to read level id";
110 return 0;
111 }
112 if (levelId < 0 || levelId >= 32)
113 {
114 gameData->parseError = "Invalid level id";
115 return 0;
116 }
117 gameData->lastLevelIndex = levelId;
118 printf("Parsing level %d\n", levelId);
119 }
120 }
121
122 return 1;
123 }
124
125 int main()
126 {
127 char *fileContent = LoadFileText("data/level.txt");
128 if (fileContent == NULL)
129 {
130 printf("Failed to load file\n");
131 return 1;
132 }
133
134 ParserState state = {fileContent, 0, {0}};
135 ParsedGameData gameData = {0};
136
137 if (!ParseGameData(&state, &gameData))
138 {
139 printf("Failed to parse game data: %s\n", gameData.parseError);
140 UnloadFileText(fileContent);
141 return 1;
142 }
143
144 UnloadFileText(fileContent);
145 return 0;
146 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsing level 0
So this version prints out the level id that is parsed and it has correctly detected that we have a Level section with the id 0. So far, it does not do much, but we have now the functions in place to extend this:
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 void ParserStateSkipWhiteSpaces(ParserState *state)
13 {
14 char *input = state->input;
15 int pos = state->position;
16 int skipped = 1;
17 while (skipped)
18 {
19 skipped = 0;
20 if (input[pos] == '-' && input[pos + 1] == '-')
21 {
22 skipped = 1;
23 // skip comments
24 while (input[pos] != 0 && input[pos] != '\n')
25 {
26 pos++;
27 }
28 }
29
30 // skip white spaces and ignore colons
31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
32 {
33 skipped = 1;
34 pos++;
35 }
36
37 // repeat until no more white spaces or comments
38 }
39 state->position = pos;
40 }
41
42 int ParserStateReadNextToken(ParserState *state)
43 {
44 ParserStateSkipWhiteSpaces(state);
45
46 int i = 0, pos = state->position;
47 char *input = state->input;
48
49 // read token
50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
51 {
52 state->nextToken[i] = input[pos];
53 pos++;
54 i++;
55 }
56 state->position = pos;
57
58 if (i == 0 || i == 256)
59 {
60 state->nextToken[0] = 0;
61 return 0;
62 }
63 // terminate the token
64 state->nextToken[i] = 0;
65 return 1;
66 }
67
68 int ParserStateReadNextInt(ParserState *state, int *value)
69 {
70 if (!ParserStateReadNextToken(state))
71 {
72 return 0;
73 }
74 // check if the token is a number
75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
76 for (int i = isSigned; state->nextToken[i] != 0; i++)
77 {
78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
79 {
80 return 0;
81 }
82 }
83 *value = TextToInteger(state->nextToken);
84 return 1;
85 }
86
87 typedef struct ParsedGameData
88 {
89 char *parseError;
90 Level levels[32];
91 int lastLevelIndex;
92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
93 EnemyClassConfig enemyClasses[8];
94 } ParsedGameData;
95
96 int ParseGameDataLevelSection(ParserState *state, ParsedGameData *gameData)
97 {
98 int levelId;
99 if (!ParserStateReadNextInt(state, &levelId))
100 {
101 gameData->parseError = "Failed to read level id";
102 return 0;
103 }
104
105 if (levelId < 0 || levelId >= 32)
106 {
107 gameData->parseError = "Invalid level id";
108 return 0;
109 }
110
111 gameData->lastLevelIndex = levelId;
112 Level *level = &gameData->levels[levelId];
113
114 while (1)
115 {
116 // try to read the next token and if we don't know how to handle it,
117 // we step back and return
118 ParserState prevState = *state;
119
120 if (!ParserStateReadNextToken(state))
121 {
122 // end of file
123 return 1;
124 }
125
126 if (TextIsEqual(state->nextToken, "initialGold"))
127 {
128 if (!ParserStateReadNextInt(state, &level->initialGold))
129 {
130 gameData->parseError = "Failed to read initial gold";
131 return 0;
132 }
133
134 continue;
135 }
136
137 // no match, return to previous state and break
138 *state = prevState;
139 break;
140 }
141
142 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold);
143 return 1;
144 }
145
146 int ParseGameData(ParserState *state, ParsedGameData *gameData)
147 {
148 *gameData = (ParsedGameData){0};
149 gameData->lastLevelIndex = -1;
150
151 while (ParserStateReadNextToken(state))
152 {
153 if (TextIsEqual(state->nextToken, "Level"))
154 {
155 // read id of level
156 if (ParseGameDataLevelSection(state, gameData) == 0)
157 {
158 return 0;
159 }
160 }
161
162
163 }
164
165 return 1;
166 }
167
168 int main()
169 {
170 char *fileContent = LoadFileText("data/level.txt");
171 if (fileContent == NULL)
172 {
173 printf("Failed to load file\n");
174 return 1;
175 }
176
177 ParserState state = {fileContent, 0, {0}};
178 ParsedGameData gameData = {0};
179
180 if (!ParseGameData(&state, &gameData))
181 {
182 printf("Failed to parse game data: %s\n", gameData.parseError);
183 UnloadFileText(fileContent);
184 return 1;
185 }
186
187 UnloadFileText(fileContent);
188 return 0;
189 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsed level 0, initialGold=500
There is now a dedicated ParseGameDataLevelSection function that will parse all level related data. Currently, it only reads the initial gold value. When it encounters a token it can't handle, it assumes the level section is done and returns.
We could add now the wave handling, however, there is an improvement we can do: There are now two instances where we first read a token, and if it matches, we read another token in the expectation that this has to be an integer. We can combine this into a single function that reads a token and then reads the next token as an integer. It has however to have a tristate return value: It didn't match, it matched but the next token was not an integer (error) or it matched and the next token was read successfully as an integer.
Implementing such a function should shorten quite regular operations and will help to make the code more readible by making it more concise. It also reduces the chance of introducing bugs when extending the parser.
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 void ParserStateSkipWhiteSpaces(ParserState *state)
13 {
14 char *input = state->input;
15 int pos = state->position;
16 int skipped = 1;
17 while (skipped)
18 {
19 skipped = 0;
20 if (input[pos] == '-' && input[pos + 1] == '-')
21 {
22 skipped = 1;
23 // skip comments
24 while (input[pos] != 0 && input[pos] != '\n')
25 {
26 pos++;
27 }
28 }
29
30 // skip white spaces and ignore colons
31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
32 {
33 skipped = 1;
34 pos++;
35 }
36
37 // repeat until no more white spaces or comments
38 }
39 state->position = pos;
40 }
41
42 int ParserStateReadNextToken(ParserState *state)
43 {
44 ParserStateSkipWhiteSpaces(state);
45
46 int i = 0, pos = state->position;
47 char *input = state->input;
48
49 // read token
50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
51 {
52 state->nextToken[i] = input[pos];
53 pos++;
54 i++;
55 }
56 state->position = pos;
57
58 if (i == 0 || i == 256)
59 {
60 state->nextToken[0] = 0;
61 return 0;
62 }
63 // terminate the token
64 state->nextToken[i] = 0;
65 return 1;
66 }
67
68 int ParserStateReadNextInt(ParserState *state, int *value)
69 {
70 if (!ParserStateReadNextToken(state))
71 {
72 return 0;
73 }
74 // check if the token is a number
75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
76 for (int i = isSigned; state->nextToken[i] != 0; i++)
77 {
78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
79 {
80 return 0;
81 }
82 }
83 *value = TextToInteger(state->nextToken);
84 return 1;
85 }
86
87 typedef struct ParsedGameData
88 {
89 const char *parseError;
90 Level levels[32];
91 int lastLevelIndex;
92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
93 EnemyClassConfig enemyClasses[8];
94 } ParsedGameData;
95
96 typedef enum TryReadResult
97 {
98 TryReadResult_NoMatch,
99 TryReadResult_Error,
100 TryReadResult_Success
101 } TryReadResult;
102
103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
104 {
105 if (!TextIsEqual(state->nextToken, key))
106 {
107 return TryReadResult_NoMatch;
108 }
109
110 if (!ParserStateReadNextInt(state, value))
111 {
112 gameData->parseError = TextFormat("Failed to read %s int value", key);
113 return TryReadResult_Error;
114 }
115
116 // range test, if minRange == maxRange, we don't check the range
117 if (minRange != maxRange && (*value < minRange || *value > maxRange))
118 {
119 gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d",
120 key, minRange, maxRange, *value);
121 return TryReadResult_Error;
122 }
123
124 return TryReadResult_Success;
125 }
126
127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
128 {
129 int levelId;
130 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
131 if (result != TryReadResult_Success)
132 {
133 return result;
134 }
135
136 gameData->lastLevelIndex = levelId;
137 Level *level = &gameData->levels[levelId];
138
139 while (1)
140 {
141 // try to read the next token and if we don't know how to handle it,
142 // we rewind and return
143 ParserState prevState = *state;
144
145 if (!ParserStateReadNextToken(state))
146 {
147 // end of file
148 return 1;
149 }
150
151 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000))
152 {
153 case TryReadResult_Success: continue;
154 case TryReadResult_Error: return TryReadResult_Error;
155 }
156
157 // no match, return to previous state and break
158 *state = prevState;
159 break;
160 }
161
162 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold);
163 return TryReadResult_Success;
164 }
165
166 int ParseGameData(ParsedGameData *gameData, ParserState *state)
167 {
168 *gameData = (ParsedGameData){0};
169 gameData->lastLevelIndex = -1;
170
171 while (ParserStateReadNextToken(state))
172 {
173 switch (ParseGameDataTryReadLevelSection(gameData, state))
174 {
175 case TryReadResult_Success: continue;
176 case TryReadResult_Error: return 0;
177 }
178
179 // read other sections later
180 }
181
182 return 1;
183 }
184
185 int main()
186 {
187 char *fileContent = LoadFileText("data/level.txt");
188 if (fileContent == NULL)
189 {
190 printf("Failed to load file\n");
191 return 1;
192 }
193
194 ParserState state = {fileContent, 0, {0}};
195 ParsedGameData gameData = {0};
196
197 if (!ParseGameData(&gameData, &state))
198 {
199 printf("Failed to parse game data: %s\n", gameData.parseError);
200 UnloadFileText(fileContent);
201 return 1;
202 }
203
204 UnloadFileText(fileContent);
205 return 0;
206 }
INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsed level 0, initialGold=500
There are now two `TryRead*` functions: functions: ParseGameDataTryReadKeyInt and ParseGameDataTryReadLevelSection. Both functions will only become active, when the current token matches its expectation. If not, it returns a TryReadResult_NoMatch result. After a successful match, the further reading can either fail with a TryReadResult_Error or succeed with a TryReadResult_Success result.
This simplifies extending the parser to construct the data structures we want to read.
However... it is time to test how the parser reacts to errorneous inputs and how the errors look like.
So let's add a small testing section to our program:
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 void ParserStateSkipWhiteSpaces(ParserState *state)
13 {
14 char *input = state->input;
15 int pos = state->position;
16 int skipped = 1;
17 while (skipped)
18 {
19 skipped = 0;
20 if (input[pos] == '-' && input[pos + 1] == '-')
21 {
22 skipped = 1;
23 // skip comments
24 while (input[pos] != 0 && input[pos] != '\n')
25 {
26 pos++;
27 }
28 }
29
30 // skip white spaces and ignore colons
31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
32 {
33 skipped = 1;
34 pos++;
35 }
36
37 // repeat until no more white spaces or comments
38 }
39 state->position = pos;
40 }
41
42 int ParserStateReadNextToken(ParserState *state)
43 {
44 ParserStateSkipWhiteSpaces(state);
45
46 int i = 0, pos = state->position;
47 char *input = state->input;
48
49 // read token
50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
51 {
52 state->nextToken[i] = input[pos];
53 pos++;
54 i++;
55 }
56 state->position = pos;
57
58 if (i == 0 || i == 256)
59 {
60 state->nextToken[0] = 0;
61 return 0;
62 }
63 // terminate the token
64 state->nextToken[i] = 0;
65 return 1;
66 }
67
68 int ParserStateReadNextInt(ParserState *state, int *value)
69 {
70 if (!ParserStateReadNextToken(state))
71 {
72 return 0;
73 }
74 // check if the token is a number
75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
76 for (int i = isSigned; state->nextToken[i] != 0; i++)
77 {
78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
79 {
80 return 0;
81 }
82 }
83 *value = TextToInteger(state->nextToken);
84 return 1;
85 }
86
87 typedef struct ParsedGameData
88 {
89 const char *parseError;
90 Level levels[32];
91 int lastLevelIndex;
92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
93 EnemyClassConfig enemyClasses[8];
94 } ParsedGameData;
95
96 typedef enum TryReadResult
97 {
98 TryReadResult_NoMatch,
99 TryReadResult_Error,
100 TryReadResult_Success
101 } TryReadResult;
102
103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
104 {
105 if (!TextIsEqual(state->nextToken, key))
106 {
107 return TryReadResult_NoMatch;
108 }
109
110 if (!ParserStateReadNextInt(state, value))
111 {
112 gameData->parseError = TextFormat("Failed to read %s int value", key);
113 return TryReadResult_Error;
114 }
115
116 // range test, if minRange == maxRange, we don't check the range
117 if (minRange != maxRange && (*value < minRange || *value > maxRange))
118 {
119 gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d",
120 key, minRange, maxRange, *value);
121 return TryReadResult_Error;
122 }
123
124 return TryReadResult_Success;
125 }
126
127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
128 {
129 int levelId;
130 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
131 if (result != TryReadResult_Success)
132 {
133 return result;
134 }
135
136 gameData->lastLevelIndex = levelId;
137 Level *level = &gameData->levels[levelId];
138
139 while (1)
140 {
141 // try to read the next token and if we don't know how to handle it,
142 // we rewind and return
143 ParserState prevState = *state;
144
145 if (!ParserStateReadNextToken(state))
146 {
147 // end of file
148 break;
149 }
150
151 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000))
152 {
153 case TryReadResult_Success: continue;
154 case TryReadResult_Error: return TryReadResult_Error;
155 }
156
157 // no match, return to previous state and break
158 *state = prevState;
159 break;
160 }
161
162 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold);
163 return TryReadResult_Success;
164 }
165
166 int ParseGameData(ParsedGameData *gameData, ParserState *state)
167 {
168 *gameData = (ParsedGameData){0};
169 gameData->lastLevelIndex = -1;
170
171 while (ParserStateReadNextToken(state))
172 {
173 switch (ParseGameDataTryReadLevelSection(gameData, state))
174 {
175 case TryReadResult_Success: continue;
176 case TryReadResult_Error: return 0;
177 }
178
179 // read other sections later
180 }
181
182 return 1;
183 }
184
185 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; }
186
187 int RunParseTests()
188 {
189 int passedCount = 0, failedCount = 0;
190 ParserState state;
191 ParsedGameData gameData = {0};
192
193 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}};
194 gameData = (ParsedGameData) {0};
195 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section");
196 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2");
197 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100");
198 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200");
199
200 state = (ParserState) {"Level 392\n", 0, {0}};
201 gameData = (ParsedGameData) {0};
202 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
203 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error");
204
205 state = (ParserState) {"Level 3 initialGold -34", 0, {0}};
206 gameData = (ParsedGameData) {0};
207 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
208 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error");
209
210 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount);
211
212 return failedCount;
213 }
214
215 int main()
216 {
217 printf("Running parse tests\n");
218 if (RunParseTests())
219 {
220 return 1;
221 }
222 printf("\n");
223
224 char *fileContent = LoadFileText("data/level.txt");
225 if (fileContent == NULL)
226 {
227 printf("Failed to load file\n");
228 return 1;
229 }
230
231 ParserState state = {fileContent, 0, {0}};
232 ParsedGameData gameData = {0};
233
234 if (!ParseGameData(&gameData, &state))
235 {
236 printf("Failed to parse game data: %s\n", gameData.parseError);
237 UnloadFileText(fileContent);
238 return 1;
239 }
240
241 UnloadFileText(fileContent);
242 return 0;
243 }
Running parse tests Parsed level 7, initialGold=100 Parsed level 2, initialGold=200 Passed 8 test(s), Failed 0 INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsed level 0, initialGold=500
Running a few tests did not reveal any big issues. The tests cover some range check errors. We can now extend the parser to also expect certain keys to be mandatory and also prevent repeated keys - like having two initialGold keys in a Level section. Also, multiple Level sections with the same id should be considered an error.
Let's add more tests and improve the parser to honor these requirements:
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 void ParserStateSkipWhiteSpaces(ParserState *state)
13 {
14 char *input = state->input;
15 int pos = state->position;
16 int skipped = 1;
17 while (skipped)
18 {
19 skipped = 0;
20 if (input[pos] == '-' && input[pos + 1] == '-')
21 {
22 skipped = 1;
23 // skip comments
24 while (input[pos] != 0 && input[pos] != '\n')
25 {
26 pos++;
27 }
28 }
29
30 // skip white spaces and ignore colons
31 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
32 {
33 skipped = 1;
34 pos++;
35 }
36
37 // repeat until no more white spaces or comments
38 }
39 state->position = pos;
40 }
41
42 int ParserStateReadNextToken(ParserState *state)
43 {
44 ParserStateSkipWhiteSpaces(state);
45
46 int i = 0, pos = state->position;
47 char *input = state->input;
48
49 // read token
50 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
51 {
52 state->nextToken[i] = input[pos];
53 pos++;
54 i++;
55 }
56 state->position = pos;
57
58 if (i == 0 || i == 256)
59 {
60 state->nextToken[0] = 0;
61 return 0;
62 }
63 // terminate the token
64 state->nextToken[i] = 0;
65 return 1;
66 }
67
68 int ParserStateReadNextInt(ParserState *state, int *value)
69 {
70 if (!ParserStateReadNextToken(state))
71 {
72 return 0;
73 }
74 // check if the token is a number
75 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
76 for (int i = isSigned; state->nextToken[i] != 0; i++)
77 {
78 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
79 {
80 return 0;
81 }
82 }
83 *value = TextToInteger(state->nextToken);
84 return 1;
85 }
86
87 typedef struct ParsedGameData
88 {
89 const char *parseError;
90 Level levels[32];
91 int lastLevelIndex;
92 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
93 EnemyClassConfig enemyClasses[8];
94 } ParsedGameData;
95
96 typedef enum TryReadResult
97 {
98 TryReadResult_NoMatch,
99 TryReadResult_Error,
100 TryReadResult_Success
101 } TryReadResult;
102
103 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
104 {
105 if (!TextIsEqual(state->nextToken, key))
106 {
107 return TryReadResult_NoMatch;
108 }
109
110 if (!ParserStateReadNextInt(state, value))
111 {
112 gameData->parseError = TextFormat("Failed to read %s int value", key);
113 return TryReadResult_Error;
114 }
115
116 // range test, if minRange == maxRange, we don't check the range
117 if (minRange != maxRange && (*value < minRange || *value > maxRange))
118 {
119 gameData->parseError = TextFormat("Invalid value range for %s, range is [%d, %d], value is %d",
120 key, minRange, maxRange, *value);
121 return TryReadResult_Error;
122 }
123
124 return TryReadResult_Success;
125 }
126
127 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
128 {
129 int levelId;
130 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
131 if (result != TryReadResult_Success)
132 {
133 return result;
134 }
135
136 gameData->lastLevelIndex = levelId;
137 Level *level = &gameData->levels[levelId];
138
139 // since we require the initialGold to be initialized with at least 1, we can use it as a flag
140 // to detect if the level was already initialized
141 if (level->initialGold != 0)
142 {
143 gameData->parseError = TextFormat("level %d already initialized", levelId);
144 return TryReadResult_Error;
145 }
146
147 int initialGoldInitialized = 0;
148
149 while (1)
150 {
151 // try to read the next token and if we don't know how to handle it,
152 // we rewind and return
153 ParserState prevState = *state;
154
155 if (!ParserStateReadNextToken(state))
156 {
157 // end of file
158 break;
159 }
160
161 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000))
162 {
163 case TryReadResult_Success:
164 if (initialGoldInitialized)
165 {
166 gameData->parseError = "initialGold already initialized";
167 return TryReadResult_Error;
168 }
169 initialGoldInitialized = 1;
170 continue;
171 case TryReadResult_Error: return TryReadResult_Error;
172 }
173
174 // no match, return to previous state and break
175 *state = prevState;
176 break;
177 }
178
179 if (!initialGoldInitialized)
180 {
181 gameData->parseError = "initialGold not initialized";
182 return TryReadResult_Error;
183 }
184
185 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold);
186 return TryReadResult_Success;
187 }
188
189 int ParseGameData(ParsedGameData *gameData, ParserState *state)
190 {
191 *gameData = (ParsedGameData){0};
192 gameData->lastLevelIndex = -1;
193
194 while (ParserStateReadNextToken(state))
195 {
196 switch (ParseGameDataTryReadLevelSection(gameData, state))
197 {
198 case TryReadResult_Success: continue;
199 case TryReadResult_Error: return 0;
200 }
201
202 // read other sections later
203 }
204
205 return 1;
206 }
207
208 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; }
209
210 int RunParseTests()
211 {
212 int passedCount = 0, failedCount = 0;
213 ParserState state;
214 ParsedGameData gameData = {0};
215
216 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}};
217 gameData = (ParsedGameData) {0};
218 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section");
219 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2");
220 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100");
221 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200");
222
223 state = (ParserState) {"Level 392\n", 0, {0}};
224 gameData = (ParsedGameData) {0};
225 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
226 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error");
227
228 state = (ParserState) {"Level 3 initialGold -34", 0, {0}};
229 gameData = (ParsedGameData) {0};
230 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
231 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error");
232
233 state = (ParserState) {"Level 3 initialGold 2 initialGold 3", 0, {0}};
234 gameData = (ParsedGameData) {0};
235 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
236
237 state = (ParserState) {"Level 3", 0, {0}};
238 gameData = (ParsedGameData) {0};
239 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
240
241 state = (ParserState) {"Level 7\n initialGold 100\nLevel 7 initialGold 200", 0, {0}};
242 gameData = (ParsedGameData) {0};
243 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
244
245 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount);
246
247 return failedCount;
248 }
249
250 int main()
251 {
252 printf("Running parse tests\n");
253 if (RunParseTests())
254 {
255 return 1;
256 }
257 printf("\n");
258
259 char *fileContent = LoadFileText("data/level.txt");
260 if (fileContent == NULL)
261 {
262 printf("Failed to load file\n");
263 return 1;
264 }
265
266 ParserState state = {fileContent, 0, {0}};
267 ParsedGameData gameData = {0};
268
269 if (!ParseGameData(&gameData, &state))
270 {
271 printf("Failed to parse game data: %s\n", gameData.parseError);
272 UnloadFileText(fileContent);
273 return 1;
274 }
275
276 UnloadFileText(fileContent);
277 return 0;
278 }
Running parse tests Parsed level 7, initialGold=100 Parsed level 2, initialGold=200 Parsed level 7, initialGold=100 Passed 11 test(s), Failed 0 INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsed level 0, initialGold=500
Adding a few test cases and some code to satisfy the expectations is quite straight forward.
Writing tests first is calledtest driven development (TDD) and it has its merits. I don't often use TDD, but there are cases where there are clear benefits: In this case, it simplifies extending the parser functionality and ensures that the parser is working as expected. Since the functions are pure input-output functions where we can easily check the output, applying the TDD principles is quite easy. We also have here very strict requirements how the code should behave while requiring that it does work highly reliable.
Speaking of tests and failing: Our errors are currently only mere indicators what went wrong, but it isn't pointing to location where the error happened, which would mean in practice that we would spend a lot of time to find errors.
So let's modify the errors to include line numbers.
1 #include "td_main.h"
2 #include <raylib.h>
3 #include <stdio.h>
4
5 typedef struct ParserState
6 {
7 char *input;
8 int position;
9 char nextToken[256];
10 } ParserState;
11
12 int ParserStateGetLineNumber(ParserState *state)
13 {
14 int lineNumber = 1;
15 for (int i = 0; i < state->position; i++)
16 {
17 if (state->input[i] == '\n')
18 {
19 lineNumber++;
20 }
21 }
22 return lineNumber;
23 }
24
25 void ParserStateSkipWhiteSpaces(ParserState *state)
26 {
27 char *input = state->input;
28 int pos = state->position;
29 int skipped = 1;
30 while (skipped)
31 {
32 skipped = 0;
33 if (input[pos] == '-' && input[pos + 1] == '-')
34 {
35 skipped = 1;
36 // skip comments
37 while (input[pos] != 0 && input[pos] != '\n')
38 {
39 pos++;
40 }
41 }
42
43 // skip white spaces and ignore colons
44 while (input[pos] != 0 && (input[pos] <= ' ' || input[pos] == ':'))
45 {
46 skipped = 1;
47 pos++;
48 }
49
50 // repeat until no more white spaces or comments
51 }
52 state->position = pos;
53 }
54
55 int ParserStateReadNextToken(ParserState *state)
56 {
57 ParserStateSkipWhiteSpaces(state);
58
59 int i = 0, pos = state->position;
60 char *input = state->input;
61
62 // read token
63 while (input[pos] != 0 && input[pos] > ' ' && input[pos] != ':' && i < 256)
64 {
65 state->nextToken[i] = input[pos];
66 pos++;
67 i++;
68 }
69 state->position = pos;
70
71 if (i == 0 || i == 256)
72 {
73 state->nextToken[0] = 0;
74 return 0;
75 }
76 // terminate the token
77 state->nextToken[i] = 0;
78 return 1;
79 }
80
81 int ParserStateReadNextInt(ParserState *state, int *value)
82 {
83 if (!ParserStateReadNextToken(state))
84 {
85 return 0;
86 }
87 // check if the token is a number
88 int isSigned = state->nextToken[0] == '-' || state->nextToken[0] == '+';
89 for (int i = isSigned; state->nextToken[i] != 0; i++)
90 {
91 if (state->nextToken[i] < '0' || state->nextToken[i] > '9')
92 {
93 return 0;
94 }
95 }
96 *value = TextToInteger(state->nextToken);
97 return 1;
98 }
99
100 typedef struct ParsedGameData
101 {
102 const char *parseError;
103 Level levels[32];
104 int lastLevelIndex;
105 TowerTypeConfig towerTypes[TOWER_TYPE_COUNT];
106 EnemyClassConfig enemyClasses[8];
107 } ParsedGameData;
108
109 typedef enum TryReadResult
110 {
111 TryReadResult_NoMatch,
112 TryReadResult_Error,
113 TryReadResult_Success
114 } TryReadResult;
115
116 TryReadResult ParseGameDataError(ParsedGameData *gameData, ParserState *state, const char *msg)
117 {
118 gameData->parseError = TextFormat("Error at line %d: %s", ParserStateGetLineNumber(state), msg);
119 return TryReadResult_Error;
120 }
121
122 TryReadResult ParseGameDataTryReadKeyInt(ParsedGameData *gameData, ParserState *state, const char *key, int *value, int minRange, int maxRange)
123 {
124 if (!TextIsEqual(state->nextToken, key))
125 {
126 return TryReadResult_NoMatch;
127 }
128
129 if (!ParserStateReadNextInt(state, value))
130 {
131 return ParseGameDataError(gameData, state, TextFormat("Failed to read %s int value", key));
132 }
133
134 // range test, if minRange == maxRange, we don't check the range
135 if (minRange != maxRange && (*value < minRange || *value > maxRange))
136 {
137 return ParseGameDataError(gameData, state, TextFormat(
138 "Invalid value range for %s, range is [%d, %d], value is %d",
139 key, minRange, maxRange, *value));
140 }
141
142 return TryReadResult_Success;
143 }
144
145 TryReadResult ParseGameDataTryReadLevelSection(ParsedGameData *gameData, ParserState *state)
146 {
147 int levelId;
148 TryReadResult result = ParseGameDataTryReadKeyInt(gameData, state, "Level", &levelId, 0, 31);
149 if (result != TryReadResult_Success)
150 {
151 return result;
152 }
153
154 gameData->lastLevelIndex = levelId;
155 Level *level = &gameData->levels[levelId];
156
157 // since we require the initialGold to be initialized with at least 1, we can use it as a flag
158 // to detect if the level was already initialized
159 if (level->initialGold != 0)
160 {
161 return ParseGameDataError(gameData, state, TextFormat("level %d already initialized", levelId));
162 }
163
164 int initialGoldInitialized = 0;
165
166 while (1)
167 {
168 // try to read the next token and if we don't know how to handle it,
169 // we rewind and return
170 ParserState prevState = *state;
171
172 if (!ParserStateReadNextToken(state))
173 {
174 // end of file
175 break;
176 }
177
178 switch (ParseGameDataTryReadKeyInt(gameData, state, "initialGold", &level->initialGold, 1, 100000))
179 {
180 case TryReadResult_Success:
181 if (initialGoldInitialized)
182 {
183 return ParseGameDataError(gameData, state, "initialGold already initialized");
184 }
185 initialGoldInitialized = 1;
186 continue;
187 case TryReadResult_Error: return TryReadResult_Error;
188 }
189
190 // no match, return to previous state and break
191 *state = prevState;
192 break;
193 }
194
195 if (!initialGoldInitialized)
196 {
197 return ParseGameDataError(gameData, state, "initialGold not initialized");
198 }
199
200 printf("Parsed level %d, initialGold=%d\n", levelId, level->initialGold);
201 return TryReadResult_Success;
202 }
203
204 int ParseGameData(ParsedGameData *gameData, ParserState *state)
205 {
206 *gameData = (ParsedGameData){0};
207 gameData->lastLevelIndex = -1;
208
209 while (ParserStateReadNextToken(state))
210 {
211 switch (ParseGameDataTryReadLevelSection(gameData, state))
212 {
213 case TryReadResult_Success: continue;
214 case TryReadResult_Error: return 0;
215 }
216
217 // read other sections later
218 }
219
220 return 1;
221 }
222
223 #define EXPECT(cond, msg) if (!(cond)) { printf("Error %s:%d: %s\n", __FILE__, __LINE__, msg); failedCount++; } else { passedCount++; }
224
225 int RunParseTests()
226 {
227 int passedCount = 0, failedCount = 0;
228 ParserState state;
229 ParsedGameData gameData = {0};
230
231 state = (ParserState) {"Level 7\n initialGold 100\nLevel 2 initialGold 200", 0, {0}};
232 gameData = (ParsedGameData) {0};
233 EXPECT(ParseGameData(&gameData, &state) == 1, "Failed to parse level section");
234 EXPECT(gameData.lastLevelIndex == 2, "lastLevelIndex should be 2");
235 EXPECT(gameData.levels[7].initialGold == 100, "initialGold should be 100");
236 EXPECT(gameData.levels[2].initialGold == 200, "initialGold should be 200");
237
238 state = (ParserState) {"Level 392\n", 0, {0}};
239 gameData = (ParsedGameData) {0};
240 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
241 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for Level") >= 0, "Expected invalid value range error");
242 EXPECT(TextFindIndex(gameData.parseError, "line 1") >= 0, "Expected to find line number 1");
243
244 state = (ParserState) {"Level 3\n initialGold -34", 0, {0}};
245 gameData = (ParsedGameData) {0};
246 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
247 EXPECT(TextFindIndex(gameData.parseError, "Invalid value range for initialGold") >= 0, "Expected invalid value range error");
248 EXPECT(TextFindIndex(gameData.parseError, "line 2") >= 0, "Expected to find line number 1");
249
250 state = (ParserState) {"Level 3\n initialGold 2\n initialGold 3", 0, {0}};
251 gameData = (ParsedGameData) {0};
252 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
253 EXPECT(TextFindIndex(gameData.parseError, "line 3") >= 0, "Expected to find line number 1");
254
255 state = (ParserState) {"Level 3", 0, {0}};
256 gameData = (ParsedGameData) {0};
257 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
258
259 state = (ParserState) {"Level 7\n initialGold 100\nLevel 7 initialGold 200", 0, {0}};
260 gameData = (ParsedGameData) {0};
261 EXPECT(ParseGameData(&gameData, &state) == 0, "Expected to fail parsing level section");
262 EXPECT(TextFindIndex(gameData.parseError, "line 3") >= 0, "Expected to find line number 1");
263
264
265 printf("Passed %d test(s), Failed %d\n", passedCount, failedCount);
266
267 return failedCount;
268 }
269
270 int main()
271 {
272 printf("Running parse tests\n");
273 if (RunParseTests())
274 {
275 return 1;
276 }
277 printf("\n");
278
279 char *fileContent = LoadFileText("data/level.txt");
280 if (fileContent == NULL)
281 {
282 printf("Failed to load file\n");
283 return 1;
284 }
285
286 ParserState state = {fileContent, 0, {0}};
287 ParsedGameData gameData = {0};
288
289 if (!ParseGameData(&gameData, &state))
290 {
291 printf("Failed to parse game data: %s\n", gameData.parseError);
292 UnloadFileText(fileContent);
293 return 1;
294 }
295
296 UnloadFileText(fileContent);
297 return 0;
298 }
Running parse tests Parsed level 7, initialGold=100 Parsed level 2, initialGold=200 Parsed level 7, initialGold=100 Passed 15 test(s), Failed 0 INFO: FILEIO: [data/level.txt] Text file loaded successfully Parsed level 0, initialGold=500
The error we would see would look like this:
Error at line 2: Invalid value range for initialGold, range is [1, 100000], value is -34
The tests are now also checking (occasionally) if the correct line number is provided in the error message. We determine the line number in the ParserStateGetLineNumber function - which simply counts the number of newline characters between the start of the input string and the current position.
Conclusion
This is a good point to finish this post. We have the foundation for a parser that can read a simple text file and some simple tests that ensure that the parser is working as expected for the few things we have implemented so far.
In the next part we will add support to initialize all the game data structures from the data provided in the text file. After that is done, we can integrate the parser into the game.
While this certainly is not the most exciting part of the game development, it is a very important one that will later empower us to iterate more efficiently on the game. So bear with me for a little longer.