b174875844e2

another blog post
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Sun, 08 Jul 2012 09:29:05 -0400
parents 7975761ec4f3
children 44930a6618e8
branches/tags (none)
files content/blog/2012/07/caves-of-clojure-02.html media/images/blog/2012/07/caves-02-01.png media/images/blog/2012/07/caves-02-02.png media/images/blog/2012/07/caves-02-03.png

Changes

--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/content/blog/2012/07/caves-of-clojure-02.html	Sun Jul 08 09:29:05 2012 -0400
@@ -0,0 +1,582 @@
+    {% extends "_post.html" %}
+
+    {% hyde
+        title: "The Caves of Clojure: Part 2"
+        snip: "Dealing with state."
+        created: 2012-07-08 8:00:00
+        flattr: true
+    %}
+
+{% block article %}
+
+This post is part of an ongoing series.  If you haven't already done so, you
+should probably start at [the beginning][].
+
+This entry corresponds to [post two in Trystan's tutorial][trystan-tut].
+
+If you want to follow along, the code for the series is [on Bitbucket][bb] and
+[on GitHub][gh].  Update to the `entry-02` tag to see the code as it stands
+after this post.
+
+[the beginning]: /blog/2012/07/caves-of-clojure-01/
+[trystan-tut]: http://trystans.blogspot.com/2011/08/roguelike-tutorial-02-input-output.html
+[bb]: http://bitbucket.org/sjl/caves/
+[gh]: http://github.com/sjl/caves/
+
+[TOC]
+
+Summary
+-------
+
+In Trystan's second post he introduces the concept of the game loop, as well as
+what he calls "screens": objects that handle drawing the interface and
+processing user input.
+
+I could try to port his design directly over to Clojure, but instead I wanted to
+step back and see if I could find a way to make things more functional.
+
+I think I've figured out a way to make it work, so I'm going to implement that.
+
+State
+-----
+
+When I first started thinking about how to model the game's state and the main
+game loop I had lots of crazy ideas bouncing around in my head.  Most of them
+involved an immutable world (immutable! good!) and agents representing Trystan's
+"screens".
+
+The more I thought about it, though, the more it looked like the agents would
+wind up being a tangled mess.  I put down the keyboard, took a shower, had
+dinner with a friend, and let the problem roll around in my head for a bit.
+
+At some point the following train of thought happened somewhere in my brain:
+
+* The immutable "state" that I keep should contain *everything* needed to render
+  the game on the user's screen.
+* I originally thought I'd need to track the "world" as the state, but the world
+  isn't enough!
+* In addition to the world, the user interface (menus, stats, etc) is also
+  rendered.
+
+So instead of keeping a "world" as my state, I'm going to keep a "game".
+
+The User Interface
+------------------
+
+If I'm going to keep track of the user interface in the "game" state, I need
+a way to represent it.
+
+There are two halves to the user interface: "input" and "output".  First let's
+consider output.
+
+Trystan's screens are objects that handle their own drawing.  At any given time
+there's one "active" screen object, which gets asked to draw itself.  If you
+peek ahead in his tutorial you'll see that he ends up introducing a "subscreen"
+concept to get screens layered on top of each other.
+
+Instead of having a single active screen with subscreens, I decided to keep
+a flat vector of screens.  The last screen in the vector is the "active" one,
+and is effectively a subscreen of the one that comes before it.
+
+At this point I'm going to switch terms.  Unfortunately Lanterna uses the word
+"screen" to mean something and I didn't want to try to keep two separate
+concepts under the same word, so in the code I called screens "UIs", and from
+now on I'll be using that word.
+
+So what *is* a UI in the code?  Well, it's basically just a map with a `:kind`
+mapping specifying what kind of UI it is!  It also might have some extra keys
+and values to represent its state too.
+
+For example, at some point I expect to have a UI stack that looks something like
+this:
+
+    :::clojure
+    [{:kind :play}
+     {:kind :throw}
+     {:kind :inventory-select}]
+
+This would be the UI stack when you were throwing something in your inventory at
+a monster and were choosing what to throw.  Once you pick an item you'd need to
+target a monster, so the stack would become:
+
+    :::clojure
+    [{:kind :play}
+     {:kind :throw :item foo}
+     {:kind :target}]
+
+Now the last (i.e.: "active") UI is the targetting UI, but also notice that the
+throwing UI has a bit of state attached to it now (the item the user picked).
+We'll talk about how that got there a bit later.
+
+As I said before, the "state" for our game is going to be a "game", which
+consists of the world and the user interface.  So our "game" object is going to
+be a map that looks something like this:
+
+    :::clojure
+    {:world {}
+     :uis [,,,]}
+
+For now the `:world` is empty.  In the future it'll contain stuff like the tiles
+of the map, the monsters, the player, and lots of other stuff.  The `:uis` is
+the UI stack.
+
+Between the two I have enough information to draw the game fully to the user's
+terminal by doing something like: `(map #(draw-ui ui game) (:uis game))`.  We'll
+see the real code shortly, but that's actually pretty close.
+
+User Input
+----------
+
+In an imperative programming style our game loop would look something like this:
+
+1. Draw the screen.
+2. Get some input from the user.
+3. Process that input, modifying the world.
+4. GOTO 1.
+
+In this functional loop, I want it to look more like this:
+
+1. Draw the screen.
+2. Get some user input.
+3. Process the input and the game to get a new game.
+4. Recur with this new game.
+
+How do I handle user input?  Well it depends on the current UI -- pressing `d`
+at the main screen will do something different than pressing it in an inventory
+selection screen, for example.
+
+So the UIs need to know how to handle input.  There are a number of different
+ways I can do that.  One option might be to have `:handle-input (fn ...)` as
+part of the UI.  I chose a different route which you'll see below, but that's
+not important for now.
+
+The important part is that one I glossed over in the last section.  How do I go
+from this:
+
+    :::clojure
+    [{:kind :play}
+     {:kind :throw}
+     {:kind :inventory-select}]
+
+to this:
+
+    :::clojure
+    [{:kind :play}
+     {:kind :throw :item foo}
+     {:kind :target}]
+
+Let's follow the proposed game loop and see what happens.
+
+1. Draw the play UI, then the throw UI, then the inventory UI.
+2. Get a keypress from the user.
+3. Give that keypress and the game itself to the UI input handling function to
+   get a new game.
+4. Recur with this new game.
+
+Step three is the tricky part.  What does the inventory handler need to do to
+give back a new game?
+
+It would need to pop itself off the UI stack (which is okay), put the selected
+item in the previous UI (a bit scary, but probably not a problem in practice),
+and create the targeting UI.
+
+This last part is a deal breaker.  The inventory selection UI shouldn't know
+anything about the targeting UI, because then I won't be able to reuse it for
+other functions (like equipping items, eating food, etc)!
+
+The throw UI is the one that should know about the inventory and targeting
+UIs.  Ideally it would set them up, get their "return values" and process those.
+How can I send back the values?
+
+There's actually a really elegant way I came up with for this.  At least
+I *think* it's elegant.  I may end up immuting myself into a corner and
+ragequitting this blog series.  We'll see.
+
+Anyway, here's the solution:
+
+* Make "input" part of the game state.
+* Update the game input when you want to return a value from a UI.
+
+And I can change the game loop to look like this:
+
+1. Draw the screen.
+2. If the game's input is empty, get some from the user to fill it.
+3. Process the game to get a new game.  The input is now part of the game and
+   gets processed along with it.
+4. Recur with this new game.
+
+From what little I've used it so far, this method seems very promising.
+
+Enough design talk.  Let's look at the code.
+
+Implementation
+--------------
+
+In Trystan's tutorial he had three "screens": start, win, and lose.  The user
+presses keys to transition between them.  Not a very fun game, but it lets you
+get the game loop up and running before diving into gameplay.
+
+I did the same thing.  Right now everything is in one file because I tend to
+code like that until I feel like something needs to be pulled out into its own
+namespace.  The file is still under a hundred lines of code, so that's not too
+bad.
+
+Let's walk through the code piece by piece.  First the namespace:
+
+    :::clojure
+    (ns caves.core
+      (:require [lanterna.screen :as s]))
+
+Next I define some basic data structures:
+
+    :::clojure
+    (defrecord UI [kind])
+    (defrecord World [])
+    (defrecord Game [world uis input])
+
+I used Clojure's records here because I feel like they add a bit of helpful
+structure to the code.  They're also a bit faster, but that probably won't be
+noticeable.  You could skip these and just use plain maps if you wanted to, it's
+really a personal preference.
+
+Next is a helper function:
+
+    :::clojure
+    (defn clear-screen [screen]
+      (let [blank (apply str (repeat 80 \space))]
+        (doseq [row (range 24)]
+          (s/put-string screen 0 row blank))))
+
+Unfortunately Lanterna doesn't provide a method for clearing the screen, so
+I wrote my own little hacky one that just overwrites everything with spaces.  It
+assumes the terminal is 80 by 24 characters for now.
+
+I'll be adding a feature request in the Lanterna issue tracker for this, so
+hopefully I'll be able to delete this function in a later post.
+
+Now to the meaty bits:
+
+    :::clojure
+    (defmulti draw-ui
+      (fn [ui game screen]
+        (:kind ui)))
+
+    (defmethod draw-ui :start [ui game screen]
+      (s/put-string screen 0 0 "Welcome to the Caves of Clojure!")
+      (s/put-string screen 0 1 "Press enter to win, anything else to lose."))
+
+    (defmethod draw-ui :win [ui game screen]
+      (s/put-string screen 0 0 "Congratulations, you win!")
+      (s/put-string screen 0 1 "Press escape to exit, anything else to restart."))
+
+    (defmethod draw-ui :lose [ui game screen]
+      (s/put-string screen 0 0 "Sorry, better luck next time.")
+      (s/put-string screen 0 1 "Press escape to exit, anything else to go."))
+
+    (defn draw-game [game screen]
+      (clear-screen screen)
+      (doseq [ui (:uis game)]
+        (draw-ui ui game screen))
+      (s/redraw screen))
+
+Here we have the drawing code.
+
+The UIs are very simple for now.  They each just output a couple of lines of
+text.  None of them actually look at the game state at all, but in the future
+some of them will need to do that (e.g.: when showing the list of items in the
+player's inventory).
+
+I made the `draw-ui` function a multimethod to make it easy to define the logic
+for each UI separately.  Each definition could even live in its own file if
+I wanted it to.  There are other ways to do this, but I like the concision and
+simplicity of this one.
+
+The `draw-game` function takes the immutable game object and draws some text to
+the user's terminal.  It's fairly simple.  The `redraw` call is needed because
+Lanterna [double buffers][] the output.  Check out the [clojure-lanterna
+documentation][clojure-lanterna] for more information if you're curious.
+
+[double buffers]: https://en.wikipedia.org/wiki/Multiple_buffering#Double_buffering_in_computer_graphics
+[clojure-lanterna]: http://sjl.bitbucket.org/clojure-lanterna/
+
+    :::clojure
+    (defmulti process-input
+      (fn [game input]
+        (:kind (last (:uis game)))))
+
+    (defmethod process-input :start [game input]
+      (if (= input :enter)
+        (assoc game :uis [(new UI :win)])
+        (assoc game :uis [(new UI :lose)])))
+
+    (defmethod process-input :win [game input]
+      (if (= input :escape)
+        (assoc game :uis [])
+        (assoc game :uis [(new UI :start)])))
+
+    (defmethod process-input :lose [game input]
+      (if (= input :escape)
+        (assoc game :uis [])
+        (assoc game :uis [(new UI :start)])))
+
+    (defn get-input [game screen]
+      (assoc game :input (s/get-key-blocking screen)))
+
+UIs need to know how to process their input.  I used a multimethod for this too.
+
+The method takes the game and the input as parameters and returns a modified
+copy of the game object that represents the new state.  Currently none of them
+use the "returning as input" trick, but we'll see that in one of the next few
+posts.
+
+Notice how the UIs all simply replace the UI stack in the game they return?
+This is fine for now, but in the future they'll be more likely to just pop off
+the last one (themselves) rather than replace the entire stack.
+
+An empty UI stack means "quit the game", as we'll see in a moment.
+
+You'll also see why the input is separate from the game soon.
+
+The `get-input` function gets a keypress from the user and sticks it into the
+game object.  Nothing crazy there.
+
+And now, the game loop:
+
+    :::clojure
+    (defn run-game [game screen]
+      (loop [{:keys [input uis] :as game} game]
+        (when-not (empty? uis)
+          (draw-game game screen)
+          (if (nil? input)
+            (recur (get-input game screen))
+            (recur (process-input (dissoc game :input) input))))))
+
+Here we go.  The `run-game` function `loop`s on a game object each time.
+
+First: if there are no UIs, we're done and can drop out.  Cool.
+
+If there are UIs, draw the game to the user's terminal.
+
+Then it checks if it needs to get a keypress from the user.  If so, do that,
+update the game object, and start again.
+
+I could make this a bit more efficient by continuing on to process the input
+without another round through the loop, but performance probably isn't a concern
+at the moment.  I'll revisit this in the future if it becomes an issue, but for
+now I like this structure.
+
+Anyway, if we *do* have input (i.e.: either we grabbed a keypress or a UI
+returned something the last time through the loop), process it.  Remember that
+the `process-input` function is a multimethod that dispatches on the `:kind` of
+the last UI in the stack.
+
+Here your can see why `process-input` takes the game and input separately.  I
+*could* just pass the game and pull out the `:input` value, but then I'd also
+need to `dissoc` the input from the modified game object in every UI that didn't
+return a value.
+
+If I didn't `dissoc` the input, the input would always be present and would
+cause an infinite loop.  You can play around with this by replacing `(dissoc
+game :input)` with `game` and watching what happens.
+
+Next is a simple helper function:
+
+    :::clojure
+    (defn new-game []
+      (new Game
+           (new World)
+           [(new UI :start)]
+           nil))
+
+Nothing fancy.  You could just inline that body into the next function if you
+wanted, but I'm thinking ahead to when I'm going to want to generate a random
+world.
+
+Finally, the bootstrapping:
+
+    :::clojure
+    (defn main
+      ([screen-type] (main screen-type false))
+      ([screen-type block?]
+       (letfn [(go []
+                 (let [screen (s/get-screen screen-type)]
+                   (s/in-screen screen
+                                (run-game (new-game) screen))))]
+         (if block?
+           (go)
+           (future (go))))))
+
+    (defn -main [& args]
+      (let [args (set args)
+            screen-type (cond
+                          (args ":swing") :swing
+                          (args ":text")  :text
+                          :else           :auto)]
+        (main screen-type true)))
+
+`-main` looks almost the same as before, but `main` has changed quite a bit.
+What happened?
+
+The short answer is that most of the change is to work around some Clojure/JVM
+silliness.  The important bit is that I now create a fresh game object and fire
+up the game loop with `(run-game (new-game) screen)`.
+
+If you're curious about the rest, read [this Clojure bug report][bug].  I wanted
+to be able to run the game from the command line as normal, but from a REPL
+without blocking the REPL itself, so I could play around with things.
+
+[bug]: http://dev.clojure.org/jira/browse/CLJ-959
+
+That's it!  It clocks in at 98 lines of code.  Here's the whole file at once:
+
+    :::clojure
+    (ns caves.core
+      (:require [lanterna.screen :as s]))
+
+
+    ; Data Structures -------------------------------------------------------------
+    (defrecord UI [kind])
+    (defrecord World [])
+    (defrecord Game [world uis input])
+
+    ; Utility Functions -----------------------------------------------------------
+    (defn clear-screen [screen]
+      (let [blank (apply str (repeat 80 \space))]
+        (doseq [row (range 24)]
+          (s/put-string screen 0 row blank))))
+
+
+    ; Drawing ---------------------------------------------------------------------
+    (defmulti draw-ui
+      (fn [ui game screen]
+        (:kind ui)))
+
+    (defmethod draw-ui :start [ui game screen]
+      (s/put-string screen 0 0 "Welcome to the Caves of Clojure!")
+      (s/put-string screen 0 1 "Press enter to win, anything else to lose."))
+
+    (defmethod draw-ui :win [ui game screen]
+      (s/put-string screen 0 0 "Congratulations, you win!")
+      (s/put-string screen 0 1 "Press escape to exit, anything else to restart."))
+
+    (defmethod draw-ui :lose [ui game screen]
+      (s/put-string screen 0 0 "Sorry, better luck next time.")
+      (s/put-string screen 0 1 "Press escape to exit, anything else to go."))
+
+    (defn draw-game [game screen]
+      (clear-screen screen)
+      (doseq [ui (:uis game)]
+        (draw-ui ui game screen))
+      (s/redraw screen))
+
+
+    ; Input -----------------------------------------------------------------------
+    (defmulti process-input
+      (fn [game input]
+        (:kind (last (:uis game)))))
+
+    (defmethod process-input :start [game input]
+      (if (= input :enter)
+        (assoc game :uis [(new UI :win)])
+        (assoc game :uis [(new UI :lose)])))
+
+    (defmethod process-input :win [game input]
+      (if (= input :escape)
+        (assoc game :uis [])
+        (assoc game :uis [(new UI :start)])))
+
+    (defmethod process-input :lose [game input]
+      (if (= input :escape)
+        (assoc game :uis [])
+        (assoc game :uis [(new UI :start)])))
+
+    (defn get-input [game screen]
+      (assoc game :input (s/get-key-blocking screen)))
+
+
+    ; Main ------------------------------------------------------------------------
+    (defn run-game [game screen]
+      (loop [{:keys [input uis] :as game} game]
+        (when-not (empty? uis)
+          (draw-game game screen)
+          (if (nil? input)
+            (recur (get-input game screen))
+            (recur (process-input (dissoc game :input) input))))))
+
+    (defn new-game []
+      (new Game
+           (new World)
+           [(new UI :start)]
+           nil))
+
+    (defn main
+      ([screen-type] (main screen-type false))
+      ([screen-type block?]
+       (letfn [(go []
+                 (let [screen (s/get-screen screen-type)]
+                   (s/in-screen screen
+                                (run-game (new-game) screen))))]
+         (if block?
+           (go)
+           (future (go))))))
+
+
+    (defn -main [& args]
+      (let [args (set args)
+            screen-type (cond
+                          (args ":swing") :swing
+                          (args ":text")  :text
+                          :else           :auto)]
+        (main screen-type true)))
+
+And here are some screenshots:
+
+![Screenshot](/media/images{{ parent_url }}/caves-02-01.png)
+
+![Screenshot](/media/images{{ parent_url }}/caves-02-02.png)
+
+![Screenshot](/media/images{{ parent_url }}/caves-02-03.png)
+
+It's not a very exciting game yet, but it all works, and I've managed to use an
+immutable data structure of basic maps and records to represent everything
+I need.
+
+The drawing functions aren't "pure" in the "no I/O" sense, but they're kind of
+pure in another way -- they take an immutable data structure and draw something
+to the screen based solely on that.  I think this is going to make things easy
+to work with down the line.
+
+Testing
+-------
+
+I'll leave you with one final tidbit to read through if you want more.
+
+Encapsulating the game state as an immutable objects means I can test actions
+and their effects on the world individually, without a game loop:
+
+    :::clojure
+    (ns caves.core-test
+      (:import [caves.core UI World Game])
+      (:use clojure.test
+            caves.core))
+
+    (defn current-ui [game]
+      (:kind (last (:uis game))))
+
+
+    (deftest test-start
+      (let [game (new Game nil [(new UI :start)] nil)]
+
+        (testing "Enter wins at the starting screen."
+          (let [result (process-input game :enter)]
+            (is (= (current-ui result) :win))))
+
+        (testing "Other keys lose at the starting screen."
+          (let [results (map (partial process-input game)
+                             [\space \a \A :escape :up :backspace])]
+            (doseq [result results]
+              (is (= (current-ui result) :lose)))))))
+
+That's pretty cool!
+
+{% endblock article %}
Binary file media/images/blog/2012/07/caves-02-01.png has changed
Binary file media/images/blog/2012/07/caves-02-02.png has changed
Binary file media/images/blog/2012/07/caves-02-03.png has changed