Мультимедийная библиотека SFML C++
August 7

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

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

Сегодня предлагаю написать классическую головоломку «Сокобан» на 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 == '#39; || 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 останутся прежними.

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

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

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

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

Наш Ютюб