AI Игра Сапёр на Unreal Engine > UMG > только C++ > Никаких Блупринтов

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
636a99a5325bf0b315677d28afd73453.jpg

Каюсь, 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++ и нуль блюпринтов.
Проект закрыт, галочка в гол��ве поставлета, удовольствие получето.
А если есть и те, кто просто пролистали статью, кивнули и подумали «хм, и так тоже можно» — я буду еще более радый.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru