Укрощение Змейки с помощью реактивных потоков +15



Веб в наши дни двигается очень быстро и мы все это знаем. Сегодня Реактивное Программирование является одной из самых горячих тем в веб-разработке и с такими фреймворками, как Angular или React, она стала гораздо более популярной, особенно в современном мире JavaScript. В сообществе произошел массовый переход от императивных парадигм программирования к функциональным реактивным парадигмам. Тем не менее, многие разработчики пытаются с этим бороться и часто перегружены его сложностью (большой API), фундаментальным сдвигом в мышлении (от императивного к декларативному) и множеством понятий.

Хотя это не самая простая тема, но как только мы сумеем ее понять, мы спросим себя, как мы могли без нее жить?

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


Цель этой публикации — научиться мыслить реактивно, построив классическую видеоигру, которую все мы знаем и любим — Змейка. Правильно, видеоигра! Это забавная, но сложная система, которая содержит в себе много внешнего состояния, например: счет, таймеры или координаты игрока. Для нашей версии мы будем широко использовать Observables (наблюдаемые) и использовать несколько разных операторов, чтобы полностью избежать сторонних влияний на внешнее состояние. В какой-то момент может возникнуть соблазн сохранить состояние за пределами потока Observable, но помните, что мы хотим использовать реактивное программирование и не полагаться на отдельную внешнюю переменную, которая сохраняет состояние.

Примечание. Мы будем использовать HTML5 и JavaScript исключительно с RxJS, чтобы преобразовать программный цикл событий в приложение, основанное на реактивном событии.

Код доступен на Github, и здесь можно найти демо-версию. Я призываю Вас клонировать проект, немного повозиться с ним и реализовать интересные новые игровые функции. Если вы это сделаете, напишите мне в Twitter.

Содержание

  • Игра
  • Настройка сцены
  • Идентификация исходных потоков
  • Управление змейкой
    • Поток направления (direction$)
  • Отслеживание длины
    • BehaviorSubject как спасение
    • Реализация счета (score$)
  • Укрощение змейки (snake$)
  • Создание яблок
    • Вещание событий
  • Собирая всё вместе
    • Поддержание производительности
    • Отображение сцены
  • Будущая работа
  • Особая благодарность

Игра

Как упоминалось ранее, мы собираемся воссоздать Змейку, классическую видеоигру с конца 1970-х годов. Но вместо простого копирования игры мы добавим немного вариации к ней. Вот как работает игра.

Как игрок Вы контролируете линию, похожую на голодную змею. Цель состоит в том, чтобы съесть столько яблок, сколько сможете, чтобы вырасти как можно длиннее. Яблоки можно найти в случайных позициях на экране. Каждый раз, когда змея ест яблоко, её хвост становится длиннее. Стены не остановят вас! Но послушайте, Вы должны стараться избегать попадания в своё собственное тело любой ценой. Если Вы этого не сделаете, игра окончена. Как долго Вы сможете выжить?

Вот предварительный пример того, что мы собираемся сделать:



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

Настройка сцены

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

Если это совершенно ново для Вас, ознакомьтесь с этим курсом на egghead от Keith Peters.

Файл index.html довольно прост, потому что большая часть магии происходит в JavaScript.

<html>
<head>
  <meta charset="utf-8">
  <title>Reactive Snake</title>
</head>
<body>
  <script src="/main.bundle.js"></script>
</body>
</html>


Сценарий (script), который мы добавляем к телу (body), по сути является результатом процесса сборки и содержит весь наш код. Однако вам может быть интересно, почему в body нет такого элемента как canvas. Это потому, что мы создадим этот элемент с помощью JavaScript. Кроме того, мы добавляем несколько констант, которые определяют количество строк и столбцов, а также ширину и высоту холста.

export const COLS = 30;
export const ROWS = 30;
export const GAP_SIZE = 1;
export const CELL_SIZE = 10;
export const CANVAS_WIDTH = COLS * (CELL_SIZE + GAP_SIZE);
export const CANVAS_HEIGHT = ROWS * (CELL_SIZE + GAP_SIZE);

export function createCanvasElement() {
  const canvas = document.createElement('canvas');
  canvas.width = CANVAS_WIDTH;
  canvas.height = CANVAS_HEIGHT;
  return canvas;
}

С помощью этого мы можем вызвать эту функцию, создать элемент canvas на лету и добавить его в body нашей страницы:

let canvas = createCanvasElement();
let ctx = canvas.getContext('2d');
document.body.appendChild(canvas);

Обратите внимание, что мы также получаем ссылку на CanvasRenderingContext2D, вызывая getContext ('2d') на элементе canvas. Этот контекст 2D-рендеринга для холста позволяет нам рисовать, например, прямоугольники, текст, линии, пути и многое другое.

Мы готовы двигаться! Давайте начнем работу над основной механикой игры.

Идентификация исходных потоков

Исходя из примера и описания игры мы знаем, что нам нужны следующие функции:

  • Управление змеей с помощью клавиш со стрелками
  • Слежение за счетом игрока
  • Слежение за змеей (включая еду и перемещение)
  • Слежение за яблоками на поле (включая создание новых яблок)

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

Попробуем найти наши исходные потоки, посмотрев на вышеприведенные функции.

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

Затем нам нужно следить за счетом игрока. Счет в основном зависит от того, сколько яблок съела змея. Можно сказать, что счет зависит от длины змеи, потому что всякий раз, когда змея растет, мы хотим увеличить счет на 1. Поэтому наш следующий исходный поток — snakeLength$.

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

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

Давайте начнём со змеи. Основной механизм змеи прост: она движется со временем, и чем больше яблок она съедает, тем больше она растет. Но что именно является потоком змеи? На данный момент мы можем забыть о том, что она ест и растет, потому что в первую очередь важно то, что она зависит от фактора времени, когда она движется со временем, например, 5 пикселей каждые 200 ms. Таким образом, наш исходный поток — это интервал, который отправляет значение после каждого периода времени, и мы называем это ticks$. Этот поток также определяет скорость нашей змеи.

И последнее, но не менее важное: яблоки. Расположить яблоки на поле учитывая всё — довольно просто. Этот поток в основном зависит от змеи. Каждый раз, когда змея движется, мы проверяем, сталкивается ли голова змеи с яблоком или нет. Если это так, мы удаляем это яблоко и генерируем новое в произвольной позиции на поле. Как упоминалось, нам не нужно вводить новый поток данных для яблок.

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

  • keydown$: события нажатия клавиши (KeyboardEvent)
  • snakeLength$: представляет длину змеи (Number)
  • ticks$: интервал, который представляет движение змеи (Number)

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

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

Управление змейкой

Давайте погрузимся прямо в код и внедрим механизм управления для нашей змеи. Как упоминалось в предыдущем разделе, управление зависит от ввода с клавиатуры. Оказывается, это ужасно просто, и первым шагом является создание наблюдаемой (observable) последовательности из событий клавиатуры. Для этого мы можем использовать оператор fromEvent():

let keydown$ = Observable.fromEvent(document, 'keydown');

Это наш самый первый исходный поток, и он будет инициировать KeyboardEvent каждый раз, когда пользователь нажимает клавишу. Обратите внимание, что буквально каждый keydown инициирует событие. Поэтому мы также получаем события для клавиш, которые нас абсолютно не интересуют, и это в основном все остальные клавиши, кроме клавиш со стрелками. Но прежде чем решать эту конкретную проблему, мы определяем постоянную карту направлений:

export interface Point2D {
  x: number;
  y: number;
}

export interface Directions {
  [key: number]: Point2D;
}

export const DIRECTIONS: Directions = {
  37: { x: -1, y: 0 }, // Left Arrow
  39: { x: 1, y: 0 },  // Right Arrow
  38: { x: 0, y: -1 }, // Up Arrow
  40: { x: 0, y: 1 }   // Down Arrow
};

Посмотрев на объект KeyboardEvent, можно предположить, что каждый ключ имеет уникальный keyCode. Чтобы получить коды для клавиш со стрелками, мы можем использовать эту таблицу.

Каждое направление имеет тип Point2D, который является просто объектом с x и y свойствами. Значение для каждого свойства может быть равно 1, -1 или 0, указывая, где должна быть змея. Позже мы будем использовать направление для получения новой позиции сетки для головы и хвоста змеи.

Поток направления (direction$)

Итак, у нас уже есть поток для событий keydown, и каждый раз, когда игрок нажимает клавишу, нам нужно сопоставить значение, которое являет собой KeyboardEvent, одному из векторов направления выше. Для этого мы можем использовать оператор map() для проецирования каждого события клавиатуры на вектор направления.

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])

Как упоминалось ранее, мы будем получать каждое событие нажатия клавиши, потому что мы не отфильтровываем те, которые нас не интересуют, например, клавиши символов. Однако можно утверждать, что мы уже фильтруем события, просматривая их на карте направлений. Для каждого keyCode, который не определен на этой карте, он будет возвращен как undefined. Тем не менее, на самом деле это не фильтрует значения в потоке, поэтому мы можем использовать оператор filter(), чтобы обрабатывать только нужные значения.

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
  .filter(direction => !!direction)

Хорошо, это было легко. Код выше отлично работает и работает так, как ожидалось. Тем не менее, есть еще кое-что для улучшения. Вы догадались что именно?

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

Решение довольно просто. Мы кэшируем предыдущее направление и когда вызывается новое событие, мы проверяем, не отличается ли новое направление от противоположного. Вот функция, которая вычисляет next (следующее) направление:

export function nextDirection(previous, next) {
  let isOpposite = (previous: Point2D, next: Point2D) => {
    return next.x === previous.x * -1 || next.y === previous.y * -1;
  };

  if (isOpposite(previous, next)) {
    return previous;
  }

  return next;
}

Это первый случай, когда у нас возникает соблазн сохранить состояние за пределами Observable (наблюдаемого) источника информации, потому что нам как-то нужно правильно отслеживать предыдущее направление… Легким решением является просто сохранить предыдущее направление во внешней переменной состояния. Но постойте! Ведь мы хотели избежать этого, не так ли?

Чтобы избежать внешней переменной, нам нужен способ сортировки совокупных бесконечных Observables. RxJS имеет очень удобный оператор, который мы можем использовать для решения нашей проблемы — scan().

Оператор scan() очень похож на Array.reduce(), но вместо того, чтобы только возвращать последнее значение, он инициирует отправку каждого промежуточного результата. С помощью scan() мы можем в основном накапливать значения и бесконечно сокращать поток входящих событий до одного значения. Таким образом, мы можем отслеживать предыдущее направление, не полагаясь на внешнее состояние.

Давайте применим это и посмотрим на наш окончательный direction$ поток:

let direction$ = keydown$
  .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
  .filter(direction => !!direction)
  .scan(nextDirection)
  .startWith(INITIAL_DIRECTION)
  .distinctUntilChanged();

Обратите внимание, что мы используем startWith(), чтобы инициировать начальное значение, прежде чем начинать отправлять значения из источника Observable (keydown$). Без этого оператора наш Observable начнет отправлять значения только тогда, когда игрок нажимает клавишу.

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

Следующая схема визуализирует наш поток direction$ и то, как он работает. Значения, окрашенные в синий цвет, представляют начальные значения, желтый означает, что значение было изменено на потоке Observable, а значения, отправленные в потоке результатов, окрашены в оранжевый цвет.



Отслеживание длины

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

В реактивном мире решение немного отличается. Один из простых подходов может заключаться в использовании потока snake$, и каждый раз, когда он отправляет значение, мы знаем, что змея увеличилась в длину. Хотя это действительно зависит от реализации snake$, пока мы не будем реализовывать этот поток. С самого начала мы знаем, что змея зависит от ticks$, поскольку она перемещается на определенное расстояние с течением времени. Таким образом, snake$ будет накапливать массив сегментов тела, и поскольку она основана на ticks$, она будет генерировать значение каждые x миллисекунд. Тем не менее, даже если змея не сталкивается ни с чем, snake$ будет по-прежнему отправлять значения. Это потому, что змея постоянно движется по полю, и поэтому массив всегда будет другим.

Это может быть немного сложно понять, потому что между различными потоками есть определенные зависимости. Например, apples$ будут зависеть от snake$. Причина этого в том, что каждый раз, когда движется змея, нам нужен массив сегментов её тела, чтобы проверить, не сталкивается ли какая-либо из этих частей с яблоком. В то время как поток apples$ будет накапливать массив яблок, нам нужен механизм моделирования коллизий, который в то же время избегает круговых зависимостей.

BehaviorSubject как спасение

Решение этой задачи заключается в том, что мы будем внедрять механизм вещания, используя BehaviorSubject. RxJS предлагает различные типы Subjects (предметов) с различными функциональными возможностями. Таким образом, класс Subject предоставляет базу для создания более специализированных Subjects. В двух словах, Subject является типом, который реализует одновременно типы Observer (наблюдатель) и Observable (наблюдаемый). Observables определяют поток данных и создают данные, в то время как Observers могут подписаться на Observables и получать данные.

BehaviorSubject — это более специализированный Subject, предоставляющий значение, которое изменяется со временем. Теперь, когда Observer подписывается на BehaviorSubject, он получит последнее отправленное значение, а затем все последующие значения. Его уникальность заключается в том, что он включает в себя начальное значение, так что все Observers получат как минимум одно значение при подписке.

Давайте продолжим и создадим новый BehaviorSubject с начальным значением SNAKE_LENGTH:

// SNAKE_LENGTH specifies the initial length of our snake
let length$ = new BehaviorSubject<number>(SNAKE_LENGTH);

С этого места остался лишь небольшой шаг для реализации snakeLength$:

let snakeLength$ = length$
  .scan((step, snakeLength) => snakeLength + step)
  .share();

В приведенном выше коде мы видим, что snakeLength$ основана на length$, которая является нашим BehaviorSubject. Это означает, что всякий раз, когда мы передаем новое значение для Subject, используя next(), он будет отправлять значение на snakeLength$. Кроме того, мы используем scan() для накопления длины с течением времени. Круто, но вам может быть интересно, что это за share(), не так ли?

Как уже упоминалось, snakeLength$ будет позже использоваться как входящие данные для snake$, но в то же время действует как исходный поток для счёта игрока. Как результат мы в конечном итоге воссоздаем этот исходный поток со второй подпиской на тот же Observable. Это происходит потому, что length$ является cold Observable (холодным Наблюдаемым).

Если вы совершенно не знакомы с hot and cold Observables (горячими и холодными наблюдателями), мы написали статью о Cold vs Hot Observables.

Дело в том, что мы используем share(), чтобы разрешить мульти подписки к Observable, которые в противном случае повторно создавали бы его источник при каждой подписке. Этот оператор автоматически создает Subject между исходным источником и всеми будущими подписчиками. Как только количество подписчиков перейдет от нуля к одному, он подключит Subject к базовому источнику Observable и инициирует отправку всех своих уведомлений. Все будущие подписчики будут подключены к этому промежуточному Subject, так что это эффективно, что есть только одна подписка на лежащий ниже холодный Observable. Это называется многоадресной рассылкой (мультикастинг) и поможет Вам выделиться.

Потрясающие! Теперь, когда у нас есть механизм, который мы можем использовать для передачи значений нескольким подписчикам, мы можем пойти дальше и реализовать score$.

Реализация счета (score$)

Реализация счета игрока настолько проста, насколько это возможно. Вооружившись snakeLength$, мы теперь можем создать поток score$, который просто накапливает счет игрока с помощью scan():

let score$ = snakeLength$
  .startWith(0)
  .scan((score, _) => score + POINTS_PER_APPLE);

По сути мы используем snakeLength$ или, скорее, length$, чтобы уведомлять подписчиков о том, что произошло столкновение, и если это действительно было так, мы просто увеличиваем счет на POINTS_PER_APPLE, постоянное количество очков за яблоко. Обратите внимание, что startWith(0) необходимо добавить перед scan(), чтобы избежать указания начального значения.

Давайте посмотрим на более наглядное представление того, что мы только что реализовали:



Посмотрев на приведенную выше схему, вы можете задаться вопросом, почему начальное значение BehaviorSubject появляется только на snakeLength$ и отсутствует в score$. Это связано с тем, что первый подписчик заставит share() подписаться на базовый источник данных и, поскольку исходный источник данных сразу же отправляет значение, это значение уже будет отправлено к тому времени, когда произошли последующие подписки.

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

Укрощение змейки (snake$)

К этому моменту мы уже выучили много операторов и теперь можем использовать их для реализации нашего потока snake$. Как обсуждалось в начале этой статьи, нам нужен какой-то тикер, который удерживает нашу голодную змею двигающейся. Оказывается, есть удобный оператор для этого interval(x), который отправляет значения каждые x миллисекунд. Давайте будем называть каждое значение тиком.

let ticks$ = Observable.interval(SPEED);

С этого момента до реализации финального потока snake$ совсем немного. Для каждого тика, в зависимости от того, съела ли змея яблоко или нет, мы хотим либо переместить её вперёд, либо добавить новый сегмент. Поэтому мы можем использовать уже знакомую нам функцию scan() для накопления массива сегментов тела. Но, как вы, возможно, догадались, перед нами возникает вопрос. Где вступают в игру потоки direction$ или snakeLength$?

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

К счастью, RxJS предлагает еще один очень удобный оператор с именем withLatestFrom(). Это оператор, используемый для объединения потоков, и это именно то, что мы ищем. Этот оператор применяется к первичному потоку, который контролирует, когда данные будут отправляться в результирующий поток. Другими словами, вы можете думать о withLatestFrom() как о способе регулирования отправки данных вторичного потока.

Учитывая вышесказанное у нас есть инструменты, необходимые для окончательного внедрения голодной snake$:

let snake$ = ticks$
  .withLatestFrom(direction$, snakeLength$, (_, direction, snakeLength) => [direction, snakeLength])
  .scan(move, generateSnake())
  .share();

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

Кроме того, мы передаем селекторную функцию (функцию для настройки) в withLatestFrom, которая вызывается, когда первичный поток инициирует отправку значения. Эта функция является необязательной, и если ее убрать, то появляется список со всеми элементами.

Мы не будем объяснять функцию move(), поскольку основной целью этого поста является содействие изменению фундаментального мышления. Тем не менее, вы можете найти её исходный код на GitHub.

Вот схема, которая наглядно демонстрирует код выше:



Видите как мы регулируем поток direction$? Дело в том, что использование withLatestFrom() очень практично, когда вы хотите объединить несколько потоков, и Вы не заинтересованы в создании значений в блоке Observable (наблюдаемом), когда любой из потоков отправляет данные.

Создание яблок

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

На данный момент мы реализовали несколько потоков, таких как direction$, snakeLength$, score$ и snake$. Если мы объединим их вместе, мы сможем перемещаться по этому зверю змею. Но что это за игра, если нечего есть. Довольно скучно.

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

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

let apples$ = snake$
  .scan(eat, generateApples())
  .distinctUntilChanged()
  .share();

Круто! Это означает, что всякий раз, когда apples$ отправляет новое значение, мы можем предположить, что наша змея съела один из этих вкусных фруктов. Осталось увеличить счет, а также сообщить другим потокам об этом событии, таким как snake$, которая берет последнее значение от snakeLength$, чтобы определить, добавить ли новый сегмент тела.

Вещание событий

Немного ранее мы реализовали механизм вещания, помните? Давайте использовать его для запуска желаемых действий. Вот наш код для eat():

export function eat(apples: Array<Point2D>, snake) {
  let head = snake[0];

  for (let i = 0; i < apples.length; i++) {
    if (checkCollision(apples[i], head)) {
      apples.splice(i, 1);
      // length$.next(POINTS_PER_APPLE);
      return [...apples, getRandomPosition(snake)];
    }
  }

  return apples;
}

Простым решением является вызов length$.next(POINTS_PER_APPLE) прямо внутри блока условия. Но тогда мы столкнемся с проблемой, потому что мы не сможешь извлечь этот метод в свой собственный модуль утилит (модуль ES2015). В ES2015 модули хранятся в файлах, и для каждого файла существует ровно один модуль. Наша цель состоит в том, чтобы организовать наш код таким образом, чтобы его было легко поддерживать и расширять.

Более утонченным решением является введение еще одного потока, называемого applesEaten$. Этот поток основан на apples$ и каждый раз, когда в потоке отправляется новое значение, мы хотели бы выполнить какое-то действие, вызывая length$.next(). Для этого мы можем использовать оператор do(), который будет выполнять часть кода для каждого события.

Звучит выполнимо. Но нам как-то нужно пропустить первое (начальное) значение, испускаемое apples$. В противном случае мы в конечном итоге увеличиваем счет сразу, что не имеет большого смысла, поскольку игра только началась. Оказывается, у RxJS есть оператор для этого, а именно skip().

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

let appleEaten$ = apples$
  .skip(1)
  .do(() => length$.next(POINTS_PER_APPLE))
  .subscribe();

Собирая всё вместе

На этом этапе мы реализовали все основные строительные блоки нашей игры, и мы готовы, наконец, объединить все в один поток результата — scene$. Для этого мы будем использовать combineLatest. Этот оператор очень похож на withLatestFrom, но отличается в деталях. Во-первых, давайте посмотрим на код:

let scene$ = Observable.combineLatest(snake$, apples$, score$, (snake, apples, score) => ({ snake, apples, score }));

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



Поддержание производительности

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

Мы можем сделать это, представив другой поток, похожий на ticks$, но для рендеринга. В основном это лишь другой интервал:

// Interval expects the period to be in milliseconds which is why we devide FPS by 1000
Observable.interval(1000 / FPS)

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

К счастью, мы можем использовать requestAnimationFrame, позволяя браузеру работать и выполнять наш скрипт в самое подходящее время. Но как мы используем его для нашего блока Observable? Хорошей новостью является то, что многие операторы, включая interval(), принимают в качестве последнего аргумента Scheduler (планировщик). В двух словах, Scheduler — это механизм, позволяющий запланировать выполнение какой-либо задачи в будущем.

Хотя RxJS предлагает множество планировщиков, тот, который нас интересует, называется animationFrame. Этот планировщик выполняет задачу при запуске window.requestAnimationFrame.

Отлично! Давайте применим это к нашему интервалу, и назовем результирующий Observable game$:

// Note the last parameter
const game$ = Observable.interval(1000 / FPS, animationFrame)

Этот интервал теперь будет отправлять значения примерно каждые 16 мс, поддерживающие 60 FPS.

Отображение сцены

Нам осталось объединить нашу game$ с scene$. Можете ли вы догадаться, какой оператор мы используем для этого? Помните, что оба потока отправляются с разными интервалами, и теперь цель состоит в том, чтобы отобразить нашу сцену на холсте 60 раз в секунду. Мы будем использовать game$ в качестве основного потока, и каждый раз, когда он отправляет значение, мы объединяем его с последним значением из scene$. Звучит знакомо? Да, мы снова можем использовать withLatestFrom.

// Note the last parameter
const game$ = Observable.interval(1000 / FPS, animationFrame)
  .withLatestFrom(scene$, (_, scene) => scene)
  .takeWhile(scene => !isGameOver(scene))
  .subscribe({
    next: (scene) => renderScene(ctx, scene),
    complete: () => renderGameOver(ctx)
  });

Возможно, вы заметили takeWhile() в приведенном выше коде. Это еще один очень полезный оператор, который мы можем вызвать на существующем Observable. Он будет возвращать значения из game$ до тех пор, пока isGameOver() не вернет true.

Вот живое демо для вас с которым можно поиграться:



Будущая работа

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

Будьте на связи!

Особая благодарность

Особая благодарность James Henry и Brecht Billiet за их помощь с кодом.




К сожалению, не доступен сервер mySQL