Now it's time to extend our wxNibbles game with more elements of the game. In this part, we will add the game physics, including collision detection, level mazes, apple placement, and snake growth. We will only be creating one level right now, so in this version, we will never finish a level, but that will come in a later version.
I am just starting to look at the differences between version 0.1 and version 0.2, but since I haven't made a patch, you'll need to download the new source archive. Binaries are also available on Windows and Linux with GTK+ 2.0.
Download [ Source (.zip) | Win32 Binary | Source (.tar.bz2) | Linux Binary (GTK+ 2.0) ]
There is only one new class, Level. We'll get to it later. For now, let's start examining the changes to the classes we had in version 0.1. Take a look at the NibblesApp::OnInit() method.
// seed the random number generator srand(time(NULL));We added this line to seed the random number generator. We will be using this to generate random positions for the apple. This is the only major change here, so let's move on to the NibblesPanel class. Take a look at the NibblesPanel::onPaint() method.
void NibblesPanel::onPaint(wxPaintEvent &) { wxBufferedPaintDC dc(this); // clear the background dc.SetBackground(*wxMEDIUM_GREY_BRUSH); dc.Clear(); if (game.isPlaying()) { // draw the level drawLevel(dc); // draw the snake drawSnake(dc); // draw the apple drawApple(dc); if (!game.getSnake().isAlive() && !game.isPaused()) { // pause the Game game.togglePause(); if (game.getSnake().getLives() > 0) { // the snake has more lives wxMessageBox(wxT("Your snake has died. How sad."), wxT("Snake Death"), wxOK | wxICON_EXCLAMATION); // restart the game game.restart(); } else { // no more lives for the snake wxMessageBox(wxT("Your snake is out of lives."), wxT("Game Over"), wxOK | wxICON_INFORMATION); // end the Game game.end(); // Refresh the display Refresh(); } } } }It's a much larger method than it was in version 0.1. In addition to drawing the snake, we also have to draw the level and the apple. We also handle snake death here.
Take note of the if test just before togglePause() is called. It's very important that we never reach this code more than once. The wxMessageBox code can't stop the event loop, otherwise it would never know when people clicked on it's buttons. Since the event loop is still running, EVT_PAINT events can occur. This could trigger an infinite loop of wxMessageBox displays if we let this code happen more than once. To prevent this, we do two things. First, pause the game. This is helpful because otherwise the Game would refresh the panel causing more EVT_PAINT events we don't want right now. Second, we can use the game's pause to test if we have already displayed the message box. So that when EVT_PAINT gets called normally, we'll ignore this part of the code and won't trigger an infinite loop.
It might be nicer here if instead of displaying message boxes if we drew the message graphically, but since I have no talent for that, I opted for message boxes instead. Real game writers have artists who do this stuff for them. Let's take a look at NibblesPanel::drawLevel() now.
void NibblesPanel::drawLevel(wxDC &dc) { const Level &level = game.getLevel(); // begin drawing dc.BeginDrawing(); // draw the walls dc.SetPen(*wxBLACK_PEN); dc.SetBrush(*wxBLACK_BRUSH); for (int y = 0; y < PANEL_HEIGHT; y += BLOCK_SIZE) { for (int x = 0; x < PANEL_WIDTH; x += BLOCK_SIZE) { if (level.isWall(wxPoint(x, y))) { dc.DrawRectangle(x, y, BLOCK_SIZE, BLOCK_SIZE); } } } // end drawing dc.EndDrawing(); }The drawLevel() method draws our level maze. It is a simple iteration over the two dimensions. At each point where a wall is defined, we draw a solid black block. This is one of the places where the graphics could be improved, but it wasn't a priority for me. Now let's take a look at NibblesPanel::drawApple().
void NibblesPanel::drawApple(wxDC &dc) { // begin drawing dc.BeginDrawing(); // draw the apple dc.SetPen(*wxRED_PEN); dc.SetBrush(*wxRED_BRUSH); int radius = BLOCK_SIZE / 2; wxPoint apple = game.getApple(); apple.x += radius; apple.y += radius; dc.DrawCircle(apple, radius); // end drawing dc.EndDrawing(); }The apple is a simple red circle drawn at it's location. Half the block size is the radius. Incredibly straightforward. These are all the major changes here, though I think I changed the colors in the background and the snake. Speaking of the snake, let's move on to it. Let's take a look at Snake::reincarnate(), defined in source/engine/Snake.cc.
void Snake::reincarnate() { // The Snake is alive and headed north direction = NORTH; alive = true; // center the Snake vertically in the middle of the screen wxPoint point((PANEL_WIDTH / 2) - BLOCK_SIZE, (PANEL_HEIGHT / 2) - (2 * BLOCK_SIZE)); segments.clear(); for (int i = 0; i < 4; i++) { segments.push_back(point); point.y += BLOCK_SIZE; } }Now that the snake can die, it needs a way of regenerating itself for each life. This is where reincarnate comes into play. It's basically the same as the old constructor, but it clears the segments vector since there might be more segments as the snake can eat apples now. Also note that we replaced the constructor with a call to reincarnate. Now take a look at Snake::addSegment().
void Snake::addSegment(int count) { // get the last part of the Snake wxPoint &segment = segments[segments.size() - 1]; // clone it for (int i = 0; i < count; i++) { segments.push_back(segment); } }This method was present in the last version, though it has changed its signature. My original idea for how it would work didn't make as much sense as it did when I defined it. Since we didn't look at it last time, I won't worry about the old version since it was never called. Thinking about it now, it would probably be better to call this in reincarnate rather than trying to center the snake vertically as we actually do.
addSegment() simply grows the body of the snake by the number of requested segments. For each new segment, the last body piece is cloned. These are the only major changes to snake, so we can move on to the Game class now. Let's take a look at Game::start().
// randomize apple position moveApple();We've added this line here to setup the apple position. Let's go ahead and look at the moveApplet() method.
void Game::moveApple() { for (;;) { // find a random spot for the apple apple.x = (rand() / ((RAND_MAX / (PANEL_WIDTH / BLOCK_SIZE)) + 1)) * BLOCK_SIZE; apple.y = (rand() / ((RAND_MAX / (PANEL_HEIGHT / BLOCK_SIZE)) + 1)) * BLOCK_SIZE; if (!isOccupied(apple, true)) { // if we found a free spot, we're done break; } // otherwise we have to keep looking } }Not a very complicated method. We generate a random (x,y) coordinate. Then we check if it's occupied (by a wall or the snake). If it is, we keep looking, otherwise we have our apple position. Let's examine the isOccupied() method next.
bool Game::isOccupied(const wxPoint &point, bool checkHead) const { int start = (checkHead ? 0 : 1); // is the point occupied by the Snake for (unsigned int i = start; i < snake->getSegments().size(); i++) { if (point == snake->getSegments()[i]) { return true; } } // is the point occupied by a Wall? if (level.isWall(point)) { return true; } return false; }The isOccupied() method just checks whether a certain point is occupied, either by the snake or a maze wall. It is used when moving the apple and checking for collisions. Since the snake's head can't hit itself, we use checkHead to indiciate whether we check the snake's head for collision. In moveApple(), we do, because we can't put the apple right in the snake's mouth. When we check for collision, we don't, because only the head can collide.
Back in the NibblesPanel::onPaint() method, you may have noticed a call to Game::restart(). This method is called when the snake dies and the game has to be restarted. So let's take a look at that.
void Game::restart() { // pause the Game paused = true; timer->Stop(); // reincarnate the snake snake->reincarnate(); // randomize apple position moveApple(); // refresh the display panel.Refresh(); }restart() is very similar to start(). First we make sure the game is paused, then we reincarnate the snake, move the apple, and refresh the display. Finally, we'll take a look at the updated tick() method.
void Game::tick() { // move the snake snake->move(); // did the snake crash? if (isOccupied(snake->getSegments()[0])) { snake->setAlive(false); snake->subtractLife(); } // did the snake eat the apple? if (snake->getSegments()[0] == apple) { // randomize the apple position moveApple(); // grow the snake snake->addSegment(3); } // refresh the panel panel.Refresh(); }It's much more complicated than it used to be. There's more stuff to take care of here. Remember, this is the driving method for the entire game.
After we move the snake, we need to check to see if it hit anything. First we check if it hit a wall. If it did, then the snake dies and it loses a life. Next, we check if it ate the apple. If it did, then we need to grow the snake and move the apple to a new location. Finally, we refresh the screen.
I never talked about this explicitly, but the values used for the timer to control the speed of the game are contained in the static member variable Game::SPEEDS[]. Since we only have a concept, and not an implementation of difficulty levels, the speed is always the middle value. This is how much time (in milliseconds) passes before the timer goes off and causes a Game::tick() to be called. I changed these values from version 0.1 because the game was far too slow. It would probably be nice if these values were adjusted further, but I didn't play with them much.
Now we can take a look at the new class, the Level class. There's only one important method, isWall().
bool Level::isWall(wxPoint point) const { point.x /= BLOCK_SIZE; point.y /= BLOCK_SIZE; return (LEVEL_DATA[level][point.y][point.x] == 1); }isWall() simply checks if a wall exists at the specified point. Level maze data is defined in LevelData.hh. I won't reprint all that here, but it's more complex than it looks. It is a 3D array, comprising an array of 2D level data. Levels are defined in row arrays, where each member of the array is a column point. If the column point is 1, then that column within the row is solid (a wall), otherwise it is empty (no wall). The first (and currently sole) level simply creates a wall around the level so the snake can't slither off-screen. Since there is nothing to handle wrap-around in the snake's travel, there must be a wall around the borders of the screen. It might have been nice to allow wrap-around, but it was never a priority for me, so if you define more levels, make sure they have wall borders.
The game's looking a little better now. It's fulfulls the basic requirements of a Nibbles game. There is a snake that can move, eat apples, and grow. The remaining versions of the program focus on improving the game's interface (adding more levels, scoring sytem, hi scores, custom input map, etc).
Take a look at part 3 when you're ready to continue. Feel free to contact me if you have questions.
Need to contact us? We can be reached by email or via our online feedback form.