CODEpendent

wxNibbles - Event Driven Game Programing with wxWidgets

Part 2: Game Physics

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.

Conclusion

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.


Copyright © 2005 CODEpendent
All Rights Reserved

Get Firefox!    Valid HTML 4.01!    Made with jEdit