Dungeon 61
I’ll save you some reading and trash the first version of this article. I will sum up. Then I’m going to try a coroutine. Hold my chai.
I spent two hours and a bit on Sunday, working on a Finite State Machine, which I had thought would make a better way to manage the battle. It still might be a good solution, but if I’m to write one, I’ll have to do it in a cleaner space than this full program. Once done, it would fit in well enough, I think, but at least with the scheme I had yesterday, I couldn’t see a tiny start.
For my sins, I promise to work on either an FSM or a behavior tree for the program at a future time. Probably a behavior tree, they seem more interesting. For now, I have a different plan. Let’s talk about the requirement, and then my proposed solution.
Battle commentary is after the fact
When the Encounter object runs, it conducts an entire round of battle, including multiple attacks and defenses, many rolls of the imaginary dice, and many effect, often including death of one of the entities. It does this all in one method execution, requiring a few zilliseconds to run. It produces an array of text lines describing the battle. Those lines are then slowly scrolled on the screen, written in present tense but in fact describing a battle that is already over.
One odd effect of this design is that one of the opponents can display as dead almost before the scrolling starts. This kind of spoils the excitement, especially if you’re the one who’s dead.
The feature we want is to change things so that the text description and the visible effect on screen come out at (more nearly) the same time, so that it looks better.
Bryan Beecham suggested tagging the text array with clues as to what to display on the screen, so that behind the scenes we could do visual effects. This seemed like a great idea and I had intended to do it. But I came to realize that while it’s an elegant idea, it wouldn’t work well.
For example, during a battle, the health of the opponents changes as they take damage. The flow of the battle is dependent on their actual health. If we want to defer that display until the relevant line comes out, the Encounter object would have to mirror all the variable attributes of the opponents and then update the real objects later. That seems to me to be unsuitable, even though it would be possible.
That drove me down the path of different solutions, leading me to try the FSM approach that tanked yesterday. Today I have a different solution to try to tank.
Coroutines
Coroutines are cool. We should try never to do things that are cool. Chet Hendrickson says that if a fellow developer calls you over to their desk to look at something cool they’ve just put into the product, you should reach over their shoulder and delete it. Cool code is troubled code, all too often.
Coroutines are deep in the bag of tricks. I’d wager that many readers have never heard of them, and that very few have ever actually written them. Still, to every thing there is a season, and perhaps this is coroutine season.
You can study coroutines on the web if you want more information, but I’ll do a short description here.
Imagine that you have a program that generates a series of results. It could be as simple as something like this:
for i = 1,5 do
print(i)
end
This is fine if we want those things printed one after another. But suppose instead we want a printout like this:
Beginning with 1, we then get 2, then 3, after a while 4, finally 5.
We could fiddle the loop to fill an array, and then pick values out of the array. And there are other clever things we could do.
But let me build a little coroutine for us to look at.
function generate()
for i = 1,5 do
coroutine.yield(i)
end
end
_:test("coroutine", function()
local co = coroutine.create(generate)
tf,val = coroutine.resume(co)
_:expect(tf).is(true)
_:expect(val).is(1)
tf,val = coroutine.resume(co)
_:expect(val).is(2)
tf,val = coroutine.resume(co)
_:expect(val).is(3)
tf,val = coroutine.resume(co)
_:expect(val).is(4)
tf,val = coroutine.resume(co)
_:expect(val).is(5)
_:expect(coroutine.status(co)).is("suspended")
tf,val = coroutine.resume(co)
_:expect(tf).is(true)
_:expect(val).is(nil)
_:expect(coroutine.status(co)).is("dead")
tf,val = coroutine.resume(co)
_:expect(tf).is(false)
_:expect(val).is("cannot resume dead coroutine")
end)
The test shown runs correctly. Let’s go through the key points.
- The call
coroutine.create
makes a coroutine out of the functiongenerate
. - The ‘generate” function calls
coroutine.yield
five times, with values 1 through 5. Then it returns (nil). - The test calls
coroutine.resume(co) five times, each time receiving
tfof
true` and the next value from the coroutine. - After each of those calls, the coroutine
status
is “suspended”: it has not yet run off the end of the function. - After one excess call, the coroutine returns whatever the function does, in this case nil since it has no explicit return statement.
- At this point, the coroutine
status
is “dead”. - If we call
resume
again, we receive a return offalse
and the message “cannot resume dead coroutine”.
Now this may seem a bit odd, and the first few times you use these, it feels a bit awkward. And there are things to decide about, such as how you plan to signal that the coroutine has produced all the values it cares to. Since it ends up “suspended” after all calls including the last valid one, we have to figure out a suitable protocol. Returning some “eof” marker seems best to me, but it should also be possible to use an explicit return
instead of yield
for the last value, which would cause the coroutine.status
to be “dead”, allowing the user code to check for that.
I haven’t decided what I’ll do in the real situation. This should give enough of a general picture that we can move on to …
My Actual Plan
I propose to convert the Encounter object to use the Lua coroutine facility such that the FloatingMessageList logic can “just” call resume
repeatedly to get the messages. Using a yield
for each message should mean that the screen display and the text crawl stay synchronized.
I think that what we’ll want is for the Encounter object to return a coroutine from the attack
operation, such that we can call yield
as we want more messages. But I have a concern.
I’m already an hour and 15 minutes into this task and haven’t written a line of code into the Dung program. And I expect this change to be a bit time-consuming. So I would really like to be able to split it , to allow for doing it over two days if I need to.
I have an idea. Assuming this works at all, it should be easy to write a patch of code that converts the one-at-a-time production back to the array of messages that the game now expects. So I can TDD the new coroutine form, and if it works, I should be able to quickly plug it in, replicating current behavior, then do the incremental version tomorrow. And if it doesn’t work, I’ll revert and do it again, or something new, tomorrow.
That should be safe enough. I expect some small confusions but no big difficulties. Who knows, I could be right.
Down to It
The obvious thing would be to convert the existing TestEncounter tests to work in the coroutine format. But that’s a lot of converting, and once that’s done, nothing works. Maybe I can allow this to work both ways. Yes, let’s try that. I’ll do new tests for the EncounterCoroutine stuff.
_:test("coroutine encounter, player faster", function()
local tf,msg
randomNumbers = {3, 2, 3,2, 1}
local mtile = Tile:room(10,10, runner)
local monster = Monster(mtile,runner,Monster:getMtEntry(1))
local ptile = Tile:room(11,10, runner)
local player = Player(ptile,runner)
local encounter = Encounter(player, monster, random)
local co = encounter:getCoroutine()
tf,msg = co()
_:expect(tf).is(true)
_:expect(msg).is("Princess attacks Pink Slime!")
end)
I figure there’s a chance I can make this work. What I’m not sure about is whether I should make separate functions, not member functions, for all of the code in Encounter. The issue is that self
isn’t likely to be properly defined when running as a coroutine.
If I were wise, I’d stop this process and do an experiment in a fresh Codea. But there’s blood in the water and I want to go ahead. I’ll create free-standing functions and use them.
I’m already second-guessing myself. I feel comfortable that this will go fine once we get it rolling, but I’m not quite sure how we get going. I’m going to break free of the Encounter class entirely and grow the new thing incrementally. New test:
_:test("coroutine encounter, player faster", function()
local tf,msg
randomNumbers = {3, 2, 3,2, 1}
local mtile = Tile:room(10,10, runner)
local monster = Monster(mtile,runner,Monster:getMtEntry(1))
local ptile = Tile:room(11,10, runner)
local player = Player(ptile,runner)
local co = createEncounter(player, monster,random)
tf,msg = co()
_:expect(tf).is(true)
_:expect(msg).is("Princess attacks Pink Slime!")
end)
Now let’s just create that offhand …
Offhand was overly optimistic. We are now about 30 minutes later, but I have that test modified and working:
_:test("coroutine encounter, player faster", function()
local msg
randomNumbers = {3, 2, 3,2, 1}
local mtile = Tile:room(10,10, runner)
local monster = Monster(mtile,runner,Monster:getMtEntry(1))
local ptile = Tile:room(11,10, runner)
local player = Player(ptile,runner)
local co = createEncounter(player, monster,random)
msg = co()
_:expect(msg).is("Princess attacks Pink Slime!")
end)
-- Encounter Coroutines
-- RJ 20210111
local yield = coroutine.yield
function createEncounter(player,monster,random)
f = function()
local player = player
local monster = monster
local random = random or math.random
attack(player, monster, random)
end
return coroutine.wrap(f)
end
function attack(player,monster,random)
yield(player:name().." attacks ".. monster:name().."!")
end
This is little more than a hookup test, and I’m not even sure at this moment that it’s going to work for a second call. We’ll try that in a moment, before committing. First let me explain coroutine.wrap
.
Recall that coroutine.create
returns a thing that we call with coroutine.resume
. An alternative is coroutine.wrap
, which returns a thing we call like a function. And note that it does not return the true-false value, just the results listed in the yield.
Let’s try for a second message:
_:test("coroutine encounter, player faster", function()
local msg
randomNumbers = {3, 2, 3,2, 1}
local mtile = Tile:room(10,10, runner)
local monster = Monster(mtile,runner,Monster:getMtEntry(1))
local ptile = Tile:room(11,10, runner)
local player = Player(ptile,runner)
local co = createEncounter(player, monster,random)
msg = co()
_:expect(msg).is("Princess attacks Pink Slime!")
msg = co()
_:expect(msg).is("Princess strikes!")
end)
function attack(player,monster,random)
yield(player:name().." attacks ".. monster:name().."!")
yield(player:name().." strikes!")
end
I do expect and hope that this works. And it does.
Commit: first commit coroutineEncounter tests and code.
I have a raft of tests commented out and ignored here, so that I can focus on this one. Codea’s not rigged to be helpful with that kind of focus, and today’s not the day that I want to dig into improving the framework.
Now I think I should be able to replicate the roll logic for the first strike, as shown in the original Encounter:
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
I think I’ll just do this in the coroutine version:
_:test("coroutine encounter, player faster", function()
local msg
randomNumbers = {3, 2, 3,2, 1}
local mtile = Tile:room(10,10, runner)
local monster = Monster(mtile,runner,Monster:getMtEntry(1))
local ptile = Tile:room(11,10, runner)
local player = Player(ptile,runner)
local co = createEncounter(player, monster,random)
msg = co()
_:expect(msg).is("Princess attacks Pink Slime!")
msg = co()
_:expect(msg).is("Princess is faster!")
msg = co()
_:expect(msg).is("Princess strikes!")
end)
This works, using this code:
function attack(attacker, defender,random)
yield(attacker:name().." attacks ".. defender:name().."!")
local attackerSpeed = rollRandom(attacker:speed())
local defenderSpeed = rollRandom(defender:speed())
if attackerSpeed >= defenderSpeed then
yield(attacker:name().." is faster!")
yield(attacker:name().." strikes!")
else
yield(defender:name().." is faster!")
yield(defender:name().." strikes!")
end
end
function rollRandom(aNumber)
return random(0,aNumber)
end
What’s interesting about this to me is that rollRandom
is correctly calling the random function provided clear up in our initialization:
function createEncounter(player,monster,random)
f = function()
local player = player
local monster = monster
local random = random or math.random
attack(player, monster, random)
end
return coroutine.wrap(f)
end
The local random
is not in the lexical scope of the function rollRandom
, but it is in the execution scope of the call stack. It is what Codea (and doubtless other systems) calls an “upvalue”, a value up the call chain.
I think I’ll treat this as a mysterious thing that makes things work the way we want.
Now let’s refactor toward the original object design, which looks like this:
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
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
Note that instead of putting the “strikes” message in the if statements, I put it inside firstAttack
, which is called with its arguments in the order faster, slower. I’m not sure why I didn’t push the “is faster” message down there. For now, we’l replicate it as it stands:
2: coroutine encounter, player faster -- OK
The new code is this:
function attack(attacker, defender,random)
yield(attacker:name().." attacks ".. defender:name().."!")
local attackerSpeed = rollRandom(attacker:speed())
local defenderSpeed = rollRandom(defender:speed())
if attackerSpeed >= defenderSpeed then
yield(attacker:name().." is faster!")
firstAttack(attacker,defender)
else
yield(defender:name().." is faster!")
firstAttack(defender,attacker)
end
end
function firstAttack(attacker,defender)
yield(attacker:name().." strikes!")
end
Let’s do a better job of factoring this time:
function attack(attacker, defender,random)
yield(attacker:name().." attacks ".. defender:name().."!")
local attackerSpeed = rollRandom(attacker:speed())
local defenderSpeed = rollRandom(defender:speed())
if attackerSpeed >= defenderSpeed then
firstAttack(attacker,defender)
else
firstAttack(defender,attacker)
end
end
function firstAttack(attacker,defender)
yield(attacker:name().." is faster!")
yield(attacker:name().." strikes!")
end
I expect the test to run. And it does.
It’s 1246, long past my usual stopping time. Let me put the tests back in place and commit this baby.
One of the tests for Encounter is failing: I think it’s because the battles now can loop and the test doesn’t expect that. Be that as it may, I plan to remove those tests and the object, so I’ll mark it ignore, sure that it’ll be resolved by removing the test.
Commit: Encounter coroutine now handles first three messages.
Summing Up
OK. I’m pleased with how this is going. It took a little time to get hooked up right, and I suspect the hookup can be made a bit more clean, but the coroutine version of Encounter is coming along well, and I expect we’ll complete it tomorrow. Meanwhile the game continues to work as it was yesterday, so we can ship it with the new code in there if we choose to.
I’m interested in hearing from those who are familiar with coroutines, and those who are not, regarding whether what’s going on here is clear enough, and asking questions for clarification. I’ll respond to those in the articles subsequent to receiving them.
To me, once it is hooked up, the coroutine is easy to write … just go along saying yield
when you have a result, and calling the function when you want a result. Kind of nifty really.
See you next time!