Wouldn’t you just know that I’d get a better idea for the scrolling code? Let’s try it. Tout le monde déteste l’IA.

Let me be clear: in the real world, I think I’d be in trouble with someone for taking so much time on this simple and ultimately not very important problem. Of course, in the real world, I’d have had team members to help me with the problem or to take it over for me. But here in the unreal world, I get to work on whatever I want to work on, and last night I had an idea that I think will work better than what we have now, will be simpler, and quite likely more clear.

And, if I recall the code that we originally had for scrolling, I think it’s quite similar to that, except that, if I’m not mistaken, it should work at all our zoom levels.

Here’s the basic idea:

We have made the decision that, normally, the cell containing Dot will be exactly centered on the screen. There are many other possibilities, and we might explore them sometime, but for now, Dot’s cell is generally centered.

When Dot is “too close” to a boundary, centering her cell would bring empty space, entirely outside the dungeon, onto the screen. We do not like that. So a subsidiary rule is that if centering Dot would bring empty space into view, we should center at the point closest to the edge that doesn’t bring in empty space. The effect will be that the view stops scrolling, and Dot moves across the screen toward the edge.

Since we generally center Dot’s cell, there will be an equal number of cells on each side of her at any zoom level. Therefore there will be an odd number of cells fully on the screen at any zoom level. N on the left, N on the right, 1 in the middle. 2N+1, always odd.

Suppose, now, that Dot is at cell N in the x direction. Then to her left will be cells N-1, N-2, …, 1, 0. If Dot were to move to cell N-1, to her left would be N-2, N-3, …, 1, 0, -1. And that is not allowed.

Therefore, given that the largest odd number of cells we can fully display is 2N+1, we cannot center at any point less than N. By analogous reasoning, we can set the maximum x value she can move to. max_x-N? max_x-N-1? Depends what we mean by max_x and in our code that is actually one more than the number of cells. Irritating, that.

Let’s do some tests. I’ll start with something really seemingly trivial, even stupid. Why? Because I have repeatedly demonstrated that I’m somehow easily confused by this scrolling idea, so I want to check against some real numbers, and I’m trying to make the test really obvious. Here’s the first bit:

    def test_max_odd(self):
        cell_size = 16
        dungeon_max = 56 # cells 0-55
        screen_size = cell_size*dungeon_max
        zoom = 16 # 16 <= zoom <= 64
        cells_on_screen = dungeon_max*cell_size/zoom
        assert cells_on_screen == dungeon_max
        one_side = self.cells_each_side(cells_on_screen)
        assert one_side == 27
        assert 2*one_side + 1 == dungeon_max - 1

    def cells_each_side(self, cells_across_screen):
        q, r = divmod(cells_across_screen, 2)
        return q if r == 1 else q -1

The meat here is of course cells_each_side, which, subject to further testing, always gives us the number of cells we must allow on each side of the center, given that cells_across_screen is the even or odd number of cells that can fit.

It comes to me as I write this that the cells across screen is rarely ever really an integer. We may want to force integers here. Let’s try not to forget that.

I know that divmod does something with floats. Let’s find out.

    def test_divmod(self):
        cells_on_screen = 15.63
        q, r = divmod(cells_on_screen, 2)
        assert q == 7
        assert r == pytest.approx(1.63)
        q, r = divmod(16.54, 2)
        assert q == 8
        assert r == pytest.approx(0.54)

I think that if we change cells_each_side to check against 0, we might be ok with floats. Let’s go back to testing that.

    def cells_each_side(self, cells_across_screen):
        q, r = divmod(cells_across_screen, 2)
        return q - 1 if r == 0 else q

Green so far. Now some other tests for that method. This test convinces me that we can’t just let floats in there.

    def test_cells_each_side(self):
        assert self.cells_each_side(56) == 27
        assert self.cells_each_side(55) == 27
        assert self.cells_each_side(54) == 26
        assert self.cells_each_side(14) == 6
        assert self.cells_each_side(15) == 7
        assert self.cells_each_side(15.999) == 7
        assert self.cells_each_side(14.999) == 7
        assert self.cells_each_side(14.001) == 7

Let’s force to integer internally to cells_each_side.

Now this passes:

    def test_cells_each_side(self):
        assert self.cells_each_side(56) == 27
        assert self.cells_each_side(55) == 27
        assert self.cells_each_side(54) == 26
        assert self.cells_each_side(14) == 6
        assert self.cells_each_side(15) == 7
        assert self.cells_each_side(15.999) == 7
        assert self.cells_each_side(14.999) == 6
        assert self.cells_each_side(14.001) == 6

    def cells_each_side(self, cells_across_screen):
        q, r = divmod(int(cells_across_screen), 2)
        return q - 1 if r == 0 else q

I think we’d like to commit these tests and then try the idea in prod.

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

    def clamped_position(self, cell):
        effective_zoom = self.dungeon_cam.zoom / cell_size # 1 - 4
        px, py = cell.position
        cells_across_screen = self.max_x/effective_zoom
        margin_x = self.cells_each_side(cells_across_screen)
        if px < margin_x:
            px = margin_x
        if px > self.max_x - margin_x - 1:
            px = self.max_x - margin_x -1
            
        cells_up_screen = self.max_y/effective_zoom
        margin_y = self.cells_each_side(cells_up_screen)
        if py < margin_y:
            py = margin_y
        if py > self.max_y - margin_y - 1:
            py = self.max_y - margin_y - 1
            
        return px, py

That works as advertised. Woot! Here are a few pics at random zoom factors:

pic showing correct zoom in a corner

pic showing correct zoom in a corner

pic showing correct zoom in a corner

pic showing correct zoom in a corner

pic showing correct zoom in a corner

We can improve this code.

This:

    if px < margin_x:
        px = margin_x

Is the same as this:

    px = max(px, margin_x)

And this

    if px > self.max_x - margin_x - 1:
        px = self.max_x - margin_x -1

Is the same as this:

    px = min(px, self.max_x - margin - 1)

And PyCharm inline gives us:

    px = min(max(px, margin_x), self.max_x - margin_x - 1)

Similarly for y, of course, and now we have:

    def clamped_position(self, cell):
        effective_zoom = self.dungeon_cam.zoom / cell_size # 1 - 4
        px, py = cell.position
        cells_across_screen = self.max_x/effective_zoom
        margin_x = self.cells_each_side(cells_across_screen)
        px = min(max(px, margin_x), self.max_x - margin_x - 1)

        cells_up_screen = self.max_y/effective_zoom
        margin_y = self.cells_each_side(cells_up_screen)
        py = min(max(py, margin_y), self.max_y - margin_y - 1)

        return px, py

There’s duplication of three lines. Would it be worth it to extract? Not really, we’d have to pass in the max and the zoom and the coordinate for at least three parameters to the method. I think we’ll let this ride.

We are green and the game looks better than ever. We’ll commit: replaced clamped position with margin idea.

Summary

So. At the cost of a week of sessions, we have what seems to be a robust and fairly simple scheme for scrolling the screen as we desire. However … here’s the code that I’ve replaced. It didn’t quite work but it was close.

    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):
        mx = self.max_x * cell_size
        my = self.max_y * cell_size
        return mx, my

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

We can kind of see that this rhymes with what we’ve wound up with. But it didn’t work right, so it’s not a fair comparison.

That said, it has taken me a disappointingly long time to get this seemingly simple thing to work, and I’ve worn out a lot of 3x5 cards and Procreate drawings coming up with the current idea. I do think the idea is nearly right, and we’re getting the displays that we want. The full zoom is correct but a bit off center. Maybe I’ll chase that, but probably I won’t.

Despite the many times I’ve worked out graphical things like this, and despite understanding the matrix approach (at some time in the past), and despite all my degrees in math and computer science, I recall many sessions like these recent ones, where something is off by one or a half. I think the core issue is the standard fence post problem, dealing with the bars or the posts, there’s one more post than there are bars, and for me at least, that seems to generate a lot of off by one or off by half of one errors.

Bottom line, this issue has been consuming my mind and my time, and I hope that we can put it to bed now, at least for a time.

We really need to see that inventory. Soon, I promise. Soon. See you … soon!