Saturday, February 20, 2016

Cartagena, part 4

Welcome back for the beginning of the conclusion to the Cartagena series!  In this installment, I'll be discussing the start of the web implementation for the game.

When we last spoke, I had finished a console implementation of the game.  The ultimate goal is to be able to play the game with other people across the Internet.  However, the next incremental step was to enable gameplay via the browser in a single session.  Luckily, this was handily done using Reagent and Figwheel.

Quick sidebar: I decided to house the entire effort in a single git repository with two separate projects: one clojure project that holds the game engine (as well as the console game) and another reagent project for the web UI.

I started the web project simply enough.  From my repository's root directory (/cartagena), I typed the following:

lein new figwheel cartagena-web -- --reagent

This magical incantation created an immediately runnable web project. To prove it, I changed to the newly created directory and launched the site.

cd cartagena-web
lein figwheel

Opening a browser to http://localhost:3449 proved that something great was happening. All that's left to do is fill in the blanks with the game board, player display, pieces, cards, colors, and other bits of logic...

OK, maybe that's more work than simply filling in blanks. :-)

Luckily, the server code was done (in principle).  Because I had left some code in the server to bootstrap a game for me, I didn't immediately have to implement a bunch of stuff to gather player information.  Instead, I could focus on creating routes that would call the server as necessary (such as when a game starts, when a player takes and action, etc).

I set up a ring server to handle requests coming from the web client.  Note that everything is a POST from the client (although I had a default GET in there for sanity's sake).

(ns cartagena.server
    [compojure.core :refer :all]
    [compojure.route :as route]
    [ring.middleware.cors :as cors]
    [ring.middleware.transit :as trans]
    [ring.util.response :as response]
    [cartagena.core :as engine]))

(def default-players [{:name "tanya" :color :orange} {:name "rusty" :color :black}])
(defroutes app-routes
           (GET "/" [] "<h1>Hello World</h1>")

           (POST "/update-active-player" req
             (let [{:keys [actions-remaining current-player player-order]} (:body req)]
               (response/response (engine/update-current-player actions-remaining current-player player-order))))

           (POST "/play-card" req
             (let [{:keys [player icon from-space board discard-pile]} (:body req)]
               (response/response (engine/play-card player icon from-space board discard-pile))))

           (POST "/move-back" req
             (let [{:keys [player from-space board draw-pile discard-pile]} (:body req)]
               (response/response (engine/move-back player from-space board draw-pile discard-pile))))

           (POST "/new-game" req
             (if-let [players (get (:body req) :players)]
               (response/response (engine/new-game! players))
               (response/response (engine/new-game! default-players))))

           (route/not-found "<h1>Page not found</h1>"))

(def app
  (-> app-routes
      (cors/wrap-cors :access-control-allow-origin [#"http://localhost:3449"]
                      :access-control-allow-methods [:get :put :post :delete])
      (trans/wrap-transit-response {:encoding :json :keywords? true :opts {}})
      (trans/wrap-transit-body {:keywords? true :encoding :json, :opts {}})))

The results of all of the routes are the game states that the game engine provides, wrapped in transit for good measure.  This actually *almost worked* out of the box.  The most recent versions of all of the libraries required some poking and prodding to get interoperability working, but in the end turned out to be something that I could figure out without too much violence/swearing.

At this point, I've got transit moving from the server to the client.  It was time to start building the web interface.  The figwheel template had some scaffolding that allowed me to build the first parts of the UI without thinking too hard about them.

Ha!  Fooled you!  You thought I didn't think about it very much.  As it turns out, I *did* think about how the board should be laid out.  I went so far as to figure out what the (x, y) coordinates of each space should be, as well as how the space should be designated (with images representing the space's icon) and where the player pieces would be.  I wound up with something like the following:

The space in the upper left is the jail, the lower right is the ship, and below would be the player area.  Board spaces themselves would have a picture as well as three squares where pirates would sit.  Pretty nifty, huh?  NOW all that's left to do is fill in the blanks, right?

Yeah... not quite, still.  I wanted the board images to be dynamic (because the board that's returned from the server is randomized), so I started with just the static pieces.

(defn static-board []
  [;; board
    {:x 0
     :y 0
     :width (to-scale 500)
     :height (to-scale 300)
     :stroke "black"
     :stroke-width "0.5"
     :fill "burlywood"}]
   ;; jail
    {:x 0
     :y 0
     :width (to-scale 50)
     :height (to-scale 90)
     :stroke "black"
     :fill "darkgray"}]
    {:dangerouslySetInnerHTML {:__html (str "<image xlink:href=\"img/jail.png\" x=0 y=0 width=\"" (to-scale 30) "\" height=\"" (to-scale 30) "\" />")}}]
   ;; ship
    {:x (to-scale 410)
     :y (to-scale 240)
     :width (to-scale 80)
     :height (to-scale 60)
     :stroke "black"
     :fill "sienna"}]
    {:dangerouslySetInnerHTML {:__html (str "<image xlink:href=\"img/ship.png\" x=\"" (to-scale 410) "\" y=\"" (to-scale 240) "\" width=\"" (to-scale 30) "\" height=\"" (to-scale 30) "\" />")}}]

(defn main-view []
   [:h1 "CARTAGENA"]
     {:class "btn btn-primary"
      :on-click (fn button-click [e]
     "New Game"]]

    {:class "row"
     :style {:float "left" :margin-left 10 :margin-right 10}}
     {:class "col-md-5"}
     (-> [:svg
          {:view-box (str "0 0 " (to-scale 501) " " (to-scale 301))
           :width (to-scale 501)
           :height (to-scale 301)}]
         (into (static-board))

(defn ^:export main []
  (when-let [app (. js/document (getElementById "app"))]
    (reagent/render-component [main-view] app)))


(defn on-js-reload []

Notice a couple of things.  First of all, I've got a little helper called to-scale.  I found that bumping the resolution up or down was something I wanted to be able to do easily, and that function handles scaling.  Secondly, even though React (which is what is underneath Reagent) is supposed to handle svg images gracefully, it does not.  My first pass at this left me pretty discouraged, until Tim came to the rescue yet again and tipped me off to the :dangerouslySetInnerHTML bit.

Another quick aside: for a really great and far more in-depth view of how quickly you can get a game up and running in a browser using Reagent and Figwheel, have a look at Tim's Tetris implementation and write up.

The result of this bit of code looks something like:

At this point, I needed to start the implementation for painting the more dynamic board spaces.  I quickly realized that the position of the spaces wasn't going to vary any more than the jail or ship where, so I created a simple vector for those coordinates.

(def piece-positions
   ;; jail
   {:x 0 :y 0}
   ;; row 1, left to right
   {:x 50 :y 60}
   {:x 90 :y 60}
   {:x 130 :y 60}
   {:x 170 :y 60}
   {:x 210 :y 60}
   {:x 250 :y 60}
   {:x 290 :y 60}
   {:x 330 :y 60}
   {:x 370 :y 60}
   {:x 410 :y 60}
   {:x 450 :y 60}
   ;; transition 1
   {:x 450 :y 90}
   ;; row 2, right to left
   {:x 450 :y 120}
   {:x 410 :y 120}
   {:x 370 :y 120}
   {:x 330 :y 120}
   {:x 290 :y 120}
   {:x 250 :y 120}
   {:x 210 :y 120}
   {:x 170 :y 120}
   {:x 130 :y 120}
   {:x 90 :y 120}
   {:x 50 :y 120}
   ;; transition 2
   {:x 50 :y 150}
   ;; row 3, left to right
   {:x 50 :y 180}
   {:x 90 :y 180}
   {:x 130 :y 180}
   {:x 170 :y 180}
   {:x 210 :y 180}
   {:x 250 :y 180}
   {:x 290 :y 180}
   {:x 330 :y 180}
   {:x 370 :y 180}
   {:x 410 :y 180}
   {:x 450 :y 180}
   ;; transition 3
   {:x 450 :y 210}
   ;; ship
   {:x 400 :y 240}])

Why a vector, you might ask?  As it turns out, the board pieces are returned from the server as a vector.  Iterating through it while using its index to find the space's x and y was really convenient.  And if you haven't noticed by now, I'm ALL about convenience...

Adding the code for a normal space display item and then adding the code to display normal spaces:

(def icon-images {:jail "img/jail.png"
                  :ship "img/ship.png"
                  :bottle "img/bottle.png"
                  :gun "img/gun.jpg"
                  :hat "img/hat.png"
                  :key "img/key.png"
                  :knife "img/knife.png"
                  :skull "img/skull.png"})

(defn normal-space [x y]
   {:x (to-scale x)
    :y (to-scale y)
    :width (to-scale 40)
    :height (to-scale 30)
    :stroke "black"
    :stroke-width "0.5"
    :fill "lightgray"}])

(defn space-image [x y icon]
    {:__html (str "<image xlink:href=\"" (icon icon-images) "\" x=\"" (to-scale x) "\" y=\"" (to-scale y) "\" width=\"" (to-scale 30) "\" height=\"" (to-scale 30) "\" />")}}])

(defn normal-spaces []
  (apply concat
         (for [i (range 1 37)]
           (when-let [space-data (get-in @app-state [:board i])]
             (let [position (get piece-positions i)
                   x (:x position)
                   y (:y position)
                   space (normal-space x y)
                   image (space-image x y (:icon space-data))]
               [space image])))))

For the code above, realize that the @app_state atom is populated by a POST to get the game state (either via New Game or another action).  Hence, the :board is the vector of spaces and their states returned from the server.

All that's left to do is add the call to normal-spaces to the main display function, and you wind up with a display like:

That's a lot to take in all at once, so let's pause here for reflection.  If you haven't read Tim's Tetris post, you definitely should.  Next time, we'll cover getting some pirates on the board, figuring out the current player display, and actually playing the game.

Or if you're in too big a hurry, head over to github to have a look at the final product.  :-)

No comments:

Post a Comment