In this part of our Minesweeper tutorial, we'll build upon our previous code by adding the ability to track and display the count of bombs in adjacent cells.
This feature provides players with the information they need to make informed decisions during gameplay.
We’ll also allow cells to be cleared when there are no adjacent bombs, allowing players to clear large sections of the grid automatically.
To implement the adjacent bomb count feature, we first need to determine if a MinesweeperCell
is adjacent to another MinesweeperCell
.
We'll add a new method to our MinesweeperCell
class called isAdjacent
. This method will take another MinesweeperCell
pointer as an argument and return a boolean indicating whether that cell is adjacent to the cell that is calling the method.
// Minesweeper/Cell.h
// ...
class MinesweeperCell : public Engine::Button {
// ...
private:
bool isAdjacent(MinesweeperCell* Other) const;
// ...
};
Our MinesweeperCell
objects already know where they are in the grid, thanks to the Row
and Col
values (and associated GetRow()
and GetCol()
getters) provided when the MinesweeperGrid
constructs them.
We can use this to implement our isAdjacent
method:
// Minesweeper/Cell.cpp
// ...
bool MinesweeperCell::isAdjacent(
MinesweeperCell* Other) const{
return !(Other == this)
&& std::abs(GetRow() - Other->GetRow()) <= 1
&& std::abs(GetCol() - Other->GetCol()) <= 1;
}
If it’s not clear what this logic is doing, we can imagine that two cells, A
and B
, are adjacent if they are within one row and one column of each other - that is:
A.Row - B.Row
is either -1
, 0
or 1
, andA.Col - B.Col
is either -1
, 0
or 1
We can use the concept of absolute value to simplify this. The absolute value of a number is how far away the number is from 0
. Typically, we can imagine this as simply removing the negative sign from a number, if it had one.
The std::abs
function receives a number and returns its absolute value - for example, std::abs(-1)
will return 1
.
So, equivalently, two cells are adjacent if:
std::abs(A.Row - B.Row)
is <= 1
, andstd::abs(A.Col - B.Col)
is <= 1
Note that if std::abs(A.Row - B.Row)
and std::abs(A.Col - B.Col)
are both 0
, that means A
and B
are in the exact same position in the grid.
In the context of our game, that means the the two cells are the same object. We don’t want a cell to be considered adjacent to itself so, if Other == this
, isAdjacent()
will return false
.
In the previous part, our MinesweeperCell
objects were pushing an SDL_Event
to the event queue whenever they received a bomb. Every cell is notified of this event, so they can use them to keep track of the number of adjacent bombs.
To implement this, we'll adding two new elements to our MinesweeperCell
class:
AdjacentBombs
integer to store the count of adjacent bombs.HandleBombPlaced
method to receive the event, and update the AdjacentBombs
count if the event was created by an adjacent cell.// Minesweeper/Cell.h
// ...
class MinesweeperCell : public Engine::Button {
// ...
private:
void HandleBombPlaced(const SDL_UserEvent& E);
int AdjacentBombs{0};
// ...
};
We’ll call the HandleBombPlaced()
method whenever a bomb is placed on the board. The ReportEvent()
method our our MinesweeperCell
class is attaching a pointer to the cell that created each event. This pointer is in the data1
variable of the SDL_UserEvent
.
data1
is technically a void pointer (void*
), but we know it’s pointing to a MinesweeperCell
, so we can statically cast it:
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
We’ll pass this MinesweeperCell*
off to our isAdjacent
function. If isAdjacent()
returns true, that means a bomb was placed in an adjacent cell, and we’ll update our AdjacentBombs
counter accordingly:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleBombPlaced(
const SDL_UserEvent& E){
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
if (isAdjacent(Cell)) {
++AdjacentBombs;
}
}
Finally, we need to call HandleBombPlaced
at the appropriate time, so we’ll update the HandleEvent
method. Previously, this method was simply logging to the terminal when a bomb was placed. Now, it will call our new HandleBombPlaced
function:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleEvent(
const SDL_Event& E){
if (E.type == UserEvents::CELL_CLEARED) {
// TODO
std::cout << "A Cell Was Cleared\n";
} else if (E.type == UserEvents::BOMB_PLACED) {
HandleBombPlaced(E.user);
}
Button::HandleEvent(E);
}
// ...
To visually represent the number of adjacent bombs, we'll update our game to render this count on cleared cells that have at least one adjacent bomb.
We want the count to have a different color depending on the number of adjacent bombs, so let’s add a TEXT_COLORS
array to our Config
namespace in Globals.h
.
This array maps the number of adjacent bombs to a specific color. We won’t render any text if there are no adjacent bombs, so TEXT_COLORS[0]
will technically not be used, but we’ll include it anyway as indices must start at 0
:
// Globals.h
// ...
namespace Config{
// ...
// Text color based on number of surrounding bombs
inline const std::vector<SDL_Color> TEXT_COLORS{
/* 0 */ {0, 0, 0, 255}, // Unused
/* 1 */ {0, 1, 249, 255},
/* 2 */ {1, 126, 1, 255},
/* 3 */ {250, 1, 2, 255},
/* 4 */ {1, 0, 128, 255},
/* 5 */ {129, 1, 0, 255},
/* 6 */ {0, 128, 128, 255},
/* 7 */ {0, 0, 0, 255},
/* 8 */ {128, 128, 128, 255}
};
// ...
}
// ...
We're using the Engine::Text
class to handle the rendering of the adjacent bomb count. This class takes care of creating and rendering text surfaces for us. Let’s add an Engine::Text
member to each of our MinesweeperCell
objects:
// Minesweeper/Cell.h
#pragma once
#include "Engine/Button.h"
#include "Engine/Image.h"
#include "Engine/Text.h"
class MinesweeperCell : public Engine::Button {
// ...
private:
// ...
Engine::Text Text;
};
In the MinesweeperCell
constructor, we initialize the Text
object with its position (x
and y
), size (w
and h
), the text to render (which is "0" for now) and its corresponding color.
// Minesweeper/Cell.cpp
// ...
MinesweeperCell::MinesweeperCell(
int x, int y, int w, int h, int Row, int Col)
: Button{x, y, w, h}, Row{Row}, Col{Col},
BombImage{
x, y, w, h,
Config::BOMB_IMAGE},
Text{
x, y, w, h,
std::to_string(AdjacentBombs),
Config::TEXT_COLORS[AdjacentBombs]}{};
// ...
Whenever a bomb is placed adjacent to the cell, we’ll call the SetText()
method of Engine::Text
, passing the new value, and the corresponding colour:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleBombPlaced(
const SDL_UserEvent& E){
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
if (isAdjacent(Cell)) {
++AdjacentBombs;
Text.SetText(std::to_string(AdjacentBombs),
Config::TEXT_COLORS[AdjacentBombs]);
}
}
Finally, we'll updated the Render
method to render the text when a cell is cleared and has adjacent bombs:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::Render(
SDL_Surface* Surface){
Button::Render(Surface);
if (isCleared && hasBomb) {
BombImage.Render(Surface);
} else if (isCleared && AdjacentBombs > 0) {
Text.Render(Surface);
}
#ifdef SHOW_DEBUG_HELPERS
else if (hasBomb) { BombImage.Render(Surface); }
#endif
}
If we run our application and clear cells that are adjacent to bombs, we should now see the correct number display, in the correct color:
Remember, the reason we can see the bombs is because of the preprocessor directive enabling debug helpers. If we want to test the game as a player would, we can comment out that declaration:
// Globals.h
#pragma once
// #define SHOW_DEBUG_HELPERS
// ...
In Minesweeper, when a cell with no adjacent bombs is cleared, all adjacent cells are automatically cleared as well. This creates a cascading effect that can clear large areas of the board at once.
To implement this feature, we'll follow a similar pattern as before. Our MinesweeperCell
objects are already being notified when any cell is cleared.
We’ll add a HandleCellCleared
method to implement the automatic-clearing behaviour where appropriate:
// Minesweeper/Cell.h
// ...
class MinesweeperCell : public Engine::Button {
// ...
private:
void HandleCellCleared(const SDL_UserEvent& E);
// ...
};
As before, our implementation will static_cast
the data1
void pointer to a MinesweeperCell*
.
We check if the cell that was cleared had a bomb. If it does, we don’t want to clear any adjacent cells, as the game is effectively over. We’ll implement the game-over logic in the next part.
If the cell that was cleared did not have a bomb, is adjacent to this cell, and it had no adjacent bombs, we’ll clear this cell too:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleCellCleared(
const SDL_UserEvent& E){
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
if (Cell->hasBomb) return;
if (
isAdjacent(Cell) &&
Cell->AdjacentBombs == 0
) {
ClearCell();
}
}
Remember, the ClearCell()
method creates further UserEvents::CELL_CLEARED
events, causing the chain reaction that allows large areas of the board to be cleared automatically.
Finally, let’s update our HandleEvent()
method to forward UserEvents::CELL_CLEARED
to our new HandleCellCleared()
method:
//
// ...
void MinesweeperCell::HandleEvent(
const SDL_Event& E){
if (E.type == UserEvents::CELL_CLEARED) {
HandleCellCleared(E.user);
} else if (E.type ==
UserEvents::BOMB_PLACED) {
HandleBombPlaced(E.user);
}
Button::HandleEvent(E);
}
With these changes, we should now be able to click on an area of our grid that does not have any nearby bombs, and see a chain reaction clearing a large area of the board automatically:
Complete versions of the files we changed in this part are available below
#pragma once
#define SHOW_DEBUG_HELPERS
#include <iostream>
#include <SDL.h>
#include <string>
#include <vector>
namespace UserEvents{
inline Uint32 CELL_CLEARED =
SDL_RegisterEvents(1);
inline Uint32 BOMB_PLACED =
SDL_RegisterEvents(1);
}
namespace Config{
// Game Settings
inline const std::string GAME_NAME{
"Minesweeper"};
inline constexpr int BOMB_COUNT{6};
inline constexpr int GRID_COLUMNS{8};
inline constexpr int GRID_ROWS{4};
static_assert(
BOMB_COUNT < GRID_COLUMNS * GRID_ROWS,
"Cannot have more bombs than cells"
);
// Size and Positioning
inline constexpr int PADDING{5};
inline constexpr int CELL_SIZE{50};
inline constexpr int GRID_HEIGHT{
CELL_SIZE * GRID_ROWS
+ PADDING * (GRID_ROWS - 1)
};
inline constexpr int WINDOW_HEIGHT{
GRID_HEIGHT + PADDING * 2
};
inline constexpr int WINDOW_WIDTH{
CELL_SIZE * GRID_COLUMNS
+ PADDING * (GRID_COLUMNS - 1)
+ PADDING * 2
};
// Colors
inline constexpr SDL_Color BACKGROUND_COLOR{
170, 170, 170, 255};
inline constexpr SDL_Color BUTTON_COLOR{
200, 200, 200, 255};
inline constexpr SDL_Color BUTTON_HOVER_COLOR{
220, 220, 220, 255};
inline constexpr SDL_Color
BUTTON_CLEARED_COLOR{
240, 240, 240, 255};
// Text color based on number of surrounding bombs
inline const std::vector<SDL_Color> TEXT_COLORS{
/* 0 */ {0, 0, 0, 255}, // Unused
/* 1 */ {0, 1, 249, 255},
/* 2 */ {1, 126, 1, 255},
/* 3 */ {250, 1, 2, 255},
/* 4 */ {1, 0, 128, 255},
/* 5 */ {129, 1, 0, 255},
/* 6 */ {0, 128, 128, 255},
/* 7 */ {0, 0, 0, 255},
/* 8 */ {128, 128, 128, 255}
};
// Asset Paths
inline const std::string BOMB_IMAGE{
"Bomb.png"};
inline const std::string FONT{
"Rubik-SemiBold.ttf"};
}
namespace Utils{
#ifdef SHOW_DEBUG_HELPERS
inline void CheckSDLError(
const std::string& Msg){
const char* error = SDL_GetError();
if (*error != '\0') {
std::cerr << Msg << " Error: " << error <<
'\n';
SDL_ClearError();
}
}
#endif
}
#pragma once
#include "Engine/Button.h"
#include "Engine/Image.h"
#include "Engine/Text.h"
class MinesweeperCell : public Engine::Button {
public:
MinesweeperCell(
int X, int Y, int W, int H, int Row, int Col
);
void HandleEvent(const SDL_Event& E) override;
void Render(SDL_Surface* Surface) override;
void Reset();
bool PlaceBomb();
[[nodiscard]]
bool GetHasBomb() const{ return hasBomb; }
[[nodiscard]]
int GetRow() const{ return Row; }
[[nodiscard]]
int GetCol() const{ return Col; }
protected:
void HandleLeftClick() override;
private:
void ClearCell();
void ReportEvent(uint32_t EventType);
void HandleCellCleared(
const SDL_UserEvent& E);
void HandleBombPlaced(const SDL_UserEvent& E);
bool isAdjacent(MinesweeperCell* Other) const;
int AdjacentBombs{0};
int Row;
int Col;
bool hasBomb{false};
bool isCleared{false};
Engine::Image BombImage;
Engine::Text Text;
};
#include <string>
#include "Minesweeper/Cell.h"
#include "Globals.h"
MinesweeperCell::MinesweeperCell(
int x, int y, int w, int h, int Row, int Col)
: Button{x, y, w, h}, Row{Row}, Col{Col},
BombImage{
x, y, w, h,
Config::BOMB_IMAGE},
Text{
x, y, w, h,
std::to_string(AdjacentBombs),
Config::TEXT_COLORS[AdjacentBombs]}{};
void MinesweeperCell::HandleEvent(
const SDL_Event& E){
if (E.type == UserEvents::CELL_CLEARED) {
HandleCellCleared(E.user);
} else if (E.type ==
UserEvents::BOMB_PLACED) {
HandleBombPlaced(E.user);
}
Button::HandleEvent(E);
}
void MinesweeperCell::Render(
SDL_Surface* Surface){
Button::Render(Surface);
if (isCleared && hasBomb) {
BombImage.Render(Surface);
} else if (isCleared && AdjacentBombs > 0) {
Text.Render(Surface);
}
#ifdef SHOW_DEBUG_HELPERS
else if (hasBomb) { BombImage.Render(Surface); }
#endif
}
void MinesweeperCell::Reset(){
isCleared = false;
hasBomb = false;
AdjacentBombs = 0;
SetIsDisabled(false);
SetColor(Config::BUTTON_COLOR);
}
bool MinesweeperCell::PlaceBomb(){
if (hasBomb) return false;
hasBomb = true;
ReportEvent(UserEvents::BOMB_PLACED);
return true;
}
void MinesweeperCell::ClearCell(){
if (isCleared) return;
isCleared = true;
SetIsDisabled(true);
SetColor(Config::BUTTON_CLEARED_COLOR);
ReportEvent(UserEvents::CELL_CLEARED);
}
void MinesweeperCell::ReportEvent(
uint32_t EventType){
SDL_Event event{EventType};
event.user.data1 = this;
SDL_PushEvent(&event);
}
void MinesweeperCell::HandleCellCleared(
const SDL_UserEvent& E){
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
if (Cell->hasBomb) return;
if (isAdjacent(Cell) && Cell->AdjacentBombs ==
0) { ClearCell(); }
}
void MinesweeperCell::HandleBombPlaced(
const SDL_UserEvent& E){
MinesweeperCell* Cell{
static_cast<MinesweeperCell*>(E.data1)
};
if (isAdjacent(Cell)) {
++AdjacentBombs;
Text.SetText(std::to_string(AdjacentBombs),
Config::TEXT_COLORS[
AdjacentBombs]);
}
}
bool MinesweeperCell::isAdjacent(
MinesweeperCell* Other) const{
return !(Other == this)
&& std::abs(Other->GetRow() - Row) <= 1
&& std::abs(Other->GetCol() - Col) <= 1;
}
void MinesweeperCell::HandleLeftClick(){
ClearCell();
}
Files not listed above have not been changed since the previous section.
In this part of the tutorial, we've significantly enhanced our Minesweeper game by implementing the adjacent bomb count feature.
We've added methods to determine cell adjacency, keep track of adjacent bombs, render the bomb count, and automatically clear adjacent empty cells.
These additions bring our game closer to a fully functional Minesweeper implementation.
In the next part, we'll focus on detecting and reacting to win and lose conditions, which will complete the core gameplay loop of our Minesweeper game.
Implement the techniques for detecting nearby bombs and clearing empty cells automatically.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games