Double dispatch! Chet will be so proud. Or jealous. And Keys!

Yesterday, I put elementary interactions into the dungeon program. When any entity (monster, player, whatever can wander around) tries to enter a tile, we do this:

function Tile:legalNeighbor(anEntity, aStepVector)
    local newTile = self:getNeighbor(aStepVector)
    if newTile:isRoom() then
        self:removeContents(anEntity)
        newTile:interactionFrom(anEntity)
        newTile:addContents(anEntity)
        return newTile
    else
        return self
    end
end

function Tile:interactionFrom(anEntity)
    for k,entity in pairs(self.contents) do
        entity:interact(anEntity)
    end
end

As written this code assumes the entry will be allowed. That may need to change. Anyway, we remove the moving entity from the current tile, interact it with the new tile’s contents, then add it to the new tile.

The final behavior should probably be to do the interaction and only allow the move if the interactions say to go ahead. We’ll deal with that when we have the need. Right now, our only interactions will be monster:player and monster:monster.

So when Alice enters a tile, all the existing folks in the cell, Bob and Carlos receive the message interact(Alice). Right now, that’s just implemented this way:

function Monster:interact(anEntity)
    self.alive = false
end

function Player:interact(anEntity)
    self.alive = false
end

That is, if someone enters your cell, you’re dead. That’s sufficient to generate a little strategy already. If a ghost is coming toward you, you had better not wait for it to reach you. You should run at it, so that you move into it, which will cause it to die. Tragic loss of ghost. If you wait, the ghost will run into you, and that’s tragic loss of you, which is much worse.

However, this doesn’t give us much opportunity for interesting behavior. Let’s first imagine a thing to do that we don’t want to do.

In Monster:interact we could check to see if the input entity is the player, and if it is, then die, and if not, then it must be another monster, so don’t die. This is problematical, and I’ve been taught, somewhere, probably more than once, a better way to deal with it.

Clearly, when we interact, we want to know what kind of entity we’re interacting with. But we’ll generate more and more conditional code, in more and more places, if we go down the path of checking types. There is a better way, and it is called

Double Dispatch

I’ll do a little explaining here, but the code will, I hope, make things more clear.

I’m taking the position that if you’re entering the new tile, you have what game players call “initiative”. You get to make the first move. This is an overly simple view, but this is an overly simple game. There are a number of things that could be in the square you move into and they are all informed, by receiving interact, that you’re entering. They respond by sending a message to you that includes what type they are, right in the name of the message.

A Monster will send startActionWithMonster(self), and a Player will send startActionWithPlayer(self), and a treasure chest will send startActionWithTreasure(self). The receiver, who is entering the tile, now knows what he’s dealing with. For now, what I plan to end up with is this:

  1. When Player enters upon a Monster, player will tell monster to die.
  2. When Monster enters upon Player, monster will tell player to die.
  3. When Monster1 enters upon Monster2, monster 1 will tell monster 2 to rest, which will prevent it from moving for a while. This will give them a chance to separate a bit, which is better than having them on top of each other.

Here’s how we do this:

First everyone who can interact (Monster and Player for now) will implement interact by sending startActionWith[Type](self) to the other entity.

function Player:interact(anEntity)
    anEntity:startActionWithPlayer(self)
end

function Monster:interact(anEntity)
    anEntity:startActionWithMonster(self)
end

Player implements this:

function Player:startActionWithMonster(aMonster)
    aMonster:die()
end

And monster implements this:

function Monster:startActionWithMonster(aMonster)
    aMonster:rest()
end

function Monster:startActionWithPlayer(aPlayer)
    aPlayer:die()
end

and they both implement die:

function Monster:die()
    self.alive = false
end

function Player:die()
    self.alive = false
end

And, for now, Monster does this:

function Monster:rest()
    -- TBD
end

I did this without tests, so I expect trouble, both from the code and from faithful readers all over the world. But let’s see what happens.

princess dead

In the picture above, there was a ghost in the initial room with the princess. It walked onto the princess and she died. Poor dear, but it was written.

Now I’ll see if she can run over a ghost and terminate it with extreme prejudice.

monster dead

Here, there was a monster down that hallway and the princess ran at him and ran him over. As we see in the picture, he’s dead as, well, as dead as a dead ghost.

So this is working well, and we’ll commit: double dispatch interaction.

Now let’s do the rest thing. Monsters run on a timing tween:

function Monster:setMotionTimer()
    self:setTimer(self.chooseMove, 1.0, 0.5)
end

They move every 1 to 1.5 seconds. We want to override whatever timer the run-over ghost may have, and set it to, let’s say, 5 seconds. To do that we need to save the timer, stop it, and start a new one.

function Monster:setMotionTimer(base)
    self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end

function Monster:setTimer(action, time, deltaTime)
    local t = time + math.random()*deltaTime
    return tween.delay(t, action, self)
end

Now in rest:

function Monster:rest()
    self.motionTimer:stop()
    self:setMotionTimer(5.0)
end

I added an optional parameter to ``setMotionTimer, defaulted to 1. Set it to 5 in the rest` function. Now I’m going to have to see if I can find two ghosts to collide.

Monster:87: attempt to call a nil value (method 'stop')
stack traceback:
	Monster:87: in method 'rest'
	Monster:105: in method 'startActionWithMonster'
	Monster:64: in method 'interact'
	Tile:112: in method 'interactionFrom'
	Tile:128: in method 'legalNeighbor'
	Monster:76: in method 'moveTowardAvatar'
	Monster:31: in field 'callback'
	...in pairs(tweens) do
    c = c + 1
  end
  return c
end

:158: in upvalue 'finishTween'
	...in pairs(tweens) do
    c = c + 1
  end
  return c
end

:589: in function <...in pairs(tweens) do
    c = c + 1
  end
  return c
end

:582>

I am just guessing here that I can’t refer to the motion timer as if it were an object. So:

function Monster:rest()
    tween.stop(self.motionTimer)
    self:setMotionTimer(5.0)
end

Now to orchestrate this all again. I think I’ll turn off the player’s ability to die.

Darn. I accidentally trampled one of my test subjects. I’ll turn off their ability to die as well.

You’ll have to take my word for it that this works, but it does. Putting the die functions back and commit: monster stepped on by monster rests.

This seems like a good point to stop and think, so let’s sum up.

Summing Up

We’ve made a simple but important change to how entities interact. When Princess Alice moves into a tile where Monster Bob is residing, Bob gets the message interact(Alice). Bob doesn't know who Alice is, so he unconditionally sends Alice:startActionWithMonster(Bob)`.

Alice, when she receives this message, sends: Bob:die(). Rude but effective.

Similarly, if Bob were to step into a tile where Alice was residing, Alice would get interact(Bob). She sends Bob:startActionWithPlayer(Alice) and Bob, having learned his lesson, sends Alice:die().

The actions need not be that direct. In future steps we’ll allow for longer battles, and allow for the cell residents to decline the entity’s entry. For example, to open a chest, we might need to have a key, try to enter the space with the chest, and have our entry refused but the chest open and the key removed from our inventory. Then we try to enter again, and with the chest open, we are allowed to enter (or not) and are given the contents.

The double dispatch, providing the receiver with information as to the type of the entity it’s interacting with, will be the key (no pun intended) to getting the interaction to work cleanly.

You know what? I’m not really tired yet, it’s only about 1025 so let’s see about putting down a key. We can at least get a start.

We’ll want a Key object, and it will be an entity. We don’t have a common superclass or container to be an entity, but we may start feeling the lack. Or not.

-- Key
-- RJ 20201216

Key = class()

function Key:init(tile, runner)
    self.tile = tile
    self.runner = runner
end

function Key:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CENTER)
    local g = self.tile:graphicCenter()
    sprite(xxx,g.x,g.y)
    popStyle()
    popMatrix()
end

function Key:interact(anEntity)
    anEntity:startActionWithKey(self)
end

I just randomly typed this in. I’m not necessarily proud of myself for this, but what was there to test?

Now let’s find a key sprite:

function Key:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CENTER)
    local g = self.tile:graphicCenter()
    sprite(asset.builtin.Planet_Cute.Key,g.x,g.y)
    popStyle()
    popMatrix()
end

The key size is large, we’ll need to scale it down. Since TileSize is 64, let’s make this guy about 50.

function Key:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CENTER)
    local g = self.tile:graphicCenter()
    sprite(asset.builtin.Planet_Cute.Key,g.x,g.y, 50,50)
    popStyle()
    popMatrix()
end

Now we need to sprinkle a bunch of keys around so that we can find out how they look. If anyone steps on one, they’re going to crash.

function GameRunner:createRandomRooms(count)
    self.rooms = {}
...
    self:connectRooms()
    self:convertEdgesToWalls()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    local tile = self:getTile(vec2(rcx,rcy))
    self.player = Player(tile,self)
    self:createMonsters(5)
    self:createKeys(10)
end

function GameRunner:createKeys(n)
    self.keys = {}
    for i = 1,n or 1 do
        local tile = self:randomRoomTile()
        table.insert(self.keys, Key(tile,self))
    end
end

How do the keys get into room contents? They don’t move, and I’m not sure we have done anything to put anyone into the first room they hit.

function Key:init(tile, runner)
    self.tile = tile
    self.tile:addContents(self)
    self.runner = runner
end

Now I expect to find keys all over. However, no one draws them yet. Presently GameRunner draws the monsters and the princess. We should probably defer that to the tiles, but for now:

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for i,row in ipairs(self.tiles) do
        for j,tile in ipairs(row) do
            tile:draw()
        end
    end
    self.player:draw()
    self:drawMonsters()
    self:drawKeys()
end

function GameRunner:drawKeys()
    for i,k in ipairs(self.keys) do
        k:draw()
    end
end

key

Sure enough, there’s a key right here with us. On the zoomed map, there are several, and soon a monster steps on one:

Key:23: attempt to call a nil value (method 'startActionWithKey')
stack traceback:
	Key:23: in method 'interact'
	Tile:112: in method 'interactionFrom'
	Tile:128: in method 'legalNeighbor'
	Monster:83: in method 'makeRandomMove'
	Monster:33: in field 'callback'
	...in pairs(tweens) do
    c = c + 1
...

We need to let everyone deal with startActionWithKey. This is a downside of the double dispatching approach: everyone who can interact needs to deal with everyone else. The upside is that many of those interactions are empty, or trivial.

function Monster:startActionWithKey(aKey)
end

The key never moves, so it doesn’t need to manage interactions with others. The player needs to deal with hitting a key. Let’s have her add it to her keyring.

function Player:startActionWithKey(aKey)
    self.keys = self.keys + 1
    aKey:take()
end

We’ll need to init self.keys:

function Player:init(tile, runner)
    self.alive = true
    self.tile = tile
    self.tile:addContents(self)
    self.runner = runner
    self.keys = 0
end

And we need key:take():

function Key:take()
    self.tile:removeContents(self)
end

Now I really ought to be testing whether this works, but I confess I feel quite confident that it does. We’ll talk about that in the next summary.

Let’s see what happens. I expect that if I step into a key square, the key will disappear. (And my keys count will increase, but I have no way to know that … yet.)

key right

key left

Ha. So much for confidence. Doesn’t seem to have worked, does it?

Oh: the reason is that keys are drawn by the GameRunner. It thinks the key is still there, but the tile thinks it isn’t.

Let’s make the tile draw its contents. That will be … interesting.

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for i,row in ipairs(self.tiles) do
        for j,tile in ipairs(row) do
            tile:draw()
        end
    end
    self.player:draw()
    --self:drawMonsters()
    --self:drawKeys()
end

function Tile:draw()
    pushMatrix()
    pushStyle()
    spriteMode(CORNER)
    tint(self:getTint())
    local sp = self:getSprite()
    local center = self:graphicCorner()
    sprite(sp, center.x,center.y, self.runner.tileSize)
    for k,c in pairs(self.contents) do
        c:draw()
    end
    popStyle()
    popMatrix()
end

This is rather a major design change, but I expect it to work. I’ve been wrong before. Many times. Most recently a couple of minutes ago.

key right again

key left again

OK, key disappears. Commit: keys exist and can be picked up. tiles draw contents not gamerunner.

Let’s sum up … again.

Summary (2)

The double dispatch for entering a tile has, I think, shown the value of the technique. When we add a new kind of entity that can interact, it implements the interact method, and always sends startActionWith[Type] to whatever it’s interacting with.

It is true that everyone has to then implement that new method, so there is an n-squared thing going on here. We could use inheritance or a container to provide the trivial “ignore” implementations, but for inactive entities, they’ll not ever receive any of those “typed” messages, because they never enter a room.

It’s 1100 almost exactly, so putting in the Key and having the Player take it has taken almost no time at all. The writing of the article has taken most of the time.

So there we are. We’ve improved the battle a bit, we’ve made monsters not kill monsters, and we’ve added keys. It has been a good morning.

See you next time!

D2.zip