Так получилось что я часто делаю прототипы (как по работе, так и на личных проектах)
и хочу поделиться накопленным опытом. Хочется написать статью которую бы самому было интересно читать, и в первую очередь мне интересно чем руководствуется человек когда принимает то или иное решение в реализации проекта, как он начинает проект, зачастую начать — самое сложное.

Свежий пример который я хочу с вами рассмотреть — концепт казуальной головоломки, основанной на физике. Иногда я буду упоминать сверх очевидные вещи с целью развернуть тему для новичков.

Идея выглядит так




Мы расставляем различные модификаторы по игровому полю, которые изменяют направление ракеты, притягивают, ускоряют, отталкивают и тд. Задача — проложить путь среди звезд до следующей нужной нам планеты. Выигрыш считается при приземлении/касании след планеты. Игровое поле вертикальное, несколько экранов вверх, есть допущение что траектория может занимать пол экрана влево/вправо. Проигрыш — если промазал мимо планеты назначения, столкнулся с другой планетой, вылетел далеко за зону.

И надеюсь мы все понимаем, что прежде чем врываться в разработку и осуществлять задуманное, надо сначала сделать прототип — очень сырой и быстрый продукт который позволит в быстром приближении попробовать основную механику и получить представление о геймплее. Для чего делают прототипы? Игровой процесс в наших головах и на практике очень разные вещи, то что нам кажется прикольным, для других будет полный ужас, плюс часто в проекте бывают спорные моменты — управление, правила игры, динамика игры и тд. и тп. Будет крайне глубо пропустить этап прототипа, проработать архитектуру, завезти графику, настроить уровни и в итоге выяснить что игра гавно. Из жизни — в одной игре были мини игры, порядка 10 штук, после прототипирования выяснилось что они ужасно занудные, половину выкинули, половину переделали.

Совет — помимо основной механики вычленяйте спорные места, прописывайте конкретные ожидания от прототипа. Это делается для того чтобы перезаложиться на трудные моменты. Например одна из задач которая стояла перед этим прототипом — насколько удобно и понятно что игровое поле состоит из нескольких экранов и их надо мотать. Нужно было реализовать план А — свайп. План Б — возможность зума игрового поля (по необходимости). Также было несколько вариантов работы модификаторов. На скриншоте видна первая идея — выставляем модификатор и направление его влияния. В итоге модификаторы были заменены просто сферами, которые изменяют направление ракеты при касании сферы. Решили что так будет более казуально, без траекторий и тд.

Общий функционал который мы реализуем:

  1. Можно задавать изначальную траекторию ракеты, с ограничением отклонения от перпендикуляра по градусу(ракету нельзя повернуть в сторону более чем на какой то градус )
  2. Должна быть кнопка старт, по которой отправляем ракету в путь
  3. Скроллинг экрана при расстановке модификатора (до старта )
  4. Движение камеры за игроком (после старта)
  5. Панель интерфейса с которой на поле будет осуществляться драг и дроп модификаторов
  6. В прототипе должно быть два модификатора — отталкивание и ускорение
  7. Должны быть планеты при касании которых умираешь
  8. Должна быть планета при касании которой выигрываешь

Архитектура


Очень важный момент, в большой разработке принято считать что прототип пишется как попало лишь бы быстрее, потом проект просто пишется с нуля. В суровой реальности у многих проектов ноги растут из прототипа, что плохо для большой разработки — кривая архитектура, легаси код, технический долг, дополнительное время для рефакторинга. Ну, а инди разработка в целом плавно перетекает из прототипа в бету. К чему я это, закладывать архитектуру нужно даже в прототип, пусть примитивную, чтобы потом не плакать, ну или не краснеть перед коллегами.

Перед любым стартом всегда повторяем — SOLID, KISS, DRY, YAGNI. Про Kiss и Yagni забывают даже опытные программисты ).

Какой базовой архитектуры я придерживаюсь

В сцене есть пустой gameobject GameController с соответствующим тэгом, на нём висят компоненты/монобехи, его лучше сделать префабом, потом просто на префаб добавляем компоненты по необходимости:

  1. GameController — (отвечает за состояние игры, непосредственно логику (выиграл, проиграл, сколько жизни и тд)
  2. InputController — всё что касается управления игроком, отслеживание тачей, кликов, в кого кликнули, состояние управления и тд.
  3. TransformManager — в играх часто надо знать кто где находится, различные данные связанные с положением игрока/врагов. Например если мы пролетаем мимо планеты, то засчитываем игроку поражение, за это отвечает гейм контроллер, но он откуда то же должен знать положение игрока. Менеджер трансформ именно та сущность которая знает про положения вещей
  4. AudioController — тут понятно, это про звуки
  5. InterfacesController — и тут понятно, это про UI

Общая картина вырисовывается — под каждый понятный кусок задач, заводится свой контроллер/сущность который решает эти задачи, это позволит избегать годлайк объектов, дает понимание откуда копать, наружу из контроллеров мы отдаем данные, реализацию получения данных мы всегда можем поменять. Публичные поля нельзя, данные отдаем только через публичные проперти/методы. Данные вычисляем/меняем локально.

Иногда бывает что GameController раздувается, из за различной специфичной логики и вычислений. Если нам нужно обрабатывать данные — для этого лучше завести отдельный класс GameControllerModel и делать это там.

И вот начался код


Базовый класс для ракеты
using GlobalEventAggregator;
using UnityEngine;
using UnityEngine.Assertions;

namespace PlayerRocket
{
    public enum RocketState
    {
        WAITFORSTART = 0,
        MOVE = 1,
        STOP = 2,
        COMPLETESTOP = 3,
    }

    [RequireComponent(typeof(Rigidbody))]
    public abstract class PlayerRocketBase : MonoBehaviour, IUseForces, IPlayerHelper
    {
        [SerializeField] protected RocketConfig config;
        
        protected Rigidbody rigidbody;
        protected InputController inputController;
        protected RocketHolder rocketHolder;
        protected RocketState rocketState;

        public Transform Transform => transform;
        public Rigidbody RigidbodyForForce => rigidbody;

        RocketState IPlayerHelper.RocketState => rocketState;

        protected ForceModel<IUseForces> forceModel;

        protected virtual void Awake()
        {
            Injections();
            EventAggregator.AddListener<ButtonStartPressed>(this, StartEventReact);
            EventAggregator.AddListener<EndGameEvent>(this, EndGameReact);
            EventAggregator.AddListener<CollideWithPlanetEvent>(this, DestroyRocket);
            rigidbody = GetComponent<Rigidbody>();
            Assert.IsNotNull(rigidbody, "нет ригидбоди на объекте " + gameObject.name);
            forceModel = new ForceModel<IUseForces>(this);
        }

        protected virtual void Start()
        {
            Injections();
        }

        private void DestroyRocket(CollideWithPlanetEvent obj)
        {
            Destroy(gameObject);
        }

        private void EndGameReact(EndGameEvent obj)
        {
            Debug.Log("прилетел ивент на стоп в ракету");
            rocketState = RocketState.STOP;
        }

        private void Injections()
        {
            EventAggregator.Invoke(new InjectEvent<InputController> { inject = (InputController obj) => inputController = obj});
            EventAggregator.Invoke(new InjectEvent<RocketHolder> { inject = (RocketHolder holder) => rocketHolder = holder });
        }

        protected abstract void StartEventReact(ButtonStartPressed buttonStartPressed);
    }

    public interface IPlayerHelper
    {
        Transform Transform { get; }
        RocketState RocketState { get; }
    }
}


Пробежимся по классу:

 [RequireComponent(typeof(Rigidbody))]
    public abstract class PlayerRocketBase : MonoBehaviour, IUseForces, IPlayerHelper

Первое — почему класс абстрактный? Мы не знаем какие у нас будут ракеты, как они будут двигаться, как они будут анимированы, какие геймпленые фичи будут доступны (например возможность отскока ракеты в сторону). Поэтому базовый класс делаем абстрактным, закладываем туда типовые данные, и закладываем абстрактные методы, реализация которых у конкретных ракет может различаться.

Также видно что у класса уже есть реализация интерфейсов и атрибут который навесит на геймобж нужный компонент без которого ракета не ракета.

[SerializeField] protected RocketConfig config;

Этот атрибут подсказывает нам что в инспекторе есть сериализуемое поле, куда запихивается объект, в большинстве уроков в том числе у Unity такие поля делают публичными, если вы инди разработчик — не делайте так. Пользуйтесь приватными полями и этим атрибутом. Тут немного хочу остановиться на том что это за класс и что он делает.

RocketConfig
using UnityEngine;

namespace PlayerRocket
{
    [CreateAssetMenu(fileName = "RocketConfig", menuName = "Configs/RocketConfigs", order = 1)]
    public class RocketConfig : ScriptableObject
    {
        [SerializeField] private float speed;
        [SerializeField] private float fuel;

        public float Speed => speed;
        public float Fuel => fuel;
    }
}


Это ScriptableObject который хранит в себе настройки ракеты. Это выносит пул данных который нужен геймдизайнерам — за пределы класса. Таким образом геймдизайнерам не надо копошиться и настраивать конкретный геймобжект с конкретной ракетой, они могут поправить только этот конфиг, который хранится в отдельном ассете/файле. Могут настраивать конфиг рантайм и он сохранится, также допустим если можно будет покупать разные скины для ракеты, а параметры одни и теже — конфиг просто кочует куда надо. Такой подход хорошо расширяется — можно добавлять любые данные, писать кастомные редакторы и тд.

protected ForceModel<IUseForces> forceModel;

Также хочу остановиться вот на этом, это дженерик класс для применения модификаторов к объекту.

ForceModel
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public enum TypeOfForce
{
    Push = 0,
    AddSpeed = 1,
}

public class ForceModel<T> where T : IUseForces
{
    readonly private T forceUser;
    private List<SpaceForces> forces = new List<SpaceForces>();
    protected bool IsHaveAdditionalForces;

    public ForceModel(T user)
    {
        GlobalEventAggregator.EventAggregator.AddListener<SpaceForces>(this, ChangeModificatorsList);
        forceUser = user;
    }

    private void ChangeModificatorsList(SpaceForces obj)
    {
        if (obj.IsAdded)
            forces.Add(obj);
        else
            forces.Remove(forces.FirstOrDefault(x => x.CenterOfObject == obj.CenterOfObject));

        if (forces.Count > 0)
            IsHaveAdditionalForces = true;
        else
            IsHaveAdditionalForces = false;
    }

    public void AddModificator()
    {
        if (!IsHaveAdditionalForces)
            return;

        foreach (var f in forces)
        {
            switch (f.TypeOfForce)
            {
                case TypeOfForce.Push:
                    AddDirectionForce(f);
                    break;
                case TypeOfForce.AddSpeed:
                    forceUser.RigidbodyForForce.AddRelativeForce(Vector3.up*f.Force);
                    break;
            }
        }
    }

    private void AddDirectionForce(SpaceForces spaceForces)
    {
        //Debug.Log("Работает");
        //var t = AngleDir(forceUser.TransformForForce.position, spaceForces.CenterOfObject);
        forceUser.RigidbodyForForce.AddForce(Push(spaceForces));
    }

    private Vector3 Push(SpaceForces spaceForces)
    {
        var dist = Vector2.Distance(forceUser.Transform.position, spaceForces.CenterOfObject);
        var coeff = 1 - (spaceForces.ColliderBound / dist);

        if (forceUser.Transform.position.x > spaceForces.CenterOfObject.x)
            return (Vector3.right * spaceForces.Force) * coeff;
        else
            return (-Vector3.right * spaceForces.Force) * coeff;
    }

    public static float AngleDir(Vector2 A, Vector2 B)
    {
        return -A.x * B.y + A.y * B.x;
    }
}

public interface IUseForces
{
    Transform Transform { get; }
    Rigidbody RigidbodyForForce { get; }
}

public struct SpaceForces
{
    public TypeOfForce TypeOfForce;
    public Vector3 CenterOfObject;
    public Vector3 Direction;
    public float Force;
    public float ColliderBound;
    public bool IsAdded;
}


Это то о чём я писал выше — если вам нужно делать какие то вычисления/сложную логику, выносите это в отдельный класс. Тут очень простая логика — есть список сил которые применяются к ракете. Мы итерируем список, смотрим что это за сила и применяем конкретный метод. Список обновляется по ивентам, ивенты случаются при входе/выходе в поле модификатора. Система довольно гибкая, во первых она работает с интерфейсом (привет инкапсуляция), пользователями модификаторов могут быть не только ракеты/игрок. Во вторых дженерик — можно расширить IUseForces различными потомками для нужд/экспериментов, и всё равно пользоваться этим классом/моделью.

Для первой части хватит. Во второй части рассмотрим систему ивентов, инъекции зависимостей, инпут контроллер, непосредственно класс ракеты и попробуем её запустить.

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


  1. rumyancevpavel
    23.05.2019 08:06

    Думаю что вам лучше было бы использовать ECS для вашего прототипа


    1. Brightori Автор
      23.05.2019 08:10

      Каждой парадигме своё место, ECS еще сырой и с физикой он плохо работает(а точнее не работает), также для него еще нет good practice. В субботу пойду в маил ру как раз слушать про ECS и как правильно его готовить.


  1. rumyancevpavel
    23.05.2019 08:42

    Я имел ввиду не Unity ECS, а ECS как подход к разработке. Есть полно открытых ecs фреймворков которые можно уверенно использовать в прототипе


  1. Bookvarenko
    23.05.2019 09:35

    Неплохо так. Будем ждать продолжения.


  1. sith
    24.05.2019 19:56

    Спасибо за статью.

    Перед любым стартом всегда повторяем — SOLID, KISS, DRY, YAGNI. Про Kiss и Yagni забывают даже опытные программисты ).

    Код в этой статье и в продолжении нарушает эти принципы.


    1. Brightori Автор
      24.05.2019 19:58

      Можно развернуть? В каких же местах злобное нарушение.


      1. sith
        24.05.2019 20:07

        Да. Отвечу в комментарии ко второй статье.


        1. Brightori Автор
          24.05.2019 20:07

          Спасибо ) жду


          1. sith
            24.05.2019 20:40

            Пожалуйста. Написал. Надеюсь, на Ваше понимание — я просто указал на ошибки — ничего личного :)