Still Zooming
Hello, loves! Tout le monde déteste l’IA.
- Added in Post:
- This first bit is all waste. The scheme that seems to work works by accident and isn’t the right approach at all. Skip to 0400.
We need to get the boundary behavior right. This may mean I have to understand my code. Arrgh. Long, but rather nice.
If you are like me—and if you are, you have my sincere sympathy—and if you’ve done much graphics coding at all, there have been places where you fiddled with the calls and numbers and then backed away slowly, without quite understanding why the code worked. Just me? OK.
I am confident that at our normal scale of 4, the screen behaves as intended: the view scrolls side to side and up and down, leaving Dot in the center of the screen … unless said scrolling would leave the view looking outside the space of possible cells: we never try to look at cell -1, or cell max+1, instead stopping with cell 0 or cell max on the edge. Once the edge point is reached, Dot moves toward the edge rather than always staying centered and the dungeon moving around her.
I freely grant that I got that behavior by bashing things. It wasn’t random bashing, quite. The values used are sensible ones, the cell size, the min and max cell numbers, the cell size, and such. But the way they get combined and used isn’t clear to me, and I am not entirely clear about the camera methods and properties used.
At scales greater than 1, we need to ensure that we never try to draw cells that are outside the bounds [0, max). At scale 1, the dungeon exactly fits the screen. At scale 4, the scrolling works. Let’s see what happens at scale 2.
Well! I am surprised to find that it works perfectly for scale 2! I still want to understand but at least we don’t have to change it.
I suspect that scale 3 will be a bit off, and it is:

In the picture above, there is a half-cell gap between the edge cell and the border. That might be acceptable, but it’s not right. I think it’ll be wrong because there’s an 0.5 coming up somewhere in the calculations. Something like that. Maybe.
Let’s check the code that handles this issue, then probably we’ll instrument it (ok, add some print statements to it) to see if we can see where it’s going wrong.
class Cameras:
def init_zoom(self, zoom):
self.dungeon_cam.zoom = zoom
width_margin = self.compute_margin(self.max_x, zoom)
height_margin = self.compute_margin(self.max_y, zoom)
print(f'{self.view.width=}, {self.view.height=} {width_margin=} {height_margin=}')
self.dungeon_camera_bounds = (
self.margin_rectangle(self.view.width / 2, self.view.height, width_margin, height_margin)
)
def compute_margin(self, cell_count, zoom):
cells_visible = cell_count // zoom
desired_margin = cells_visible // 2
pixel_margin = desired_margin * cell_size
print(f'{cells_visible=}, {desired_margin=}, {pixel_margin=}')
return pixel_margin
def margin_rectangle(self, view_width, view_height, width_margin, height_margin):
shift = -14*8*4/self.dungeon_cam.zoom # move rectangle left to shift the viewpoint right
rect = arcade.LRBT(shift + width_margin, shift + view_width - width_margin, height_margin, view_height - height_margin)
return rect
I think I see the issue, in compute_margin. What happens if we change it like this:
def compute_margin(self, cell_count, zoom):
cells_visible = cell_count // zoom
desired_margin = cells_visible / 2
pixel_margin = desired_margin * cell_size
print(f'{cells_visible=}, {desired_margin=}, {pixel_margin=}')
return pixel_margin
That has no useful effect. Good try, but didn’t win the big stuffed rabbit. I add a print of rect and run, getting this very telling info dump:
zoom=4, cells_visible=14, desired_margin=7.0, pixel_margin=112.0
zoom=4, cells_visible=14, desired_margin=7.0, pixel_margin=112.0
self.view.width=1792, self.view.height=896 width_margin=112.0 height_margin=112.0
rect=Rect(left=0.0, right=672.0, bottom=112.0, top=784.0, width=672.0, height=672.0, x=336.0, y=448.0)
zoom=3, cells_visible=18, desired_margin=9.0, pixel_margin=144.0
zoom=3, cells_visible=18, desired_margin=9.0, pixel_margin=144.0
self.view.width=1792, self.view.height=896 width_margin=144.0 height_margin=144.0
rect=Rect(left=-5.333333333333343, right=602.6666666666666, bottom=144.0, top=752.0, width=608.0, height=608.0, x=298.66666666666663, y=448.0)
zoom=2, cells_visible=28, desired_margin=14.0, pixel_margin=224.0
zoom=2, cells_visible=28, desired_margin=14.0, pixel_margin=224.0
self.view.width=1792, self.view.height=896 width_margin=224.0 height_margin=224.0
rect=Rect(left=0.0, right=448.0, bottom=224.0, top=672.0, width=448.0, height=448.0, x=224.0, y=448.0)
zoom=1, cells_visible=56, desired_margin=28.0, pixel_margin=448.0
zoom=1, cells_visible=56, desired_margin=28.0, pixel_margin=448.0
self.view.width=1792, self.view.height=896 width_margin=448.0 height_margin=448.0
rect=Rect(left=0.0, right=0.0, bottom=448.0, top=448.0, width=0.0, height=0.0, x=0.0, y=448.0)
I fiddled with the settings for a bit. Then I stopped to study how that .grips.constrain_xy works. Then I drew a picture. Studied some more. Finally concluded that the code shown above is really the wrong way to do it. Went to bed.
0400 Hours. Really.
- Added in Post:
- What follows is long but just because I refactor the code in very tiny steps, showing each one, unless I accidentally skipped one. So you can scan, if you’re even still here.
The above was done starting at 0900 Thursday, with studying going on Thursday afternoon and evening. It is now 0400 hours on Friday. I am awake for no particular reason and so I decided to get up and try something new. I am a morning person, but this is a bit much. Still, I was just awake enough to realize that I wasn’t gong to drop back to sleep.
Here is my new plan, as always rough and sure to change:
- Rip out the mini-map.
- Reduce the window back down to one pane wide.
- Make the minimal changes to put the zoomable map back inside the window.
- Change the window centering to use the right approach.
I think “the right approach” is, approximately, set the camera center to the desired position, unless that position is too close to the edge, in which case set it a fixed distance from the edge, equal to half the view size.
I am at a save point, nothing bad committed. Let’s get to it.
In DungeonView:
class DungeonView(arcade.View):
def __init__(self, dungeon):
if arcade.window_commands._window:
super().__init__()
self.cameras = Cameras(self, dungeon.max_x, dungeon.max_y, zoom=params.zoom_factor)
self.dungeon = dungeon
self.pub_sub = dungeon.pub_sub
self.keys = None
self.keyed_sprites = None # updated below
KeyedSpriteListMaker(dungeon).update(self)
self.subscribe(self.dungeon, self.pub_sub)
if arcade.window_commands._window:
self.section_manager = SectionManager(self)
self.scroller_section = ScrollerSection(
self.window.width/2, 0,
self.window.width/2, self.window.height,
name="RightScroller",
prevent_dispatch_view=set()
)
self.section_manager.add_section(
self.scroller_section
)
self.mini_map_section = MiniMapSection(
self,
0, 0, self.window.width/2, self.window.height,
name="MiniMap",
prevent_dispatch_view=set()
)
self.section_manager.add_section(
self.mini_map_section
)
Remove the last two statements, removing the minimap. Change the scroller section to start at 0,0 and be the width of the window, which we need to change in a moment.
if arcade.window_commands._window:
self.section_manager = SectionManager(self)
self.scroller_section = ScrollerSection(
0, 0,
self.window.width, self.window.height,
name="Scroller",
prevent_dispatch_view=set()
)
self.section_manager.add_section(
self.scroller_section
)
In main, change the window size to square:
def main():
# random.seed(35)
# random.seed(234)
layout = DungeonLayout(56, 56)
dungeon = Dungeon(layout)
stepper = make_build_table(layout, dungeon)
screen_width = cell_size*dungeon.max_x
screen_height = cell_size*dungeon.max_y
arcade.Window(screen_width, screen_height, 'Caveat Emptor')
...
At this point I expect to see the scroller but no map, but I won’t be surprised at something worse. It’s different, not necessarily worse:

Interesting but not sought after, as my sainted brother once said about green shoes. Let’s look at the view and see what we can remove to fix this up. I think we’ll remove the ctx stuff that splits the screen for sure, and the vertical line. And some shift ideas. Here is on_draw before any changes:
def on_draw(self):
self.clear()
arcade.draw_line(self.window.width/2, 0, self.window.width/2, self.window.height, arcade.color.WHITE)
player_cell = self.dungeon.player_cell
if player_cell:
self.cameras.scroll_dungeon_cam(self.dungeon.player_cell)
with self.cameras.dungeon_cam.activate():
ctx = self.window.ctx
ctx.scissor = (
self.window.width/2, 0, self.window.width/2, self.window.height
)
self.keyed_sprites.draw()
self.draw_adventurer()
self.draw_flood()
ctx.scissor = None
Remove the ctx scissoring stuff, and the line.
def on_draw(self):
self.clear()
player_cell = self.dungeon.player_cell
if player_cell:
self.cameras.scroll_dungeon_cam(self.dungeon.player_cell)
with self.cameras.dungeon_cam.activate():
self.keyed_sprites.draw()
self.draw_adventurer()
self.draw_flood()
This may draw a full window, off center, but again I won’t be surprised at much.

That’s about what I expected. Now we need to look inside the Cameras object, where the map scrolling happens. Note that DungeonView is scrolling and drawing with the Cameras dungeon_cam. There is currently only one camera in Cameras, but we’ll not concern ourselves with that detail just now. Here’s the code from Cameras:
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.scroller = Scroller(lines=4, base=(512,800))
self.dungeon_cam = arcade.Camera2D()
self.init_zoom(zoom)
def init_zoom(self, zoom):
self.dungeon_cam.zoom = zoom
width_margin = self.compute_margin(self.max_x, zoom)
height_margin = self.compute_margin(self.max_y, zoom)
print(f'{self.view.width=}, {self.view.height=} {width_margin=} {height_margin=}')
self.dungeon_camera_bounds = (
self.margin_rectangle(self.view.width / 2, self.view.height, width_margin, height_margin)
)
def compute_margin(self, cell_count, zoom):
cells_visible = cell_count // zoom
desired_margin = cells_visible // 2
pixel_margin = desired_margin * cell_size
print(f'{desired_margin=}, {cells_visible=}, {pixel_margin=}')
return pixel_margin
def margin_rectangle(self, view_width, view_height, width_margin, height_margin):
shift = -14*8*4/self.dungeon_cam.zoom # move rectangle left to shift the viewpoint right
rect = arcade.LRBT(shift + width_margin, shift + view_width - width_margin, height_margin, view_height - height_margin)
return rect
def scroll_dungeon_cam(self, cell):
cx, cy = cell.xy
# shift = (cx - self.max_x)*cell_size/4
shift = -14*8*4/self.dungeon_cam.zoom # TODO
self.dungeon_cam.position = (shift + cx * cell_size, cy * cell_size)
# self.dungeon_cam.position = (cx * cell_size, cy * cell_size)
self.dungeon_cam.position = (
arcade.camera.grips.constrain_xy(
self.dungeon_cam.view_data, self.dungeon_camera_bounds
)
)
I think we’ll rip out most everything here and set the dungeon_cam position to the desired position and leave it there. I think it might just display Dot. We’ll see. As changed:
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.scroller = Scroller(lines=4, base=(512,800))
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):
cx, cy = cell.xy
self.dungeon_cam.position = (cx * cell_size, cy * cell_size)
I was thinking at some earlier point, why doesn’t Cell know how to return the screen-scaled x and y? We’ll try to remember that. What does this do on screen?

That looks nearly right but I’m not sure whether it’s centered as we’d like. Odd. Let’s draw some cross-hairs. It is OK, this is what I expect:

We center on Dot’s position, which is actually the lower left corner of her tile. We may find that we need to adjust that, but it has been how we do it all along.
I think that if Dot were to walk to a border, the border would come in to meet her, which we do not want. Even before Dot gets there, I see that when we zoom out we are not getting the leftmost tile. Perhaps others as well. Compare these two pics.


Note that after I move Dot left, we see one more tile there. That is odd. Moving right along …

We do not want that black space on the left and top. I am more concerned about the missing bit when zoomed out. That’s the first surprise we’ve had this morning.
After walking Dot around a bit, I realize that the bug is in my head (not in the RFK Jr sense). Dot does not start at center screen. So until we do something about the margins, we cannot expect the zoomed out screen to be centered.
We have reached step 4: Change the window centering to use the right approach.
Imagine a small square, our window/view, floating over a large square, our world. Our window is 16 (a random number chosen to be seriously divisible by 2) times the number of cells in x and y, which is 56. The window is nominally 896 pixels wide. (On my retina screen it is actually 2016, for reasons that only Apple knows.) At zoom 4, our starting size, we have 14x14 tiles on the screen, because 56 over 4 is 14. When we are near a border, therefore, we want to stop scrolling when half that many tiles are showing on that near size.
Here is where we set the view position:
class Cameras:
def scroll_dungeon_cam(self, cell):
cx, cy = cell.xy
self.dungeon_cam.position = (cx * cell_size, cy * cell_size)
We want to limit that position so that it doesn’t get too close to zero or to 896. At zoom 4, we want 7 squares on each side. Let me just hammer that in: it helps me think about these things.
def scroll_dungeon_cam(self, cell):
cx, cy = cell.xy
mx = self.max_x*cell_size
my = self.max_y*cell_size
margin = 7*16
px = cx * cell_size
if px < margin: px = margin
if px > mx - margin: px = mx - margin
py = cy * cell_size
if py < margin: py = margin
if py > my - margin: py = my - margin
self.dungeon_cam.position = (px, py)
That’s the code for zoom 4, where we need 7 cells. It works. Of course the other zooms are only using seven, so they do not look right.
At zoom 2, the margin should be 14, not 7, since twice as many tiles show up at 2 compared to 4. So the equation is this:
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
And the method is this:
def scroll_dungeon_cam(self, cell):
cx, cy = cell.xy
mx = self.max_x*cell_size
my = self.max_y*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
margin_y = my/(2*self.dungeon_cam.zoom)
px = cx * cell_size
if px < margin_x: px = margin_x
if px > mx - margin_x: px = mx - margin_x
py = cy * cell_size
if py < margin_y: py = margin_y
if py > my - margin_y: py = my - margin_y
self.dungeon_cam.position = (px, py)
And that works as intended. We can improve this code. (I should hope so!) Let’s see if we can do it in nice tiny steps.
- Note:
- Nice little refactoring sequence here, small steps to better code.
Let’s reorder things.
def scroll_dungeon_cam(self, cell):
cx, cy = cell.xy
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = cx * cell_size
if px < margin_x: px = margin_x
if px > mx - margin_x: px = mx - margin_x
my = self.max_y*cell_size
margin_y = my/(2*self.dungeon_cam.zoom)
py = cy * cell_size
if py < margin_y: py = margin_y
if py > my - margin_y: py = my - margin_y
self.dungeon_cam.position = (px, py)
PyCharm notes the duplication, doesn’t offer a solution. Isn’t that first conditional max?
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = cx * cell_size
px = max(px, margin_x)
if px > mx - margin_x: px = mx - margin_x
And isn’t that last line min?
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = cx * cell_size
px = max(px, margin_x)
px = min(mx - margin_x, px)
And can’t we inline that?
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = cx * cell_size
px = min(mx - margin_x, max(px, margin_x))
And can’t we inline px again?
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = min(mx - margin_x, max(cx * cell_size, margin_x))
Can we make a method of those last two lines? Maybe. But first, let’s get some help from Cell.
class Cell:
@property
def position(self):
return self.x*cell_size, self.y*cell_size
Use that:
def scroll_dungeon_cam(self, cell):
px, py = cell.position
mx = self.max_x*cell_size
margin_x = mx/(2*self.dungeon_cam.zoom)
px = min(mx - margin_x, max(px, margin_x))
my = self.max_y*cell_size
margin_y = my/(2*self.dungeon_cam.zoom)
if py < margin_y: py = margin_y
if py > my - margin_y: py = my - margin_y
self.dungeon_cam.position = (px, py)
Extract method:
def scroll_dungeon_cam(self, cell):
px, py = cell.position
mx = self.max_x*cell_size
px = self.apply_margin(mx, px)
my = self.max_y*cell_size
margin_y = my/(2*self.dungeon_cam.zoom)
if py < margin_y: py = margin_y
if py > my - margin_y: py = my - margin_y
self.dungeon_cam.position = (px, py)
def apply_margin(self, mx, px):
margin_x = mx / (2 * self.dungeon_cam.zoom)
px = min(mx - margin_x, max(px, margin_x))
return px
Might best test this, I’m pretty sure I did this all correctly but I did type a couple of things: it wasn’t all machine refactoring. Works. Apply the function in the second bit.
def scroll_dungeon_cam(self, cell):
px, py = cell.position
mx = self.max_x*cell_size
px = self.apply_margin(mx, px)
my = self.max_y*cell_size
py = self.apply_margin(my, py)
self.dungeon_cam.position = (px, py)
def apply_margin(self, mx, px):
margin_x = mx / (2 * self.dungeon_cam.zoom)
px = min(mx - margin_x, max(px, margin_x))
return px
I think we should extract all that computation from the scroll method.
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 = self.max_x * cell_size
px = self.apply_margin(mx, px)
my = self.max_y * cell_size
py = self.apply_margin(my, py)
return px, py
I see duplication. Remove it with an Extract Method? Wait, let’s do this, reorder:
def clamped_position(self, cell):
px, py = cell.position
mx = self.max_x * cell_size
my = self.max_y * cell_size
px = self.apply_margin(mx, px)
py = self.apply_margin(my, py)
return px, py
Extract:
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, mx, px):
margin_x = mx / (2 * self.dungeon_cam.zoom)
px = min(mx - margin_x, max(px, margin_x))
return px
A couple of renames and inlining there in the last method:
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):
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))
- Note:
- Sequence ends. Code is neater, most of the work was by machine refactoring so quite solid.
We’ll test again, anyway. Works. Remove the cross-hairs. Commit: save point. It’s 0700 and I think I might go back to bed. Or something.
Reflection Before Break
I am pleased with this result. It even works at zoom factor 3, which makes me think it’ll work at any factor. Our original plan, you may recall, was to zoom based on scrolling the mouse. We’ll look at that, and reflect on the code, when next I’m awake.
But I think I understand the code we have. I think it’s pretty readable, and I think I could explain it. That’s far better than what I had before, because I could never get it past “nearly always working”.
Not bad at all for being done at oh-dark-hundred hours.
Around 1500 Hours
- Note:
- Another nice refactoring sequence below.
Slept from about 0700 to 10-something, now back here at the keyboard. I typed in this code:
class DungeonView:
def on_mouse_scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> bool | None:
self.cameras.zoom(scroll_y)
class Cameras:
def zoom(self, amount):
if amount != 0:
zoom = self.dungeon_cam.zoom
zoom += amount/10
zoom = max(1, min(zoom, 4))
self.init_zoom(zoom)
We already had init_zoom, used in the debug key-stroke code, hardly worth noticing now that zoom works:
class Cameras:
def init_zoom(self, zoom):
self.dungeon_cam.zoom = zoom
And of course the actual handling of the zoom factor, as shown above. And it works:
- Note:
- What follows is fun, I try around eight or ten different ways of writing this one method. Good practice, good brain exercise. for those of us who program with our brains, not some borrowed fake one.
Let’s simplify the zoom method with some inlining. I guess since we’re just setting it, we can remove the check for zero. A few steps:
Expand expression.
def zoom(self, amount):
zoom = self.dungeon_cam.zoom
zoom = zoom + amount/10
zoom = max(1, min(zoom, 4))
self.init_zoom(zoom)
Inline.
def zoom(self, amount):
zoom = self.dungeon_cam.zoom + amount / 10
zoom = max(1, min(zoom, 4))
self.init_zoom(zoom)
Inline.
def zoom(self, amount):
zoom = max(1, min(self.dungeon_cam.zoom + amount / 10, 4))
self.init_zoom(zoom)
Inline.
def zoom(self, amount):
self.init_zoom(max(1, min(self.dungeon_cam.zoom + amount / 10, 4)))
Hm, I don’t quite like that. Too many magic numbers. Let’s extract some variables:
def zoom(self, amount):
min_zoom = 1
max_zoom = 4
scale = 10
self.init_zoom(max(min_zoom, min(self.dungeon_cam.zoom + amount / scale, max_zoom)))
Just for fun let’s expand that max/min, even though it is pretty idiomatic.
def zoom(self, scroll_movement):
min_zoom = 1
max_zoom = 4
scale = 10
scaled_adjustment = scroll_movement / scale
no_more_than_max = min(self.dungeon_cam.zoom + scaled_adjustment, max_zoom)
no_less_than_min = max(min_zoom, no_more_than_max)
self.init_zoom(no_less_than_min)
So there are seven different ways of expressing the idea. There might be more that one could prefer, including changing the max and min to if/else or, if truly perverse, match-case. Oh, one more thing, the name of the method zoom. Should be something like adjust_zoom, and I choose this compromise method:
def adjust_zoom(self, scroll_movement):
min_zoom = 1
max_zoom = 4
scale = 10
self.init_zoom(max(min_zoom, min(self.dungeon_cam.zoom + scroll_movement / scale, max_zoom)))
Name the magic numbers, assume that the max/min construct is well-known. Good enough. Commit: Mouse scroll wheel controls zoom between scale 1 to 4.
Summary
I think we’re done here. The zooming works nicely with scrolling of the Magic Mouse. The code is much simpler than the former attempt, which I believe was taking the wrong approach and could never have been made to work quite right. This way is how you really do it.
Maybe I’ll follow up with a short article on how it works, why it’s what one does.
This article got out of hand. If you got to the end, you have my thanks and admiration.
See you next time!