Dungeon 166
More about the crawl. But let’s see if we can get the Mimic animated the way we’d like. Unfortunately, these two ideas are not unrelated.
Ubergoober, from the Codea forum, wrote to me about the crawl, and my “kill your darlings” comment, noting that the crawl is pretty neat. And it is. I’ll not quote him without permission, but here’s my reply to his kind note:
Good thoughts. And yes, my articles are about good code. The Crawl is neat, and I like it. I am somewhat proud of the way it melds the slow incremental exposure of the crawl lines with the instantaneous nature of things that happen in the dungeon.
However, I’m not sure that I’d call it good code. It is convoluted and very difficult to think about when you work on it. You have to keep thinking about “what will have happened” by the time the crawl reports it.
So it could be better, from a code viewpoint, and whether it would still be “as good” if the code were made more straightforward, I honestly don’t know. If this were a real product, and we were facing this code arrangement with its difficulties, we would need to borrow time somehow even to experiment with alternatives, because from the outside, it’s a nice feature. Since this is not a real product, we can, if we choose, take the time to do the experiments because we’re here to learn and to see what happens.
I can imagine that a few hours of experimenting might make a crawl that is just as “good”, or nearly so, but much easier to work with. I can imagine that we would discover that the best we can do with better code isn’t as good. I can even imagine that we might decide to go back to the combat coroutine, which worked nicely and wasn’t so difficult to reason about.
The nice thing about this exercise is that we don’t have to stop with imagination: we can find out what would happen “if” we did something, by trying it in all the ways we want to know about.
I don’t know what I’ll do. I would like to kill that darling code, without killing the crawl. I don’t know how possible that is … yet.
What’s All the Fuss About?
Thanks for asking!
The cool thing about the crawl is the way the floating text scrolls up slowly, with new lines appearing when there is room for them. It’s a nice effect, and I’m pleased with how it looks.
The problem is that when the program has some reason to add a line to the crawl, like “WATCH OUT, THERE’S A GIANT TOAD!”, that line doesn’t come right out at the instant the toad hops in, it comes out at some undetermined future time when the crawl gets around to it.
The problem is worst during combat. Combat is made up of “Combat Rounds”, which represent one attack during combat. One player tries an attack, which either hits or is evaded. If the hit succeeds, damage is rolled and accumulated for future application. The round ends, and the results are dumped to the crawl.
For this to look right, the effects of the attack need to come out when the commentary comes out, which can be long after the combat has actually occurred, in computer terms. To make this work, at least two weird things happen:
First, commands are embedded in the crawl’s input stream, and the Provider, which feeds the crawl, executes those commands when it encounters them:
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if item.op == "display" then
return item.text
elseif item.op == "extern" then
self:execute(item)
elseif item.op == "op" then
self:addItems(self:execute(item))
else
assert(false, "unexpected item in Provider array "..(item.op or "no op"))
end
return self:getItem()
end
This is just a tiny interpreter that displays text when given a “display” command, and executes other commands as appropriate. Looking at the code now, I think the “op” option is unused. Its purpose is to execute a command that will return one more more lines to be appended to the crawl. I don’t recall what it was actually used for, and my site appears to be down, so I can’t look. Anyway, doesn’t matter.
Provider:getItem is called from the Floater when it feels ready for a new line:
We could “readily” create a new Floater that immediately pulls everything from the Provider and displays it. Instead of scrolling up blanks, which is what is happening now, the new Floater would just statically display all the lines available, perhaps fading or deleting from the top on some kind of time cycle.
From a visual viewpoint, this might be almost as good as the scrolling one, and it would be timely: when something happens, it would appear immediately. That, in turn, would let combat proceed in a more synchronous fashion, without all the fancy stuff about predicting whether the attacker and defender “will be alive” by the time the combat shows up on the screen.
After all this thinking, my view is this: The floater/provider/combat round objects usually don’t bother our development, and any new kind of crawl sounds not quite as good as the current one. So let’s let it be.
We do have anomalies sometimes. If you move the princess rapidly, messages can appear later than you’d expect. This all started because it is possible for Spike messages not to appear until you have stepped over the spikes by a few tiles.
I’m calling this. Let it be, at least for now. It works, it’s good enough, and there are more important things to do.
Like …
Mimic Attack
We have introduced this new kind of monster with much more sophisticated animations, including attack, hide, dead, hurt, walk, and idle.
We’d like to have those animations triggered at the right moments:
- Hide triggers when the monster wakes up (and might trigger in reverse if it ever goes back to sleep).
- Walk triggers whenever the monster goes to a new square;
- Attack triggers right when the monster attacks the princess;
- Hurt triggers when the princess does damage to the monster;
- Dead triggers when the monster is knocked down.
- Idle runs most of the time when the monster is not hidden, subject to the above triggers.
Some of these are in place, but others are not.
In the movie above we see the mimic doing “hide” (which is really “unhide”). Then if the princess moves away soon enough, it goes into “idle”. If the princess gets too close, it starts to chase her, running “walk”. It does not appear to go back to “idle” after a step. When it attacks, it doesn’t run the “attack” animation. When it goes down, it correctly runs “dead”.
Maybe we’ll start by going to idle between walk moves. Here’s the strategy now:
function MimicMonsterStrategy:selectMove(range)
if not self.monster.awake then
self.monster:setFirstFrameAnimation("hide")
return "basicDoNotMove"
end
if self.moveCount == 3 then
self.monster:setOneShotAnimation("hide")
end
self.moveCount = self.moveCount - 1
if self.moveCount <= 0 then
if range > 2 then
self.monster:setAnimation("idle")
return "basicDoNotMove"
else
self.monster:setAnimation("moving")
return "basicMoveTowardPlayer"
end
else
return "basicDoNotMove"
end
end
This code executes for every move decision the monster makes. The moveCount
countdown is there to give the player time to run away if she wants to.
When we decide to move, what we would like to have happen is for the “moving” animation to run once, and then the “idle” should come back.
We don’t have the ability to do that, yet. Our animation calls now are setAnimation
and setOneShotAnimation
. That works well for dying, because it runs the death animation and holds on the last frame. But for waking up, and walking, we really want “set oneshot anmation X and then set animation Y”.
Here’s monster:
function Monster:setAnimation(animationName)
self.animator:cycle(animationName)
end
function Monster:setDeathAnimation()
self:setOneShotAnimation("dead")
end
function Monster:setOneShotAnimation(name)
self.animator:oneShot(name)
end
function Monster:setFirstFrameAnimation(name)
self.animator:firstFrame(name)
end
And in Animator:
function Animator:cycle(name)
self.animation = Animation:cycle(self:getAnimation(name))
end
function Animator:defaultAnimation()
return self.moving
end
function Animator:draw()
self.animation:draw()
end
function Animator:firstFrame(name)
self.animation = Animation:firstFrame(self:getAnimation(name))
end
function Animator:getAnimation(name)
return self.animations[name] or self:defaultAnimation()
end
function Animator:oneShot(name)
self.animation = Animation:oneShot(self:getAnimation(name))
end
And in Animation:
function Animation:oneShot(frames)
return Animation(frames,self.oneShotStep)
end
function Animation:cycle(frames)
if frames == nil then error("frames") end
return Animation(frames, self.cycleStep)
end
function Animation:firstFrame(frames)
return Animation(frames, self.firstFrameStep)
end
function Animation:init(frames, iterationMethod)
self.frames = frames
self.index = 1
self.iterate = iterationMethod or self.cycleStep
end
We do have tests for Animator and Animation, so let’s stick with the trend and TDD up a new Animator creator oneShotAndThen(frames1, frames2)
.
Here’s what I think I want to have happen:
_:test("Animator oneShotAndThen", function()
local frames1 = { "f1", "f2" }
local frames2 = { "g1", "g2", "g3" }
local animation = Animator:oneShotAndThen(frames1, frames2)
_:expect(animation:frame()).is("f1")
animation:step()
_:expect(animation:frame()).is("f2")
animation:step()
_:expect(animation:frame()).is("g1")
animation:step()
_:expect(animation:frame()).is("g2")
animation:step()
_:expect(animation:frame()).is("g3")
animation:step()
_:expect(animation:frame()).is("g1")
end)
We’ll go through the frames1 one time, then cycle in frames2. Now all we have to do is make it work.
I basically just typed this in:
function Animation:oneShotAndThen(frames1, frames2)
return Animation(frames1, self.oneShotAndThenStep, frames2)
end
function Animation:init(frames, iterationMethod, nextFrames)
self.frames = frames
self.nextFrames = nextFrames
self.index = 1
self.iterate = iterationMethod or self.cycleStep
end
function Animation:oneShotAndThenStep()
self.index = self.index + 1
if self.index > #self.frames then
self.frames = self.nextFrames
self.index = 1
self.iterate = self.cycleStep
end
end
The test runs. Now I need the method in Animator. I’ll just type that in without a test:
function Animator:oneShotAndThen(name1, name2)
local a1 = self:getAnimation(name1)
local a2 = self:getAnimation(name2)
self.animation = Animation:oneShotAndThen(a1, a2)
end
And in Monster:
function Monster:setOneShotAndThenAnimation(name1, name2)
self.animator:oneShotAndThen(name1,name2)
end
Now let’s use this in the strategy, first for unhiding. I think this ought to do it:
function MimicMonsterStrategy:selectMove(range)
if not self.monster.awake then
self.monster:setFirstFrameAnimation("hide")
return "basicDoNotMove"
end
if self.moveCount == 3 then
-- v change here
self.monster:setOneShotAndThenAnimation("hide", "idle")
end
self.moveCount = self.moveCount - 1
if self.moveCount <= 0 then
if range > 2 then
self.monster:setAnimation("idle")
return "basicDoNotMove"
else
self.monster:setAnimation("moving")
return "basicMoveTowardPlayer"
end
else
return "basicDoNotMove"
end
end
In the movie above, the mimic wakes up and immediately goes to idle, as intended. He runs the moving animation throughout the encounter. Now we want to run the attack one when he attacks.
That’s going to take us into the swamp of CombatRound. But it should be almost straightforward if we don’t panic.
function CombatRound:attemptHit()
local msg
if self.attacker:attackerSpeedAdvantage() + 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
We want to trigger the attack animation just before that message comes out. We do that by filing a command before appending the message. Copying from here:
function CombatRound:applyDamage(damage, kind)
self.defender:accumulateDamage(damage, kind)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=damage, arg2=kind }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage..self:wordFor(kind).." damage!"))
if self.defender:willBeDead() then
local op = OP("extern", self.defender, "youAreDead", self)
self:append(op)
local msg = self.defender:name().." is down!"
self:append(self:display(msg))
self.defender:playerCallback(self, "playerIsDead")
end
end
I try this:
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())
local op = OP("extern", self.defender, "setAttackAnimation", nil)
self:append(self:display(msg))
self:attemptHit()
end
self:publish()
return self:getCommandList() -- only for testing
end
And this:
function Monster:setAttackAnimation()
self:setOneShotAndThenAnimation("attack","idle")
end
I think this might be nearly good. Well, it would help if I actually enqueued the command:
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())
local op = OP("extern", self.defender, "setAttackAnimation", nil)
self:append(op)
self:append(self:display(msg))
self:attemptHit()
end
self:publish()
return self:getCommandList() -- only for testing
end
That almost works, but the Provider crashes.
Provider:28: attempt to call a nil value (field '?')
stack traceback:
Provider:28: in method 'execute'
Provider:38: in method 'getItem'
Floater:53: in method 'fetchMessage'
Floater:64: in method 'increment'
Floater:48: in method 'draw'
GameRunner:234: in method 'drawMessages'
GameRunner:180: in method 'draw'
Main:34: in function 'draw'
function Provider:execute(command)
local receiver = command.receiver
local method = command.method
local arg1 = command.arg1
local arg2 = command.arg2
local t = receiver[method](receiver, arg1, arg2)
return t
end
Oh … I bet that’s in the princess. We’d better give her that method:
Right. We already had a null death one:
function Player:setAttackAnimation()
end
function Player:setDeathAnimation()
end
This looks pretty much as advertised. There might be an extra bite there before he goes down, but it looks good in any case.
I think we’ll ship this. Not quite yet, tho, we’ve broken some CombatOperation tests.
1: First Attack -- Actual: extern, Expected: display
1: First Attack -- Actual: nil, Expected: Princess attacks Spider!
6: monster attack -- Actual: nil, Expected: Spider attacks Princess!
These will be off because of the extra item in the output.
_:test("First Attack", function()
local result
local i,r
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
local co = CombatRound(player, monster)
result = co:attack()
i,r = next(result,i) -- skip blank line
i,r = next(result,i) -- skip attack animation
i,r = next(result,i)
_:expect(r.op).is("display")
_:expect(r.text).is("Princess attacks Spider!")
end)
I added the “skip attack animation” line. First test runs.
_:test("monster attack", function()
local result
local i,r
local defender = FakeEntity("Princess")
local attacker = FakeEntity("Spider")
local co = CombatRound(attacker, defender)
result = co:attack()
i,r = next(result,i) -- skip blank line
i,r = next(result,i) -- skip attack animation line
i,r = next(result,i)
_:expect(r.text).is("Spider attacks Princess!")
end)
Same change here. Tests run. Commit: mimic goes to idle after unhiding, uses attack animation when attacking.
The commit touches six files, GameRunner, AnimatorAnimation, MonsterStrategy, CombatRound, Monster, and Player. GameRunner doesn’t count, I changed a number in it that was left over from yesterday.
The other five make sense. A bit more spread out than we might like, but not incredible. A total of 58 lines added, 6 removed. Not bad, and a nice feature. Let’s sum up.
Summary
I think the biggest lesson here is that even when the code does lead me into the floater/provider/combat nest, it’s not necessarily difficult. This leaves me more comfortable leaving the crawl logic alone.
There’s a bit of passing around of messages, from combat to monster to animator to animation, but that’s the nature of the objects. We hold on to the ones we care about and tell them what we want done, and they do it by calling the objects they hold on to, repeat until someone does something.
I’m kind of pleased with this setup. I am quite tempted to buy some more of the sprites made by whoever made the Mimic, and thus have more monsters that show a bit more activity than the current rather simple ones.
It’s not exactly programming progress, but it makes the game more interesting and will show us that the current setup allow more monsters to be plugged in by nothing more than attaching a table entry. That’s kind of what you’d want, so that your art department could be beavering away making monsters.
A good morning, especially for a Saturday.
See you next time!