Before designing any object, one must ask oneself what it needs. In our case, the snake needs to have a direction to move towards. It also needs to have lives, keep track of the score, its speed, whether it lost or not, and whether it lost or not. Lastly, we're going to store a rectangle shape that will represent every segment of the snake. When all these are addressed, the header of the snake class would look something like the following:
class Snake{
public:
Snake(int l_blockSize);
~Snake();
// Helper methods.
void SetDirection(Direction l_dir);
Direction GetDirection();
int GetSpeed();
sf::Vector2i GetPosition();
int GetLives();
int GetScore();
void IncreaseScore();
bool HasLost();
void Lose(); // Handle losing here.
void ToggleLost();
void Extend(); // Grow the snake.
void Reset(); // Reset to starting position.
void Move(); // Movement method.
void Tick(); // Update method.
void Cut(int l_segments); // Method for cutting snake.
void Render(sf::RenderWindow& l_window);
private:
void CheckCollision(); // Checking for collisions.
SnakeContainer m_snakeBody; // Segment vector.
int m_size; // Size of the graphics.
Direction m_dir; // Current direction.
int m_speed; // Speed of the snake.
int m_lives; // Lives.
int m_score; // Score.
bool m_lost; // Losing state.
sf::RectangleShape m_bodyRect; // Shape used in rendering.
};
Note that we're using our new type alias for the snake segment vector. This doesn't look that helpful just yet, but it's about to be, really soon.
As you can see, our class has a few methods defined that are designed to split up the functionality, such as Lose(), Extend(), Reset(), and CheckCollision(). This will increase code re-usability as well as readability. Let's begin actually implementing these methods:
The constructor is pretty straightforward. It takes one argument, which is the size of our graphics. This value gets stored for later use and the member of type sf::RectangleShape gets its size adjusted based on it. The subtraction of one pixel from the size is a very simple way of maintaining that the snake segments appear visually slightly separated, as illustrated here:
The constructor also calls the Reset() method on the last line. A comment in the header file states that this method is responsible for moving the snake into its starting position. Let's make that happen:
This chunk of code will be called every time a new game begins. First, it will clear the snake segment vector from the previous game. After that, some snake segments will get added. Because of our implementation, the first element in the vector is always going to be the head. The coordinates for the snake pieces are hardcoded for now, just to keep it simple.
Now we have a three-piece snake. The first thing we do now is set its direction to None. We want no movement to happen until a player presses a key to move the snake. Next, we set up some arbitrary values for the speed, the lives, and the starting score. These can be adjusted to your liking later. We also set the m_lost flag to false in order to signify a new round taking place.
Before moving on to more difficult to implement methods, let's quickly cover all the helper ones:
This preceding method is the one responsible for actually growing out our snake when it touches an apple. The first thing we did was create a reference to the last element in the segment vector, called tail_head. We have a fairly large if-else statement chunk of code next, and both cases of it require access to the last element, so it's a good idea to create the reference now in order to prevent duplicated code.
Tip
The std::vector container overloads the bracket operator in order to support random access via a numeric index. It being similar to an array enables us to reference the last element by simply using an index of size() - 1. The random access speed is also constant, regardless of the number of elements in this container, which is what makes the std::vector a good choice for this project.
Essentially, it comes down to two cases: either the snake is longer than one segment or it's not. If it does have more than one piece, we create another reference, called tail_bone, which points to the next to last element. This is needed in order to determine where a new piece of the snake should be placed upon extending it, and the way we check for that is by comparing the position.x and position.y values of the tail_head and tail_bone segments. If the x values are the same, it's safe to say that the difference between the two pieces is on the y axis and vice versa. Consider the following illustration, where the orange rectangle is tail_bone and the red rectangle is tail_head:
Let's take the example that's facing left and analyze it: tail_bone and tail_head have the same y coordinate, and the x coordinate of tail_head is greater than that of tail_bone, so the next segment will be added at the same coordinates as tail_head, except the x value will be increased by one. Because the SnakeSegment constructor is conveniently overloaded to accept coordinates, it's easy to perform this simple math at the same time as pushing the segment onto the back of our vector.
In the case of there only being one segment in the vector, we simply check the direction of our snake and perform the same math as we did before, except that this time it's based on which way the head is facing. The preceding illustration applies to this as well, where the orange rectangle is the head and the red rectangle is the piece that's about to be added. If it's facing left, we increase the x coordinate by one while leaving y the same. Subtracting from x happens if it's facing right, and so on. Take your time to analyze this picture and associate it with the previous code.
Of course, none of this would matter if our snake didn't move. That's exactly what is being handled in the update method, which in our case of a fixed time-step is referred to as a "tick":
void Snake::Tick(){
if (m_snakeBody.empty()){ return; }
if (m_dir == Direction::None){ return; }
Move();
CheckCollision();
}
The first two lines in the method are used to check if the snake should be moved or not, based on its size and direction. As mentioned earlier, the Direction::None value is used specifically for the purpose of keeping it still. The snake movement is contained entirely within the Move method:
void Snake::Move(){
for (int i = m_snakeBody.size() - 1; i > 0; --i){
m_snakeBody[i].position = m_snakeBody[i - 1].position;
}
if (m_dir == Direction::Left){
--m_snakeBody[0].position.x;
} else if (m_dir == Direction::Right){
++m_snakeBody[0].position.x;
} else if (m_dir == Direction::Up){
--m_snakeBody[0].position.y;
} else if (m_dir == Direction::Down){
++m_snakeBody[0].position.y;
}
}
We start by iterating over the vector backwards. This is done in order to achieve an inchworm effect of sorts. It is possible to do it without iterating over the vector in reverse as well, however, this serves the purpose of simplicity and makes it easier to understand how the game works. We're also utilizing the random access operator again to use numeric indices instead of the vector iterators for the same reasons. Consider the following illustration:
We have a set of segments in their positions before we call the tick method, which can be referred to as the "beginning state". As we begin iterating over our vector backwards, we start with the segment #3. In our for loop, we check if the index is equal to 0 or not in order to determine if the current segment is the front of the snake. In this case, it's not, so we set the position of segment #3 to be the same as the segment #2. The preceding illustration shows the piece to be, sort of, in between the two positions, which is only done for the purpose of being able to see both of them. In reality, segment #3 is sitting right on top of segment #2.
After the same process is applied again to the second part of the snake, we move on to its head. At this point, we simply move it across one space in the axis that corresponds to its facing direction. The same idea applies here as it did in the illustration before this one, but the sign is reversed. Since in our example, the snake is facing right, it gets moved to the coordinates (x+1;y). Once that is done, we have successfully moved our snake by one space.
One last thing our tick does is call the CheckCollision() method. Let's take a look at its implementation:
First, there's no need to check for a collision unless we have over four segments. Understanding certain scenarios of your game and putting in checks to not waste resources is an important part of game development. If we have over four segments of our snake, we create a reference to the head again, because in any case of collision, that's the first part that would hit another segment. There is no need to check for a collision between all of its parts twice. We also skip an iteration for the head of the snake, since there's obviously no need to check if it's colliding with itself.
The basic way we check for a collision in this grid-based game is essentially by comparing the position of the head to the position of the current segment represented by our iterator. If both positions are the same, the head is intersecting with the body. The way we resolve this was briefly covered in the Game design decisions section of this chapter. The snake has to be cut at the point of collision until the player runs out of lives. We do this by first obtaining an integer value of the segment count between the end and the segment being hit. STL is fairly flexible with its iterators, and since the memory in the case of using a vector is all laid out contiguously, we can simply subtract our current iterator from the last element in the vector to obtain this value. This is done in order to know how many elements to remove from the back of the snake up until the point of intersection. We then invoke the method that is responsible for cutting the snake. Also, since there can only be one collision at a time, we break out of the for loop to not waste any more clock cycles.
Let's take a look at the Cut method:
void Snake::Cut(int l_segments){
for (int i = 0; i < l_segments; ++i){
m_snakeBody.pop_back();
}
--m_lives;
if (!m_lives){ Lose(); return; }
}
At this point, it's as simple as looping a certain amount of times based on the l_segments value and popping the elements from the back of the vector. This effectively slices through the snake.
The rest of the code simply decreases the amount of lives left, checks if it's at zero, and calls the Lose() method if there are no more lives.
Phew! That's quite a bit of code. One thing still remains, however, and that is rendering our square serpent to the screen:
Quite similarly to a lot of the methods we've implemented here, there's a need to iterate over each segment. The head itself is drawn outside of the loop in order to avoid unnecessary checks. We set the position of our sf::RectangleShape that graphically represents a snake segment to its grid position multiplied by the m_size value in order to obtain the pixel coordinates on the screen. Drawing the rectangle is the last step of implementing the snake class in its entirety!