Dungeon 14
There’s a bit of tindying up I’d like to do, but I think we have to start making this act more like a game.
I really enjoy working with code like what we have in our D1 dungeon program. There’s something about working on data structures and getting the objects more nearly right that appeals to me. And I do think it pays off to become accomplished at this kind of programming–at least for some programmers. With more and more data stored in databases whose structure is “given”, a lot of programming these days turns into screen-scraping followed by a few database calls, followed by spraying some data back onto the screen.
That’s certainly not what we’re doing here in my little Codea world, which may be why the readership of these articles is um limited. (I can imagine other reasons for that. Don’t look at me!)
The games I’ve programmed here, Spacewar, Asteroiods, Space Invaders, and now this one, are all very small simple examples of games compared to what’s out there today. The first three, however, are quite close to the original games of the 1980-90 time frame. Things have moved along.
Dungeon games, even very simple ones, are perhaps the first games that I’d call “content driven”. They tell a story, and the player gets to be part of that story and to shape how that story goes. The earliest of these, like Colossal Cave and Zork, were text only. “You are in a huge room populated entirely by bats. There is something strange all over the floor.”
Later games added in graphics, the 2D graphics we’ll be using here, then more and more elaborate graphics and puzzles. Those more elaborate games are built up of far more than code. If you look at a game like Myst, you can easily see that there’s a vast amount of graphical art in the game. The buildings and trees and such are probably mostly made of pretty simple code, but the art work … the art work goes on forever.
I’m not qualified to create that kind of art, nor am I interested in doing so. (These two ideas may have some kind of causal relationship.) But we do need at least minimal art to make our game here into a game at all.
And I’m thinking it’s just about time to work on that. Let’s discuss my plans such as they are:
The “Plan”
For the first cut at this game, I propose to work from essentially the maps we have now, with rooms adjacent to each other and connected by doors. The maps look like this:
The green rooms are not adjacent–at least not adjacent enough–to other rooms. They are inaccessible, and I plan to delete them from the map before the game runs. (Possibly they could just stay there, but it seems better to me to remove them.)
Our intrepid player, represented by some sprite, will start out in some room, which we’ll draw with some kind of floor tile and wall representation that we’ll lift from Codea assets or find on the web. There are plenty to find:
My initial plan for the game is that we’ll put the player in room 1, and draw an expanded version of that room, with “stuff” in it. All the rooms will have zero or more stuff. You know the drill, keys, swords, shields, bags of M&Ms. Our intrepid hero picks up the loot, chooses a door, and goes through it. At that point, my initial plan is that we’ll just draw the new room. The player will only see one room at a time.
However, that will surely get boring quickly, and I imagine we’ll move on to showing adjacent rooms, if we’ve visited them, as many as reasonably fit on the screen.
We’ll elaborate the game until I get bored. We might have a version of the big map visible or accessible, showing only what the player has already seen … or perhaps out in the unmapped areas, showing icons of the treasures and monsters that are out there. Who knows? Not me. I’m just here for the fun of doing it.
Now in the Zoom Ensemble, the author of the game we’re working on there has taken a somewhat different approach. The dungeon has its rooms further apart, with hallways. Presently I have no hallways: rooms are directly adjacent to each other. The ZE game selects rooms to hold interesting items, based on their size, and it chooses the starting room and the “boss room” to be far apart from each other. If we have a boss room, we’ll probably want to do something similar. Certainly we’ll need some way to complete a level and go to a new one. (Will that require us to save an existing level so that the player can return? Or can he only go forward? We’ll deal with that if we get there.)
You said something about a “plan”?
Yes, right. Well, the plan for this week will be to display a room with walls and doors, and a player sprite, and allow the sprite to navigate around the map. In other words, we’re going to finally start making this look something like a game.
I mentioned some code that I’d like to tidy up. There always is some, and there is definitely some now. But let’s switch modes. Our code is what it is; we’ll clean up what needs it as we encounter it; we’ll learn when we encounter things that trouble us, and we’ll deal with them.
OK, what’s the minimal thing we could do to make this like a game? Just spitballing …
After the map creation and door making completes, automatically enter into “expanded” mode, where we have a current room number and we display the current room, expanded, on the screen. There’s some kind of mark on the screen representing the player, and some way to move it about in the room. We’ll build from there, doors etc. We’ll start the player in room 1, and for now, if room 1 turns out to have no neighbors, so be it.
Right now, our main setup and drawing code looks like this:
function setup()
if CodeaUnit then
codeaTestsVisible(true)
runCodeaUnitTests()
end
Rooms = Room:createRooms(50)
AllDoors = {}
AdjustTime = ElapsedTime
AdjustDelay = 0.00
AdjustCount = 1
end
function draw()
background(40, 40, 50)
if CodeaUnit then showCodeaUnitTests() end
pushMatrix()
pushStyle()
rectMode(CENTER)
for i,r in ipairs(Rooms) do
r:draw()
end
if AdjustCount > 0 then
if ElapsedTime - AdjustTime > AdjustDelay then
AdjustTime = ElapsedTime
AdjustCount = Room:adjustAll(Rooms)
if AdjustCount == 0 then
Room:setAllToInteger(Rooms)
Rooms[1]:colorNeighborsIn(Rooms, color(255,0,0,100))
end
end
end
for i,l in pairs(AllDoors) do
l:draw()
end
popStyle()
popMatrix()
end
I like the fact that the room adjustment and connection drawing takes place on the screen, live. That should really all be done behind the scenes, but for now we’ll leave it where it is. Except that it needs to be extracted, at least far enough to let us see what we’re doing. So:
function draw()
background(40, 40, 50)
if CodeaUnit then showCodeaUnitTests() end
drawDungeonCreation()
end
Everything that used to be in that function is now in drawDungeonCreation
. I’ll spare you the code: it is unchanged.
Now I noticed that we don’t have anything going on with screen touches at all. Let’s introduce a display mode flag that is toggled by touch:
function touched(touch)
if touch.state == ENDED then
DisplayExpanded = not DisplayExpanded
end
end
function draw()
background(40, 40, 50)
if CodeaUnit then showCodeaUnitTests() end
if DisplayExpanded then
drawExpanded()
else
drawDungeonCreation()
end
end
With initializing the flag to false, when I touch the screen, this happens:
That, of course is because of this:
function drawExpanded()
pushMatrix()
pushStyle()
fill(255,0,0)
fontSize(50)
text("YOU HAVE BEEN EATEN BY A GRUE", WIDTH/2, HEIGHT/2)
popStyle()
popMatrix()
end
Tests are good, game over. commit: Grue. Time to head out for chai … and I’m back.
The code, of course, could be improved:
function draw()
background(40, 40, 50)
if CodeaUnit then showCodeaUnitTests() end
if DisplayExpanded then
drawExpanded()
else
drawDungeonCreation()
end
end
First, I think I’ll move the unit test display into the draw creation method. And I’ll push the background statement into each of the draw functions. But there’s still this not to like:
function draw()
if DisplayExpanded then
drawExpanded()
else
drawDungeonCreation()
end
end
First of all, we were told by some people whose opinions we value that if
statements are suspect. They connote procedural code, and when we program with objects we can often eliminate conditionals to our advantage. And second, with the two draw methods being global functions, we’ve kind of set ourselves on the path of elaborating more procedural, less object-oriented code in Main.
At this moment I’m not sure what object to invoke here, so I’ll guess at an ExpandedRoom object. Let’s create one and see what we can do with it.
I’m not going to TDD this. It’s a display object, and anyway right now I don’t see anything useful to test. Instead, I’m just going to create one and call it from here:
function draw()
if DisplayExpanded then
ExpandedRoom:drawRoom()
else
drawDungeonCreation()
end
end
Is that a class or an object there? I think it’s a class. But the call above is a class method call. We’ll start with this for ExpandedRoom
:
-- ExpandedRoom
-- RJ 20201116
ExpandedRoom = class()
-- class methods
local Instance
function ExpandedRoom:drawRoom()
if not Instance then
Instance = self()
end
Instance:draw()
end
function ExpandedRoom:init(aRoom)
self.room = aRoom or Rooms[1]
end
function ExpandedRoom:draw()
background(40, 40, 50)
if CodeaUnit then showCodeaUnitTests() end
pushMatrix()
pushStyle()
fill(255,0,0)
fontSize(50)
textAlign(CENTER)
local t = string.format("YOU HAVE BEEN EATEN BY A GRUE\nIN ROOM NUMBER %d", self.room.number)
text(t, WIDTH/2, HEIGHT/2)
popStyle()
popMatrix()
end
I’m doing a kind of singleton notion here for now. I’m not sure how we should really hook this all up but that’ll do for now. Now when I touch the screen, I get this:
Hm, I thought I had moved the unit test display. Ha, yes. Pasted it into the wrong function. Moving that back to the one in Main.
OK, that’s good. Commit: Grue in Room 1.
Now let’s draw at least a rudimentary room. Here’s room creation:
function Room:init(x,y,w,h)
self.number = 666
self.color = color(0,255,0,25)
self.x = x or math.random(WIDTH//4,3*WIDTH//4)
self.y = y or math.random(HEIGHT//4,3*HEIGHT//4)
self.w = w or math.random(WIDTH//40,WIDTH//20)*2
self.h = h or math.random(HEIGHT//40,WIDTH//20)*2
self.doors = {}
end
Each room is no more than 1/10th of the screen size (2*(1/20)), So in principle we can expand the rooms10-fold. Let’s do 8-fold for now.
function ExpandedRoom:draw()
background(40, 40, 50)
pushMatrix()
pushStyle()
translate(WIDTH/2, HEIGHT/2)
scale(8)
rectMode(CENTER)
fill(229, 143, 37)
stroke(255)
strokeWidth(4)
rect(0,0,self.room.w, self.room.h)
popStyle()
popMatrix()
fill(255,0,0)
fontSize(50)
textAlign(CENTER)
local t = string.format("ROOM %d", self.room.number)
text(t, WIDTH/2, HEIGHT/2)
end
Early on, I get this picture, which seems to me to be too tall given a multiplier of 8, so I want some more info in my room text.
local t = string.format("ROOM %d\n%d x %d", self.room.number, self.room.w,self.room.h)
text(t, WIDTH/2, HEIGHT/2)
Right away I get one that goes off the screen:
It says its h is 136. 136*8 is 1088, and my screen height is 1366 x 1024. Unless I miss my guess, 1088 is larger than 1024, so obviously I don’t understand what I’m asking for in room sizes.
Oh. Ha again:
function Room:init(x,y,w,h)
self.number = 666
self.color = color(0,255,0,25)
self.x = x or math.random(WIDTH//4,3*WIDTH//4)
self.y = y or math.random(HEIGHT//4,3*HEIGHT//4)
self.w = w or math.random(WIDTH//40,WIDTH//20)*2
self.h = h or math.random(HEIGHT//40,WIDTH//20)*2
self.doors = {}
end
Never cut and paste, boys and girls. It’s worse than running with scissors.
function Room:init(x,y,w,h)
self.number = 666
self.color = color(0,255,0,25)
self.x = x or math.random(WIDTH//4,3*WIDTH//4)
self.y = y or math.random(HEIGHT//4,3*HEIGHT//4)
self.w = w or math.random(WIDTH//40,WIDTH//20)*2
self.h = h or math.random(HEIGHT//40,HEIGHT//20)*2
self.doors = {}
end
In case you, too, didn’t notice: HEIGHT
as the last parameter in self.h
. Now the rooms seem not to go off the screen. Now to improve the room graphics a bit. I don’t like that bright white, and I’d like to show the doors.
function ExpandedRoom:draw()
background(40, 40, 50)
pushMatrix()
pushStyle()
translate(WIDTH/2, HEIGHT/2)
scale(8)
rectMode(CENTER)
fill(229, 143, 37)
stroke(108, 108, 108)
strokeWidth(4)
rect(0,0,self.room.w, self.room.h)
self:drawDoors()
popStyle()
popMatrix()
fill(255,0,0)
fontSize(50)
textAlign(CENTER)
local t = string.format("ROOM %d\n%d x %d", self.room.number, self.room.w,self.room.h)
text(t, WIDTH/2, HEIGHT/2)
end
function ExpandedRoom:drawDoors()
for _x,door in pairs(doors) do
door:drawExpandedFor(self)
end
end
Doors know room coordinates for each room, I think. So I’m passing in the room so the door can look up the info. Let’s see how that’s done in the map display:
function Room:drawDoors()
pushMatrix()
pushStyle()
fill(255)
stroke(255)
for i,door in pairs(self.doors) do
x,y = door:getDoorCoordinates(self)
rect(x,y,10,10)
end
popStyle()
popMatrix()
end
That’s Feature Envy, of course. Some of the code that needs to be tidieed up. There’s also another issue here, which is that the door coordinates here are in screen coordinates not room relative coordinates, which we need in the expanded view.
This really could be TDD’d, but I’m on a roll and nothing’s gonna stop me from bashing this in.
function Door:drawExpandedFor(aRoom)
pushStyle()
fill(0)
stroke(0)
x,y = self:getDoorCoordinates(aRoom)
relX = x - aRoom.x
relY = y - aRoom.y
rect(relX,relY,10,10)
popStyle()
end
We get something like this:
Curiously, the map numbers are um somewhat larger when we go back to the map view. Someone isn’t pushing or popping when they should be. Sure enough, it’s ExpandedRoom:draw
:
function ExpandedRoom:draw()
background(40, 40, 50)
pushMatrix()
pushStyle()
translate(WIDTH/2, HEIGHT/2)
scale(8)
rectMode(CENTER)
fill(229, 143, 37)
stroke(108, 108, 108)
strokeWidth(4)
rect(0,0,self.room.w, self.room.h)
self:drawDoors()
popStyle()
popMatrix()
pushStyle()
fill(255,0,0)
fontSize(50)
textAlign(CENTER)
local t = string.format("ROOM %d\n%d x %d", self.room.number, self.room.w,self.room.h)
text(t, WIDTH/2, HEIGHT/2)
popStyle()
end
Adding the push and pop style there at the bottom fixes the display. However it also tells us that this method could be made more clear:
function ExpandedRoom:draw()
background(40, 40, 50)
self:drawGeometry()
self:drawDescription()
end
function ExpandedRoom:drawGeometry()
pushMatrix()
pushStyle()
translate(WIDTH/2, HEIGHT/2)
scale(8)
rectMode(CENTER)
fill(229, 143, 37)
stroke(108, 108, 108)
strokeWidth(4)
rect(0,0,self.room.w, self.room.h)
self:drawDoors()
popStyle()
popMatrix()
end
function ExpandedRoom:drawDescription()
pushStyle()
fill(255,0,0)
fontSize(50)
textAlign(CENTER)
local t = string.format("ROOM %d\n%d x %d", self.room.number, self.room.w,self.room.h)
text(t, WIDTH/2, HEIGHT/2)
popStyle()
end
function ExpandedRoom:drawDoors()
for _x,door in pairs(self.room.doors) do
door:drawExpandedFor(self.room)
end
end
That’s nicer. Commit: expanded rooms have black doors.
The doors are where they belong, mysteriously enough, corresponding to their positions on the map. Let’s reflect.
Reflection
Basically, other than a bit of messing about, what we have done here is to add a new object, ExpandedRoom
, wrapper for a Room
, that can display it in a large (expanded) format, including its doors. Rather clearly, if the Room contained a treasure or a key, this object could display those as well. And clearly, the display format can be elaborated to make it more attractive and game-like.
It seems to me that the design is somewhat lopsided, in that we have a wrapper display object for the room, but none for the map. The Room
knows how to display its map form, but not its expanded form. It seems to me that it might be better if it knew how to do both, or neither. If we’re going to draw things with wrappers, then we should always use wrappers.
The whole program is a bit more scattered than I might like, and I can think of at least one “strategic” possibility for why it’s odd. Rather than build toward a game, I chose to build some infrastructure / architecture. That wasn’t necessarily a bad decision: when we’re uncertain about how to do some key thing, it makes sense to increase certainty before committing to the masses of work that will depend on that key thing. On the other hand, once we have a handle on that uncertainty, it’s almost certain that the code we have at that moment isn’t suitable for building upon. Why not? Because we weren’t focusing on using it when we built it.
This is, of course, the problem with every framework and library in the universe. Yes, including the Scrum framework. The more we build a framework by focusing on the framework, the less we’ll be focusing on what it’s like to use it, and it’s correspondingly likely to drift away from being ideal for use.
In addition, though we’ve done some work to come up with solid abstractions, and code “where it belongs”, we have plenty of oddities like the feature envy we observed above:
function Room:drawDoors()
pushMatrix()
pushStyle()
fill(255)
stroke(255)
for i,door in pairs(self.doors) do
x,y = door:getDoorCoordinates(self)
rect(x,y,10,10)
end
popStyle()
popMatrix()
end
There, the Room is drawing the doors, instead of asking them to draw themselves. Not a big violation, but a few of those and you find that your ability to change the code rapidly is hampered, because you have to look around to see who’s ripping your entrails out and using them for arcane purposes.
Nonetheless, we have taken a small but significant step today toward getting a playable game. You can look at that rudimentary room picture and almost imagine your intrepid adventurer in there, ready to do her thing.
See you next time!