Dungeon 97
Today, girls just want to have fun, and so do I.
I hate to seem like a complainer here, but this hassle with the Combat has been irritating. I’ve felt that I couldn’t move on to other things while the rudimentary combat cycle wasn’t working. Now I have confidence in it and I feel we can move forward.
Even cleaning up some code would feel good today. And I do have something in mind there.
It is my practice, lately, to keep methods in alphabetical order within a class, perhaps also separated out by class vs instance methods, occasionally otherwise grouped. But since Codea has no real hierarchical view in the IDE (coming soon, I’m told), alphabetical order is quite helpful when you’re looking for something.
I’ve noticed in particular that Player needs help, so I’ll do that. I’ll spare you the display of 200 lines out of order and then 200 in order. Quickly done. A nice little warmup, and it leaves things a bit easier to work with.
I was thinking that now that the combat round works, it should be beefed up a bit. And I think it needs a bit of internal improvement as well:
CombatRound = class()
function CombatRound:init(attacker,defender, rng)
self.attacker = attacker
self.defender = defender
self.random = rng or math.random
end
function CombatRound:__tostring()
return "CombatRound"
end
function CombatRound:attack()
if self.attacker:willBeDead() or self.defender:willBeDead() then return {} end
local result = {}
table.insert(result, self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, self:display(msg) )
local cmds = self:attemptHit()
table.move(cmds,1,#cmds,#result+1, result)
return result
end
function CombatRound:attemptHit()
local result = {}
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, self:display(msg) )
local cmds = self:rollDamage()
table.move(cmds,1,#cmds,#result+1, result)
return result
end
function CombatRound:display(aString)
return OP:display(aString)
end
function CombatRound:rollDamage()
local result = {}
local damage = self.random(1,6)
self.defender:accumulateDamage(damage)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
table.insert(result,op)
table.insert(result, self:display(self.defender:name().." takes "..damage.." damage!"))
return result
end
The structure here is left over from the time when the various methods were called by the crawl, one by one. So each one needed to return its own collection of results. We wrap the whole thing up as a unit now, so we can go to a single accumulator. I think that should make the code more clear. Let’s try it.
function CombatRound:init(attacker,defender, rng)
self.attacker = attacker
self.defender = defender
self.random = rng or math.random
self.commandList = {}
end
I’ve added a new member variable, commandList
, an empty table. The plan will be to accumulate everything into that list and return it from here:
function CombatRound:attack()
if self.attacker:willBeDead() or self.defender:willBeDead() then return {} end
local result = {}
table.insert(result, self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
table.insert(result, self:display(msg) )
local cmds = self:attemptHit()
table.move(cmds,1,#cmds,#result+1, result)
return result
end
I think all the other methods here can be considered private, though they are called in the tests. We’ll see about that as we move along.
I’ll be creating one or more helper methods as we go here.
function CombatRound:append(cmd)
table.insert(self.commandList, cmd)
end
function CombatRound:appendAll(cmds)
table.move(cmds,1,#cmds,#self.commandList+1, self.commandList)
end
function CombatRound:attack()
if self.attacker:willBeDead() or self.defender:willBeDead() then return self.commandList end
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
local cmds = self:attemptHit()
self:appendAll(cmds)
return self.commandList
end
I think this is correct. Tests will tell. Everything seems to work, though I did think I saw a late “Ghost is dead” message scrolling by after another monster attack. I’m going to ignore that, but I have to confess, it’s making me a bit sad to think that there may still be something off about combat and the crawl.
My overall plan here is to have the methods that are called in attack
do their own appending to the command list, rather than accumulate a temp result and then move it. So …
function CombatRound:attack()
if self.attacker:willBeDead() or self.defender:willBeDead() then return self.commandList end
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
return self.commandList
end
function CombatRound:attemptHit()
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg) )
local cmds = self:rollDamage()
self:appendAll(cmds)
end
Again this seems likely to work. But it breaks a test, because now attemptHit
isn’t returning a result and the test expects that it will. Let’s see what we can do about that:
_:test("attempt hit", function()
local result
local i,r
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
local co = CombatRound(player, monster)
result = co:attemptHit()
i,r = next(result,i)
_:expect(r.op).is("display")
_:expect(r.text).is("Princess whacks Spider!")
end)
If we fetch the commandList from co, we should find this test running.
_:test("attempt hit", function()
local result
local i,r
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
local co = CombatRound(player, monster)
co:attemptHit()
result = co.commandList -- invasive
i,r = next(result,i)
_:expect(r.op).is("display")
_:expect(r.text).is("Princess whacks Spider!")
end)
Test is green. I note in passing that an improvement I have in mind is going to break these tests further. We’ll talk about that soon. Let’s finish this refactoring improvement.
Next step is to make rollDamage
do its own accumulation:
function CombatRound:attemptHit()
local msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg) )
self:rollDamage()
end
function CombatRound:rollDamage()
local damage = self.random(1,6)
self.defender:accumulateDamage(damage)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
end
This probably breaks more tests.
3: rollDamage -- TestCombatRound:55: bad argument #1 to 'next' (table expected, got nil)
Yes. Same fix:
_:test("rollDamage", function()
local result
local i,r
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
randomNumbers = {4}
local co = CombatRound(player, monster, fakeRandom)
co:rollDamage()
result = co.commandList
i,r = next(result,i)
_:expect(r.op).is("extern")
_:expect(r.receiver).is(monster)
_:expect(r.method).is("damageFrom")
_:expect(r.arg2).is(4)
i,r = next(result,i)
_:expect(r.op).is("display")
_:expect(r.text).is("Spider takes 4 damage!")
end)
All good. However, this code seems odd to me. attack
calls attemptHit
, which calls rollDamage
. But there are reasons why it wants to be that way. The sequence is conditional, and in fact my plan is to enhance it to make it more so. But first, one more minor improvement:
function CombatRound:attack()
if self.attacker:willBeDead() or self.defender:willBeDead() then return self.commandList end
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
return self.commandList
end
I think this will be more clear this way:
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
end
return self.commandList
end
We don’t have willBeAlive
but that is easily remedied:
function Entity:willBeAlive()
return not self:willBeDead()
end
A quick test … Oddly, I’m getting this error:
6: monster attack -- TestCombatRound:123: attempt to call a nil value (method 'willBeAlive')
Oh. The error is from my Fakes.
function FakeEntity:willBeAlive()
return not self:willBeDead()
end
This is one reason why I don’t like using test doubles: they often require changes after confusing me. If I were smarter, perhaps I’d like them better.
Testing, all seems well. Let’s commit: refactoring CombatRound.
Now, I’d like to improve the cycle just a bit. I have in mind that an attempted hit could fail. This would be done by a roll of attacker speed against defender speed. If defender is faster, the attack misses.
function CombatRound:attemptHit()
local msg
if self.attacker:speedRoll() >= self.defender:speedRoll() then
msg = string.format("%s whacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg) )
self:rollDamage()
else
msg = string.format("%s evades attack!", self.defender:name())
self:append(self:display(msg))
end
end
We need to implement speedRoll
of course:
function Entity:speedRoll()
return math.random(1,self:speed())
end
I remembered to put something in for FakeEntity this time.
The new evasion feature works nicely. Let’s commit: faster entity can evade attack.
I think we’ll want to improve things further here, with different weapons and perhaps armor and the like. The mechanism seems to be in place and works well. I do think I’ve seen some sequencing issues in the crawl, but I’m not sure. Nothing really odd has happened, to the point where I’m not sure anything’s wrong, and a user probably will not notice a problem.
That’s not very comforting to me, and it shouldn’t be comforting to you either. If the code works, we should know it. If it doesn’t, we should know it, find out why, and fix it. “Seems OK” is not a good state to be in.
But sometimes … that’s the best we seem to get.
I was also thinking of improving the text that comes out. Every attacker always “whacks” the defender. That was fine when I was just testing, but now I’d like to have something more varied and interesting.
What if entities knew an “attack verb”? Perhaps a serpent poisons the princess, a vampire bat bites her, a ghost terrifies her, and so on. We could add that to the monster table, and give the princess a list of her own.
Let’s try:
function Monster:attackVerb()
return self.mtEntry.attackVerb or "whacks"
end
...
self.attackVerbs = {"whacks", "bashes", "stabs", "slaps", "punches", "slams"}
...
function Player:attackVerb()
return self.attackVerbs[math.random(1,#self.attackVerbs)]
end
And finally:
function CombatRound:attemptHit()
local msg
if self.attacker:speedRoll() >= self.defender:speedRoll() then
msg = string.format("%s %s %s!", self.attacker:name(), self.attacker:attackVerb(), self.defender:name())
self:append(self:display(msg) )
self:rollDamage()
else
msg = string.format("%s evades attack!", self.defender:name())
self:append(self:display(msg))
end
end
So that’s fun. It’s coming up on lunch time, let’s call it a day. We’ll do the monster ones tomorrow.
Commit: attack verbs for Player.
Summary
It’s so freeing to feel that things are under control. We managed to alphabetize a class today, then improved combat’s code, then added an additional combat feature, the possibility of evading a strike, and then added some interesting verbs to the Player’s vocabulary.
Strikes. I’ll add that. Commit: added “strikes” to player attack verbs.
So today has gone smoothly. I do believe I’ve seen some crawl anomalies, such as a death message coming out a bit late, but I’m not certain. I’ve certainly not seen anything really strange like we used to see. We’ll stay on the alert, and I’ll try to think of some way to test things more extensively, but the inherently asynchronous behavior makes that difficult, at least for the likes of me.
Overall, a pleasant morning, and that’s just what I needed. I wish the same to you!