Clojure Script と core.async で、ブロック崩し
core.asyncでメインループを手に入れる #Clojure – Qiita を見ておもしろそうだったので、以前から気になっていたcore.asyncでブロック崩しを作ってみました。
まずはイベントドリブンで作成
shadow-cljsでプロジェクトを作成
$ npx create-cljs-project breackout
srcフォルダ構成を変更して、shadow-cljs.ednも変更
;; shadow-cljs configuration
{:source-paths
["src"]
:dependencies
[]
:dev-http {8080 "public"}
:builds
{:app
{:target :browser
:modules {:main {:init-fn breakout.core/init}}}}}
ブラウザでcanvasを使ってゲームを作るのは初めてなので、純粋な JavaScript を使ったブロック崩しゲーム – ゲーム開発 | MDN k(mozilla.org) を参考に。/public/index.htmlを作成。
全部で step 10 までだけど、ひとまず完成となっている step 8 までの内容を参考に、以下のように作成してみました。
(ns breakout.core)
(def canvas (.getElementById js/document "myCanvas"))
(def ctx (.getContext canvas "2d"))
;;定数
(def left-key 37)
(def right-key 39)
(def ball-radius 10)
(def paddle-height 10)
(def paddle-width 75)
(def paddle-y (- canvas.height paddle-height))
(def paddle-speed 7)
(def brick-row-count 3)
(def brick-column-count 5)
(def brick-width 75)
(def brick-height 20)
(def brick-padding 10)
(def brick-offset-top 30)
(def brick-offset-left 30)
;; 状態の保持
(def interval-id (atom nil))
(def game-state (atom {}))
;; キーイベント
(defn keydown [state event]
(condp = (.-keyCode event)
left-key (assoc state :left-key-pressed true)
right-key (assoc state :right-key-pressed true)
state))
(defn keyup [state event]
(condp = (.-keyCode event)
left-key (assoc state :left-key-pressed false)
right-key (assoc state :right-key-pressed false)
state))
(.addEventListener js/document "keydown" #(swap! game-state merge (keydown @game-state %)) false)
(.addEventListener js/document "keyup" #(swap! game-state merge (keyup @game-state %)) false)
;; 描画
(defn draw-ball [ball-x ball-y]
(.beginPath ctx)
(.arc ctx ball-x ball-y ball-radius 0 (* Math/PI 2))
(set! (.-fillStyle ctx) "#0095DD")
(.fill ctx)
(.closePath ctx))
(defn draw-rect [x y width height color]
(.beginPath ctx)
(.rect ctx x y width height)
(set! (.-fillStyle ctx) color)
(.fill ctx)
(.closePath ctx))
(defn draw-paddle [paddle-x]
(draw-rect paddle-x paddle-y paddle-width paddle-height "#0095DD"))
(defn draw-brick [x y]
(draw-rect x y brick-width brick-height "#0095DD"))
(defn draw-score [score]
(set! (.-font ctx) "16px Arial")
(set! (.-fillStyle ctx) "#0095DD")
(.fillText ctx (str "Score: " score) 8 20))
(defn clear-screen []
(.clearRect ctx 0 0 canvas.width canvas.height))
;;
(defn collision? [brick-x brick-y brick-width brick-height ball-x ball-y]
(and (<= brick-x ball-x (+ brick-x brick-width))
(<= brick-y ball-y (+ brick-y brick-height))))
;;
(defn setup
"最初に一回だけ呼ばれ、初期の状態を返す"
[]
{:ball-x (/ canvas.width 2)
:ball-y (- canvas.height 30)
:ball-dx 2
:ball-dy -2
:paddle-x (/ (- canvas.width paddle-width) 2)
:bricks (for [x (range brick-column-count)
y (range brick-row-count)]
{:x (+ (* x (+ brick-width brick-padding))
brick-offset-left)
:y (+ (* y (+ brick-height brick-padding))
brick-offset-top)})
:right-key-pressed false
:left-key-pressed false
:gameover false
:score 0})
(defn update-state
"引数で状態を受け取って、更新した状態を返す"
[state]
(let [update-ball-position (fn [{:keys [ball-dx ball-dy] :as state}]
(-> state
(update :ball-x + ball-dx)
(update :ball-y + ball-dy)))
bounce-wall-or-paddle (fn [{:keys [ball-x ball-y paddle-x] :as state}]
(-> state
;;top wall
(#(if (< ball-y ball-radius)
(update % :ball-dy -)
%))
;;bottom wall or paddle
(#(if (< (- canvas.height ball-radius) (:ball-y %))
(if (< paddle-x ball-x (+ paddle-x paddle-width))
(update % :ball-dy -)
(assoc % :gameover true))
%))
;;left or right wall
(#(if (or (< ball-x ball-radius)
(< (- canvas.width ball-radius) (:ball-x %)))
(update % :ball-dx -)
%))))
update-paddle-position (fn [{:keys [paddle-x right-key-pressed left-key-pressed] :as state}]
(-> state
(#(if right-key-pressed
(assoc % :paddle-x (min (+ paddle-x paddle-speed)
(- canvas.width paddle-width)))
%))
(#(if left-key-pressed
(assoc % :paddle-x (max (- paddle-x paddle-speed)
0))
%))))
collision-brick (fn [{:keys [bricks ball-x ball-y] :as state}]
(let [surviving-bricks (remove (fn [{:keys [x y]}]
(collision? x y brick-width brick-height ball-x ball-y))
bricks)]
(if (not= bricks surviving-bricks)
(-> state
(assoc :bricks surviving-bricks)
(update :ball-dy -)
(update :score inc))
state)))]
(-> state
(update-ball-position)
(update-paddle-position)
(bounce-wall-or-paddle)
(collision-brick))))
(defn draw-state
"引数で受け取った状態をもとに描画"
[state]
(clear-screen)
(let [{:keys [ball-x ball-y paddle-x gameover bricks score]} state]
(cond
gameover (do
(js/alert "game over")
(js/document.location.reload)
(js/clearInterval @interval-id))
(empty? bricks) (do
(js/alert "you win. congratuaion!")
(js/document.location.reload)
(js/clearInterval @interval-id))
:else (do
(draw-ball ball-x ball-y)
(draw-paddle paddle-x)
(doseq [{:keys [x y]} bricks]
(draw-brick x y))
(draw-score score)))))
;;
(defn game-loop []
(let [new-state (update-state @game-state)]
(draw-state new-state)
(swap! game-state merge new-state)))
(defn init []
(reset! game-state merge (setup))
(reset! interval-id (js/setInterval game-loop 10)))
Clojure版のProcessing である Quil にならって、初期状態を設定して返す「setup」、引数で受け取った状態を変更して返す「update-state」、引数で受け取った状態をもとに描画する「draw-state」に処理を分けました。
$ npx shadow-cljs watch app
で、動作確認OK。
core.asyncを使ってみる
ここから、core.async に置き換えていきます。
shadow-cljs.edn に core.async の依存を追加。
;shadow-cljs.edn
:dependencies
[[org.clojure/core.async "1.6.681"]] ;変更
core.cljs に、core.asyncのrequireを追加。
(ns breakout.core
(:require [cljs.core.async :refer [chan timeout put! alts! go-loop]] ;追加
[goog.events :as gevent]))
まずは、init の中に go-loop を作り、その中で setup, update-state, draw-state を回します。ウェイトを入れるためにtimeouを使います。
ボールが動くようになりました。
(defn init []
(go-loop [state (setup)]
(let [new-state (update-state state)]
(draw-state new-state)
(<! (timeout 10))
(recur new-state))))
次に、キー操作でパドルが動くように変更します。チャネルを2つ作成し、keydown, keyupイベント発生時の情報をそこに入れます。
(def keydown-ch
(let [c (chan)]
(gevent/listen js/document "keydown" #(put! c %))
c))
(def keyup-ch
(let [c (chan)]
(gevent/listen js/document "keyup" #(put! c %))
c))
go-loopの中でalts!を使って、keydownとkeyupのチャンネルとtimeout のどれかから情報を受け取って、それをもとに状態を更新する処理を追加して、キー操作でパドルが動くようになりました。
(defn init []
(go-loop [state (setup)]
(let [[event ch] (alts! [keydown-ch keyup-ch (timeout 10)])
new-state (-> state
(#(condp = ch
keydown-ch (keydown % event)
keyup-ch (keyup % event)
%))
(update-state))]
(draw-state new-state)
(recur new-state))))
まとめ
とりあえず動くようにはなったけど、課題としては
- go-loop の中で呼んでいる関数内で game over などの alert を出しているので、モーダルダイアログが連続で表示される。
- チャネルに transducer を設定したり、pipe を使ったりして、もっとcore.asyncっぽく作れないか?
ディスカッション
コメント一覧
まだ、コメントがありません