Pull to refresh

Прототипирование мобильной игры, с чего начать, и как это делать. Часть 2

Reading time 9 min
Views 3K
Для тех кто пропустил первую часть — Часть 1
Следующая часть — Часть 3

Если кому интересно почитать про используемый ивент агрегатор, то вам сюда, но это не обязательно.

Итак, начинаем собирать всё в кучу




Ракета:

Класс базовой ракеты
using DG.Tweening;
using GlobalEventAggregator;
using UnityEngine;

namespace PlayerRocket
{
    public class Rocket : PlayerRocketBase
    {
        [SerializeField] private float pathСorrectionTime = 10;
        private Vector3 movingUp = new Vector3(0, 1, 0);

        protected override void StartEventReact(ButtonStartPressed buttonStartPressed)
        {
            transform.SetParent(null);
            rocketState = RocketState.MOVE;
            transform.DORotate(Vector3.zero, pathСorrectionTime);
        }

        protected override void Start()
        {
            base.Start();

            EventAggregator.Invoke(new RegisterUser { playerHelper = this });
            if (rocketState == RocketState.WAITFORSTART)
                return;
            RocketBehaviour();
        }


        private void FixedUpdate()
        {
            RocketBehaviour();
        }

        private void RocketBehaviour()
        {
            switch (rocketState)
            {
                case RocketState.WAITFORSTART:
                    if (inputController.OnTouch && !inputController.OnDrag)
                        rocketHolder.RotateHolder(inputController.worldMousePos);
                    break;
                case RocketState.MOVE:
                    rigidbody.AddRelativeForce(Vector3.up*(config.Speed*Time.deltaTime));
                    forceModel.AddModificator();
                    break;
                case RocketState.STOP:
                    Debug.Log("мы стопаемся");
                    rigidbody.velocity = Vector3.zero;
                    rigidbody.drag = 50;
                    rocketState = RocketState.COMPLETESTOP;
                    break;
                case RocketState.COMPLETESTOP:
                    break;
                default:
                    rocketState = RocketState.COMPLETESTOP;
                    break;
            }
        }
    }
}


Что нам нужно чтобы ракета взлетела? В игровом пространстве нам нужна условная планета с которой стартуем, кнопка старт и ракета. Что должна уметь ракета?

  1. Ждать старта
  2. Лететь
  3. Подвергаться влиянию модификаторов
  4. Останавливаться

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

Для сложных поведений объектов — лучше использовать поведенческие паттерны, например паттерн состояние. Для простых — начинающие программисты часто используют много много if else. Я же рекомендую использовать switch и enum. Во первых это более четкое разделение логики на конкретные этапы, благодаря этому мы точно будем знать в каком состоянии мы сейчас находимся, и что происходит, меньше возможностей превратить код в лапшу из десятков исключений.

Как это работает:

Сначала заводим enum с нужными нам состояниями:

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

В родительском классе у нас есть поле —
protected RocketState rocketState;

По дефолту ему назначается первое значение. Enum по умолчанию сам выставляет значения, но для данных которые могут изменяться сверху или настраиваться геймдизайнерами — я прописываю значения вручную, для чего? Для того чтобы можно было добавить еще одно значение в инам в любое место и не нарушить хранимые данные. Также советую изучить flag enum.

Далее:

Само поведение мы определяем в апдейте, в зависимости от значения поля rocketState

 private void FixedUpdate()
        {
            RocketBehaviour();
        }

        private void RocketBehaviour()
        {
            switch (rocketState)
            {
                case RocketState.WAITFORSTART:
                    if (inputController.OnTouch && !inputController.OnDrag)
                        rocketHolder.RotateHolder(inputController.worldMousePos);
                    break;
                case RocketState.MOVE:
                    rigidbody.AddRelativeForce(Vector3.up*(config.Speed*Time.deltaTime));
                    forceModel.AddModificator();
                    break;
                case RocketState.STOP:
                    Debug.Log("мы стопаемся");
                    rigidbody.velocity = Vector3.zero;
                    rigidbody.drag = 50;
                    rocketState = RocketState.COMPLETESTOP;
                    break;
                case RocketState.COMPLETESTOP:
                    break;
                default:
                    rocketState = RocketState.COMPLETESTOP;
                    break;
            }
        }

Расшифрую что происходит:

  1. Когда ждем — просто вращаем ракету по направлению к курсору мыши, таким образом задаём начальную траекторию
  2. Второе состояние — мы летим, разгоняем ракету в нужном направлении, и обновляем модель модификаторов на предмет появления объектов влияющих на траекторию
  3. Третье состояние это когда нам прилетает команда остановиться, тут отрабатываем всё чтобы ракета остановилась и переводим в состояние — мы полностью остановились.
  4. Последнее состояние — стоим ничего не делаем.

Удобство текущего паттерна — это всё очень легко расширяется и регулируется, но есть одно но, слабое звено — это когда у нас может быть состояние которое комбинирует ряд других состояний. Тут или флаговый инам, с усложнением обработки, или уже переходить на более «тяжелые» паттерны.

С ракетой разобрались. На очереди простой, но забавный объект — кнопка старта.

Кнопка старта


От неё требуется следующий функционал — нажали, она оповестила что на неё нажали.

Класс кнопки старт
using UnityEngine;
using UnityEngine.EventSystems;

public class StartButton : MonoBehaviour, IPointerDownHandler
{
    private bool isTriggered;

    private void ButtonStartPressed()
    {
        if (isTriggered)
            return;
        isTriggered = true;
        GlobalEventAggregator.EventAggregator.Invoke(new ButtonStartPressed());
        Debug.Log("поехали");
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        ButtonStartPressed();
    }
}

public struct ButtonStartPressed { }


По геймдизайну это 3д объект на сцене, кнопку предполагается интегрировать в дизайн стартовой планеты. Ну ок, есть нюанс — как отслеживать нажатие на объект в сцене?

Если гуглить то мы найдем кучу методов OnMouse, среди которых будет и нажатие. Казалось бы легкий выбор, но он как раз является очень плохим, начиная с того что он часто криво работает(есть много нюансов по отслеживанию нажатия), «дорогой», заканчивая тем что он не дает той тонны плюшек которая есть в UnityEngine.EventSystems.

В итоге я рекомендую пользоваться UnityEngine.EventSystems и интерфейсами — IPointerDownHandler, IPointerClickHandler. В их методах мы и реализуем реакцию на нажатие, но тут есть несколько нюансов.

  1. В сцене должна присутствовать EventSystem, это объект/класс/компонент юнити, обычно создается когда мы создаем канвас для интерфейса, но его также можно создать самому.
  2. На камере должен присутствовать Physics RayCaster (это для 3д, для 2д графики там отдельный рейкастер)
  3. На объекте должен быть коллайдер

В проекте это выглядит так:



Теперь объект отслеживает нажатие и вызывается этот метод:


public void OnPointerDown(PointerEventData eventData)
    {
        ButtonStartPressed();
    }

    private void ButtonStartPressed()
    {
        if (isTriggered)
            return;
        isTriggered = true;
        GlobalEventAggregator.EventAggregator.Invoke(new ButtonStartPressed());
        Debug.Log("поехали");
    }

Что тут происходит:

У нас есть булево поле в котором мы отслеживаем нажали кнопку или нет (это защита от многократного нажатия, чтобы у нас не запускался каждый раз сценарий старта).

Далее мы вызываем ивент — кнопка нажата, на который подписан класс ракета, и переводим ракету в состояние движения.

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

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

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

Вращение ракеты для определения стартовой траектории



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

Но по порядку:

  1. Нам нужно чтобы ракета поворачивалась относительно планеты в сторону тача
  2. Нам нужно клампить угол поворота

Насчет поворота относительно планеты — можно хитро вращать вокруг оси и вычислять ось вращения, а можно просто создать объект пустышку с центром внутри планеты, переместить туда ракету, и спокойно вращать пустышку вокруг оси Z, пустышка будет иметь класс который будет определять поведение объекта. Ракета будет вращаться с ней. Объект я назвал RocketHolder. С этим разобрались.

Теперь насчёт ограничения поворота и поворота в сторону тача:

сlass RocketHolder
using UnityEngine;

public class RocketHolder : MonoBehaviour
{
    [SerializeField] private float clampAngle = 45;

    private void Awake()
    {
        GlobalEventAggregator.EventAggregator.AddListener(this, (InjectEvent<RocketHolder> obj) => obj.inject(this));
    }
       
    private float ClampAngle(float angle, float from, float to)
    {
        if (angle < 0f) angle = 360 + angle;
        if (angle > 180f) return Mathf.Max(angle, 360 + from);
        return Mathf.Min(angle, to);
    }

    private Vector3 ClampRotationVectorZ (Vector3 rotation )
    {
        return new Vector3(rotation.x, rotation.y, ClampAngle(rotation.z, -clampAngle, clampAngle));
    }

    public void RotateHolder(Vector3 targetPosition)
    {
        var diff = targetPosition - transform.position;
        diff.Normalize();
        float rot_z = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg;
        transform.rotation = Quaternion.Euler(0f, 0f, rot_z - 90);
        transform.eulerAngles = ClampRotationVectorZ(transform.rotation.eulerAngles);
    }
}


Не смотря на то что игра по идее 3д, но вся логика и игровой процесс на самом деле 2д. И нам просто надо довернуть ракету вокруг оси Z по направлению к месту нажатия. В конце метода мы клампим градус поворота по значению заданному в инспекторе. В методе Awake можно посмотреть самую правильную реализацию инъекции класса через агрегатор.

InputController


Один из самых важных классов, именно он собирает и обрабатывает поведение пользователя. Нажатия хоткеев, кнопок геймпада, клавиатуры и тд. У меня в прототипе довольно простой инпут, по факту надо знать только 3и вещи:

  1. Есть ли нажатие и его координаты
  2. Есть ли вертикальный свайп и насколько свайпаться
  3. Оперирую ли я с интерфейсом/модификаторами

class InputController
using System;
using UnityEngine;
using UnityEngine.EventSystems;

public class InputController : MonoBehaviour
{
    public const float DirectionRange = 10;
    private Vector3 clickedPosition;

    [Header("расстояние после которого мы считаем свайп")]
    [SerializeField] private float afterThisDistanceWeGonnaDoSwipe = 0.5f;

    [Header("скорость вертикального скролла")]
    [SerializeField] private float speedOfVerticalScroll = 2;
    public ReactiveValue<float> ReactiveVerticalScroll { get; private set; }

    public Vector3 worldMousePos => Camera.main.ScreenToWorldPoint(Input.mousePosition);

    public bool OnTouch { get; private set; }
    public bool OnDrag { get; private set; }

    // Start is called before the first frame update
    private void Awake()
    {
        ReactiveVerticalScroll = new ReactiveValue<float>();
        GlobalEventAggregator.EventAggregator.AddListener(this, (ImOnDragEvent obj) => OnDrag = obj.IsDragging);
        GlobalEventAggregator.EventAggregator.AddListener<InjectEvent<InputController>>(this, InjectReact);
    }

    private void InjectReact(InjectEvent<InputController> obj)
    {
        obj.inject(this);
    }

    private void OnEnable()
    {
        GlobalEventAggregator.EventAggregator.Invoke(this);
    }

    void Start()
    {
        GlobalEventAggregator.EventAggregator.Invoke(this);
    }

    private void MouseInput()
    {
        if (EventSystem.current.IsPointerOverGameObject() && EventSystem.current.gameObject.layer == 5)
            return;

        if (Input.GetKeyDown(KeyCode.Mouse0))
            clickedPosition = Input.mousePosition;

        if (Input.GetKey(KeyCode.Mouse0))
        {
            if (OnDrag)
                return;

            VerticalMove();
            OnTouch = true;
            return;
        }

        OnTouch = false;
        ReactiveVerticalScroll.CurrentValue = 0;
    }

    private void VerticalMove()
    {
        if ( Math.Abs(Input.mousePosition.y-clickedPosition.y) < afterThisDistanceWeGonnaDoSwipe)
            return;
        var distance = clickedPosition.y + Input.mousePosition.y * speedOfVerticalScroll;

        if (Input.mousePosition.y > clickedPosition.y)
            ReactiveVerticalScroll.CurrentValue = distance;
        else if (Input.mousePosition.y < clickedPosition.y)
            ReactiveVerticalScroll.CurrentValue = -distance;
        else
            ReactiveVerticalScroll.CurrentValue = 0;
    }

    // Update is called once per frame
    void Update()
    {
        MouseInput();
    }
}
}


Тут всё в лоб и без заморочек, из интересного может быть примитивная реализация реактивной проперти — когда я только начинал программировать, всегда было интересно как же узнать о том что данные изменились, без постоянной вентиляции данных. Ну так вот, это оно.

Выглядит это так:

class ReactiveValue
public class ReactiveValue<T> where T: struct
{
    private T currentState;
    public Action<T> OnChange;

    public T CurrentValue
    {
        get => currentState;
        set
        {
            if (value.Equals(currentState))
                return;
            else
            {
                currentState = value;
                OnChange?.Invoke(currentState);
            }
        }
    }
}


Подписываемся на OnChange, и дергаемся если только значение изменилось.

Касательно прототипирования и архитектуры — советы всё те же самые, публичные только проперти и методы, все данные должны изменяться только локально. Любые обработки и вычисления — складывайте по отдельным методам. В итоге вы всегда сможете поменять реализацию/вычисления, и это не будет задевать внешних пользователей класса. На этом пока всё, в третьей заключительной части — про модификаторы и интерфейс (драг дроп). И планирую выложить проект на гит, чтобы можно было посмотреть/пощупать. Если есть вопросы по прототипированию — задавайте, попробую внятно ответить.
Tags:
Hubs:
+2
Comments 9
Comments Comments 9

Articles