Dungeon 29
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.
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:
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:
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.
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 …
And at the larger scale:
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:
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!
-
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. ↩