Dungeon 38
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:
- When Player enters upon a Monster, player will tell monster to die.
- When Monster enters upon Player, monster will tell player to die.
- 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.
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.
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
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.)
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.
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!