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

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

Комментарии (0)