К чему приводят тестовые задания или как я реализовал Match-3 для терминала

от автора

Вы когда-нибудь играли в 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 сообщество?

А как вы относитесь к тестовым заданиям?


ссылка на оригинал статьи https://habr.com/ru/post/663156/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *