Привет, Хабр. Будущих студентов курса "Unity Game Developer. Professional" приглашаем принять участие в открытом вебинаре на тему "Продвинутый искусственный интеллект врагов в шутерах".

Также предлагаем к прочтению перевод интересной статьи.


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

Не отвлекайтесь на котят. Читайте о реактивном программировании!

Итак, я привлек ваше внимание?

Отлично! Я приложу все силы, чтобы не потерять его.

Что такое реактивное программирование?

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

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

Где мне следует хранить здоровье игроков?

Конечно же, внутри компонента Player!

Где мне следует хранить количество урона, наносимого ракетой после попадания в игрока?

Внутри компонента Rocket!

Где мне следует хранить время, оставшееся до геймовера на текущем уровне?

Гадать не приходится. Внутри компонента Level.

Ну разве же это не замечательно? Кажется, что все находится на своем месте, и все в нашем мире хорошо и прекрасно. Но, как мы все прекрасно знаем, долго это продолжаться не может.

Glue Code (связующий код) — начинаем потихонечку влипать…

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

Здесь все просто, не так ли? Как насчет того, чтобы разместить внутри компонента Player ссылку на метку (label) в UI и обновлять UI каждый раз, когда изменяется состояние здоровья?

Идем дальше. Как насчет того, сколько времени осталось до геймовера?

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

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

Отлично, так почему бы нам просто не вернуться к классу Player, добавить еще одну ссылку на анимацию и просто запускать ее таким же образом с каждым уменьшением здоровья?

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

Ладно, погодите секундочку… ой…

Это случалось с лучшими из нас…

Реактивное программирование спешит на помощь!

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

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

Императивные против декларативных

Разницу между императивными и декларативными языками можно очертить довольно легко: в то время как императивные языки описывают, как что-то нужно сделать, декларативные языки описывают, что нужно делать.

Мы не можем изменить тот факт, что C# — язык императивный. И, по сути, на нем мы можем писать только императивно. Однако, используя определенные фреймворки, которые делают за нас всю тяжелую работу, мы все же можем пользоваться определенными декларативными парадигмами.

Одна из этих парадигм — реактивное программирование, привнесенное в Unity и C# в форме реактивных расширений (Reactive Extensions) для Unity (UniRx).

Покажите мне код!

А пока хватит теории. Погрузимся в код!

И начнем мы это с рассмотрения примера класса Player, о котором мы говорили ранее:

 using UnityEngine;
 using UnityEngine.UI;
 
 public class Player : MonoBehaviour {
   public int health = 100;
   public Text scoreLabel;
 
   public void ApplyDamage(int damage) {
     health -= damage;
     scoreLabel.text = health.ToString();
   }
 }

Прежде чем мы что-то будем с ним делать, давайте посмотрим поближе, поищем тот самый «клей» (glue) внутри этого компонента и обсудим, что делает его таким неприятным (в оригинале обыгрывается “sticky”).

using UnityEngine;
 using UnityEngine.UI;
 
 public class Player : MonoBehaviour {
   public int health = 100;
   public Text scoreLabel;
 
   public void ApplyDamage(int damage) {
     health -= damage;
     scoreLabel.text = health.ToString();
   }
 }

Вот оно! Это достаточно скользкий момент.

Теперь давайте избавимся от него, заменив магией реактивных расширений:

 using UnityEngine;
 using UnityEngine.UI;
 using UniRx;
 
 public class Player : MonoBehaviour {
   public ReactiveProperty<int> health 
     = new ReactiveProperty<int>(100);
 
   public void ApplyDamage(int damage) {
     health.Value -= damage;
   }
 }

Куда делась ссылка на метку? Мы перенесли ее куда-то в другое место. Но давайте сначала поговорим об этом необыкновенном классе ReactiveProperty.

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

Реактивные суперспособности ReactiveProperty

Представляю вам нового игрока в нашей игре, класс UiPresenter:

using UnityEngine;
 using UnityEngine.UI;
 using UniRx;
 
 public class UiPresenter : MonoBehaviour {
   public Player player;
 
   public Text scoreLabel;
 
   void Start() {
     player.health.SubscribeToText(scoreLabel);
   }
 }

Снова эта наша scoreLabel. Но погодите-ка, что здесь вообще происходит?

Давайте начнем с того, что попытаемся устранить всякое недопонимание.

Помните, как мы говорили о декларативном и императивном, и как каким-то образом реактивные расширения позволяют нам быть декларативными, давая нам императивные расширения, позволяющие нам быть декларативными? Запутались еще больше?

Я сделал это специально! Потому что, если вы сейчас сбиты с толку, вы точно в правильном состоянии, чтобы вскоре соединить все точки. И не волнуйтесь, мы сделаем это шаг за шагом.

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

Так что это значит? Подписка на ReactiveProperty? Чтобы ответить на этот вопрос, мы должны сначала пролить свет на некоторые концепции.

ReactiveProperty — это особый вид Observable (дословно “наблюдаемый), базового класса, на основе которого построены Reactive Properties. Вы можете думать о Observable как о потоке значений.

Это выглядит потрясающе, но, к сожалению, мы говорим не о таком потоке значений
Это выглядит потрясающе, но, к сожалению, мы говорим не о таком потоке значений

Поток значений — звучит круто, правда? Но, наверное, на деле вы пока не понимаете, как на основе этой фразы вообразить что-нибудь полезное.

Итак, давайте заменим это потрясающее изображение, которое нам не очень помогает, на что-нибудь менее помпезное, но более практичное:

Что вы видите? Бильярдный стол!

Да, что еще?

Предупреждаю, если вы продолжите читать, вы можете начать видеть Observable повсюду!

Я вижу 6 Observable!

Теперь, чтобы понять, что такое Observable, вам нужно добавить к изображению время. Представьте, что игра заканчивается, и одна за другой — независимо друг от друга лузы-Observable «выплевывают» (emit) шары обратно. На следующем изображении мы видим этот процесс для всех луз. Ось X нашей диаграммы означает движение вперед во времени:

Окей, observable — это потоки значений во времени. В чем же заключалась вся эта история о декларативном программировании?

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

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

Чтобы описать это поведение, мы можем использовать декларативный язык, предоставляемый нам реактивными расширениями, например:

all_balls_in_order = Observable.Merge(h1, h2, h3, h4, h5, h6);

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

Вернемся в мир практики: реактивные суперспособности.

Теперь мы можем даже путешествовать во времени!

Впрочем, только вперед. Извините, что разочаровал вас.

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

all_balls_delayed = Observable.Merge(
  h1.Delay(
    TimeSpan.FromSeconds(1)
  ), 
  h2.Delay(
    TimeSpan.FromSeconds(2)
  ), 
  h3.Delay(
    TimeSpan.FromSeconds(3)
  ), 
  h4.Delay(
    TimeSpan.FromSeconds(3)
  ), 
  h5.Delay(
    TimeSpan.FromSeconds(2)
  ), 
  h6.Delay(
    TimeSpan.FromSeconds(1)
  )
);

Это даже слишком просто, не правда ли?

Все еще не впечатлены?

В этом случае зайдите на rxmarbles.com и посмотрите, сколько еще крутых вещей вы можете сделать с помощью простых встроенных команд.

Отлепляем UI

Помните, как в самом начале мы просто хотели избавиться от связующего кода UI, а теперь каким-то образом закончили разговор на путешествиях во времени? Добро пожаловать в увлекательный мир реактивного программирования!

Но вернемся к нашему предыдущему примеру. Давайте поговорим о самых основных операциях, которые мы можем определить для observable, и о том, как мы их будем использовать после их объявления. Итак, посмотрим на другую версию UiPresenter:

using UnityEngine;
 using UnityEngine.UI;
 using UniRx;
 
 public class UiPresenter : MonoBehaviour {
   public Player player;
 
   public Text scoreLabel;
 
   void Start() {
     player.health
       .Select(health => string.Format("Health: {0}", health))
       .Subscribe(text => scoreLabel.text = text);
   }
 }

Сначала поговорим об операторе Select rxmarbles и некоторых других реализациях реактивных расширений он называется map). Он применяет функцию к каждому элементу в потоке. В этом случае мы берем все входные элементы (которые являются целыми числами) и превращаем их в строку.

То, что мы делаем далее, — это подписка на полученный поток. Помните, как мы говорили, что простое объявление потоков еще ничего не делает?

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

Теперь давайте расширим этот пример еще раз, чтобы реализовать все три требования, которыми мы хотели реализовать в самом начале. Сначала анимация:

using UnityEngine;
 using UnityEngine.UI;
 using UniRx;
 
 public class UiPresenter : MonoBehaviour {
   public Player player;
 
   public Text scoreLabel;
   public Animator animator;
 
   void Start() {
     player.health
       .Select(health => string.Format("Health: {0}", health))
       .Do(x => animator.Play("Flash"))
       .Subscribe(text => scoreLabel.text = text);
   }
 }

Это было легко, не правда ли? Как вы, наверное, догадались, оператор Do просто выполняет функцию для каждого значения в нашем потоке, не изменяя его.

И, наконец, третье — поддержка замены на другого (управляемого) активного игрока:

using UnityEngine;
 using UnityEngine.UI;
 using UniRx;
 
 public class UiPresenter : MonoBehaviour {
   public ReactiveProperty<Player> player 
     = new ReactiveProperty<Player>();
 
   public Text scoreLabel;
   public Animator animator;
 
   void Start() {
     var playerHealth = this.player
       .Where(player => player != null)
       .Select(player => player.health);
 
     playerHealth
       .Select(health => string.Format("Health: {0}", health))
       .Do(x => animator.Play("Flash"))
       .Subscribe(text => scoreLabel.text = text);
   }
 }

Разве это не просто? Мы просто превратили игрока в ReactiveProperty, а затем взяли его оттуда. Поэтому вместо написания кода, который манипулирует одним конкретным игроком, мы просто превращаем активного игрока из переменной в поток.

И, как вы вскоре поймете, как только углубитесь в реактивное программирование, почти все можно превратить в поток.

Изображение взято из Введения в реактивное программирование, которого вам не хватало

Заключение

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

Также загляните на rxmarbles.com, чтобы увидеть очень хороший визуальный обзор операций, которые теперь у вас под рукой. Но обратите внимание, что имена этих операций не всегда совпадают с именами в UniRx (map, как я уже говорил, называется Select в UniRx).

«Введение в реактивное программирование, которого вам не хватало» — тоже замечательное чтиво.

Спасибо за ваше внимание!


Узнать о курсе подробнее "Unity Game Developer. Professional".

Записаться на открытый урок "Продвинутый искусственный интеллект врагов в шутерах".