# HG changeset patch # User Steve Losh # Date 1341783424 14400 # Node ID 4d776e8be476ccd1e1c126b0b05b570f1b2b9e9e # Parent 44930a6618e8e5fe847ee135f27a3c637ba36b59 another blog post diff -r 44930a6618e8 -r 4d776e8be476 content/blog/2012/07/caves-of-clojure-03-1.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/content/blog/2012/07/caves-of-clojure-03-1.html Sun Jul 08 17:37:04 2012 -0400 @@ -0,0 +1,364 @@ + {% extends "_post.html" %} + + {% hyde + title: "The Caves of Clojure: Part 3.1" + snip: "World generation." + created: 2012-07-09 9:45: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 three 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-03-1` 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-03-scrolling-through.html +[bb]: http://bitbucket.org/sjl/caves/ +[gh]: http://github.com/sjl/caves/ + +[TOC] + +Summary +------- + +In Trystan's third post he introduces three new things: + +* Random world generation. +* Smoothing the world to look nicer. +* The "play screen" and scrolling. + +I'm going to split this up into three separate short posts, so you can see the +process I actually went through as I built it. This post will deal with the +world generation and basic displaying. The next one will be about smoothing, +and the one after that about scrolling around. + +Organization +------------ + +First of all, my single `core.clj` is going to get a bit crowded by the time +we're done with this, so it's time to start splitting it apart. I made +a `world.clj` file alongside `core.clj`. That'll do for now. + +Creating a Random World +----------------------- + +First I set up the namespace: + + :::clojure + (ns caves.world) + +Next I defined a constant for the size of the world. I chose 160 by 50 tiles +arbitrarily: + + :::clojure + (def world-size [160 50]) + +I moved the `World` record declaration out of `core.clj` and into this file: + + :::clojure + (defrecord World [tiles]) + +I also added the `tiles` attribute to it. For now I'm going to store the tiles +as a vector of rows, where each row is a vector of tiles. + +What is a tile? For now it's going to be a simple record: + + :::clojure + (defrecord Tile [kind glyph color]) + +Tiles are immutable, so let's make a map of some of the ones we'll be needing: + + :::clojure + (def tiles + {:floor (new Tile :floor "." :white) + :wall (new Tile :wall "#" :white) + :bound (new Tile :bound "X" :black)}) + +The `:bound` Tile represents "out of bounds". It will be returned if you try to +get a tile outside the bounds of the map. There are other ways to handle that, +but this is the one Trystan used so I'm going to use it too. + +Next I created a little helper function for retrieving a specific tile: + + :::clojure + (defn get-tile [tiles x y] + (get-in tiles [y x] (:bound tiles))) + +Remember that we're storing the tiles as a vector of rows, so when we index into +it we need to get the y coordinate (i.e.: the row) first, then index in for the +column with the x coordinate. + +This is a bit ugly, but storing the screen as rows is going to help us later as +we draw the screen, and it gives us the opportunity to do bounds checking as +well. + +The `get-in` call is a really handy way to check bounds. No worrying about +comparing indexes to dimensions -- just try to get it and if you fail it must be +out of bounds. + +That's about it for the world structure. Time to move to the generation: + + :::clojure + (defn random-tiles [] + (let [[cols rows] world-size] + (letfn [(random-tile [] + (tiles (rand-nth [:floor :wall]))) + (random-row [] + (vec (repeatedly cols random-tile)))] + (vec (repeatedly rows random-row))))) + + (defn random-world [] + (new World (random-tiles))) + +Nothing too fancy happening here. In `random-tiles` I built up a series of +helper functions to make it easier to read. You could save some LOC by just +using anonymous functions instead, but to me this way is easier to read. +Personal preference, I guess. + +For now we're just going to generate a world where every tile has an equal +chance of being a wall or a floor. I might revisit this later if I want to make +the caves sparser or denser. + +That's it for the world generation. Next we'll move on to actually displaying +the new random worlds. + +Displaying +---------- + +Let's switch back to `core.clj`. First I updated the namespace to pull in the +`random-world` function: + + :::clojure + (ns caves.core + (:use [caves.world :only [random-world]]) + (:require [lanterna.screen :as s])) + +Before going further I decided to do a bit of cleanup. Instead of hardcoding +the 80 by 24 terminal size, I pulled it out into a constant: + + :::clojure + (def screen-size [80 24]) + +I updated `clear-screen` to use that: + + :::clojure + (defn clear-screen [screen] + (let [[cols rows] screen-size + blank (apply str (repeat cols \space))] + (doseq [row (range rows)] + (s/put-string screen 0 row blank)))) + +It's still not perfect (what if the user's terminal isn't 80 by 24?) but it's +not something I care enough to fix right now. I'll get to it later. At least +now the hardcoded numbers are in one spot. + +There's a few things I need to do to get the world on the screen. First +I created a `:play` UI similar to Trystan's. I'm not a big fan of the +generic-sounding name, but I couldn't come up with anything better in a few +minutes of thinking. + +Creating a UI requires implementing the `draw-ui` and `process-input` +multimethods from the previous post. I'll start with the easy one: input +processing. + +For now the flow of the game will go like this: + +1. The player is shown an introduction screen with some instructions. +2. They press a key and see the world. +3. Pressing enter wins the game. Backspace loses the game. Any other key does + nothing. +4. Once they win or lose, they see a text blurb and can press escape to quit, or + any other key to GOTO 1. + +With that in mind, I wrote the `process-input` implementation for `:play` UIs: + + :::clojure + (defmethod process-input :play [game input] + (case input + :enter (assoc game :uis [(new UI :win)]) + :backspace (assoc game :uis [(new UI :lose)]) + game)) + +I'm still replacing the entire UI stack at once. I'm going to be throwing that +code away later anyway so it's not a big deal. + +Now I need to update the `:start` UI to send the user to the `:play` UI instead +of directly to the `:win` or `:lose` UIs. + + :::clojure + (defmethod process-input :start [game input] + (-> game + (assoc :world (random-world)) + (assoc :uis [(new UI :play)]))) + +I also decided that this is where I'd generate the new random world. It makes +sense to put that here, because every time you restart the game you should get +a different world. + +On the other hand, this means that the `process-input` function is no longer +pure for `:start` UIs (all the other input processing functions are still pure). +I'm not sure how I feel about that. For now I'm going to accept it, but I may +rethink it in the future. + +Now that I have the world generation in there, I can remove the `(new World)` +call from the `new-game` helper function: + + :::clojure + (defn new-game [] + (new Game nil [(new UI :start)] nil)) + +Now `new-game` does even less than before. It just sets up a game object with +the initial UI and `nil` world/input. + +Okay, on to the last piece: drawing the world. This is some pretty dense code, +but don't be scared -- I'll guide you through it and we'll make it together: + + :::clojure + (defmethod draw-ui :play [ui {{:keys [tiles]} :world :as game} screen] + (let [[cols rows] screen-size + vcols cols + vrows (dec rows) + start-x 0 + start-y 0 + end-x (+ start-x vcols) + end-y (+ start-y vrows)] + (doseq [[vrow-idx mrow-idx] (map vector + (range 0 vrows) + (range start-y end-y)) + :let [row-tiles (subvec (tiles mrow-idx) start-x end-x)]] + (doseq [vcol-idx (range vcols) + :let [{:keys [glyph color]} (row-tiles vcol-idx)]] + (s/put-string screen vcol-idx vrow-idx glyph {:fg color}))))) + +Let's look at this beast line-by-line. First we pull the tile vector out of the +game object with Clojure's destructuring: + + :::clojure + {{:keys [tiles]} :world :as game} + +This seems a bit hard to read to me, so I might move it into the `let` statement +with a `get-in` call later. Maybe. + +Next I bind a bunch of symbols I'll be using: + + :::clojure + (let [[cols rows] screen-size + vcols cols + vrows (dec rows) + start-x 0 + start-y 0 + end-x (+ start-x vcols) + end-y (+ start-y vrows)] + ,,,) + +`cols` and `rows` are the dimensions of the screen (hardcoded at 80 by 24 for +the moment). + +`vcols` and `vrows` are the "viewport columns" and "viewport rows". The +"viewport" is what I'm calling the part of the screen that's actually showing +the world. I'm reserving one row at the bottom of the screen to use for +displaying the player's hit points, score, and so on. It would be trivial to +increase that to two rows if I need more later. + +`start-x` and `start-y` are the coordinates of the upper-left corner of the +viewport in the full map, and `end-x` and `end-y` are the coordinates of the +lower-right corner. For now I'm just displaying the upper-left section of the +map. In the entry after the next one I'll add the ability to scroll around. + +It's easier to explain with a diagram. Imagine I reduced the size of the world +to 10 by 10 and the terminal to 5 by 3, and the user was standing near the +middle of the map: + + :::text + columns (x) + 0123456789 + rows 0.......... + (y) 1.......... + 2.......... + 3..VVVVV... + 4..VVVVV... + 5..VVVVV... + 6.......... + 7.......... + 8.......... + 9.......... + +Here `V` represents the portion of the map the viewport can show (which is what +we'll be drawing to the user's terminal). In this example `start-x` would +be `2`, `start-y` would be `3`, `end-x` would be `6`, and `end-y` would be `5`. + +Okay, so I've calculated the part of the map that needs to be drawn. Now +I loop throgh the rows: + + :::clojure + (doseq [[vrow-idx mrow-idx] (map vector + (range 0 vrows) + (range start-y end-y)) + :let [row-tiles (subvec (tiles mrow-idx) start-x end-x)]] + ,,,) + +This is a bit obtuse, but basically the `map` call pairs up the viewport row and +map row indices. In our example it would result in this: + + :::clojure + [[0 3] + [1 4] + [2 5]] + +So viewport row `1` corresponds to map row `4`. There's probably a less +"clever" way to do this that I should use instead. + +For each row, I grab the tiles we're going to draw and store them in a vector +called `row-tiles` by grabbing the row of tiles from the world and taking +a slice of it. + +Almost there! Next I loop through each column in the row: + + :::clojure + (doseq [vcol-idx (range vcols) + :let [{:keys [glyph color]} (row-tiles vcol-idx)]] + ,,,) + +For each column I grab the appropriate tile and figure out what glyph and color +to draw (remember the definition of `Tile`: `(defrecord Tile [kind glyph +color])`). + +Finally I can actually draw the tile to the screen at the appropriate place: + + :::clojure + (s/put-string screen vcol-idx vrow-idx glyph {:fg color}) + +Whew! If that seemed painful and fiddly to you, trust me, I agree. I'm open to +suggestions on making it easier to read. + +Results +------- + +Now that the `:play` UI knows how to draw itself and process its input, and is +properly hooked up by the `:start` UI, it's time to give it a shot! + +![Screenshot](/media/images{{ parent_url }}/caves-03-1-01.png) + +![Screenshot](/media/images{{ parent_url }}/caves-03-1-02.png) + +![Screenshot](/media/images{{ parent_url }}/caves-03-1-03.png) + +Each time we start we get a different random world. Great! + +The code is getting a bit big to include in its entirety, but you can view it +[on GitHub][result-code]. + +[result-code]: https://github.com/sjl/caves/tree/entry-03-1/src/caves + +That covers the first part of Trystan's third post. Aside from the painful +`draw-ui` function it was pretty easy to add. In the next post I'll add the +smoothing code to make the caves look a bit nicer. + +{% endblock article %} diff -r 44930a6618e8 -r 4d776e8be476 media/images/blog/2012/07/caves-03-1-01.png Binary file media/images/blog/2012/07/caves-03-1-01.png has changed diff -r 44930a6618e8 -r 4d776e8be476 media/images/blog/2012/07/caves-03-1-02.png Binary file media/images/blog/2012/07/caves-03-1-02.png has changed diff -r 44930a6618e8 -r 4d776e8be476 media/images/blog/2012/07/caves-03-1-03.png Binary file media/images/blog/2012/07/caves-03-1-03.png has changed