October 23, 2018

Minesweeper in Reagent

Minesweeper

Recent updates

  • Right-click flags work now
  • Emojis for graphics
  • Right-click auto-clear

I had previously written an ASCII Minesweeper game in Clojure that you play in the terminal, but wanted to make a version that runs in the browser.

Then I realized I could just put it in a KLIPSE snippet, and we'll be able to play it right here on this page! The last code snippet below is a Reagent snippet, which renders the form on the bottom using React.

(require '[reagent.core :refer [atom]])

First we'll define some constants for our game, like the board dimensions and number of mines:

(def board-width 12)
(def board-height 12)
(def num-mines 18)

We'll use the for macro to generate a sequence of x and y coordinates, and then use shuffle to scramble them - that way when we set our mines they will be in random positions.

(defn rand-positions []
  (shuffle
    (for [i (range board-width)
          j (range board-height)]
      [i j])))

(defn set-mines [] 
    (for [i (range (* board-height board-width))]
      {:mined (< i num-mines)
       :exposed false}))

(def app-state
  (atom (into {} (map vector (rand-positions) (set-mines)))))

Now we need to implement the mine-detector, and have it do the thing where it recursively clears the squares with no surrounding mines.

(defn neighbors [[x y]]
  (filter (partial contains? @app-state) ; remove invalid squares
          (for [i [-1 0 1] j [-1 0 1] :when (or i j)]
            [(+ x i) (+ y j)])))

(defn mine-count [[x y]]
  (if (:mined (get @app-state [x y])) 1 0))

(defn mine-detector [[x y]]
  (reduce + (map (partial mine-count)
                 (neighbors [x y]))))

(defn step [grid [x y]]
  (let [square (get grid [x y])
        step-square (assoc-in grid [[x y] :exposed] true)]
    (cond
      (:exposed square) grid
      (or (:mined square) (< 0 (mine-detector [x y]))) step-square
      :else (reduce step step-square (neighbors [x y])))))

We'll use the app-state to determine the game status, and what message to display:

(defn game-status []
  (cond
    (some (fn [[_k v]] (and (:exposed v) (:mined v))) @app-state) :dead
    (every? (fn [[_k v]] (or (:exposed v) (:mined v))) @app-state) :win
    (some (fn [[_k v]] (:exposed v)) @app-state) :in-progress
    :else :new))

(defn icon []
  (case (game-status) :dead "🤯" :win "🤓" "🥺"))

Now for the UI. We'll be nice and make it impossible to blow up on the first turn.

(def mouse-over-cell (atom nil))

(defn flag! [[x y]]
  (when (not (:exposed (get @app-state [x y])))
  (swap! app-state assoc-in [[x y] :flagged] true)))

(defn unflag! [[x y]]
  (swap! app-state assoc-in [[x y] :flagged] false))

(defn step! [[x y]]
  (when (not (:flagged (get @app-state [x y])))
    (reset! app-state (step @app-state [x y]))))

(defn rect-cell [[x y]]
  [:rect
   {:width 1.85 :height 1.85
    :x -0.9 :y -0.9
    :stroke-width (if (= [x y] @mouse-over-cell) 0.1 0.08)
    :stroke "black"
    :fill (cond
            (:exposed (get @app-state [x y])) "white"
            (= [x y] @mouse-over-cell) "darkgrey"
            :else "silver")
    :on-mouse-over
    #(reset! mouse-over-cell [x y])
    :on-click
    #(when (not (:flagged (get @app-state [x y])))
       (case (game-status)
         :new
         (do
           (swap! app-state assoc [x y] {:mined false :exposed false})
           (swap! app-state step [x y]))
         :in-progress
         (swap! app-state step [x y])))
    :on-contextMenu
    #(do (.preventDefault %)
         (cond
           (:exposed (get @app-state [x y]))
           (run! step! (neighbors [x y]))
           (:flagged (get @app-state [x y]))
           (unflag! [x y])
           :else
           (flag! [x y])))}])

(defn mine-num [[x y]]
  [:text
   {:y 0.5
    :text-anchor "middle"
    :font-weight "900"
    :fill (case (mine-detector [x y])
            1 "blue"
            2 "green"
            3 "red"
            4 "purple"
            5 "brown"
            "black")
    :font-size "1.25"
    :on-contextMenu
    #(do (.preventDefault %)
         (run! step! (neighbors [x y])))}
   (mine-detector [x y])])

(defn bomb []
  [:text
   {:y 0.5
    :text-anchor "middle"
    :font-weight "900"
    :font-size "1.25"}
   "💥"])

(defn flag [[x y]]
  [:text
   {:y 0.5 :text-anchor "middle" :font-weight "900" :font-size "1.75"
    :on-contextMenu
    #(do (.preventDefault %)
         (if (:flagged (get @app-state [x y])) (unflag! [x y])))}
   "☠️"])

(defn render-board []
  (into
   [:svg.board
    {:view-box (str "0 0 " board-width " " board-height)
     :shape-rendering "auto"
     :style {:max-height "500px"}}]
   (for [[[x y] attrs] @app-state]
     [:g {:transform (str "translate(" x  "," y ") "
                          "scale (0.5)"
                          "translate(1,1)")}
      [rect-cell [x y]]
      (if (:flagged attrs) [flag [x y]])
      (when (:exposed attrs)
        (if (:mined attrs)
          [bomb]
          (if (< 0 (mine-detector [x y]))
            [mine-num [x y]])))])))

(defn minesweeper []
  [:center
   [:h1
    {:style {:font-size "50px"}
     :on-click #(reset! app-state (into {} (map vector (rand-positions) (set-mines))))}
    (icon)]
   [render-board]])
Tags: Reagent KLIPSE games Cryogen Clojure Clojurescript