Каюсь, AI было использовано для картинки
Каюсь, AI было использовано для картинки

Захотелось мне как-то написать что-нибудь на 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++ и нуль блюпринтов.
Проект закрыт, галочка в голове поставлета, удовольствие получето.
А если есть и те, кто просто пролистали статью, кивнули и подумали «хм, и так тоже можно» — я буду еще более радый.