Dungeon 19
So many things to be thankful for here on Thanksgiving. One small one, I hope, is the Rectangle. Then, a wild Andrew appears!
I’m still on the path of getting Princess Avatar to move between rooms. And I have a cunning plan.
We now have the ability for the princess to move into a doorway, which allows her to pass through the wall zone, up to, and even over the edge of the room. And that’s where my cunning plan comes in.
We check like this:
function Room:legalMoveTo(aRoomPosition)
for k,door in pairs(self.doors) do
if door:contains(aRoomPosition) then
return aRoomPosition
end
end
return self:stayInRoom(aRoomPosition)
end
The function above accepts a RoomPosition (room plus coordinates within room), and returns one, either the same or adjusted as need be. Presently the door:contains
is returning a boolean, but what if it returned a RoomPosition, and we checked here to see if it was the same position as we passed in, or a new one. If new, we return it. That new RoomPosition could reference the room moved into.
Now, I’m not loving the idea of comparing the positions for equality, but right now I don’t see a better way. So let’s look at Door:contains
:
function Door:contains(aRoomPosition)
local x,y = self:getDoorCoordinates(aRoomPosition.room)
local r = Rectangle(x,y,10,10)
local px,py = aRoomPosition:screenLocation()
return r:contains(px,py)
end
That refers the containment question down to Rectangle, and returns true/false. …
Now as we talk about this, I realize that the question we’re asking here may need to be thought of differently. The door question really has three possible conditions:
- The point isn’t inside the doorway. We presently return false, which is interpreted elsewhere as “not interesting”.
- The point is inside the doorway. We presently return true, which is taken elsewhere to mean “this point is officially OK”.
- The point is inside the doorway and outside the room. This should mean “it’s time to move to the new room”.
What if we think of what’s going on in Room:legalMoveTo
as a sort of transformer. We pass the coordinate to various transformers. They can return a new coordinate, and if they do, it will be used. (One could argue that it should be passed to the next transformer, but that won’t work for us.) So we use the first transformed coordinate that we get.
I think that view makes a bit of sense, at least to me. Let’s put this notion into the code a bit more clearly.
I want an isEqual
operator on RoomPosition. The Codea default would be “identical” and that’s too constraining, as I think we’ll see in a moment. So …
function RoomPosition:isSameAs(aRoomPosition)
return self.room == aRoomPosition.room and self.x == aRoomPosition.x and self.y == aRoomPosition.y
end
I named it “isSameAs because that made more sense to me in the moment. Now let’s adjust those other two methods:
function Room:legalMoveTo(aRoomPosition)
for k,door in pairs(self.doors) do
if door:contains(aRoomPosition) then
return aRoomPosition
end
end
return self:stayInRoom(aRoomPosition)
end
This one becomes …
function Room:legalMoveTo(aRoomPosition)
for k,door in pairs(self.doors) do
local pos = door:transform(aRoomPosition)
if pos:differsFrom(aRoomPosition) then return pos end
end
return self:stayInRoom(aRoomPosition)
end
Notice that I need differsFrom
. I could negate the other but let’s instead replace it.
function RoomPosition:differsFrom(aRoomPosition)
return self.room ~= aRoomPosition.room or self.x ~= aRoomPosition.x or self.y ~= aRoomPosition.y
end
At this point I expect everything to work again. If it does, the princess can walk well into a doorway. As so often happens, I am disappointed. I forgot to write transform
:
Room:342: attempt to call a nil value (method 'transform')
stack traceback:
Room:342: in function <Room:340>
(...tail calls...)
Player:33: in method 'keyDown'
Main:24: in function 'keyboard'
I guess I’ll use contains
to build transfer
:
…
Darn. This is what happens when you start sort of bottom up. You keep writing stuff you don’t quite need. In particular, the new legalMoveTo
can’t quite work as advertised. Why? Because the doorway wants to accept an existing position that would be rejected by the room. I was just talking about the three cases, and differsFrom
doesn’t handle three cases any better than contains
.
I’m carrying on two conversations on line while doing this. I foresee difficulties.
This needs a bit of thinking as to a decent way to set it up. I’m reverting.
Rooms?
I posted a description of my dilemma on Twitter, and got an interesting reply from Andrew Travis. Here’s his tweet and my reply:
The idea of course changes the design. Presently rooms have doors, doors are special. Andrew’s suggestion just makes doors a kind of room. We’d have to build that new structure, but setting that aside, then each room would have one or more adjacent rooms. If the room is a regular big room, its adjacent rooms would be door type rooms. If the room is a door type room, its adjacent rooms would be regular type rooms.
There could even be direct room-to-room connections, which would make sense because many of my rooms are in fact directly adjacent. I allow them to connect if they’re a bit apart, but if they really abut, why not just let it be?
So it seems to me that changes the move checking a bit. Let’s see. We’ll check our RoomPosition(room,x,y) against the adjacent rooms first. If the x,y is inside the adjacent room, we return a new RoomPosition with the new room number, and exit using it. If not, keep checking. Finally, call the base room, which either returns the original RoomPosition or a slightly adjusted one. Either way, we use it.
I think we’ll try this idea. We’re going to be doing a rather large design change here, which is that instead of Doors being a link between two rooms, and owned by each of the rooms, there will be no doors, only more rooms. So where we now create a Door, we’ll instead create a tiny room. (We’ll need to get it the right size. Look at the doors in this picture:
The door between room 1 and room 11 is a bit longer than the door between 11 and 13, because 13 is directly adjacent to 11, but 1 and 11 are a bit separated. We allow some distance between rooms and still count them as connected by our current Doors. We define “close enough” this way:
function Room:closeEnough()
return 5
end
What if we made that larger, maybe as much as 30? Let’s see.
In this picture we can see that now there are doorways between rooms that are presently very far apart. Rooms 33 and 19, a bit to the right of center and above, are quite far apart, but we’ve calculated doorways for them.
In our new scheme, we need to make a longish room between them.
Now when they were doors, I wasn’t troubled by showing them as graphically different spaces. When they’re rooms, we will have to do something special. We presently draw the wall all around the boundary of the room, and then pile the door graphic (a white square for now) on top. So we don’t see any wall.
My current scheme relies on the door’s boundaries being inside the room. You can be in the room and your coordinates can also be in the doorway. And the idea was, the doorway is checked first, and accepts you. All good.
Now we have to decide what rule to break. Currently rooms per se cannot overlap. To follow that rule, our new door-room-hall things would be created adjacent to the wall boundaries of the rooms they connect.
Hmm, maybe they just act a little differently in checking. If we always check our neighbor rooms first, they can detect an attempted move into their space and accept it, changing the nominal position of the princess to inside the new room.
Is this coherent? Probably not. But I’ve convinced myself that, graphics concerns aside, we can probably make this work. Graphics, we can always fix. Probably.
OK, where do we create doors?
function Room:addDoor(aDoor)
self.doors[aDoor:key()] = aDoor
end
Called by:
function Door:init(room1, room2, isPrimary)
self.isPrimary = isPrimary
self.room1 = room1
self.room2 = room2
if room1.number > room2.number then
self.room2,self.room1 = room1,room2
end
self.room1:addDoor(self)
self.room2:addDoor(self)
self.mt = getmetatable(self)
self.mt.__tostring = self.toString
end
Called, indirectly, by:
function Room:colorNeighborsIn(rooms, aColor)
self:setColor(aColor)
for i,r in ipairs(rooms) do
if r:isNeighbor(self) then
if r:getColor() ~= aColor then
self:insertNewDoor(AllDoors, Door:primary(self, r))
r:colorNeighborsIn(rooms, aColor)
else
self:insertNewDoor(AllDoors, Door:secondary(self, r))
end
end
end
end
We don’t want to duplicate doors, so we check to be sure we only add them once:
function Room:insertNewDoor(doors, door)
if not self:doorPresent(doors,door) then
doors[door:key()] = door
end
end
function Room:doorPresent(doors,door)
return doors[door:key()] ~= nil
end
There’s a lot of mechanism here, and not all of it will make sense in the new scheme.
Is This a Refactoring?
We are about to improve the design of existing code. That’s the formal definition of refactoring, so yes, it is.
However, ripping out a million lines of code and replacing it with entirely new code, while it may be formally a refactoring, isn’t a particularly good kind of refactoring. We want something that can be done incrementally, without breaking the program for a long period of time.
Let’s think about where we want to wind up.
We want each Room not to have a collection of Doors, but a collection of neighbors, which are other rooms. Sometimes, if the rooms abut, they’ll count directly as neighbors. However, when they are not abutting, we intend to create a new room and install it as neighbor to each of the close enough but not abutting rooms.
And all this is going on while we are hunting down neighboring rooms in a big loop inside a recursive function:
function Room:colorNeighborsIn(rooms, aColor)
self:setColor(aColor)
for i,r in ipairs(rooms) do
if r:isNeighbor(self) then
if r:getColor() ~= aColor then
self:insertNewDoor(AllDoors, Door:primary(self, r))
r:colorNeighborsIn(rooms, aColor)
else
self:insertNewDoor(AllDoors, Door:secondary(self, r))
end
end
end
end
I don’t think it’s at all safe to insert new rooms in the middle of this. But we want a new room for each unique Door that’s created, and this code creates a single door for every connection, and each of the two connected rooms gets that instance.
So, what if after all this exciting recursion and door creation is over, we loop over all the doors and convert them to rooms? That seems something we could do right now, leaving the doors in place. We might change how they draw themselves just to see what we get.
Now all this room jostling happens while we’re drawing, mostly because it is fun to watch. The code is called from Main:draw
and it looks like this:
function adjustPositions()
if AdjustCount > 0 then
if ElapsedTime - AdjustTime > AdjustDelay then
AdjustTime = ElapsedTime
AdjustCount = Room:adjustAll(Rooms)
if AdjustCount == 0 then
Room:setAllToInteger(Rooms)
Room:createFloors(Rooms)
Rooms[1]:colorNeighborsIn(Rooms, color(255,0,0,100))
end
end
end
end
We want to create rooms from doors after the colorNeighborsIn
function returns. Let’s just do that. For now, I’m going to write it as a free-standing function. I’m not sure if this is a room function, a door function, or what.
function createRoomsFromDoors()
for k,door in pairs(AllDoors) do
door:createRoom()
end
end
Things are so easy when you can just pass them off to someone else. I start this way:
function Door:createRoom()
local x1,y1 = self:getDoorCoordinates(self.room1)
local x2,y2 = self:getDoorCoordinates(self.room2)
end
Now either x1==x2 or y1==y2, because of how doors work. But I’m thinking I’d better TDD this, it’s a tiny bit tricky, plus it’s the thing to do if I don’t want my friends to mock me.
We have the convenient method Door:fromRooms
, which is a test-only creator and it’s just the thing we need. Probably.
I got this error running the tests:
15: Distant rooms aren't neighbors -- Actual: 666->666 n, Expected: nil
That may be due to my change of the distance to allow up to 30. Let’s check:
_:test("Distant rooms aren't neighbors", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(211,124, 311, 144)
local door = Door:fromRooms(r1,r2)
_:expect(door).is(nil)
end)
Yes, this room is only 11 away. Let’s move it to 31 away.
_:test("Distant rooms aren't neighbors", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(231,124, 331, 144)
local door = Door:fromRooms(r1,r2)
_:expect(door).is(nil)
end)
That works. My new test-in-progress is this, so far:
_:test("Rooms from doors", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(210,125, 310, 175)
local d = Door:fromRooms(r1,r2)
local x,y = d:getDoorCoordinates(r1)
_:expect(x).is(200)
_:expect(y).is(150)
end)
OK, we found a door. Now let’s posit our createRoom
function and see what it should return. These rooms are connected by a horizontal path, so we can and should check the other coordinates:
_:test("Rooms from doors", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(210,125, 310, 175)
local d = Door:fromRooms(r1,r2)
local x1,y1 = d:getDoorCoordinates(r1)
_:expect(x1).is(200)
_:expect(y1).is(150)
local x2,y2 = d:getDoorCoordinates(r2)
_:expect(x2).is(210)
_:expect(y2).is(y1)
end)
Note that I renamed my x and y to x1 y1 for symmetry, and I am expecting y2==y1, not just 150. This is intended to make more clear that I do expect the two to be equal.
Now let’s decide about the room. How wide should we make it? Let’s try 8 for now. But first this:
_:test("Rooms from doors", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(210,125, 310, 175)
local d = Door:fromRooms(r1,r2)
local x1,y1 = d:getDoorCoordinates(r1)
_:expect(x1).is(200)
_:expect(y1).is(150)
local x2,y2 = d:getDoorCoordinates(r2)
_:expect(x2).is(210)
_:expect(y2).is(y1)
local newRoom = d:createRoom()
local nx1,ny1,nx2,ny2 = newRoom:corners()
_:expect(nx1).is(201)
_:expect(nx2).is(209)
end)
I guess the new room should start just to the right of the left room and end just to the left of the right room. Right?
When we run this we crash because we’re not returning a room yet.
23: Rooms from doors -- Tests:263: attempt to index a nil value (local 'newRoom')
No surprise there. Now:
function Door:createRoom()
local x1,y1 = self:getDoorCoordinates(self.room1)
local x2,y2 = self:getDoorCoordinates(self.room2)
if x2 < x1 then
x1,x2 = x2,x1
y1,y2 = y2,y1
end
return Room:fromCorners(x1+1,y1,x2-1,y2)
end
This, I expect, returns a zero-width room, but I rather hope the coordinates are right. Hope is not a strategy, but between the complexity of this and the fact that my dear wife is bringing me things to eat and drink, I’m at the limit of my juggling capacity. Run the test.
It passes. Amazing. Now what should the y’s be? I’ll start with +/-4 but I think that’s questionable.
local nx1,ny1,nx2,ny2 = newRoom:corners()
_:expect(nx1).is(201)
_:expect(nx2).is(209)
_:expect(ny1).is(146)
_:expect(ny2).is(154)
As expected so far:
23: Rooms from doors -- Actual: 150.0, Expected: 146
23: Rooms from doors -- Actual: 150.0, Expected: 154
I’m worried about whether that makes the room 8 or nine wide. Frankly, I don’t care. Let’s make it work and move on.
function Door:createRoom()
local roomHalfWidth = 4
local x1,y1 = self:getDoorCoordinates(self.room1)
local x2,y2 = self:getDoorCoordinates(self.room2)
if x2 < x1 then
x1,x2 = x2,x1
y1,y2 = y2,y1
end
y1 = y1 - roomHalfWidth
y2 = y2 + roomHalfWidth
return Room:fromCorners(x1+1,y1,x2-1,y2)
end
Test runs. Woot. Now for a vertical case. That will drive out some new code here.
_:test("Rooms from doors vertical", function()
local r1 = Room:fromCorners(100,100,200,200)
local r2 = Room:fromCorners(125,210, 175, 310)
local d = Door:fromRooms(r1,r2)
local x1,y1 = d:getDoorCoordinates(r1)
_:expect(x1).is(150)
_:expect(y1).is(200)
local x2,y2 = d:getDoorCoordinates(r2)
_:expect(x2).is(x1)
_:expect(y2).is(210)
local newRoom = d:createRoom()
local nx1,ny1,nx2,ny2 = newRoom:corners()
_:expect(nx1).is(146)
_:expect(nx2).is(154)
_:expect(ny1).is(201)
_:expect(ny2).is(209)
end)
As always, I don’t trust my arithmetic but I think this is good. I think it should succeed down to the nx1 test and then fail.
24: Rooms from doors vertical -- Actual: 151.0, Expected: 146
Right. I think in the createRoom function we need to distinguish horizontal and vertical rooms. Let’s try:
function Door:createRoom()
local roomHalfWidth = 4
local x1,y1 = self:getDoorCoordinates(self.room1)
local x2,y2 = self:getDoorCoordinates(self.room2)
if y1 == y2 then -- horizontal
if x2 < x1 then
x1,x2 = x2,x1
y1,y2 = y2,y1
end
y1 = y1 - roomHalfWidth
y2 = y2 + roomHalfWidth
return Room:fromCorners(x1+1,y1,x2-1,y2)
else
if y2 < y1 then
x1,x2 = x2,x1
y1,y2 = y2,y1
end
x1 = x1 - roomHalfWidth
x2 = x2 + roomHalfWidth
return Room:fromCorners(x1,y1+1,x2,y2-1)
end
end
Mysteriously, this works.
So, I think we are creating some really cool rooms. Let’s see about putting them into the room collection as planned. We’re going to have to force in the room numbers, though, which is irritating.
The good news is that Rooms is an integer-indexed table. So …
function createRoomsFromDoors()
for k,door in pairs(AllDoors) do
local room = door:createRoom()
room.number = #Rooms+1
table.insert(Rooms,room)
end
end
And I added in flooring:
function Room:fromCorners(xSW, ySW, xNE, yNE)
local cx = (xSW+xNE)/2
local cy = (ySW+yNE)/2
local w = (xNE-xSW)
local h = (yNE-ySW)
local r = Room(cx,cy,w,h)
r:createFloor()
return r
end
And look what we get:
One more tweak, to change how Doors are drawn (by rooms):
function Room:drawDoors()
pushMatrix()
pushStyle()
fill(255)
stroke(255)
rectMode(CENTER)
for i,door in pairs(self.doors) do
x,y = door:getDoorCoordinates(self)
rect(x,y,10,10)
end
popStyle()
popMatrix()
end
Let’s make a sort of vague grey blob instead of the white rectangle.
That looks nearly good. Tests are green. Commit: new rooms between rooms where doors are indicated.
Good place to stop. A quick summing up:
Summing Up
We see here the value of a pair or other partner in programming. They have ideas that your standard rubber duck may not have. And often they are good ideas, and often they lead to even better ideas.
I can tell this is going to be better overall than it was.
There’s a lot to do, to draw the rooms better and to figure out how to manage the transitions between them. We’ll talk about that, and deal with it, in future articles.
For now, thanks for tuning in, and thanks Andrew, for getting me out of the hole I was digging.