Spacecrash day 5 of 7: fonts and menus
So today I took the time to add some text-based menus. Nothing out of the ordinary, but something necessary for any complete game. I spiced up the menu just by having graphics from the game in the background – this is a common trick by all cost-conscious game developers since time immemorial!
First thing I needed some way to draw text. OpenGL doesn’t provide any built-in way to do so, so you need to do it your own way. The simplest thing is to prepare a texture with your font, and paint pieces of this texture for each character. You can see the texture right next to this text (this is a font by Marc Russell from SpicyPixel – thanks!), and here is the code to do the text drawing using this font:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
//============================================================================= // Font support #define FONTDEF_ROWS 8 #define FONTDEF_COLS 8 #define MAX_FONT_CHARDEFS 256 #define FONTDEF_CHAR_WIDTH (16.f/128.f) #define FONTDEF_CHAR_HEIGHT (16.f/128.f) #define FONT_CHAR_WIDTH 16.f #define FONT_CHAR_HEIGHT 16.f char fontdef[FONTDEF_ROWS][FONTDEF_COLS+1] = { { " !\"~*%-'" }, { " +, ./" }, { "01234567" }, { "89:;aib?" }, { "*ABCDEFG" }, { "HIJKLMNO" }, { "PQRSTUVW" }, { "XYZ " }, }; struct FontCharDef { char ch; vec2 p0; }; FontCharDef fontchardefs[MAX_FONT_CHARDEFS] = { 0 }; //----------------------------------------------------------------------------- void PrepareFont() { for (int i = 0; i < FONTDEF_ROWS; i++) // One iteration per row { for (int j = 0; j < FONTDEF_COLS; j++) // Inside row { unsigned char ch = fontdef[i][j]; fontchardefs[ch].ch = ch; fontchardefs[ch].p0 = vmake(j * FONTDEF_CHAR_WIDTH, (FONTDEF_ROWS - i - 1) * FONTDEF_CHAR_HEIGHT); } } } //----------------------------------------------------------------------------- void DrawChar(vec2 p0, vec2 p1, unsigned char ch, rgba color) { if (ch < MAX_FONT_CHARDEFS && fontchardefs[ch].ch == ch) { glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glColor4f( color.r, color.g, color.b, color.a); glBindTexture( GL_TEXTURE_2D, CORE_GetBmpOpenGLTex(Tex(T_FONT)) ); glBegin( GL_QUADS ); glTexCoord2d(fontchardefs[ch].p0.x , fontchardefs[ch].p0.y ); glVertex2f(p0.x, p0.y); glTexCoord2d(fontchardefs[ch].p0.x + FONTDEF_CHAR_WIDTH, fontchardefs[ch].p0.y ); glVertex2f(p1.x, p0.y); glTexCoord2d(fontchardefs[ch].p0.x + FONTDEF_CHAR_WIDTH, fontchardefs[ch].p0.y + FONTDEF_CHAR_HEIGHT); glVertex2f(p1.x, p1.y); glTexCoord2d(fontchardefs[ch].p0.x , fontchardefs[ch].p0.y + FONTDEF_CHAR_HEIGHT); glVertex2f(p0.x, p1.y); glEnd(); } } //----------------------------------------------------------------------------- void DrawString(vec2 p0, const char string[], float charsize, rgba color) { int n = (int)strlen(string); for (int i = 0; i < n; i++) { DrawChar(p0, vadd(p0, vmake(charsize, charsize)), string[i], color); p0 = vadd(p0, vmake(charsize, 0.f)); } } |
As you can see, the PrepareFont() function explores the table mapping where the characters are, and prepares an alternate fontchardefs array which is easier to use for drawing later on.
Once we have this, we need to implement the drawing and input-handling of menus. To handle keypresses, the existing SYS_IsKeyPressed() functionality is not enough, because you don’t want to keep moving the selection while the key is pressed. So I have added logic to know when a key has just been pressed:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
... // Trigger-only keypresses bool g_just_pressed_up = false; bool g_just_pressed_down = false; bool g_just_pressed_enter = false; bool g_just_pressed_esc = false; bool g_just_pressed_space = false; bool g_was_pressed_up = false; bool g_was_pressed_down = false; bool g_was_pressed_enter = false; bool g_was_pressed_esc = false; bool g_was_pressed_space = false; ... //----------------------------------------------------------------------------- void ProcessInput() { g_just_pressed_up = SYS_KeyPressed(SYS_KEY_UP) && !g_was_pressed_up; g_just_pressed_down = SYS_KeyPressed(SYS_KEY_DOWN) && !g_was_pressed_down; g_just_pressed_enter = SYS_KeyPressed(SYS_KEY_ENTER) && !g_was_pressed_enter; g_just_pressed_esc = SYS_KeyPressed(SYS_KEY_ESC) && !g_was_pressed_esc; g_just_pressed_space = SYS_KeyPressed(SYS_KEY_SPACE) && !g_was_pressed_space; ... g_was_pressed_up = SYS_KeyPressed(SYS_KEY_UP); g_was_pressed_down = SYS_KeyPressed(SYS_KEY_DOWN); g_was_pressed_enter = SYS_KeyPressed(SYS_KEY_ENTER); g_was_pressed_esc = SYS_KeyPressed(SYS_KEY_ESC); g_was_pressed_space = SYS_KeyPressed(SYS_KEY_SPACE); } |
That way, code can check “g_just_pressed_up” and be safe knowing there won’t be unwanted “autorepeats”.
Next thing is defining the menus in a common format, and writing the code to render them and to handle input:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
//============================================================================= // Menus #define MENU_CHAR_SIZE 60.f #define MENU_SPACE_BETWEEN_LINES 20.f #define MENU_COLOR_UNSELECTED makergba(.8f, .8f, .8f, .8f) #define MENU_COLOR_SELECTED COLOR_WHITE enum MenuId { M_NONE, M_MAIN, M_LEVELS, M_HELP, M_MAIN_OPTIONS, M_CONFIRM_EXIT, M_INGAME, M_INGAME_OPTIONS, M_CONFIRM_CANCEL, // Pseudo-menus M_PASSIVE, M_ACTION_EXIT, M_ACTION_CANCEL, M_ACTION_PLAY, M_ACTION_RESUME, M_ACTION_TOGGLE_MUSIC, M_ACTION_TOGGLE_SOUND_FX }; #define MAX_MENU_STRING 100 #define MAX_MENU_ENTRIES 20 struct MenuEntryDef { char text[MAX_MENU_STRING]; MenuId target; int target_ix; }; struct MenuDef { char title[MAX_MENU_STRING]; int num_entries; MenuEntryDef entries[MAX_MENU_ENTRIES]; }; MenuDef g_MenuDefs[] = { /*M_NONE*/ { "", 0, {}}, /*M_MAIN*/ { "", 4, {{ "PLAY", M_LEVELS, 0 }, { "HELP", M_HELP, 0 }, { "OPTIONS", M_MAIN_OPTIONS, 0 }, { "EXIT", M_CONFIRM_EXIT, 1 }} }, /*M_LEVELS*/ { "CHOOSE WORLD", 10, {{ "JUNGLE", M_ACTION_PLAY, 0}, { "PLAINS", M_ACTION_PLAY, 1}, { "DESERT", M_ACTION_PLAY, 2}, { "CAVES", M_ACTION_PLAY, 3}, { "OCEAN", M_ACTION_PLAY, 4}, { "TECHNO", M_ACTION_PLAY, 5}, { "LAVA", M_ACTION_PLAY, 6}, { "ANEMONA", M_ACTION_PLAY, 7}, { "LIMBO", M_ACTION_PLAY, 8}, { "BACK", M_MAIN, 0}} }, /*M_HELP*/ { "", 15, {{ "WELCOME TO", M_PASSIVE, 0}, { "SPACECRASH!", M_PASSIVE, 0}, { "STEER YOUR SHIP,", M_PASSIVE, 0}, { "AVOID ROCKS,", M_PASSIVE, 0}, { "MINES, AND", M_PASSIVE, 0}, { "ENEMY DRONES", M_PASSIVE, 0}, { "", M_PASSIVE, 0}, { "GAME BY JONBHO", M_PASSIVE, 0}, { "GFX BY DAN COOK", M_PASSIVE, 0}, { "MUSIC AND FX BY", M_PASSIVE, 0}, { "(---)", M_PASSIVE, 0}, { "", M_PASSIVE, 0}, { "WWW.JONBHO.NET", M_PASSIVE, 0}, { "", M_PASSIVE, 0}, { "BACK", M_MAIN, 1}} }, /*M_MAIN_OPTIONS*/ { "OPTIONS", 3, {{ "MUSIC: ON", M_ACTION_TOGGLE_MUSIC, 0}, { "SOUND FX: ON", M_ACTION_TOGGLE_SOUND_FX, 1}, { "BACK", M_MAIN, 2}}, }, /*M_CONFIRM_EXIT*/ { "ARE YOU SURE?", 2, {{ "EXIT", M_ACTION_EXIT, 0}, { "GO BACK", M_MAIN, 3}} }, /*M_INGAME*/ { "", 3, {{ "OPTIONS", M_INGAME_OPTIONS, 0 }, { "EXIT WORLD", M_CONFIRM_CANCEL, 1 }, { "CONTINUE", M_ACTION_RESUME, 0 }} }, /*M_INGAME_OPTIONS*/ { "OPTIONS", 3, {{ "MUSIC: ON", M_ACTION_TOGGLE_MUSIC, 0}, { "SOUND FX: ON", M_ACTION_TOGGLE_SOUND_FX, 1}, { "BACK", M_INGAME, 1}} }, /*M_CONFIRM_CANCEL*/ { "ARE YOU SURE?", 2, {{ "EXIT", M_ACTION_CANCEL, 0}, { "GO BACK", M_INGAME, 1}} }, }; //----------------------------------------------------------------------------- MenuId g_current_menu = M_HELP; int g_current_menu_option = 0; //----------------------------------------------------------------------------- void DrawCenteredLine(float y, char text[], rgba color) { float w = strlen(text) * MENU_CHAR_SIZE; DrawString(vmake(.5f * G_WIDTH - .5f * w, y), text, MENU_CHAR_SIZE, color); } //----------------------------------------------------------------------------- void RenderMenu() { // Make sure current option is valid for current menu if (g_current_menu_option < 0) g_current_menu_option = 0; else if (g_current_menu_option >= g_MenuDefs[g_current_menu].num_entries) g_current_menu_option = g_MenuDefs[g_current_menu].num_entries - 1; while (g_current_menu_option >= 0 && g_current_menu_option < g_MenuDefs[g_current_menu].num_entries - 1 && g_MenuDefs[g_current_menu].entries[g_current_menu_option].target == M_PASSIVE) g_current_menu_option++; // Calculate menu height bool has_title = strlen(g_MenuDefs[g_current_menu].title) > 0; int nlines = g_MenuDefs[g_current_menu].num_entries + (has_title ? 1 : 0); float menu_height = nlines * MENU_CHAR_SIZE + (nlines-1) * MENU_SPACE_BETWEEN_LINES; float current_y = .5f * G_HEIGHT + .5f * menu_height; if (has_title) { DrawCenteredLine(current_y, g_MenuDefs[g_current_menu].title, MENU_COLOR_UNSELECTED); current_y -= MENU_CHAR_SIZE + MENU_SPACE_BETWEEN_LINES; } for (int i = 0; i < g_MenuDefs[g_current_menu].num_entries; i++) { char text[MAX_MENU_STRING]; // Special text if (g_MenuDefs[g_current_menu].entries[i].target == M_ACTION_TOGGLE_MUSIC) { if (g_opt_music) strcpy(text, "MUSIC: ON"); else strcpy(text, "MUSIC: OFF"); } else if (g_MenuDefs[g_current_menu].entries[i].target == M_ACTION_TOGGLE_SOUND_FX) { if (g_opt_sound_fx) strcpy(text, "SOUND FX: ON"); else strcpy(text, "SOUND FX: OFF"); } else strcpy(text, g_MenuDefs[g_current_menu].entries[i].text); if (i != g_current_menu_option) DrawCenteredLine(current_y, text, MENU_COLOR_UNSELECTED); else DrawCenteredLine(current_y, text, MENU_COLOR_SELECTED); current_y -= MENU_CHAR_SIZE + MENU_SPACE_BETWEEN_LINES; } } //----------------------------------------------------------------------------- void EnterMenu(GameState gs) { if (gs == GS_MAIN_MENU) { g_gs = GS_MAIN_MENU; g_current_menu = M_MAIN; g_current_menu_option = 0; } else if (gs == GS_INGAME_MENU) { g_gs = GS_INGAME_MENU; g_current_menu = M_INGAME; g_current_menu_option = 0; } } //----------------------------------------------------------------------------- void DoMenuAction(MenuId action, int ix) { switch (action) { case M_NONE: case M_PASSIVE: // Do nothing! break; case M_ACTION_EXIT: g_user_exit = true; break; case M_ACTION_CANCEL: // Cancel the game FinishGame(); EnterMenu(GS_MAIN_MENU); break; case M_ACTION_PLAY: ResetNewGame(ix); // Start new game in given level break; case M_ACTION_RESUME: g_gs = GS_PLAYING; // Return to playing break; case M_ACTION_TOGGLE_MUSIC: g_opt_music = !g_opt_music; UpdateSoundStatus(); break; case M_ACTION_TOGGLE_SOUND_FX: g_opt_sound_fx = !g_opt_sound_fx; UpdateSoundStatus(); break; // All other cases: enter menu default: g_current_menu = action; g_current_menu_option = ix; break; } } #define IS_UNSELECTABLE(ENTRY) (ENTRY.target == M_PASSIVE || (ENTRY.target == M_ACTION_PLAY && ENTRY.target_ix > g_unlocked_level)) //----------------------------------------------------------------------------- void ProcessInputMenu() { if (g_just_pressed_up) { do { g_current_menu_option--; if (g_current_menu_option < 0) g_current_menu_option = g_MenuDefs[g_current_menu].num_entries - 1; } while (IS_UNSELECTABLE(g_MenuDefs[g_current_menu].entries[g_current_menu_option])); } else if (g_just_pressed_down) { do { g_current_menu_option++; if (g_current_menu_option >= g_MenuDefs[g_current_menu].num_entries) g_current_menu_option = 0; } while (IS_UNSELECTABLE(g_MenuDefs[g_current_menu].entries[g_current_menu_option])); } else if (g_just_pressed_space || g_just_pressed_enter) { MenuId action = g_MenuDefs[g_current_menu].entries[g_current_menu_option].target; int ix = g_MenuDefs[g_current_menu].entries[g_current_menu_option].target_ix; DoMenuAction(action, ix); } else if (g_just_pressed_esc) { // Do action for last entry in the menu MenuId action = g_MenuDefs[g_current_menu].entries[g_MenuDefs[g_current_menu].num_entries-1].target; int ix = g_MenuDefs[g_current_menu].entries[g_MenuDefs[g_current_menu].num_entries-1].target_ix; DoMenuAction(action, ix); } } |
This takes over 300 lines of code, as you see, more than our Windows OpenGL support, or more than our core sound engine! This is often the case in games: things that are not really very exciting may end up taking more work than the high-tech ones!
Finally, we need to integrate this into the main game. It is not difficult, but you have to take into account all conditions. I’ve added two main new GameState types (GS_MAIN_MENU and GS_INGAME_MENU). When in a menu, ProcessInputMenu() has to be called instead of the regular ProcessInput(), RenderMenu() has to be called, and I’ve had to implement a few extra functions such as FinishGame(). Here are the highlights of this integration code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
... // Global options bool g_opt_music = true; bool g_opt_sound_fx = true; ... void StopLoopSound(unsigned loopchannel) { CORE_StopLoopSound(loopchannel); } ... bool g_user_exit = false; int g_unlocked_level = 0; ... //----------------------------------------------------------------------------- void FinishGame() { g_active_tileset = MAIN_MENU_TILESET; g_current_race_pos = 0.f; g_camera_offset = 0.f; g_rock_chance = START_ROCK_CHANCE_PER_PIXEL; g_gs = GS_MAIN_MENU; g_gs_timer = 0.f; g_last_generated = -1; for (int i = 0; i < MAX_ENTITIES; i++) g_entities[i].type = E_NULL; ResetPSystems(); StopLoopSound(SHIP_ENGINE_SOUND_CHANNEL); } ... void Render() { glClear( GL_COLOR_BUFFER_BIT ); ... if (g_gs == GS_MAIN_MENU || g_gs == GS_INGAME_MENU) RenderMenu(); } //----------------------------------------------------------------------------- void Run() { if (g_gs != GS_INGAME_MENU && g_gs != GS_MAIN_MENU) { ... Running the actual game ... } // Advance game mode g_gs_timer += FRAMETIME; switch (g_gs) { ... case GS_MAIN_MENU: g_camera_offset += MENU_BKG_SCROLL_SPEED; break; case GS_INGAME_MENU: // Do nothing break; } g_time_from_last_rocket += FRAMETIME; } //----------------------------------------------------------------------------- void ProcessInput() { g_just_pressed_up = SYS_KeyPressed(SYS_KEY_UP) && !g_was_pressed_up; g_just_pressed_down = SYS_KeyPressed(SYS_KEY_DOWN) && !g_was_pressed_down; g_just_pressed_enter = SYS_KeyPressed(SYS_KEY_ENTER) && !g_was_pressed_enter; g_just_pressed_esc = SYS_KeyPressed(SYS_KEY_ESC) && !g_was_pressed_esc; g_just_pressed_space = SYS_KeyPressed(SYS_KEY_SPACE) && !g_was_pressed_space; if (g_gs == GS_MAIN_MENU || g_gs == GS_INGAME_MENU) ProcessInputMenu(); else ProcessInputInGame(); g_was_pressed_up = SYS_KeyPressed(SYS_KEY_UP); g_was_pressed_down = SYS_KeyPressed(SYS_KEY_DOWN); g_was_pressed_enter = SYS_KeyPressed(SYS_KEY_ENTER); g_was_pressed_esc = SYS_KeyPressed(SYS_KEY_ESC); g_was_pressed_space = SYS_KeyPressed(SYS_KEY_SPACE); } |
The code is a bit messy in places now, with handling belonging to things in wholly separate conceptual areas too intermixed (for example, running the menu and running the rocket-shooting are in completely different levels, and the code should reflect that). I’ve done so for the ProcessInput() code, but I should find some time do that for the running, rendering, etc…
Here you can get the whole latest version of the code and graphics: sc-day-5.zip.
And here is how this looks now:
Spacecrash: Day 5 of 7 from Jon Beltran de Heredia on Vimeo.
Afterword
So at the end of day five, we have all the core elements for a complete game. Tomorrow I will work in preparing an installer, which is the last critical piece. But there are several key areas that still need work:
- Gameplay and difficulty progression
- Sound and music
- Graphics effects
And there are a few glitches to fix up: sound has to be paused in the in-game menu, progression data has to be saved to disk, and probably a few more like this.
I will work on these things on Sunday and finish a “1.0” version, and most likely, I will invest a couple more days next week for the final polish. I think even this reflects pretty well on how a game development project usually works!
And if you want to be notified when new articles to help you become a games developer are ready, just fill in the form below:
[…] Spacecrash day 5 of 7: fonts and menus […]