Dungeon 68
Why do I almost always do something other than I planned to do yesterday? It’s a mystery.
I was thinking on my way to *$ for my morning chai that it seems I wind up each morning with a bit of planning for the next day, and then the next day, I work on something else. Surely this happens at least half the time. I don’t do that on purpose, but it is a bit like working on a real product with real product owners. It seems that priorities change, right in the middle of whatever we thought we would be doing.
Maybe it’s just that when the morning is over, I’m heads down in whatever I was working on and whatever I ran into, and in the morning, I see more clearly what needs to be done. Maybe in the morning I’m really dull and looking for something easy. Maybe I’m whimsical.
What’s moderately important about this is that wherever I’m called upon to work, I need to quickly refresh my recollection of how things work, and then I need to change things in order to add whatever new whim has gained top priority. If I’m going to be able to do that, I need code that I can quickly understand, code that does what it says. I need code that doesn’t surprise me.
When it does surprise me, the surprises are not usually happy ones. I try to work so that there are no surprises, because the odds are against me when it comes to surprises. To avoid surprises, I have a lot of strategies and tactics, many of them almost unconscious. (Unfortunately, I also have some unconscious habits that get in the way.) Here are some of the things I try to do:
- Meaningful names
- I give most variables and functions, especially longer-lived ones, meaningful names. I do use short-hand names, even the occasional anonymous function, but I use them very locally, where the context keeps them clear.
- Small meaningful methods
- When I capture tiny bits of meaning in a method, when the meaning has to change, I just have to change that method. When the meaning is spread around, I have to change lots of things.
- Methods tell stories
- Some methods only do one simple thing. Some do several things, like a drawing method. I find it best to write these methods as a series of calls to smaller meaningful methods. When I don’t–which is far too often–then I have to decode the meaning from a long patch of erratic code.
- Objects embody single ideas
- I try to create small objects that embody just one idea. Tile. Monster. Room. When I don’t manage that, things become harder to understand. The GameRunner is a
goodbad example of this. It has at least two very different functions, namely setting up a dungeon, and operating the game during play. This makes the code larger and harder to take in. It should be broken up, much like Amazon and Google. - Remove duplication
- We often find ourselves writing very similar patches of code. Sometimes we even copy one and change it a little. Whenever this happens, that patch of code represents some idea that we have, together with a way of doing it. This is a strong hint that there is some concept in our mind that isn’t fully represented in the code. When we remove that duplication, we give that idea a place to stand, whether it’s just a method or function or a very small class. It almost always pays off to do this, if only because it makes errors less likely the next time we do whatever that patch represents. More commonly, the starting idea will grow into a concept, becoming more and more useful … until, finally, it is so large that it wants to break into two or more ideas.
- When we do this consistently and judiciously, we evolve our program not just in capability, but in clarity. And that’s a very good thing.
I’ll come back and update this during the morning, if I catch myself applying other common strategies, I’ll come back here and mention them. Let’s do some work.
What Now?
Yesterday we improved the attribute sheets for player and monster. Which reminds me of something to do. That class is named MonsterSheet
. It should be AttributeSheet
. Let’s rename it.
Easily done, even as manually as Codea requires it. Five minutes to do, a few minutes of game testing, including besting a ghost and a vampire bat. Commit: MonsterSheet renamed to AttributeSheet.
Ah, here’s another thing I often do, remove duplication. You’ve already read about it, because I’m going to put it on the list above, right now.
There is great duplication between the Monster and Player classes. They’re not identical, but here are some examples of duplication:
function Monster:damageFrom(aTile,amount)
if not self.alive then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(asset.downloaded.A_Hero_s_Quest.Monster_Die_1)
self:die()
else
sound(asset.downloaded.A_Hero_s_Quest.Monster_Hit_1)
end
end
function Player:damageFrom(aTile,amount)
if not self:isAlive() then return end
sound(asset.downloaded.A_Hero_s_Quest.Hurt_1, 1, 1.5)
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(asset.downloaded.A_Hero_s_Quest.Hurt_3,1,1.5)
self:die()
end
end
These are not identical, but they certainly could be. A good first step is to make them identical. It avoids confusion about “why are these different” and it sets us up for reducing the duplication later. (Yes, increasing duplication is often a good start at reducing it.)
The Player version is simpler. However, the Monster one may be better. When the player dies, she makes two sounds, her injury sound and her death sound. The Monster makes only one or the other. I think that’s preferable.
function Player:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(asset.downloaded.A_Hero_s_Quest.Hurt_3,1,1.5)
self:die()
else
sound(asset.downloaded.A_Hero_s_Quest.Hurt_1, 1, 1.5)
end
end
Now they’re the same except for the sounds. Right now, we have no real reason to make them absolutely the same, because we have no plan to combine the two classes, but we can readily do this:
function Player:init(tile, runner)
self.alive = true
self.tile = tile
self.tile:illuminate()
self.tile:addContents(self)
self.runner = runner
self.keys = 0
self.healthPoints = 12
self.speedPoints = 8
self.strengthPoints = 10
self.attributeSheet = AttributeSheet(self,750)
self.deathSound = asset.downloaded.A_Hero_s_Quest.Hurt_3
self.hurtSound = asset.downloaded.A_Hero_s_Quest.Hurt_1
end
function Player:damageFrom(aTile,amount)
function Player:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(self.deathSound,1,1.5)
self:die()
else
sound(self.hurtSound, 1, 1.5)
end
end
function Monster:init(tile, runner, mtEntry)
if not MT then self:initMonsterTable() end
...
self.attributeSheet = AttributeSheet(self)
self.deathSound = asset.downloaded.A_Hero_s_Quest.Monster_Die_1
self.hurtSound = asset.downloaded.A_Hero_s_Quest.Monster_Hit_1
end
function Monster:damageFrom(aTile,amount)
if not self.alive then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(self.deathSound)
self:die()
else
sound(self.hurtSound)
end
end
Now the two methods are nearly identical. There’s just one little difference, in the sound calls themselves:
sound(self.deathSound,1,1.5) -- player
sound(self.deathSound) -- monster
The difference is that we use a higher pitch for the player, as the sound files we have are a bit low-pitched, even for as strong a princess as we have. We can readily make those lines duplicate as well:
function Monster:damageFrom(aTile,amount)
if not self.alive then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
And of course an init of pitch to 1 in Monster and 1.5 in Player.
Now these two methods are identical. There’s an opportunity here to boil them down into one place, but we’ll leave that for now. Commit: Monster and Player damageFrom
are identical.
Why not combine them now? Well, there’s no real place to put the new function, and it wouldn’t seem right to call from Monster over to Player or vice versa. Perhaps there is a common new object, a BattleComponent or something, waiting to be born.
Let’s look for more duplicate or near-duplicate methods between Monster and Player.
function Monster:damageFrom(aTile,amount)
if not self.alive then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(asset.downloaded.A_Hero_s_Quest.Monster_Die_1)
self:die()
else
sound(asset.downloaded.A_Hero_s_Quest.Monster_Hit_1)
end
end
function Monster:die()
self.alive = false
end
function Player:damageFrom(aTile,amount)
if not self:isAlive() then return end
sound(asset.downloaded.A_Hero_s_Quest.Hurt_1, 1, 1.5)
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.healthPoints = 0
sound(asset.downloaded.A_Hero_s_Quest.Hurt_3,1,1.5)
self:die()
end
end
function Player:die()
self.alive = false
self.healthPoints = 0
end
I copied the damageFrom
methods again, because I noticed the small difference between the Monster and the Player’s die
methods: Player sets health to zero and Monster does not. Player also sets zero in the damageFrom
method, as does Monster. We can change monster to match Player, and remove the redundant sets from the damageFrom
method.
function Player:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
function Player:die()
self.alive = false
self.healthPoints = 0
end
function Monster:damageFrom(aTile,amount)
if not self.alive then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
function Monster:die()
self.healthPoints = 0
self.alive = false
end
Note that the little methods aren’t identical yet. It seemed to me that setting health to zero is logically prior to being dead. (Arguably, we should get rid of the alive flags altogether, but that’s an unnecessary complication right now.) Anyway I decided I like that order better, so I go back and change Player:
function Player:die()
self.healthPoints = 0
self.alive = false
end
What else is duplicated?
function Player:displayDamage(boolean)
self.showDamage = boolean
end
This is duplicated in both. Seems more and more like we have a BattleHelper class or something forming.
Here are two with the same idea but different implementations:
function Player:displaySheet()
return true
end
function Monster:displaySheet()
return self:isAlive()
end
We display the player’s sheet always. We don’t display a dead monster, who may be nearby, in favor of displaying a live one who may be further away. That was an intentional decision.
I find methods healthMax
and healthPossible
, which are not even used. Remove this duplication with extreme prejudice. Then commit: removing duplication, Monster/Player.
I think those Max/Possible methods are mirrored in strength. Yes, remove. Commit: more duplication M/P.
In testing, I’m reminded that I think I’ve seen someone, a Monster or a Player, display Yellow before dying rather than Red. Where’s that handled?
Ah, I see what’s going on there. The red tint doesn’t mean fatal, it means your health, prior to the damage, is <= 2. So a massive hit will display yellow, then dead. That’s fine.
I’m bored with this duplication game but let’s take one more quick scan of Monster and Player to see what else may pop up.
Methods getTile
, isAlive
, isDead
are identical, as is selectDamageTint
. I notice a method out of alpha order. Commit: reorder Monster methods alphabetically
Where are we?
Lifting our head above the keyboard and checking out the snow on the lake, we recognize that we’ve simplified the code in a strange way, by making methods in two classes identical. We used a few simple tricks to make that happen:
- Moving lines
- Intentional duplication
- Extracting constants to member variables
We haven’t removed much duplication yet, but there does seem to be a common notion among these things we found, something about damage or battle or conflict. Maybe there’s an object to be found.
Another possibility is to make one of these objects a subclass of the other, or of an abstract class, and override methods. Overriding concrete methods is supposedly a bad thing to do, but I confess that I’ve done it and not been troubled by it, so it’s a possibility.
I read somewhere that inheritance is really just a programmer hack to save code, and to the extent that that’s true, the tactics above are pragmatic and probably OK. If you buy into the use of hierarchy to represent some kind of pure abstraction, you’ll perhaps run into more trouble. I’ve studied a lot of theory, but in the end, I try to do what works.
One more thing …
I think I mentioned that on my other iPad, the one by the TV, the processor is slower. This one runs most Codea at 1/120th of a second per draw cycle. The other one runs at 1/60. Our floater increments the y position of the text by one pixel every draw cycle. That means that it runs at half speed on my other iPad. This is truly boring.
1/120 is 0.008333 and 1/60 is 0.016666. So if we were to check DeltaTime
and increment by 2, that should bring my old iPad up to a reasonable crawl speed. Let’d do that.
function Floater:increment(n)
self.yOff = self.yOff + (n or 1)
if self:linesToDisplay() > self.lineCount then
table.remove(self.buffer,1)
self.yOff = self.yOff - self.lineSize
end
if #self.buffer < self:linesToDisplay() then
self:fetchMessage()
end
if #self.buffer == 0 then
if self.runner then self.runner:startMonsters() end
end
end
Let’s change that. The parameter n is used only in testing, and I don’t want to mess with what it does if the value is there. By intention:
function Floater:increment(n)
self.yOff = self.yOff + (n or self:adjustedIncrement())
if self:linesToDisplay() > self.lineCount then
table.remove(self.buffer,1)
self.yOff = self.yOff - self.lineSize
end
if #self.buffer < self:linesToDisplay() then
self:fetchMessage()
end
if #self.buffer == 0 then
if self.runner then self.runner:startMonsters() end
end
end
And …
function Floater:adjustedIncrement()
if DeltaTime > 0.01 then return 2 else return 1 end
end
This made the scroll go twice as fast on my fast machine. That sent me looking for DeltaTime and I find that it is running between 0.04 and 0.05, that is, not 1/120, not 1/60, but maybe 1/30 or worse. This program is cycling very slowly.
Perhaps this should not be a surprise. We are drawing 5440 tiles, twice, on every cycle. The code to do that isn’t all that simple. Still, the game’s response is fine. I don’t see the need to optimize, although optimization would be fairly straightforward, maybe.
We could probably reduce the impact in half, because this is the current draw:
function GameRunner:draw()
font("Optima-BoldItalic")
self:drawLargeMap()
self:drawButtons()
self:drawTinyMap()
self:drawMessages()
end
We draw the map twice, once large and once small. We could probably push the draw down to the Tile function. We only have one draw loop, called twice:
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
In any case, I’m not going to worry about that right now, but I still would like to adjust the scrolling speed. I’d best hold off on that until I can time the other iPad, to get a value to use. So:
function Floater:adjustedIncrement()
return 1
--if DeltaTime > 0.02 then return 2 else return 1 end
end
Commit: get ready to speed up floater on slower iPads.
This will be a good place to stop, on a surprise. See what I mean about surprises often not being happy ones?
See you next time?