Для тех кто пропустил первую часть — Часть 1
Следующая часть — Часть 3
Если кому интересно почитать про используемый ивент агрегатор, то вам сюда, но это не обязательно.

Ракета:
Что нам нужно чтобы ракета взлетела? В игровом пространстве нам нужна условная планета с которой стартуем, кнопка старт и ракета. Что должна уметь ракета?
То есть у нас появляется разное поведение/состояние ракеты, в зависимости от текущего состояния, ракета должна обеспечивать разное поведение. В программировании мы постоянно сталкиваемся с ситуацией, когда у объекта может быть много кардинально разных поведений.
Для сложных поведений объектов — лучше использовать поведенческие паттерны, например паттерн состояние. Для простых — начинающие программисты часто используют много много if else. Я же рекомендую использовать switch и enum. Во первых это более четкое разделение логики на конкретные этапы, благодаря этому мы точно будем знать в каком состоянии мы сейчас находимся, и что происходит, меньше возможностей превратить код в лапшу из десятков исключений.
Как это работает:
Сначала заводим enum с нужными нам состояниями:
В родительском классе у нас есть поле —
По дефолту ему назначается первое значение. Enum по умолчанию сам выставляет значения, но для данных которые могут изменяться сверху или настраиваться геймдизайнерами — я прописываю значения вручную, для чего? Для того чтобы можно было добавить еще одно значение в инам в любое место и не нарушить хранимые данные. Также советую изучить flag enum.
Далее:
Само поведение мы определяем в апдейте, в зависимости от значения поля rocketState
Расшифрую что происходит:
Удобство текущего паттерна — это всё очень легко расширяется и регулируется, но есть одно но, слабое звено — это когда у нас может быть состояние которое комбинирует ряд других состояний. Тут или флаговый инам, с усложнением обработки, или уже переходить на более «тяжелые» паттерны.
С ракетой разобрались. На очереди простой, но забавный объект — кнопка старта.
От неё требуется следующий функционал — нажали, она оповестила что на неё нажали.
По геймдизайну это 3д объект на сцене, кнопку предполагается интегрировать в дизайн стартовой планеты. Ну ок, есть нюанс — как отслеживать нажатие на объект в сцене?
Если гуглить то мы найдем кучу методов OnMouse, среди которых будет и нажатие. Казалось бы легкий выбор, но он как раз является очень плохим, начиная с того что он часто криво работает(есть много нюансов по отслеживанию нажатия), «дорогой», заканчивая тем что он не дает той тонны плюшек которая есть в UnityEngine.EventSystems.
В итоге я рекомендую пользоваться UnityEngine.EventSystems и интерфейсами — IPointerDownHandler, IPointerClickHandler. В их методах мы и реализуем реакцию на нажатие, но тут есть несколько нюансов.
В проекте это выглядит так:

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

По геймдизайну ракета должна уметь вращаться вокруг планеты, для определения начальной траектории, но не больше какого то угла. Вращение осуществляется тачем — ракета просто следит за пальцем и направлена всегда на то место куда мы ткнули в экран. Кстати как раз прототип позволил определить что это слабое место и возникает много пограничных с этим функционалом эпизодов связанных с управлением.
Но по порядку:
Насчет поворота относительно планеты — можно хитро вращать вокруг оси и вычислять ось вращения, а можно просто создать объект пустышку с центром внутри планеты, переместить туда ракету, и спокойно вращать пустышку вокруг оси Z, пустышка будет иметь класс который будет определять поведение объекта. Ракета будет вращаться с ней. Объект я назвал RocketHolder. С этим разобрались.
Теперь насчёт ограничения поворота и поворота в сторону тача:
Не смотря на то что игра по идее 3д, но вся логика и игровой процесс на самом деле 2д. И нам просто надо довернуть ракету вокруг оси Z по направлению к месту нажатия. В конце метода мы клампим градус поворота по значению заданному в инспекторе. В методе Awake можно посмотреть самую правильную реализацию инъекции класса через агрегатор.
Один из самых важных классов, именно он собирает и обрабатывает поведение пользователя. Нажатия хоткеев, кнопок геймпада, клавиатуры и тд. У меня в прототипе довольно простой инпут, по факту надо знать только 3и вещи:
Тут всё в лоб и без заморочек, из интересного может быть примитивная реализация реактивной проперти — когда я только начинал программировать, всегда было интересно как же узнать о том что данные изменились, без постоянной вентиляции данных. Ну так вот, это оно.
Выглядит это так:
Подписываемся на OnChange, и дергаемся если только значение изменилось.
Касательно прототипирования и архитектуры — советы всё те же самые, публичные только проперти и методы, все данные должны изменяться только локально. Любые обработки и вычисления — складывайте по отдельным методам. В итоге вы всегда сможете поменять реализацию/вычисления, и это не будет задевать внешних пользователей класса. На этом пока всё, в третьей заключительной части — про модификаторы и интерфейс (драг дроп). И планирую выложить проект на гит, чтобы можно было посмотреть/пощупать. Если есть вопросы по прототипированию — задавайте, попробую внятно ответить.
Следующая часть — Часть 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; } } } }
Что нам нужно чтобы ракета взлетела? В игровом пространстве нам нужна условная планета с которой стартуем, кнопка старт и ракета. Что должна уметь ракета?
- Ждать старта
- Лететь
- Подвергаться влиянию модификаторов
- Останавливаться
То есть у нас появляется разное поведение/состояние ракеты, в зависимости от текущего состояния, ракета должна обеспечивать разное поведение. В программировании мы постоянно сталкиваемся с ситуацией, когда у объекта может быть много кардинально разных поведений.
Для сложных поведений объектов — лучше использовать поведенческие паттерны, например паттерн состояние. Для простых — начинающие программисты часто используют много много 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; } }
Расшифрую что происходит:
- Когда ждем — просто вращаем ракету по направлению к курсору мыши, таким образом задаём начальную траекторию
- Второе состояние — мы летим, разгоняем ракету в нужном направлении, и обновляем модель модификаторов на предмет появления объектов влияющих на траекторию
- Третье состояние это когда нам прилетает команда остановиться, тут отрабатываем всё чтобы ракета остановилась и переводим в состояние — мы полностью остановились.
- Последнее состояние — стоим ничего не делаем.
Удобство текущего паттерна — это всё очень легко расширяется и регулируется, но есть одно но, слабое звено — это когда у нас может быть состояние которое комбинирует ряд других состояний. Тут или флаговый инам, с усложнением обработки, или уже переходить на более «тяжелые» паттерны.
С ракетой разобрались. На очереди простой, но забавный объект — кнопка старта.
Кнопка старта
От неё требуется следующий функционал — нажали, она оповестила что на неё нажали.
Класс кнопки старт
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. В их методах мы и реализуем реакцию на нажатие, но тут есть несколько нюансов.
- В сцене должна присутствовать EventSystem, это объект/класс/компонент юнити, обычно создается когда мы создаем канвас для интерфейса, но его также можно создать самому.
- На камере должен присутствовать Physics RayCaster (это для 3д, для 2д графики там отдельный рейкастер)
- На объекте должен быть коллайдер
В проекте это выглядит так:

Теперь объект отслеживает нажатие и вызывается этот метод:
public void OnPointerDown(PointerEventData eventData) { ButtonStartPressed(); } private void ButtonStartPressed() { if (isTriggered) return; isTriggered = true; GlobalEventAggregator.EventAggregator.Invoke(new ButtonStartPressed()); Debug.Log("поехали"); }
Что тут происходит:
У нас есть булево поле в котором мы отслеживаем нажали кнопку или нет (это защита от многократного нажатия, чтобы у нас не запускался каждый раз сценарий старта).
Далее мы вызываем ивент — кнопка нажата, на который подписан класс ракета, и переводим ракету в состояние движения.
Немного забегая вперед — почему тут сплошь и рядом ивенты? Это событийно-ориентированное программирование, Во первых событийная модель дешевле постоянной обработки данных, с целью выяснения их изменений. Во вторых это та самая слабая связанность, нам не нужно на ракете знать что существует кнопка, что кто то её нажал и так далее, мы просто знаем что есть событие для старта, мы его получили и действуем. Далее — это событие интересно не только ракете, например на это же событие подписана панель с модификаторами, она скрывается при старте ракеты. Также это событие может быть интересно инпут контроллеру — и пользовательский ввод может не обрабатываться или обрабатываться по другому после старта ракеты.
Почему событийную парадигму не любят многие программисты? Потому-что тонна событий и подписок на эти события легко превращают код в лапшу, в которой вообще не понятно откуда начать и закончится ли это где то, не говоря о том что также надо следить за отпиской/подпиской и чтобы все объекты были живыми.
И именно поэтому для реализации ивентов я использую свой агрегатор ивентов, который по сути передаёт не ивенты, а контейнеры данных посредством ивентов, и классы подписываются на те данные которые им интересны. Также агрегатор сам следит за живыми объектами и выкидывает из подписчиков дохлые объекты. Благодаря перебросу контейнера, также возможна реализация инъекции, можно передать ссылку на интересующий нас класс. По контейнеру можно легко отследить кто эти данные обрабатывает и посылает. Для прототипирования — отличная вещь.
Вращение ракеты для определения стартовой траектории

По геймдизайну ракета должна уметь вращаться вокруг планеты, для определения начальной траектории, но не больше какого то угла. Вращение осуществляется тачем — ракета просто следит за пальцем и направлена всегда на то место куда мы ткнули в экран. Кстати как раз прототип позволил определить что это слабое место и возникает много пограничных с этим функционалом эпизодов связанных с управлением.
Но по порядку:
- Нам нужно чтобы ракета поворачивалась относительно планеты в сторону тача
- Нам нужно клампить угол поворота
Насчет поворота относительно планеты — можно хитро вращать вокруг оси и вычислять ось вращения, а можно просто создать объект пустышку с центром внутри планеты, переместить туда ракету, и спокойно вращать пустышку вокруг оси 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и вещи:
- Есть ли нажатие и его координаты
- Есть ли вертикальный свайп и насколько свайпаться
- Оперирую ли я с интерфейсом/модификаторами
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, и дергаемся если только значение изменилось.
Касательно прототипирования и архитектуры — советы всё те же самые, публичные только проперти и методы, все данные должны изменяться только локально. Любые обработки и вычисления — складывайте по отдельным методам. В итоге вы всегда сможете поменять реализацию/вычисления, и это не будет задевать внешних пользователей класса. На этом пока всё, в третьей заключительной части — про модификаторы и интерфейс (драг дроп). И планирую выложить проект на гит, чтобы можно было посмотреть/пощупать. Если есть вопросы по прототипированию — задавайте, попробую внятно ответить.
