Dungeon 87
Now for some magic. How can we get screen effects synchronized with the information crawl? You won’t believe this cunning plan!
Of course, when it comes to my plans, one would be well-advised not to believe them, but in this case I think the plan will work. Here’s the issue:
During an encounter, the game “AI” will be choreographing the action, rolling random numbers, computing whether A hits D, computing how much damage they do, accepting player input on action to be taken, working out what happens because of that, and so on and so on.
The encounter writes information to the Floater, which is scrolling it onto the screen at nominal reading speed. This means that the game has computed a whole series of messages, and they’ll show up on screen in due time. But we also want to display certain things as if they happen when the descriptive line begins to show.
For example, if several lines in, the monster hits the princess, we want her to make that cute little EEK sound that signifies that she’s hit, and we want to flash her picture briefly yellow or red depending on her health level. And, should she unfortunately meet her demise, we want to turn her picture a respectful black.
Presently the encounter triggers those actions when it gets to that part of its decision process, so right when the encounter starts we may hear sounds and see things flash and change color, and only after many seconds does the line come out saying “Serpent is dead!”
We don’t like this. (Trust me, we don’t.) What we want instead is for those visible actions to take place when the relevant description line appears on screen. The big question is: How?
Here’s How We’re Gonna Do That
The Floater reads lines from its input array when it has room for another line in the crawl. If there is no line available, it scrolls a blank line. (Yes, it is scrolling blank lines all the time during the game. Yes, we could probably fix that, but it doesn’t seem worthwhile to me, at least not just now.)
What if there was a command in the input array? What if the crawl, when it goes to look for a text line, could detect that what it had fetched was a command, and instead of displaying it, it executed the command. then if the command somehow said “turn serpent red”, and the next line said “Princess whacks serpent”, the effect would be just what we want, the visual or audible effect would synch up with the text.
So let’s do that.
What should a command be? Well, it seems to me that since we pretty much have objects everywhere up in this thing, the command should amount to sending a message to an object. What does that entail? Here’s what:
- A receiver object: the object to which the message is to be sent;
- A method name: the method to be sent;
- Arguments: zero or more arguments to be passed to the method (along with the receiver as “self”).
My plan is to start with the command being just a table of receiver, method name, arg1, arg2, etc. Perhaps we’ll create a object to be a command, but we’ll start with the table.
One more observation, then we’ll code.
Since lines are fetched at a rate of maybe one per second, we’ll want to put the commands relating to a given line ahead of that line in the table. And since there can be more than one command, we’ll need to loop until we have consumed all the commands, and then return a text line, if there is one. In principle, there might not be one, though in practice we’ll generally provide one. If there isn’t one, the current code returns a default string, and we’ll preserve that behavior.
Let’s do it.
Commands
The relevant code is this:
function Floater:fetchMessage()
local msg = self.provider:getItem()
table.insert(self.buffer, msg)
end
function Provider:getItem()
if #self.items < 1 then return self.default end
return table.remove(self.items, 1)
end
All we need to do is just cause getItem
to execute all the commands that occur before whatever it finally returns.
Let’s do this:
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
return item
end
I expect this to work just like before. And it does. Now let’s see if we can check the type of the thing.
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if type(item) == "string" then
return item
end
end
This continues to work. You may be wondering why I did that in two steps. The reason is that I didn’t. I initially typed
if type(item) == string then return item end
Without the quotes. It didn’t work. It confused me. I went for something simpler. Finally the light dawned.
Moving right along, let’s add an else clause. In fact I went further and did this:
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if type(item) == "string" then
return item
else
self:execute(item)
end
return self:getItem()
end
If it’s not a string, we’ll execute it. Then we’ll return whatever comes back from a recursive call to getItem
. I’m putting the final call at the very end, because Lua can do “tail calls” without actually recurring, and I want to be sure that happens.
Now let’s write something for execute
:
function Provider:execute(command)
print("command executed")
end
Now let’s put a command into the initial crawl. Anything not a string will do, but I’ll try to make it have the right format. Here’s the initial crawl array:
function GameRunner:initialCrawl()
return { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
"Some of you may die, but I am willing",
"to make that sacrifice."}
end
We’ll just pop a little table in there:
function GameRunner:initialCrawl()
return { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
{self, "gotHere" },
"Some of you may die, but I am willing",
"to make that sacrifice."}
end
function GameRunner:gotHere()
print("GOT HERE")
end
Now you know what? I’m gonna just go for it. Revising execute:
function Provider:execute(command)
local receiver = command[1]
local method = command[2]
receiver[method](receiver)
end
Recall that we can’t just say receiver:method()
because that will try to run the method named “method”. So we have to do this roundabout thing. But I think it’s going to work.
Let’s see why I’m mistaken. Ha! Even a blind big, eh? In the console, I see:
GOT HERE
Take that, disbelievers!
Now let’s do parameters. I’ll extend gotHere
to expect an integer and a string.
function GameRunner:gotHere(anInteger, aString)
print("GOT HERE", anInteger+1, "foo:"..aString)
end
Now to pass in some stuff in the call:
function GameRunner:initialCrawl()
return { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
{self, "gotHere", 10, "bar" },
"Some of you may die, but I am willing",
"to make that sacrifice."}
end
And to make it work:
function Provider:execute(command)
local receiver = command[1]
local method = command[2]
local arg1 = command[3]
local arg2 = command[4]
receiver[method](receiver, arg1, arg2)
end
And the result is:
GOT HERE 11 foo:bar
As expected. Yay, me. Perhaps I could have TDD’d that, but I don’t quite see how, and this went just about as quickly. Now let’s put this new feature into actual use.
In our current Encounter function (not yet a method, and since we expect to replace it with new Combat objects, we’ll let it stay a function, we do perform actions on the objects at one point, that of damage:
function attackStrikes(attacker,defender, random)
local damage = rollRandom(attacker:strength(), random)
if damage == 0 then
yield("Weak attack! ".. defender:name().." takes no damage!")
if math.random() > 0.5 then
yield("Riposte!!")
firstAttack(defender,attacker, random)
end
else
defender:displayDamage(true)
yield(attacker:name().." does "..damage.." damage!")
defender:displayDamage(false)
defender:damageFrom(attacker.tile, damage)
if defender:isDead() then
yield(defender:name().." is dead!")
end
end
end
Right away we see an opportunity, and a problem. the opportunity is to treat those two calls to defender
as commands. The problem is, if those commands are deferred, we won’t know whether the defender is dead or not, so we can’t, at this time, yield that final message.
Let’s do the two we can do and then see what happens. I’m pretty sure that the defender will die, we just won’t get a letter about it.
I’m gonna try this:
else
defender:displayDamage(true)
yield(attacker:name().." does "..damage.." damage!")
--defender:displayDamage(false)
yield({defender, "displayDamage", false})
--defender:damageFrom(attacker.tile, damage)
yield({defender, "damageFrom", attacker.tile, damage})
if defender:isDead() then
yield(defender:name().." is dead!")
end
end
Then I’ll go get in a fight and try to see what happens.
Well, in this first attempt, I see the defender turn color right away. I see the reason: there’s another call up at the top that I missed. Way to go, Ron.
Ah, I see what’s going on here. displayDamage(true)
turns the defender yellow or red, and false
turns it back to the right color. That causes the flash while we said for the “does damage” line to scroll up. This should work, I think.
OK, that works as expected. What can we do about getting that “dead” message to come out?
We could do the damageFrom
operation live, but if we do, then the attribute sheet will adjust instantly. As things are now, it will adjust slowly, as the battle goes on.
In further testing, I see another issue, which is that the princess can go on fighting even when she’s dead. This is brave of her and all, but not correct. I don’t think this is a result of our current work, however.
Meanwhile, we have some deciding to do. I’m absolutely certain that I’m going to replace this current Encounter function nest with something new for combat. When that happens, whatever fixes I put in to report death may need to be changed. So we could stop here and carry on doing real combat next time. On the other hand, we hate to release the game working worse than it did before.
I want to fix it. Point of personal pride or something. Here’s my plan. Move the “X is dead!” message into the moribund entity. We can add a line to the crawl from anywhere. So let’s try that.
else
--defender:displayDamage(true)
yield({defender, "displayDamage", true})
yield(attacker:name().." does "..damage.." damage!")
--defender:displayDamage(false)
yield({defender, "displayDamage", false})
--defender:damageFrom(attacker.tile, damage)
yield({defender, "damageFrom", attacker.tile, damage})
--[[
if defender:isDead() then
yield(defender:name().." is dead!")
end
--]]
end
That can’t happen, so removing it is harmless. The result must come from damageFrom
.
function Entity: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
Let’s add this:
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
if self.healthPoints <= 0 then
self.runner:addToCrawl({self:name().." is dead!"})
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
This does the job nearly well. Now when things die, we get the death message in the crawl. However, we can see in the movie above, it takes a while before the Encounter figures out that the battle is over, since it never sees that the creature is dead (because it isn’t, in that timeline). We’ll have to do better, but this is good enough for now.
Commit: Crawl Provider has execute command. Crawl messages and screen actions are synchronized.
And we’re done. It’s time for Sunday brekkers and tv with my dear wife. Let’s sum up.
Summary
The tricky part of all this was having the idea of the crawl provider actually taking actions, not just providing lines to be displayed. The implementation was pretty straightforward, put a table in the provider array including the message receiver the message name, and any arguments, then detect that’s what we have and execute the command.
The implementation does have that one quirk:
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if type(item) == "string" then
return item
else
self:execute(item)
end
return self:getItem()
end
If we have no items, we return the default. If we have an item, we remove it so that we only process it once. If it is a string, we return it.
If it is not a string, we execute it and then call ourselves recursively to return the next item.
This could be coded as a loop, but it would be a rather messy one. It’s even a bit messy now, in my view. But one way or another, when we find a command, we need to keep going until we return a string, because the user of getItem
expects us to come back with a string, even if it’s just the default. That keeps the crawl pump running.
The execute
looks like this:
function Provider:execute(command)
local receiver = command[1]
local method = command[2]
local arg1 = command[3]
local arg2 = command[4]
receiver[method](receiver, arg1, arg2)
end
That could be folded up all nice and tight into a single line, but I prefer it longhand like this for clarity. I’d like to make it deal with an arbitrary number of arguments, but I’m not quite sure how to do that and will have to experiment a bit. In any case, this works well enough and could be extended manually if need be.
So in my summary view, the array-driven crawl provider idea has shown itself to be a good replacement for the previous coroutine-based one, and it manages to interweave actions and display lines in a way that seems natural in game play.
I’m calling this a success.
See you next time!