Dungeon 126
Some planning, then something. Come find out with me. I have no plan for the morning.
I really have no plan for the morning, so let’s see how th old man plans. Let’s begin by reviewing the yellow sticky notes for things not yet done. I’ll transcribe them exactly as they are. There’s a point to that: in the context of a team working together to make something, we don’t need a lot of detail. We create the details as we need them.
Notes are added today, not on the sticky notes.
- Poison
- Don’t Die
- Puzzles
- Doors / keys
- Levels
- Level up
- Traps
- Write test for cloning
- Test for acc damage
- Pain when there are lots of tests. (Note: Partly solved.)
- Separate projects??
- Magic 1 in getRandomMonsters, make param. (Note: May be done.)
- __newindex Wrong variable names
- CombatRound appendText vs display is confusing. (Note: talking here about the names of methods.)
- Make CombatRound easier to use in e.g. Spikes
- Flickering torches
- Darkness / Torches
- Monster deterrent objects
- Decor e.g. skels
- Gold / Store
- Health +1 every so often (Note: done)
- Poison -2 every so often
- Better test for monster behavior, called wrong method.
- Global __index
I also have a request from UberGoober on the Codea forum, to include CodeaUnit with the package. It’s only one more tab, so it could be done with no problem. I had thought that if people wanted to play the game they wouldn’t want it, but upon reflection, if people reading these articles are taking the code, I want them to be able to run the tests. So let’s do that right now.
Including CodeaUnit
I just open the CodeaUnit project, copy the CodeaUnit tab, open D2, add blank tab named CodeaUnit, paste the code. After the usual two attempts to run, it works.
It reminds me that since I added the frogs to wander around the WayDown, some tests have broken. Let’s deal with that. But first commit: CodeaUnit bound in directly.
Now the tests that fail. I’ll see if I can just adjust some results, or if something more profound is called for.
The messages are all like this with different values:
2: monsters correctly initialized -- Actual: 8, Expected: 6
The tests are:
_:test("monsters correctly initialized", function()
local gm = GameRunner()
Runner = gm
gm:createLevel(12)
local mt = gm.monsters:rawTable()
_:expect(#mt).is(6)
for i,m in ipairs(mt) do
_:expect(m.level).is(1)
end
end)
_:test("monsters correctly initialized level 2", function()
local gm = GameRunner()
Runner=gm
gm:createLevel(12)
gm:createLevel(12)
local mt = gm.monsters:rawTable()
_:expect(#mt).is(6)
for i,m in ipairs(mt) do
_:expect(m.level).is(2)
end
end)
Here we have lovely examples of why tests at high levels are a problem. These tests are creating entire dungeon levels, and checking to see if there right number and type of monster have been created. Note that the second test creates and checks level 2, by calling createLevel twice!
I suspect this test predates the Monsters class entirely. What are we interested in testing here?
We might be interested in testing whether GameRunner knows to ask for level two monsters in the second test, i.e. whether it is tracking level numbers correctly. If we are interested in that, we should write a more direct test. In my view, however, we weren’t interested in that.
We had just created the monster level selecting code and we wanted to know whether it correctly selected from the right group.
Today, we can, and should, test that more directly. These tests need to be redone.
Again, this is a thing that happens frequently when we test large assemblies of objects, that is, when our tests are not what GeePaw Hill calls “microtests”. Maybe the opposite of microtest is macrotest. Be that as it may, macrotests often change for the wrong reasons. Microtests usually only change for the right reasons.
I’ve been feeling the lack of good testing lately, so let’s replace these tests with better ones going to the same question, whether we’re creating the right number of monsters at the right level, given the number and the level.
That’s done in the Monster class, here:
function Monster:getRandomMonsters(runner, number, level)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile(1)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
What’s to test here? We don’t really want to test whether we set them down, that’s not our concern. We just want to know whether we get N of them, if we doubt this code, and whether they are at the right level, which is done by this code:
function Monster:getMonstersAtLevel(level)
if not MT then self:initMonsterTable() end
local t = {}
for i,m in ipairs(MT) do
if m.level == level then
table.insert(t,m)
end
end
return t
end
Let’s test that, not that it could fail. I guess that ideally, we’d test two things. We’d test whether all the monsters in the output table have the right level. That’s easy. And we’d test whether we have the same number of monsters as exist at that level. That would require us to scan the monsters table.
Let’s do it all. That means I need an accessor to MT, which is local to the Monster class. That’s how I avoid contaminating the global space. Back in the tests, I’ll remove the other tests and replace:
_:test("monsters level one correctly chosen", function()
local mt = Monster:getMonstersAtLevel(1)
local MT = Monster:getMT()
local c = 0
for i,m in ipairs(MT) do
if m.level == 1 then c = c + 1 end
end
_:expect(#mt).is(c)
for i,m in ipairs(mt) do
_:expect(m.level).is(1)
end
end)
Now I need getMT
:
function Monster:getMT()
if not MT then self:initMonsterTable() end
return MT
end
I decided to include the lazy init here, and I think I’ll refactor Monster to use the method. Once I have a method to access a member, I always feel that it’s better to use it everywhere.
I expect my new test to run. Naturally enough, it does. I remove the level two check, because this test convinces me that the function works as advertised.
Tests are green. Commit: improve monster level checking.
OK, that was fun. Now what?
Now What?
There was that sticky note about a random 1 in getRandomMonsters:
function Monster:getRandomMonsters(runner, number, level)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile(1)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
It’s the 1 there in the call to randomRoomTile
. That function returns a random room tile not in the room whose number you provide:
function GameRunner:randomRoomTile(roomToAvoid)
return self:getDungeon():randomRoomTile(roomToAvoid)
end
The getRandomMonsters
method knows that we want to avoid Room 1, because it knows that we want to ignore the princess’s room and it knows that that’s room 1.
Ideally, we’d pass that down as a parameter, from GameRunner, who knows what he wants. That would be done here:
function GameRunner:setupMonsters(n)
self.monsters = Monsters()
self.monsters:createRandomMonsters(self, 6, self.dungeonLevel)
self.monsters:createHangoutMonsters(self,2, self.wayDown.tile)
end
And here:
function Monsters:createRandomMonsters(runner, count, level)
self.table = Monster:getRandomMonsters(runner, count, level)
end
Not too arduous I guess. GameRunner should express the idea better, however.
function GameRunner:init()
self.tileSize = 64
self.tileCountX = 85 -- if these change, zoomed-out scale
self.tileCountY = 64 -- may also need to be changed.
self.cofloater = Floater(self, 50,25,4)
self.musicPlayer = MonsterPlayer(self)
self.dungeonLevel = 0
self.requestNewLevel = false
self.playerRoom = 1
end
And first, while we’re at it, we’ll pass that down those three levels:
function GameRunner:setupMonsters(n)
self.monsters = Monsters()
self.monsters:createRandomMonsters(self, 6, self.dungeonLevel, self.playerRoom)
self.monsters:createHangoutMonsters(self,2, self.wayDown.tile)
end
function Monsters:createRandomMonsters(runner, count, level, roomToAvoid)
self.table = Monster:getRandomMonsters(runner, count, level, roomToAvoid)
end
function Monster:getRandomMonsters(runner, number, level, roomToAvoid)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile(roomToAvoid)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
So that should be linked up just fine. But we also have some other 1s, in GameRunner:
function GameRunner:createLoots(n)
local loots = {}
for i = 1, n or 1 do
local tile = self:randomRoomTile(1)
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
table.insert(loots, Loot(tile, tab[1], tab[2], tab[3]))
end
return loots
end
Becomes, of course:
function GameRunner:createLoots(n)
local loots = {}
for i = 1, n or 1 do
local tile = self:randomRoomTile(self.playerRoom)
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
table.insert(loots, Loot(tile, tab[1], tab[2], tab[3]))
end
return loots
end
And also:
function GameRunner:createThings(aClass, n)
local things = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile(1)
table.insert(things, aClass(tile,self))
end
return things
end
Becomes
function GameRunner:createThings(aClass, n)
local things = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile(self.playerRoom)
table.insert(things, aClass(tile,self))
end
return things
end
That seems to be all of them. Test. All is well: Commit: removed magic number 1 (playerRoom) throughout.
And Now …
I noticed that I’m not using my new trial convention of putting an underbar on private member variables. I actually thought of doing it before I typed the first playerRoom
and actively decided not to do it. I reckon that means that I don’t like the idea.
Arguably, I owe it to future versions of me to do it one way or the other. But clearly I am not going to go back and check each member var for whether it is private. Fact is, however, my preferred style would be that they are all private, and that no one should be grabbing them out of other objects. If they need the info at all, it should be via an accessor, and often, needing the info is a hint that some capability is in the wrong place
I’d do better to search the system for accesses to other people’s privates.
Yes. I’ll first search out the _ variables and remove the underbar.
Meh. I did a few of those. It’s not worth it. I’ll fix them as I encounter them, but there’s no big gain to doing them all now, and it’ll take a while. Commit: renamed _characterSheet.
And Now?
It would be nice to do a little something for the users, but since it’s Saturday and I’m on my own time, unlike Monday through Friday, where I’m on my own time, I feel that I can just clean up a bit of code, add tests, whatever makes sense.
One bit of semi-maintenance might be to move some of my more recent sprite finds into the game. The sticky notes mentioned “skels”, which is of course short for skeletons, and I do have some objects like that that I’ve found and downloaded. Let’s move some items in so that we can readily use them.
Here’s a pic of some of the items. I think I’ll move the skeletons and the barrels, the … heck let’s just move all of them.
If I recall the trick, I use files to move them into Codea’s dropbox and then use Codea to move them into the project.
OK, they’re moved. Let’s see about putting some of them down. We have a request for the skeletons.
We could make them instances of Loot, with no behavior. Let’s look at Loot class.
local LootIcons = {Strength=asset.builtin.Planet_Cute.Gem_Blue, Health=asset.builtin.Planet_Cute.Heart, Speed=asset.builtin.Planet_Cute.Star}
function Loot:init(tile, kind, min, max)
self.tile = tile
self.kind = kind
self.icon = self:getIcon(self.kind)
self.min = min
self.max = max
if tile then tile:addDeferredContents(self) end
end
We could certainly put them here. They’d be odd, giving no points to anything. Or … what if they scare you and take away a health point? That might be amusing.
Let’s look at the creation of Loots though.
function GameRunner:createLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
TileLock=false
self:createTiles()
self:clearLevel()
self:createRandomRooms(count)
self:connectRooms()
self:convertEdgesToWalls()
self:placePlayerInRoom1()
self:placeWayDown()
self:placeSpikes(5)
self:setupMonsters(6)
self.keys = self:createThings(Key,5)
self:createThings(Chest,5)
self:createLoots(10)
self:createButtons()
self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
self:startTimers()
self.playerCanMove = true
TileLock = true
end
function GameRunner:createThings(aClass, n)
local things = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile(self.playerRoom)
table.insert(things, aClass(tile,self))
end
return things
end
Things get created by instantiating a class. Do we really want to have a class for these things? Yes, I guess we’d better, but let’s just make one class, Decor, for now. It will be something like a Key, but not even that bright. Let’s check Key.
Key = class()
function Key:init(tile, runner)
self.tile = tile
self.tile:addDeferredContents(self)
self.runner = runner
end
function Key:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(asset.builtin.Planet_Cute.Key,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
function Key:getTile()
return self.tile
end
function Key:take()
self.tile:removeContents(self)
end
OK, I think I’ll TDD a bit of decor, because I figure they’ll have random sprites.
New tab, Decor.
I figure I’ll let them randomly decide what to be, but you can choose if you wish, using a string to look up the sprite.
_:test("Decor Creation", function()
local tile = FakeTile()
local runner = nil
local decor = Decor(tile, runner, "Skeleton1")
_:expect(decor.sprite).is(asset.Skeleton1)
end)
With an empty class in place:
1: Decor Creation -- Actual: nil, Expected: Asset Key: Skeleton1.png (path: "/private/var/mobile/Containers/Data/Application/483B2209-7216-46A0-8C24-5253968FCBE5/Documents/D2.codea/Skeleton1.png")
No big surprise there. Let’s code. I kind of went above and beyond. Here’s what I’ve got:
Decor = class()
local DecorSprites = { Skeleton1=asset.Skeleton1, Skeleton2=asset.Skeleton2,
BarrelEmpty=asset.barrel_empty, BarrelClosed=asset.barrel_top, BarrelFull=asset.barrel_full,
}
function Decor:init(tile,runner,kind)
self.sprite = kind and DecorSprites[kind] or Decor:randomSprite()
self.tile = tile
self.tile:addDeferredContents(self)
self.runner = runner
end
function Decor:draw()
end
function Decor:randomSprite()
return asset.banner_1
end
Test runs. I want to test that complicated and
or
thing, so:
_:test("Random Decor", function()
local tile = FakeTile()
local runner = nil
local decor = Decor(tile,runner)
_:expect(decor.sprite).is(asset.banner_1)
end)
I expect this to run but not to last.
Test runs. Of course I want to select a random item here. Interesting question: how do we select a random item from a table of k,v pairs? And even worse, how do we test it.
Hell, I’m gonna write it.
function Decor:getDecorKeys()
local keys = {}
for k,v in pairs(DecorSprites) do
table.insert(keys, k)
end
return keys
end
function Decor:randomSprite()
local keys = self:getDecorKeys()
local key = keys[math.random(1,#keys)]
return DecorSprites[key]
end
And now I can only check for non-nil in the test:
_:test("Random Decor", function()
local tile = FakeTile()
local runner = nil
local decor = Decor(tile,runner)
_:expect(decor.sprite).isnt(nil)
end)
I’m satisfied that this works. Now they need to draw, and I can rip off that code from Key.
function Decor:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(self.sprite,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
Now to create some of these babies:
function GameRunner:createDecor(n)
for i = 1,n or 10 do
local tile = self:randomRoomTile(666)
local decor = Decor(tile, self)
end
end
I’ve called that with an n of 20. Note that I allow decor in the princess’s room. Let’s try this. I think it’s going to work.
Works fine:
Right now we have a lot of barrels and not much else, because of this:
local DecorSprites = { Skeleton1=asset.Skeleton1, Skeleton2=asset.Skeleton2,
BarrelEmpty=asset.barrel_empty, BarrelClosed=asset.barrel_top, BarrelFull=asset.barrel_full,
}
We’ll have three barrels for every two skeletons. Honestly, I want more skeletons. Let’s review what else we put in the list though. There was a crate … and pots … I’ll save the full pot to be a loot, and that gives me this:
local DecorSprites = { Skeleton1=asset.Skeleton1, Skeleton2=asset.Skeleton2,
BarrelEmpty=asset.barrel_empty, BarrelClosed=asset.barrel_top, BarrelFull=asset.barrel_full,
Crate=asset.Crate, PotEmpty=asset.pot_empty
}
I want lots more skeletons. I’ll just add some in with names no one will have to know.
local DecorSprites = { Skeleton1=asset.Skeleton1, Skeleton2=asset.Skeleton2,
s11=asset.Skeleton1, s12=asset.Skeleton1, s13=asset.Skeleton1, s14=asset.Skeleton1,
s21=asset.Skeleton2, s22=asset.Skeleton2, s23=asset.Skeleton2, s24=asset.Skeleton2,
BarrelEmpty=asset.barrel_empty, BarrelClosed=asset.barrel_top, BarrelFull=asset.barrel_full,
Crate=asset.Crate, PotEmpty=asset.pot_empty
}
Now we should get more skeletons. Also I think 20 decor isn’t enough. I’m going to put down 30.
Here’s the starting room I get now:
The bones look too much the same. Let’s randomly flip all the sprites at creation time, so they’ll be reversed.
local ScaleX = {-1,1}
function Decor:init(tile,runner,kind)
self.sprite = kind and DecorSprites[kind] or Decor:randomSprite()
self.tile = tile
self.tile:addDeferredContents(self)
self.runner = runner
self.scaleY = ScaleX[math.random(1,2)]
end
function Decor:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
scale(self.scaleX, 1)
sprite(self.sprite,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
That should look a bit better. Most of the tiles are symmetrical but the skels will visibly flip.
After some fiddling, because I coped and pasted poorly, I get this fine result:
The code is this:
local ScaleX = {-1,1}
function Decor:init(tile,runner,kind)
self.sprite = kind and DecorSprites[kind] or Decor:randomSprite()
self.tile = tile
self.tile:addDeferredContents(self)
self.runner = runner
self.scaleX = ScaleX[math.random(1,2)]
end
function Decor:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
translate(g.x, g.y)
scale(self.scaleX, 1)
sprite(self.sprite,0,0, 50,50)
popStyle()
popMatrix()
end
Let’s commit: Added 30 decor items to each level.
And let’s sum up. I’m taking the rest of the day off.
Summary
We have a nice start at decor, including what I consider a semi-elegant hack to change the proportions of items. There’s surely a better way to do that, but I can’t think of an easier or simpler one offhand. I’m sure the rules on Decor will change anyway, with level-by-level differences and so on.
It went in very nicely. The stumble with the draw
method may hint that we need a more generic means of drawing tile contents. Arguably we should have just grabbed a TileDrawingFlipper object or something, and used it.
Decor items are little more than obstacles at this point. Like all room items, they are placed so that they can’t, in principle, block anything important, though I suppose randomness could position a bunch of items surrounding an important item. We might extend the decor to let you walk on it, or even make them dangerous. That’s something we’ll decide later.
Other than the nice decor, which was easier to do than I had feared, we improved a bit of code and some tests.
A nice, easy, Saturday morning. I hope yours was as well.
See you next time!