Dungeon 248: Some fun.
I have in mind a fun small win on the path to hexes: a multiple dispatch. If nothing else, you may be surprised at what I think is fun. (And I do get in the weeds a bit along the way.)
Yesterday, we were working on this method, trying to improve it:
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local start = startingTile:pos()
local target = targetTile:pos()
local offsets = self:neighborOffsets()
local offsetToPos = function(offset) return offset + start end
local isRightDistance = function(pos)
return manhattan(pos,target) == desiredDistance end
local tileFromPosition = function(pos) return self:getTile(pos) end
local tileIsFloor = function(tile) return tile:isFloor() end
local positionsToConsider = map(offsets, offsetToPos)
local positionResults = arraySelect(positionsToConsider, isRightDistance)
local tileResults = map(positionResults, tileFromPosition)
local finalResults = arraySelect(tileResults, tileIsFloor)
return #finalResults > 0 and finalResults or { startingTile }
end
Today, we’re going to improve it better–I fondly hope–with the result that we add some necessary behavior to our new Maps objects. Let’s begin by analyzing the method above. I’m surprised and disappointed that none of my few readers called me on this one.
This method is choked with Feature Envy. Feature envy is the term for a method that pays more attention to the insides of other objects than it does to its own. Look at this method in that light and you’ll see that it is not really an instance method at all. It makes no useful references to self. The one reference it makes is to self:getTile(pos)
, which looks like this:
function Dungeon:getTile(pos)
return self:privateGetTileXY(pos.x, pos.y)
end
function Dungeon:privateGetTileXY(x,y)
if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
return Tile:edge(x,y, self.runner)
end
return self.map:atXYZ(x,0,y)
end
Wonderful, down there at the bottom we actually refer to a couple of member variables. And we don’t really need those, since we could use the fact that the map
will return a nil if there is no Tile.
It might be nicer if the atXYZ
function could return a default result if one was provided, but we don’t really want to create a Tile for this case. But if we could return a default, we could avoid an if statement. I think I use too many of those.
Back to the long method. Let’s look at its callers:
function Dungeon:availableTilesCloserToTile(tile1, tile2)
local desiredDistance = self:manhattanBetween(tile1,tile2) - 1
return self:availableTilesAtDistanceFromTile(tile1,tile2, desiredDistance)
end
function Dungeon:availableTilesFurtherFromTile(tile1, tile2)
local desiredDistance = self:manhattanBetween(tile1,tile2) + 1
return self:availableTilesAtDistanceFromTile(tile1,tile2, desiredDistance)
end
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local targetTile = self:playerTile()
return self:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
end
Let’s look again at the big guy and think about how it ought to work.
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local start = startingTile:pos()
local target = targetTile:pos()
local offsets = self:neighborOffsets()
local offsetToPos = function(offset) return offset + start end
local isRightDistance = function(pos)
return manhattan(pos,target) == desiredDistance end
local tileFromPosition = function(pos) return self:getTile(pos) end
local tileIsFloor = function(tile) return tile:isFloor() end
local positionsToConsider = map(offsets, offsetToPos)
local positionResults = arraySelect(positionsToConsider, isRightDistance)
local tileResults = map(positionResults, tileFromPosition)
local finalResults = arraySelect(tileResults, tileIsFloor)
return #finalResults > 0 and finalResults or { startingTile }
end
We start out with two tiles and a distance. We are supposed to return a collection of tiles that are at the given distance from the targetTile, and that are within one step of the starting tile. The function takes tiles in and puts tiles out.
But it does so by converting tiles to positions, calculating a bunch of vector additions to get other positions, then selecting positions based on their distance from another position, then fetching the tiles for the selected positions, and then selecting all the tiles that are floor tiles. (And, if there are none, it returns a collection containing only the starting Tile, signifying that there’s no better place to be.)
Now, if I were looking for an excuse, I’d say that well, it’s a deep design decision that tile positions are vectors, mumble mumble, didn’t make sense to drill all around down in the guts and anyway we were under pressure to deliver and this works just fine so back off. Yes, by the time I got done defending, I’d be mad, because the alternative to anger is fear, and I’d be afraid that I was a terrible programmer for having written this abomination, much less defending it.
Fortunately, I’m not looking for an excuse. I already know that on a given day I am a terrible programmer, but circumstances require me to program anyway, and once something tricky like this works, we back away slowly. Besides, you should have seen it before!
The thing is, that was the best I had on the day I wrote it, and it has worked just fine ever since. However, based on trying yesterday to make it more clear and how it looks today, I think I can agree that it could be better.
Let’s explore how.
Making It Better
I want to think about some options and some desiderata.
I think the main point of desire is that the notion of “manhattan distance” should be pushed down into the Maps area somewhere, because we no longer want any objects above the map messing with coordinates or pos()
. That’s part of what will let the game run on either type of map.
As for options, I can vaguely see a couple.
One possibility is that since the Dungeon is about Tiles (and the Map really isn’t: it’s about objects it doesn’t understand, stored at coordinates that it does understand), this method should operate by sending messages to Tiles. Ask the starting tile to find neighbor tiles at a given distance from another tile. This is my natural choice, because the Dungeon object and this method in particular, is about tiles.
The other idea, and I really only have a flickering notion here, is that we could somehow ask our map to return a collection of whatever is in the map, it doesn’t have to know, at a given distance from another thing.
But there are issues. For the first notion, tile does not know the map. It only knows its coordinate. Well, we could pass in the map, that’s the sort of thing one does.
For the second notion, the map doesn’t know whether the objects in it know their coordinates or not. They do know it, you and I know that, but the map doesn’t require that. It did pass in the coordinate to the creation of the object, but there’s no rule that the object keeps it. So in this scheme, we probably have to provide coordinates up at the Dungeon level.
Now what we most want, I’d argue, is that the user code, the Dungeon code in this case, should be as natural as we can manage. If weird things have to happen, they should happen down at the bottom, where they are centralized and we can keep an eye on them.
A wild idea has appeared! What if the Map could be given a coordinate, and return all the objects surrounding that coordinate? Or perhaps even including the object at that very coordinate, depending on what we want at the top.
And what if Tiles could get their manhattan distance from each other?
Couldn’t our big method become much nicer?
Let’s refactor in that direction. We’re going to break this method but hopefully in a good way. Fact is, we’re going to rewrite this method with these two ideas in mind.
Before I even write a single line of code, I get a further idea: don’t ask the map to do something from Dungeon. Ask the Tile to do it, providing the map.
Here goes …
Coding It Up
I come up with this:
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local neighbors = Tile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
return #floorTiles > 0 and floorTiles or { startingTile }
end
I’m not showing the functions for the arraySelect
calls but the names should serve to give the idea. We ask the Tile for its reachable neighbors, i.e. those one step away. We select those that are at the correct distance from the target tile. We select again for those that are floor tiles. We return those, or if there are none, the original starting tile (in an array).
Let’s put in the functions.
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
local tileIsFloor = function(tile)
return tile:isFloor()
end
local neighbors = Tile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
return #floorTiles > 0 and floorTiles or { startingTile }
end
That seems rather nice. I’ll run this to see what explodes, but we can see that we need Tile:reachableTilesInMap(map)
and Tile:distanceFrom(tile)
. Running, I get:
2: Dungeon helps monster moves -- Dungeon:187: attempt to call a nil value (method 'reachableTilesInMap')
That’s encouraging. So let’s do that.
Exploring in Tile, I do find a couple of interesting methods, distance
and manhattanDistance
. We’ll sort that out when we get there.
function Tile:reachableTilesInMap(aMap)
return aMap:surroundingObjects(self.mapPoint)
end
The Tile knows its mapPoint, so that’s legit. So … what about this:
function BaseMap:pointsToObjects(aPointsArray)
local result = {}
for _k,pt in ipairs(aPointsArray) do
table.insert(result, self:atPoint(pt))
end
return result
end
function BaseMap:surroundingObjects(aCoordinate)
local points = Maps:surroundingPoints(aCoordinate)
return self:pointsToObjects(points)
end
This will demand the function on Maps.
2: Dungeon helps monster moves -- BaseMap:84: attempt to call a nil value (method 'surroundingPoints')
Check. Code:
function Maps:surroundingPoints(aPoint)
local dirs = Maps:allNeighborDirections()
local result = {}
for _i,dir in ipairs(dirs) do
table.insert(aPoint+dir)
end
end
I’m writing out the table creations in line. I might go back and use my table map
and such. But for now I prefer the long form. There may be a learning there.
I’m not sure where this fails, but I think I’m on the path so I just run it.
2: Dungeon helps monster moves -- MapStrategy:118: attempt to perform arithmetic on a nil value (local 'aPoint')
Hm, no one gave me a point. What’s up?
function BaseMap:surroundingObjects(aCoordinate)
local points = Maps:surroundingPoints(aCoordinate)
return self:pointsToObjects(points)
end
That looks OK. Assert.
function BaseMap:surroundingObjects(aCoordinate)
assert(aCoordinate, "nil coordinate")
local points = Maps:surroundingPoints(aCoordinate)
return self:pointsToObjects(points)
end
It asserts. Hrm. That’s from here:
function Tile:reachableTilesInMap(aMap)
return aMap:surroundingObjects(self.mapPoint)
end
Do we not have a mapPoint? We sure don’t. But I thought …
Let’s review the code:
function Tile:init(mapPoint,kind, runner)
assert(mapPoint:is_a(MapPoint), "Tile requires MapPoint")
if runner ~= Runner then
print(runner, " should be ", Runner)
error("Invariant Failed: Tile must receive Runner")
end
self.mapPoint = mapPoint
self.kind = kind
self.runner = runner
self:initDetails()
end
I don’t see how we can get a tile with no mapPoint.
This is happening in a test:
_:test("Dungeon helps monster moves", function()
local tiles, x1,x2
local dungeon = Runner:getDungeon()
for key,tile in dungeon.map:pairs() do
tile:testSetRoom()
end
dungeon:setTestPlayerAtPosition(vec2(30,30))
local monsterTile = dungeon:getTile(vec2(34,34))
_:expect(dungeon:manhattanToPlayer(monsterTile)).is(8)
tiles = dungeon:availableTilesCloserToPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,34)), dungeon:getTile(vec2(34,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesFurtherFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(35,34)), dungeon:getTile(vec2(34,35))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesSameDistanceFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,35)), dungeon:getTile(vec2(35,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
end)
That init code goes over all the tiles. Let’s check them to see if they have mapPoints.
Some injudicious printing in that test tells me that the issue comes about in the call to availableTilesCloserToPlayer
. Let’s review that:
function Dungeon:availableTilesCloserToPlayer(aTile)
local desiredDistance = self:manhattanToPlayer(aTile) - 1
return self:availableTilesAtDistanceFromPlayer(aTile, desiredDistance)
end
Oh. Look:
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
local tileIsFloor = function(tile)
return tile:isFloor()
end
local neighbors = Tile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
return #floorTiles > 0 and floorTiles or { startingTile }
end
The call to create neighbors
is sent to Tile class. Of course it has no mapPoint. Simple but deadly mistae.
local neighbors = startingTile:reachableTilesInMap(self.map)
2: Dungeon helps monster moves -- MapStrategy:118: wrong number of arguments to 'insert'
That’ll be me, getting in trouble for not using my select function.
function Maps:surroundingPoints(aPoint)
local dirs = Maps:allNeighborDirections()
local result = {}
for _i,dir in ipairs(dirs) do
table.insert(aPoint+dir)
end
end
Yep.
function Maps:surroundingPoints(aPoint)
local dirs = Maps:allNeighborDirections()
local result = {}
for _i,dir in ipairs(dirs) do
table.insert(result, aPoint+dir)
end
end
2: Dungeon helps monster moves -- attempt to index a nil value
That would be more helpful with a better message. But the prints I have sprayed around tell me that it’s still inside the closer to player thing. A location would have been handy though.
More print spraying tells me it’s here:
function Tile:reachableTilesInMap(aMap)
assert(self.mapPoint, "no mapPoint")
return aMap:surroundingObjects(self.mapPoint)
end
Since the assert didn’t trigger, it’s here:
function BaseMap:surroundingObjects(aCoordinate)
assert(aCoordinate, "nil coordinate")
local points = Maps:surroundingPoints(aCoordinate)
return self:pointsToObjects(points)
end
I bet I forgot to return the result. Let’s look:
function Maps:surroundingPoints(aPoint)
local dirs = Maps:allNeighborDirections()
local result = {}
for _i,dir in ipairs(dirs) do
table.insert(result, aPoint+dir)
end
end
Now you see why I like those map
and arraySelect
functions. Add the return.
Finally:
2: Dungeon helps monster moves -- Dungeon:184: attempt to call a nil value (method 'distanceFrom')
That looks more on track.
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
We need to implement that. No surprise there.
Oh. Here’s where I was planning to have some fun. At last, fun.
How can we implement this method:
function Tile:distanceFrom(aTile)
end
We could rip out the coords and send them messages. The old distance
method does that:
function Tile:distance(aTile)
return self:pos():dist(aTile:pos())
end
That’s doing an actual vector dist. I’m not sure what that’s used for. Anyway, it’s rude to rip things out of objects, and we’re trying to avoid that. However, it is OK to access our own members, so we use what’s called “multiple dispatch”. We take what we know, pass it to someone who can deal with it, who takes what he knows and passes that to someone who can deal with it, until finally, somewhere down at the bottom of things, we have nothing but things we know how to deal with.
It goes like this. Tile doesn’t know how to get the distance to another Tile. We know that a Tile has a Point. Let’s ask our Point to give us the distance to a the other Tile:
function Tile:distanceFrom(aTile)
return self.mapPoint:distanceFromTile(aTile)
end
That means we need this method on MapPoint:
function MapPoint:distanceFromTile(aTile)
end
But MapPoint doesn’t know how to get the distance from itself to a Tile. It doesn’t even know what a tile is but it guesses from the method name that it must be a thing. So what can we do? We can’t rip the coord out of Tile. We aren’t even allowed at this level to know what one is. So we call back to it, telling it that we’re passing it a Point:
function MapPoint:distanceFromTile(aTile)
aTile:distanceFromMapPoint(self)
end
So now we find outselves here in Tile again:
function Tile:distanceFromMapPoint(aPoint)
???
end
We stil don’t know what to do, but we’ve been given a Point, and we have a Point, so we can let the Points sort it out among themselves:
function Tile:distanceFromMapPoint(aPoint)
return self.mapPoint:distanceFromMapPoint(aPoint)
end
Now we’re talking. At least we’ve got Points vs Points, so we write:
function MapPoint:distanceFromMapPoint(aPoint)
end
Now we’re cooking! Points are cartesian or hexagonal coordinates. They can understand distance.
We want manhattan distance (and perhaps should have been saying so right along), so we look it up.
It turns out that hex coordinates and cartesian coordinates have different manhattan distances.
type | distance |
cart | abs(dx) + abs(dy) |
hex | (abs(dx) + abs(dy) + abs(dz))/2 |
I’ll spare you the reasoning. I looked it up on the Red Blob page.
This means we’ll have to dispatch a bit further based on map type, which suggests:
function Maps:manhattanDistance(...)
return _ms:manhattanDistance(...)
end
function HexStrategy:manhattanDistance(p1,p2)
return p1:hexManhattanDistance(p2)
end
function CartesianStrategy:manhattanDistance(p1,p2)
return p1:cartesianManhattanDistance(p2)
end
function MapPoint:cartesianManhattanDistance(aPoint)
x1,y1 = aPoint:x(), aPoint:z()
x2,y2 = self:x(), self:z()
return math.abs(x1-x2) + math.abs(y1-y2)
end
function MapPoint:hexManhattanDistance(aPoint)
x1,y1,z1 = aPoint:x(), aPoint:y(), aPoint:z()
x2,y2 = self:x(), self:y(), self:z()
return (math.abs(x1-x2) + math.abs(y1-y2) + math.abs(z1-z2))/2
end
- Later: A Warning
- The above is almost right, modulo a couple of missing return statements. However, I wasn’t test driving, and I took a lot of steps, and I get in the weeds a bit from here down a bit.
Now on the one hand, I think this is probably right, and I was on a roll when I typed it in. On the other hand, where are my tests??? These elementary functions definitely need tests and should have been TDD’d.
But in for a penny, I’m going to at least run this. When I do, I discover that monsters don’t move. Not good. And we’re in pretty deep, too. I really would prefer not to revert all this.
Let’s at least see about testing those MapPoint distance functions.
_:test("Manhattan Distances", function ()
Maps:cartesian()
local p1 = Maps:point(1,2)
local p2 = Maps:point(4,6)
local dist = p1:manhattanDistance(p2)
_:expect(dist).is(7)
end)
I do have a couple of other tests failing. Let’s see what we see now:
14: Manhattan Distances -- TestMapClasses:174: attempt to call a nil value (method 'manhattanDistance')
Hm, bad naming conventions going on here. The method is distanceFromMapPoint
.
14: Manhattan Distances -- Actual: nil, Expected: 7
Someone in that chain forgot to return. It was this guy now fixed:
function MapPoint:distanceFromMapPoint(aPoint)
return Maps:manhattanDistance(self,aPoint)
end
Hard to believe I can make a mistake in one line of code.
Couple more tests still failing, and monsters don’t move. At least not the ghost ones.
2: Dungeon helps monster moves -- Actual: table: 0x285d2b240, Expected: Tile[33][35]: room
2: Dungeon helps monster moves -- Actual: table: 0x285d2b240, Expected: Tile[35][33]: room
_:test("Dungeon helps monster moves", function()
local tiles, x1,x2
local dungeon = Runner:getDungeon()
for key,tile in dungeon.map:pairs() do
tile:testSetRoom()
end
dungeon:setTestPlayerAtPosition(vec2(30,30))
local monsterTile = dungeon:getTile(vec2(34,34))
_:expect(dungeon:manhattanToPlayer(monsterTile)).is(8)
tiles = dungeon:availableTilesCloserToPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,34)), dungeon:getTile(vec2(34,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesFurtherFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(35,34)), dungeon:getTile(vec2(34,35))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
tiles = dungeon:availableTilesSameDistanceFromPlayer(monsterTile)
x1,x2 = dungeon:getTile(vec2(33,35)), dungeon:getTile(vec2(35,33))
_:expect(tiles).has(x1)
_:expect(tiles).has(x2)
end)
The errors are in those last two expects
, since they are the only two with 33,35 as the coordinates. And I suspect that I know what’s going on.
A little in-game experiment tells me that I’m not as right as I might be. I was thinking that the old distance check perhaps used geometric distance and we know the new one uses manhattan. It may well be that from a given location, there are zero cells at the same manhattan distance from player as one currently is. Any move must take you nearer or further away. But things are worse than that, in that the monsters also don’t move after an attack, when they are angry and trying to move closer.
Let’s see how this works. I am perilously close to reverting, but that will spoil my whole day.
This is very bad thinking. I’m bashing at this problem. I’d do well at least to take a break, and probably I should revert. But I have some good code in here that I don’t want to lose, some of which should already have been committed. This is playing not to lose instead of playing to win. But that’s what I’m gonna do a while longer.
Now I think this didn’t work:
function Dungeon:availableTilesCloserToPlayer(aTile)
local desiredDistance = self:manhattanToPlayer(aTile) - 1
return self:availableTilesAtDistanceFromPlayer(aTile, desiredDistance)
end
Because if it had, the Ghost would have chased me, but it didn’t. That method is written in terms of manhattan distance, so it should be ok … it calls …
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
assert(startingTile.mapPoint, "no mappoint on starting tile")
local targetTile = self:playerTile()
assert(targetTile.mapPoint, "no mappoint on player tile")
return self:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
end
And that, of course, calls:
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
local tileIsFloor = function(tile)
return tile:isFloor()
end
local neighbors = startingTile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
return #floorTiles > 0 and floorTiles or { startingTile }
end
I’m going to regret this, I fear, but I’m going to put in a couple of prints.
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
local tileIsFloor = function(tile)
return tile:isFloor()
end
local neighbors = startingTile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
if #floorTiles == 0 then
print("Avail", startingTile, targetTile, desiredDistance)
print("neighbors", #neighbors)
print("correct", #correctDistance)
print("floor tiles", #floorTiles)
end
return #floorTiles > 0 and floorTiles or { startingTile }
end
The print isn’t enlightening, but does tell me that the tiles look right and the correctDistance
array is empty. Easiest way that could be wrong would be if Tile:distanceFrom(tile)
doesn’t work. Let’s write a test for that, after a quick inspection.
I’m still hoping that there’s a simple defect, that I’ll see it, and that everything will be OK. This is a forlorn hope. I know it. But I’m not ready to throw in the towel.
function Tile:distanceFrom(aTile)
return self.mapPoint:distanceFromTile(aTile)
end
Ah this is my cool double dispatch stuff. Next:
function MapPoint:distanceFromTile(aTile)
aTile:distanceFromMapPoint(self)
end
There it is. No return
. Fix that.
- Later: Out of the Weeds
- We were never far off the road. But I tell you true, I was confused, I had too few tests to feel confident, and had the problem been much more difficult than a missing return, I think I’d have had to revert.
-
But we’re good now. Carry on …
The tests all run and the game plays well. There is an issue, a very subtle one. It used to be that it was easy to corner a monster that was staying away from you, and once it was cornered, you could attack it. Now if you do get close enough to hound the monster into a corner, you’ll be diagonal to it, and if you most up it moves down and if you move right it moves left, maintaining the manhattan distance of 2.
I’m really rather sure that this is a change. I’m not sure that I dislike it, because it might be good to make it harder to get into combat with a monster who isn’t angry.
I’m going to play a bit further into the game and if all goes well, I think we can ship this.
All goes well. Let’s commit: available tiles distance calculations (mostly?) defer to Tile and on down to MapPoint.
And sum up.
Summary
To begin with, this was a 90-line commit, counting -2 for a small deletion. That’s a lot. Ten lines might be a better target. And I was in the weeds for a while, although the actual problems mostly turned out to be missing return statements after creating tables.
I built those table functions map
and arraySelect
and such for two main reasons. First, I wanted to. Second, they are more compact. Third, as usual, lied about “two”. Fourth, they are cool. Fifth, they make it much harder to make the mistake of not returning a computed collection, which is one of my standard mistakes.
So why didn’t I use them? I thought it would be clearer to write the straightforward looping code. And maybe it was clearer, but without returning the result, it’s not going to work.
So I need to think about that.
Too little test driving
I’d have been able to commit more often and would have had a lot less confusion, I think, had I been test driving. Yes, I knew just what I wanted to do, but that multiple dispatch stuff won’t fit in my brain. I know that written without mistakes it works just fine, but I can’t remember four or six or eight different things at once. The result was that I was still following my nose, but no longer felt confident that I wan’t going to bump my nose into a wall or into something that didn’t smell sweet.
I’d have done better to use TDD, I think.
A continuing issue …
There are still methods in Dungeon that are not using our current distance logic, and some of them seem to be doing vector distance, which we need to at least think about. We’ll do that soon, I promise.
The main point …
The main point of today’s exercise was to do and talk about this example of multiple dispatch, a technique to deal with getting to compatible operation types. In our case, we needed to get from two tiles to two points, to find the distance between them:
function Tile:distanceFrom(aTile)
return self.mapPoint:distanceFromTile(aTile)
end
function MapPoint:distanceFromTile(aTile)
return aTile:distanceFromMapPoint(self)
end
function Tile:distanceFromMapPoint(aPoint)
return self.mapPoint:distanceFromMapPoint(aPoint)
end
function MapPoint:distanceFromMapPoint(aPoint)
return Maps:manhattanDistance(self,aPoint)
end
That unwinds further, but this is the sequence I was aiming for. It shows how we can defer actions down to the right objects without ripping the guts out of the objects we have.
The trick is to tell the called object what his parameter is. “mapPoint, we’re sending you a tile”. mapPoint doesn’t know from Tile, so it can only send back “tile, we’re sending you a mapPoint”. Tile knows how to get a compatible mapPoint, so it does and says “mapPoint, here’s another mapPoint”. Finally, in MapPoint, we have just two mapPoints to deal with, which we do. In this case, there’s additional dispatching based on the map type, but in a simpler case we could just have returned abs(dx)+abs(dy)
.
Each stage in the call series unwraps one half of the mystery. The first stage unwraps the itself to a Point and tells the Point that it’s sending it a tile. The Point just passes itself back to Tile, but now Tile is looking at the second parameter to the original call, so it unwraps that (Point) and sends it back to the first Point, telling it that now it has the right type to do the work.
That, to the best of my ability, is how you use multiple dispatch of methods to sort out and process mixed data types. It’s something you’ll see in the depths of Smalltalk all the time, as it sorts out all the various kinds of numbers it can handle.
But that’s hard to think about!!!
It can be. It can be tricky to implement, especially if you forget to return values like I do. But it’s really terrifically simple. You’re a Tile. Get your Point and find its distance from the other Tile. Now you’re a Point, asked to get a distance from a tile. You don’t know how, but you can ask the tile to give yo the distance from yourself, a Point. Now you’re a Tile again being asked to get a distance from a Point. You don’t know how but you can get your Point and ask it for distance from a Pint. Now you’re a Point and of course you know how to get your distance from another Point. You answer that and the calls unwind.
I still hate it!!!
Permission granted. But the alternative is to rip the guts out of objects that don’t belong to you, and when those objects change, as ours are doing right now, you wind up with lots of places where your untimely ripping no longer works and the system crashes in obscure places.
There’s a trick …
When you code this way, and you’re reading along, you see this:
function Dungeon:availableTilesAtDistanceFromPlayer(startingTile, desiredDistance)
local targetTile = self:playerTile()
return self:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
end
This method tells you what is going to happen: you’re going to get the available tiles at a given distance from another tile. You can stop drilling down and get back to what you were doing. Or, curious, you can look at this:
function Dungeon:availableTilesAtDistanceFromTile(startingTile, targetTile, desiredDistance)
local atCorrectDistance = function(tile)
return targetTile:distanceFrom(tile) == desiredDistance
end
local tileIsFloor = function(tile)
return tile:isFloor()
end
local neighbors = startingTile:reachableTilesInMap(self.map)
local correctDistance = arraySelect(neighbors, atCorrectDistance)
local floorTiles = arraySelect(correctDistance, tileIsFloor)
return #floorTiles > 0 and floorTiles or { startingTile }
end
You see tile:distanceFrom:tile
. That’s clear. No reason to read down to how tiles do that. We can get back to what we were doing.
This is why we try to use methods that say what they are doing rather than how they do it. And then we can safely develop the habit of not drilling down, except when we really need to.
It’s still weird and difficult and tricky …
Yes. It took me years of getting comfortable with Smalltalk to learn to appreciate this, and longer to learn to do it. And, as you’ve seen, I have not done it at all frequently here in Lua.
But in my view, it’s a very strong technique, it’s not really difficult, since every dispatch is a single message, and it works in a powerful and flexible fashion.
YMMV, but I think it’s good, and fun.
See you next time!