October 23, 2018

Minesweeper in Reagent

Minesweeper

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! In the process we'll improve upon the code as well as make it easier to understand and even change to other games, like Sudoku or Othello.

KLIPSE makes it really easy to use Reagent. We just require it like usual:

(require '[reagent.core :as r])

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 [] 
  (loop [squares (repeat num-mines 1)]
    (if (= (count squares) (* board-width board-height))
      squares
      (recur (conj squares 0)))))

(defn init-matrix []
  (into {}
    (map vector
      (rand-positions)
      (set-mines))))

(def app-state
  (r/atom
    {:matrix (init-matrix)
     :stepped []
     :game-status :in-progress
     :message "Tread lightly..."}))

Now we need to implement the mine-detector, and have it do the thing where it recursively clears the squares with no surrounding mines. We start with a simple predicate function to find out if a given square is mined:

(defn mine? [x y]
  (= 1 (get (:matrix @app-state) [x y])))

Now the fun part - we define a function for each of the 8 directions, which will take a square and a number of mines and add 1 to it if that neighboring square has a mine in it. Each one will return the current coordinates as well as the mine count, so we can chain them together. You know what... let's do this right, by going clockwise from the top-left.

(defn top-left? [x y n]
  (if (mine? (dec x) (dec y))
    [x y (inc n)]
    [x y n]))
    
(defn top? [x y n]
  (if (mine? x (dec y))
    [x y (inc n)]
    [x y n]))
    
(defn top-right? [x y n]
  (if (mine? (inc x) (dec y))
    [x y (inc n)]
    [x y n]))
    
(defn right? [x y n]
  (if (mine? (inc x) y)
    [x y (inc n)]
    [x y n]))
    
(defn bottom-right? [x y n]
  (if (mine? (inc x) (inc y))
    [x y (inc n)]
    [x y n]))
    
(defn bottom? [x y n]
  (if (mine? x (inc y))
    [x y (inc n)]
    [x y n]))
    
(defn bottom-left? [x y n]
  (if (mine? (dec x) (inc y))
    [x y (inc n)]
    [x y n]))
    
(defn left? [x y n]
  (if (mine? (dec x) y)
    [x y (inc n)]
    [x y n]))

Now we take this neat little thing and spin it around like so:

(defn mine-detector [x y]
  (->> [x y 0]
       (apply top-left?)
       (apply top?)
       (apply top-right?)
       (apply right?)
       (apply bottom-right?)
       (apply bottom?)
       (apply bottom-left?)
       (apply left?)
       last))

When we click on a square it will activate this function:

(defn step! [x y]
  (swap! app-state assoc-in [:stepped]
                  (conj (:stepped @app-state) [x y])))

In the case that mine-detector returns 0, we want to auto-step all around it:

(defn clear-squares [x y] 
  (swap! app-state assoc-in [:stepped]
         (conj (:stepped @app-state) 
               [(dec x) (dec y)]
               [x (dec y)]
               [x (inc y)]
               [(dec x) y]
               [(inc x) y] 
               [(inc x) (dec y)]
               [(inc x) (inc y)]
               [(dec x) (inc y)])))

A side effect of this is that our :stepped vector ends up with duplicates and invalid squares in it (due to the edges). This isn't a problem, but we need to filter them out so we can use the count to see if we've won:

(defn valid-square? [[x y]]
  (and (<= 0 x (dec board-width))
             (<= 0 y (dec board-width))))

(defn filter-squares [[x y]]
      (filter valid-square? (distinct (:stepped @app-state))))

(defn win? []
  (= num-mines
    (-  (* board-height board-width)
         (count (filter-squares (:stepped @app-state))))))

Now here are our rendering functions:

(defn blank [x y]
  [:rect
   {:width 0.9
    :height 0.9
    :fill "grey"
    :x (+ 0.05 x)
    :y (+ 0.05 y)
    :on-click
    (fn blank-click [e]
      (when (= (:game-status @app-state) :in-progress)
        (step! x y)
        (if (win?)
             (do (swap! app-state assoc :game-status :win)
                     (swap! app-state assoc :message "Congratulations!")))
        (if (= 1 (get (:matrix @app-state) [x y]))
          (do (swap! app-state assoc :game-status :dead)
            (swap! app-state assoc :message "Fuck. You blew up."))
          (if (zero? (mine-detector x y))
            (clear-squares x y)))))}])

(defn rect-cell
  [x y]
  [:rect.cell
   {:x (+ 0.05 x) :width 0.9
    :y (+ 0.05 y) :height 0.9
    :fill "white"
    :stroke-width 0.025
    :stroke "black"}])

(defn text-cell [x y]
  [:text
   {:x (+ 0.5 x) :width 1
    :y (+ 0.72 y) :height 1
    :text-anchor "middle"
    :font-size 0.6}
   (if (zero? (mine-detector x y))
     ""
   (str (mine-detector x y)))])

(defn cross [i j]
  [:g {:stroke "darkred"
       :stroke-width 0.4
       :stroke-linecap "round"
       :transform
       (str "translate(" (+ 0.5 i) "," (+ 0.5 j) ") "
            "scale(0.3)")}
   [:line {:x1 -1 :y1 -1 :x2 1 :y2 1}]
   [:line {:x1 1 :y1 -1 :x2 -1 :y2 1}]])

(defn render-board []
  (into
    [:svg.board
     {:view-box (str "0 0 " board-width " " board-height)
      :shape-rendering "auto"
      :style {:max-height "500px"}}]
    (for [i (range board-width)
          j (range board-height)]
      [:g
       [rect-cell i j]
       (if (some #{[i j]} (:stepped @app-state))
         (if (= 1 (get (:matrix @app-state) [i j]))
           [cross i j]
           [text-cell i j])      
         [blank i j])])))

(defn mine []
  [:center
   [:h1 (:message @app-state)]
   [:button
    {:on-click
     (fn new-game-click [e]
       (swap! app-state assoc
              :matrix (init-matrix)
              :message "Welcome back"
              :game-status :in-progress
              :stepped []))}
    "Reset"]
   [:div [render-board]]])

[mine]
Tags: Reagent KLIPSE games Cryogen Clojure Clojurescript