Arantium Maestum

プログラミング、囲碁、読書の話題

Reactで書かれた囲碁ボードをReagentで作ってみる(Logic編)

こういう記事を発見した。

React beginner tutorial: implementing the board game Go | Chris LaRose, Software Developer

JSとReactで簡単な囲碁のボードを作成する、というもの。コードとしても非常に簡単だったのでClojurescriptとReagentで書いてみた。 機能や大まかなロジックはReact版にある程度忠実に作ってある。が、Clojurescriptなのでほぼすべてが副作用なしの純粋関数。

囲碁のロジックをlogic.cljsファイルに書いてみる。

まずはデータ構造の定義:

(defn new-board [size]
  (zipmap
    (for [i (range size)
          j (range size)]
      [i j])
    (repeat :empty)))

(defn new-game-state [size]
  {:current-color :black
   :size size 
   :board (new-board size)
   :last-move-passed false
   :in-atari false
   :attempted-suicide false
   :game-over false})

盤上をベクトルのベクトルで表現してもいいのだが、各点を[i j]というベクトルのキーを使って表したマップが一番簡単だと思う。これがboardデータ。

ゲーム関連で直接盤上に関係しない、あるいは盤上から算出するよりフラグとして管理してしまった方がいい情報も加えてgame-stateデータを定義する。

後ほどよりClojureっぽく一から書き直してみたいと考えているのだが、その場合はここはclojure.specでちゃんと定義しよう。

次はパスに関する状態変化を定義:

(defn switch-player [game-state]
  (update game-state 
         :current-color
         #(if (= :black %) :white :black)))

(defn pass [game-state]
  (-> game-state
      (#(if (:last-move-passed %) 
          (assoc % :game-over true)
          %))
      (assoc :last-move-passed true)
      switch-player))

二回連続パスでゲーム終了なのだが、元のReactチュートリアルだとconsoleにゲームオーバーと出力するだけ。唐突にここでUIっぽいコードが出現するのもなんなので、フラグの一つをセットするだけにとどめる。 前回パスがあったかを確認する:last-move-passedフラグをtrueにし、次のプレーヤーを変更した状態のgame-stateを返す。

(defn valid-coord [game-state coord]
  (and
    (every? #(> % -1) coord)
    (every? #(< % (:size game-state)) coord)))

(defn get-adjacent [game-state [i j]]
  (->> [[i (dec j)]
        [i (inc j)]
        [(dec i) j]
        [(inc i) j]]
       (filter #(valid-coord game-state %))))

ヘルパー関数。ある点の周りにある点を返す。囲碁のルール上周囲の点の数は2、3、4が可能。

ある点に置かれた石と繋がっているすべての同色の石のグループと、そのグループの駄目(空いた点)の数を返すget-group関数の定義:

(defn get-group [move game-state]
  (let [color (get-in game-state [:board move])]
    (if-not (= color :empty)
      (loop [visited #{}
             visited-list []
             queue [move]
             liberties 0]
        (cond 
          (empty? queue)
          {:liberties liberties
           :stones visited-list}

          (visited (first queue))
          (recur visited visited-list (rest queue) liberties)

          :else
          (let [current   (first queue)
                neighbors (get-adjacent game-state current)
                empties   (filter #(= :empty (get-in game-state [:board %]))
                                  neighbors)
                sames     (filter #(= color (get-in game-state [:board %]))
                                  neighbors)]
            (recur 
              (conj visited current)
              (conj visited-list current)
              (into (rest queue) sames)
              (+ liberties (count empties)))))))))

挙動にあまり影響はないが、元のReactチュートリアルのコードの時点でバグがある。同じグループの二つの石の駄目が重複していると二重に数えてしまうので「当たり判定」がたまに間違っている。石を取る・取らないの段階ではどう数えても駄目は0なので、そこまで大きな問題ではないが・・・ 直そうと思うならlibertiesもsetにしてしまって最後に数えればいい。

盤上から石を取り除くヘルパー関数:

(defn remove-stone [game-state coord]
  (assoc-in game-state [:board coord] :empty))

(defn remove-stones [game-state coords]
  (reduce remove-stone game-state coords))

reduceが決まるとやっぱり気持ちいいな。

最後に現在のゲーム状態と次の一手の点を指定して、次のゲーム状態を返す関数play-moveを定義:

(defn play-move [game-state move]
  (if-not (= :empty (get-in game-state [:board move]))
    game-state
    (let [color (:current-color game-state)
          new-state (-> game-state
                        (assoc-in [:board move] color)
                        switch-player)
          neighbors (get-adjacent game-state move)
          n-others  (filter #(and (not= :empty (get-in new-state [:board %]))
                                  (not= color (get-in new-state [:board %]))) 
                            neighbors)
          n-groups  (map #(get-group % new-state) n-others)
          captured  (filter #(zero? (:liberties %)) n-groups)
          atari     (some #(= 1 (:liberties %)) n-groups)]
      (if (and (empty? captured)
               (-> move (get-group new-state) :liberties zero?))
       (-> game-state
         (assoc :attempted-suicide true))
       (-> new-state
         (remove-stones (->> captured (map :stones) (apply concat)))
         (assoc :in-atari atari)
         (assoc :attempted-suicide false)
         (assoc :last-move-passed false))))))

すでに石が打ってある点に打つのは無効。

打った点の周りにある敵の石とそれらの石が属するグループの駄目の状況をチェックし、もしそれらのグループの駄目が0になっていたら「取られている」と判定。

相手の石を取らず、逆に自分の石が取られてしまう(自分の石の駄目が0になる)手は無効。自殺手だというフラグをtrueにしてそれ以外は元の状態のまま返す。

そういう「着手禁止点」以外の場合、取られるべき石を取り除き、この手によってアタリになる相手の石があればアタリ判定フラグをtrueにし、自殺手とパスフラグをfalseにし、その手を盤上に置いて、次のプレーヤーの色を反転させたgame-stateを返す。

次はRegentでのUIだが長くなったので次回に。

コードはここ:

github.com