In this final part of our Minesweeper series, we'll add the ability for players to place flags on suspected mine locations.
We'll update our game logic to handle flag placement and removal, create a visual representation for flags, and implement a counter to track the number of flags placed.
By the end of this lesson, you'll have a fully functional Minesweeper game with all the classic features players expect!
Let’s start by updating our Globals.h
. We’ll register events for flags being placed and cleared, and we’ll also add a string to define where our flag image is stored:
// Globals.h
// ...
namespace UserEvents{
// ...
inline Uint32 FLAG_PLACED =
SDL_RegisterEvents(1);
inline Uint32 FLAG_CLEARED =
SDL_RegisterEvents(1);
}
namespace Config{
// ...
inline const std::string FLAG_IMAGE{
"flag.png"};
}
// ...
To place a flag, players will right click on a cell, so we need to update our MinesweeperCell
class. The base Engine::Button
class has a HandleRightClick()
method we can override, and we’ll additionally at a boolean to store whether the player has placed a flag in this cell:
// Minesweeper/Cell.h
// ...
class MinesweeperCell : public Engine::Button {
protected:
void HandleRightClick() override;
// ...
private:
// ...
bool hasFlag{false};
};
In our HandleRightClick()
implementation, we’ll simply toggle the hasFlag
variable, and report the appropriate event:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleRightClick(){
if (hasFlag) {
ReportEvent(UserEvents::FLAG_CLEARED);
hasFlag = false;
} else {
ReportEvent(UserEvents::FLAG_PLACED);
hasFlag = true;
}
}
Left-clicking on a cell that contains a flag shouldn’t clear the cell, so let’s add that logic to our HandleLeftClick()
function:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::HandleLeftClick(){
if (!hasFlag) { ClearCell(); }
}
// ...
When the player starts a new game, all the flags should be cleared. Let’s add that to our Reset()
function:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::Reset(){
isCleared = false;
hasFlag = false;
hasBomb = false;
AdjacentBombs = 0;
SetIsDisabled(false);
SetColor(Config::BUTTON_COLOR);
}
// ...
The player doesn’t necessarily need to mark all the bombs to win the game. As a small presentational improvement, let’s just automatically flag all the cells with bombs in response to the GAME_WON
event:
// Minesweeper/Cell.cpp
// ...
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);
} else if (E.type == UserEvents::GAME_WON) {
if (hasBomb) {
hasFlag = hasBomb;
SetColor(Config::BUTTON_SUCCESS_COLOR);
}
SetIsDisabled(true);
} else if (E.type == UserEvents::GAME_LOST) {
if (hasBomb) {
isCleared = true;
SetColor(Config::BUTTON_FAILURE_COLOR);
}
SetIsDisabled(true);
}
Button::HandleEvent(E);
}
// ...
Now that our cells are keeping track of whether or not they’re flagged, let’s render some visual output for the flags. We’ll add another Engine::Image
member to our MinesweeperCell
to render the flag:
// Minesweeper/Cell.h
class MinesweeperCell : public Engine::Button {
// ...
private:
// ...
Engine::Image FlagImage;
};
In our constructor, we initialize this variable in much the same way we initialized the bomb image:
// 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},
FlagImage{
x, y, w, h,
Config::FLAG_IMAGE},
BombImage{
x, y, w, h,
Config::BOMB_IMAGE},
Text{
x, y, w, h,
std::to_string(AdjacentBombs),
Config::TEXT_COLORS[AdjacentBombs]}{};
// ...
Finally, in our Render()
method, if the cell has a flag, we’ll render the FlagImage
:
// Minesweeper/Cell.cpp
// ...
void MinesweeperCell::Render(
SDL_Surface* Surface){
Button::Render(Surface);
if (hasFlag) {
FlagImage.Render(Surface);
} else 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
}
Running our game, we can now right-click in any uncleared cell to place a flag. Left clicking a flagged cell should have no effect, but right clicking it will remove the flag:
Let’s add a counter to our footer, so the player can keep track of how many bombs they have yet to place flags on. This indicator will have the full height of the footer, with its width and color controlled by Config
variables in Global.h
:
// Globals.h
// ...
namespace Config{
// ...
inline constexpr int FLAG_COUNTER_WIDTH{100};
// ...
inline constexpr SDL_Color FLAG_COUNTER_COLOR{
80, 80, 80, 255};
// ...
}
// ...
Let’s create our Minesweeper/FlagCounter
class. It inherits from Engine::Rectangle
, and will contain an Image
and Text
element side-by-side.
The position and size of the Rectangle
will be controlled by constructor arguments, with the color coming from our Config
namespace.
The Image
will be placed at the top left of the Rectangle
, with its height being based on the FOOTER_HEIGHT
config variable, and its width being the same as the height to ensure it is square. We also apply a 24 pixel padding to make the image slightly smaller.
The Text
variable uses slightly more arithmetic to ensure it appears to the right of the Image
. We also set the initial string to be the total number of bombs in the grid. We set the color to white, and the font size to 20:
// Minesweeper/FlagCounter.h
#pragma once
#include <string>
#include "Globals.h"
#include "Engine/Rectangle.h"
#include "Engine/Text.h"
#include "Engine/Image.h"
class FlagCounter : public Engine::Rectangle {
public:
FlagCounter(int x, int y, int w, int h)
: Rectangle{
x, y, w, h, Config::FLAG_COUNTER_COLOR},
Image{
x, y,
Config::FOOTER_HEIGHT - Config::PADDING,
Config::FOOTER_HEIGHT - Config::PADDING,
Config::FLAG_IMAGE,
24},
Text{
x + Config::FOOTER_HEIGHT, y,
w - Config::FOOTER_HEIGHT - 24, h,
std::to_string(Config::BOMB_COUNT),
{255, 255, 255, 255}, 20}{}
private:
Engine::Image Image;
Engine::Text Text;
};
We’ll override the base rectangle’s Render()
method to ensure the Text
and Image
are also rendered:
// Minesweeper/FlagCounter.h
// ...
class FlagCounter : public Engine::Rectangle {
public:
// ...
void Render(SDL_Surface* Surface) override{
Rectangle::Render(Surface);
Text.Render(Surface);
Image.Render(Surface);
}
// ...
};
Finally, we’ll implement a HandleEvent()
method so that the FlagCounter
can update appropriately:
0
, because when this happens, our MinesweeperCell
objects automatically place flags where all the bombs wereAdditionally, when any of these events occur, we call Text
member’s SetText()
function to inform it what number it should be rendering:
// Minesweeper/FlagCounter.h
// ...
class FlagCounter : public Engine::Rectangle {
public:
// ...
void HandleEvent(const SDL_Event& E){
if (E.type ==
UserEvents::FLAG_PLACED) {
--FlagsAvailable;
} else if (E.type ==
UserEvents::FLAG_CLEARED) {
++FlagsAvailable;
} else if (E.type == UserEvents::GAME_WON) {
FlagsAvailable = 0;
} else if (E.type == UserEvents::NEW_GAME) {
FlagsAvailable = Config::BOMB_COUNT;
} else { return; }
Text.SetText(
std::to_string(FlagsAvailable)
);
}
private:
// ...
int FlagsAvailable{Config::BOMB_COUNT};
};
Finally, let’s add it to the UI. As before, we add it as a member of our MinesweeperUI
, and add it to our Render()
and HandleEvent()
functions.
We reduce the width of the NewGameButton
to make space in the footer, and then do some arithmetic to set the FlagCounter
's position (x
and y
) and size (w
and h
).
The calculations involved to determine these constructor arguments is getting excessively messy and something we’d typically want to address.
Given we’re essentially finished with this project, we’ll leave it like this for now, but it’s a problem we should consider solving in our Engine
module to make future projects easier. We discuss options for this later in the lesson.
Our final MinesweeperUI
class looks like this:
// Minesweeper/UI.h
#pragma once
#include "Globals.h"
#include "Minesweeper/Grid.h"
#include "Minesweeper/NewGameButton.h"
#include "Minesweeper/FlagCounter.h"
class MinesweeperUI {
public:
void Render(SDL_Surface* Surface){
Grid.Render(Surface);
Button.Render(Surface);
Counter.Render(Surface);
}
void HandleEvent(const SDL_Event& E){
Grid.HandleEvent(E);
Button.HandleEvent(E);
Counter.HandleEvent(E);
};
private:
MinesweeperGrid Grid{
Config::PADDING, Config::PADDING
};
NewGameButton Button{
Config::PADDING,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::WINDOW_WIDTH - Config::PADDING * 3
- Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
FlagCounter Counter{
Config::WINDOW_WIDTH - Config::PADDING
- Config::FLAG_COUNTER_WIDTH,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
};
Our game is now complete and should be fully working! Remember to disable the SHOW_DEBUG_HELPERS
definition to hide the bombs:
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);
inline Uint32 FLAG_PLACED =
SDL_RegisterEvents(1);
inline Uint32 FLAG_CLEARED =
SDL_RegisterEvents(1);
inline Uint32 GAME_WON =
SDL_RegisterEvents(1);
inline Uint32 GAME_LOST =
SDL_RegisterEvents(1);
inline Uint32 NEW_GAME =
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 FOOTER_HEIGHT{60};
inline constexpr int FLAG_COUNTER_WIDTH{100};
inline constexpr int GRID_HEIGHT{
CELL_SIZE * GRID_ROWS
+ PADDING * (GRID_ROWS - 1)
};
inline constexpr int GRID_WIDTH{
CELL_SIZE * GRID_COLUMNS +
PADDING * (GRID_COLUMNS - 1)};
inline constexpr int WINDOW_HEIGHT{
GRID_HEIGHT + FOOTER_HEIGHT
+ PADDING * 2
};
inline constexpr int WINDOW_WIDTH{
GRID_WIDTH + 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};
inline constexpr SDL_Color
BUTTON_SUCCESS_COLOR{
210, 235, 210, 255};
inline constexpr SDL_Color
BUTTON_FAILURE_COLOR{
235, 210, 210, 255};
inline constexpr SDL_Color FLAG_COUNTER_COLOR{
80, 80, 80, 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 FLAG_IMAGE{
"flag.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;
void HandleRightClick() 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 hasFlag{false};
bool isCleared{false};
Engine::Image FlagImage;
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},
FlagImage{
x, y, w, h,
Config::FLAG_IMAGE},
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);
} else if (E.type == UserEvents::GAME_WON) {
if (hasBomb) {
hasFlag = hasBomb;
SetColor(Config::BUTTON_SUCCESS_COLOR);
}
SetIsDisabled(true);
} else if (E.type == UserEvents::GAME_LOST) {
if (hasBomb) {
isCleared = true;
SetColor(Config::BUTTON_FAILURE_COLOR);
}
SetIsDisabled(true);
}
Button::HandleEvent(E);
}
void MinesweeperCell::Render(
SDL_Surface* Surface){
Button::Render(Surface);
if (hasFlag) {
FlagImage.Render(Surface);
} else if (isCleared && hasBomb) {
BombImage.Render(Surface);
} else if (isCleared && AdjacentBombs > 0) {
Text.Render(Surface);
}
#ifdef SHOW_DEBUG_HELPERS
if (hasBomb) { BombImage.Render(Surface); }
#endif
}
void MinesweeperCell::Reset(){
isCleared = false;
hasFlag = 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){
if (isCleared) return;
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::HandleRightClick(){
if (hasFlag) {
ReportEvent(UserEvents::FLAG_CLEARED);
hasFlag = false;
} else {
ReportEvent(UserEvents::FLAG_PLACED);
hasFlag = true;
}
}
void MinesweeperCell::HandleLeftClick(){
if (!hasFlag) { ClearCell(); }
}
#pragma once
#include "Globals.h"
#include "Minesweeper/Grid.h"
#include "Minesweeper/NewGameButton.h"
#include "Minesweeper/FlagCounter.h"
class MinesweeperUI {
public:
void Render(SDL_Surface* Surface){
Grid.Render(Surface);
Button.Render(Surface);
Counter.Render(Surface);
}
void HandleEvent(const SDL_Event& E){
Grid.HandleEvent(E);
Button.HandleEvent(E);
Counter.HandleEvent(E);
}
private:
MinesweeperGrid Grid{
Config::PADDING, Config::PADDING
};
NewGameButton Button{
Config::PADDING,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::WINDOW_WIDTH - Config::PADDING * 3
- Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
FlagCounter Counter{
Config::WINDOW_WIDTH - Config::PADDING
- Config::FLAG_COUNTER_WIDTH,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
};
#pragma once
#include <string>
#include "Globals.h"
#include "Engine/Rectangle.h"
#include "Engine/Text.h"
#include "Engine/Image.h"
class FlagCounter : public Engine::Rectangle {
public:
FlagCounter(int x, int y, int w, int h)
: Rectangle{
x, y, w, h, Config::FLAG_COUNTER_COLOR},
Image{
x, y,
Config::FOOTER_HEIGHT - Config::PADDING,
Config::FOOTER_HEIGHT - Config::PADDING,
Config::FLAG_IMAGE,
24},
Text{
x + Config::FOOTER_HEIGHT, y,
w - Config::FOOTER_HEIGHT - 24, h,
std::to_string(Config::BOMB_COUNT),
{255, 255, 255, 255}, 20}{}
void Render(SDL_Surface* Surface) override{
Rectangle::Render(Surface);
Text.Render(Surface);
Image.Render(Surface);
}
void HandleEvent(const SDL_Event& E){
if (E.type ==
UserEvents::FLAG_PLACED) {
--FlagsAvailable;
} else if (E.type ==
UserEvents::FLAG_CLEARED) {
++FlagsAvailable;
} else if (E.type == UserEvents::GAME_WON) {
FlagsAvailable = 0;
} else if (E.type == UserEvents::NEW_GAME) {
FlagsAvailable = Config::BOMB_COUNT;
} else { return; }
Text.SetText(
std::to_string(FlagsAvailable)
);
}
private:
Engine::Image Image;
Engine::Text Text;
int FlagsAvailable{Config::BOMB_COUNT};
};
The remaining project files (which were not changed in this section) are available here:
#pragma once
#include "Globals.h"
#include "Engine/Rectangle.h"
namespace Engine{
class Button : public Rectangle {
public:
Button(int x, int y, int w, int h)
: Rectangle{x, y, w, h}{
SetColor(Config::BUTTON_COLOR);
}
virtual void
HandleEvent(const SDL_Event& E){
if (isDisabled) return;
if (E.type == SDL_MOUSEMOTION) {
HandleMouseMotion(E.motion);
} else if (E.type ==
SDL_MOUSEBUTTONDOWN) {
if (IsWithinBounds(E.button.x,
E.button.y)) {
E.button.button == SDL_BUTTON_LEFT
? HandleLeftClick()
: HandleRightClick();
}
}
}
void SetIsDisabled(bool NewValue){
isDisabled = NewValue;
}
protected:
virtual void HandleLeftClick(){}
virtual void HandleRightClick(){}
virtual void HandleMouseMotion(
const SDL_MouseMotionEvent& E){
if (IsWithinBounds(E.x, E.y)) {
SetColor(Config::BUTTON_HOVER_COLOR);
} else { SetColor(Config::BUTTON_COLOR); }
}
private:
bool isDisabled{false};
};
}
#pragma once
#include <SDL_image.h>
namespace Engine{
class Image {
public:
Image(
int x, int y, int w, int h,
const std::string& Filename,
int Padding = 12
): Destination{
x + Padding/2, y + Padding/2,
w-Padding, h-Padding
}{
ImageSurface = IMG_Load(Filename.c_str());
#ifdef SHOW_DEBUG_HELPERS
Utils::CheckSDLError("IMG_Load");
#endif
}
void Render(SDL_Surface* Surface) {
SDL_BlitScaled(
ImageSurface,nullptr,
Surface, &Destination
);
}
~Image() {
if (ImageSurface) {
SDL_FreeSurface(ImageSurface);
}
}
Image(const Image&){}
private:
SDL_Surface* ImageSurface{nullptr};
SDL_Rect Destination{0, 0, 0, 0};
};
}
#pragma once
#include <random>
namespace Engine::Random{
inline std::random_device SEEDER;
inline std::mt19937 ENGINE{SEEDER()};
inline size_t Int(size_t Min, size_t Max){
std::uniform_int_distribution Get{Min, Max};
return Get(ENGINE);
}
}
#pragma once
#include <SDL.h>
namespace Engine{
class Rectangle {
public:
Rectangle(
int x, int y, int w, int h,
SDL_Color Color = {0, 0, 0, 255})
: Rect{x, y, w, h}, Color{Color}{}
virtual void Render(SDL_Surface* Surface){
SDL_FillRect(
Surface, &Rect, SDL_MapRGB(
Surface->format, Color.r, Color.g,
Color.b
)
);
}
void SetColor(SDL_Color C){ Color = C; }
bool IsWithinBounds(int x, int y) const{
// Too far left
if (x < Rect.x) return false;
// Too far right
if (x > Rect.x + Rect.w) return false;
// Too high
if (y < Rect.y) return false;
// Too low
if (y > Rect.y + Rect.h) return false;
// Within bounds
return true;
}
SDL_Rect* GetRect(){ return &Rect; }
virtual ~Rectangle() = default;
private:
SDL_Rect Rect{0, 0, 0, 0};
SDL_Color Color{0, 0, 0, 0};
};
}
#pragma once
#include <SDL_ttf.h>
#include "Globals.h"
namespace Engine{
class Text {
public:
Text(
int x, int y, int w, int h,
const std::string& Content,
SDL_Color Color = {0, 0, 0, 255},
int FontSize = 30
) : DestinationRect{x, y, w, h},
Color{Color}
{
Font = TTF_OpenFont(
Config::FONT.c_str(), FontSize);
#ifdef SHOW_DEBUG_HELPERS
Utils::CheckSDLError("TTF_OpenFont");
#endif
SetText(Content);
}
void SetText(const std::string& Text){
SetText(Text, Color);
}
void SetText(const std::string& Text,
SDL_Color NewColor){
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
Color = NewColor;
TextSurface = TTF_RenderUTF8_Blended(
Font, Text.c_str(), Color
);
auto [x, y, w, h] = DestinationRect;
// Horizontally centering
const int WidthDifference{
w - TextSurface->w};
const int LeftOffset{WidthDifference / 2};
// Vertically centering
const int HeightDifference{
h - TextSurface->h};
const int TopOffset{HeightDifference / 2};
TextPosition = {
x + LeftOffset, y + TopOffset, w, h
};
}
void Render(SDL_Surface* Surface) {
SDL_BlitSurface(
TextSurface, nullptr,
Surface, &TextPosition
);
}
~Text() {
if (Font) { TTF_CloseFont(Font); }
if (TextSurface) {
SDL_FreeSurface(TextSurface);
}
}
private:
SDL_Surface* TextSurface{nullptr};
TTF_Font* Font{nullptr};
SDL_Rect DestinationRect{0, 0, 0, 0};
SDL_Rect TextPosition{0, 0, 0, 0};
SDL_Color Color{0, 0, 0, 255};
};
}
#pragma once
#include <SDL.h>
#include "Globals.h"
namespace Engine{
class Window {
public:
Window(){
SDLWindow = SDL_CreateWindow(
Config::GAME_NAME.c_str(),
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
Config::WINDOW_WIDTH,
Config::WINDOW_HEIGHT, 0
);
}
void Render(){
SDL_FillRect(
GetSurface(), nullptr,
SDL_MapRGB(GetSurface()->format,
Config::BACKGROUND_COLOR.r,
Config::BACKGROUND_COLOR.g,
Config::BACKGROUND_COLOR.b));
}
void Update(){
SDL_UpdateWindowSurface(SDLWindow);
}
SDL_Surface* GetSurface(){
return SDL_GetWindowSurface(SDLWindow);
}
~Window() {
SDL_DestroyWindow(SDLWindow);
}
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
private:
SDL_Window* SDLWindow;
};
}
#pragma once
#include "Globals.h"
#include "Engine/Button.h"
#include "Engine/Text.h"
class NewGameButton : public Engine::Button {
public:
NewGameButton(int x, int y, int w, int h)
: Button{x, y, w, h},
Text{x, y, w, h, "NEW GAME", {}, 20}{}
void Render(SDL_Surface* Surface) override{
Button::Render(Surface);
Text.Render(Surface);
}
void HandleLeftClick() override{
SDL_Event E{UserEvents::NEW_GAME};
SDL_PushEvent(&E);
}
private:
Engine::Text Text;
};
#pragma once
#include <SDL.h>
#include "Globals.h"
#include "Minesweeper/Cell.h"
#include "Engine/Random.h"
class MinesweeperGrid {
public:
MinesweeperGrid(int x, int y){
using namespace Config;
Children.reserve(GRID_COLUMNS * GRID_ROWS);
for (int Col{1}; Col <= GRID_COLUMNS; ++
Col) {
for (int Row{1}; Row <= GRID_ROWS; ++
Row) {
constexpr int Spacing{
CELL_SIZE + PADDING};
Children.emplace_back(
x + (Spacing) * (Col - 1),
y + (Spacing) * (Row - 1),
CELL_SIZE, CELL_SIZE, Row, Col
);
}
}
PlaceBombs();
};
void Render(SDL_Surface* Surface){
for (auto& Child : Children) {
Child.Render(Surface);
}
}
void HandleEvent(const SDL_Event& E){
if (E.type == UserEvents::CELL_CLEARED) {
HandleCellCleared(E.user);
} else if (E.type == UserEvents::NEW_GAME) {
for (auto& Child : Children) {
Child.Reset();
}
PlaceBombs();
}
for (auto& Child : Children) {
Child.HandleEvent(E);
}
};
private:
void HandleCellCleared(
const SDL_UserEvent& E){
auto* Cell{
static_cast<MinesweeperCell*>(
E.data1
)};
if (Cell->GetHasBomb()) {
SDL_Event Event{UserEvents::GAME_LOST};
SDL_PushEvent(&Event);
} else {
--CellsToClear;
if (CellsToClear == 0) {
SDL_Event Event{UserEvents::GAME_WON};
SDL_PushEvent(&Event);
}
}
}
void PlaceBombs(){
int BombsToPlace{Config::BOMB_COUNT};
CellsToClear = Config::GRID_COLUMNS
* Config::GRID_ROWS - Config::BOMB_COUNT;
while (BombsToPlace > 0) {
const size_t RandomIndex{
Engine::Random::Int(
0, Children.size() - 1
)};
if (Children[RandomIndex].PlaceBomb()) {
--BombsToPlace;
}
}
}
std::vector<MinesweeperCell> Children;
int CellsToClear;
};
#include <SDL_image.h>
#include <SDL_ttf.h>
#include "Globals.h"
#include "Engine/Window.h"
#include "Minesweeper/UI.h"
int main(int argc, char** argv){
SDL_Init(SDL_INIT_VIDEO);
#ifdef SHOW_DEBUG_HELPERS
Utils::CheckSDLError("SDL_Init");
#endif
IMG_Init(IMG_INIT_PNG);
#ifdef SHOW_DEBUG_HELPERS
Utils::CheckSDLError("IMG_Init");
#endif
TTF_Init();
#ifdef SHOW_DEBUG_HELPERS
Utils::CheckSDLError("TTF_Init");
#endif
Engine::Window GameWindow;
MinesweeperUI UI;
SDL_Event Event;
bool shouldQuit{false};
while (!shouldQuit) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_QUIT) {
shouldQuit = true;
} else { UI.HandleEvent(Event); }
}
GameWindow.Render();
UI.Render(GameWindow.GetSurface());
GameWindow.Update();
}
SDL_Quit();
return 0;
}
cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 20)
project(Minesweeper VERSION 1.0.0)
add_executable(Minesweeper
"main.cpp"
"Globals.h"
"Engine/Button.h"
"Engine/Rectangle.h"
"Engine/Image.h"
"Engine/Text.h"
"Engine/Window.h"
"Engine/Random.h"
"Minesweeper/Grid.h"
"Minesweeper/UI.h"
"Minesweeper/Cell.h"
"Minesweeper/Cell.cpp"
"Minesweeper/NewGameButton.h"
"Minesweeper/FlagCounter.h"
)
target_include_directories(
Minesweeper PUBLIC ${PROJECT_SOURCE_DIR}
)
add_subdirectory(external/SDL)
add_subdirectory(external/SDL_image)
add_subdirectory(external/SDL_ttf)
target_link_libraries(Minesweeper PRIVATE
SDL2
SDL2_image
SDL2_ttf
)
if (WIN32)
target_link_libraries(
Minesweeper PRIVATE SDL2main
)
endif()
set(AssetDirectory "${PROJECT_SOURCE_DIR}/Assets")
add_custom_command(
TARGET Minesweeper POST_BUILD COMMAND
${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:SDL2>"
"$<TARGET_FILE:SDL2_image>"
"$<TARGET_FILE:SDL2_ttf>"
"${AssetDirectory}/bomb.png"
"${AssetDirectory}/Rubik-SemiBold.ttf"
"${AssetDirectory}/flag.png"
"$<TARGET_FILE_DIR:Minesweeper>"
VERBATIM
)
Assets/Rubik-SemiBold.tff
available from Google FontsAssets/bomb.png
available from IconFinderAssets/flag.png
available from IconsDBTo make the most of our learning, it can be helpful to go back to our project and change some things around, reworking how things work, or even completely rebuilding it.
This self-directed learning helps solidify the concepts we’ve covered, and makes it easier to apply them to new projects that won’t have as much guidance. Some ideas on how the project could be reworked are below:
Something we should constantly be reviewing is areas of our code that seem quite complex and difficult to follow.
For example, our program has a very simple visual layout, with a grid at the top and a new game button and flag counter side by side at the bottom.
However, the code to establish such a simple layout is already difficult to write and follow:
// ...
class MinesweeperUI {
public:
void Render(SDL_Surface* Surface){
Grid.Render(Surface);
Button.Render(Surface);
Counter.Render(Surface);
}
// ...
private:
MinesweeperGrid Grid{
Config::PADDING, Config::PADDING
};
NewGameButton Button{
Config::PADDING,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::WINDOW_WIDTH - Config::PADDING * 3
- Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
FlagCounter Counter{
Config::WINDOW_WIDTH - Config::PADDING
- Config::FLAG_COUNTER_WIDTH,
Config::GRID_HEIGHT + Config::PADDING * 2,
Config::FLAG_COUNTER_WIDTH,
Config::FOOTER_HEIGHT - Config::PADDING
};
};
Laying objects out in columns and rows is a recurring problem, so we could consider introducing generic classes like Column
and Row
that take care of that for us.
These classes can simply position children next to each other vertically or horizontally, and could accept arguments to control things like the padding between objects:
// ...
#include "Engine/Column.h"
#include "Engine/Row.h"
class MinesweeperUI {
public:
void Render(SDL_Surface* Surface){
Layout.Render(Surface);
}
// ...
private:
MinesweeperGrid Grid;
NewGameButton Button;
FlagCounter Counter;
Column Layout{
Grid,
Row{
Button, Counter
}
}
};
Currently, our game places bombs before the user clears the first cell. This can cause unfair outcomes, such as the first cell containing a bomb.
Many implementations of Minesweeper do not choose bomb positions until after the user clicks their first cell. This allows them to guarantee the cell is safe so the player doesn’t lose immediately.
Additionally, it allows them to place bombs further away from this starting point. For example, we can ensure the adjacent cells to where the player starts have no bombs, either.
This results in the first click clearing a large area of the grid, allowing the player to proceed strategically and reducing the probability they’ll need to make guesses.
Currently, the cells in our grid are generating surfaces unnecessarily. For example, if our settings specify the grid has 20 bombs, then the bomb images is loaded 20 times, and 20 identical surfaces are generated.
Similarly, we’re rasterizing text surfaces unnecessarily - every cell that shows a 3
image, for example, is generating it’s own surface, which is identical to every other 3
.
If a cell ultimately has 3
adjacent bombs, the bomb placement algorithm has it generate 1
and 2
surfaces which it ultimately discards.
For performance reasons, it can be helpful to pool these surfaces together into a shared repository that all the cells can access.
We can optimize this concept even further by pooling the images into a single image, called a texture atlas. The original Microsoft version of Minesweeper might have a texture atlas that looks like this:
Cells can then select the data they want by providing a source rectangle to the SDL_BlitSurface()
or SDL_BlitScaled()
functions:
#include "MinesweeperAtlas.h"
// ...
void Render(SDL_Surface* Surface) {
SDL_BlitScaled(
MinesweeperAtlas::Surface,
MinesweeperAtlas::Bomb,
Surface,
&Destination
);
}
// ...
Most Minesweeper games allow players to choose their preferred difficulty - typically, easy, medium or hard.
Harder difficulties increase the size of the grid, and increase the number of bombs within the grid. It would involve a lot of changes to set our game up to support this. However, it wouldn’t require any new concepts - we know everything we need to know.
The SDL_SetWindowSize()
function (official documentation) let’s us programatically resize the window, which can be useful if our grid size changes based on difficulty setting.
To keep things simple, our project routed pretty much every event through to every relevant object in our game. This results in a lot of excess traffic, where object’s HandleEvent()
functions are invoked with an event the object has no interest in.
Every function call that results in no effect has an unnecessary performance cost, and that’s especially true when the functions are virtual
.
An obvious way to reduce this traffic is having each parent check an event is relevant to its children before forwarding it:
void HandleEvent(const SDL_Event& E){
if (isRelevantToChildren(E.type) {
for (auto& Child : Children) {
Child.HandleEvent(E);
}
}
};
However, this limits flexibility. When creating a parent class, we often won’t know what type of children we’ll eventually be managing.
Even if we did know exactly what event types the children are interested in, declaring that within the parent would typically be a bad design.
To keep our code well organised and understandable, the event types that a class is interested in should be managed within that same class. For example, the event types that MinesweeperCell
objects are interested in should be declared in MinesweeperCell
class.
Objects can then provide that list to their parent, or to some object that is managing event traffic. For example:
class Child: public EventReceiver {
// Subscribe to event types from constructor
Child(EventReceiver* Parent) {
Parent->SubscribeToEventTypes({
UserEvents::GAME_START,
UserEvents::GAME_END,
UserEvents::CELL_CLEARED,
// ...
})
}
// ...
}
This system can become increasingly elaborate - for example, objects may subscribe or unsubscribe from events at different points during their lifecycle:
class Child: public EventReceiver {
// ...
void SetIsDisabled() {
// No longer interested in mouse events
Parent->UnsubscribeToEventTypes({
SDL_MOUSEMOTION,
SDL_MOUSEBUTTON
})
}
// ...
}
Designs that implement these inversions of control are often called publish-subscribe pattern or observer pattern.
Usually, Minesweeper tracks how long it takes us to complete a game, and keeps track of our fastest time. Implementing this may require research into some new topics.
The C++ standard library includes chrono, a system for working with time. We covered it in our beginner course:
Remembering our best time within a single session is easy, but keeping track of it from one session to the next requires saving the data to the player’s hard drive.
Again, the C++ standard library can help us here. We covered it’s file system utilities in our advanced course:
Mechanics like these can also be implemented using functions provided by the SDL library. SDL_GetTicks64()
(official documentation) can be used to track the time between two events, and RWops
(official documentation) can be used to read and write data to the hard drive.
Well done on finishing your Minesweeper project! We've successfully added flag placement functionality, complete with visual feedback and a counter in the UI.
These final features round out the gameplay, allowing players to mark potential mine locations and strategize their moves.
With all elements now in place, you have a fully-featured Minesweeper game that offers the same engaging puzzle-solving experience as the classic versions.
Congratulations on your achievement!
Implement flag placement and tracking to complete your Minesweeper project.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games