По ходу разбирательства с 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))
Весь код есть на гитхабе. Поиграть можно здесь.
Поругать меня можно в твиттере, комментариев пока нет ;)