The Caves of Clojure part 3.1: World Generation
stevelosh.com
stevelosh.com
What if instead of the UI "returning a value", it instead has a property that is a function describing what to do upon selecting inventory, something like this:
[{:kind :play}
{:kind :throw}
{:kind :inventory-select
:on-select (fn [game selected-item]
; code here that returns an altered game:
; * puts selected item into throw UI
; * pushes targeting UI onto the stack
) }]
That way whenever something creates the inventory UI, it can pass in a function that specifies what to do.Let's see how this might actually work. First the easy part, the inventory selector. It takes the user's input and figures out what item they picked, then calls the callback:
(defn pop-ui [game]
(update-in game [:uis] butlast))
(defn push-ui [game ui]
(update-in game [:uis] conj ui))
(defmethod process-input :inventory-select [ui game input]
(let [selected-item (get-item game input)
callback (:on-select ui)]
(callback (pop-ui game)
selected-item)))
That's not too bad. It calls the callback with the game (after popping itself off the UI stack) and the item they picked.Now to the main play UI that's going to set up the throwing when the user presses t:
(defmethod process-input :play [ui game input]
; ...
\t (update-in game [:uis] concat (make-throw-ui-stack))
; ...
)
Also pretty clean. We're going to be appending two UIs to the stack (throw and inventory select) so we use concat, but that's fine. You could have another multimethod if you really didn't want the play UI to know anything about targeting, but that's irrelvant for now.So far so good, but now it's time to dive down the rabbit hole:
(defn make-throw-ui-stack []
[(new UI :throw)
(new UI :inventory-select
:on-select (fn [game selected-item]
,,,))])
Okay so we have the basic stack of UIs we'll need to start, all that's left is the callback. Well it needs to:* Take the game and the item that was selected.
* Return a game with the throw UI storing the item for later, and a targeting UI after that so the user can pick a monster to kill.
That doesn't sound so bad:
(defn make-throw-ui-stack []
[(new UI :throw)
(new UI :inventory-select
:on-select (fn [game selected-item]
(push-ui (new UI :target))))])
Okay, a bit indentation-heavy but readable. At least we don't have to store the selected-item in the throw UI any more, because our callback closes over it. Let's make the targeting UI: (defmethod process-input :target [ui game input]
(let [selected-monster (get-monster game input)
callback (:on-select ui)]
(callback (pop-ui game)
selected-monster)))
Cool, but wait, now we need another callback in the throw UI! (defn make-throw-ui-stack []
[(new UI :throw)
(new UI :inventory-select
:on-select (fn [game selected-item]
(push-ui (new UI :target
:on-select (fn [game selected-item]
(let [game (pop-ui game)]
(throw-item-at game selected-item selected-monster)))))))])
Okay this looks pretty bad. It's ugly, and all the logic is in the "make throw UI stack" call.Now notice that we haven't handled the "user pressed escape to cancel" case at all. Oh god.
Contrast this to the "reduce over input" method:
(defn pop-ui [game]
(update-in game [:uis] butlast))
(defn push-ui [game ui]
(update-in game [:uis] conj ui))
(defmethod process-input :inventory-select [ui game input]
(let [result (if (= input :escape)
:cancel
(get-item game input))]
(-> game
pop-ui
(assoc :input {:item result})))
(defmethod process-input :target [ui game input]
(let [result (if (= input :escape)
:cancel
(get-monster game input))]
(-> game
pop-ui
(assoc :input {:monster result})))
(defmethod process-input :throw [ui game item]
(cond
(= input :escape) (pop-ui game)
(contains? input :item) (-> game
pop-ui
(push-ui (assoc ui :item (:item input)))
(push-ui (new UI :target)))
(contains? input :monster) (throw-item-at game (:item ui) (:monster input))))
(defn make-throw-ui-stack []
[(new UI :throw)
(new UI :inventory-select)])
(defmethod process-input :play [ui game input]
; ...
\t (update-in game [:uis] concat (make-throw-ui-stack))
; ...
)
Not only do we now handle escaping from the menus, but this looks much cleaner to me because we're no longer nesting functions inside each other. We've also decoupled the "make the throwing UI" from the "game logic of the throwing UI". sjl at grendel in ~/s/caves
><((°> lein2 repl
caves.core=> (use 'caves.world)
nil
caves.core=> (new World [:foo])
CompilerException java.lang.IllegalArgumentException: Unable to resolve classname: World, compiling:(NO_SOURCE_PATH:1)
caves.core=> (import '[caves.world World])
caves.world.World
caves.core=> (new World [:foo])
#caves.world.World{:tiles [:foo]}
caves.core=> Bye for now!
sjl at grendel in ~/s/caves
><((°> lein2 repl
caves.core=> (use 'caves.world)
nil
caves.core=> (World. [:foo])
CompilerException java.lang.IllegalArgumentException: Unable to resolve classname: World, compiling:(NO_SOURCE_PATH:1)
caves.core=>
Is there another way to create record instances that doesn't involve that warty (import) call that I'm not aware of? (->World [:foo])
(->World {:tiles [:foo]})
More details here:http://dev.clojure.org/display/design/defrecord+improvements
It's good to know that progress toward patching all the places Java tends to leak through into Clojure is happening.
I can't say I'm a fan of the punctuation-laden syntax, but I'll pick my battles and not complain about it.
I'll switch over to this new form tonight and mention it in one of the later posts. Thanks!