Dungeon 96
Combat and the Crawl have been kicking my tail. This does not please me, even a little bit. I’m sure the difficulty is at least partly due to a poor description of the problem, leading to poor solutions. Today I win.
Let’s try to express our problem clearly.
A combat round is a unit of behavior where one entity attacks another. There are initial conditions: both entities need to be alive when the combat starts. Combat can change an entity’s status such as health and life.
The system displays entity status in the entity’s graphic, and in an attribute sheet showing things like health. The system reads this information directly from the entity, which has the values.
We display narrative information in the “crawl”, which is a floating scroll of text that appears above the player’s image when there is something to describe.
We want the player’s status display to change only when the crawl reaches that part of the narrative. Crawl says “Princess takes 3 points damage!” and at that time, her health bar drops and she says “Eeek”. Note that the 3 points damage was rolled back in the past, when the combat round was computed.
Entity combat behavior is predicated on the status of the combatants at the time the combat starts. In particular, we must know at the start of a given combat calculation whether the combatants will be alive when the combat actually starts, but that information isn’t presently available until the crawl stops displaying the previous combat round.
The unresolved problem: Player and monsters take turns. When it is the monsters’ turn, GameRunner loops over all monsters, letting each one move. Each monster’s move might initiate an attack. Suppose monster A attacks. The game runs a CombatRound and buffers up the narrative, returning to the GameRunner loop, which lets another Monster move. If that monster moves to attack, the results of the preceding attack have not yet been applied to the player and so we cannot (today) know whether the combat can run or not.
Musing About Solutions
Don’t have a crawl.
One easy solution would be to ditch the crawl and flash-dump results onto a console. But we like the crawl and see it as a unique selling point for our game.
Queue monsters separately
Possibly, instead of looping over all monsters at once, we should have a list of Player, Monster1, M2, M3, …, and must allow one entity to have a turn until the previous one had finished. But I don’t think this solves the problem entirely, because the question is “when is the turn over?”
If a monster just moves and doesn’t encounter the player, its move is immediately over, and the next can proceed. But if it does encounter the player, combat ensues, then the next monster can’t proceed until the result of the combat round is known.
One way to know the result is to prevent the next monster from proceeding until the crawl has shown the previous encounter. Another way to know the result might be to predict it somehow, based on, say, whether accumulated but unapplied hit points exceed current health.
Just predict the result
What if we just accumulated unapplied hit points in entities, and when we start combat, consider that value compared to current health. And what if, when we applyDamage, we subtract that value from the accumulated unapplied hit points, keeping it in balance?
Would that allow us to run all the monsters in a batch as we presently do?
It might work. It should be easy to try. Let’s try.
Accumulated Damage
Let’s try this:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
if self.accumulatedDamage < 0 then self.accumulatedDamage = 0 end
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
I’ve initialized accumulatedDamage
to zero in both monster and player.
Now let’s implement willBeDead
to see if it’s helpful.
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage > self.healthPoints
end
Now let’s provide a way to accumulate damage:
function Entity:accumulateDamage(amount)
self.accumulatedDamage = self.accumulatedDamage + amount
end
Now let’s use it in combat when we issue damage:
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
And let’s start combat with a check for whether the entities will be dead:
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 freely grant that I’m not sure if this will solve my problems, but I am hopeful. Let’s try it and see what happens.
Tests fail. I need:
function FakeEntity:willBeDead()
return self:isDead()
end
Try again. Also needs accumulateDamage. I think our fake can ignore it.
function FakeEntity:accumulateDamage()
end
Well. I still see anomalies, but of course I don’t really know whether the accumulated damage is working correctly. It seems like it should be. Maybe I can display it in the attribute sheet.
This sequence looks good, and we see the accumulated damage going up and down as it should. However, I have seen attacks occurring after the player is dead. They’re not easy to catch on video, but they do happen.
I think I see a problem:
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage > self.healthPoints
end
Should be >=
.
This gang of monsters kills the princess, but the sequencing looks to be OK.
We still have the deferred turn completion stuff going, I think. Let’s see if we can find that.
function GameRunner:playerTurnComplete()
self.playerCanMove = false
self:moveMonsters()
local op = OP("extern", self, "monsterTurnComplete")
self:addToCrawl( { op } )
end
I think we can replace that:
function GameRunner:playerTurnComplete()
self.playerCanMove = false
self:moveMonsters()
self.playerCanMove = true
--local op = OP("extern", self, "monsterTurnComplete")
--self:addToCrawl( { op } )
end
Now the player should move at full speed, but with the monsters getting a turn when she does. I’ll have to double-check that. Or single check it. When do we send Player:turnComplete
? Yes, after every button or keypress. So they should be able to keep up.
It seems that they do keep up. I am noticing that I can’t run at full speed of my typing. This could just be due to the fact that I’m running 19 monsters at the moment. Or perhaps playerCanMove
is being set elsewhere?
function Player:turnComplete()
self.runner:stopPlayerMovement()
local op = OP("extern", self.runner, "playerTurnComplete")
self.runner:addToCrawl( { op } )
end
Ah, that’s enqueueing the message in the crawl. I think we can call that directly now, and remove all the deferral stuff.
function Player:turnComplete()
self.runner:playerTurnComplete()
end
I believe this works correctly. Let’s commit: using accumulatedDamage to predict an entity will be dead.
But first, I want to do a belt and suspenders thing:
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
Oh, and stop displaying that accumulated damage.
Now we can commit: using accumulatedDamage to predict that an entity will be dead.
And let’s sum up. I’m really wondering what the heck has been going on, so I’m looking forward to my explanation,
Summary
It seems that the solution to my problem is very simple:
When should we not initiate combat? When either of the participants will be dead by the time of combat. How can we know if they’ll be dead? If their accumulated damage meets or exceeds their health.
But as constant readers know, I’ve been circling around this issue for a long elapsed time, and quite a few attempts. Then it turns out to be this simple. What happened to cause me to suddenly see the answer? What was happening to cause me not to see such a simple answer?
Honestly, I don’t know. There’s no question that the crawl idea makes things odd, since it made us want to compute effects now that will be displayed in the future. There’s no question that I find thinking about it to be difficult, even if all my readers find it easy. (And I suspect that my readers haven’t engaged the problem deeply, and are just reading along going “what is with that guy anyway?”)
Since my thoughts about the problem were complicated, maybe I kept looking for complicated solutions, like coroutines, and deferred operations, and deferred locks and unlocks. I was thinking about transactions and Futures and Promises. I was thinking about some kind of covering object that I could manipulate and later apply to the original.
The solution: add up accumulated damage when you decide to do damage, count it down when you apply the damage, use the accumulated and current value to decide what to do.
Wow. Where did this simple and easy solution come from? Honestly, all I’ve got is “when things seem to complicated, they probabaly are”, and “keep trying”.
Anyway this seems to work. It’s simpler than it was. I think I understand it, and given that I do, it works.
What’s not to like?
Well, there’s one potential issue, which is that we have two separate kinds of accounting for damage, supporting this “in the future” behavior that we need. Those two could, in principle, get out of sync. If they do, surely strange things will happen.
What would such a defect look like? Well, if we accumulate damage and then never apply it, the account would be out of balance, and there’s no obvious way to get it back into balance correctly. Was the original accumulation wrong? Or was it a failure to apply?
I’m not sure how we could readily detect that happening and and determine what happened. We could attach a list of transactions to the entity, and add them up to get accumulated damage, and then resolve them when we do damageFrom
, and if, at a suitable moment, we found an unresolved transaction, we could arrange to be able to tell where it came from.
That’s probably more than we want to deal with. For right now, I think we’re good to go. I’ll reduce the number of monsters back to normal (I had it set high for testing) and we’ll commit: combat working better than ever before.
Let’s call it a day. See you next time!