- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Каюсь, AI было использовано для картинки
Захотелось мне как-то написать что-нибудь на UMG в Unreal Engine 5 и исключительно на С++ (без использования блупринтов). Заодно было любопытно краем глаза взглянуть на QT. “Пахнет небольшим проектом”, - подумал я и избрал для вдохновения игру “Сапёр” из сего репозитория -> GitHub - Bollos00/LibreMines: A Free/Libre and Open Source Software Qt based Minesweeper game available for GNU/Linux, FreeBSD, MacOS and Windows systems. https://github.com/Bollos00/LibreMines. Моё почтение автору той репы!
Началось всё с бездумного и медитативного переписывания… простите, портирования структуры и логики на анриловский UMG и параллельного просмотра любимых шоу на ютьюбе. Чудесное было время! Далее чуть подрефачил код, упростил его, убрал ненужное, и получилось то, что получилось.
В этой статейке я акцентирую внимание на интересных (на мой взгляд) моментах. Если вдруг кто-то это прочитает и ему захочется подробнее посмотреть, что и как написато, то для этих двух людей я залил четыре файла исходников на драйв -> Source – Google Drive 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:😀efault);
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++ и нуль блюпринтов.
Проект закрыт, галочка в гол��ве поставлета, удовольствие получето.
А если есть и те, кто просто пролистали статью, кивнули и подумали «хм, и так тоже можно» — я буду еще более радый.