Самая интересная часть этой серии 🍺

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

Рефакторинг 👷‍♀️

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

type sequence = list(Types.colors);

Types.colors из отдельного файла с именем Types.re

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

let list =
    Belt.List.makeBy(
      20,
      _i => {
        let num = Js.Math.floor(Js.Math.random() *. 4.0 +. 1.0);
        switch (num) {
        | 1 => Green
        | 2 => Red
        | 3 => Blue
        | 4 => Yellow
        | _ => Green
        };
      },
    );

Вместо Array вы делаете List. И вместо массива чисел у вас есть список цветов. Вот почему вы используете сопоставление с образцом, чтобы обновить каждое число до соответствующего цвета. Маловероятно, что вы получите число, отличное от 1–4, но компилятор Reason будет кричать на вас за то, что вы недостаточно исчерпываете сопоставление с образцом. Поэтому важно обрабатывать все случаи, т. Е. _ => Green, даже если они маловероятны.

Однако здесь все еще что-то не так. Компилятор не понимает, что такое Green.

Error: Unbound constructor Green

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

open Types;
let num = Js.Math.floor(Js.Math.random() *. 4.0 +. 1.0);

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

initialState: () => {sequence: []},

Небольшие настройки 🏗

Прежде чем переходить к более сложным вещам, я думаю, что всегда лучше сначала избавиться от мелких вещей. state необходимо обновить для учета каждого level, в который будет играть пользователь. И когда приложение подключается, пользователь запускается с level 1. Обновите тип состояния, чтобы включить поле level с типом int.

type state = {
  sequence,
  level: int,
};

Поскольку вы работаете в Reason, это вызовет серию ошибок в вашем редакторе. Не расстраивайся! Это хорошая вещь. Компилятор просто следит за тем, чтобы вы знали, что делаете. Далее initialState необходимо обновить до начального уровня: 1.

initialState: () => {sequence: [], level: 1},

Ой, ах! Внутри редуктора есть еще одна ошибка:

Error: Some record fields are undefined: level

В действии SetSequence вы устанавливаете последовательность, но ничего больше. В мире React это может сойти с рук. Однако state в ReasonReact - неизменная запись. Это означает, что всякий раз, когда вы меняете состояние, вам нужно создавать его новую копию, включая все поля. Чтобы скопировать все поля в ReasonReact, вы используете оператор распространения, как и в JavaScript.

| SetSequence(list) => ReasonReact.Update({...state, sequence: list})

Пока я это делал, я изменил array на list, чтобы быть последовательным.

Затем в состояние нужно добавить поле active с option типом colors. Это связано с тем, что всякий раз, когда пользователь нажимает на поле или игра воспроизводит последовательность, должен быть какой-то визуальный индикатор для коробки, которая издает звук. Я называю это active. Ящик будет большую часть времени неактивен, но когда ящик издает звук, он будет active, а вскоре после этого снова перейдет в неактивное состояние. Вот почему цвет будет заключен в option, потому что в большинстве случаев это будет None. Вероятно, сейчас самое время узнать о option типах. Если да, проверьте это 👉 https://reasonml.github.io/docs/en/null-undefined-option#docsNav.

type state = {
  sequence,
  level: int,
  active: option(Types.colors),
};

Это снова привело к некоторым изменениям в вашем приложении. Теперь необходимо обновить initialState. Перейдите на initialState и обновите его до active None.

initialState: () => {sequence: [], level: 1, active: None},

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

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

type action =
  | SetSequence(sequence)
  | PlaySequence;

Теперь в вашем reducer вы должны увидеть это приятное предупреждение.

Warning 8: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
PlaySequence

Не беспокойтесь о логике этого действия. Просто пройдите ReasonReact.NoUpdate и продолжайте движение.

reducer: (action, state) =>
    switch (action) {
    | SetSequence(list) => ReasonReact.Update({...state, sequence: list})
    | PlaySequence => ReasonReact.NoUpdate
    },

Теперь, когда все это готово, давайте перейдем к JSX.

HTML, он же JSX 📄

Чтобы пользователь знал, на каком уровне он находится, давайте отрендерим уровень, который находится в состоянии, в JSX.

Что хорошо в ReasonReact, так это то, что в нем вы можете делать то же самое, что и в React. Например, вы можете деструктурировать. Здесь вы деструктурируете запись состояния и снимете поле уровня.

render: self => {
    let {level} = self.state;
    /* More stuff here */
  },

Имея в руках поле level, давайте отрендерим его пользователю.

<div className=Styles.controls>
  <div> {{j|Level: $level|j} |> ReasonReact.string} </div>
</div>

Вы заметили, что я использую {j||j}. Это синтаксис интерполяции. Затем я могу просто добавить $ перед level, и он преобразует int в string.

Попутно я обновил некоторые стили. Я добавил новый набор стилей под названием controls.

let controls = style([marginTop(`px(10))]);

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

let container =
    style([
      display(`flex),
      justifyContent(`center),
      alignItems(`center),
      minHeight(`vh(100.0)),
      flexDirection(`column), /* This is new */
    ]);

Игра во всей красе:

Вы зашли так далеко. Хорошо. Впереди еще много всего 😄.

Первый игрок готов 🎮

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

<div className=Styles.controls>
  <div> {{j|Level: $level|j} |> ReasonReact.string} </div>
  <div>
    <button onClick={_e => self.send(PlaySequence)}>
      {"Start" |> ReasonReact.string}
    </button>
  </div>
</div>

Поскольку объект события не используется, я поставил перед ним подчеркивание. Кроме того, я обернул кнопку в div. В какой-то момент справа от Start будет еще одна кнопка "Сброс".

Теперь пора понять, как игра будет воспроизводить последовательность, которая будет происходить в редукторе.

Когда редуктор получает действие PlaySequence, ему необходимо воспроизвести последовательность до точки уровня. Затем пользователь повторит последовательность и перейдет на уровень выше. Однако сейчас вы сосредоточитесь только на воспроизведении последовательности, а не на вводе пользователем.

Поскольку вы воспроизводите последовательность только до текущего уровня, вам нужно нарезать последовательность до этого уровня, затем выполнить итерацию по каждому цвету и воспроизвести соответствующий джингл. Чтобы разрезать последовательность, используйте для этого функцию Belt.List.take.

| PlaySequence =>
  let l =
    Belt.List.take(state.sequence, state.level)
    ->Belt.Option.getWithDefault([]);
  ReasonReact.NoUpdate;

Функция Belt.List.take принимает список и сколько нужно взять. Это возвращает тип option. Я использую помощник, fast pipe, чтобы передать результат функции take в качестве первого аргумента для getWithDefault. Если я получу обратно None, я верну пустой список.

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

let l =
    Belt.List.take(state.sequence, state.level)
    ->Belt.Option.getWithDefault([]);
    ReasonReact.SideEffects(
      (
        _self =>
          Belt.List.forEachWithIndex(
            l,
            (index, color) => {
              let _id =
                Js.Global.setTimeout(
                  () => Js.log({j|$color|j}),
                  index * 1000,
                );
              ();
            },
          )
      ),
    );

Нажмите кнопку «Пуск» и посмотрите, что произойдет. Вы увидите вывод случайных чисел на консоль. Число действительно представляет цветовой тип, но BuckleScript делает это более оптимизированным способом под капотом. Вот почему вы видите число, а не строку.

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

type action =
  | SetSequence(sequence)
  | PlaySequence
  | PlaySound(Types.colors);

Теперь обновите редуктор.

| PlaySound(color) => ReasonReact.NoUpdate

И примените это действие внутри обработчика ReasonReact.SideEffects.

(index, color) => {
  let _id =
    Js.Global.setTimeout(
      () => self.send(PlaySound(color)),
      index * 1000,
    );
  ();
},

По-прежнему ничего страшного не произойдет. Давайте исправим это в PlaySound.

На этот раз вы будете использовать ReasonReact.UpdateWithSideEffects. Это потому, что вы хотите установить active цвет в состоянии, издать звук, а затем удалить цвет active.

| PlaySound(color) =>
    ReasonReact.UpdateWithSideEffects(
      {...state, active: Some(color)},
      (
        _self => {
          Js.log2("color: ", color);
        }
      ),
    )

Однако есть проблема. Как нам динамически получить экземпляр звука для каждого цвета? К сожалению, Sounds[color]##play() нельзя делать так, как в JavaScript. О, радости динамических языков. Вместо этого давайте создадим помощника, который позволит вам динамически получать нужный звук.

/* Sounds.re */
open Types;
/* Sound instances here */
let map = [(Green, green), (Red, red), (Blue, blue), (Yellow, yellow)];

Вот вам и ассоциативный список. Он действует как карта или словарь и позволяет динамически извлекать звук в зависимости от типа цвета с помощью Belt.List.getAssoc. Давай попробуем.

| PlaySound(color) =>
    ReasonReact.UpdateWithSideEffects(
      {...state, active: Some(color)},
      (
        _self => {
          let sound =
            Belt.List.getAssoc(Sounds.map, color, (==))
            ->Belt.Option.getWithDefault(Sounds.green);
          sound##play();
        }
      ),
    )

Функция getAssoc возвращает параметр, поэтому я обязательно передаю его через getWithDefault. А теперь играй!

Измените level на 5 в initialState, чтобы вы могли слышать больше, чем 1 джингл.

Ах да, это работает! Ну, по крайней мере, со звуковой частью.

CSS-хакерство 👩‍🎨

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

Обновите стили box, чтобы они принимали как bgColor, так и active color как помеченные аргументы.

let box = (~bgColor: Types.colors, ~active: option(Types.colors)) =>

Также измените switch (color) на switch(bgColor)

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

<div
  className={Styles.box(~bgColor=Green, ~active)}
  onClick={_e => Sounds.green##play()}
/>

Как я использую ~active таким образом? Ну, я деструктурировал это выше.

let {level, active} = self.state;

Это позволяет мне писать ~active, что является сокращением от ~active=active.

Как я уже говорил ранее, должен быть какой-то визуальный индикатор для коробки, издающей звук. Для этого я настрою opacity коробки, чтобы она была легче других. Когда цвет active совпадает с цветом bgColor, box будет иметь opacity из 0.5, в противном случае это будет 1.0.

let opacity =
      switch (bgColor, active) {
      | (Green, Some(Green)) => opacity(0.5)
      | (Red, Some(Red)) => opacity(0.5)
      | (Blue, Some(Blue)) => opacity(0.5)
      | (Yellow, Some(Yellow)) => opacity(0.5)
      | (_, None) => opacity(1.0)
      | (_, Some(_)) => opacity(1.0)
      };
/* More stuff here */
style([bgColor, opacity, ...baseStyle]);

Играйте снова!

Вы замечаете, что это сработало, но чего-то не хватает. Цвет просто прилипает, он не сбрасывается как должен. Создайте действие! Назовите это ResetColor, и после 300ms цвет active сбрасывается на None.

type action =
  | SetSequence(sequence)
  | PlaySequence
  | PlaySound(Types.colors)
  | ResetColor;

Теперь обновите редуктор.

| ResetColor => ReasonReact.Update({...state, active: None})

Наконец, вызовите действие внутри PlaySound.

(
 self => {
   let sound =
   Belt.List.getAssoc(Sounds.map, color, (==))
   ->Belt.Option.getWithDefault(Sounds.green);
   sound##play();
   let _id = Js.Global.setTimeout(() => self.send(ResetColor), 300);
   ();
 }
),

Оно работает! Надеюсь 😅.

Боковое примечание 🗒

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

Резюме 📝

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



ПРОДОЛЖИТЬ ЧАСТЬ 5