author |
Steve Losh <steve@stevelosh.com> |
date |
Wed, 21 Aug 2024 09:10:34 -0400 |
parents |
f5556130bda1 |
children |
(none) |
(
:title "The Caves of Clojure: Part 3.3"
:snip "Scrolling."
:date "2012-07-11T09:25:00Z"
:draft nil
)
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-3` 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/
<div id="toc"></div>
Summary
-------
When the last post left off I had a random world generated and smoothed to
create some nice looking caves. The world was displayed on the screen, but it
would only display the upper left corner of the map.
This post is going to be about scrolling the viewport so we can view the entire
map. It's the last remaining piece of Trystan's third post that I still need to
implement.
Refactoring
-----------
This is going to involve changing the worst function in the code so far
(`draw-ui` for `:player` UIs), so before I start hacking away I want to factor
out a bit of functionality to clean things up.
Right now that `draw-ui` function in `core.clj` looks like this:
```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})))))
```
I pulled out the guts of that function into a helper function:
```clojure
(defn draw-world [screen vrows vcols start-x start-y end-x end-y tiles]
(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}))))
(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)]
(draw-world screen vrows vcols start-x start-y end-x end-y tiles)))
```
No functionality has changed, I just pulled the body out into its own function.
This will make things cleaner as we add more functionality.
As I mentioned in the last post, I don't like the distructuring in the argument
list here. Let's remove that:
```clojure
(defmethod draw-ui :play [ui game screen]
(let [world (:world game)
tiles (:tiles world)
[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)]
(draw-world screen vrows vcols start-x start-y end-x end-y tiles)))
```
It's a few more lines of code but I find it more readable. If you prefer the
more concise syntax feel free to use the destructuring — it's not really that
important either way.
Crosshairs
----------
Trystan draws an `X` as a kind of crosshair to take the place of the traditional
roguelike `@` (since there's no player yet), so let's do that. I made
a separate function to draw the crosshair as a red `X` in the center of the
screen:
```clojure
(defn draw-crosshairs [screen vcols vrows]
(let [crosshair-x (int (/ vcols 2))
crosshair-y (int (/ vrows 2))]
(s/put-string screen crosshair-x crosshair-y "X" {:fg :red})
(s/move-cursor screen crosshair-x crosshair-y)))
```
This function seems pretty straightforward. It finds the x and y coordinates of
the viewport where the `X` should go and puts it there. It also moves the
cursor on top of it because I like how that looks.
Yeah, it might not actually end up in the exact center of the screen because the
`int` will truncate if we've got an odd number of rows or columns. Honestly,
I'm going to be throwing away this crosshair once we've got a player on the
screen, so it's not worth fixing.
I need to call `draw-crosshairs` that in the `:play` UI-drawing function:
```clojure
(defmethod draw-ui :play [ui game screen]
(let [world (:world game)
tiles (:tiles world)
[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)]
(draw-world screen vrows vcols start-x start-y end-x end-y tiles)
(draw-crosshairs screen vcols vrows)))
```
The only change here is the `(draw-crosshairs screen vcols vrows)` after I draw
the world. This draws the crosshair `X` on top of the world, which isn't an
issue because Lanterna's double buffering will ensure that the user never sees
an intermediate render that's missing the `X`.
Now there's a red `X` in the center of the screen. Great, but we still need to
add the main point of this post: scrolling.
Scrolling
---------
Right now the `start-x` and `start-y` in the `draw-ui` function are hardcoded at
`0`. All I need to do is change those to modify which part of the map the
viewport draws, and I'll have scrolling!
First of all, I need a way to keep track of where the viewport should be
centered. This will get thrown away once we have a player (the player will be
the center of the viewport), so I'll just slap it right in the `game` object for
now:
```clojure
(defn new-game []
(assoc (new Game nil [(new UI :start)] nil)
:location [40 20]))
```
The `new-game` function now `assoc`s a `:location` into the `game` before
returning it.
I *could* have modified the `(defrecord Game [world uis input])` to add the
location as a proper field. But I know I'm going to be removing this soon
anyway, so I may as well take advantage of the fact that Clojure's record can
have extra fields `assoc`ed onto them on the fly.
`[40 20]` is an arbitrary location. It's kind of in the middleish area of the
map. Good enough.
Okay, now I need to actually display the correct area of the map in the
viewport. I'm going to need to modify `draw-ui` again, which, just as
a reminder, looks like this:
```clojure
(defmethod draw-ui :play [ui game screen]
(let [world (:world game)
tiles (:tiles world)
[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)]
(draw-world screen vrows vcols start-x start-y end-x end-y tiles)
(draw-crosshairs screen vcols vrows)))
```
I had a feeling this is going to get a bit gross, so I pulled out the code for
getting the viewport coordinates into its own helper function:
```clojure
(defn get-viewport-coords [game vcols vrows]
(let [start-x 0
start-y 0
end-x (+ start-x vcols)
end-y (+ start-y vrows)]]
[start-x start-y end-x end-y]))
(defmethod draw-ui :play [ui game screen]
(let [world (:world game)
tiles (:tiles world)
[cols rows] screen-size
vcols cols
vrows (dec rows)
[start-x start-y end-x end-y] (get-viewport-coords game vcols vrows)]]
(draw-world screen vrows vcols start-x start-y end-x end-y tiles)
(draw-crosshairs screen vcols vrows)))
```
No functionality changed, I just shuffled a bit of code out of that ugly
`draw-ui` function. As a bonus, the `get-viewport-coords` function is now pure.
It'll be easy to add unit tests for it later if I want. Cool.
Now that the viewport coordinates are isolated, it's time to calculate them
correctly instead of hardcoding them at `0`:
```clojure
(defn get-viewport-coords [game vcols vrows]
(let [location (:location game)
[center-x center-y] location
tiles (:tiles (:world game))
map-rows (count tiles)
map-cols (count (first tiles))
start-x (max 0 (- center-x (int (/ vcols 2))))
start-y (max 0 (- center-y (int (/ vrows 2))))
end-x (+ start-x vcols)
end-x (min end-x map-cols)
end-y (+ start-y vrows)
end-y (min end-y map-rows)
start-x (- end-x vcols)
start-y (- end-y vrows)]
[start-x start-y end-x end-y]))
```
This is long, but very straightforward. I use the fact that `let` doesn't care
if you rebind variables many times in the same binding vector to write
imperative code. There may be a more "clever" way to do this, but I like the
clarity.
First it finds the location of the crosshair (which will be `[40 20]` from
`new-game` at the moment). It calls that `center-x` and `center-y`.
It also pulls the tile vector out of the `game` object and uses it to
determine the full dimensions of the map. I'm thinking of having a `map-size`
constant somewhere instead of doing it this way. I may do that in a later post.
Next come these scary lines:
```clojure
start-x (max 0 (- center-x (int (/ vcols 2))))
start-y (max 0 (- center-y (int (/ vrows 2))))
```
They're not as scary as they look. Both are exactly the same except for which
dimension they're working on. First I subtract half the viewport size from the
center coordinate. This should give me either the topmost or leftmost
coordinate we're going to be drawing.
Then I use `max` to make sure that if the starting coordinate would be less than
zero (i.e.: off of the map) I just use 0 instead.
Okay, so now I've got the coordinates of the top left point I need to draw, and
I'm sure that it doesn't fall off the top or left edge of the map. Cool. Time
to get the bottom right coordinate.
```clojure
end-x (+ start-x vcols)
end-x (min end-x map-cols)
end-y (+ start-y vrows)
end-y (min end-y map-rows)
```
This is similar to how we get the starting coordinates. We calculate a "naive
end x" by adding the viewport size to the start, and then make sure the end
doesn't fall off the map.
I did this all in one line for the start coordinates, but split it into two for
the end coordinates. I'm not sure why I did it like that — I just noticed it
now. I'm going to go ahead and change the start to be the expanded, two-line
form. I think it's clearer.
Okay, so now I've ensured that the end coordinate doesn't fall off the map. I'm
done, right?
Well, not quite. If I truncated the end coordinate here I'll have ended up with
a smaller-than-normal viewport. To fix that I'll reset the start coordinates
one more time:
```clojure
start-x (- end-x vcols)
start-y (- end-y vrows)
```
This time I don't need to check any bounds. I know the end coordinate is good
because it was based on a known start coordinate (the top/left side is good) and
I corrected the bottom/right side. So I simply use this known-good end
coordinate to get a known-good start coordinate and I'm done.
If the map is smaller than the viewport size this is probably going to explode.
I'm going to ignore that for now. I may revisit it later, or I may just stick
with an 80 by 24 viewport for all time like Nethack.
That was a lot of work, but the only thing that's changed is I'm now displaying
a section of the map near the middle instead of at the upper left. The last
piece is to add the ability to adjust the `:location` in the `game` object on
the fly.
The player should be able to scroll around when they're at the `:play` UI, so
let's add the appropriate input handling:
```clojure
(defn move [[x y] [dx dy]]
[(+ x dx) (+ y dy)])
(defmethod process-input :play [game input]
(case input
:enter (assoc game :uis [(new UI :win)])
:backspace (assoc game :uis [(new UI :lose)])
\q (assoc game :uis [])
\s (assoc game :world (smooth-world (:world game)))
\h (update-in game [:location] move [-1 0])
\j (update-in game [:location] move [0 1])
\k (update-in game [:location] move [0 -1])
\l (update-in game [:location] move [1 0])
\H (update-in game [:location] move [-5 0])
\J (update-in game [:location] move [0 5])
\K (update-in game [:location] move [0 -5])
\L (update-in game [:location] move [5 0])
game))
```
I did a few things here. First I added the `q` key mapping to quit the game
without going through the win or lose screens, just to same myself some time.
Enter and backspace still win and lose the game respectively.
`s` still smooths the world map for now. No reason to remove that yet.
To handle the movement inputs I first made a `move` helper function which takes
a coordinate and an amount to move by and returns the new coordinate.
The `process-input` function uses this to get the new coordiate when it gets an
`h`, `j`, `k`, or `l` keypress. I also added the shifted versions of the
letters as "fast movement" keys for convenience.
Right now there's no bounds checking here, so it's possible for your `:location`
to get scrolled off the edge of the map. This won't be a problem for the
display (it will just snap the viewport to the edge of the map), but will make
the input a bit weird.
For example, if you scroll to the right edge of the map and press right 10 more
times, you'll need to press left 10 times before it will actually start
scrolling left again.
This is a bug, but not one I care to fix right now. I'll be replacing this code
with player-based code soon enough, so it's just going to get thrown out anyway.
Results
-------
That's it! Running the game, I can now scroll around the map and/or smooth it
whenever I like:
![Screenshot](/static/images/blog/2012/07/caves-03-3-01.png)
![Screenshot](/static/images/blog/2012/07/caves-03-3-02.png)
This doesn't look much different in pictures, but I can scroll through the world
with `hjkl`. Here's a screencast showing what that looks like:
<http://www.screenr.com/T1k8>
As always, you can view the code [on GitHub][result-code] if you want to see it
all at once.
[result-code]: https://github.com/sjl/caves/tree/entry-03-3/src/caves
That's it for Trystan's third post. Next time I'll tackle his fourth (adding
an actual player).