Dungeon 241--Stealth
Today my cunning plan is to try to make the Map the primary container of Tiles, and the tiles collection secondary.
The theme of today’s article is apparently Stealth. The notion I want to emphasize is that even though we are making a fairly radical change to a very important structure in our program, we are doing it in a very incremental, stealthy way, requiring as few big changes as we can manage. My thinking is that when we become adept at working in small steps with limited impact, good things happen. We can make progress on internal design while also delivering user-focused capability. We can adjust our priorities to address surprises, without the need to hold off until some big change is complete. We can keep our customer satisfied.
Yesterday, we got the new Map object, with its associate MapPoint, plugged into the system. We’re actually using it to draw the dungeon. However, we installed the map in rather a messy fashion. We built a tiny almost empty map and then scanned over the old tiles
structure, moving the Tiles from that structure into the Map. Today, I want to reverse that process. I want to get the Map creation to create the Tiles, and then I’ll copy them into the old self.tiles
structure.
If we can accomplish that, we’ll have made an important step toward the Map being the “real” representation of the tile space, and the self.tiles
structure will be more of an appendix, a leftover. We’ll then reduce our reliance on self.tiles
until we can remove it.
- Thinking
- One advantage to writing these articles is that it gets me to think clearly enough to write sentences that somewhat hang together. So when I wrote “reduce our reliance”, my thoughts went to the general question of ways to reduce our reliance on some particular part of the code. And I thought about whether there could be some kind of “fake array” that could be put in place of the
tiles
array, that would respond to the same messages we sent totiles
, and divert them over to Map. -
A problem is that since
tiles
is an array of arrays of Tiles, it is addressed by subscripting, e.g.self.tiles[x][y]
. It is probably possible to build an object in Lua that will pretend to be an array of arrays and properly handle attempts to subscript into it. Lua has hooks for__index
and__newindex
that would enable that. I think it would be fun, but probably time-consuming and certainly obscure. But we might insert a simple interface object with a method:at(x,y)
, find and change all the subscripting references, and work from there. -
I think that notion is worth pursuing. We’ll see. But first, I still want to get Map to be the primary object.
Map and Tiles Creation
Here’s what we have now:
function Dungeon:createTiles(tileCountX, tileCountY)
self.tileCountX = tileCountX
self.tileCountY = tileCountY
self.map = Map:cartesianMap(1,1, DummyTile)
self.tiles = {}
for x = 1,tileCountX+1 do
self.tiles[x] = {}
for y = 1,tileCountY+1 do
local tile = Tile:edge(x,y, self.runner)
self:defineTile(tile)
end
end
end
function Dungeon:defineTile(aTile)
assert(not TileLock, "attempt to set tile while locked")
local pos = aTile:pos()
self.tiles[pos.x][pos.y] = aTile
local pt = MapPoint:cartesian(pos.x,pos.y)
self.map:atPointPut(pt,aTile)
end
The second method there, defineTile
, ensures that whenever we change a tile in our map tables, we change both representations. And–thinking again–I’m reminded that there is at least one place where a Tile is changed in situ rather than replaced. Let’s look for that.
function Tile:convertEdgeToWall()
if self.kind ~= TileEdge then return end
local byte = self:getSurroundingInfo()
if byte == 0 then return end -- all neighbors are non-room
self.kind = TileWall
self.sprite = self:getWallTile(byte)
end
function Tile:convertToEdge()
self.kind = TileEdge
self:initDetails()
end
function Tile:testSetRoom()
self.kind = TileRoom
end
Looking at those, I think I’ll allow them. I’d vaguely prefer immutable objects, but there’s nothing inherently evil about objects that maintain state, as far as I know, so we’ll not worry about these.
Back to our quest, creating the Map as primary and copying it into the self.tiles
arrays.
There’s a bit of juggling to do here. The Map thinks in terms of MapPoint objects, representing the coordinates in map space of the tiles in the map. Tile does not expect a MapPoint on creation, and so far, doesn’t even have one as a member variable.
At the same time, Map creates objects in the Map by calling a provided class, together with other parameters:
function Map:init(xWidth, zHeight, mapType, klass, ...)
assert(mapType == cartesian or mapType == hex, "MapType must be cartesian or hex")
self.mapType = mapType
self:createRectangle(xWidth, zHeight, klass, ...)
end
function Map:createRectangle(xWidth, zHeight, klass, ...)
self.tab = {}
for z = 1,zHeight do
for x = 1,xWidth do
local pt = self.mapType(x,z)
local item = klass(pt, ...)
self:atPointPut(pt,item)
end
end
end
We can pass in the necessary GameRunner using the ...
capability, but we need to prepare Tile to be created with a MapPoint. Presently it’s expecting x and y:
function Tile:hall(x,y, runner)
assert(not TileLock, "Attempt to create room tile when locked")
return Tile(x,y,TileHall, runner)
end
function Tile:room(x,y, runner)
assert(not TileLock, "Attempt to create room tile when locked")
return Tile(x,y,TileRoom, runner)
end
function Tile:wall(x,y, runner)
assert(not TileLock, "Attempt to create wall tile when locked")
return Tile(x,y,TileWall, runner)
end
function Tile:edge(x,y, runner)
return Tile(x,y,TileEdge, runner)
end
function Tile:init(x,y,kind, runner)
if runner ~= Runner then
print(runner, " should be ", Runner)
error("Invariant Failed: Tile must receive Runner")
end
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
self:initDetails()
end
So. Least invasive might be to change init to expect a single parameter coord
rather than the two x,y
. Change the class methods to create one of those. An issue is that the current value of position
is vec2
, and we want it to be a MapPoint. Let’s quickly glance at how position is used.
Ah. I begin to remember. There’s only this:
function Tile:pos()
return self.position
end
But then:
… I was about to list all the uses of pos. Instead, if position
were to become a MapPoint, we could readily construct a vec2
from it and no one should be the wiser.
Let’s do that. Our mission, and we choose to accept it, is to create MapPoints in the Tile class methods, change init
to expect a MapPoint, and change pos()
to return a vec2.
function Tile:hall(x,y, runner)
assert(not TileLock, "Attempt to create room tile when locked")
return Tile(MapPoint:cartesian(x,y),TileHall, runner)
end
function Tile:room(x,y, runner)
assert(not TileLock, "Attempt to create room tile when locked")
return Tile(MapPoint:cartesian(x,y),TileRoom, runner)
end
function Tile:wall(x,y, runner)
assert(not TileLock, "Attempt to create wall tile when locked")
return Tile(MapPoint:cartesian(x,y),TileWall, runner)
end
function Tile:edge(x,y, runner)
return Tile(MapPoint:cartesian(x,y),TileEdge, runner)
end
function Tile:init(mapPoint,kind, runner)
if runner ~= Runner then
print(runner, " should be ", Runner)
error("Invariant Failed: Tile must receive Runner")
end
self.position = mapPoint
self.kind = kind
self.runner = runner
self:initDetails()
end
function Tile:pos()
return vec2(self.mapPoint:x(), self.mapPoint:z())
end
Note that we create our vec2
using the MapPoint’s x and z members, not x and y, because our MapPoints for cartesian have zero y. That makes the map math work as I expected it to. Probably could be done differently, but wasn’t.
I think this ought to work. Can’t think why it wouldn’t, but I’m sure that running the program will tell me. No, wait, I’d better look for callers of Tile(). There may be some in the tests or elsewhere.
There seem to be none. Let’s make sure:
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.position = mapPoint
self.kind = kind
self.runner = runner
self:initDetails()
end
Did you notice that odd if statement in Tile:init
? If we want runner always to be Runner, why don’t we just set it instead of checking? I have no recollection of what I was thinking when I wrote that. Weird.
Anyway, let’s see what happens when we run.
Tile:395: attempt to index a nil value (field 'mapPoint')
stack traceback:
Tile:395: in method 'pos'
Dungeon:248: in method 'defineTile'
Dungeon:241: in method 'createTiles'
Tests:25: in field '_before'
codeaUnit:44: in method 'test'
Tests:36: in local 'allTests'
codeaUnit:16: in method 'describe'
Tests:12: in function 'testDungeon2'
[string "testDungeon2()"]:1: in main chunk
codeaUnit:139: in field 'execute'
Tests:625: in function 'runCodeaUnitTests'
Main:8: in function 'setup'
Well, that’s not so good is it? I think the point is that I put the MapPoint into position
, not mapPoint
, then assumed I had done the right thing and renamed the member variable.
Which is correct? Is it a position or is it a mapPoint? Or does it matter? position is more generic. MapPoint is more accurate. Go for the latter.
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
Run again. The game runs. I am not even surprised. Commit: Tiles now created with MapPoint rather than vector position. Can still return a vector as needed.
Making Map Primary
We have prepared the soil. The Tile is now created in a manner that the Map can deal with. We should therefore be able to copy the Map into the self.tiles
instead of the other way around.
Let’s give it a go.
Before:
function Dungeon:createTiles(tileCountX, tileCountY)
self.tileCountX = tileCountX
self.tileCountY = tileCountY
self.map = Map:cartesianMap(1,1, DummyTile)
self.tiles = {}
for x = 1,tileCountX+1 do
self.tiles[x] = {}
for y = 1,tileCountY+1 do
local tile = Tile:edge(x,y, self.runner)
self:defineTile(tile)
end
end
end
Note that for some arcane reason, we create an array that is one cell larger in each dimension that called for. That’s a crock of some kind of substance, but we’re not here to deal with that at the moment.
This should create the desired map:
self.map = Map:cartesianMap(self.tileCountX+1,self.tileCountY+1, Tile,TileEdge, self. runner)
I remember to go over to Tile and make TileEdge global. That’s a bit naff but tolerable at this stage.
Now to create self.tiles
, I think we can just modify the loop above to fetch the desired tile from the Map and stuff it into the array. And we should change the defineTile
not to restuff the Map.
function Dungeon:createTiles(tileCountX, tileCountY)
self.tileCountX = tileCountX
self.tileCountY = tileCountY
self.map = Map:cartesianMap(self.tileCountX+1,self.tileCountY+1, Tile,TileEdge, self. runner)
self.tiles = {}
for x = 1,tileCountX+1 do
self.tiles[x] = {}
for y = 1,tileCountY+1 do
local tile = self.map:atXYZ(x,0,y)
self:defineTile(tile)
end
end
end
function Dungeon:defineTile(aTile)
assert(not TileLock, "attempt to set tile while locked")
local pos = aTile:pos()
self.tiles[pos.x][pos.y] = aTile
--local pt = MapPoint:cartesian(pos.x,pos.y)
--self.map:atPointPut(pt,aTile)
end
I have a good feeling about this, but it seems almost too easy. Run, standing well back.
Tiles all come up black. We can’t change defineTile
that way: we modify the tiles often enough during the setup. I’ll uncomment those two lines. We’ll be storing redundantly back into the map but that’s OK for now.
Test. Game works fine. Commit: Map is now primary data structure, Dungeon.tiles created as a copy of it.
Let’s pause here to reflect, and maybe even stop.
Reflection
We’ve changed about ten lines this morning, and shifted from making a Map by scanning the old structure, to creating a Map in the desired official form and creating the old structure from the map.
This is important. We can now proceed to remove references to the old structure at our convenience, directing them to the Map instead, and when we’ve done the last one, we can remove the old structure. We’d like to do that expeditiously, since there are nearly 6000 elements in those structures, so the duplication is somewhat irritating. But the hybrid system works fine, because the two structures are just different ways of accessing the same base elements, the Tiles.
We aren’t quite to the point where we can readily start creating a hex-based level inside Dung. For that to work well, I think we need to at least modify the methods that access Tiles through the old self.tiles
structure. There are a lot of methods for assessing neighboring tiles and calculating distances and such.
Or are we? We’re certainly to a point where the cartesian team can keep working on things that happen in the square-tiles levels. Everything there works, though we’ll all be looking at the old-style code and seeing how to convert it. I think the main approach will be to push logic down into the MapPoint, things like distance calculations, and into Map, for things like inspecting neighboring cells.
I think we should find out. I think our next steps should be to try to create a hex-based level in the game. Even a very simple one, with just a wide-open big hex-tiled area with some monsters and treasures in it, should tell us a lot.
Yeah. That should probably be next week’s work. Unless I get an idea I like better.
See you next time!