# HG changeset patch # User Steve Losh # Date 1341754145 14400 # Node ID b174875844e2a72fc885446e496509def8e075ca # Parent 7975761ec4f3d82d0d13ab819c3256348c0cacd6 another blog post diff -r 7975761ec4f3 -r b174875844e2 content/blog/2012/07/caves-of-clojure-02.html --- /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 %} diff -r 7975761ec4f3 -r b174875844e2 media/images/blog/2012/07/caves-02-01.png Binary file media/images/blog/2012/07/caves-02-01.png has changed diff -r 7975761ec4f3 -r b174875844e2 media/images/blog/2012/07/caves-02-02.png Binary file media/images/blog/2012/07/caves-02-02.png has changed diff -r 7975761ec4f3 -r b174875844e2 media/images/blog/2012/07/caves-02-03.png Binary file media/images/blog/2012/07/caves-02-03.png has changed