Let’s limit the Princess’s view of the dungeon. I know one way to do that. Let’s think of at least one more.

As things stand now, everything that’s within the viewport when we’re scaled is in view. The princess can get a sense of things that is perhaps too clear.

view

There’s a Codea demo called “Simple Dungeon” that does an interesting trick: it tints the screen darker and darker, the further away it is from the player. It’s rather a nice effect:

simple

I’d like something similar. The Simple Dungeon version is using the tint function, which only tints sprites. But our dungeon isn’t made of sprites, other than the princess herself. So we can’t use tint unless we go to sprites for flooring and walls and the like. That’s certainly not difficult at all, but it’s a bit of a diversion. Rather than decide Y/N, I thought I’d see what other options are possible.

One is to make the current floor and wall into sprites as they are, by drawing them into a graphics context and saving them. That might actually reduce the cost of displaying the dungeon a bit. But it’s as much of a diversion as just finding some nice sprites, and probably a bit more.

Another possibility would be just not to draw anything beyond some radius of the princess. That might be amusing, and it would perhaps be a step along the way to the tinting solution.

But I do think that in the end, we’ll want the tint solution. So unless we’re in a hurry to get the effect and are willing to accept that it won’t look as good and will have to change, I think we should go forward with the sprite approach.

I’m glad we had this little chat. It’s useful to consider options, and often some of them let us slice the work down, or let us get a capability in place sooner. Even when we stick with our original plan, we feel more comfortable that we haven’t accidentally missed something that would have been somehow better.

So. Sprites.We have some blocks and things in our Codea Assets. We’ll pick some that are acceptable and leave getting really nice ones up to the graphic arts department.

Drawing the Dungeon

Drawing the dungeon consists of little more than drawing all the tiles. We don’t even suppress the ones that aren’t in view because of scaling:

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

function Tile:draw()
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

That’s all there is to it. Our floor is a rectangle with a fill color dependent on what kind of tile it is. There is also a bit of a trick going on there, however, which we want to deal with:

function Tile:fillColor()
    if self.kind == TileEdge and self:hasRoomNeighbor() then
        self.kind = TileWall
    end
    local c = self:fillColors()[self.kind]
    return c or color(255,0,0)
end

When we go for the fill color, there’s a patch in there that checks to see if the current kind is “edge” but there is an adjacent cell of kind “room”. If there is, we convert the cell to “wall”. That’s what gives our narrow hallways their wall boundaries. We did that a few articles ago.

But that code isn’t in the right place, is it? If we’re going to do this on the fly, which is OK with me, we shouldn’t be doing it in fillColor, it should be done separately. Let’s move it to the top of draw:

function Tile:draw()
    if self.kind == TileEdge and self:hasRoomNeighbor() then
        self.kind = TileWall
    end
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

One might argue that this should be done after the dungeon layout is done and before we draw, but at this moment I don’t want to change the design that much. This is a bit expensive, since it’s done on every pass. Ah … and I was thinking, well, once it’s done, it’s done, but no, every edge cell that isn’t against a room will be checked every time we draw.

OK, you’ve talked me into it: we’ll put this where it belongs. Let’s see if we can figure out where it belongs. How about here:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        count = count -1
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(self.tileCountX,self.tileCountY, 4,15, self)
            if self:hasRoomFor(r) then
                placed = true
                r:paint()
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
    self:connectRooms()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    self.avatar = Player(rcx,rcy,self)
end

We could do our wall adjustment right after connectRooms.

OK, new method in GameRunner:

function GameRunner:convertEdgesToWalls()
    for i,row in ipairs(self.tiles) do
        for j,tile in ipairs(row) do
            tile:convertEdgeToWall()
        end
    end
end

And we’ll put that little patch of tile code into a method:

function Tile:convertEdgeToWall()
    if self.kind == TileEdge and self:hasRoomNeighbor() then
        self.kind = TileWall
    end
end

And we have the walls. OK, happy now? Commit: moved conversion of hall-adjacent edges to walls to init time.

As I was saying …

The drawing of the tiles looks like this:

function Tile:draw()
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

If we’re going to replace those rectangles with sprites, we do need to select the sprite. Let’s code this by intention1.

It’s “top down”, of course, but with a focus on writing code that says what we intend. This should be a simple case here.

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

Now for getSprite:

function Tile:getSprite()
    return self.sprites[self.kind]
end

I’m positing a table of sprites, indexed by kind. I’m not sure I want that to be a local thing, though. But for now let’s continue …

function Tile:init(x,y,kind)
    self.x = x
    self.y = y
    self.kind = kind
    self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end

Now we get this:

sprites

I don’t exactly love it, but with better sprites, it’ll look better.

Commit: dungeon drawn with sprites.

We should remove those fill color related methods, I suppose:

function Tile:fillColor()
    local c = self:fillColors()[self.kind]
    return c or color(255,0,0)
end

function Tile:fillColors()
    if not FillColors then
        FillColors = {wall=color(128,128,128),   room=color(255),   edge=color(0) }
    end
    return FillColors
end

Everything still works. Commit again: remove fill color logic.

Obscuring the View

We came here with the notion of obscuring the view. Maybe we could get around to that now?

Let’s look at the scene and decide how many tiles the princess can see.

scene

In this picture, I’ve drawn a rectangle around each tile so that we can see about how far we’d like the princess to see in tiles. It’s about 7 up to the top of the screen and 7 down. That might be too large. Let’s try 6 tiles in each direction.

What we’ll want to do is use a plain white tint on the princess’s tile, and decrease toward black out to 7 where we should be full black.

A color consists of values from 0-255, and I think negative colors just register as black. So we want a function of distance from princess such that it’s 255 at distance zero and zero at distance 7. So that’l be 255-distance*somefactor.

Distance will be small integers, zero up through a dozen or so, and we want distance*somefactor to be 255 when distance = 7. So somefactor = 255/7, right? I think so.

We need to know distance from princess, and we’re going to have to ask the runner for that.

Tiles do not presently know the gameRunner, since we had no idea they might care. For now, we’ll use the global.

Let’s TDD this one, since it has a bit of a tricky calculation in it.

        _:test("tint as function of distance from avatar", function()
            
        end)

I want to glance at tile draw to get a sense of how we want to use it there:

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

We’ll have a call to tint right before sprite. Probablyself:getTint() because we have no special knowledge in draw to pass on. So we’ll TDD toward that working:

        _:test("tint as function of distance from avatar", function()
            local tile = Tile:room(100,100)
            local player = Player(104,104)
            local d = tile:distanceFrom(player)
            _:expect(d).is(5.6,0.1)
        end)

I think the player is 4x4 away, or sqrt(16+16), so that could be OK …

Test will fail wanting the distanceFrom method:

7: tint as function of distance from avatar -- Tests:105: attempt to call a nil value (method 'distanceFrom')

Now to do that method:

function Tile:distanceFrom(aPlayer)
    return aPlayer:distanceXY(self.x,self.y)
end

This is “tell, don’t ask. We could ask the player for her x and y and use them. Instead, we give her ours and she uses them:

function Player:distanceXY(x,y)
    local dx = self.tileX-x
    local dy = self.tileY-y
    return math.sqrt(dx*dx+dy+dy)
end

I kind of expect this to work. Or, as we say “I’ll know the reason why”. Well the reason why is that + between the dys. I’m glad I didn’t have you hold my chai.

OK, with that fixed, the test runs. I’m confident enough to use it. To use it, I want to ask the Runner, because as a tile, I don’t know where the player is. So now I realize that this test isn’t saying what I really want. Instead I want this:

        _:test("tint as function of distance from avatar", function()
            local x,y = 100,100
            local player = Player(104,104)
            local runner = GameRunner()
            runner.avatar = player
            local d = runner:playerDistanceXY(x,y)
            _:expect(d).is(5.6,0.1)
        end)

I think this is more like it. We’ll see. Remove the new method from Tile before we forget and do this one:

function GameRunner:playerDistanceXY(x,y)
    return self.avatar:distanceXY(x,y)
end

Test runs. Now let’s compute the tint. That does belong in Tile. I want my Tile back. So it goes. I’ve decided to do a new test where I can better know the answers. I don’t know what the tint should be for distance 4. Rename the current test:

        _:test("distance from avatar", function()
            local x,y = 100,100
            local player = Player(104,104)
            local runner = GameRunner()
            runner.avatar = player
            local d = runner:playerDistanceXY(x,y)
            _:expect(d).is(5.6,0.1)
        end)

A new one:

        _:test("tint as function of distance from avatar", function()
            local tile
            local tint
            local player = Player(100,100)
            Runner = GameRunner()
            Runner.avatar = player
            tile = Tile:room(100,100)
            tint = tile:getTint()
            _:expect(tint.r).is(255)
        end)

This is end-to-end. It requires the global runner, at least for now. Running will demand getTint.

8: tint as function of distance from avatar -- Tests:118: attempt to call a nil value (method 'getTint')
function Tile:getTint()
    local d = Runner:playerDistanceXY(self.x,self.y)
    local t = 255-d*255/7
    return color(t,t,t,255)
end

That seems right to me, and the test agrees. Now a couple more values:

            tile = Tile:room(107,107)
            tint = tile:getTint()
            _:expect(tint.r).is(0)

However …

8: tint as function of distance from avatar  -- Actual: -105.62445840514, Expected: 0

That seems odd. I do realize that I’m going to have to min the value at zero. On, and the distance at 107,107 isn’t 7. First change the test to this:

            tile = Tile:room(107,100)
            tint = tile:getTint()
            _:expect(tint.r).is(0)

And that does pass. Now the other case:

            tile = Tile:room(150,150)
            tint = tile:getTint()
            _:expect(tint.r).is(0)

This should return some negative value for r, which we’ll then fix.

8: tint as function of distance from avatar  -- Actual: -2320.8889886081, Expected: 0
function Tile:getTint()
    local d = Runner:playerDistanceXY(self.x,self.y)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

That does it. Now I think we should be able to plug this right into our draw:

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

And we get this at the small scale …

small shade

And at the larger scale:

large shade

So that’s working as intended. Not bad, for a couple of hours of coding and writing. Commit: princess can’t see very far.

Now, how hard would it be to create all the tiles knowing the runner, to avoid the global access?

function Tile:room(x,y)
    return Tile(x,y,TileRoom)
end

function Tile:wall(x,y)
    return Tile(x,y,TileWall)
end

function Tile:edge(x,y)
    return Tile(x,y,TileEdge)
end

function Tile:init(x,y,kind)
    self.x = x
    self.y = y
    self.kind = kind
    self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end

If we’d just pass runner in to all those, we should be good to go. Let’s put in the parm:

function Tile:room(x,y, runner)
    return Tile(x,y,TileRoom, runner)
end

function Tile:wall(x,y, runner)
    return Tile(x,y,TileWall, runner)
end

function Tile:edge(x,y, runner)
    return Tile(x,y,TileEdge, runner)
end

function Tile:init(x,y,kind, runner)
    self.x = x
    self.y = y
    self.kind = kind
    self.runner = runner
    self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end

Now we need to fix all the calls to those methods:

function Room:correctTile(x,y)
    if x == self.x1 or x == self.x2 or y == self.y1 or y == self.y2 then
        return Tile:wall(x,y, self.runner)
    else
        return Tile:room(x,y, self.runner)
    end
end

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

function GameRunner:getTile(x,y)
    if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
        return Tile:edge(x,y, self)
    end
    return self.tiles[x][y]
end

function GameRunner:horizontalCorridor(fromX, toX, y)
    fromX,toX = math.min(fromX,toX), math.max(fromX,toX)
    for x = fromX,toX do
        self:setTile(Tile:room(x,y, self))
    end
end

function GameRunner:verticalCorridor(fromY, toY, x)
    fromY,toY = math.min(fromY,toY), math.max(fromY,toY)
    for y = fromY, toY do
        self:setTile(Tile:room(x,y, self))
    end
end

That’s it, I think, except for the recent tests, which I also fixed.

Now we can change to this:

function Tile:getTint()
    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

And we expect everything good. Tests are green, princess is still shrouded in darkness. Commit: tiles now know runner.

What Now?

I’ve done my two hour stint, so I could stop. But I’m really wanting to let the princess explore the dungeon, so maybe I can put the keyboard handling back in fairly quickly.

You may nave noticed in the picture of the Simple Dungeon demo, there arrows and indicators on the screen. We may wish to do that at some point, since requiring a $1500 ipad plus magic keyboard may not be the right platform for this two-bit game. If we do, however, I think we’re in for some painful learning about adjusting our viewport. And since we probably aren’t really creating a real game, maybe we’ll never do it. Or, we could make a floating control board that moves out of the way of the princess. Anyway, that’s for another day. For today, we’ll just see if we can move her with the keyboard.

In D1, we had this:

function Player:keyDown(aKey)
    local step = 2
    if aKey == "w" then self:move(0,step)
    elseif aKey == "a" then self:move(-step,0)
    elseif aKey == "s" then self:move(0,-step)
    elseif aKey == "d" then self:move(step,0)
    end
end

function keyboard(key)
    Avatar:keyDown(key)
end

We can do approximately the same here. We only know the Runner, so we’ll tell him about the key and he’ll tell the player:

function keyboard(key)
    Runner:keyPress(key)
end

function GameRunner:keyPress(key)
    self.avatar:keyPress(key)
end

PlayerSteps = {a={x=-1,y=0}, w={x=0,y=1}, s={x=-1,y=0}, d={x=0,y=1}}

function Player:keyPress(key)
    local step = PlayerSteps[key]
    if step then
        sx,sy = step.x,step.y
        psx,psy = sx + self.tileX, sy + self.tileY
        self.runner:requestMove(self, psx,psy)
    end
end

This much should call back to the runner with our proposed new location. He’ll move us or not as he sees fit.

function GameRunner:requestMove(mover, x,y)
    local tile = self:getTile(x,y)
    if tile.kind == TileRoom then
        mover:moveTo(x,y)
    end
end

And Player:

function Player:moveTo(x,y)
    self.tileX = x
    self.tileY = y
end

But she’s not moving correctly. w and d move her up, s and a move her left. Did I get too fancy? No, apparently I just typed in random numbers. Try this:

PlayerSteps = {a={x=-1,y=0}, w={x=0,y=1}, s={x=0,y=-1}, d={x=1,y=0

She now moves correctly, but we’re not keeping her centered. Since she can move, we need to refocus the screen.

function GameRunner:draw()
    if displayToggle then
        local x,y = self.player:tileCoordinates()
        focus(x,y,4)
    end
...

After a bit of fiddling, I find that this doesn’t quite work. What does work is to remove that call to focus from GameRunner:draw and do this:

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, 4)
    end
    Runner:draw()
    popMatrix()
end

At this moment, I’m not sure why the one works and not the other, though I understand that both together could be a problem. But now she moves as expected:

moving

Commit: princess can wander.

At this moment the code has reached “make it work”, but not “make it right”. I’ll think about that when my brain is more nearly fresh. It’s nearly noon now, so I’m another hour in and that’ll do for today. Let’s sum up:

Summing Up

We did a few neat things today. We wanted to limit the princess’s view of the dungeon. To do that we wanted to use the ability of Codea to tint sprites, which can be used to get a nice dimming effect. So we converted tiles to draw with sprites, and used the distance of a given tile from the princess to compute the darkness at that tile. I think that code is worthy of a review:

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:getTint()
    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

function GameRunner:playerDistanceXY(x,y)
    return self.avatar:distanceXY(x,y)
end

function Player:distanceXY(x,y)
    local dx = self.tileX-x
    local dy = self.tileY-y
    return math.sqrt(dx*dx+dy*dy)
end

This is a good example of tell-don’t-ask in practice. We just keep forwarding the message until someone knows the answer. Tile asks runner runner asks player, player knows the answer. Nice handoff, and very tiny methods.

The alternative we commonly see in live code is to rip the guts out of various objects and compute wherever we started, with something like

function Tile:getTint()
    local player = self.runner.avatar
    local x,y = player.tileX,player.tileY
    local dx,dy = self.x-x,self.y-s
    local d = math.sqrt(dx*dx,dy*dy)
    local t = math.max(255-d*255/7,0)
    return color(t,t,t,255)
end

That, or something like it, would work. (I just randomly typed the above code in, so it’s close but perhaps not right.) But the code above knows where the runner keeps the avatar, it knows what member variables the player has and what they mean. If we change anything relating to these notions, the code above will break and need changing.

This is not a good thing. If I gave advice, I’d advice against that approach.

Anyway, we computed the darkness and apply it. Then we wanted to move the avatar. We did that in a not dissimilar way, with cooperating objects each of whom know something about the situation:

function Player:keyPress(key)
    local step = PlayerSteps[key]
    if step then
        sx,sy = step.x,step.y
        psx,psy = sx + self.tileX, sy + self.tileY
        self.runner:requestMove(self, psx,psy)
    end
end

function GameRunner:requestMove(mover, x,y)
    local tile = self:getTile(x,y)
    if tile.kind == "room" then
        mover:moveTo(x,y)
    end
end

function Player:moveTo(x,y)
    self.tileX = x
    self.tileY = y
end

Player and GameRunner agree on the fact that they’re working in tile coordinates, but everyone works in tile coordinates. Beyond that, player knows what it wants, and tells GameRunner to move it if possible. We could even rename that method “moveMeIfPossible”. Should we? Let’s do.

function Player:keyPress(key)
    local step = PlayerSteps[key]
    if step then
        sx,sy = step.x,step.y
        psx,psy = sx + self.tileX, sy + self.tileY
        self.runner:moveMeIfPossible(self, psx,psy)
    end
end

function GameRunner:moveMeIfPossible(mover, x,y)
    local tile = self:getTile(x,y)
    print("move request", x,"",y, mover, tile.kind)
    if tile.kind == "room" then
        mover:moveTo(x,y)
        print("accepted", mover)
    end
end

Commit: improved name moveMeIfPossible.

I’m not entirely sure that I like the way we handle the motion. When the screen is centered and she’s in a big room, it’s not easy to see that the princess is moving. As she gets near an edge, our focus routine moves her closer to the edge, to allow her to get all the way to the edge without displaying outside the region. But in the middle, at least with the tiles as they are now, I don’t quite like it.

But that’s for another day. For today, we’ve done a good thing, the princess can wander around. Maybe tomorrow we’ll give her something to find.

See you then!


D2.zip

  1. Coding by intention is one of the many techniques I learned from the great Kent Beck. As I interpret what I heard, the technique amounts to writing code that expresses what you intend to do, and then for each thing you’ve expressed, repeat the process until you’ve reached the bottom and everything works.