Dungeon 31
I believe that the dungeon should have some monsters in it. Let’s take a cut at that. Also, I provide an overview of how it works.
First, the overview. Recall that this is the second “design” so much of what one might remember from the early articles is no longer true. Here’s what I think is going on.
The code for the game has only six tabs:
- Main - the setup, top-level drawing, basic touch handling.
- Tests - eight named tests, usually with more than one assert. Two are failing, when did that happen?
- Tile - a 64x64 pixel square. Mostly just draws itself. Can inspect neighbors, see below.
- Room - represents a random isolated rectangle, paints the tiles under it with flooring surrounded by walls. Knows how to connect corridors to another room, see below.
- GameRunner - creates dungeons, maintains the tiles, mediates whether things can move, digs corridors.
- Player - displays the avatar sprite (princess), fields keyboard keys WASD for moving. Can answer how far it is from some other tile.
The thing that makes this design nice–so far–is that everything is focused on the tiles. There is a last-minute conversion to graphics coordinates and that only comes down to deciding how much to scroll, because the game board is displayed at full 1:1 scale. Even if it were scaled, the focus would remain on tiles, however.
Creating the dungeon is particularly neat. I stole the idea: I’m not trying to take credit for it. What happens is this:
First, create some number of non-overlapping rectangular rooms of varying sizes. This is done using a collection of Rooms, which amount to little more than a holder of a rectangle, used to check for intersections.
Once the rooms are in place, connect room 2 to room1, room3 to room 2, room 4 to room 3, and so on. Connect means to dig a corridor from the first room’s center to the second room’s center. The connection starts horizontally or vertically at random, and digs from, say, room 2’s center, horizontally over to the x coordinate or room 1’s center, and then vertically from there to room 1’s actual center.
This digging is done without considering other rooms, so it can go through them, adjacent to them, or around them. The result is that the dungeon is guaranteed to be fully connected and almost always has interesting paths.
When the corridors are initially dug, they consist of a single row of floor tiles, going wherever they go. I wanted them to have wall tiles, so there is a special pass made over the whole tile space. That pass checks every edge tile (the ones that are not floor and not wall) and if that tile is directly adjacent to a floor tile, the edge tile is changed to a wall tile. That gives all the corridors nice walls, as you can see in the pictures above.
After that, the game is running. All that happens so far is that if you type WASD the princess asks the GameRunner if she can move up left down right (screen right) and if she can, GameRunner tells her where to move to. (So far, that’s only to the square she requested, but in principle it could move her anywhere.)
And that’s how it works. Very simple. I’m quite happy with it. Now then …
Fix Those Tests
8: tint as function of distance from avatar -- Actual: 255.0, Expected: 0
_:test("tint as function of distance from avatar", function()
local tile
local tint
local player = Player(100,100)
runner = GameRunner()
runner.avatar = player
tile = Tile:room(100,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(255)
tile = Tile:room(107,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
tile = Tile:room(150,150, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
end)
The last two are failing. I think I know what this will be, but let’s check Tile:getTint
. Right:
function Tile:getTint()
if not DisplayToggle then return color(255) end
local d = self.runner:playerDistanceXY(self.x,self.y)
local t = math.max(255-d*255/7,0)
return color(t,t,t,255)
end
I changed getTint
to allow me to display the zoomed-out map in its entirety, not just the bit around the princess. We need to set that flag in the test.
_:test("tint as function of distance from avatar", function()
local tile
local tint
local player = Player(100,100)
local disp = DisplayToggle
DisplayToggle = true
runner = GameRunner()
runner.avatar = player
tile = Tile:room(100,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(255)
tile = Tile:room(107,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
tile = Tile:room(150,150, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
DisplayToggle = disp
end)
And we’re green. Now let’s see about monsters.
Monsters
We’ll begin with one monster, of course, as is our fashion. Simple, then generalize. I’ll pick some bit of art, probably the pink ghost from Codea’s Platformer Art pack, and wrap enough code around it to allow it to move about the dungeon. It seems to me that we have at least these issues:
- The ghost has three shapes, standing, moving, and squished. We’ll use the squished form for his um ectoplasmic residuum after being dispatched by the player. I think it’ll be amusing to swap the other two forms whenever he moves. In the SimpleDungeon sample program, he swaps between the moving form and the squished form, and I’d say he swaps four times a second.
- The monster will want to move toward the princess, as best he can. He should only be allowed to move every so often, probably about once a second or a bit faster.
- Monsters should be placed randomly. It’ll be easy to place them in rooms, but it might be better to place them in hallways. I kind of want to remain focused on tiles, so we’ll try to lean that way.
This makes me think that the monster class will want a timed callback from the GameRunner on some short cycle. I’m tempted to rig up some clever scheme of registering an object for events, but let’s just leave it for now. Since the monsters will have to register with GameRunner to be drawn, they can do their own timing based on the drawing cycle. They’ll use time, not draw cycle count, because the draw cycle can be (approximately) 1/120 sec, 1/60, or even 1/30 or 1/15, depending on program complexity and processor speed. But we can read system time, so we’ll do that.
Let’s begin by putting a monster in room 2. I’ll start with a class. Based on Princess, I pulled this out of um the air:
Monster = class()
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.sprite1 = asset.builtin.Platformer_Art.Monster_Standing
self.sprite2 = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
end
function Monster:draw()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(self.sprite1, gx,gy)
end
I figure if GameRunner creates a monster and puts it somewhere, it should show up, just standing there.
function GameRunner:createRandomRooms(count)
self.rooms = {}
while count > 0 do
count = count -1
local timeout = 100
local placed = false
while not placed do
timeout = timeout - 1
local r = Room:random(self.tileCountX,self.tileCountY, 4,15, self)
if self:hasRoomFor(r) then
placed = true
r:paint()
table.insert(self.rooms,r)
elseif timeout <= 0 then
placed = true
end
end
end
self:connectRooms()
self:convertEdgesToWalls()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
self.avatar = Player(rcx,rcy,self)
self:createMonsters()
end
function GameRunner:createMonsters()
self.monsters = {}
gx,gy = self.avatar:position()
mx = gx + 1
my = gy + 1
table.insert(self.monsters, Monster(mx,my,self))
end
function Player:position()
return self.tileX,self.tileY
end
function GameRunner:draw()
fill(0)
stroke(255)
strokeWidth(1)
for i,row in ipairs(self.tiles) do
for j,tile in ipairs(row) do
tile:draw()
end
end
self.avatar:draw()
self:drawMonsters()
end
function GameRunner:drawMonsters()
for i,m in ipairs(self.monsters) do
m:draw()
end
end
I’m an optimist and I expect to see a ghost right next to the Princess. Mysteriously enough, I do:
So that’s downright amazing. All that code and it works. Let’s make him bounce every 1/4 second. That seems fun, and we’ll have to sort out some kind of time thing. I was thinking to keep time in the individual object but it seems better just to do it once. What if we do this: we’ll have the GameRunner keep track of quarter-seconds, and call each monster every time the clock turns over. I don’t think the princess needs to know but if she does we’ll deal with it as needed. We’ll call the tick
operation before we draw, so the object can update itself prior to the draw. Won’t make much difference but seems more nearly right.
function GameRunner:draw()
self:timer()
fill(0)
stroke(255)
strokeWidth(1)
for i,row in ipairs(self.tiles) do
for j,tile in ipairs(row) do
tile:draw()
end
end
self.avatar:draw()
self:drawMonsters()
end
function GameRunner:timer()
local t = os.time()
if os.difftime(t,self.time) > 0.25 then
self.time = t
self:runTimedEvents(t)
end
end
function GameRunner:runTimedEvents(time)
for i,m in ipairs(self.monsters) do
m:time(time)
end
end
function Monster:time(aTime)
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
end
Truly mysteriously, this works as intended:
However, the motion is not too exciting. Let’s use the squished form, and let’s stop swapping the member variables around:
Monster = class()
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
end
function Monster:draw()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(self.sprite1, gx,gy)
end
function Monster:time(aTime)
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
end
Now I’m using sprite1 and 2 just as the display choices, and saving the originals under better names.
This sure looks more like once a second to me rather than 1/4. Ah. difftime
returns seconds as integer, not a float. We’ll use os.clock
which returns a sub-second result. It’s documented as CPU time but seems to work. Now I think our guy switches a bit too often:
I’ll try every other time:
function Monster:time(aTime)
self.swap = self.swap + 1
if self.swap == 2 then
self.swap = 0
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
end
end
That’s better, I think:
Let’s commit this: monster appears and animates.
Moving the Monster
Now let’s make him move. He’ll try to move every second. For now, we’ll move him randomly but I think we can do better later.
function Monster:time(aTime)
self:moveIfTime()
self:animateIfTime()
end
function Monster:animateIfTime()
self.swap = self.swap + 1
if self.swap == 2 then
self.swap = 0
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
end
end
function Monster:moveIfTime()
self.move = self.move + 1
if self.move >= 4 then
self.move = 0
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local nx, ny = self.tileX + move.x, self.tileY + move.y
self.runner:moveMeIfPossible(self, nx,ny)
end
end
And it’s alive!!!
I think he could be a bit taller but we’ll leave that up to the graphics art department. Commit: monster walks randomly.
Next things to do will be to position him randomly in the dungeon and give him a tendency to move toward the player. We’ll save that for next time: this is enough for today.
Let’s sum up.
Summing Up
Things went quite smoothly today despite having no tests. We did run into a few items, including:
- os.time isn’t sub-second precision. We’re using os.clock, which may be elapsed but is listed as CPU time. Seems to be OK.
- I made a few syntax errors that were so boring that I didn’t report them. You may assume that I type something wrong every few lines. Unless it’s either hilariously egregious or a conceptual mistake, I might skip over it.
- Monster went in nicely. Possibly this was due to experience in such things, but I think much of the credit goes to the very simple Tile model, which means less thinking on the part of the programmer.
It’ll be interesting to look at picking a random location for the monster. It’s tempting to just look around in tile space until we find a vacant tile, maybe one that’s not too close to the avatar.
We’re all set up for more than one monster. That was a bit speculative on my part, but I know for certain we’ll allow more than one, so I made the collection. It’s a pretty standard approach and should bear up. If not, we’ll learn something and it’ll be easy to adapt.
All in all this is going quite smoothly. I’m almost getting nervous about it.
See you next time!