Привет, разработчики!
Сегодня предлагаю написать классическую головоломку «Сокобан» на C++20 с
использованием библиотеки SFML. На первый взгляд игра кажется совсем простой: есть человек, несколько ящиков и отмеченные клетки. Ходим по лабиринту, толкаем ящики — что здесь вообще может пойти не так?
А пойти не так может многое.
Ящик нельзя тянуть назад. Два ящика могут заблокировать друг друга. Один
неудачный толчок в угол способен сделать уровень непроходимым. Кроме того, нам понадобятся отмена хода, несколько уровней, плавная анимация, русский текст и нормальная структура проекта, которую не захочется переписать через неделю. Именно поэтому Sokoban — замечательный учебный проект. Здесь нет сложной физики и огромного количества графики, зато есть настоящие игровые правила, состояния, работа с картами и хороший повод отделить логику от отрисовки.
В результате мы получим игру с тремя лабиринтами, счётчиком ходов, отменой
действий и аккуратным интерфейсом. Давайте разберём всё по порядку.
Что мы будем использовать
Для проекта нам понадобятся:
- Visual Studio 2022 с компонентом «Разработка классических приложений на C++».
- C++20.
- SFML 2.6.1.
- Немного терпения — ящики иногда упрямее, чем компилятор.
У меня 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_ только после всех проверок.
Если игрок нажал клавишу возле стены, состояние не изменилось, поэтому и
отменять здесь нечего.
Порядок проверки простой:
- Находим соседнюю клетку.
- Останавливаемся, если это стена.
- Определяем, находится ли там ящик.
- Для ящика проверяем следующую клетку.
- Сохраняем снимок.
- Изменяем игровое состояние.
Именно в этом методе живёт почти всё главное правило 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 рисует:
- фон;
- пол;
- цели;
- стены;
- ящики;
- игрока.
Позиция клетки преобразуется в пиксели только здесь:
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:
- Откройте Sokoban.sln.
- Выберите платформу x64.
- Выберите Debug или Release.
- Нажмите Ctrl+Shift+B.
- Запустите проект.
Альтернативная сборка через 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 останутся прежними.
Конечно, проект можно развивать дальше. Добавить редактор уровней, загрузку карт
из файлов, звук движения ящиков, сохранение прогресса, главное меню и таблицу
лучших результатов. Но даже в текущем виде это хороший пример того, как из
простого правила постепенно вырастает полноценная игра.
Надеюсь, у Вас всё получилось. Если появились вопросы или Вы нашли интересный способ улучшить архитектуру — пишите в комментариях и делитесь своими идеями!