115b1827e606

Another entry.
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Sat, 14 Jul 2012 17:08:43 -0400
parents ee49211d75fb
children 739cc98ac689
branches/tags (none)
files content/blog/2012/07/caves-of-clojure-interlude-1.html media/images/blog/2012/07/caves-interlude-1-01.png media/images/blog/2012/07/caves-interlude-1-02.png

Changes

--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/content/blog/2012/07/caves-of-clojure-interlude-1.html	Sat Jul 14 17:08:43 2012 -0400
@@ -0,0 +1,560 @@
+    {% extends "_post.html" %}
+
+    {% hyde
+        title: "The Caves of Clojure: Interlude 1"
+        snip: "Black magic."
+        created: 2012-07-14 17:06: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 is an interlude after [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 `interlude-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/09/roguelike-tutorial-05-stationary.html
+[bb]: http://bitbucket.org/sjl/caves/
+[gh]: http://github.com/sjl/caves/
+
+[TOC]
+
+Summary
+-------
+
+At the end of the last post I said that this one would be about refactoring and
+a macro, so that's what it's going to be.
+
+Refactoring
+-----------
+
+I don't want to bore you with lots of long chunks of refactored code, so I'll
+just outline the changes and if you want to see what happened you can look in
+the repo on GitHub.
+
+I ran [Kibit][] over the code and fixed what it complained.
+
+[jdmarble][] on GitHub pointed out a place where I was using `update-in` and
+could simplify it to `assoc-in`.  This is the kind of thing Kibit should catch,
+so I added it in and sent a pull request.
+
+[jmgimeno][] on GitHub pointed out that I could use an `atom` for the entity ID
+generation instead of a `ref`.  That cleaned up a few lines nicely.
+
+I updated the game loop and moved the call to `draw-game` so it doesn't get draw
+more times than is necessary.
+
+I added a bunch of comments and docstrings throughout the code.
+
+I added a few functions to the latest release of [clojure-lanterna][] that
+allowed me to clean up the UI drawing code.  I was able to completely remove the
+`clear-screen` function, and replaced the hardcoded screen size with a dymanic
+lookup.
+
+I also changed how the actual screen gets drawn.  Look in the repo for the full
+details -- I think it's much nicer now (though I'm still not 100% happy).
+
+I think that's about it.  On to the meat of this post.
+
+[kibit]: https://github.com/jonase/kibit/
+[jdmarble]: https://github.com/jdmarble
+[jmgimeno]: https://github.com/jmgimeno
+[clojure-lanterna]: http://sjl.bitbucket.org/clojure-lanterna/
+
+The Problem
+-----------
+
+Right now, the entity system in the Caves works like this:
+
+* Aspects are Clojure protocols.  They define what functions an entity must
+  implement to have that aspect.
+* Entity types use `extend-type` to add the appropriate implementations for the
+  aspects they want to be.
+
+This is all vanilla Clojure, and up until now it's been fine because there was
+no crossover between the `Lichen` aspects and the `Player` aspects.  But what's
+going to happen when I create a creature that shares behavior with one or both
+of them?
+
+To see the problem, I'm going to create a `Bunny` entity that will hop around
+the screen, and can also be destroyed (assuming the player is a terrible,
+terrible person).  So I'll create `entities/bunny.clj`:
+
+    :::clojure
+    (ns caves.entities.bunny)
+
+    (defrecord Bunny [id glyph color location hp])
+
+    (extend-type Bunny Entity
+      (tick [this world]
+        world))
+
+We'll worry about `tick` soon, but so far, so good.  Now I need to let bunnies
+move around:
+
+    :::clojure
+    (extend-type Bunny Mobile
+      (move [this world dest]
+        {:pre [(can-move? this world dest)]}
+        (assoc-in world [:entities (:id this) :location] dest))
+      (can-move? [this world dest]
+        (is-empty? world dest)))
+
+Hmm, where have we seen this code before?
+
+It's an almost exact copy of the `Player`'s implementation of the `Mobile`
+protocol!
+
+When you think about it, this makes sense.  Most of the entities in the game
+will have the same implementation for most aspects.  The flexibility of Clojure
+protocols means I have the power to customize behavior for every one of them,
+but it also means that I have to redefine the same behavior over and over.
+
+Or do I?
+
+The Not-Quite Solutions
+-----------------------
+
+There are a number of ways I could try to get around this duplication.
+
+First, I could define the "default" implementations as separate, normal
+functions, and then the entity-specific implementations could just call those.
+
+This would work, absolutely.  It would isolate the generic functionality in one
+place successfully.  But it means I'd have to manually type out the calls to
+those generic functions all the time.  This is a Lisp -- I can do better.
+
+The next idea is to make `Object` implement every aspect (with the default
+implementations).  This isn't ideal for two reasons.
+
+First, it means that the type of an entity is no longer useful.  If `Object`
+implements `Mobile` to provide the default functionality, it means *every*
+entity will effectively be `Mobile` even if it shouldn't be!
+
+Second, it doesn't even give me everything I want.  Observe:
+
+    :::clojure
+    (defprotocol Foo
+     (hello [this])
+     (world [this]))
+
+    (defrecord A [])
+
+    (extend-type Object Foo
+     (hello [this] :hello-object)
+     (world [this] :world-object))
+
+    (extend-type A Foo
+     (hello [this] :hello-a))
+
+    (def a (->A))
+
+    (hello a) ; Works
+    (world a) ; Doesn't work
+
+In this example, the `Foo` object doesn't get the benefit of the default
+implementation because it implements the protocol itself.  So when figuring out
+what `world` function to call Clojure asks "Hmm, does A implement Foo?  Oh, it
+does?  Okay, I'll use A's implementations then".
+
+So the entities would either have to implement all of the aspect functions
+(resulting in the duplication of the ones they don't need to change) or none of
+them (which *would* give them the defaults).
+
+So this isn't ideal.  I could also have used multimethods here, because they
+*do* support default implementations.  But multimethods don't give me a nice
+way to group related functions together like protocols.
+
+Protocols also interact with the type system to give me handy functionality
+like:
+
+    :::clojure
+    (defn find-targets
+      "Find potential things to kill!"
+      [world]
+      (filter #(satisfies? Destructible %)
+              (vals (:entities world))))
+
+The concept of "bunnies are destructible" is a useful one, and I'd lose it if
+I used multimethods.
+
+The Macro
+---------
+
+Macros are not something you should reach for right away.  They're tricky and
+much harder to understand than a normal function.  But when all else fails,
+they're there as a last resort.
+
+I couldn't figure out a way to do this without macros, so it's time to roll up
+my sleeves and work some dark Lispy magic to get a nice syntax.
+
+When I'm writing a macro, my first step is usually to start at the end by
+writing out what I want its ultimate usage to be.  For this functionality I'm
+actually going to need a pair of macros.
+
+First, `defaspect` will replace `defprotocol` and allow me to define a protocol
+*and* provide the default implementations:
+
+    :::clojure
+    (defaspect Mobile
+      (move [this world dest]
+        {:pre [(can-move? this world dest)]}
+        (assoc-in world [:entities (:id this) :location] dest))
+      (can-move? [this world dest]
+        (is-empty? world dest)))
+
+Those implementations are generic enough (moving moves an entity from their
+current space into an empty one) that many entities will probably be able to use
+them unchanged.
+
+I'll also need a macro to replace `extend-type`.  I decided to call it
+`add-aspect`:
+
+    :::clojure
+    (add-aspect EarthElemental Mobile
+      (can-move? [this world]
+        (entity-at? world dest)))
+
+In this example the `EarthElemental` entity is implementing `Mobile`.  It will
+use the default `move` implementation (which just changes its location), but it
+overrides `can-move?`.  Earth elementals can't walk through other entities, but
+they *can* walk through the rock walls of the Caves.
+
+So I've got my examples of usage, now it's time to implement the macros.  I'll
+start with `defaspect`.
+
+My second step when writing a macro is writing out what the usage should be
+expanded into.  After a bit of thinking I came up with this:
+
+    :::clojure
+    (defaspect Mobile
+      (move [this world dest]
+        {:pre [(can-move? this world dest)]}
+        (assoc-in world [:entities (:id this) :location] dest))
+      (can-move? [this world dest]
+        (is-empty? world dest)))
+
+    ; should expand into
+
+    (do
+      (defprotocol Mobile
+        (move [this world dest])
+        (can-move? [this world dest]))
+      (def Mobile
+        (with-meta Mobile
+                   {:defaults
+                    {:move (fn [this world dest]
+                             {:pre [(can-move? this world dest)]}
+                             (assoc-in world [:entities (:id this) :location] dest))
+                     :can-move? (fn [this world dest]
+                                  (is-empty? world dest))}})))
+
+This looks a bit complicated because of the method implementations, which aren't
+really important when writing the macro, so let's remove those:
+
+    :::clojure
+    (defaspect Mobile
+      (move [this world dest]
+        {:pre [(can-move? this world dest)]}
+        (assoc-in world [:entities (:id this) :location] dest))
+      (can-move? [this world dest]
+        (is-empty? world dest)))
+
+    ; should expand into
+
+    (do
+      (defprotocol Mobile
+        (move [this world dest])
+        (can-move? [this world dest]))
+      (def Mobile
+        (with-meta Mobile
+                   {:defaults
+                    {:move (fn [this world dest] ...)
+                     :can-move? (fn [this world dest] ...)}})))
+
+That's a bit easier to read.  The `defaspect` macro is going to take all the
+forms I give it and expand into a `do` form with two actions: defining the
+protocol as before, and attaching a map to the Protocol itself with Clojure's
+metadata feature.
+
+This map will contain the default implementations.  For now just trust me that
+I'm going to need them in a map later.
+
+Now to write the actual macro!  It'll go in `entities/core.clj` for the moment.
+I'll start with a skeleton:
+
+    :::clojure
+    (defmacro defaspect [label & fns]
+      (let [fnmap (make-fnmap fns)
+            fnheads (make-fnheads fns)]
+        `(do
+           (defprotocol ~label
+             ~@fnheads)
+           (def ~label
+             (with-meta ~label {:defaults ~fnmap})))))
+
+If you've used macros before, this should be pretty easy to read.  I've pulled
+as much functionality as possible into two helper functions.  Let's look at
+those:
+
+    :::clojure
+    (defn make-fnmap
+      "Make a function map out of the given sequence of fnspecs.
+
+      A function map is a map of functions that you'd pass to extend.  For example,
+      this sequence of fnspecs:
+
+      ((foo [a] (println a)
+       (bar [a b] (+ a b)))
+
+      Would be turned into this fnmap:
+
+      {:foo (fn [a] (println a))
+       :bar (fn [a b] (+ a b))}
+
+      "
+      [fns]
+      (into {} (for [[label fntail] (map (juxt first rest) fns)]
+                 [(keyword label)
+                  `(fn ~@fntail)])))
+
+    (defn make-fnheads
+      "Make a sequence of fnheads of of the given sequence of fnspecs.
+
+      A fnhead is a sequence of (name args) like you'd pass to defprotocol.  For
+      example, this sequence of fnspecs:
+
+      ((foo [a] (println a))
+       (bar [a b] (+ a b)))
+
+      Would be turned into this sequence of fnheads:
+
+      ((foo [a])
+       (bar [a b]))
+
+      "
+      [fns]
+      (map #(take 2 %) fns))
+
+Hopefully the docstrings will make them pretty clear.  If you have questions let
+me know (or play around with them in a REPL to see how they behave).
+
+And with that, `defaspect` is complete!  I now have a way to define a protocol
+and attach some default implementations to it in one easy, beautiful call.
+
+The other macro, `add-aspect`, is a piece of cake now that I've got the helper
+functions:
+
+    :::clojure
+    (defmacro add-aspect [entity aspect & fns]
+      (let [fnmap (make-fnmap fns)]
+        `(extend ~entity ~aspect (merge (:defaults (meta ~aspect))
+                                        ~fnmap))))
+
+The important thing in understanding this macro is `extend`.  `extend` is what
+`extend-type` and `extend-protocol` sugar over.  It takes a type, a protocol,
+and a map of the implementations of that protocol's functions.
+
+The key word there is "map", which really does mean a plain old Clojure map.  So
+this macro will expand like so:
+
+    :::clojure
+    (add-aspect EarthElemental Mobile
+      (can-move? [this world dest]
+       (entity-at? world dest)))
+
+    ; should expand into
+
+    (extend EarthElemental Mobile
+      (merge (:defaults (meta Mobile))
+             {:can-move? (fn [this world dest] ...)})
+
+The `(:defaults (meta Mobile))` simply retrieves the function mapping that
+`defaspect` attached to the Protocol, so in effect I get something like:
+
+    :::clojure
+    (extend EarthElemental Mobile
+      (merge {:move (fn [this world dest] ...)
+              :can-move? (fn [this world dest] ...)}
+             {:can-move? (fn [this world dest] ...)})
+
+`merge` is just the vanilla Clojure `merge` function, so the resulting map will
+have the default implementations overridden by any custom ones given.
+
+And that's it!  Let's see it in action.
+
+Usage
+-----
+
+First I need to update the aspects to use `defaspect` and include their default
+implementations.  Here's `Destructible`:
+
+    :::clojure
+    (ns caves.entities.aspects.destructible
+      (:use [caves.entities.core :only [defaspect]]))
+
+
+    (defaspect Destructible
+      (take-damage [{:keys [id] :as this} world damage]
+        (let [damaged-this (update-in this [:hp] - damage)]
+          (if-not (pos? (:hp damaged-this))
+            (update-in world [:entities] dissoc id)
+            (assoc-in world [:entities id] damaged-this)))))
+
+The code is just torn out of the `Lichen` code.  Since this is how lichens will
+act, I can update them to use `add-aspect`:
+
+    :::clojure
+    (add-aspect Lichen Destructible)
+
+One line!  Nice.
+
+I'll make bunnies destructible too:
+
+    :::clojure
+    (add-aspect Bunny Destructible)
+
+Perfect.  I then updated `Mobile` to use the `defaspect` macro.  Look in the
+repository if you want to see that.  Now players and bunnies can both use the
+same default implementations for movement:
+
+    :::clojure
+    (add-aspect Bunny Mobile)
+    (add-aspect Player Mobile)
+
+Beautiful.  I then converted the remaining aspects and implementations to use
+these macros.
+
+Let's add some bunnies to the world.  First I'll need a `make-bunny` function
+similar to `make-lichen`:
+
+    :::clojure
+    (defn make-bunny [location]
+      (->Bunny (get-id) "v" :yellow location 1))
+
+I don't know why I picked yellow.  Are there yellow bunnies?  There are in
+*this* world.  I used a `v` as the glyph because it kind of looks like bunny
+ears.
+
+Then I updated the world-populating code over in `input.clj`:
+
+    :::clojure
+    (defn add-creature [world make-creature]
+      (let [creature (make-creature (find-empty-tile world))]
+        (assoc-in world [:entities (:id creature)] creature)))
+
+    (defn add-creatures [world make-creature n]
+      (nth (iterate #(add-creature % make-creature)
+                    world)
+           n))
+
+    (defn populate-world [world]
+      (let [world (assoc-in world [:entities :player]
+                            (make-player (find-empty-tile world)))]
+        (-> world
+          (add-creatures make-lichen 30)
+          (add-creatures make-bunny 20))))
+
+Bunnies will be a bit rarer than lichens for the moment (and maybe in the future
+they could eat them).
+
+Finally, let's run the game!
+
+![Screenshot](/media/images{{ parent_url }}/caves-interlude-1-01.png)
+
+Bunnies!  They're populated into the world and the player can kill them because
+they're `Destructible`.
+
+Right now they *can* move, but choose not to.  I'll fix that by updating their
+`tick` function:
+
+    :::clojure
+    (extend-type Bunny Entity
+      (tick [this world]
+        (if-let [target (find-empty-neighbor world (:location this))]
+          (move this world target)
+          world)))
+
+Now they'll move whenever they're not backed into a corner.  Obviously move
+complicated AI is possible, but this is fine for now.
+
+So this is all good, but I haven't even used the "override one function of an
+aspect but not others" part of my fancy macro.  I'll add another creature to
+show that off:
+
+
+    :::clojure
+    (ns caves.entities.silverfish
+      (:use [caves.entities.core :only [Entity get-id add-aspect]]
+            [caves.entities.aspects.destructible :only [Destructible]]
+            [caves.entities.aspects.mobile :only [Mobile move can-move?]]
+            [caves.world :only [get-entity-at]]
+            [caves.coords :only [neighbors]]))
+
+
+    (defrecord Silverfish [id glyph color location hp])
+
+    (defn make-silverfish [location]
+      (->Silverfish (get-id) "~" :white location 1))
+
+
+    (extend-type Silverfish Entity
+      (tick [this world]
+        (let [target (rand-nth (neighbors (:location this)))]
+          (if (get-entity-at world target)
+            world
+            (move this world target)))))
+
+    (add-aspect Silverfish Mobile
+      (can-move? [this world dest]
+        (not (get-entity-at world dest))))
+
+    (add-aspect Silverfish Destructible)
+
+
+Oh no!  The horrible silverfish from Minecraft!  They wormy little guys are
+`Mobile` and `Destructible`, but they can move through walls with their custom
+`can-move?` function.
+
+Notice how I didn't have to provide an implementation of `move`.  Silverfish
+move like any other mobile entity (just by updating their location), the only
+thing special is where they can go.
+
+After adding them to the world population, we can see a few wriggling their way
+though the walls in the northeast corner:
+
+![Screenshot](/media/images{{ parent_url }}/caves-interlude-1-02.png)
+
+Results
+-------
+
+You can view the code [on GitHub][result-code] if you want to see the end
+result.
+
+[result-code]: https://github.com/sjl/caves/tree/interlude-1/src/caves
+
+These two macros allowed me to add a new mob, with custom movement, in about 13
+lines of code (excluding imports).  That's pretty nice!  They certainly aren't
+perfect though.
+
+For one, every aspect *has* to define default implementations for its methods.
+You can't force all entities to implement it.  This isn't a big deal for now,
+but I may want to add it in later.
+
+Second, it doesn't handle docstrings properly.  Again, not a huge problem at the
+moment but something to put on the todo list for later.
+
+Finally, defining the protocol and implementations in the same form may lead to
+some tricky circular import issues.  I can always split them into separate files
+in the future though.
+
+That about wraps it up.  If you have any questions or comments let me know!  In
+the next entry I'll get back to Trystan's tutorial.
+
+{% endblock article %}
Binary file media/images/blog/2012/07/caves-interlude-1-01.png has changed
Binary file media/images/blog/2012/07/caves-interlude-1-02.png has changed