Dungeon 69
New Tiles? Torches? Redecorating the Dungeon.
I’ve been browsing the web for tile sets for top-down games like this one, and some of them are pretty tempting. I’m a bit concerned that better tiles are a digression from making the game actually playable, but It does seem like it would be amusing to do.
And since so few people read these articles, instead of writing something more interesting, I can use them to entertain myself. Today, I think I’ll work on new tiles.
We have this new object, AdjustedSprite, that includes a scale factor and could include other settings. Let’s begin by putting that object in place for tile drawing, and then see whether we can plug in different tiles.
function Tile:init(x,y,kind, runner)
self.position = vec2(x,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}
self.contents = {}
self.newlyAdded = {}
self.seen = false
self:clearTint()
end
Here’s where we define our three tile types, and they are used here:
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCorner()
for i,sp in ipairs(sprites) do
sprite(sp,center.x,center.y,self.runner.tileSize)
end
end
function Tile:getSprites(pos)
local result = {self.sprites[self.kind]}
if self:isWall() and pos.x%3 == pos.y%3 then
table.insert(result,asset.builtin.Blocks.Grass2)
end
return result
end
We just select the desired base sprite, and then optionally insert the funny grass thing on top, which makes it a bit more obvious when you’re moving.
Since AdjustedSprite knows about scale, we probably want to push that into the sprites themselves and remove the tileSize
reference from the sprite
call. I’ll begin by commenting out the grass insertion, so that I can focus on the three main tiles.
The Blocks I’m currently using are 128x128, and tileSize
is 64, so we want a scale of 0.5 for the AdjustedOnes.
function Tile:init(x,y,kind, runner)
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
--sprite(xxx)
local half = vec2(0.5,0.5)
self.sprites = {room=AdjustedSprite(asset.builtin.Blocks.Brick_Grey,half), wall=AdjustedSprite(asset.builtin.Blocks.Dirt,half), edge=AdjustedSprite(asset.builtin.Blocks.Greystone,half)}
self.contents = {}
self.newlyAdded = {}
self.seen = false
self:clearTint()
end
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCorner()
for i,sp in ipairs(sprites) do
pushMatrix()
translate(center.x,center.y)
sp:draw()
popMatrix()
end
end
The getSprites
function is still the same, but needs an adjustedSprite of its own.
function Tile:init(x,y,kind, runner)
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
--sprite(xxx)
local half = vec2(0.5,0.5)
self.sprites = {room=AdjustedSprite(asset.builtin.Blocks.Brick_Grey,half), wall=AdjustedSprite(asset.builtin.Blocks.Dirt,half), edge=AdjustedSprite(asset.builtin.Blocks.Greystone,half)}
self.wallMarker = AdjustedSprite(asset.builtin.Blocks.Grass2,half)
self.contents = {}
self.newlyAdded = {}
self.seen = false
self:clearTint()
end
function Tile:getSprites(pos)
local result = {self.sprites[self.kind]}
if self:isWall() and pos.x%3 == pos.y%3 then
table.insert(result,self.wallMarker)
end
return result
end
Display is the same, game works fine. Now, if we want to change the basic game tiles, we can work with this structure. And, just for fun, let’s do. First commit: Tiles use AdjustedSprite.
I’ve purchased a few tile sets. The license does not permit you to use them in new code, but I think that if you happen to be following along, you can use them in this program. Please do not abuse this privilege. The tiles are inexpensive and hard to make, so pirating them would be quite naff.
One thing I want to try first is an animated torch that I got, to replace the silly grass thing. I’ll start by making it display without flame, then with fixed flame, then finally with variable flame. At least that’s my plan.
Clearly this is going to mess up my wallMarker code that I just wrote. Ah, such is life.
First, I have to go through the rather tedious process Codea imposes for getting assets into the project. They are downloaded into the iPad’s “downloads” folder. I need to move them to Codea’s dropbox.assets folder, and, if desired, from there into the projects own assets.
The torch comes in two parts, a handle part and 8 separate flame shapes. Why he couldn’t have included the handle as part of the thing, I’ll never know. I’m almost tempted to do that myself, especially since the two items are different sizes and will need to be adjusted. In fact, why not? I can do it in Procreate, or maybe even in Paper. Procreate will be better: I can control the size of the file.
That took longer than I’d hoped, just proving that making tiles is hard work and we should pay for them. But now I have a single torch file that I can use. The result looks like this.
Not bad, but they’re not very visible. Maybe I can improve the look by turning off the distance tinting. If I just turn it off entirely, we’ll see torches all over the screen. I’m not quite sure how to turn it off anyway, but here’s a hack just to test the look:
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
The effect is, um, delightful:
Still, that’s close to what I’d like to have happen. We can condition the if further, of course. Which reminds me, Bryan Beecham suggested that I increase the range of the dark circle a bit. Let’s look at that.
The implementation of tinting is a bit obscure, because the code checks to see if a tile is within range of the player, but also whether it is blocked from view. We use a very simple blocking check that works moderately well. The relevant code is here:
function Tile:illuminateLine(dx,dy)
local pts = Bresenham:drawLine(0,0,dx,dy)
for i,offset in ipairs(pts) do
local pos = self:pos() + offset
local d = self:pos():dist(pos)
if d > 7 then break end
local t = math.max(255-d*255/7,0)
if t > 0 then self.seen = true end
local tile = self.runner:getTile(pos)
tile:setTint(color(t,t,t))
if tile.kind == TileWall then break end
end
end
We’re essentially drawing a line from the player at some slope dy/dx. If the tile is out of range (7 at present), it will not be given a tint. (All tiles have already had their tint cleared. This suggests an optimization to me.) Then we compute a tint based on the distance, and if it’s not zero, we mark the tile as seen. We set the tint in any case. If the tile is of type wall, we break, because we can’t see through walls.
We can readily change this code to allow for a range of 8 rather than 7, or we could make the value a parameter. At this moment, I’m concerned about the weight of the tile class, so let’s just change the constants.
function Tile:illuminateLine(dx,dy)
local max = 8
local pts = Bresenham:drawLine(0,0,dx,dy)
for i,offset in ipairs(pts) do
local pos = self:pos() + offset
local d = self:pos():dist(pos)
if d > max then break end
local t = math.max(255-d*255/max,0)
if t > 0 then self.seen = true end
local tile = self.runner:getTile(pos)
tile:setTint(color(t,t,t))
if tile.kind == TileWall then break end
end
end
I do think this give us a wider view that is nice. Let’s now condition the tint on whether the tile is seen at all.
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 and self.seen then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
Commit: simple torches.
However, there’s improvement to be made here. We shouldn’t display any sprites if the tile isn’t even visible.
Let’s move the seen check to the top.
function Tile:drawSprites()
if not self.seen then return end
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
The most amazing thing has happened. The picture looks the same, as far as I can tell, but the text scroll has sped up immensely. This simple change has sped up the draw cycle immensely. Why? All that drawing has been stopped.
However, the screen now goes to black where the tiles are not drawn, instead of the very dark gray color that the tiles give it. If we’re to leave this optimization in, we will need to deal with that issue. For now, I think we’ll call this tint stuff an experiment, and leave it where we had it.
No, upon looking more closely, that’s too much. I like the bright torches, but not when they’re off in space. The issue is, I used self.seen
which is a flag that is true if the tile has ever been seen, used for the tiny map. My bad. Also bad name? Anyway, after some tweaking, this looks pretty good:
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 and self.tintColor.r > 50 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
Commit: reasonable torch tinting.
I’m hungry to get that optimization that comes from not drawing all the tiles. We don’t have much of a performance measurement, but we do have the observation that the crawl was much faster, really too fast to read.
Let’s at least change from a two-pass to a one-pass algorithm, if it isn’t too difficult.
function GameRunner:draw()
font("Optima-BoldItalic")
self:drawLargeMap()
self:drawButtons()
self:drawTinyMap()
self:drawMessages()
end
function GameRunner:drawLargeMap()
pushMatrix()
self:scaleForLocalMap()
self:drawMap(false)
popMatrix()
end
function GameRunner:drawTinyMap()
pushMatrix()
self:scaleForTinyMap()
Runner:drawMap(true)
popMatrix()
end
function GameRunner:drawMap(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(tiny)
end
Looking at this, yes, we’re looping over the tiles twice, but the work will all be done in Tile:draw
. Combining the loop will require us to set and reset the scale on every draw. That’s likely to be slower, not faster.
This is not fruitful terrain. How about Tile:draw
?
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:updateContents()
tint(self:getTint(tiny))
self:drawSprites()
self:drawContents(tiny)
popStyle()
popMatrix()
end
function Tile:drawContents(tiny)
for k,c in pairs(self.contents) do
c:draw(tiny)
end
end
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 and self.tintColor.r > 50 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
We update contents twice. That’s where we copy from new contents to the real contents, the standard hack for adding to things while looping over them. But we don’t need to do it twice. On the other hand if the new contents table is empty, it’ll be quick. Probably not fruitful.
We draw the contents in tiny mode, but the only reason we do it is to show where the player is. And I’m not even sure that’s needed. Let’s not draw contents in tiny mode.
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:updateContents()
tint(self:getTint(tiny))
self:drawSprites()
if not tiny then self:drawContents(tiny) end
popStyle()
popMatrix()
end
That drops the red dot marking our current location. Fine for now. What about drawSprites? Any savings there?
function Tile:drawSprites()
local sprites = self:getSprites(self:pos())
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 and self.tintColor.r > 50 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
If we passed in the tiny flag, we could process only one sprite, which causes me to think “what about those torches”, and sure enough there are tiny torches in the tiny map.
This isn’t going to be terribly productive but let’s pass the flag down and use it:
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:updateContents()
tint(self:getTint(tiny))
self:drawSprites(tiny)
if not tiny then self:drawContents(tiny) end
popStyle()
popMatrix()
end
function Tile:drawSprites(tiny)
local sprites = self:getSprites(self:pos(), tiny)
local center = self:graphicCenter()
for i,sp in ipairs(sprites) do
pushMatrix()
pushStyle()
translate(center.x,center.y)
if i > 1 and self.tintColor.r > 50 then noTint() end
sp:draw()
popStyle()
popMatrix()
end
end
function Tile:getSprites(pos, tiny)
local result = {self.sprites[self.kind]}
if not tiny and self:isWall() and pos.x%3 == pos.y%3 then
table.insert(result,self.wallMarker)
end
return result
end
This should suppress the torches, at fairly low cost. And it does.
I notice that monsters show up at a distance, even outside our view. Make a note, we’re on a different quest. Commit: improved performance with drawing in tiny mode.
Arrgh
I just noticed that some tests are failing. I think they’ve been failing for a while. We need to fix this once and for all. Is this the moment? Well, we’re at a commit, so maybe it is.
Fix tests to be meaningful.
Our current code looks like this:
function draw()
if CodeaUnit then showCodeaUnitTests() end
Runner:draw()
end
function showCodeaUnitTests()
if CodeaVisible then
background(40, 40, 50)
pushMatrix()
pushStyle()
fontSize(50)
textAlign(CENTER)
if not Console:find("0 Failed") then
stroke(255,0,0)
fill(255,0,0)
elseif not Console:find("0 Ignored") then
stroke(255,255,0)
fill(255,255,0)
else
fill(0,255,0,50)
end
text(Console, WIDTH/2, HEIGHT-100)
popStyle()
popMatrix()
end
end
There are two issues. One is that the test display is overwritten by the game. If I turn off runner drawing, we see this:
The display is in the dull green I chose for when they’re good. They should be bright red, but since there are two test suites and one of them said “0 Filed”, we accept the final state as good. That won’t be easy to fix, will it?
Codea does have a regular expression pattern matching facility, but the pattern for this kind of eludes me. Let’s settle for putting the tests on top and see if that helps me.
For now, this:
local CodeaCount
local CodeaMax = 100
function runCodeaUnitTests()
local det = CodeaUnit.detailed
CodeaUnit.detailed = false
CodeaCount = 0
Console = _.execute()
CodeaUnit.detailed = det
end
function showCodeaUnitTests()
if CodeaVisible and CodeaCount < CodeaMax then
CodeaCount = CodeaCount + 1
background(40, 40, 50)
pushMatrix()
pushStyle()
zLevel(10)
fontSize(50)
textAlign(CENTER)
if not Console:find("0 Failed") then
stroke(255,0,0)
fill(255,0,0)
elseif not Console:find("0 Ignored") then
stroke(255,255,0)
fill(255,255,0)
else
fill(0,255,0,50)
end
text(Console, WIDTH/2, HEIGHT-100)
popStyle()
popMatrix()
end
end
The effect of this is that the tests appear on screen briefly and then go away. Now if I could get them to show up red, so I’d look at them when they’re bad, we’d be in good shape.
I’ll have to think on that. For now, let’s commit this: tests display at startup.
Now to see what’s up with the tests. All the failures are this:
3: floater pulls messages appropriately -- Actual: 125.5, Expected: 125
This is the correct value, because I tweaked this:
function Floater:adjustedIncrement()
return 1.5
--if DeltaTime > 0.02 then return 2 else return 1 end
end
I’ll change the tests for now, but they’re going to fail again if and when we make the crawl speed the same over all processors.
Tests green. Commit: adjusted values in floater tests.
This is an issue with tests that use magic values, but when we have code that uses magic values, there’s ot much we can do about it. And when it comes to things looking right on the screen, magic values seem to be necessary. No doubt we could better encapsulate all this. But it’s hard to give it priority.
This is always the dilemma. We know that if we keep our code squeaky clean, we’ll go faster. But we’re not up to it, and when we’re just creating, we’re dealing with specifics, and abstracting all the specifics up is beyond us–beyond me, anyway–and then once it works and we move on, it never quite seems to pay off to make things more abstract.
The best accommodation I know is to improve things when I find myself working in cruft. You’ve seen me do it, even today, and you’ll see it again. I do not generally go looking for cruft, though there are times and moods where that’s fun. It’s probably not as productive as fixing cruft where we’re working anyway, but it’s honest work.
What now?
This all started with some work on tiles. I tried the new torches and I kind of like them. Now I’d like to try some of the tiles I bought. But before I do that, I’d like to do something about this:
function Tile:init(x,y,kind, runner)
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
--sprite(asset.documents.Dropbox.Torch_0)
local half = vec2(0.5,0.5)
self.sprites = {room=AdjustedSprite(asset.builtin.Blocks.Brick_Grey,half), wall=AdjustedSprite(asset.builtin.Blocks.Dirt,half), edge=AdjustedSprite(asset.builtin.Blocks.Greystone,half)}
self.wallMarker = AdjustedSprite(asset.documents.Dropbox.Torch_0,vec2(0.6,0.6))
self.contents = {}
self.newlyAdded = {}
self.seen = false
self:clearTint()
end
Every time we create a Tile, we give it an array of sprites. A new array. All the same as the array in every other tile.
This can, I suspect, be improved. Let’s do this:
local half = vec2(0.5,0.5)
local TileSprites = {room=AdjustedSprite(asset.builtin.Blocks.Brick_Grey,half), wall=AdjustedSprite(asset.builtin.Blocks.Dirt,half), edge=AdjustedSprite(asset.builtin.Blocks.Greystone,half)}
local TileWallMarker = AdjustedSprite(asset.documents.Dropbox.Torch_0,vec2(0.6,0.6))
function Tile:init(x,y,kind, runner)
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
--sprite(asset.documents.Dropbox.Torch_0)
local half = vec2(0.5,0.5)
self.contents = {}
self.newlyAdded = {}
self.seen = false
self:clearTint()
end
function Tile:getSprites(pos, tiny)
local result = {TileSprites[self.kind]}
if not tiny and self:isWall() and pos.x%3 == pos.y%3 then
table.insert(result,TileWallMarker)
end
return result
end
This works fine. Now, presumably, I can try new tiles by setting them into these handy new arrays. I did have to move the AdjustedSprite tab to the left to get this to compile. I could resolve that with some kind of pre-init or lazy init. For now, I’ll live with the tab being moved forward.
Now to find some new tiles to try.
local half = vec2(0.5,0.5)
local p250 = vec2(64/250,64/250)
local TileSprites = {room=AdjustedSprite(asset.documents.Dropbox.floor_2,p250), wall=AdjustedSprite(asset.documents.Dropbox.wall_15,p250),
edge=AdjustedSprite(asset.builtin.Blocks.Greystone,half)}
This didn’t seem bright enough until I changed the tint to be either full bright or zero:
function Tile:getLargeScaleTint()
if self.tintColor.r > 0 then
return color(255)
else
return color(0)
end
--return self.tintColor
end
Now I think it’ll do:
I think we’re good to go forward with these tiles. We can enhance things a bit later on, but for today, I’m going to commit: new walls and floor, and call it a day.
Summing Up
This all went fairly smoothly, but I have to report that it felt a bit fast and loose to me. A bit like getting ahead of your skis, or hitting that slippery corner a little faster than your tires are up to. We got away with it, but I’m not sure we did things as nicely as we could. We did improve the campground a lot by at least displaying the tests, but more work is needed there, and it should be put into the main CodeaUnit project, not just this one. That will take a bit of review and planning.
One area that really needs work, I suspect, is visibility. We have some artifacts from the simple “illuminate” function: those gaps in the walls are there because of gaps in the angles used for the Bresenham call. And the tint idea probably needs to go away entirely.
We also have an opportunity, if we can sort out a decent way to clip what we display, because we’ve seen that the cost of drawing the whole dungeon is just too darn high.
Let’s do a back of the envelope calculation here.
We have 5440 tiles. The screen is 1366x1024, or 21x16 tiles, or 336 tiles. So we can save about 93 percent of the current cost of displaying, by just ignoring tiles that are totally off screen.
That’s surely worth doing. Just because we’re thinking about it, here’s how we draw:
function GameRunner:drawMap(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(tiny)
end
We do all rows, all columns. It should be “easy” to calculate the row and column index ranges and use those here. I’d only get it wrong three or four times at most. However … what about the tiny map? We need to span all the tiles to draw it.
Unless we did something else entirely for the tiny map. So the change isn’t as trivial as we’d like, if we’re to preserve the tiny map. And we could at least avoid drawing everything twice. Maybe the tiny map is a cached image that we update when a tile is first seen.
All that is, of course, for some other day, but it will clearly pay off if we can get even close to that 93 percent.
There is the issue of monsters showing outside the expected view range, which is ominous but not intended. And some of them are not scaled or positioned correctly: we might want to come up with a standard adjustment scheme for our various sizes of entities.
All that taken into account, I find it a satisfactory morning. For both of my readers: I hope you did as well.
See you next time! Send a friend!