Пишем простую игру с Om и ClojureScript

По ходу разбирательства с Om, написал небольшую игру. Думаю, полезно будет оформить это в виде поста — русскоязычных ресурсов по Clojure и ClojureScript не так уж и много, а по Om наверно и вообще нет.

В качестве игры нужно было что-то очень простое, просто чтобы пощупать Om. В итоге сделал вариант Concentration, который мне был известен под дназванием Memo.

Для начала определимся с правилами игры. Есть два игрока и колода из 32-х карт. На каждой карте находится изображение. Каждая карта встречается в колоде по 2 раза. Итого — 16 уникальных карт. Перед началом игры карты кладутся рубашкой вверх, в случайном порядке. Каждый игрок по очереди переворачивает две карты, если картинка на них совпадает — забирает себе, нет — переворачивает обратно и оставляет на столе. Побеждает тот, кто собрал больше пар, разумеется.

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

(defn generate-cards []
  (let [cards (mapcat (partial repeat 2) (range 16))]
    (shuffle cards)))

Следующим делом — глобальное состояние игры.

(def app-state 
  (atom {:deck (mapv #(hash-map :kind % :face-up false :on-deck true)
                     (generate-cards))
         :players [{:name "Igor" :score 0}
                   {:name "Lina" :score 0}]
         :current-player 0
         :deck-disabled false}))

Колоду карт здесь немного преобразуем — у карты есть “вид” (просто число, две карты с одинаковым видом — совпадают), и некоторое состояние: находится ли карта в игровой колоде и перевернута ли рубашкой вверх.

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

Самое главное у нас есть, осталось только его отрендерить и навесить немного логики. Рендерить с Om — одно удовольствие. Просто описываем как выглядит каждый из компонентов игры (карта, колода, игра в целом) и склеиваем всё вместе.

Карта — обычный div с различными классами, в зависимости от состояния. На клик по карте вешаем обработчик, в нём и будет всё мясо.

(defn card-view [card owner]
  (reify
    om/IRender
    (render [_]
      (let [class (str "card" (when (:face-up card)
                                (str " faceup kind-" (:kind card))))
            class (str class (when-not (:on-deck card) " empty"))
            props #js {:className class
                       :onClick (when (:on-deck card) #(make-move % card))}]
      (dom/div props)))))

Выводим колоду рядами по 8 карт в каждом. Колода у нас хранится как одномерный вектор (что удобно для манипуляций) и разбить её как нам нужно мы можем прямо при рендеринге. Если захочется поменять формат — нужно будет просто изменить функцию рендеринга.

(defn deck-view [deck owner]
  (reify
    om/IRender
    (render [_]
      (apply dom/div #js {:className "deck"}
        (let [deck (partition 8 deck)]
          (map #(apply dom/div
                        #js {:className "row"}
                        (om/build-all card-view (nth deck %)))
                (range (count deck))))))))

View самой игры это текущий игрок, счет и колода. Всё прямолинейно.

(defn game-view [app owner]
  (reify
    om/IRender
    (render [_]
      (dom/div nil
        (dom/h1 nil (str "Current player: " (:name (current-player @app))))
        (apply dom/div nil
               (map #(dom/h2 nil (:name %) ": " (:score %)) (:players app)))
        (om/build deck-view (:deck app))))))

Монтируем игру к нужному элементу в html.

(om/root app-state game-view (. js/document (getElementById "game")))

С интерфейсом — всё, осталось только его оживить, пишем обработчик клика на карту.

(defn face-up-cards [{deck :deck}]
  (filter #(= (:face-up %) true) deck))

(defn make-move [_ card]
  (when-not (or (:deck-disabled @app-state) (:face-up @card))
    (om/transact! card #(assoc % :face-up true))
    (let [face-up (face-up-cards @app-state)
          next-player-move? (= (count face-up) 2)
          cards-equal? (if next-player-move?
                         (= (:kind (nth face-up 0)) (:kind (nth face-up 1)))
                         false)]
      (when next-player-move?
        (wait-and-go-on app-state cards-equal?)))))

Обработчик принимает event (здесь его игнорируем) и карту, по которой кликнул пользователь. Первым делом проверяем можно ли совершить ход и переворачиваем карту картинкой вверх при помощи (om/transact card #(assoc % :face-up true)). Затем проверяем закончил ли текущий игрок свой ход (перевернул две карты), если да — ждем немного, чтобы игроки успели увидеть и запомнить карты и передаем ход следующему игроку.

(defn wait-and-go-on [app-state win?]
  (swap! app-state #(assoc % :deck-disabled true))
  (go
   (<! (timeout (if win? 500 1500)))
   (swap! app-state #(assoc % :deck-disabled false))
   (when win? (swap! app-state inc-score))
   (swap! app-state switch-to-next-player)))

Отключаем взаимодействие с игрой (помните проверку на :deck-disabled в начале make-move?). Ждем либо пол, либо полторы секунды, в зависимости от того выбрал ли игрок 2 одинаковые карты. В случае когда карты раные задержка больше, чтобы дать игрокам возможность запомнить месторасположение карт.

Задержку реализуем с помощью core.async и специального типа канала — timeout. Это намного удобней, чем .setTimeout c callback’ом — поток выполнения выглядит как линейный, проще понять что происходит.

Также увеличиваем игроку количество очков, если он угадал 2 карты и убираем эти карты с доски.

(defn inc-score [app-state]
  (let [new-state (update-in app-state
                             [:players (:current-player app-state) :score]
                             + 2)]
    (update-in new-state [:deck]
               (fn [deck] (mapv #(if (:face-up %)
                                   (assoc % :on-deck false) %) deck)))))

Последний штрих — переход к следующему игроку. Переворачиваем все карты рубашкой вниз и изменяем :current-player соответственно.

(defn switch-to-next-player [app-state]
  (let [new-deck (mapv #(assoc % :face-up false) (:deck app-state))
        next-player (inc (:current-player app-state))
        next-player (rem next-player (count (:players app-state)))
        new-state (assoc app-state
                         :deck new-deck
                         :current-player next-player)]
    new-state))

Весь код есть на гитхабе. Поиграть можно здесь.

Поругать меня можно в твиттере, комментариев пока нет ;)