# HG changeset patch # User Steve Losh # Date 1342100699 14400 # Node ID ffaa4b5737499b74b6522e8242e2a4b645960530 # Parent 4cf3a64203d29d53d00e95c47861af6fddcf972e moar diff -r 4cf3a64203d2 -r ffaa4b573749 content/blog/2012/07/caves-of-clojure-03-4.html --- a/content/blog/2012/07/caves-of-clojure-03-4.html Wed Jul 11 12:04:56 2012 -0400 +++ b/content/blog/2012/07/caves-of-clojure-03-4.html Thu Jul 12 09:44:59 2012 -0400 @@ -101,10 +101,12 @@ update-in --------- -Next is a tiny change that's just a bit cleaner. Allan Malloy [told me][] about +Next is a tiny change that's just a bit cleaner. Alan Malloy [told me][] about it. In the `process-input` function for the `:play` UI, the code to handle smoothing the world looked like this: +[told me]: https://twitter.com/alanmalloy/status/222748536595423232 + :::clojure \s (assoc game :world (smooth-world (:world game))) diff -r 4cf3a64203d2 -r ffaa4b573749 content/blog/2012/07/caves-of-clojure-04.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/content/blog/2012/07/caves-of-clojure-04.html Thu Jul 12 09:44:59 2012 -0400 @@ -0,0 +1,624 @@ + {% extends "_post.html" %} + + {% hyde + title: "The Caves of Clojure: Part 4" + snip: "A player!" + created: 2012-07-12 09:42: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 four 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-04` 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-04-player.html +[bb]: http://bitbucket.org/sjl/caves/ +[gh]: http://github.com/sjl/caves/ + +[TOC] + +Summary +------- + +In Trystan's fourth post he adds three main things: + +* A player +* Player movement +* Digging + +I'm going to add all three of those, but I'm going to do things very differently +than he did. + +My goal is to play around with some Clojurey concepts and see how far I can +stretch them. I have a feeling that it's going to let me do some very cool +things in the future. + +Refactoring +----------- + +Before I started I wanted to clean up the `world` namespace a bit. I'm not +going to go in depth -- I'll just post the code and you can read over it or skip +it if you trust me. + +First I created `coords.clj`: + + :::clojure + (ns caves.coords) + + + (defn offset-coords + "Offset the starting coordinate by the given amount, returning the result coordinate." + [[x y] [dx dy]] + [(+ x dx) (+ y dy)]) + + (defn dir-to-offset + "Convert a direction to the offset for moving 1 in that direction." + [dir] + (case dir + :w [-1 0] + :e [1 0] + :n [0 -1] + :s [0 1] + :nw [-1 -1] + :ne [1 -1] + :sw [-1 1] + :se [1 1])) + + (defn destination-coords + "Take an origin's coords and a direction and return the destination's coords." + [origin dir] + (offset-coords origin (dir-to-offset dir))) + +Then I cleaned up `world.clj`: + + :::clojure + (ns caves.world) + + + ; Constants ------------------------------------------------------------------- + (def world-size [160 50]) + + ; Data structures ------------------------------------------------------------- + (defrecord World [tiles]) + (defrecord Tile [kind glyph color]) + + (def tiles + {:floor (->Tile :floor "." :white) + :wall (->Tile :wall "#" :white) + :bound (->Tile :bound "X" :black)}) + + + ; Convenience functions ------------------------------------------------------- + (defn get-tile-from-tiles [tiles [x y]] + (get-in tiles [y x] (:bound tiles))) + + (defn random-coordinate [] + (let [[cols rows] world-size] + [(rand-int cols) (rand-int rows)])) + + + ; World generation ------------------------------------------------------------ + (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 get-smoothed-tile [block] + (let [tile-counts (frequencies (map :kind block)) + floor-threshold 5 + floor-count (get tile-counts :floor 0) + result (if (>= floor-count floor-threshold) + :floor + :wall)] + (tiles result))) + + (defn block-coords [x y] + (for [dx [-1 0 1] + dy [-1 0 1]] + [(+ x dx) (+ y dy)])) + + (defn get-block [tiles x y] + (map (partial get-tile-from-tiles tiles) + (block-coords x y))) + + (defn get-smoothed-row [tiles y] + (mapv (fn [x] + (get-smoothed-tile (get-block tiles x y))) + (range (count (first tiles))))) + + (defn get-smoothed-tiles [tiles] + (mapv (fn [y] + (get-smoothed-row tiles y)) + (range (count tiles)))) + + (defn smooth-world [{:keys [tiles] :as world}] + (assoc world :tiles (get-smoothed-tiles tiles))) + + + (defn random-world [] + (let [world (->World (random-tiles)) + world (nth (iterate smooth-world world) 3)] + world)) + +The changes were mostly centered around removing debugging functions and making +all the world functions take an `[x y]` coordinate vector in place of two +separate `x` and `y` arguments. + +Read through the code if you're curious, it's only about 100 total lines. + +Entities +-------- + +Now it's time to add a player. Rather than take the approach Trystan used of +creating a `Creature` class and `Player` subclass, I went with something a bit +different. + +In Minecraft's network protocol, when you get a list of "things in the world" +it's not just creatures -- the list includes both creatures and items. It uses +the word "entity" to refer to them. I'm sure it's not the first game to do +that, but it's the first time I've run across it because I'm not hugely into +game programming. + +That got me thinking: are items and creatures really so different that I need to +represent them as two completely separate ideas? + +Both have a location in the world. Both have a "glyph" that I'll be using to +display them to the player. Both will have some kind of "id" so I can use them +in mappings efficiently. + +On the other hand, there are definitely some differences. Creatures move +around, eat things, attack things, can be attacked (and killed), have an AI to +decide what to do, and so on. + +Items can be picked up and dropped, can contain other items, can be eaten or +quaffed, can rot over time (e.g.: corpses), can be used as weapons or armor, et +cetera. + +But wait a second: are things really so clear? The bag of tricks in Nethack can +attack the player. Cockatrice corpses can be wielded and used as +petrification-inducing clubs. In Dwarf Fortress discarded pieces from +slaughtered animals can come alive if a necromancer sieges your fortress. + +I can think of a lot of cool things I could do when I eliminate the distinction +between items and creatures. + +Maybe there's a "pixie" creature that wanders around and does normal creaturey +things, but if you attack it while wielding a butterfly net you "catch" it and +it gets picked up and put in your inventory like an item. + +Once you've got one you could "apply" it (maybe that means "setting it free") to +heal yourself, or quaff it to restore mana (mmm, delicious pixie blood). + +Oh, and it still has an AI so maybe every 100 turns it has a chance of escaping +from your inventory. Unless you put it in a jar. + +I can think of tons of interesting things to do with a unified "entity" system. +A bag that eat things, where you need to remember to "feed" it normal food or +it'll start digesting the other items! Giant venus fly traps that eat unwary +pixies! Potions that evaporate over time if you don't use them! + +The possibilities are really exciting. But how can I actually *code* all this +crazy stuff without special-casing *everything*? + +Protocols +--------- + +After thinking about this problem for a while, I came up with a solution that +I think has some real promise. + +Individual types of entity ("pixie", "player", "goblin", "steel helmet") will be +defined with simple `(defrecord)`s. Each should have an `:id`, `:glyph`, and +`:location`, but beyond that the rest of their state is flexible. + +I'm going to create an `Entity` protocol that such records will implement. That +protocol will have a single `tick` function that they need to define. This will +be called once per game "tick" and will be how the various types of entity +decide what to do over time. They may define a `tick` that does nothing if they +don't change over time. + +On its own an entity record can't do anything except exist, be displayed on +the map, and update itself every tick. To actually *do* something during +a tick (or have things done to them) they'll implement what I'm calling +"aspects". + +An "aspect" is a protocol that defines a group of related functions, probably +all having to do with a simple gameplay mechanic. Here are a few rough examples +from the top of my head: + + :::clojure + (defprotocol Edible + (can-be-eaten? [this eater world]) + (nutrition-value [this world]) + (eat [this eater world])) + + (defprotocol Eater + (can-eat? [this food world]) + (eat [this food world])) + + (defprotocol Item + (can-be-contained-in? [this container world]) + (insert-into [this container world]) + (remove-from [this container world])) + + (defprotocol Container + (get-contained [this world]) + (can-contain? [this item world]) + (insert [this item world]) + (remove [this item world])) + +As you can see, many aspects will be paired up. Some entities can have things +done to them by other entities, which will actually do those things. Both will +have the opportunity to override the default method implementations to customize +the behavior. + +Anyway, I think this way of adding in functionality (basically mixin-style, but +decoupled from the entity class declaration and without namespace clashes) +could be very cool. I'm going to give it a shot and see how it works. + +The Player +---------- + +Let's start with the first and most important entity: the player. This game +isn't going to be much fun without one of those. + +First I added a new file: `entities/core.clj`. It'll contain the basic `Entity` +definition: + + :::clojure + (ns caves.entities.core) + + + (defprotocol Entity + (tick [this world] + "Update the world to handle the passing of a tick for this entity.")) + +Simple enough. `tick`ing an entity will return a new immutable world that +accounts for whatever the entity decides to do during that tick. + +Now to add a player! + + :::clojure + (ns caves.entities.player + (:use [caves.entities.core :only [Entity]])) + + + (defrecord Player [id glyph location]) + + (extend-type Player Entity + (tick [this world] + world)) + +Right now the player doesn't do anything during a tick -- the world will remain +unchanged. + +We'll need to actually place the player somewhere in the world to start, so I'll +make a helper function like Trystan's to find an empty spot for them in +`world.clj`: + + :::clojure + (defn find-empty-tile [world] + (loop [coord (random-coordinate)] + (if (#{:floor} (get-tile-kind world coord)) + coord + (recur (random-coordinate))))) + +Basically I just try a bunch of random coordinates until I find one that's +a `:floor` tile. Maybe not the most efficient way to do things, but it's fine +for now. + +Back in `player.clj` I'll need a way to make a new player when we start a new +game: + + :::clojure + (defn make-player [world] + (->Player :player "@" (find-empty-tile world))) + +For now I'll use the special ID `:player` for the entity ID. Since this is +going to be a single player game, with no chance of ever being multiplayer, it's +okay to special case things for the player a bit. + +Now to actually add the new player into the main `game` object. Remember that +the `:start` screen is the one that makes fresh games, so I updated that: + + :::clojure + (defn reset-game [game] + (let [fresh-world (random-world)] + (-> game + (assoc :world fresh-world) + (assoc-in [:world :player] (make-player fresh-world)) + (assoc :uis [(->UI :play)])))) + + (defmethod process-input :start [game input] + (reset-game game)) + +I pulled out the guts of the `process-input` function into a helper, which: + +* Creates a fresh, random world. +* Replaces the game's world with the new one. +* Creates a fresh player at some empty location in that world. +* Attaches the player to the world. +* Replaces the UI stack of the game with the main `:play` UI. + +I could have made a completely new `game` object instead of just overwriting +some fields here, but this way if I decide to store configuration options on the +`game` later they won't be lost when restarting. + +Displaying the Player +--------------------- + +Now that I've got a player it's time to display them on the map as the +traditional `@`. I opened up `input.clj` and replaced the crosshair-drawing +code from the last two posts with code to draw the player: + + :::clojure + (defn draw-player [screen start-x start-y player] + (let [[player-x player-y] (:location player) + x (- player-x start-x) + y (- player-y start-y)] + (s/put-string screen x y (:glyph player) {:fg :white}) + (s/move-cursor screen x y))) + +If the screen's `start-x` (i.e.: its left edge) is at 10, and the player is at +24, then I need to draw the `@` at screen coordinate 14. Same goes for the +y coordinates. + +Now to tweak the main `draw-ui` function to account for this change: + + :::clojure + (defmethod draw-ui :play [ui game screen] + (let [world (:world game) + {:keys [tiles player]} world + [cols rows] screen-size + vcols cols + vrows (dec rows) + [start-x start-y end-x end-y] (get-viewport-coords game (:location player) vcols vrows)] + (draw-world screen vrows vcols start-x start-y end-x end-y tiles) + (draw-player screen start-x start-y player))) + +Instead of a `center-x` and `center-y` that are based on an arbitrary value +in the `game` object, I'm now basing things off of the player's coordinates. +Otherwise not much has changed here. + +One more thing I decided to do is the start using that line at the bottom of the +screen that I reserved for stats. I added a simple function to draw it: + + :::clojure + (defn draw-hud [screen game start-x start-y] + (let [hud-row (dec (second screen-size)) + [x y] (get-in game [:world :player :location]) + info (str "loc: [" x "-" y "]") + info (str info " start: [" start-x "-" start-y "]")] + (s/put-string screen 0 hud-row info))) + +And add a call for that to the `draw-ui` function. I'm sure you can figure that +out yourself. + +Now the last line of the screen will look like: + + :::text + loc: [30-53] start: [10-34] + +I can now see the coordinates of the player and the top left corner of the +screen at all times. This was really handy when debugging display/movement +problems later. + +Movement +-------- + +Now that I'm basing the viewport on the player's location, I need a way for +players to move around. I could just tweak the current code, but lots of things +are going to need to move so this sounds like a great place for my first +"aspect". + +I created the `entities/aspects/mobile.clj` file and added the protocol +representing the aspect: + + :::clojure + (ns caves.entities.aspects.mobile) + + + (defprotocol Mobile + (move [this world dest] + "Move this entity to a new location.") + (can-move? [this world dest] + "Return whether the entity can move to the new location.")) + +Right now I'm just defining some simple functions. Mobile entities must be able +to check if they can move into a coordinate, as well as actually move themselves +into it. + +Why allow entities to check for movement and move themselves instead of having +a single movement handling chunk of code for all entities? + +Well this means we can customize how movement works on a per-entity basis. +Maybe we'll have a minotaur that can move into other entities' spaces, +displacing them. Or a stone elemental that can walk through wall tiles. + +Next I made the Player entity implement Mobile back in `entities/player.clj`: + + :::clojure + (ns caves.entities.player + (:use [caves.entities.core :only [Entity]] + [caves.entities.aspects.mobile :only [Mobile move can-move?]] + [caves.coords :only [destination-coords]] + [caves.world :only [find-empty-tile get-tile-kind]])) + + + (defrecord Player [id glyph location]) + + (defn check-tile + "Check that the tile at the destination passes the given predicate." + [world dest pred] + (pred (get-tile-kind world dest))) + + + (extend-type Player Entity + (tick [this world] + world)) + + (extend-type Player Mobile + (move [this world dest] + {:pre [(can-move? this world dest)]} + (assoc-in world [:player :location] dest)) + (can-move? [this world dest] + (check-tile world dest #{:floor}))) + + + (defn make-player [world] + (->Player :player "@" (find-empty-tile world))) + + (defn move-player [world dir] + (let [player (:player world) + target (destination-coords (:location player) dir)] + (cond + (can-move? player world target) (move player world target) + :else world))) + +Notice how simple (and concise) this was to add. I defined `can-move?` to +simply make sure that the destination is a floor tile. + +`move` itself uses a Clojure function precondition to sanity-check that the +entity isn't trying to cheat and move somewhere illegal. If everything's okay, +I simply update the player's location. + +`move-player` is an ugly helper function that most entities won't need. Players +are special because we're going to want to make certain keystrokes do multiple +things, as we'll see shortly. For now don't worry too much about that one. + +Before going on make sure you understand how movement is actually going to +happen, from the point where `(move-player game :s)` is called and down. + +The last thing to do to actually make the player movable is handling the actual +keystrokes from the user, so I did that next over in `ui/input.clj`: + + :::clojure + (defmethod process-input :play [game input] + (case input + :enter (assoc game :uis [(->UI :win)]) + :backspace (assoc game :uis [(->UI :lose)]) + \q (assoc game :uis []) + + \h (update-in game [:world] move-player :w) + \j (update-in game [:world] move-player :s) + \k (update-in game [:world] move-player :n) + \l (update-in game [:world] move-player :e) + \y (update-in game [:world] move-player :nw) + \u (update-in game [:world] move-player :ne) + \b (update-in game [:world] move-player :sw) + \n (update-in game [:world] move-player :se) + + game)) + +Each of the traditional roguelike movement keys will now move the player around +the world. Because the screen drawing is already updated to be based on the +player's location, movement is pretty much complete! + +I added the `yubn` diagonal movement keys because as I was trying out movement +myself it felt like I needed them. + +This is something that I've noticed while watching Notch's Ludum Dare recordings +(Google for them if you want to see them). He plays the game he's making for +longer periods than you might think. He doesn't just make a feature and make +sure it works, he makes a feature and then plays the game normally for a few +minutes to make sure it fits into the game right (and is fun)! + +Those `update-in` statements are a bit ugly, but not ugly enough for me to want +to do something clever to remove them. They can stay for now. + +Digging +------- + +As Trystan mentioned in his post, we're not doing anything special to make sure +the caves we generate are connected. The player may very well start in a tiny +cave. + +To make this less of a problem, he added the ability for the player to dig +through walls. + +Digging sounds like a great candidate for another aspect, so I added +`entities/aspects/digger.clj`: + + :::clojure + (ns caves.entities.aspects.digger) + + + (defprotocol Digger + (dig [this world target] + "Dig a location.") + (can-dig? [this world target] + "Return whether the entity can dig the new location.")) + +Nothing fancy here. Then I made the Player entity implement it: + + :::clojure + (extend-type Player Digger + (dig [this world dest] + {:pre [(can-dig? this world dest)]} + (set-tile-floor world dest)) + (can-dig? [this world dest] + (check-tile world dest #{:wall}))) + +This looks very similar to the Mobile implementation, except instead of changing +the player's location I change the map tile from a wall to a floor. + +Finally, I update the `move-player` function (which is called when we receive +a keystroke): + + :::clojure + (defn move-player [world dir] + (let [player (:player world) + target (destination-coords (:location player) dir)] + (cond + (can-move? player world target) (move player world target) + (can-dig? player world target) (dig player world target) + :else world))) + +Now if the space the user is telling the player to enter is open, the player +will move there, otherwise if it's diggable the player will dig it, otherwise +nothing will happen. + +This means that moving into a space that is currently a wall will take two +keypresses: the first digs out the wall, the second moves into the newly open +space. + +I like how this feels. It takes longer to travel through rock, which makes +sense. If you prefer to dig and move all at once you could dig and move in the +same action. It's up to you. + +Results +------- + +Finally, after seven entries I've got a hero in the game! It's taken a while, +but I've laid the groundwork for what I think is some really cool stuff down the +line. + +You can view the code [on GitHub][result-code] if you want to see the end +result. From now on I'm going to start moving a bit faster, not always showing +the namespace declarations and such. If you want the full code for each post +look at the GitHub repository. + +[result-code]: https://github.com/sjl/caves/tree/entry-04/src/caves + +And the obligatory screenshots of our intrepid hero: + +![Screenshot](/media/images{{ parent_url }}/caves-04-01.png) + +![Screenshot](/media/images{{ parent_url }}/caves-04-02.png) + +![Screenshot](/media/images{{ parent_url }}/caves-04-03.png) + +Next time I'll be adding some monsters for the hero to slay. + +{% endblock article %} diff -r 4cf3a64203d2 -r ffaa4b573749 media/images/blog/2012/07/caves-04-01.png Binary file media/images/blog/2012/07/caves-04-01.png has changed diff -r 4cf3a64203d2 -r ffaa4b573749 media/images/blog/2012/07/caves-04-02.png Binary file media/images/blog/2012/07/caves-04-02.png has changed diff -r 4cf3a64203d2 -r ffaa4b573749 media/images/blog/2012/07/caves-04-03.png Binary file media/images/blog/2012/07/caves-04-03.png has changed