Let’s create a mini-map.

Clearly, an adventurer exploring a dungeon would continually update a sketched map of the dungeon as they wander. To do otherwise would be foolhardy, unwise, and ultimately deadly. So let’s add a map to the game, that shows only areas that the player as seen.

I expect that this will be fairly easy, since the map is already drawn at two scales. My expectations are often dashed, however, so we’ll try to stay alert for trouble.

Our main draw function goes like this:

function draw()
    pushMatrix()
    if CodeaUnit then showCodeaUnitTests() end
    if DisplayToggle then
        local center = Runner.player:graphicCorner()
        focus(center, 1)
    else
        scale(0.25)
    end
    Runner:draw()
    popMatrix()
end

Let’s begin with the poorly-named DisplayToggle. When that is true, we display the player-centered game view, and when it is false, we display the full-screen map. This latter mode is very valuable in development, and not intended for game play at all.

We should probably rename that variable, but I can’t think of a name I prefer offhand, and it should be removed when the game goes to production anyway. It’s used in one other place:

function Tile:getTint()
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

When we display the map in game-play mode, we do that tinting trick so that the player only sees a roughly circular area around their current location. In full-screen mode, we always use full color. Other than that and a few tests, DisplayToggle has no effect.

We’re here to draw a tiny map. When should we do that? After all the other drawing is done, so that it’ll go on top. Let’s do this:

function draw()
    pushMatrix()
    if CodeaUnit then showCodeaUnitTests() end
    if DisplayToggle then
        local center = Runner.player:graphicCorner()
        focus(center, 1)
    else
        scale(0.25)
    end
    Runner:draw()
    popMatrix()
    drawTinyMap()
end

We’ll draw it after the popMatrix so that we know we’re starting with a clean slate. Now we just need to implement drawTinyMap.

Since DisplayToggle turns on the weird tinting behavior, I think we’ll have to turn it off while drawTinyMap runs. And if it’s false already we shouldn’t even do anything: we’ll be displaying the big map.

function drawTinyMap()
    pushMatrix()
    if not DisplayToggle then return end
    DisplayToggle = false
    scale(0.05)
    Runner:draw()
    DisplayToggle = true
    popMatrix()
end

This was straightforward. scale down and draw, then put things back the way we found them. I confess I tried a couple of values for scale before I chose 0.05. The screen looks like this:

first map

I should feel no surprise at this working so quickly, since the program already shows the map at more than one scale. Still, it’s nice that it showed up on the screen. And it’s at 0,0, which makes our work easier. Except that I want it on the top right. I’m not sure why, I just think it should go there. So we need to translate. How far, you ask? Yes, well.

We have a function that we use in focus:

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

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

The function dungeonSize is the one that I have in mind. It returns the number of pixels that we need to display the entire map:

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

Whatever those numbers are, we need room for 0.05 that many pixels, since we’re going to scale down. And we want the map at top right so we need that many subtracted from HEIGHT and WIDTH.

So … if I’m not mistaken:

function drawTinyMap()
    if not DisplayToggle then return end
    local sc = 0.05
    pushMatrix()
    local dw,dh = Runner:dungeonSize()
    translate(WIDTH-sc*dw, HEIGHT-sc*dh)
    DisplayToggle = false
    scale(sc)
    Runner:draw()
    DisplayToggle = true
    popMatrix()
end

second-map

Strangely enough, I wasn’t mistaken. That’s right where I wanted it. This could be my lucky day.

However this map is much too fancy, and we really only want to show places where the player has been. Let’s address fancy.

It’s clear, I think, that we’re going to have to make detailed decisions about things down inside the Runner’s draw function. Let’s pass down a flag, true, if we’re drawing tiny maps.

function draw()
    pushMatrix()
    if CodeaUnit then showCodeaUnitTests() end
    if DisplayToggle then
        local center = Runner.player:graphicCorner()
        focus(center, 1)
    else
        scale(0.25)
    end
    Runner:draw(false)
    popMatrix()
    drawTinyMap()
end

function drawTinyMap()
    if not DisplayToggle then return end
    local sc = 0.05
    pushMatrix()
    local dw,dh = Runner:dungeonSize()
    translate(WIDTH-sc*dw, HEIGHT-sc*dh)
    DisplayToggle = false
    scale(sc)
    Runner:draw(true)
    DisplayToggle = true
    popMatrix()
end

There may be some more clever, less “iffy” way to do this, but I don’t see it right now. We’ll push on:

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

Now the tiles know they are being drawn in tiny mode. I would like all the edge tiles to draw in black instead of that greenish tint.

function Tile:draw(tiny)
    pushMatrix()
    pushStyle()
    spriteMode(CORNER)
    tint(self:getTint())
    local sp = self:getSprite()
    local center = self:graphicCorner()
    sprite(sp, center.x,center.y, self.runner.tileSize)
    for k,c in pairs(self.contents) do
        c:draw()
    end
    popStyle()
    popMatrix()
end

I’ve passed in the tiny flag. What shall we do with it? Let’s give it to getTint.

function Tile:getTint(tiny)
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

What should we do here? Well, when tiny is true, DisplayToggle is false, so we could trust that guard clause, but I’d rather make it explicit:

function Tile:getTint(tiny)
    if not tiny and not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

map 3

In the map above, we now only see a small tinted area, because we’re dropping down into the tint code, which we need to adjust.

I think the deal is simple, if tiny is true and the tile is an edge, return black, otherwise white:

function Tile:getTint(tiny)
    if not tiny and not DisplayToggle then return color(255)  end
    if tiny and self.kind == TileEdge then return color(0)   end
    if tiny then return color(255)   end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

map 4

Still too fancy. Let’s also return black if it’s a wall: (Also this if stuff is getting messy, we’ll need to clean it up. But first make it work.

function Tile:getTint(tiny)
    if not tiny and not DisplayToggle then return color(255)  end
    if tiny and self.kind ~= TileRoom then return color(0)   end
    if tiny then return color(255)   end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

map 5

Ah. I like the look of that. It just displays the floor part. Excellent.

But we only want to display the part of the map that the Princess has seen. How can we do that? Mostly by making our if tangle here worse, I’m afraid. But make it work, then make it right.

We’ll add a member variable to the Tile, seen. False until the princess has seen it.

function Tile:getTint(tiny)
    if not tiny and not DisplayToggle then return color(255)  end
    if tiny and self.kind ~= TileRoom then return color(0)   end
    if tiny then return color(255)   end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

Then, here in getTint, if the tile is not seen, again we return black.

function Tile:getTint(tiny)
    if not tiny and not DisplayToggle then return color(255)  end
    if tiny and self.kind ~= TileRoom then return color(0)   end
    if tiny and self.seen then 
        return color(255)
    else
        return color(0)
    end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

This should make the map disappear entirely.

Oops, totally disappeared. A bit overly rough there.

It’s time to make this code more right.

function Tile:getTint(tiny)
    if tiny then return self:getTinyTint() end
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

function Tile:getTinyTint()
    if self.kind ~= TileRoom then return color(0)   end
    if self.seen then
        return color(255)
    else
        return color(255)
    end
end

Now with a 255 there in the last return, the map shows up. With it set, as intended, to zero, no map at all. We need to set seen.

I think we can do this inside of `getTint. If we return a non-zero tint, the tile is seen. Therefore …

function Tile:getTint(tiny)
    if tiny then return self:getTinyTint() end
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    if t > 0 then self.seen = true end
    return color(t,t,t,255)
end

I almost said self.seen = t > 0 but that would set it back to false after we left an area. We only want to set to true here.

Let’s see what happens. It works as intended:

map 6

map 7

map 8

Commit: mini-map.

Now let’s see whether we can clean this up further. First this:

function Tile:getTint(tiny)
    if tiny then return self:getTinyTint() end
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    if t > 0 then self.seen = true end
    return color(t,t,t,255)
end

We can pull the latter part into getTintLarge:

function Tile:getTint(tiny)
    if tiny then 
        return self:getTinyTint()
    else
        return self:getTintLarge()
    end
end

One of the other of those should be renamed. Why not both? How about:

function Tile:getTint(tiny)
    if tiny then 
        return self:getTinyScaleTint()
    else
        return self:getLargeScaleTint()
    end
end

function Tile:getTinyScaleTint()
    if self.kind ~= TileRoom then return color(0)   end
    if self.seen then
        return color(255)
    else
        return color(0)
    end
end

function Tile:getLargeScaleTint()
    if not DisplayToggle then return color(255)  end
    local d = self.runner:playerDistance(self)
    local t = math.max(255-d*255/7,0)
    if t > 0 then self.seen = true end
    return color(t,t,t,255)
end

Tiny only returns two possibilities. We can improve that:

function Tile:getTinyScaleTint()
    if not self.seen or self.kind ~= TileRoom then return color(0)   end
    return color(255)
end

That’s weird with all the negativity.

function Tile:getTinyScaleTint()
    if self.seen and self.kind == TileRoom then
        return color(255)
    else
        return color(0)
    end
end

That’s better. Commit: improved Tile tint code.

I think I’m inclined to sum this one up, since we have this nice new feature. And who here can gainsay me? None.

Summing Up

This went swimmingly. At the top, we translated to where we wanted the map and scaled. We called our existing draw code, with a flag to indicate that we’re drawing a tiny map. That flag just gets passed on to the tiles, and they just pass it on to the tinting function, where all the work is done.

We added a flag to the tile to determine whether it had been seen by the main game view, and set that flag when the main view tint is non-zero. We could make the map a bit less generous by adjusting that check.

The code is a bit more procedural than I’d like. We’re down in our bottom-level object, the Tile, and it has no subclasses or helper classes to assist. We could imagine some kind of TileTinter object, but that seems to me to entail more complexity than we have here. If tile coloring gets any more complicated, we might revisit that idea, but for now, I think this is close to good.

I would welcome ideas on what would actually be better right now. I emphasize “right now”: I agree that if things get more complicated we might want something more robust. But if a new idea isn’t actually simpler than what we have here, it’s probably too soon to put it in.

But a simpler idea? I’d welcome that whole-heartedly.

Looking back, I’m almost surprised at how smoothly things have been going. I expected that the tile-based design would help things go better, but even so, things rarely go this well in my experience. I’ve not been TDDing much, but it’s very difficult to see just how to TDD the sorts of things we’ve been doing.

But that’s never a good excuse, because it can be rephrased as “I don’t know how to test this”. And when we see that we don’t know something, that’s at least a hint that maybe we could, and maybe we even “should” learn about it.

Now that I think about it, it would have been possible to do some TDD around the getTint code, setting the various flags and making sure it returned the right tint. I’m not sure it would have made things go better, but the tests might have focused my attention better, and certainly might serve as a kind of documentation for what the function has to do.

I’m still not going to write those tests, at least not right now. For now, I’ll publish this and move on to other matters.

See you next time!


D2.zip