Dungeon 59--Get back, mama
Today, more work on combat encounters. I have in mind separating the fighters.
The Encounter description crawl seems to be kind of fun. However, the flow of an Encounter isn’t complete yet, as we’ll see shortly. In addition, we need to freeze the player during an Encounter, which we’ve not yet done. Finally, I think I’ll make one of the participants fall back.
My reason for the falling back is that if we leave them adjacent to each other, as they are at the beginning of the encounter, one or the other can attack before the other or the one is ready. I think this will make the battles a bit more balanced.
Let’s start by filling out the Encounter. The attack
method drives all the work:
function Encounter:attack()
self:log(self.attacker:name().." attacks "..self.defender:name())
local attackerSpeed = self:rollRandom(self.attacker:speed())
local defenderSpeed = self:rollRandom(self.defender:speed())
if attackerSpeed >= defenderSpeed then
self:firstAttack(self.attacker, self.defender)
else
self:firstAttack(self.defender, self.attacker)
end
self.attacker.runner:addMessages(self.messages)
return self.messages
end
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes first")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
else
self:attackStrikes(attacker,defender)
end
end
function Encounter:attackMisses(attacker, defender)
self:log(defender:name().." avoids strike")
end
function Encounter:attackStrikes(attacker,defender)
local damage = self:rollRandom(attacker:strength())
if damage > 0 then
self:log(attacker:name().." does "..damage.." damage")
defender:damageFrom(attacker.tile, damage)
if defender:isDead() then
self:log(defender:name().." is dead")
end
end
end
Some of the flows aren’t as complete as I’d like, and there are some state changes that are not given a message. For examine, look at attackStrikes
just above. We’ve rolled against speed to see if the defender was able to avoid the strike or block it (though we only report “avoids”). If the attacker wins the speed roll, we then roll damage, which we log, and then we determine whether the blow was fatal.
If there was no damage, we don’t say anything. Let’s say something:
function Encounter:attackStrikes(attacker,defender)
local damage = self:rollRandom(attacker:strength())
if damage == 0 then
self:log("Weak attack! ".. attacker:name().." does no damage!")
else
self:log(attacker:name().." does "..damage.." damage!")
defender:damageFrom(attacker.tile, damage)
if defender:isDead() then
self:log(defender:name().." is dead!")
end
end
end
I’ve also added exclamation points to the messages, which I am sure will add to the excitement of the game almost immeasurably. Small or large immeasurably, I’m not saying.
I think it’ll be better to mention the defender in the new message, we’ll have mentioned the attacker in the preceding one.
self:log("Weak attack! ".. defender:name().." takes no damage!")
This message won’t come out often, but it will provide a kind of closure to the Encounter that is otherwise missing.
I have in mind another stage for the encounter, a possible riposte from the defender, but before we do that let’s see about how we might manage someone stepping back.
We always return to the end of the attack
method:
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes first!")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
else
self:attackStrikes(attacker,defender)
end
end
This would be one good place to make the decision. However, there’s an issue. When the encounter runs, one entity has tried to move into the tile of the other. Let’s see if we can trace how this all happens. We’ll look at Player. I believe the behavior is the same for Monster, though we’ll check it.
The Player selects a move and then:
function Player:moveBy(aStep)
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
end
The legalNeighbor
function:
function Tile:legalNeighbor(anEntity, aStepVector)
local newTile = self:getNeighbor(aStepVector)
return self:validateMoveTo(anEntity,newTile)
end
function Tile:validateMoveTo(anEntity, newTile)
if newTile:isRoom() then
local tile = newTile:attemptedEntranceBy(anEntity, self)
self:moveEntrant(anEntity,tile)
return tile
else
return self
end
end
function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
local ta
local acceptedTile
local accepted = false
local residents = 0
for k,residentEntity in pairs(self.contents) do
residents = residents + 1
ta = TileArbiter(residentEntity,enteringEntity)
acceptedTile = ta:moveTo()
accepted = accepted or acceptedTile==self
end
if residents == 0 or accepted then
return self
else
return oldRoom
end
end
The attemptedEntranceBy
function checks all the residents and uses TileArbiter to decide whether to accept. If any resident accepts the move it is allowed.
I plan to change things so that, in general, there is only one resident on a given tile. Otherwise, things get odd. But for now, there could be a few. We’ll suppose there’s only an enemy. What does TileArbiter do?
function TileArbiter:moveTo()
local entry = self:tableEntry(self.resident,self.mover)
local action = entry.action
if action then action(self.mover,self.resident) end
local result = entry.moveTo(self)
return result
end
This guy looks up a pair entry in the table and first does the action, if there is one, and then does the entry’s moveTo
function.
The monster-player entry looks like this:
t[Monster][Player] = {moveTo=TileArbiter.refuseMoveIfResidentAlive, action=Player.startActionWithMonster}
So we’ll start action and then allow the player to move if the monster has died. (I think I’d like for this not to happen, just based on game play.)
So this is tricky. the start action will have run the battle, but we don’t expect any result back from the encounter. We could return something, and could have a new TA:moveIf
method to check that return. The TileArbiter really only has two possible results:
function TileArbiter:refuseMove()
return self.mover:getTile()
end
function TileArbiter:acceptMove()
return self.resident:getTile()
end
Ah. Suppose that during the Encounter, we change the mover’s tile to something else, like one tile backward. Then the table entry can either always refuse, or continue to refuse if the resident is alive, and the refuseMove
method will actually move the attacker one square backward.
I conclude that if the encounter sets the attacker onto a new tile, it will remain there. One more thing: which direction can we move?
We can’t necessarily move backward away from the defender: we might have sidled along a wall to get into attack position. We’ll have to search around the player for a tile we can move to. There are three possibilities for where we came from, and we could in principle move the attacker diagonally one tile.
In fact we could move them anywhere, if my reasoning is correct.
For now, let’s just move the attacker in the direction away from the attack.
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes first!")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
else
self:attackStrikes(attacker,defender)
end
self:moveAttackerAway(attacker,defender)
end
For a first test:
function Encounter:moveAttackerAway(attacker,defender)
local dir = defender.tile:directionTo(attacker.tile)
local newTile = attacker.tile:getNeighbor(dir)
attacker.tile = newTile
end
This seems not to work as intended. Odd things happen, including that sometimes the attacker disappears entirely until it moves again. I think we can’t just go around randomly setting people’s tiles. We need to move them explicitly so that the tile contents get adjusted. Ah, right:
function Tile:moveEntrant(anEntity, newTile)
self:removeContents(anEntity)
newTile:addContents(anEntity)
end
This has gone on a while, though. Too long. If this doesn’t start working real soon now, I’m going to revert.
function Encounter:moveAttackerAway(attacker,defender)
local dir = defender.tile:directionTo(attacker.tile)
local newTile = attacker.tile:getNeighbor(dir)
attacker.tile:moveEntrant(attacker, newTile)
end
OK, still not working, and I’m not seeing the reason. Revert.
Arrgh, wait. I didn’t commit the good encounter changes. I need to cherry pick this work. Easy enough. Remove this:
self:moveAttackerAway(attacker,defender)
function Encounter:moveAttackerAway(attacker,defender)
local dir = defender.tile:directionTo(attacker.tile)
local newTile = attacker.tile:getNeighbor(dir)
print(defender, attacker, attacker.tile, " to " , newTile)
attacker.tile:moveEntrant(attacker, newTile)
end
Quick test and the commit: “Add message to encounter for weak attack missing”.
Let’s Regroup
OK, that was a gumption trap, but presumably I learned something. What might it be?
- Trying to trick the TileArbiter into doing an unanticipated move may not be a good idea. Something more direct might be better.
- Sometimes the move code seemed to think that the attacker and defender were on the same tile, and other times it seemed to think that they were on adjacent tiles. The latter is what I expected. What’s the real situation.
- Perhaps the right place to put the withdrawalmove is to specify it in the TileArbiter table.
- Maybe there’s a way to test this with CodeaUnit. If so, we’d be wise to think of it.
- We need a better idea for how to do this.
Now it’s 1012, so I’m near my two hour limit, which sometimes runs to three hours if I’m on a roll. At the moment I’m on whatever the reverse of a roll is. Let’s take up another task to get some momentum back.
One task is to freeze the player during the Encounter. That should be easy enough: we have that flag.
function GameRunner:monsterCanMove()
return not self.freezeMonsters
end
Let’s just condition Player movement on that flag:
function Player:moveBy(aStep)
if not self.runner:monsterCanMove() then return end
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
end
That should do nicely. And it does. Doesn’t stop me from repeatedly tapping a move button until I can move, but that seems OK. At least now I can’t attack quite as instantly. I still want the withdrawal though.
Commit: player can’t move if monsters can’t move.
Now, how about a riposte? Despite who tries to attack whom, we roll speed and decide who really gets to strike. Right now, the other entity never gets to strike back. If they’re not damaged, we want them to attempt a riposte.
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes first!")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
else
self:attackStrikes(attacker,defender)
end
end
If attackMisses
, let’s allow the defender to attack. An interesting question is whether we should do this with a subsequent method, or by calling firstAttack
recursively. If we call recursively, an entire battle can play out with riposte upon riposte.
Let’s try it. The messages will be a bit off but we can surely fix that.
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes first!")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
if math.random() > 0.5 then
self:log("Riposte!!")
self:firstAttack(defender,attacker)
end
else
self:attackStrikes(attacker,defender)
end
end
This doesn’t happen very often, so it’s difficult to get a good movie of it. Also, there are other places from which a riposte is reasonable, namely when an attack does no damage.
function Encounter:attackStrikes(attacker,defender)
local damage = self:rollRandom(attacker:strength())
if damage == 0 then
self:log("Weak attack! ".. defender:name().." takes no damage!")
if math.random() > 0.5 then
self:log("Riposte!!")
self:firstAttack(defender,attacker)
end
else
self:log(attacker:name().." does "..damage.." damage!")
defender:damageFrom(attacker.tile, damage)
if defender:isDead() then
self:log(defender:name().." is dead!")
end
end
end
I’m a little concerned that we could get some strange battle flow from this recursion, but inspection tells me that it should unwind just fine.
This long film shows some interesting results.
First, there is a very long sequence of strike and riposte, long enough to make me worry about whether I had somehow gotten stuck in the recursion, despite that it clearly has to stop at some point, by rolling less than 0.5.
Second, it’s really clear that we shouldn’t move onto the dead guy’s square as part of the battle sequence.
Third, it doesn’t seem fair that the monsters were attacking the dead princess.
Let’s fix the moving into dead square. That’s just a change to the TA table:
t[Player][Monster] = {moveTo=TileArbiter.refuseMove, action=Monster.startActionWithPlayer}
t[Monster][Player] = {moveTo=TileArbiter.refuseMov, action=Player.startActionWithMonster}
Commit: Ripostes are possible. Winner no longer moves into dead entity tile.
Now what about the entity that’s already dead? They shouldn’t start a war, they should just allow entry. That’s in the startActionWith
methods.
Hmm. It’ll be easy to make them not start the fight. Not so easy to allow the move. Let’s do the fight bit:
function Monster:startActionWithPlayer(aPlayer)
if self:isDead() then return end
Encounter(self,aPlayer):attack()
end
function Player:startActionWithMonster(aMonster)
if self:isDead() then return end
Encounter(self,aMonster):attack()
end
At this point, a monster can’t step on a dead princess, nor can the princess step on a dead monster. That could be a problem if we’re in a hallway, so we will need to deal with the concern.
Let’s test and commit, however.
Hm this is bad:
TileArbiter:28: attempt to call a nil value (field 'moveTo')
stack traceback:
TileArbiter:28: in method 'moveTo'
Tile:60: in method 'attemptedEntranceBy'
Tile:256: in function <Tile:254>
(...tail calls...)
Player:136: in method 'moveBy'
Player:129: in method 'keyPress'
GameRunner:208: in method 'keyPress'
Main:27: in function 'keyboard'
I suspect this bug is in the first change, not the second. Revert and verify.
Correct. Princess attack crashes the system. Weird. Oh. Do I see a typo in the table?
t[Monster][Player] = {moveTo=TileArbiter.refuseMov, action=Player.startActionWithMonster}
Indeed I do. Let’s add an “e” and see if that helps. Yes, that fixes the crash. Commit: fix defect in TA table.
Now to put back the dead checks … arrgh and they were wrong before. They should be:
function Player:startActionWithMonster(aMonster)
if aMonster:isDead() then return end
Encounter(self,aMonster):attack()
end
function Monster:startActionWithPlayer(aPlayer)
if aPlayer:isDead() then return end
Encounter(self,aPlayer):attack()
end
That works as intended. The other way makes little sense. You shouldn’t be moving about if you’re dead … although the player can in fact move when dead … for now.
Commit: can’t move onto opponent squares, living or dead.
I think that’ll do for the morning. Let’s sum up.
Summary
Maybe three out of four is a gentleman’s C for the day. There were a few missteps even in the things that worked, and of course I spent way too long failing to get the withdrawal to work. Arguably withdrawal never works. But I digress.
Remind me next time to set a timer and use it to at least think about whether I’m at the bottom of a hole and still digging.
Overall, things are nearly good. I think I want to do one more change now, though. When an entity gets a riposte, we call “first attack” recursively, and that always logs “strikes first”, which isn’t quite right. Let’s change that message and see if we can log another one for the real first strike.
function Encounter:firstAttack(attacker, defender)
self:log(attacker:name().." strikes!")
local attackerSpeed = self:rollRandom(attacker:speed())
local defenderSpeed = self:rollRandom(defender:speed())
if defenderSpeed > attackerSpeed then
self:attackMisses(attacker,defender)
if math.random() > 0.5 then
self:log("Riposte!!")
self:firstAttack(defender,attacker)
end
else
self:attackStrikes(attacker,defender)
end
end
function Encounter:attack()
self:log(self.attacker:name().." attacks "..self.defender:name().."!")
local attackerSpeed = self:rollRandom(self.attacker:speed())
local defenderSpeed = self:rollRandom(self.defender:speed())
if attackerSpeed >= defenderSpeed then
self:log(self.attacker:name().." is faster!")
self:firstAttack(self.attacker, self.defender)
else
self:log(self.defender:name().." is faster!")
self:firstAttack(self.defender, self.attacker)
end
self.attacker.runner:addMessages(self.messages)
return self.messages
end
This will tell a story like this:
Princess attacks Murder Hornet
Murder Hornet is faster!
Murder Hornet strikes!
Princess avoids strike!
Riposte!
Princess strikes!
...
Ah, got a lovely long battle with a Poison Frog. We’ll close with that. See you next time!