Вы когда-нибудь играли в Match-3 в текстовом терминале? Вот и я бы не подумал, что поводом для этого, может стать очередное тестовое задание.
В разработке я уже около 10 лет и в последнее время начал задумываться, а не уйти ли мне в геймдев? Учитывая, что большую часть времени я посвятил разработке приложений на Unity, а 3D моделированием увлекаюсь ещё со школы.
И вот, после очередной порции откликов на интересующие вакансии. Я получаю ответ от компании X:
Благодарим Вас за отклик на вакансию "Senior Unity C# Developer". Готовы ли Вы выполнить тестовое задание?
Как правило я игнорирую вакансии с обязательным выполнением тестового, но это только в том случае, если задача меня абсолютно не заинтересовала, и я не смогу найти проделанной работе полезного применения. Поэтому если и берусь, то делаю его так, как нравится мне самому. Может поэтому я и не дошёл до этапа собеседования?
Задание
Написать логику осыпания игрового поля Match 3
Базовый функционал:
реализовать построение игрового поля из любого конфига (json, SO и т.д.)
поле должно быть размером X на Y клеток и может иметь пустоты
клетки в первой строке сверху, при нажатии на пробел, должны генерировать фишки которые падая вниз заполнят всё поле
Продвинутый функционал:
фишки осыпаются с нарастающей задержкой относительно друг друга
если в каком-то столбце нет генератора (пустота сверху), фишки опавшие вертикально в соседних столбцах, начинают сверху осыпаться диагонально в образовавшиеся незаполненные клетки
Космос:
после осыпания всего поля можно нажать на любую фишку и эта фишка вместе со всеми соседями такого-же цвета уничтожатся, а образовавшаяся пустота так-же заполнится согласно правилам осыпания написанным выше
В общем, задача показалась интересной. Было решено реализовать космос, добавив продвинутого функционала, и разбавив это всё базовым. Я бы и не подумал, что только алгоритмов заполнения игрового поля мне в голову придет 6 штук. Собственно, это и сподвигло меня реализовать гибкую систему с добавлением неограниченного числа алгоритмов заполнения поля, и возможностью менять их прямо во время игрового процесса.
Стоит добавить, что на всё про всё даётся 7 дней и тестовое не оплачивается.
Выполнение тестового задания не оплачивается, мы не используем результаты тестовых заданий в коммерческих целях, поэтому автор может распоряжаться работой по своему усмотрению.
Спустя три вечера, на руках у меня был полноценный прототип Match-3 игры.
Получилось вполне сносно. Но давайте разберём более интересную часть, код и как это всё устроено.
Обратите внимание, что ниже рассматривается код из первой реализации, который можно найти в ветке simple_implementation. Финальный код можно найти в main
ветке на GitHub.
Основная магия происходит в классах реализующих интерфейс IBoardFillStrategy
.
public interface IBoardFillStrategy
{
string Name { get; }
IEnumerable<IJob> GetFillJobs(IGameBoard gameBoard);
IEnumerable<IJob> GetSolveJobs(IGameBoard gameBoard, IEnumerable<ItemSequence> sequences);
}
IJob
это любая работа которую необходимо выполнить после заполнения или изменения состояния игрового поля.
public interface IJob
{
int ExecutionOrder { get; }
UniTask ExecuteAsync(CancellationToken cancellationToken = default);
}
Свойство ExecutionOrder
отвечает за порядок выполнения. Работы с одинаковым ExecutionOrder
будут выполняться параллельно. В качестве работы может быть, например анимация элементов.
Вот так можно плавно показать элемент с анимацией масштабирования:
public class ItemsShowJob : Job
{
private const float ScaleDuration = 0.5f;
private readonly IEnumerable<IUnityItem> _items;
public ItemsShowJob(IEnumerable<IUnityItem> items, int executionOrder = 0) : base(executionOrder)
{
_items = items;
}
public override async UniTask ExecuteAsync(CancellationToken cancellationToken = default)
{
var itemsSequence = DOTween.Sequence();
foreach (var item in _items)
{
item.SetScale(0);
item.Show();
_ = itemsSequence.Join(item.Transform.DOScale(Vector3.one, ScaleDuration));
}
await itemsSequence.SetEase(Ease.OutBounce).WithCancellation(cancellationToken);
}
}
Использовать получившуюся анимацию можно при заполнении игрового поля:
public IEnumerable<IJob> GetFillJobs(IGameBoard gameBoard)
{
var itemsToShow = new List<IItem>();
for (var rowIndex = 0; rowIndex < gameBoard.RowCount; rowIndex++)
{
for (var columnIndex = 0; columnIndex < gameBoard.ColumnCount; columnIndex++)
{
var gridSlot = gameBoard[rowIndex, columnIndex];
if (gridSlot.State != GridSlotState.Empty)
{
continue;
}
var item = _itemsPool.GetItem();
item.SetWorldPosition(_gameBoardRenderer.GetWorldPosition(rowIndex, columnIndex));
gridSlot.SetItem(item);
itemsToShow.Add(item);
}
}
return new[] { new ItemsShowJob(itemsToShow) };
}
Обратите внимание, что для каждой строки, можно создать собственную работу, например для показа элементов построчно друг за другом.
Алгоритм для обработки последовательностей совпавших элементов будет ненамного сложнее:
public IEnumerable<IJob> GetSolveJobs(IGameBoard gameBoard, IEnumerable<ItemSequence> sequences)
{
var itemsToHide = new List<IUnityItem>();
var itemsToShow = new List<IUnityItem>();
foreach (var solvedGridSlot in sequences.GetUniqueGridSlots())
{
var newItem = _itemsPool.GetItem();
var currentItem = solvedGridSlot.Item;
newItem.SetWorldPosition(currentItem.GetWorldPosition());
solvedGridSlot.SetItem(newItem);
itemsToHide.Add(currentItem);
itemsToShow.Add(newItem);
_itemsPool.ReturnItem(currentItem);
}
return new IJob[] { new ItemsHideJob(itemsToHide), new ItemsShowJob(itemsToShow) };
}
За формирование последовательностей элементов, которые передаются в стратегию, отвечает метод Solve
интерфейса IGameBoardSolver
.
public interface IGameBoardSolver
{
IReadOnlyCollection<ItemSequence> Solve(IGameBoard gameBoard, params GridPosition[] gridPositions);
}
Такой подход предоставляет возможность реализации абсолютно любой логики формирования последовательностей элементов, а также стратегии заполнения игрового поля, используя для этого различную анимацию. Плюс стратегии можно менять прямо во время игрового процесса.
Ожидая ответа по проделанной работе, я подумал, а что, если я захочу реализовать поддержку специальных блоков (лёд, камень и т.д.)? Как выяснилось, реализовать можно, но с некими ограничениями. Например, невозможность "красиво" получить специальные блоки в стратегиях заполнения, так как изначально в стратегию передавалась только последовательность совпавших элементов.
Было решено исправить это недоразумение и максимально упростить процесс добавления специальных блоков. После внесенных изменений, для добавления специального блока, достаточно просто реализовать интерфейс ISpecialItemDetector<TGridSlot>
и передать его в GameBoardSolver
.
Вот так, например можно реализовать поддержку блока камень:
public class StoneItemDetector : ISpecialItemDetector<IUnityGridSlot>
{
private readonly GridPosition[] _lookupDirections;
public StoneItemDetector()
{
_lookupDirections = new[]
{
GridPosition.Up,
GridPosition.Down,
GridPosition.Left,
GridPosition.Right
};
}
public IEnumerable<IUnityGridSlot> GetSpecialItemGridSlots(IGameBoard<IUnityGridSlot> gameBoard,
IUnityGridSlot gridSlot)
{
foreach (var lookupDirection in _lookupDirections)
{
var lookupPosition = gridSlot.GridPosition + lookupDirection;
if (gameBoard.IsPositionOnGrid(lookupPosition) == false)
{
continue;
}
var lookupGridSlot = gameBoard[lookupPosition];
if (lookupGridSlot.State.GroupId == (int) TileGroup.Stone)
{
yield return lookupGridSlot;
}
}
}
}
Для отслеживания состояний ячейки используется интерфейс IStatefulSlot
.
public interface IStatefulSlot
{
bool NextState();
void ResetState();
}
Теперь, специальные блоки автоматически передаются в метод обработки совпавших последовательностей, после того как NextState
вернет false
.
public override IEnumerable<IJob> GetSolveJobs(IGameBoard<IUnityGridSlot> gameBoard,
SolvedData<IUnityGridSlot> solvedData)
{
var itemsToHide = new List<IUnityItem>();
var itemsToShow = new List<IUnityItem>();
foreach (var solvedGridSlot in solvedData.GetUniqueSolvedGridSlots(true))
{
var newItem = _itemsPool.GetItem();
var currentItem = solvedGridSlot.Item;
newItem.SetWorldPosition(currentItem.GetWorldPosition());
solvedGridSlot.SetItem(newItem);
itemsToHide.Add(currentItem);
itemsToShow.Add(newItem);
_itemsPool.ReturnItem(currentItem);
}
foreach (var specialItemGridSlot in solvedData.GetSpecialItemGridSlots(true))
{
var item = _itemsPool.GetItem();
item.SetWorldPosition(_gameBoardRenderer.GetWorldPosition(specialItemGridSlot.GridPosition));
specialItemGridSlot.SetItem(item);
itemsToShow.Add(item);
}
return new IJob[] { new ItemsHideJob(itemsToHide), new ItemsShowJob(itemsToShow) };
}
Получившийся результат:
Следующая гениальная идея, которая посетила мою голову, а насколько универсальным получилось моё решение? Смогу ли я использовать этот код для реализации, например, такой же игры используя другой движок для визуализации? И вспомнив, что на днях наткнулся на реализацию тетриса в терминале решил, а зачем вообще использовать какой-то движок?
Так как изначально логика была разделена на слои и не было жёсткой привязки к Unity, перенести удалось практически весь код, реализовав только логику отрисовки игрового поля в терминале. Для реализации асинхронности в Unity проектах я использую UniTask, а он помимо всех плюсов, которые я описал в своей предыдущей статье, имеет ещё и .NET Core версию и доступен как nuget пакет. Всё это вкупе, позволило реализовать версию для терминала всего за один вечер.
Вот так тот же уровень выглядит в терминале:
Эксперимент с терминалом показал, что в целом код получился довольно универсальным и его можно использовать. Но были моменты, которые можно было улучшить.
Например, обработка способа ввода. В начальной реализации вся логика была скрыта в классе Match3Game
, который сам описывал логику взаимодействия с игровым полем получая интерфейс IInputSystem
. Но если мы посмотрим на интерфейсы ввода с клавиатуры и мыши, то заметим, что у них мало общего.
public interface ITerminalInputSystem
{
event EventHandler<ConsoleKey> KeyPressed;
event EventHandler Break;
void StartMonitoring();
void StopMonitoring();
}
public interface IUnityInputSystem
{
event EventHandler<PointerEventArgs> PointerDown;
event EventHandler<PointerEventArgs> PointerDrag;
event EventHandler<PointerEventArgs> PointerUp;
}
А так как логика в классе Match3Game
опиралась на управление при помощи пальца или мыши, появились лишние проверки, которые при управлении с клавиатуры вовсе не нужны. Например, проверка и блокировка диагонального перемещения элементов, что с клавиатуры сделать невозможно. Конечно, можно и на базе IUnityInputSystem
реализовать управление с клавиатуры, как я сначала и сделал, но выглядело это как костыль. В итоге было решено сделать класс Match3Game
абстрактным и вообще выпилить из него интерфейс IInputSystem
, предоставив возможность самому менять местами элементы игрового поля вызывав необходимый метод.
В итоге, как вы уже могли догадаться, всё это вылилось в разработку кроссплатформенной библиотеки, которую можно использовать для создания Match-3 игр.
Библиотека распространяется под лицензией MIT. Поэтому не стесняйтесь использовать её в своих проектах. А все исходники, примеры и документацию можно найти на GitHub.
Ах да, чуть не забыл. Во время публикации пакета на площадке OpenUPM выяснилось, что все пакеты собираются с использованием старой версии npm, отчего директория Match3.Core
моей библиотеки, просто не попала в пакет. Но связавшись с автором он обновил npm до актуальной версии. Кто бы мог подумать, что простое тестовое может внести столько вклада в open source сообщество?
А как вы относитесь к тестовым заданиям?