ffaa4b573749

moar
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Thu, 12 Jul 2012 09:44:59 -0400
parents 4cf3a64203d2
children 6d7871cdeae7
branches/tags (none)
files content/blog/2012/07/caves-of-clojure-03-4.html content/blog/2012/07/caves-of-clojure-04.html media/images/blog/2012/07/caves-04-01.png media/images/blog/2012/07/caves-04-02.png media/images/blog/2012/07/caves-04-03.png

Changes

--- 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)))
 
--- /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 %}
Binary file media/images/blog/2012/07/caves-04-01.png has changed
Binary file media/images/blog/2012/07/caves-04-02.png has changed
Binary file media/images/blog/2012/07/caves-04-03.png has changed