Добавить в корзинуПозвонить
Найти в Дзене

Разработка игры «Сокобан» на SFML C++: ящики, лабиринты и чистая архитектура

Привет, разработчики! Сегодня предлагаю написать классическую головоломку «Сокобан» на C++20 с
использованием библиотеки SFML. На первый взгляд игра кажется совсем простой: есть человек, несколько ящиков и отмеченные клетки. Ходим по лабиринту, толкаем ящики — что здесь вообще может пойти не так? А пойти не так может многое. Ящик нельзя тянуть назад. Два ящика могут заблокировать друг друга. Один
неудачный толчок в угол способен сделать уровень непроходимым. Кроме того, нам понадобятся отмена хода, несколько уровней, плавная анимация, русский текст и нормальная структура проекта, которую не захочется переписать через неделю. Именно поэтому Sokoban — замечательный учебный проект. Здесь нет сложной физики и огромного количества графики, зато есть настоящие игровые правила, состояния, работа с картами и хороший повод отделить логику от отрисовки. В результате мы получим игру с тремя лабиринтами, счётчиком ходов, отменой
действий и аккуратным интерфейсом. Давайте разберём всё по порядку. Д
Оглавление

Привет, разработчики!

Сегодня предлагаю написать классическую головоломку «Сокобан» на C++20 с
использованием библиотеки SFML. На первый взгляд игра кажется совсем простой: есть человек, несколько ящиков и отмеченные клетки. Ходим по лабиринту, толкаем ящики — что здесь вообще может пойти не так?

А пойти не так может многое.

Ящик нельзя тянуть назад. Два ящика могут заблокировать друг друга. Один
неудачный толчок в угол способен сделать уровень непроходимым. Кроме того, нам понадобятся отмена хода, несколько уровней, плавная анимация, русский текст и нормальная структура проекта, которую не захочется переписать через неделю. Именно поэтому Sokoban — замечательный учебный проект. Здесь нет сложной физики и огромного количества графики, зато есть настоящие игровые правила, состояния, работа с картами и хороший повод отделить логику от отрисовки.

В результате мы получим игру с тремя лабиринтами, счётчиком ходов, отменой
действий и аккуратным интерфейсом. Давайте разберём всё по порядку.

Что мы будем использовать

Для проекта нам понадобятся:

  1. Visual Studio 2022 с компонентом «Разработка классических приложений на C++».
  2. C++20.
  3. SFML 2.6.1.
  4. Немного терпения — ящики иногда упрямее, чем компилятор.

У меня SFML установлена в каталоге:

C:/IT/SFML-2.6.1-windows-vc17-64-bit/SFML-2.6.1

Вы можете использовать другой путь. Главное — правильно указать каталоги
include и lib в Visual Studio либо передать SFML_DIR при настройке CMake.

Вспоминаем правила Sokoban

Игрок перемещается по клетчатому полю в четырёх направлениях. Он может зайти на
свободную клетку, но не может проходить через стены.

Если перед игроком стоит ящик, происходит дополнительная проверка:

  • клетка за ящиком свободна — ящик можно толкнуть;
  • там стена или другой ящик — ход запрещён;
  • тянуть ящик на себя нельзя.

Уровень завершён, когда каждый ящик находится на цели.

Важно заметить одну вещь: игровое правило ничего не говорит о пикселях,
спрайтах или sf::RectangleShape. Для него существуют только клетки, стены,
ящики и направление движения. Это наблюдение поможет нам правильно разделить
проект.

Структура проекта

Я не советую складывать всё в один main.cpp. На первом уровне это ещё будет
работать, но после добавления undo и интерфейса файл быстро превратится в
смесь условий, координат и вызовов window.draw().

Используем следующую структуру:

Sokoban/
├── src/
│ ├── main.cpp
│ ├── core/
│ │ ├── Game.hpp
│ │ ├── Game.cpp
│ │ ├── GameConfig.hpp
│ │ └── GameState.hpp
│ ├── entities/
│ │ ├── Cell.hpp
│ │ ├── Board.hpp
│ │ └── Board.cpp
│ ├── levels/
│ │ ├── LevelDefinition.hpp
│ │ ├── LevelManager.hpp
│ │ └── LevelManager.cpp
│ ├── rendering/
│ │ ├── SceneRenderer.hpp
│ │ └── SceneRenderer.cpp
│ ├── resources/
│ │ ├── SystemFontLoader.hpp
│ │ └── SystemFontLoader.cpp
│ └── ui/
│ ├── Hud.hpp
│ └── Hud.cpp
└── tests/
└── SokobanTests.cpp

Здесь каждый класс решает одну задачу:

  • Game связывает все системы и содержит главный цикл;
  • Board знает правила Sokoban;
  • LevelManager хранит карты и переключает уровни;
  • SceneRenderer рисует лабиринт и игровые объекты;
  • Hud отвечает за текст и сообщения;
  • SystemFontLoader ищет установленный системный шрифт.

Самое важное — Board вообще не зависит от SFML. Его можно тестировать без
окна, OpenGL-контекста и графики.

Создаём координату клетки

Начнём с самого маленького типа — координаты клетки.

Cell.hpp

#pragma once

#include <compare>

struct Cell {
int x{0};
int y{0};

auto operator<=>(const Cell&) const = default;
};

inline Cell operator+(Cell left, Cell right) noexcept
{
return {left.x + right.x, left.y + right.y};
}

enum class Direction {
Up,
Down,
Left,
Right
};

[[nodiscard]] inline Cell directionOffset(Direction direction) noexcept
{
switch (direction) {
case Direction::Up: return {0, -1};
case Direction::Down: return {0, 1};
case Direction::Left: return {-1, 0};
case Direction::Right: return {1, 0};
}
return {};
}

Почему не sf::Vector2i? Потому что клетка относится к правилам игры, а не к
SFML. Если однажды мы захотим сделать консольную версию или перенести проект на
другую библиотеку, доменную часть менять не придётся.

Оператор сравнения нужен для хранения клеток в std::set. Оператор сложения
позволяет получить соседнюю клетку без постоянного ручного сложения x и y.

Описываем уровень обычным текстом

Карту удобно хранить как набор строк. Для этого создадим небольшую структуру.

LevelDefinition.hpp

#pragma once

#include <string>
#include <vector>

struct LevelDefinition {
std::wstring name;
std::wstring subtitle;
std::vector<std::string> map;
};

Условимся использовать следующие символы:

# стена
@ игрок
$ ящик
. цель
* ящик, уже стоящий на цели
+ игрок, стоящий на цели
свободный пол

Например, первый лабиринт выглядит так:

{
L"Лабиринт I",
L"Развилка смотрителя",
{
"###########",
"# # #",
"# . # . #",
"# ## # #",
"# $ ##$ #",
"## @ #",
"# ### #",
"# #",
"###########"
}
}

Такую карту легко читать прямо в исходном коде. Чтобы передвинуть стену или
ящик, не нужно высчитывать пиксельную позицию — достаточно изменить символ.

Есть и обратная сторона: опечатка в карте может сломать уровень. Поэтому при
загрузке мы обязательно проверим одинаковую длину строк, количество игроков,
число целей и замкнутую внешнюю стену.

Сердце игры — класс Board

Теперь создадим класс игрового поля. Он будет хранить стены, цели, ящики,
позицию игрока и историю ходов.

Для начала опишем результат одного движения:

using CellSet = std::set<Cell>;

struct MoveResult {
bool moved{false};
bool pushedBox{false};
Cell playerFrom{};
Cell playerTo{};
std::optional<Cell> boxFrom;
std::optional<Cell> boxTo;
bool levelCompleted{false};
};

Можно было вернуть из move() обычный bool, но тогда отрисовка не знала бы,
откуда и куда переместился объект. MoveResult пригодится для плавной анимации
и при этом не заставит Board заниматься графикой.

Board.hpp

#pragma once

#include "entities/Cell.hpp"
#include "levels/LevelDefinition.hpp"

#include <optional>
#include <set>
#include <vector>

using CellSet = std::set<Cell>;

class Board {
public:
explicit Board(LevelDefinition definition);

[[nodiscard]] MoveResult move(Direction direction);
[[nodiscard]] MoveResult undo();
void restart();

[[nodiscard]] bool isComplete() const noexcept;
[[nodiscard]] bool isWall(Cell cell) const noexcept;
[[nodiscard]] bool hasBox(Cell cell) const noexcept;
[[nodiscard]] bool isGoal(Cell cell) const noexcept;

[[nodiscard]] Cell player() const noexcept;
[[nodiscard]] const CellSet& walls() const noexcept;
[[nodiscard]] const CellSet& goals() const noexcept;
[[nodiscard]] const CellSet& boxes() const noexcept;

private:
struct Snapshot {
Cell player;
CellSet boxes;
int moves{0};
int pushes{0};
};

void parseAndValidate();

LevelDefinition definition_;
CellSet walls_;
CellSet goals_;
CellSet initialBoxes_;
CellSet boxes_;
Cell initialPlayer_{};
Cell player_{};
std::vector<Snapshot> history_;
int moves_{0};
int pushes_{0};
};

В заголовке нет ни одного типа SFML. Именно этого мы и добивались.

Разбираем карту и проверяем ошибки

В конструкторе сохраняем определение, разбираем символы и запускаем уровень:

Board::Board(LevelDefinition definition)
: definition_(std::move(definition))
{
parseAndValidate();
restart();
}

Основная часть разбора выглядит так:

for (int y = 0; y < height_; ++y) {
const auto& row = definition_.map[static_cast<std::size_t>(y)];

if (static_cast<int>(row.size()) != width_) {
throw std::invalid_argument(
"Every level row must have the same width");
}

for (int x = 0; x < width_; ++x) {
const char tile = row[static_cast<std::size_t>(x)];
const Cell cell{x, y};

if (tile == '#') {
walls_.insert(cell);
}
if (tile == '.' || tile == '*' || tile == '+') {
goals_.insert(cell);
}
if (tile == '$' || tile == '*') {
initialBoxes_.insert(cell);
}
if (tile == '@' || tile == '+') {
initialPlayer_ = cell;
++playerCount;
}
}
}

После этого проверяем два важных условия:

if (playerCount != 1) {
throw std::invalid_argument(
"Level must contain exactly one player");
}

if (goals_.empty() || goals_.size() != initialBoxes_.size()) {
throw std::invalid_argument(
"The number of boxes and goals must match");
}

Я также проверяю, что вся внешняя граница карты заполнена стенами. Без такой
проверки игрок может уйти за пределы массива, а это уже совсем другая игра —
скорее побег со склада.

Реализуем движение игрока и ящика

Теперь самая интересная часть. Метод move() должен проверить ход, сохранить
состояние для undo и только потом изменить позиции.

Board::move()

MoveResult Board::move(Direction direction)
{
const Cell next = player_ + directionOffset(direction);

if (isWall(next)) {
return {};
}

const bool pushesBox = hasBox(next);
const Cell boxDestination = next + directionOffset(direction);

if (pushesBox
&& (isWall(boxDestination) || hasBox(boxDestination))) {
return {};
}

history_.push_back({player_, boxes_, moves_, pushes_});

MoveResult result;
result.moved = true;
result.playerFrom = player_;
result.playerTo = next;

if (pushesBox) {
boxes_.erase(next);
boxes_.insert(boxDestination);
++pushes_;

result.pushedBox = true;
result.boxFrom = next;
result.boxTo = boxDestination;
}

player_ = next;
++moves_;
result.levelCompleted = isComplete();
return result;
}

Обратите внимание: снимок добавляется в history_ только после всех проверок.
Если игрок нажал клавишу возле стены, состояние не изменилось, поэтому и
отменять здесь нечего.

Порядок проверки простой:

  1. Находим соседнюю клетку.
  2. Останавливаемся, если это стена.
  3. Определяем, находится ли там ящик.
  4. Для ящика проверяем следующую клетку.
  5. Сохраняем снимок.
  6. Изменяем игровое состояние.

Именно в этом методе живёт почти всё главное правило Sokoban.

Проверяем победу

Уровень считается завершённым, если каждый ящик находится в наборе целей:

bool Board::isComplete() const noexcept
{
return std::all_of(
boxes_.begin(),
boxes_.end(),
[this](Cell box) {
return goals_.contains(box);
});
}

Благодаря C++20 и методу contains() код читается почти как обычное
предложение: «все ящики содержатся среди целей».

Добавляем отмену хода

Sokoban без undo может быть довольно беспощадным. Один неверный толчок — и
приходится начинать уровень заново. Поэтому сохраним перед каждым успешным
ходом:

  • позицию игрока;
  • набор ящиков;
  • число ходов;
  • число толчков.

Само восстановление получается небольшим:

MoveResult Board::undo()
{
if (history_.empty()) {
return {};
}

const Snapshot snapshot = history_.back();
history_.pop_back();

MoveResult result = transitionTo(snapshot);
player_ = snapshot.player;
boxes_ = snapshot.boxes;
moves_ = snapshot.moves;
pushes_ = snapshot.pushes;
return result;
}

Метод transitionTo() сравнивает текущий и предыдущий наборы ящиков. Так мы
узнаём, двигался ли ящик при отмене, и можем анимировать его возвращение.

Для трёх небольших уровней полная копия std::set вполне подходит. Если Вы
создаёте огромную карту и храните тысячи ходов, можно перейти к компактным
командам, но начинать проект с такой оптимизации точно не нужно.

Управляем уровнями

Board отвечает за одну карту. Кампанией занимается LevelManager.

LevelManager.hpp

class LevelManager {
public:
LevelManager();

[[nodiscard]] MoveResult move(Direction direction);
[[nodiscard]] MoveResult undo();

void resetCurrentLevel();
void restartCampaign();
[[nodiscard]] bool loadNextLevel();
[[nodiscard]] bool selectLevel(std::size_t index);

[[nodiscard]] std::size_t currentLevelIndex() const noexcept;
[[nodiscard]] std::size_t levelCount() const noexcept;
[[nodiscard]] bool isLastLevel() const noexcept;
[[nodiscard]] const Board& board() const noexcept;

private:
void createLevelDefinitions();
void loadLevel(std::size_t index);

std::vector<LevelDefinition> definitions_;
std::unique_ptr<Board> board_;
std::size_t currentIndex_{0};
};

При загрузке создаём новый Board из определения:

void LevelManager::loadLevel(std::size_t index)
{
if (index >= definitions_.size()) {
throw std::out_of_range("Level index is out of range");
}

currentIndex_ = index;
board_ = std::make_unique<Board>(definitions_[currentIndex_]);
}

Менеджер не обрабатывает клавиатуру и ничего не рисует. Его задача — хранить
уровни и активное поле. Чем меньше класс знает о соседях, тем проще его менять.

Разделяем игру и отрисовку

Теперь у нас есть полностью рабочая логика, но на экране пока ничего нет.

Можно передать Board& в класс отрисовки и позволить ему делать всё подряд.
Но тогда одна случайная строка в рендерере сможет переместить ящик. Лучше
передавать константную ссылку и небольшой снимок анимации:

struct SceneData {
const Board& board;
float totalTime{0.0f};
float moveProgress{1.0f};
const MoveResult* transition{nullptr};
};

SceneRenderer рисует:

  1. фон;
  2. пол;
  3. цели;
  4. стены;
  5. ящики;
  6. игрока.

Позиция клетки преобразуется в пиксели только здесь:

sf::Vector2f SceneRenderer::cellPosition(
const SceneLayout& layout,
Cell cell)
{
return layout.boardOrigin
+ sf::Vector2f(
static_cast<float>(cell.x),
static_cast<float>(cell.y))
* layout.cellSize;
}

Получается полезная граница: Board оперирует клетками, а SceneRenderer —
пикселями.

Делаем плавное движение

Логика перемещает игрока сразу из одной клетки в другую. Это правильно:
состояние поля всегда однозначно. Но визуально резкие скачки выглядят не очень
приятно.

Для анимации сохраним MoveResult и время перехода:

std::optional<MoveResult> transition_;
float animationElapsed_{0.0f};

Прогресс движения вычислим через smoothstep:

float Game::animationProgress() const noexcept
{
if (!transition_) {
return 1.0f;
}

const float linear = std::clamp(
animationElapsed_ / GameConfig::MoveAnimationDuration,
0.0f,
1.0f);

return linear * linear * (3.0f - 2.0f * linear);
}

После этого рендерер интерполирует координаты:

sf::Vector2f interpolate(Cell from, Cell to, float amount)
{
return {
static_cast<float>(from.x)
+ static_cast<float>(to.x - from.x) * amount,
static_cast<float>(from.y)
+ static_cast<float>(to.y - from.y) * amount
};
}

Игровое правило при этом не меняется. Board уже выполнил ход, а мы только
показываем красивый переход.

Рисуем HUD отдельно

Текстовый интерфейс лучше не смешивать со стенами и ящиками. Создадим снимок
данных:

struct HudData {
std::size_t levelNumber{1};
std::size_t levelCount{1};
std::wstring levelName;
std::wstring levelSubtitle;
int moves{0};
int pushes{0};
bool boardComplete{false};
bool showCompletion{false};
GameState state{GameState::Playing};
sf::FloatRect panel{};
};

Теперь Hud не получает доступ к Game и не может менять уровень. Он знает
только те значения, которые действительно нужны для текущего кадра.

Загружаем системный шрифт

С русским текстом иногда возникает знакомая проблема: окно работает, панели
рисуются, а вместо букв появляются квадраты. Причина почти всегда в шрифте.

Чтобы не хранить отдельный TTF-файл возле программы, найдём системный каталог
Windows:

std::filesystem::path SystemFontLoader::systemFontDirectory()
{
#ifdef _WIN32
std::array<wchar_t, MAX_PATH> buffer{};
const UINT length = GetWindowsDirectoryW(
buffer.data(),
static_cast<UINT>(buffer.size()));

if (length > 0 && length < buffer.size()) {
return std::filesystem::path(buffer.data()) / L"Fonts";
}
#endif
return {};
}

Затем пробуем известные шрифты с кириллицей:

for (const wchar_t* filename : {
L"segoeui.ttf",
L"tahoma.ttf",
L"arial.ttf",
L"calibri.ttf",
L"verdana.ttf"}) {
result.push_back(windowsFonts / filename);
}

В нашем случае загружается Segoe UI, который уже установлен в Windows. Если
стандартные имена не найдены, можно пройти по каталогу Fonts и попробовать
другой TTF или OTF-файл.

Собираем всё в классе Game

Game — координатор. Он создаёт окно, принимает события, обновляет анимацию и
вызывает отрисовку.

Главный цикл выглядит привычно:

void Game::run()
{
while (window_.isOpen()) {
processEvents();

const float deltaTime = std::min(
frameClock_.restart().asSeconds(),
GameConfig::MaximumDeltaTime);

update(deltaTime);
render();
}
}

Обработка клавиши переводит код SFML в наше доменное направление:

if (key == sf::Keyboard::Up || key == sf::Keyboard::W) {
movePlayer(Direction::Up);
}
else if (key == sf::Keyboard::Down || key == sf::Keyboard::S) {
movePlayer(Direction::Down);
}
else if (key == sf::Keyboard::Left || key == sf::Keyboard::A) {
movePlayer(Direction::Left);
}
else if (key == sf::Keyboard::Right || key == sf::Keyboard::D) {
movePlayer(Direction::Right);
}

А movePlayer() обращается к менеджеру уровня:

void Game::movePlayer(Direction direction)
{
const MoveResult result = levelManager_.move(direction);
startAnimation(result);

if (result.levelCompleted) {
state_ = levelManager_.isLastLevel()
? GameState::CampaignComplete
: GameState::LevelComplete;

completionDelay_ = GameConfig::CompletionRevealDelay;
completionVisible_ = false;
}
}

Обратите внимание, как обязанности проходят по цепочке:

Клавиатура -> Game -> LevelManager -> Board

В обратную сторону возвращается MoveResult, который используется для
анимации и проверки победы.

Точка входа

После такого разделения main.cpp остаётся очень маленьким:

#include "core/Game.hpp"

#include <exception>
#include <iostream>

int main()
{
try {
Game game;
game.run();
}
catch (const std::exception& exception) {
std::cerr << "Sokoban startup error: "
<< exception.what() << '\n';
return 1;
}

return 0;
}

Так и должно быть. Точка входа создаёт приложение, запускает его и обрабатывает
критическую ошибку запуска.

Проверяем уровни автоматически

Нарисовать красивый лабиринт недостаточно — он должен иметь решение. Причём
желательно узнать об ошибке до того, как уровень попадёт к игроку.

Для каждой карты я сохранил проверенную последовательность команд:

const std::vector<std::string> verifiedSolutions = {
"LLLURDRUURULLRDDDRRRRUU",
"LLLLLULLLULLDRRRRDRUDRRURDRULLULLDLUUULUULRRDLDRRRRDRUU",
"DLLLULDDDDDLDRRRDRULLLLLDLLLDLUURUUURUULRDDRRRRDRUU"
};

Буквы означают направления Up, Down, Left, Right. Тест загружает каждый
уровень, выполняет все команды и проверяет результат:

for (std::size_t index = 0;
index < verifiedSolutions.size();
++index) {
levels.selectLevel(index);

for (const char command : verifiedSolutions[index]) {
const MoveResult result = levels.move(
parseDirection(command));

require(result.moved,
"A verified solution contains an illegal move");
}

require(levels.board().isComplete(),
"A verified solution did not complete its level");
}

Отдельно проверяются undo, счётчики ходов, перезапуск и выбор уровня. В этом и
есть практическая польза отделения логики от SFML: тесту не нужно открывать
окно и рисовать ни одного прямоугольника.

Настраиваем CMake

Доменную часть удобно собрать в отдельную библиотеку:

add_library(sokoban_domain STATIC
src/entities/Board.cpp
src/entities/Board.hpp
src/entities/Cell.hpp
src/levels/LevelDefinition.hpp
src/levels/LevelManager.cpp
src/levels/LevelManager.hpp
)

target_include_directories(sokoban_domain
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")

Исполняемый файл уже зависит от SFML:

add_executable(Sokoban
src/main.cpp
src/core/Game.cpp
src/rendering/SceneRenderer.cpp
src/resources/SystemFontLoader.cpp
src/ui/Hud.cpp
)

target_link_libraries(Sokoban PRIVATE
sokoban_domain
sfml-graphics
sfml-window
sfml-system
)

А тесты используют только доменный слой:

add_executable(sokoban_tests tests/SokobanTests.cpp)
target_link_libraries(sokoban_tests PRIVATE sokoban_domain)
add_test(NAME sokoban_domain_levels_and_undo
COMMAND sokoban_tests)

Собираем и запускаем проект

Для Visual Studio 2022:

  1. Откройте Sokoban.sln.
  2. Выберите платформу x64.
  3. Выберите Debug или Release.
  4. Нажмите Ctrl+Shift+B.
  5. Запустите проект.

Альтернативная сборка через CMake:

cmake -S . -B build-cmake -A x64
cmake --build build-cmake --config Release
ctest --test-dir build-cmake -C Release --output-on-failure
.\build-cmake\Release\Sokoban.exe

Если SFML установлена в другом месте:

cmake -S . -B build-cmake -A x64 `
-DSFML_DIR="D:/Libraries/SFML/lib/cmake/SFML"

Что получилось

В итоге у нас есть не просто перемещение квадратиков по экрану, а законченный
небольшой проект:

  • три лабиринтных уровня;
  • правила классического Sokoban;
  • отмена последнего хода;
  • перезапуск и выбор уровня;
  • счётчик ходов и толчков;
  • плавное движение игрока и ящиков;
  • русский интерфейс на системном шрифте;
  • автоматическая проверка всех решений;
  • разделение логики, уровней, отрисовки и UI.

Мне особенно нравится, что самая важная часть игры — Board — получилась
полностью независимой от SFML. Интерфейс можно перерисовать, библиотеку можно
заменить, а правила Sokoban останутся прежними.

Конечно, проект можно развивать дальше. Добавить редактор уровней, загрузку карт
из файлов, звук движения ящиков, сохранение прогресса, главное меню и таблицу
лучших результатов. Но даже в текущем виде это хороший пример того, как из
простого правила постепенно вырастает полноценная игра.

Надеюсь, у Вас всё получилось. Если появились вопросы или Вы нашли интересный способ улучшить архитектуру — пишите в комментариях и делитесь своими идеями!

Клонировать репозиторий

Наш Телеграмм

Наш Ютюб