
Захотелось мне как-то написать что-нибудь на UMG в Unreal Engine 5 и исключительно на С++ (без использования блупринтов). Заодно было любопытно краем глаза взглянуть на QT. “Пахнет небольшим проектом”, - подумал я и избрал для вдохновения игру “Сапёр” из сего репозитория -> https://github.com/Bollos00/LibreMines. Моё почтение автору той репы!
Началось всё с бездумного и медитативного переписывания… простите, портирования структуры и логики на анриловский UMG и параллельного просмотра любимых шоу на ютьюбе. Чудесное было время! Далее чуть подрефачил код, упростил его, убрал ненужное, и получилось то, что получилось.
В этой статейке я акцентирую внимание на интересных (на мой взгляд) моментах. Если вдруг кто-то это прочитает и ему захочется подробнее посмотреть, что и как написато, то для этих двух людей я залил четыре файла исходников на драйв -> https://drive.google.com/drive/folders/1xXQ3A6WD-KV8PtLdHGmrp0ducspM8Eb9?usp=sharing.
Архитектура (громко сказано конечно...)
Игра условно разбита на два слоя: представления (View) и логики (Model). Пользователь взаимодействует с первым. Вьюшка, в свою очередь, дёргает за методы модели напрямую. Логическая часть не знает о существовании представлений, и даёт обратную связь исключительно через колбэки. Для этого я использовал TFunction.
Создание виджетов в С++
В коде встроенные виджеты можно создавать через следующий метод UUserWidget::WidgetTree->ConstructWidget<ТипВиджета>()
Инициализация поля
struct FDifficultySettings { uint8 RowCount = 0; uint8 ColumnCount = 0; uint16 MineCount = 0; };
В начале игрок выбирает размер поля и количество мин. Здесь это называется сложностью и определяется структурой FDifficultySettings.
void UMineSweeperHudWidget::NewGame(const FDifficultySettings& InDifficultySettings) { LogicGameEngine.NewGame(InDifficultySettings.RowCount, InDifficultySettings.ColumnCount, InDifficultySettings.MineCount); auto* GameplayRootWidget = WidgetTree->ConstructWidget<UHorizontalBox>(); FieldWidget = CreateField(InDifficultySettings.RowCount, InDifficultySettings.ColumnCount); CreateGameplayChild<UScaleBox>(*WidgetTree, *GameplayRootWidget, 1.f)->AddChild(FieldWidget); CreateGameplayChild<UVerticalBox>(*WidgetTree, *GameplayRootWidget, 0.3f)->AddChild(CreateGameplayPanel()); auto* GameplayRootWidgetSlot = RootPanelWidget->AddChildToOverlay(GameplayRootWidget); GameplayRootWidgetSlot->SetHorizontalAlignment(EHorizontalAlignment::HAlign_Fill); GameplayRootWidgetSlot->SetVerticalAlignment(EVerticalAlignment::VAlign_Fill); UpdateFaceReaction(EFaceType::Default); UpdateCellValues(); }
Эти параметры передаются в функцию инициализации нового раунда UMineSweeperHudWidget::NewGame. Это метод слоя представления (View).
Он вызывает одноимённую функцию из Логики FGameEngine::NewGame, после чего создает следующую иерархию:
Gameplay Root Widget
[UHorizontalBox][UScaleBox]- нужен для удобного скейла игрового поля в зависимости от размера окна.Field Widget
[UUniformGridPanel]
Gameplay Side Panel
[UVerticalBox]
Только после того как закинули Gameplay Root Widget в корневой игровой виджет, можно настроить выравнивания по вертикали и горизонтали. Это делается через соответствующий Slot (UOverlaySlot в этом случае), который мы получаем после добавления виджета в родительский.
Создание визуального игрового поля выглядит вот так:
UUniformGridPanel* UMineSweeperHudWidget::CreateField(const uint8 InRowCount, const uint8 InColumnCount) { auto* Grid = WidgetTree->ConstructWidget<UUniformGridPanel>(); for (uint8 Row = 0; Row < InRowCount; ++Row) { for (uint8 Col = 0; Col < InColumnCount; ++Col) { auto* Cell = CreateWidget<UCell>(this); Cell->OnClicked = [this, Row, Col](const FKey& InKey) { if (!LogicGameEngine.IsGameActive()) return; if (InKey == EKeys::LeftMouseButton) { LogicGameEngine.ClearCell(Row, Col); UpdateProgressBar(); } else if (InKey == EKeys::RightMouseButton) { LogicGameEngine.ToggleCellState(Row, Col); } }; auto* CellSlot = Grid->AddChildToUniformGrid(Cell, Row, Col); CellSlot->SetVerticalAlignment(EVerticalAlignment::VAlign_Fill); CellSlot->SetHorizontalAlignment(EHorizontalAlignment::HAlign_Fill); } } return Grid; }
Создаём UUniformGridPanel
Для этого поля создаём виджеты клеток UCell
Для каждой клетки устанавливаем обработчики кликов OnClicked
ЛКМ - вскрытие ячейки
ПКМ - установка флага
А вот чего происходит в FGameEngine::NewGame
void FGameEngine::NewGame(const uint8 InRowCount, const uint8 InColumnCount, uint16 InMineCount) { MineCount = InMineCount; RowCount = InColumnCount; ColumnCount = InColumnCount; Field.Empty(); for (uint8 RowNum = 0; RowNum < InRowCount; ++RowNum) { TArray<FCell> Row; for (uint8 ColNum = 0; ColNum < InColumnCount; ++ColNum) { FCell Cell; Cell.OnNewFlagState = [this, RowNum, ColNum](const EFlagState InPrevFlagState, const EFlagState InCurFlagState) { OnCellFlagStateChanged(RowNum, ColNum, InCurFlagState); }; Row.Push(std::move(Cell)); } Field.Push(std::move(Row)); } while (InMineCount != 0) { const auto RandRow = FMath::Rand() % InRowCount; const auto RandCol = FMath::Rand() % InColumnCount; FCell& Cell = Field[RandRow][RandCol]; if (Cell.GetValue() == ECellValue::Zero) { Cell.SetValue(ECellValue::Mine); --InMineCount; } } for (uint8 RowNum = 0; RowNum < InRowCount; ++RowNum) { for (uint8 ColNum = 0; ColNum < InColumnCount; ++ColNum) { FCell& Cell = Field[RowNum][ColNum]; if (Cell.GetValue() == ECellValue::Zero) { int NeighbourMineCount = 0; ForEachNeighbour(Field, RowNum, ColNum, [&NeighbourMineCount](const FGameEngine::FCell& InCell, uint8, uint8) { if (InCell.GetValue() == ECellValue::Mine) ++NeighbourMineCount; }); Cell.SetValue(static_cast<ECellValue>(NeighbourMineCount)); } } } bIsGameActive = true; }
Проходимся по всему логическому полю
Создаем для него клетки и подписываемся на колбэк изменения состояния флага, чтобы передать это на уровень представления.
Расставляем все мины в рандомных ячейках
Снова пробегаем по всем клеткам:
для каждой из них считаем кол-во мин по соседству
заносим это значение в поле Value структуры
FCell.
Структура логической FCell
class FCell { public: void ToggleState(); ECellValue GetValue() const { return Value; } void SetValue(ECellValue InValue) { Value = InValue; } EFlagState GetFlagState() const { return FlagState; } TFunction<void(EFlagState, EFlagState)> OnNewFlagState; bool IsHidden = true; private: ECellValue Value = ECellValue::Zero; EFlagState FlagState = EFlagState::NoFlag; };
Value - принимает значение “Мина”, либо кол-ва мин по соседству
FlagState - установлен ли “Флаг” или “Вопрос”
IsHidden - была ли клетка вскрыта
Действия игрока
void FGameEngine::ClearCell(const uint8 InRow, const uint8 InColumn) { FCell& Cell = Field[InRow][InColumn]; if (!Cell.IsHidden) return; if (Cell.GetFlagState() != EFlagState::NoFlag) return; const ECellValue CellValue = Cell.GetValue(); if (CellValue == ECellValue::Mine) { GameLost(InRow, InColumn); return; } Cell.IsHidden = false; OnCellClear(InRow, InColumn); if (CellValue == ECellValue::Zero) { ForEachNeighbour(Field, InRow, InColumn, [this](const FCell& , const uint8 InNeighbourRow, const uint8 InNeighbourCol) { ClearCell(InNeighbourRow, InNeighbourCol); }); } const bool AreHiddenCellsLeft = IsAnyOfCells([](const FCell& InCell) { return InCell.IsHidden && InCell.GetValue() != ECellValue::Mine; }); if (!AreHiddenCellsLeft) GameWon(); }
Если игрок нажимает ПКМ, мы просто устанавливаем флаг у логической клетки.
У нажатия ЛКМ чуть больше смысловой нагрузки:
Если на клетке стоит флаг или она уже открыта - прекращаем дальнейшую обработку
Если ткнули в спрятанную мину - делаем гейм овер
В противном случае,
Вскрываем ячейку
Предупреждаем об этом подписчиков
Если рядом нет мин - рекурсивно вскрываем соседние клетки
Далее проверяем остались ли скрытые клетки без мин
Если нет - кастуем событие выигранного раунда
Финалимся
Вцелом это все чем я хотел поделиться. Маленький сапёр, немного UMG, немного C++ и нуль блюпринтов.
Проект закрыт, галочка в голове поставлета, удовольствие получето.
А если есть и те, кто просто пролистали статью, кивнули и подумали «хм, и так тоже можно» — я буду еще более радый.
