К чему приводят тестовые задания или как я реализовал 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/


Интересные статьи

Интересные статьи

Совсем недавно, организация, направленная на развитие экосистемы Solana - Solana Foundation, выступила организатором череды мероприятий под названием «HackerHouse». Solana HackerHouse — это серия меро...
Вот пример для проверки:Чему равно выражение -3/3u*3 на С++? Не угадаете. Ответ: -4. Небольшое расследование под катом.
Привет, Хабр! Меня зовут Станислав Маскайкин, я архитектор аналитических систем ВТБ. Сегодня я расскажу о том, почему мы перевели нашу систему подготовки отчётности с Ora...
В прошлый раз мы выяснили, что стриминговые сервисы скупают все, что относится к подкастингу, но делают это с умом. С точки зрения эксклюзивных прав их интересуют только ...
Привет, Хабр! Представляю вашему вниманию перевод статьи «5 Reasons You Should Stop Using System.Drawing from ASP.NET». Ну что ж, они таки сделали это. Команда corefx в конце концов соглас...