Spacecrash day 4 of 7: multiple levels with cool, varied graphics and gameplay
I initially planned to have only 3 levels, but turning that into 9 looked more sensible: completing a level is a positive reward for the player, and thus it will help us provide them with a better experience. So let’s get started trying to generate nine levels with varied graphics and progressive difficulty.
Both variety and progression are very important to providing a satisfying game experience. Let’s start with the graphics which are the easiest here, thanks to Dan Cook’s Tyrian graphics. I initially planned on having three “worlds”, thus three main sets of graphics, and three levels in each world with progressing difficulty. But given the Tyrian graphics easily allow more variety, I have decided on having 9 different looks, one for each level.
Level graphics
Having a look at the Tyrian graphics, I saw some interesting things for level background graphics. Have a look at this piece (shown at 4x size):
The piece above can be broken down in tiles of 24×28 pixels, and the cool thing is that the piece above comprise all possible combinations of sand and grass. Thus, if we create a grid, and we randomly assign a type of terrain to each grid corner (sand or grass), we can then find the right tile to paint in each cell, looking at the terrain type for the corners.
Actually, it’s not true, the tiles above are missing two cases (grass and sand in pairs in opposite corners of the cell), but those are easy enough to prepare by hand.
So what did I do? I extracted these graphics by hand using Paint.NET, and saved as separate 32bpp BMP images for each tile: 16 tiles per combination of two terrain types. Fortunately the Tyrian graphics have several of these cases… so I did this for 9 different combinations, choosing the ones I liked the most. There were several combinations I really liked, but they didn’t respond to the same pattern. I had to keep things simple, so I only used tilesets that could processed in the same way.
Once I had this, I needed to write some code to generate the random terrain types for grid corners, and then the tiles for each grid cell. I don’t want to store the whole level, so I made the code generate the background by pieces. I have a piece that can contain about two screenfuls of data (less would do too), and the whole level is mapped on top of this in a repeated fashion – this structure is called a “ring buffer”.
Have a look at the code that does this (in game.cpp):
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 |
... // Tiles T_TILES_G_ON_S, T_SSSS = T_TILES_G_ON_S, T_SSSG, T_SSGS, T_SSGG, T_SGSS, T_SGSG, T_SGGS, T_SGGG, T_GSSS, T_GSSG, T_GSGS, T_GSGG, T_GGSS, T_GGSG, T_GGGS, T_GGGG, ... //----------------------------------------------------------------------------- // Background generation #define TILE_WIDTH 120//96//75 #define TILE_HEIGHT 140//112//58 #define TILES_ACROSS (1+(int)((G_WIDTH +TILE_WIDTH -1)/TILE_WIDTH)) #define TILES_DOWN (1+(int)((G_HEIGHT+TILE_HEIGHT-1)/TILE_HEIGHT)) #define RUNNING_ROWS (2 * TILES_DOWN) // Bottom to top! byte Terrain[RUNNING_ROWS][TILES_ACROSS]; TexId TileMap[RUNNING_ROWS][TILES_ACROSS]; int g_last_generated = -1; void GenTerrain(float upto) { int last_required_row = 1 + (int)(upto / TILE_HEIGHT); // Generate random terrain types for (int i = g_last_generated+1; i <= last_required_row; i++) { int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) Terrain[mapped_row][j] = (CORE_RandChance(0.5f) & 1); } // Calculate the tiles for (int i = g_last_generated; i <= last_required_row; i++) { int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) { unsigned v = (Terrain[mapped_row ][j ] << 1) | (Terrain[mapped_row ][UMod(j+1, TILES_ACROSS)] << 0) | (Terrain[UMod(mapped_row+1, RUNNING_ROWS)][j ] << 3) | (Terrain[UMod(mapped_row+1, RUNNING_ROWS)][UMod(j+1, TILES_ACROSS)] << 2); if (v > 15) v = 15; TileMap[mapped_row][j] = (TexId)(g_active_tileset + v); } } g_last_generated = last_required_row; } //----------------------------------------------------------------------------- void RenderTerrain() { int first_row = (int)(g_camera_offset / TILE_HEIGHT); for (int i = first_row; i < first_row + TILES_DOWN; i++) { int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) CORE_RenderCenteredSprite( vsub(vmake(j * TILE_WIDTH + .5f * TILE_WIDTH, i * TILE_HEIGHT + .5f * TILE_HEIGHT), vmake(0.f,g_camera_offset)), vmake(TILE_WIDTH * 1.01f, TILE_HEIGHT * 1.01f), Tex(TileMap[mapped_row][j]) ); } } |
As you can see, the code is not complex, although the underlying arithmetics can be a bit tricky to wrap your head around. The tiles are ordered in the textures list such that the right one is picked.
If you are doing these changes by hand, which is actually the best way to follow along the one-week-game project, please note that you have to change the value of MAX_TEXTURES in core.cpp to 256 or so, or the textures won’t fit and you will see a lot of white blocks!
The result is pretty nice for what we have been able to invest, look how this looks with the tile set above and a couple other nicer tilesets:



Level progression
But the most important part is the actual challenges the player has to confront as they progress through the game. We prepared some logic yesterday which can generate level content according to some parameters, while always allowing the player to overcome the challenges. What I have done is prepared a different set of values for these parameters, and the right set is selected by a g_current_level variable that is set at the beginning of the level.
Here is the code that holds this information, and the content-generation using these parameters:
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 |
... TexId g_active_tileset = T_TILES_G_ON_S; int g_current_level = 0; // 0 to 8 //============================================================================= // Levels #define NUM_LEVELS 9 struct LevelDesc { TexId tileset; float level_length; float path_width; float path_twist_ratio; int min_layers; int max_layers; int min_rocks_per_layer; int max_rocks_per_layer; float chance_mine; float chance_drone; float min_space_between_layers; float max_space_between_layers; float min_space_between_challenges; float max_space_between_challenges; float level_start_chance_alt_terrain; float level_end_chance_alt_terrain; }; LevelDesc LevelDescs[NUM_LEVELS] = { // { T_TILES_G_ON_S, 40000.f, (2.2f * MAINSHIP_RADIUS), 0.5f, 0, 10, 0, 3, 0.1f, 0.0f, 300.f, 600.f, 700.f, 2500.f, 0.2f, 0.9f }, { T_TILES_P_ON_S, 60000.f, (2.1f * MAINSHIP_RADIUS), 0.5f, 1, 15, 0, 3, 0.1f, 0.0f, 300.f, 600.f, 650.f, 2300.f, 0.2f, 0.9f }, { T_TILES_D_ON_S, 80000.f, (2.0f * MAINSHIP_RADIUS), 0.5f, 1, 20, 0, 3, 0.2f, 0.0f, 250.f, 600.f, 600.f, 2100.f, 0.2f, 0.9f }, { T_TILES_E_ON_D, 100000.f, (1.9f * MAINSHIP_RADIUS), 0.6f, 2, 25, 1, 4, 0.3f, 0.0f, 250.f, 500.f, 550.f, 1900.f, 0.2f, 0.9f }, { T_TILES_I_ON_W, 120000.f, (1.8f * MAINSHIP_RADIUS), 0.7f, 2, 30, 1, 4, 0.4f, 0.1f, 200.f, 500.f, 500.f, 1700.f, 0.1f, 0.9f }, { T_TILES_Y_ON_X, 140000.f, (1.7f * MAINSHIP_RADIUS), 0.8f, 3, 35, 1, 4, 0.4f, 0.1f, 200.f, 500.f, 450.f, 1500.f, 0.2f, 0.9f }, { T_TILES_R_ON_L, 160000.f, (1.6f * MAINSHIP_RADIUS), 0.9f, 3, 40, 2, 5, 0.5f, 0.1f, 200.f, 400.f, 400.f, 1300.f, 0.8f, 0.1f }, { T_TILES_V_ON_K, 180000.f, (1.5f * MAINSHIP_RADIUS), 1.0f, 4, 45, 2, 5, 0.6f, 0.2f, 150.f, 400.f, 350.f, 1100.f, 0.2f, 0.9f }, { T_TILES_B_ON_K, 200000.f, (1.4f * MAINSHIP_RADIUS), 1.1f, 4, 50, 2, 5, 0.7f, 0.3f, 150.f, 400.f, 300.f, 900.f, 0.2f, 0.9f } }; //----------------------------------------------------------------------------- // Background generation #define TILE_WIDTH 120//96//75 #define TILE_HEIGHT 140//112//58 #define TILES_ACROSS (1+(int)((G_WIDTH +TILE_WIDTH -1)/TILE_WIDTH)) #define TILES_DOWN (1+(int)((G_HEIGHT+TILE_HEIGHT-1)/TILE_HEIGHT)) #define RUNNING_ROWS (2 * TILES_DOWN) // Bottom to top! byte Terrain[RUNNING_ROWS][TILES_ACROSS]; TexId TileMap[RUNNING_ROWS][TILES_ACROSS]; int g_last_generated = -1; void GenTerrain(float upto) { int last_required_row = 1 + (int)(upto / TILE_HEIGHT); // Generate random terrain types for (int i = g_last_generated+1; i <= last_required_row; i++) { float advance = (i * TILE_HEIGHT) / LevelDescs[g_current_level].level_length; float chance = LevelDescs[g_current_level].level_start_chance_alt_terrain + advance * (LevelDescs[g_current_level].level_end_chance_alt_terrain - LevelDescs[g_current_level].level_start_chance_alt_terrain); int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) Terrain[mapped_row][j] = (CORE_RandChance(chance) & 1); } // Calculate the tiles for (int i = g_last_generated; i <= last_required_row; i++) { int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) { unsigned v = (Terrain[mapped_row ][j ] << 1) | (Terrain[mapped_row ][UMod(j+1, TILES_ACROSS)] << 0) | (Terrain[UMod(mapped_row+1, RUNNING_ROWS)][j ] << 3) | (Terrain[UMod(mapped_row+1, RUNNING_ROWS)][UMod(j+1, TILES_ACROSS)] << 2); if (v > 15) v = 15; TileMap[mapped_row][j] = (TexId)(g_active_tileset + v); } } g_last_generated = last_required_row; } //----------------------------------------------------------------------------- void RenderTerrain() { int first_row = (int)(g_camera_offset / TILE_HEIGHT); for (int i = first_row; i < first_row + TILES_DOWN; i++) { int mapped_row = UMod(i, RUNNING_ROWS); for (int j = 0; j < TILES_ACROSS; j++) CORE_RenderCenteredSprite( vsub(vmake(j * TILE_WIDTH + .5f * TILE_WIDTH, i * TILE_HEIGHT + .5f * TILE_HEIGHT), vmake(0.f,g_camera_offset)), vmake(TILE_WIDTH * 1.01f, TILE_HEIGHT * 1.01f), Tex(TileMap[mapped_row][j]) ); } } ... //----------------------------------------------------------------------------- // Level generation float g_next_challenge_area = FIRST_CHALLENGE; vec2 g_last_conditioned = vmake(0.f,0.f); //#define PATH_TWIST_RATIO .5f // This means about 30 degrees maximum //#define PATH_WIDTH (2.f * MAINSHIP_RADIUS) void GenNextElements() { // Called every game loop, but only does work when we are close to the next "challenge area" if (g_current_race_pos + G_HEIGHT > g_next_challenge_area) { // Get params from current level float path_width = LevelDescs[g_current_level].path_width; float path_twist_ratio = LevelDescs[g_current_level].path_twist_ratio; int min_layers = LevelDescs[g_current_level].min_layers; int max_layers = LevelDescs[g_current_level].max_layers; int min_rocks_per_layer = LevelDescs[g_current_level].min_rocks_per_layer; int max_rocks_per_layer = LevelDescs[g_current_level].max_rocks_per_layer; float chance_mine = LevelDescs[g_current_level].chance_mine; float chance_drone = LevelDescs[g_current_level].chance_drone; float min_space_between_layers = LevelDescs[g_current_level].min_space_between_layers; float max_space_between_layers = LevelDescs[g_current_level].max_space_between_layers; float min_space_between_challenges = LevelDescs[g_current_level].min_space_between_challenges; float max_space_between_challenges = LevelDescs[g_current_level].max_space_between_challenges; float current_y = g_next_challenge_area; LOG(("Current: %f\n", g_next_challenge_area)); // Choose how many layers of rocks int nlayers = (int)CORE_URand(min_layers, max_layers); LOG((" nlayers: %d\n", nlayers)); for (int i = 0; i < nlayers; i++) { LOG((" where: %f\n", current_y)); // Choose pass point float displace = (current_y - g_last_conditioned.y) * path_twist_ratio; float bracket_left = g_last_conditioned.x - displace; float bracket_right = g_last_conditioned.x + displace; bracket_left = MAX(bracket_left, 2.f * MAINSHIP_RADIUS); bracket_right = MIN(bracket_right, G_WIDTH - 2.f * MAINSHIP_RADIUS); g_last_conditioned.y = current_y; g_last_conditioned.x = CORE_FRand(bracket_left, bracket_right); /*InsertEntity(E_JUICE, vmake(g_last_conditioned.x, g_last_conditioned.y), vmake(0.f,0.f), JUICE_RADIUS, T_JUICE, false, true);*/ // Choose how many rocks int nrocks = (int)CORE_URand(min_rocks_per_layer, max_rocks_per_layer); LOG((" nrocks: %d\n", nrocks)); // Gen rocks for (int i = 0; i < nrocks; i++) { // Find a valid pos! vec2 rockpos; for (int k = 0; k < 10; k++) // 10 attempts maximum, avoid infinite loops! { rockpos = vmake(CORE_FRand(0.f, G_WIDTH), current_y); if ( rockpos.x + ROCK_RADIUS < g_last_conditioned.x - path_width || rockpos.x - ROCK_RADIUS > g_last_conditioned.x + path_width) break; } // Insert obstacle EType t = E_ROCK; TexId gfx = T_ROCK1; if (CORE_RandChance(chance_mine )) { t = E_MINE; gfx = T_MINE; } else if (CORE_RandChance(chance_drone)) { t = E_DRONE; gfx = T_DRONE2; } InsertEntity(t, rockpos, vmake(CORE_FRand(-.5f, +.5f), CORE_FRand(-.5f, +.5f)), //vmake(0.f,0.f), ROCK_RADIUS, gfx, true); } current_y += CORE_FRand(min_space_between_layers, max_space_between_layers); } g_next_challenge_area = current_y + CORE_FRand(min_space_between_challenges, max_space_between_challenges); LOG(("Next: %f\n\n", g_next_challenge_area)); } } ... //----------------------------------------------------------------------------- void ResetNewGame(int level) { if (level < 0) level = 0; else if (level >= NUM_LEVELS) level = NUM_LEVELS-1; g_current_level = level; g_active_tileset = LevelDescs[level].tileset; // Reset everything for a new game... g_next_challenge_area = FIRST_CHALLENGE; g_last_conditioned = vmake(.5f * G_WIDTH, 0.f); g_current_race_pos = 0.f; g_camera_offset = 0.f; g_rock_chance = START_ROCK_CHANCE_PER_PIXEL; g_gs = GS_STARTING; g_gs_timer = 0.f; g_last_generated = -1; // Start logic for (int i = 0; i < MAX_ENTITIES; i++) g_entities[i].type = E_NULL; InsertEntity(E_MAIN, vmake(G_WIDTH/2.0, G_HEIGHT/8.f), vmake(0.f, SHIP_START_SPEED), MAINSHIP_RADIUS, T_SHIP_C, true); PlayLoopSound(1, SND_ENGINE, 0.7f, 0.3f); ResetPSystems(); MAIN_SHIP.psystem = CreatePSystem(PST_FIRE, MAIN_SHIP.pos, vmake(0.f,0.f)); MAIN_SHIP.psystem_off = vmake(0.f, -120.f); } ... //----------------------------------------------------------------------------- void ProcessInput() { ... if (SYS_KeyPressed('1')) ResetNewGame(0); else if (SYS_KeyPressed('2')) ResetNewGame(1); else if (SYS_KeyPressed('3')) ResetNewGame(2); else if (SYS_KeyPressed('4')) ResetNewGame(3); else if (SYS_KeyPressed('5')) ResetNewGame(4); else if (SYS_KeyPressed('6')) ResetNewGame(5); else if (SYS_KeyPressed('7')) ResetNewGame(6); else if (SYS_KeyPressed('8')) ResetNewGame(7); else if (SYS_KeyPressed('9')) ResetNewGame(8); } |
While we don’t have menus implemented, I’ve just made the keys 1-9 take you to the start of each level.
And here is how the levels look now (some of them gorgeous, if you ask me, thanks to the awesome Tyrian graphics):
Spacecrash: Day 4 of 7 from Jon Beltran de Heredia on Vimeo.
You can download the whole package of source and resources in its current state, for you to play around: sc-day-4.zip.
Recap
I am behind compared to what I expected to have today. Here are the main limitations:
- Gameplay and gameplay difficulty are not adjusted for an engaging experience and progression.
- More enemy behavior and variety is needed. At the very least proper sizes for obstacles and enemies!
- Graphics and sound effects need a lot of work
I don’t want to change the schedule as a matter of principle for this project. If I go deeper into these areas, I may miss other critical areas, such as menus/UI or deployment. So I will work on menus tomorrow, and prepare an installer on Saturday. If I can make some time apart from this, I will put in some work in these areas. Since it probably won’t be enough time, on Sunday we’ll have a complete game, only with mediocre effects, gameplay and progression – so I will plan for a couple more days next week working on these two areas. It’s definitely not the worst budget blow-up I’ve been through!
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:
Awesome.
I ironed out my graphics problems and made my “always speeding up/ slow down when shooting” game mode more challenging by tweaking some settings. And I’ve made the drones slowly home in on you, so that you have no choice than to use your limited amount of rockets.
And I’ve managed to merge it with the current code base.
You can find my changes here : https://www.dropbox.com/sh/4rktp3w29rnha70/ND1sKoyacW
I’m off to work now 🙂
Hey that’s great, and quite similar to what I’m planning to do with the drones. I have also given it some thought and I’m considering your concept of slowing down when shooting. The main thing is that I don’t want to allow the player to slow down too easily, it removes a big part of the “tension”. So it has to go.
I’m also going to get rid of fuel (the original idea was to reward very precise players and punish those who have to steer a lot, but it isn’t working, and it makes things confusing).
Will post with these things later, and hopefully with some nice menus too.
I keep meaning to try your version, although my time is so scarce I’m not sure I can! If You uploaded a video I would certainly want to have a look.
Thanks!
I managed to make a video.
Maybe the game starts out with too many rockets (29 – that’s the max amount that I could fit on the left next to the life bar). But at the end of the video I was still starting to run down on rockets.
http://vimeo.com/73474219
Thanks much, that’s cool! I really enjoyed seeing your variation 🙂 Indeed, the gameplay changes a lot. I’m actually thinking about concentrating more and more in proper steering, and I have a bit of a hard time finding the proper place for rocket-shooting in the concept. Will keep thinking about it and post my thoughts (and the details on what I do).
Thanks for putting in the work in your version, and for sharing it!
Well, playing with the code helps me understand it a little better, and sharing my changes gives me joy (even more if it’s appreciated 😉 )
I’m eagerly awaiting your next update. And see if I can play with it. That’s the best way to learn.
BTW : did you know that there’s a promotion on commandos 2+3 on gog.com this weekend? http://www.gog.com/promo/blue_moon_red_owl_digital_game_factory_weekend_promo_300813
Hah cool, those games are a part of history now 🙂 I’m working on the menus right now and I’ll post it later today when I got them done.
I’m having a weird issue, and I can’t find what I’m doing wrong.
When I first start the level, I see the terrain, and it’s sort of right, but it there are a few scattered white tiles, like the generator is going out of range or something.
So I checked that all my files are in place, and they are. Then I debugged GenTerrain, and found that the values being generated for the tiles are in range (I think?). The start of the sg tileset is at index 23, and the gen function gets a tile between index 23 and 23+15 (38). I can’t see any values above 38 or below 23.
The major clue is that as the level progresses, more and more white tiles show up, until the end of the level when all the tiles are totally white. I’m guessing it’s finding out of range tiles because of the g_camera_offset or maybe the line that calculates “float advance” in GenTerrain, but I’m not sure… my code in there is identical to yours, I compared in a diff program.
It’s the same for RenderTerrain–it’s passing a sensible value to Tex, and the code is identical to yours.
What do you think?
Pete, my apologies because the issue is most likely in a part of the code that is fixed in the downloadable code, but not mentioned above – the MAX_TEXTURES constant defined at the top of core.cpp has to be raised to 256. White textures appear when there is no valid texture, and this can happen either when the file was not found, or when there is no room to load the texture in the texture manager array in core.cpp!
I will edit the text above to add a mention to this. My apologies for the time wasted.
Damn, I should’ve been able to figure that out based on the indices (anything above 31 wouldn’t work!). Thanks a lot 8)
[…] Spacecrash day 4 of 7: multiple levels with cool, varied graphics and gameplay […]