Dungeon 18
I guess I should work on putting a player in the room and moving her about.
Just riffing here, how about if for now our player is a global object that knows, i don’t know, a room and its coordinates in the room. Rooms have global position at present, so we could use that, or a room-relative position. I suspect we’ll want things to be room-relative by the time we get anywhere, but we’ll see.
In fact, I’m glad we had this little chat, I’m going to start right off room relative.
Let’s create a player at the center of Room 1.
function setup()
if CodeaUnit then
codeaTestsVisible(true)
runCodeaUnitTests()
end
Rooms = Room:createRooms(50)
AllDoors = {}
AdjustTime = ElapsedTime
AdjustDelay = 0.00
AdjustCount = 1
DisplayExpanded = false
Avatar = Player(1,0,0)
local seed = math.random(1234567)
--seed = 1028003
math.randomseed(seed)
print(seed)
end
I created Avatar
, a Player
. The class is this:
Player = class()
function Player:init(aRoomNumber, x, y)
self.room = Rooms[aRoomNumber]
self.x = x
self.y = y
end
function Player:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
translate(self.room.x,self.room.y)
scale(1/8)
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,0,0)
popStyle()
popMatrix()
end
This gives me a princess in the middle of the room:
I had hoped to be able to use the keyboard arrow keys to move the princess, but no luck on that. Rather than create a GUI, I think I’ll make WASD work first. In Main:
function keyboard(key)
Avatar:keyDown(key)
end
And in Player:
function Player:keyDown(aKey)
if aKey == "w" then self:move(0,1)
elseif aKey == "a" then self:move(-1,0)
elseif aKey == "s" then self:move(0,-1)
elseif aKey == "d" then self:move(1,0)
end
end
function Player:move(x,y)
self.x = self.x + x
self.y = self.y + y
end
This lets me move the princess around:
We probably want to keep her inside the room.
function Player:move(x,y)
self.x = self.x + x
self.y = self.y + y
self:stayInRoom()
end
function Player:stayInRoom()
local w = self.room.w/2
local h = self.room.h/2
self.x = stayBetween(-w + 5, self.x, w - 5)
self.y = stayBetween(-h + 5, self.y, h - 5)
end
function stayBetween(lo,val,hi)
local r = math.max(math.min(val,hi), lo)
return r
end
This does the job. In doing it, we discover that the princess’s center isn’t where we might expect it: she has a lot more bits below than above, as we can see in this pic of her near the edge, with a white circle drawn at her center:
We’ll want to bias the stayBetween
check by her offset from center, but that’s for later.
Commit: princess can move about room 1.
And, magically, it’s Tuesday
Yesterday got weird. I got started late and then someone reported a problem with Space Invaders on the latest version of Codea. I chased that and identified an actual defect in the current release, a very rare occurrence with Codea. And the defect itself was hard to identify, turning out to be related to an optimization to some pretty rarely used facilities around images. The optimization itself was probably a good idea, as it was put in to support Shade, Codea’s amazing shader-building tool. But, like all optimizations, it must have been tricky, and it optimized my Invader shields right out of existence.
So that derailed me and I moved on to other matters of importance, like reading and eating. So now it is Tuesday at 0745 and I’m good to go. And so is our Avatar, the princess:
She’d really like to go through that door, but she is presently confined to whatever room she’s in, because:
function Player:keyDown(aKey)
if aKey == "w" then self:move(0,1)
elseif aKey == "a" then self:move(-1,0)
elseif aKey == "s" then self:move(0,-1)
elseif aKey == "d" then self:move(1,0)
end
end
function Player:move(x,y)
self.x = self.x + x
self.y = self.y + y
self:stayInRoom()
end
function Player:stayInRoom()
local w = self.room.w/2
local h = self.room.h/2
self.x = stayBetween(-w + 5, self.x, w - 5)
self.y = stayBetween(-h + 5, self.y, h - 5)
end
function stayBetween(lo,val,hi)
local r = math.max(math.min(val,hi), lo)
return r
end
Even within the current room, we see an issue with letting her just navigate into another room. She’s already way over to the side of the screen, and if she kept going, she’d go right off. We need to do something. It could be as simple as centering whatever room she’s currently in. That might be a bit abrupt if bang, we suddenly re-center, but we could probably smooth that out if we wanted.
Optionally, we could center on the Avatar, moving the room instead. That would probably look odd, but might be OK. I may well try that just to see. Or we could do both, sort of.
There’s a simple dungeon demo program for Codea that lets its player move around on the screen like our Princess does, until the player begins to move outside a fairly large rectangular area of the screen, at which point the screen scrolls instead of the player moving. That sounds weird but it seems to work.
Let’s begin by looking at how we manage the screen center. We have this function in Main:
function focusOnRoomOne()
scale(4)
local appliedScale = modelMatrix()[1]
local w = WIDTH/2/appliedScale
local h = HEIGHT/2/appliedScale
translate(w-Rooms[1].x, h-Rooms[1].y)
end
That’s called in Main:draw
:
function draw()
if DisplayExpanded then
focusOnRoomOne()
end
drawDungeonCreation()
end
I reckon that if we accessed the Avatar there in the translate, we’d scroll the screen to keep her centered. Let’s try it. Now, her x and y are local to the room, so we should be able to just plug them into the translate like this:
function focusOnRoomOne()
scale(4)
local appliedScale = modelMatrix()[1]
local w = WIDTH/2/appliedScale
local h = HEIGHT/2/appliedScale
translate(w-Rooms[1].x-Avatar.x, h-Rooms[1].y-Avatar.y)
end
Wow, that actually works as I anticipated:
The motion is a bit jerky, because I’m using the keyboard, and Codea’s keyboard handling is rudimentary, not allowing for key repeat and such. If and when we build an on-screen travel pad, we should be able to smooth that out.
Let’s try one other thing, using the Avatar’s room number instead of 1, and start her in some other room, like 2.
function focusOnRoomOne()
scale(4)
local appliedScale = modelMatrix()[1]
local w = WIDTH/2/appliedScale
local h = HEIGHT/2/appliedScale
translate(w-Avatar.room.x-Avatar.x, h-Avatar.room.y-Avatar.y)
end
And in Main:setup
:
Avatar = Player(2,0,0)
I’m not sure how we’ll know she’s in room 2. I’ll just patch rooms to display room number for a moment, and sure enough she’s in Room 2:
I think I’ll leave that patch in, but move her back to room 1 and commit: screen scrolls around player.
Now maybe some thinking …
I feel the need to focus a bit, on the purpose of this exercise. It started when our little Zoom Ensemble began working on another member’s dungeon game, and the problems seemed interesting enough to play with. At least one other Ensemble member has also gotten the bug. But it’s time to think a bit about where this effort is going.
I started doing old-fashioned games just to entertain myself, beginning with Spacewar, which I think I’ve done three times for Codea. The more recent Asteroids and Space Invaders were much the same. And of course my hope for the articles is that they will illuminate my thought process, my good and bad habits, and perhaps encourage readers to think about their own processes and habits, to some benefit.
The question in my mind right now is how long this exercise will be sufficiently fun, sufficiently interesting, sufficiently educational to push forward with it. I’m pretty sure I’ll get bored long before it is polished enough to be a real game, because there is a lot of drudgery to making a real program, and I get bored with drudgery quite quickly. When the learning and discoveries get spare enough, it’s time for me to move on. I’m not working for a living, I’m playing as part of my life, so I have that luxury.
Right now there are quite a few things we could add to our game:
- Moving to another room would be nice, since we’re locked in whatever room we start in;
- Treasures would be nice, since no one travels a dungeon for fun;
- Monsters would add to the spice, since no dungeon is without its dangers;
- Traps are always good;
- Puzzles could add interest to our dungeon experience.
I’m sure there are more. Puzzles could be simple, find the key to open the door kind of thing, or could be as complicated as we cared to code up. The biggest issue with puzzles in a real game is having enough of them, especially since each one probably has to be hand-crafted. It would be pretty boring to have to solve 15 “arrange these letters to spell the magic word” puzzles, so we probably can’t use them over and over.
The big issue I see with adding most of these complications is how we interact with the game. As I mentioned, Codea’s keyboard handling is pretty rudimentary, although it is at least possible to pop up the keyboard and let the user type in a string. So a command like “drop rod” would at least be possible. Or there could be a touch menu on the screen, or the player could touch things to interact with them.
Hm, I suppose that if we wanted a special puzzle, the player could touch the magic door and we’d zoom in on the combination lock or whatever. That would certainly hand us a bunch of irritating problems to solve.
I’ll reflect on that while I go for chai. Wait right here.
There, that didn’t take long, did it?
So I guess there are potentially fun things to do here. We’ll press on and try to find some of them.
First thing …
I reckon the first thing to do is to allow the player to move between rooms. That shouldn’t be profoundly difficult, but I bet we find some interesting issues, even though I’d rather it just went perfectly.
I think we want the game’s behavior to focus more and more on the Player object, giving it behavior to support the human player’s focus and desires. What this means to me is, roughly, that we’ll code the Player to “do” things, rather than have things done to it. That’s usually a better design in any case and certainly here. We have a good start on that with our keyboard:
function keyboard(key)
Avatar:keyDown(key)
end
I suspect we’ll want to do the same thing with touched
, which isn’t that way now:
function touched(touch)
if touch.state == ENDED then
DisplayExpanded = not DisplayExpanded
end
end
Let’s move that logic right now, even though it isn’t terribly player oriented.
function touched(touch)
Avatar:touched(touch)
end
function Player:touched(touch)
if touch.state == ENDED then
DisplayExpanded = not DisplayExpanded
end
end
Commit: move touch handling to Player.
Now let’s see if we can get the player into a new room. Right now, the moving code is kind of passive in style, moving the Player instead of letting her move herself:
function Player:keyDown(aKey)
if aKey == "w" then self:move(0,1)
elseif aKey == "a" then self:move(-1,0)
elseif aKey == "s" then self:move(0,-1)
elseif aKey == "d" then self:move(1,0)
end
end
function Player:move(x,y)
self.x = self.x + x
self.y = self.y + y
self:stayInRoom()
end
function Player:stayInRoom()
local w = self.room.w/2
local h = self.room.h/2
self.x = stayBetween(-w + 5, self.x, w - 5)
self.y = stayBetween(-h + 5, self.y, h - 5)
end
function stayBetween(lo,val,hi)
local r = math.max(math.min(val,hi), lo)
return r
end
Well, maybe I just felt passive when I wrote it. We could consider this as the Player “self” moving and such. But let’s try to think of a movement “story” to implement.
The player tries to move to a new location in some direction. If there’s no obstacle, she moves to the new location. If there is an obstacle, what happens depends on the obstacle. If it’s a wall, she just can’t move. If it’s an interactive object, the object might do something (a box might open, a key might move to the Player’s inventory, etc).
If the Player tries to move into a doorway, she can do so. If she moves past the outer edge of the room she’s in, she’ll be placed on the corresponding outer edge of the room the door leads to, and will thereafter be in the new room rather than the old one.
We’ll have to be a little careful here, because there can be gaps between rooms. I wrote the story above to essentially teleport the Player across any gap, putting her at the very edge of the new room.
We may have an issue before us, of determining what wall position the player should be placed on. I’m sure that the Doors know enough for us to figure that out, but I don’t remember enough to be sure how it’ll be done.
So when we move, before we deal with crashing into walls, we’ll need to determine whether the Player is near enough to a door to be allowed to go through it. I’m worried about dealing with all the up down right left of doors here. Maybe we can just finesse that and allow free movement if you’re near a door.
I’m going to sketch something into move
. It starts like this:
function Player:move(x,y)
self.x = self.x + x
self.y = self.y + y
self:stayInRoom()
end
Instead, how about …
function Player:move(x,y)
local proposedX = self.x + x
local proposedY = self.y + y
self.x, self.y = self:legalMoveTo(x,y)
end
Now instead of the stayInRoom
function, which basically forced us to stay put (passive), we take a more active view of our move, transforming it from a proposed move to a “legal” move.
We can first replicate our current behavior this way:
function Player:legalMoveTo(x,y)
return self:stayInRoom(proposedX,proposedY)
end
function Player:stayInRoom(x,y)
local w = self.room.w/2
local h = self.room.h/2
local x = stayBetween(-w + 5, x, w - 5)
local y = stayBetween(-h + 5, y, h - 5)
return x,y
end
So far so good. Commit: refactor room motion to “legalMoveTo” idea.
You may be wondering whether that was in fact a refactoring: legalMoveTo
sure looks like new functionality. But it isn’t. Program behavior, for now, is exactly as it was. The difference is how it’s done. Now we have a place to allow more things to happen. Next step will be implementation, not refactoring.
I’m thinking now, something like this:
Well, in
legalMoveTo
, I could check whether we’re near a doorway, and just allow any move. That would be mostly harmless. Even if she did move sideways into a wall, her next move would put her back in the room. So that might be an early check for the room change. But I rather hate using if statements, despite how many I wind up using. I wonder if there’s a better way.
Ah … how about the notion of an accessible point within a room? A point is accessible if its inside the walls, or inside a doorway. That sounds interesting, let’s try it.
function Player:legalMoveTo(x,y)
return self:accessiblePoint(x,y)
end
That’s not much progress, but it inserts something between legal move and stay in room. Now what if …
But wait … we’re getting rather feature envious here in Player
. We should be getting the room to do these things for us.
Let’s see if we can move some behavior before we do this. I’ll back out the accessiblePoint thing, back to:
function Player:legalMoveTo(x,y)
return self:stayInRoom(x,y)
end
Let’s zoom out and look at a bit more code:
function Player:move(x,y)
local proposedX = self.x + x
local proposedY = self.y + y
self.x, self.y = self:legalMoveTo(proposedX,proposedY)
end
function Player:legalMoveTo(x,y)
return self:stayInRoom(x,y)
end
function Player:stayInRoom(x,y)
local w = self.room.w/2
local h = self.room.h/2
local x = stayBetween(-w + 5, x, w - 5)
local y = stayBetween(-h + 5, y, h - 5)
return x,y
end
I think I’m OK with Player knowing her room-centric coordiinates. Her x and y are local to room center. But she should ask the Room for a legal move determination.
I’m on a fresh commit, so I have a good revert point if this goes wrong. But why should it, he asked naively.
function Player:move(x,y)
local proposedX = self.x + x
local proposedY = self.y + y
self.x, self.y = self.room:legalMoveTo(proposedX,proposedY)
end
We delegate to room. Room knows nothing about this, so we move our existing code:
function Room:legalMoveTo(x,y)
return self:stayInRoom(x,y)
end
function Room:stayInRoom(x,y)
local w = self.w/2
local h = self.h/2
local x = stayBetween(-w + 5, x, w - 5)
local y = stayBetween(-h + 5, y, h - 5)
return x,y
end
I notice while doing this that Room
is getting very messy. It’s up to 155 lines now, and only the tests are longer. (That makes me feel somewhat good, though.) We’ll try to take a look at Room and see what needs to be split off. There’s probably a lot.
Now let’s commit: refactor room limits to Room from Player.
Now let’s see if we can make that legalMoveTo
allow a move into a doorway. We are fortunate enough to have the doors at our disposal.
The Door
object knows two rooms, and whether or not it is primary. And it knows–can compute–the coordinates of the door in the room, using a rather complicated method. It doesn’t cache those values, but that’s a matter of optimization.
Generally speaking, the door coordinates are a point along one wall, marking the center of the doorway. We’re presently using a doorwidth of 10, which means that if the Avatar is within 5 of the doorway, we can consider her to be in the doorway. So maybe …
We have this “stayInRoom” function:
function Room:stayInRoom(x,y)
local w = self.w/2
local h = self.h/2
local x = stayBetween(-w + 5, x, w - 5)
local y = stayBetween(-h + 5, y, h - 5)
return x,y
end
We could extend that to pass in low and high limits, if it would be helpful. For example, if the Princess is within 5 pixels of a door at dx,dy, instead of being limited to w-5, h-5 on that edge, she could be limited to w and h.
So, hm, thinking, what if we had a function roomLimits
, that returns the x and y limits within which the Princess can move given that she’s at some particular point?
Or … imagine a set of rectangles. One is the room’s rectangle with the 5 pixel wall limit. Other rectangles represent the doors, five pixels around the door center. If the Princess is trying to move into any of these rectangles, her move is legal. We don’t even need to know which option it is … yet.
Hm, I think I like how that feels. We do have some rectangle-oriented code already: the rooms check to see if their rectangles intersect while we’re jostling. So maybe there’s a call already for a Rectangle object. We’ll worry about that aspect later, but I think I’ll TDD a rectangle object that answers whether a point is within it.
I feel some boolean logic coming on, and that’s bugging me. But let’s push this direction for a while and see how it turns out.
_:test("rectangle 100,100,48,48 contains 100,100", function()
local r = Rectangle(100,100,48,48)
_:expect(r:contains(100,100)).is(true)
end)
That’ll drive out this code:
~~~lua– Rectangle – RJ 20201124
Rectangle = class()
function Rectangle:init(centerX,centerY, width,height) local x = centerX local y = centerY local w = width local h = height end
function Rectangle:contains(x,y) return true end
I expect the test to pass, unless, as often happens, I've messed up. Mysteriously, it does pass.
Let's drive out some actual behavior:
~~~lua
_:test("rectangle 100,100,48,48 contains 100,100", function()
local r = Rectangle(100,100,48,48)
_:expect(r:contains(100,100)).is(true)
_:expect(r:contains(150,150)).is(false)
end)
Shall I play it straight here? I think I will, just to see what happens. “Obviously” I could jump to the full implementation right now.
OK, well, why did I store the member variables in local
? Here’s a better cut, including the check we need for the current test:
function Rectangle:init(centerX,centerY, width,height)
self.x = centerX
self.y = centerY
self.w = width
self.h = height
end
function Rectangle:contains(x,y)
return x > self.x + self.w/2 or y > self.y + self.h/2
end
Except that the comparisons are reversed. We’re doing contains, not outside.
function Rectangle:contains(x,y)
return x <= self.x + self.w/2 or y <= self.y + self.h/2
end
Test runs. Well done, Ron. Aren’t you glad you tested this?
Let’s drive out the rest and check some edge cases (literally).
_:test("rectangle 100,100,48,48 contains 100,100", function()
local r = Rectangle(100,100,48,48)
_:expect(r:contains(100,100)).is(true)
_:expect(r:contains(149,149)).is(false)
_:expect(r:contains(75,75)).is(false)
end)
By the way, I noticed that the code above is wrong. When I changed my thinking from outside to inside, I needed to change from or
to and
. I think I’ve fixed it but let’s write a bunch more expectations.
I separated the tests out a bit, and the results here surprised me:
_:test("rectangle 100,100,48,48 doesn't contain 75,75", function()
local r = Rectangle(100,100,48,48)
_:expect(r:contains(75,75)).is(false)
end)
_:test("rectangle 100,100,48,48 contains 76,76", function()
local r = Rectangle(100,100,76,76)
_:expect(r:contains(75,75, "75 75")).is(true)
_:expect(r:contains(75,76), "75 76").is(false)
_:expect(r:contains(76,75), "76 75").is(false)
end)
The rectangle specified goes from 76,76 to 124,124, if I’m not mistaken. So the first test fails. I expect that if either value is 75, the second test will fail, thus I expect true, false, false. But here are my results:
22: rectangle 100,100,48,48 contains 76,76 75 76 -- Actual: true, Expected: false
22: rectangle 100,100,48,48 contains 76,76 76 75 -- Actual: true, Expected: false
Why? Here’s my current implementation:
function Rectangle:contains(x,y)
return x <= self.x + self.w/2 and y <= self.y + self.h/2
and self.x - self.w/2 <= x and self.y - self.h/2 <= y
end
I’m pretty sure 48/2 is 24, so why isn’t this the case:
self.x - self.w/2 == 100-24 == 76
self.y - self.h/2 == 100-24 == 76
one of the other of x and y should be 75 if those latter two calls. 76 isn’t <= 75. What’s going on?
OK, that took longer than I’m proud of. The test somehow said to set w and h to 76. It should be:
_:test("rectangle 100,100,48,48 contains 76,76", function()
local r = Rectangle(100,100,48,48)
_:expect(r:contains(76,76, "76 76")).is(true)
_:expect(r:contains(75,76), "75 76").is(false)
_:expect(r:contains(76,75), "76 75").is(false)
end)
I really typoed the heck out of that one. However, with all that under my belt, I’m feeling good about my rectangle.
I’m also feeling like it’s time for a break for lunch, so I’ll pick this up tomorrow, or maybe this afternoon. Commit: rectangle object works.
Wednesday
0708 and time to do something to wrap this article up. I’d like to get moving between rooms working before I do that.
I’ve decided to create a new object, RoomCoordinates, that holds a room, and x,y coordinates within it. The Player will have an instance of this object to say where she is.
I think I’ll just sort of extract this new class from Player. For now, we’ll create them inside the Player init:
function Player:init(aRoomNumber, x, y)
self.room = Rooms[aRoomNumber]
self.x = x
self.y = y
end
That’ll become …
function Player:init(aRoomNumber, x, y)
self.position = RoomPosition(aRoomNumber, x, y)
end
Note that I already decided to rename the class to RoomPosition. It seemed to work better. We can rename it again if need be.
Now I’ll make the class:
RoomPosition = class()
function RoomPosition:init(aRoom, x, y)
self.room = aRoom
self.x = x
self.y = y
end
Note that I’ve changed the signature of the creation to expect a Room, not a room number. For now, I’ll change Player, but I expect we’ll push that decision higher soon.
function Player:init(aRoomNumber, x, y)
self.position = RoomPosition(Rooms[aRoomNumber], x, y)
end
Now to make Player’s methods work with the new position variable. Here’s a place where using methods to access things would have been better than accessing member variables. We’ll see how it goes.
function Player:draw()
pushMatrix()
pushStyle()
stroke(255)
fill(255)
ellipseMode(CENTER)
spriteMode(CENTER)
translate(self.room.x + self.x,self.room.y + self.y)
scale(1/8)
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,0,0)
ellipse(0,0,20)
popStyle()
popMatrix()
end
Here we want the Player’s full coordinates room-centered. We’ll forward to our position:
function Player:draw()
pushMatrix()
pushStyle()
stroke(255)
fill(255)
ellipseMode(CENTER)
spriteMode(CENTER)
local x,y = self.position:screenLocation()
translate(x ,y)
scale(1/8)
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,0,0)
ellipse(0,0,20)
popStyle()
popMatrix()
end
With the corresponding:
function RoomPosition:screenLocation()
return self.room.x + self.x, self.room.y + self.y
end
At this point I’m wondering if the game will at least display the princess. So I’ll run it and see what explodes.
Main:54: attempt to index a nil value (field 'room')
stack traceback:
Main:54: in function 'focusOnRoomOne'
Main:29: in function 'draw'
That function looks like this:
function focusOnRoomOne()
scale(4)
local appliedScale = modelMatrix()[1]
local w = WIDTH/2/appliedScale
local h = HEIGHT/2/appliedScale
--translate(w-Rooms[1].x, h-Rooms[1].y)
translate(w-Avatar.room.x-Avatar.x, h-Avatar.room.y-Avatar.y)
end
(I’ve saved the old translate statement out of fear. A more courageous man would delete it.) Anyway … looks like we just need the Player’s position in there:
function focusOnRoomOne()
scale(4)
local appliedScale = modelMatrix()[1]
local w = WIDTH/2/appliedScale
local h = HEIGHT/2/appliedScale
--translate(w-Rooms[1].x, h-Rooms[1].y)
local x,y = Avatar:screenCoordinates()
translate(w-x, h-y)
end
I am a little worried about that. We’ll see. We need to put screenCoordinates
onto Player, forwarding:
function Player:screenLocation()
return self.position:screenLocation()
end
The word is “location”, not coordinates”. Change Main accordingly.
While I was looking at Player, I noticed some code needing changing to the new scheme, so let’s do it:
function Player:move(x,y)
local proposedX = self.x + x
local proposedY = self.y + y
self.x, self.y = self.room:legalMoveTo(proposedX,proposedY)
end
Now x and y here are really changes in x and y. Rename:
function Player:move(deltaX,deltaY)
local proposedX = self.x + deltaX
local proposedY = self.y + deltaY
self.x, self.y = self.room:legalMoveTo(proposedX,proposedY)
end
But this just wants to be forwarded to room anyway, so let’s just remove this method and call room’s method from this code:
function Player:keyDown(aKey)
local step = 2
if aKey == "w" then self:move(0,step)
elseif aKey == "a" then self:move(-step,0)
elseif aKey == "s" then self:move(0,-step)
elseif aKey == "d" then self:move(step,0)
end
end
That becomes:
function Player:keyDown(aKey)
local step = 2
if aKey == "w" then self.position:move(0,step)
elseif aKey == "a" then self.position:move(-step,0)
elseif aKey == "s" then self.position:move(0,-step)
elseif aKey == "d" then self.position:move(step,0)
end
end
Which we receive gratefully thus:
function RoomPosition:move(deltaX,deltaY)
local proposedX = self.x + deltaX
local proposedY = self.y + deltaY
self.x, self.y = self.room:legalMoveTo(proposedX,proposedY)
end
Now I really do expect things to work again. Perhaps surprisingly, things do work again. Here we are in room one, and we can move around.
Commit: added RoomPosition object.
Now, I think the point of all this was to go through doors.
Doors
The idea I have is roughly this. Each room has its own coordinates, and it has its collection of Doors. Each Rood connects two rooms, the one we’re in and the other one, and can produce the room-centered coordinates of the middle of the door, which is always along some edge of the room.
I don’t see the whole shape of the idea yet, but the notion is that when we do the legalMoveTo
operation, we’ll pass in room-centered coordinates (or maybe a RoomPosition would be better) and the Room will check with its doors to see if the position is near enough to one of them. If the position isn’t in a doorway, then it’ll check the room, and be sure the proposed coordinates are within the room. The legalMoveTo
function returns a legal room position, perhaps clamping the coordinates proposed into the room’s bounds. That’s what it does now, like this:
function Room:legalMoveTo(x,y)
return self:stayInRoom(x,y)
end
function Room:stayInRoom(x,y)
local w = self.w/2
local h = self.h/2
local x = stayBetween(-w + 5, x, w - 5)
local y = stayBetween(-h + 5, y, h - 5)
return x,y
end
function stayBetween(lo,val,hi)
local r = math.max(math.min(val,hi), lo)
return r
end
That last function is properly named clamp
, by the way, and I might just do that one of these days.
So the first effect I’d like to get is that when the Avatar is within, say, 5 of a door, she can move freely, clear to the edge, instead of being clamped inside the 5 pixel boundary. That will be enough to let me see that the door is detecting the Avatar. Right now, if she tries to go through the door, she cannot:
As you can see, she is balked from entering the door further, because she has reached the wall edge. When this next step works, she’ll be able to walk on through, at least to room edge, and maybe further. Let’s try.
The obvious way to do this is with a loop and some if statements. I think for now, I’ll do that. I’m also getting more nervous about not passing the RoomPosition into that function. Let’s do that … it’s probably closer to the right thing than passing raw x’s and y’s all around.
function RoomPosition:move(deltaX,deltaY)
local proposedX = self.x + deltaX
local proposedY = self.y + deltaY
self.x, self.y = self.room:legalMoveTo(RoomPosition(self.room,proposedX,proposedY)
end
We create a new RoomPosition. This should turn out to be a value object that never gets modified once created, though we are not there yet. We pass it to the room’s method …
OK, this gets a bit weird:
function Room:legalMoveTo(aRoomPosition)
return self:stayInRoom(aRoomPosition)
end
function Room:stayInRoom(aRoomPosition)
local w = self.w/2
local h = self.h/2
return aRoomPosition:clamp(w,h,5)
end
I’ve decided that its the job of the RoomPosition to clamp itself. I’m giving that method my halved w and h, and the extra boundary.
So … this is probably nearly right:
function Room:legalMoveTo(aRoomPosition)
return self:stayInRoom(aRoomPosition)
end
function Room:stayInRoom(aRoomPosition)
local w = self.w/2
local h = self.h/2
return aRoomPosition:clamp(w,h,5)
end
function RoomPosition:clamp(halfW,halfH, wall)
local lox = -halfW+wall
local hix = halfW-wall
local loy = -halfH+wall
local hiy = halfH-wall
local x = clamp(lox,self.x,hix)
local y = clamp(loy,self.y,hiy)
return RoomPosition(self.room,x,y)
end
function clamp(lo,val,hi)
return math.max(math.min(val,hi), lo)
end
Room just provides its half width and height and the wall thickness, to RoomPosition:clamp(), which expands the values for two clamp calls, clamps the values, and returns a new RoomPosition.
However, we need to actually use that new position, I think. This function:
function RoomPosition:move(deltaX,deltaY)
local proposedX = self.x + deltaX
local proposedY = self.y + deltaY
self.x, self.y = self.room:legalMoveTo(RoomPosition(self.room,proposedX,proposedY))
end
We’re returning a RoomPosition here now, so we need to pass it all the way back, so I think we need to change this:
function Player:keyDown(aKey)
local step = 2
if aKey == "w" then self.position:move(0,step)
elseif aKey == "a" then self.position:move(-step,0)
elseif aKey == "s" then self.position:move(0,-step)
elseif aKey == "d" then self.position:move(step,0)
end
end
To this:
function Player:keyDown(aKey)
local step = 2
if aKey == "w" then self.position = self.position:move(0,step)
elseif aKey == "a" then self.position = self.position:move(-step,0)
elseif aKey == "s" then self.position = self.position:move(0,-step)
elseif aKey == "d" then self.position = self.position:move(step,0)
end
end
All this actually works. Commit: RoomPosition integrated with Room and Player.
And it’s 0808 and time for a chai run. And it’s 0838 and I’m back.
This arrangement of code is rather interesting and may strike you as odd. Let’s talk about it as it stands so far.
Room, Player, RoomPosition
We have an interesting interplay between these three objects. I expect we’ll find some rough edges, but I think it’s close to what I want.
The RoomPosition object is intended to be a value object–an unchanging object–that represents the position of something in the dungeon. It has a Room, and an x,y position inside the room. We don’t change these objects. If a Player or some other yet to be defined object “moves”, it gets a new RoomPosition.
When the Player wants to move, she produces a new RoomPosition by asking her current position to move (which will create a new position. So she stores it back:
function Player:keyDown(aKey)
local step = 2
if aKey == "w" then self.position = self.position:move(0,step)
elseif aKey == "a" then self.position = self.position:move(-step,0)
elseif aKey == "s" then self.position = self.position:move(0,-step)
elseif aKey == "d" then self.position = self.position:move(step,0)
end
end
Now already we don’t like this much, because of the duplication of self.position = self.position:move(
, but we’ll let that be for now. So Player asks the RoomPosition for a new position, moved by some amount. That goes here:
function RoomPosition:move(deltaX,deltaY)
local proposedX = self.x + deltaX
local proposedY = self.y + deltaY
return self.room:legalMoveTo(RoomPosition(self.room,proposedX,proposedY))
end
We create a new RoomPosition as requested by the Player. But we need to know that it is a legal position: the Player can’t go beyond the walls. So we ask the room to return a legal position:
function Room:legalMoveTo(aRoomPosition)
return self:stayInRoom(aRoomPosition)
end
function Room:stayInRoom(aRoomPosition)
local w = self.w/2
local h = self.h/2
return aRoomPosition:clamp(w,h,5)
end
Right now, we just call back to the RoomPosition, asking it to clamp its values and return a new, now legal, RoomPosition, which it does:
function RoomPosition:clamp(halfW,halfH, wall)
local lox = -halfW+wall
local hix = halfW-wall
local loy = -halfH+wall
local hiy = halfH-wall
local x = clamp(lox,self.x,hix)
local y = clamp(loy,self.y,hiy)
return RoomPosition(self.room,x,y)
end
function clamp(lo,val,hi)
return math.max(math.min(val,hi), lo)
end
It seems to me that with this design, the work is pretty much done by the object that knows best how to do it. The Player knows how much she wants to move. The RoomPosition knows how to adjust her position accordingly. The Room knows how to ensure that the position is legit. And everything unwinds and the Player stores the now legitimate position.
You may find all this passing of objects around, with no one really doing anything, to be kind of odd. This is pretty nearly the ultimate of tell-don’t-ask. No one asks any questions about are you in range or are you a wall. Everyone just tells someone else what is wanted, and in the end it all comes back. At this point, the only conditional logic is in the max/min code of clamp.
At this moment, however, I’m at a bit of a loss. I’d like to continue this style to allow the legal position to fall inside a doorway, which is the first step toward going to a new Room. But it seems to me that we have some “conditions” here, and I’m not clear how to deal with them without an if kind of construct somewhere.
I think about there being a collection of Doors and the Room, or the Rectangles they imply (we do have that new unused Rectangle object that was created with this problem in mind). Given a RoomPosition, and this collection, we want something like this:
If the position is inside a doorway, we want that position as is. If it’s not inside a doorway, we want the clamped position inside the room.
I don’t see a clever way to do that. Let’s do it in a non-clever way and see what we get.
We’ll extend this method:
function Room:legalMoveTo(aRoomPosition)
return self:stayInRoom(aRoomPosition)
end
function Room:legalMoveTo(aRoomPosition)
for k,door in pairs(self.door) do
if door:contains(aRoomPosition) then
return aRoomPosition
end
end
return self:stayInRoom(aRoomPosition)
end
That’s not terribly horrible. Let’s do Door:contains()
:
function Door:contains(aRoomPosition)
local x,y = self:getDoorCoordinates(self)
local r = Rectangle(x,y,5,5)
local px,py = aRoomPosition:screenLocation()
return r:contains(px,py)
end
Mostly I don’t like this because the getDoorCoordinates
function is that massive thing that computes all kinds of adjacency. It leaves me wondering if it can possibly be right. I feel like there’s one too many objects in here. Anyway let’s run it and see.
Room:341: bad argument #1 to 'for iterator' (table expected, got nil)
stack traceback:
[C]: in function 'next'
Room:341: in function <Room:340>
(...tail calls...)
Player:31: in method 'keyDown'
Main:24: in function 'keyboard'
Hm. Room 341:
function Room:legalMoveTo(aRoomPosition)
for k,door in pairs(self.door) do
if door:contains(aRoomPosition) then
return aRoomPosition
end
end
return self:stayInRoom(aRoomPosition)
end
Typo. should be self.doors
. But now:
Door:78: attempt to call a nil value (method 'closeEnoughOnWestTo')
stack traceback:
Door:78: in method 'getDoorCoordinates'
Door:112: in method 'contains'
Room:342: in function <Room:340>
(...tail calls...)
Player:31: in method 'keyDown'
Main:24: in function 'keyboard'
Ah.
function Door:contains(aRoomPosition)
local x,y = self:getDoorCoordinates(self)
local r = Rectangle(x,y,5,5)
local px,py = aRoomPosition:screenLocation()
return r:contains(px,py)
end
We need to pass the room to the getDoorCoordinates
, not the door (self). Fortunate, aRoomPosition knows that:
function Door:contains(aRoomPosition)
local x,y = self:getDoorCoordinates(aRoomPosition.room)
local r = Rectangle(x,y,5,5)
local px,py = aRoomPosition:screenLocation()
return r:contains(px,py)
end
With that in place, the good news is that the Princess can move. The bad news is, she still can’t enter into the doorway, and I don’t know why. I think I’ll try displaying the coordinates that go into the door check.
When she’s trying to go in, we have:
rect contains 916.0 805.0 5
5 913.0 806.0
So the rectangle is at 916,805 and therefore extends from 911,800 to 921,810. Seems to me that 913,806 is inside. Further tracing needed. Here’s another run:
rect contains 823.0 557.0 5 5
820.0 559.0 = false
Now it … ah. the width and height are 5,5 so the half width/height is only 2.5. I should be passing in 10,10.
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
Now I expect she can pass through a door, unless I’m still wrong, which is always on the table. In this instance, I’m not. Here she is, in the doorway:
Commit: Princess can enter doorway.
Now it turns out that if I were to move her one more step through that doorway, she would not register as inside the doorway and she’d be moved back into the room, outside the doorway, just where we’d expect. That’s because the doors will all find her outside their ranges, and so the room clamping will pull her back.
The next step will be to detect that she is far enough through the doorway to move her to the other room. If we can do that, I think the logic will just pick up where it left off, except in the new room, and she’ll be able to traverse the maze.
There will probably be an issue with the sides, since doors do not currently know what side they are on, so once she is out in the doorway she can probably move along between the walls. But no, the clamping should move her back. I’ll check that.
Yes, that works. She can kind of fiddle around in the doorway, but if she moves outside the white square, she gets nudged back into the room.
I’m going to hold the room switch until the next article, perhaps tomorrow (which is Thanksgiving) or perhaps later in the week. For now, let’s sum up.
Summing Up
This has been a three day journey of somewhere between 4 and 6 hours of work, including article writing. Much of it has been spent creating some infrastructure, a Rectangle object, which we only used in the past few moments, and a RoomPosition object, which appeared this morning and got plugged in fairly nicely. The actual implementation that allows the princess to move into doorways was just 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
With a few new methods backing that up, like
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
Everything else was just preparation, getting the objects lined up a bit better to do the job.
We’ve drawn out some fairly decent underpinnings, with the RoomPosition keeping track of where something (like a Princess) is, and the Rectangle, which lets us refer to something a bit more capable and a bit more abstract than just the x’s and y’s we’ve been using. I think we’ll find other places where the Rectangle will be useful, and we’ll try to use it as a model for getting more of our code lifted away from the raw numbers, or “primitive obsession” as we in the trade call it.
It has gone smoothly, the breakages were mostly typos and a couple of missed conversions from old form to new.
I’m calling it good for now, and wish you all a happy and safe holiday, if you’re holidaying, and happy and safe random days otherwise. Stay safe.