I think we should refocus the design a bit, for improved graphics. This promises to confuse me.

Presently, our dungeon program’s natural drawing scale is the zoomed-out view of the entire map. We can’t even see the entire map any more, since the imposed darkness affects it as well as the large-scale map. Of course we could fix that readily if we wanted to.

In the real game, we probably won’t offer the whole-map view at all, although you can imagine that it might be useful. And the large view is using 16 pixel tiles, scaled up by a factor of 4 to 64x64. That means that we’re losing fine details in our display. What’s the point of a good display screen if we don’t use it?

What I’d like to do today is to convert the program to use 64x64 tiles naturally, to draw the detailed view at scale=1, and draw the zoomed-out view at scale=0.25. Let me write down my expectations so that we’ll know when I’m surprised.

  1. I expect the basic change to 64x64 to be just a matter of changing a constant. But I expect also that there are places in the code that are relying on the value 16 to do things. For example, I think the princess sprite has some odd offset that is expressed in pixels. Bottom line, I expect the size conversion to go quickly but with some glitches.
  2. I expect to have to change the overall size of the dungeon. I believe it’s presently calculated as how many tiles will fit on a full screen. We’ll need to change that to an absolute size. I expect that to be straightforward.
  3. I expect to have to change the logic that keeps the screen from scrolling inward from the corners, namely the code in the focus function. I expect this to confuse me, but I hope it won’t confuse me much. Scaling and translation is simple: I just don’t remember the details any more.
  4. I’m sure there are other issues we’ll run into.

So, netting it all out, I am hoping this will go quite smoothly, and expecting a bit of trouble. A day like all days, filled with those events that alter and illuminate our times.

Let’s get started. I think what I’ll do is turn off the shade around the princess, the better to see what’s going on. And we’ll default the DisplayToggle to true, showing the big picture. And I’ll change the scale used in focus to 1. That should be interesting.

function Tile:getTint()
    if true then return color(255) end
    local d = self.runner:playerDistanceXY(self.x,self.y)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

scale 1

That’s what I expected. We’re at scale 1 by default, so the tiles and rooms are small. Now what about this:

function GameRunner:init()
    self.tileSize = 16
    self.tileCountX = math.floor(WIDTH/self.tileSize)
    self.tileCountY = math.floor(HEIGHT/self.tileSize)
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
end

That’s where we decide on tile size and how many tiles there should be. It turns out that tileCountX is 85 and tileCountY is 64. Just to make our job harder, let’s make the new values each 100.

And run.

tile64

That’s interesting. It’s clear that we have more dungeon off to the right and top. But the tiles and rooms are not four times larger. Why not? Someone’s making an assumption that they shouldn’t be making.

Before I go find out where I’ve done the wrong thing, let’s talk about the right thing. We try to express every idea “once and only once”. When we do that. things like changing a single constant affect everything. When we don’t, and we want to change some constant, we have to go digging.

When we dig this up, we’ll work to align better with “once and only once”.

Since the map itself really knows nothing about tile sizes, I think we need to look at draw:

This looks pretty innocent:

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for i,row in ipairs(self.tiles) do
        for j,tile in ipairs(row) do
            tile:draw()
        end
    end
    self.avatar:draw()
end

Let’s look at Tile:

function Tile:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CORNER)
    tint(self:getTint())
    local sp = self:getSprite()
    sprite(sp, self:gx(),self:gy(), TileSize)
    popStyle()
    popMatrix()
end

function Tile:gx()
    return (self.x-1)*TileSize
end

function Tile:gy()
    return (self.y-1)*TileSize
end

Well, I see that TileSize there that should be a reference to the runner. That will sure do something bad. That value was there because Tiles originally didn’t know the GameRunner, but they needed to know that game parameter. I took the easy way out instead of the right way out. They do know the runner now, so we can do this:

    sprite(sp, self:gx(),self:gy(), self.runner.tileSize)

And in the other two locations. I expect this to make a big difference. And it does:

big

We’re just looking at the lower right corner now. Our focus display logic is wrong, and I suspect that the princess may not be in the right place either.

However, I need the big picture. So now let’s change the main display logic to default to 0.25 scale.

function draw()
    pushMatrix()
    if CodeaUnit then showCodeaUnitTests() end
    if DisplayToggle then
        local x,y = Runner.avatar:tileCoordinates()
        local gx,gy = x*Runner.tileSize,y*runner.tileSize
        focus(gx,gy, 1)
    else
        scale(0.25)
    end
    Runner:draw()
    popMatrix()
end

That lets me get a full screen view of the map with a tap:

big2

big2small

But where’s the princess? I think I’ve taken too big a bite here, and I’m going to set the dungeon size back down to small enough to fit on screen: 85x64. That should let me see where the princess is going.

zoom3

I don’t see her anywhere. Let’s go find out what’s wrong with her code. Perhaps both her graphical coordinate setting and her size need to be adjusted.

function Player:draw()
    local dx = -2
    local dy = -3
    pushMatrix()
    pushStyle()
    spriteMode(CORNER)
    local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
    sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,gx+dx,gy+dy, 20,34)
end

This is surprisingly nearly good, in that she asks the runner to convert her coordinates. We’ll want to fix her sprite size and dxdy as well, however. The runner function is this:

function GameRunner:graphicXY(tileX,tileY)
    return (tileX-1)*self.tileSize,(tileY-1)*self.tileSize
end

I think that should be correct. Maybe she was just too tiny to notice? She needs to be 4x larger so first I’ll hammer those numbers.

sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,gx+dx,gy+dy, 80,136)

princess

Ah, there she is. She was just hiding all along. But in the large view, focus isn’t working any more. I’m not surprised, though I am somewhat disappointed. Let’s have a look:

function focus(x,y, zoom)
    local tx,ty
    scale(zoom)
    local appliedScale = modelMatrix()[1]
    local w = WIDTH/2/appliedScale
    local h = HEIGHT/2/appliedScale
    tx,ty = w-x, h-y
    tx,ty = math.min(0,tx), math.min(0,ty)
    -- ah! if we scrolled 'scale' screenlengths, we'd put right edge at zero.
    -- so we must scroll one less than 'scale'.
    local mul = (appliedScale-1)/appliedScale
    tmx = -WIDTH*mul
    tmy = -HEIGHT*mul
    tx = math.max(tmx,tx)
    ty = math.max(tmy,ty)
    translate(tx,ty)
end

I think I want to go back to first principles on this, even though I am rusty on the principles. First we’ll work to center the princess. That will give us blank screen areas sometimes. Then we’ll limit the scrolling.

function focus(x,y, zoom)
    local tx,ty
    local w = WIDTH/2
    local h = HEIGHT/2
    tx,ty = w-x, h-y
    translate(tx,ty)
end

That works perfectly:

princess in room

Now if I walk her close enough to the edges of the dungeon, bad things will happen to the display:

bad2

bad3

On the left, the fix is easy. Don’t translate past zero. Our translation value is negative, I believe, so:

function focus(x,y, zoom)
    local tx,ty
    local w = WIDTH/2
    local h = HEIGHT/2
    tx,ty = w-x, h-y
    tx,ty = math.min(tx,0), math.min(ty,0)
    translate(tx,ty)
end

left edge

That works very nicely. What about the other edge? We’re at scale 1. Doesn’t that mean that we never want to scroll past w and h, the WIDTH and HEIGHT over two? Could it be that easy?

It turns out that neither -w -h nor -WIDTH -HEIGHT work. I’ve got to do some printing.

After more futzing than I’d care to admit, I have this, which seems to work just right:

function GameRunner:dungeonSize()
    return self.tileSize*(self.tileCountX+1), self.tileSize*(self.tileCountY+1)
end

function focus(x,y, zoom)
    local tx,ty
    local w = WIDTH/2
    local h = HEIGHT/2
    local DW,DH = Runner:dungeonSize()
    local maxHScroll = DW - WIDTH
    local maxVScroll = DH - HEIGHT
    tx,ty = w-x, h-y
    tx,ty = math.min(tx,0), math.min(ty,0)
    tx,ty = math.max(tx,-maxHScroll), math.max(ty,-maxVScroll)
    translate(tx,ty)
end

Basically what’s happening here is that we are mapping our dungeon’s full size, that is, all the way out to the edge of the outermost tile, so one more than the tile table dimensions.

The mapping has to be such that we never scroll past HEIGHT or WIDTH, so we subtract those values from the max value we’d scroll if we could, limiting our scrolling to that much.

Let’s try to clean up the code a little bit, make it a bit more understandable.

I want the clamp function.

function clamp(lo,val,hi)
    return math.max(math.min(val,hi), lo)
end

I feel that this should be built into math, but Lua doesn’t agree.

Now …

function focus(x,y, zoom)
    local tx,ty
    local w = WIDTH/2
    local h = HEIGHT/2
    local maxH,maxV = maxScrollValues()
    tx,ty = w-x, h-y
    tx,ty = math.min(tx,0), math.min(ty,0)
    tx,ty = math.max(tx,-maxH), math.max(ty,-maxV)
    translate(tx,ty)
end

Still good. Now …

function focus(x,y, zoom)
    local tx,ty
    local w = WIDTH/2
    local h = HEIGHT/2
    local maxH,maxV = maxScrollValues()
    tx,ty = w-x, h-y
    tx,ty = clamp(-maxH,tx,0),clamp(-maxV,ty,0)
    translate(tx,ty)
end

This caused me a stumble, because I put the -max as the high value but they are negative so are the low value. Maybe we should have a clamp that deals with that:

function clamp(lo,val,hi)
    if lo <= hi then
        return math.max(math.min(val,hi), lo)
    else
        return math.max(math.min(val,lo), hi)
    end
end

I have mixed feelings about that. Let’s continue cleaning:

function focus(x,y, zoom)
    local tx,ty
    local maxH,maxV = maxScrollValues()
    tx,ty = WIDTH/2-x, HEIGHT/2-y
    tx,ty = clamp(-maxH,tx,0),clamp(-maxV,ty,0)
    translate(tx,ty)
end

Now … what if we did this:

function focus(x,y, zoom)
    local maxH,maxV = maxScrollValues()
    translate(clamp(-maxH, WIDTH/2-x, 0), clamp(-maxV, HEIGHT/2-y, 0))
end

This is pretty compact but still right. I think I want to rename those locals and (unlike my normal standard, capitalize them so they’ll show up against WIDTH and HEIGHT. And let’s rename the function too. These aren’t max values, they are minimum values. And let’s return them negative.

function focus(x,y, zoom)
    local LOWX,LOWY = maxScrollValues()
    translate(clamp(LOWX, WIDTH/2-x, 0), clamp(LOWY, HEIGHT/2-y, 0))
end

function maxScrollValues()
    local DW,DH = Runner:dungeonSize()
    return WIDTH - DW, HEIGHT - DH
end

function clamp(lo,val,hi)
    if lo <= hi then
        return math.max(math.min(val,hi), lo)
    else
        return math.max(math.min(val,lo), hi)
    end
end

OK, done and works. Now I believe this will work with any size dungeon on the large view, but the small view won’t encompass everything if we went to a large enough dungeon.

I think we’d best commit this. “Tiles are 64x64, drawn at scale 1”

Oh, I forgot to put the shading back. Commit: princess in shade again.

Now what about the actual dungeon size? I’ve got it set back to 85x64 now, and they look like this:

maze

That’s with a dozen rooms. I could imagine doing more, or letting it be random. Mostly, I think it’s fine. I’m just going to put a comment (so sue me) into the GameRunner init, saying that if those numbers change, the zoomed-out scaling may need to change as well.

I think we’re at a good stopping point for today. Let’s sum up.

Summing Up

We changed the system’s entire display mode, to display the maze at full scale in playing mode, rather than scaled up. We resized the tiles to 64x64, which allows us to have far more detailed tiles, which the graphics team will surely appreciate. The whole process went fairly smoothly, at least up until the adjustments for scroll limits.

There were a couple of places where the code made assumptions, notably that tiles knew how large they were going to be. We resolved that by having them ask the GameRunner for their size. It might be better just to tell them when they’re created.

The information is used in two places. First, it’s used to tell the flooring sprite how large to be:

    sprite(sp, self:gx(),self:gy(), self.runner.tileSize)

In addition, it’s used to convert tile coordinates to graphical coordinates:

function Tile:gx()
    return (self.x-1)*self.runner.tileSize
end

function Tile:gy()
    return (self.y-1)*self.runner.tileSize
end

These values are all constant and have no need to be dynamic. I like to leave things dynamic, but it’s tempting to nail these down.

The player gets her coordinates dynamically but differently:

    local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)

Let’s change Tile to do the same thing. They’ll still be dynamic but they’ll be consistent. We’ll remove the gx() gy() functions, of course.

function Tile:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CORNER)
    tint(self:getTint())
    local sp = self:getSprite()
    local gx,gy = self.runner:graphicXY(self.x,self.y)
    sprite(sp, gx,gy, self.runner.tileSize)
    popStyle()
    popMatrix()
end

OK, small improvements matter. Commit: Tile uses runner:graphicXY.

Where we we? Oh, yes, summing up.

All of this went well, down to the scroll controlling.

Because we’re drawing at scale 1, and translating within a larger space, the code is simpler than it was at scale 4. It came down to just a couple of lines, with some supporting functions. But I’m darned if I can really explain it any better.

Such is life. Sometimes we just bang it until it works. I’m confident in it, because the numbers don’t seem bizarrely ad hoc. But to derive it? At this moment, I can’t, I could only fumble my way to it in the dark.

Maybe I’ll be smarter tomorrow, although the trend doesn’t suggest that. See you then!


D2.zip