Hello, loves!

We’d like the dungeon picture to fill the window out to the edge. I’m not as quick as some ideal person who wasn’t here, but I think I’ve got a handle on it.

We’re still in the task of simplifying our graphical display code. After a quick review of recent changes, we need to address this need:

Thinking about the Need

Generally, we scroll the dungeon picture to keep Dot in the center of the map display, regardless of the zoom level. As things stand, if Dot is at the edge of the map, half the screen will be empty, as there is no map beyond that point in that direction. Please change the display so that the map never pulls away from the edge of her pane.

Here’s a picture of what we’re trying to fix:

map with Dot centered but lower half of window is blank

In that picture, Dot is around the zero coordinate in y, and we’re displaying all that negative y area, where nothing can exist. We’d like to have the scrolling stop, such that the bottom edge of that room sticks to the bottom of the screen, and so on for all four sides.

I’ve read more deeply about the Arcade Camera object, and I think it can give us info that will be useful for getting this right, such as telling us the window coordinates of “world” coordinates.

A word about that. Our “world” coordinates, internally, are integers from 0 up to some maximum, around 50 or 60, to be determined. At each integer point (i,j) we have a Cell with those coordinates. The cell has a floor tile and possibly some content images associated with it, and the cell coordinates are the center of the drawing of that cell. That is recently changed as part of our adjusting the display code. We used to think of the lower left corner of the cell, such that the picture extended in the positive direction in x and y. And we also scaled those coordinates by 16, so that if Dot was at (1,2) in the Dungeon, we were telling the display to display 16*(1,2), or (16,32). At the time, that made sense to me.

In the current scheme, we don’t scale the coordinates up, we zoom 16 times more strongly, to expand the image. Seems to make more sense.

There is, however, something that we’ll need to keep in mind. Since we are focused on the center of the cell now, when we are in the lower left corner, the minimum coordinate to display will look like (-0.5, -0,5), and the cell extends up to (0.5, 0.5). I believe that mostly we will not have to worry about that, but we’ll have to deal with it in at least one place. With luck (and skill, if we have any) only one place.

Thinking about the Design

There is a place in the code now, where we used to adjust the margins, and that adjustment doesn’t work after changing over to the new unscaled world notion. We’ll look at that place, and how all this happens, in just a moment. First let’s think about what we need to do.

I freely grant that I find the window-world coordinate changes difficult to think about, despite the fact that decades ago I was all about affine transformation matrices and, after that, actually built display code that did all that stuff. At the time, it seemed I just swam freely in all that. Now … not so much. So I try to go slowly and carefully.

If our dungeon (world) coordinates go from 0 up two and not including N, we need to display the world coordinate from -0.5 to N-0.5, to allow space for the tile and contents, which extend 0.5 in all directions around the central point.

So I expect that in our margin calculation, we’re going to see some 0.5 thinking.

Existing Code

Let’s review our changes so far.

class ContentSpriteMaker:
    textures = TextureProvider()
    def __init__(self, resources, scale=0.5):
        self.sprite = self.textures.load_sprite(resources)
        self.sprite.set_texture(0)
        self.sprite.visible = params.sprite_visible
        self.sprite.scale = scale_texture(self.sprite.textures[-1], scale*16)

That *16 was the only change, which scales all our textures up by 16 to compensate for the cell being “smaller” by a factor of 16. I’m not loving the whole texture scaling thing, but if that 16 is the worst thing that ever happens we’re OK.

Current:

class DungeonView:
    def draw_adventurer(self):
        player_cell = self.dungeon.player_cell
        if not player_cell: return
        cx, cy = self.dungeon.player_cell.position
        arcade.draw_circle_filled(cx, cy, 1 / 4, arcade.color.RED)

Was:

class DungeonView:
    def draw_adventurer(self):
        player_cell = self.dungeon.player_cell
        if not player_cell: return
        cx, cy = self.dungeon.player_cell.center_position(cell_size)
        arcade.draw_circle_filled(cx, cy, cell_size // 4, arcade.color.RED)

The cell size is 1 and we want Dot to have a radius of 1/4 of the cell. There are a number of changes like that and I think I’ll not mention them here unless they become germane.

OK, I think we really only need to take a look at Cameras and then get down to it.

class Cameras:
    def __init__(self, view, max_x, max_y, zoom):
        self.view = view
        self.max_x = max_x
        self.max_y = max_y
        self.dungeon_cam = arcade.Camera2D()
        self.init_zoom(zoom)

    def init_zoom(self, zoom):
        self.dungeon_cam.zoom = zoom

    def scroll_dungeon_cam(self, cell):
        px, py = self.clamped_position(cell)
        self.dungeon_cam.position = (px, py)

    def clamped_position(self, cell):
        px, py = cell.position
        mx, my = self.max_position()
        px = self.apply_margin(mx, px)
        py = self.apply_margin(my, py)
        return px, py

    def max_position(self):
        return self.max_x, self.max_y

    def apply_margin(self, max_coord, coord):
        margin = max_coord / (2 * self.dungeon_cam.zoom)
        return min(max_coord - margin, max(coord, margin))

    def adjust_zoom(self, scroll_movement):
        min_zoom = 16
        max_zoom = 64
        scale = 1
        self.init_zoom(max(min_zoom, min(self.dungeon_cam.zoom + scroll_movement / scale, max_zoom)))

It’s the apply_margin method that needs to be fixed up. I’m not going to try to understand what it says now, that’s left over from the old scheme and we need modern thinking here.

My reading of the Arcade Camera documentation suggests to me that given a world coordinate, we can get the screen coordinate. Let me look for that again … here it is.

I find this after exhaustive search of the page:

project(world_coordinate)→ Vec2
Take a Vec2 or Vec3 of coordinates and return the related screen coordinate

I’d like to find out what that really does. Let’s write a test. We could print some stuff or something but let’s try to be a little more careful.

class TestCamera:
    def test_project(self):
        win = arcade.Window(100, 100)
        cam = arcade.Camera2D()
        px, py = cam.position
        assert (px, py) == (50,50)
        x, y = cam.project((0,0))
        win.close()
        assert (x,y) == (0,0)
        x, y = cam.project((1,2))
        win.close()
        assert (x,y) == pytest.approx((1,2))

This passes. The actual values are almost 1 and 2 but more like:

E         +     1.0000000000000009,
E         +     2.0000000000000018,

Don’t you just love floating point?

The first thing to notice here is that by default, the camera looks at the center of the window, 50, 50. We’ll be setting it to look at Dot (and then adjusting to look near to Dot if need be).

What I want to understand is what world coordinates the window has when we’re looking at various points in the world, at a given zoom factor. So, another test:

    def test_zoomed_edges(self):
        win = arcade.Window(100, 100)
        cam = arcade.Camera2D()
        win.close()
        cam.zoom = 10
        cam.position = (0,0)
        wx, wy, _z = cam.unproject((0,0))
        assert (wx, wy) == (-5, -5)

tHe unproject method, given a screen coordinate, returns the world coordinate at that location. So at zoom 10, we have 10 cells in the screen, and if we are looking at (0,0) in the center, we’d have (-5, -5) at the lower left.

I think the zoom of 10 is too large compared to the screen. Let’s set the screen to 1000x1000.

Brain tired. Need to step away, rest, draw a picture of the screen, think.

Friday around 0900 hours

The above was around ten o’clock Thursday. I came back and wrote a few more tests in the afternoon, then in the evening drew some pictures trying to express what the tests told me. We’ll review. not too deeply, and then dig in further. Let’s start with the pictures, though they were drawn after the tests. Keep in mind that these pictures are my notes to myself, not something prepared for public consumption. So they are a bit ragged and cryptic in places.

In all these pictures, the idea is that the “world”, the dungeon, is 50x50 cells, and the screen is 1000x1000 pixels. Those are the figures I used in the tests, with the hope that it would make it easier to understand the numeric results. And they all deal with the values of the Camera method ‘unproject’, which is given screen pixel coordinates (0-1000) and returns the corresponding world coordinates I(0-50). We vary zoom and observe its effect on unprojecting the lower left (0,0) and upper right (1000,1000) pixels, to see how much of the world is shown in the window.

I think all the examples in the picture have set the camera position at (25,25), which represents the point in the world (dungeon) that will be centered on the screen.

drawing and figures showing effect of zoom and unproject

Two cases checked here. In the upper example, at zoom=1, looking at (25,25), we see these values:

zoom 1
screen world
(0, 0) (-475, -475)
(1000, 1000) (525, 525)


Zoom = 1 means that Arcade assumes that the world scale is the same as the screen, so that one pixel on the screen corresponds to one pixel in the world. If position were (0,0) the values would be (-500,=500) to (500,500). Since we moved up to (25,250), the upper right corner moves up to (525,525) and the lower left moves up to (-475, -475).

There was a time in my past when I had done this kind of thing so often that I could do all this in my head. Those days are long gone, and so, I guess, is my head.

There is a key fact here: the position applies to the center of the view, not, as one might possibly expect, the lower left. As presently defined, our dungeon world extents only into the first quadrant, with only positive x and y values. I’m not sure quite how that will affect us.

In the lower example I had the notion of the “basic zoom”, a zoom value that exactly scales the size of the world to the size of the window. This notion is still imprecise, since the world and the screen do not have to be proportional one to the other. But if they are the value of the basic zoom is screen-size/world-size, or in our case 1000/50, which is, if I’m not mistaken, 20. We get these values:

basic zoom
s/w = 20
screen world
(0, 0) (0, 0))
(1000, 1000) (50, 50)


Recall that position is (25, 25), dungeon center in this example. So the world fits exactly, with its (0, 0) displayed at bottom left and (50,50) at top right.

drawing and figures showing effect of zoom and unproject

In the picture above, we consider two more zoom factors:

zoom 2.5 * basic
screen world
(0, 0) (15, 15))
(1000, 1000) (35, 35)


Since basic gets a range of 50 in the window, 2.5 will get 50 / 2.5 cells, or 20. Since we’re centered on (25, 25), we see from 15 to 35.

zoom 5 * basic
screen world
(0, 0) (20, 20))
(1000, 1000) (30, 30)


At zoom 5 times basic, we get a range of ten in the window, centered on 25, showing 20 to 30.

Let’s just glance at one of the tests I wrote to get this information. I could have done it with print statements, maybe even in a REPL, but I like to use tests because they retain information and record it for the future. If you’re into naming things you might call these “characterization tests” because they characterize what the system does. I don’t generally call them anything, I just write the tests that I think I need. Here’s one of those coming by right now …

    def test_procreate_1(self):
        w = 50
        world = (w, w)
        s = 1000
        screen = (s, s)
        basic_zoom = s / w
        win = arcade.Window(*screen, visible=False)
        cam = arcade.Camera2D()
        win.close()
        cam.zoom = 1
        cam.position = (25, 25)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((-(s / 2 - 25), -(s / 2 - 25)))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((s / 2 + 25, s / 2 + 25))

        cam.zoom = basic_zoom
        cam.position = (25, 25)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((0, 0))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((w, w))

        cam.zoom = 2.5 * basic_zoom
        cam.position = (25, 25)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((15, 15))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((35, 35))

        cam.zoom = 5 * basic_zoom
        cam.position = (25, 25)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((20, 20))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((30, 30))

You can see there the tests that provide the values in the pictures and tables above. I wrote a lot of tests yesterday as I tried to build up my intuition about all this.

A Plan Takes Shape

Imagine that we are at basic zoom, looking at the center of the world. The entire world will be in our window, assuming that window and world are proportional, which they are (at least for now).

If we were to change the view position to 26,26, what would happen? I predict that this would happen:

Note:
Prediction is flat wrong. Went in the wrong direction.
basic zoom
s/w = 20
screen world
(0, 0) (-1, -1))
(1000, 1000) (51, 51)


Let’s write that test and see:

        cam.zoom = basic_zoom
        cam.position = (26, 26)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((-1, -1))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((w+1, w+1)) # 51, 51

Doesn’t pass. I bet it’ll pass with +1s.

        cam.zoom = basic_zoom
        cam.position = (26, 26)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((1, 1))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((w+1, w+1)) # 51, 51

Makes sense, of course. In my defense, I changed my mind and did 26,26 instead of 24,24 at the last minute. Yeah, that’s it.

But here’s the idea I’m working toward: at basic zoom, if we move away from world center by any non-zero amount, the coordinates taken in will go out of world bounds, either too high (51 in this case) or too low (-1 if we use 24). Let’s do a test showing both:

        cam.zoom = basic_zoom
        cam.position = (24, 26)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((-1, 1)) # x is out
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((49, 51)) # y is out

That test runs. We see that the x is out of range by -1. To get it back in range we need to subtract -1 from x=24, getting 25. We see that the y is out of range by +1. We need to subtract +1 from y=26, getting 25.

Now of course it is obvious that if we are at basic zoom, we must never look at anything but the true center of the world, lest we view outside it. But the principle we’re building up is becoming clear.

Aside:
My thoughts, which I’ll describe in just a moment, remind me of one of the Arcade examples. I make a note to look into it.

As we zoom in from the basic zoom (larger zoom values), the region where looking directly at a point in the world doesn’t expose any non-world cells grows. I think that at zoom 2*basic, we can look at 13 safely, but not at 12 (because 1/4 of 50 is 12.5). Let’s test that.

        cam.zoom = 2*basic_zoom
        cam.position = (13, 13)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((0.5, 0.5))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((25.5, 25.5))

        cam.position = (12, 12)
        x, y, z = cam.unproject((0, 0))
        assert (x, y) == pytest.approx((-0.5, -0.5))
        x, y, z = cam.unproject((s, s))
        assert (x, y) == pytest.approx((24.5, 24.5))

That test runs as expected. We note that being off by one cell in setting position makes us move only 0.5 in the world view, because zoom is 2 and therefore we move 1/zoom for each step of 1.

But the point is … I’m starting to see it … at high zoom factors the screen is logically smaller than the world, because we’re magnifying the world. So picture a small rectangle moving around inside a big one. We want that small rectangle never to go outside the big one. So the small rectangle’s center must stay inside a margin equal to half the width (or height) of the rectangle. The margin is expressed in world coordinates, as we see above, where the margin is 12.5.

Pause, Reflect, Break

It has taken me longer than I feel it should have to come down to this simple idea of the margin. We’ll come back to that feeling, because in addition to feeling it took too long, I feel good, because I’m confident that this is a key idea in keeping the picture in the window, and it seems pretty simple, and probably easy to code.

I’m not sure what we’ll do next, write some tests of the margin idea, or just put it into the code and look at the screen. I think I’d be wise to do the tests first. It’ll verify my thinking and probably help get the code better structured. Now that I’ve said that, it’s clear that I have to do the tests or you’ll all be pointing at me and laughing, even more than usual.

But I think we’ve got it, and I deserve a break, as it is now just about 1100.

Let’s talk about how long it has taken. I really do feel, for two reasons, that some ideal Ron Jeffries would have cracked this problem in moments. That was the young one who had recently finished a math course in transformations, and who had written a lot of basic-level graphics code for the DEC PDP-1 Type 340 Display. Yes, that long ago. And, if the answer is as simple as it now seems, it “should” have been simple to see. And, I’m pretty sure, if there is an Arcade example that does just about this thing, I “should” have remembered.

Yeah, well, that idealized person wasn’t available, so they called on me to stand in, and by golly I believe I’ve cracked it.

It’s easy now, why wasn’t it easy yesterday or the day before? I knew some guy who could have solved it in seconds, on the back of a bar napkin. Blah blah. Stop that stuff. Just stop. Things take as long as they take. Don’t take any crap from yourself, and don’t take any from some other person who didn’t have to solve it. Good point, boss, next time I get hung up on something, I’ll come right to your office and have you explain the math to me, right? Thanks!

I think we’re on the right track. I think we’ll have a clean solution. Good stuff. See you next time!