I think today I’d like to get started on Poison and Antidotes, and on eliminating death from the dungeon. Is that what I’ll do today? Maybe. One never knows, do one?

Yesterday we convinced ourselves that if we had a dungeon design team, we could provide for them to design dungeons with little difficulty. So the priority of that concern, which spiked yesterday, has dropped. Our attention turns to more incremental improvements in game play.

Today’s priorities are to eliminate the death of the player, and to provide for poison and antidotes. Poison will eat away at health and maybe other attributes, and an antidote will stop that from happening. An antidote will be an inventory item, so that if you find one, you can use it at will. As for death, the rough plan is that when the princess is defeated, she’ll be knocked unconscious and find herself transported back to the first room on the current level. We may want to rob her of some of her inventory, but that will be a separate story.

Let’s begin with not dying, always a good plan for any given day.

Everyone lives a unique life, but in our game, dies the same:

function Entity:damageFrom(aTile,amount)
    if not self:isAlive() then return end
    self.characterSheet:applyAccumulatedDamage(amount)
    if self.characterSheet:isDead() 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

Let’s change the message to “is down”. For now, when a monster goes down, it doesn’t get up, but we may change that as well at some point. Now let’s check to see what die does:

function Entity:die()
    self.characterSheet:die()
end

function CharacterSheet:die()
    self._health:die()
end

function CharacterAttribute:die()
    self._value = 0
    self._accumulatedDamage = 0
end

In short, nothing happens except that the health value is set to zero and accumulated damage is cleared.

Interesting. Remember how I often remark that in good OO code, you can follow the path of some presumably complicated idea down to the bottom and find nothing, or a constant? That’s what’s happening here.

We do have a bit more, though.

function Entity:isDead()
    return self.characterSheet:isDead()
end

function CharacterSheet:isDead()
    return self._health:isDead()
end

function CharacterAttribute:isDead()
    return self._value == 0
end

Perfect. Now let’s see who might care if she’s dead, senders of isDead or isAlive. (This is the moment where you slightly regret having both but it makes code more readable and one rarely has to do these searches.)

function Player:drawExplicit(tiny)
    local sx,sy
    local dx = 0
    local dy = 30
    pushMatrix()
    pushStyle()
    spriteMode(CENTER)
    local center = self:graphicCenter() + vec2(dx,dy)
    translate(center.x,center.y)
    if self:isDead() then tint(0)    end
    sx,sy = self:setForMap(tiny)
    self:drawSprite(sx,sy)
    popStyle()
    popMatrix()
    if not tiny then
        self.attributeSheet:draw()
    end
end

Right. When she’s dead, we tint her black. Nothing else changes, and if you’ve played the game you know that in a second or two, her health starts coming back and she already comes back to life.

We “just” need to move her to her home tile in Room 1. Now that I’ve put it that way, I’m tempted to have her know that tile, but let’s see how we place her now.

function GameRunner:placePlayerInRoom1()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    self.player = Player:cloneFrom(self.player, tile,self)
end

This is done at the start of a level, and we clone the player to put her in room one. We used the cloning procedure to be sure that any cruft from one level was lost, and to avoid a long process of creating the player. Be that as it may, if we were to call that method right in the middle of dying, what would happen?

Let’s paste that in. First, I want to commit that small message change. Commit: change “is dead” to “is down”.

OK. Now when should we make this decision? We could to it all in the middle of things, but we have a mechanism for just this sort of thing. After we file the message “is down”, we can put a command into the Floater crawl that will move us to room one when the message scrolls up.

We could go into the CombatRound and do this, but it mostly concerns itself with the future, willBeDead and the like. Still that’s an option for the end of the round. Other possibilities include:

We could send a message to the entity saying “you are dying”. Monsters might ignore that message and just lie there, but the player could put a message in the crawl.

We could check in GameRunner, at the end of a player move, to see whether the player is dead, and if so, move her (or a clone) to room 1. If we do that, we should be sure to bump her back to life or we’d move her repeatedly. That could be bad.

My attention goes back to this code:

function Entity:damageFrom(aTile,amount)
    if not self:isAlive() then return end
    self.characterSheet:applyAccumulatedDamage(amount)
    if self.characterSheet:isDead() then
        self.runner:addTextToCrawl(self:name().." is down!")
        sound(self.deathSound, 1, self.pitch)
        self:die()
    else
        sound(self.hurtSound, 1, self.pitch)
    end
end

One odd thing here is that the sounds are signaled immediately when the damage is accumulated, but the crawl is still running. We probably have a–dare I call it a defect–where the death sound comes out long before the message about it. We should be adding those sounds to the crawl. That’s not for now, but it does suggest that we should be dealing with the combat round here, not just the tile and amount.

For now, we have in CombatRound, this function:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
end

That’s called from rollDamage:

function CombatRound:rollDamage()
    local damage = self.random(1,6)
    self:applyDamage(damage)
end

And that’s called here:

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 should be able to check after the call to rollDamage to see whether the defender will be dead and if so, we can add a command to the queue to move them to room one if they’re the princess.

Yes, this is a bit weird. We’ll talk about that. Right now, I’m juggling ideas. Let’s try this:

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()
        if self.defender:willBeDead() then
            local op = OP("extern", self.defender, "youAreDead")
            self:append(op)
        end
    else
        msg = string.format("%s evades attack!", self.defender:name())
        self:append(self:display(msg))
    end
end

I think I’ll just go play until I get killed and see whether I get a crash. I admit that’s weird, but I have too many balls in the air and want to settle some of them.

The good news is, I do get the error. The bad news is that while I was looking for a monster to attack me, I ran over the spikes. The spikes can kill if your health is low enough … ah, good news: the Spikes actually create a CombatRound. That should mean I get the same crash when the spikes take me out …. but no. They just do this:

function Spikes:actionWith(player)
    local co = CombatRound(self,player)
    co:appendText("Spikes ".. self.verbs[self.state]..player:name().."!")
    local damage = math.random(self:damageLo(), self:damageHi())
    co:applyDamage(damage)
    self.tile.runner:addToCrawl(co:getCommandList())
end

So if we are to do our thing, we need to do it in apply damage. Makes some sense.

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
    if self.defender:willBeDead() then
        local op = OP("extern", self.defender, "youAreDead")
        self:append(op)
    end
end

Now I’m going to implement that method for monsters, who’ll ignore it, and player, who will not:

function Monster:youAreDead()
end

function Player:youAreDead()
    self.runner:placePlayerInRoom1()
end

Let’s see what that does. What it does is work perfectly, subject to there being no message about what happened. You get the “Princess is down!” message and poof you’re back in room one,

Let’s add a message:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
    if self.defender:willBeDead() then
        local op = OP("extern", self.defender, "youAreDead")
        self:append(op)
        self:append(self:display("You find yourself in a strangely familiar room ..."))
    end
end

This doesn’t quite work, because the “is down” message comes out after this one, because it only shows up when damage is finally applied, here:

function Entity:damageFrom(aTile,amount)
    if not self:isAlive() then return end
    self.characterSheet:applyAccumulatedDamage(amount)
    if self.characterSheet:isDead() then
        self.runner:addTextToCrawl(self:name().." is down!")
        sound(self.deathSound, 1, self.pitch)
        self:die()
    else
        sound(self.hurtSound, 1, self.pitch)
    end
end

I’m going to move that message into the CombatRound, and worry about the sounds as a separate issue,

Oh, and I can’t really put that strangely familiar message here unless we’re dealing with the player.

Make it work, then make it right.

In testing, I have found a tile onto which I cannot move. That means there is something invisible on it. Defect! Again, not on our plate at this moment.

I settle on this:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
    if self.defender:willBeDead() then
        local op = OP("extern", self.defender, "youAreDead")
        self:append(op)
        local msg = self.defender:name().." is down!"
        self:append(self:display("   ")
        self:append(self:display(msg))
        if self.defender:name() == "Princess" then
            self:append(self:display("You find yourself in a strangely familiar room ..."))
        end
    end
end

We’d like to get rid of that if, and we’ll do so shortly. Let’s first commit: when princess is down she teleports back to room 1.

Let’s Kill That If

We don’t love this if statement:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
    if self.defender:willBeDead() then
        local op = OP("extern", self.defender, "youAreDead")
        self:append(op)
        local msg = self.defender:name().." is down!"
        self:append(self:display("   ")
        self:append(self:display(msg))
        if self.defender:name() == "Princess" then
            self:append(self:display("You find yourself in a strangely familiar room ..."))
        end
    end
end

We have implemented youAreDead this way:

function Player:youAreDead()
    self.runner:placePlayerInRoom1()
end

We could pass the combat round into this message and it could call us back to issue the other messages. We do want the “is down” unconditionally of course.

function Player:youAreDead(combatRound)
    combatRound:playerIsDead()
    self.runner:placePlayerInRoom1()
end

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." 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))
    end
end

function CombatRound:playerIsDead()
    self:append(self:display("   "))
    self:append(self:display("You find yourself in a strangely familiar room ..."))
end

I expect this to work just fine.

I am mistaken. Nothing comes out. The reason is that the Combat Round returns a list of commands, and our new ones aren’t in there. We’ll need to do the messaging immediately, but only if the defender is player. Irritating.

OK, first I go to this:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." 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))
        if self.defender:name() == "Princess" then
            self:playerIsDead()
        end
    end
end

function CombatRound:playerIsDead()
    self:append(self:display("   "))
    self:append(self:display("You find yourself in a strangely familiar room ..."))
end

That works as intended again. It amounts to having extracted the two appends. But now what I really want is a method to call me back if you’re the player:

function CombatRound:applyDamage(damage)
    self.defender:accumulateDamage(damage)
    local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
    self:append(op)
    self:append(self:display(self.defender:name().." takes "..damage.." 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

This demands a new method on both Player and Monster:

function Player:playerCallback(receiver, method)
    return receiver[method](receiver)
end

function Monster:playerCallback()
end

That does the trick. We have put a generic callback method on Player and Monster that calls back a given method on a given receiver, if the object called with playerCallback is in fact the player. We could do the same for Monster if we need it.

I think this is close to righteous, so I’ll commit: remove conditional statement from CombatRound.

Let’s sum up.

Summary

We have again encountered the oddity that comes from the fact that the crawl is synchronized with human time, while combat and other operations are synchronized with game time. I still find that difficult to think about.

But the fact is, we found the right place for our new messages, and the right place for the action to move the player fairly readily, and with only the usual amount of fumbling, we arranged for the player not to die but instead merely pass out and go back to the first room.

That callback method is strange, but I rather like it and would like to highlight it:

function Player:playerCallback(receiver, method)
    return receiver[method](receiver)
end

The syntax is odd. It just means:

    return receiver:method()

except that you can’t write it that way, because that would call “method”, not whatever the string named method is.

In Smalltalk, we would have written something like:

   ^receiver perform: method

And we could certainly implement that in Lua, as:

    return receiver:perform(method)

That might be a good convention to follow. I’ll try to remember to think about moving to that.

Be that as it may, we now have a way to avoid using explicit type or class checks in our code, but instead to ask the objects in hand to call us back if they care to. You may find that odd. In fact, it’s a common object-oriented thing to do. We don’t make a decision, we send a message to someone who knows what to do, and they do it. In this case, the message is “tell me to do this if you want to”.

In my view, and that of people I respect, this sort of thing is a good way to keep objects from knowing too much about each other.

I find that it is 1010, so I think I’ll call it a morning, and push this out. Here’s our new feature in operation:

down


D2.zip