Dungeon 41
Brute force FTW, a sound in the silence, and … I don’t know what else yet.
Last night we had our Zoom Ensemble, and with a little prompting from Bill Wake, I discovered that my iPad can in fact share screen in Zoom, so folks got a look at this program for the first time. Just a glance: we weren’t terribly productive, and our focus is on the program of another ensemble member anyway. But it’s good to know that I can get some feedback from my colleagues.
In the few moments when I did show the code, Bill Wake spotted a problem … wow … in checking to see if I could revert the problem back into place, I realized that I did no commits at all yesterday. So that’s done now, and let’s look at the thing Bill spotted:
The illuminate
was originally like this:
function Tile:illuminate()
for x = 0,7 do
self:illuminateLine(x,7)
self:illuminateLine(x,-7)
self:illuminateLine(-x,7)
self:illuminateLine(-x,-7)
end
for y = 0,6 do
self:illuminateLine(-7,y)
self:illuminateLine(7,y)
self:illuminateLine(-7,-y)
self:illuminateLine(7,-y)
end
end
I thought that increasing the radius and addressing more squares might help with the spottiness, so I had changed it to take a variable count. So last night it looked like this:
function Tile:illuminate()
local count = 15
for x = 0,count do
self:illuminateLine(x,7)
self:illuminateLine(x,-7)
self:illuminateLine(-x,7)
self:illuminateLine(-x,-7)
end
for y = 0,count-1 do
self:illuminateLine(-7,y)
self:illuminateLine(7,y)
self:illuminateLine(-7,-y)
self:illuminateLine(7,-y)
end
end
I had forgotten to change the 7s to count
. Bill spotted that immediately. This is why we recommend pair and ensemble programming, especially with Bill. So that code needs to look like this:
function Tile:illuminate()
local count = 25
for x = 0,count do
self:illuminateLine(x,count)
self:illuminateLine(x,-count)
self:illuminateLine(-x,count)
self:illuminateLine(-x,-count)
end
for y = 0,count-1 do
self:illuminateLine(-count,y)
self:illuminateLine(count,y)
self:illuminateLine(-count,-y)
self:illuminateLine(count,-y)
end
end
And now it does. You’ll notice the 25 up there. I’m sending out 8*25 or 200 rays of ray-casting now. That’s a lot, but it seems to work just fine, leaving none of those messy skipped wall joints that I was seeing yesterday.
We experimented briefly with ray-casting from the player’s square and the four adjacent squares, which can bring corridor walls into view sooner. It’s not really right, but we thought it might look better. I left the feature commented out, just as a reminder of the idea:
function Player:moveBy(aStep)
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
--[[
local pos = self.tile:pos()
local t = self.runner:getTile(pos+vec2(1,0))
t:illuminate()
local t = self.runner:getTile(pos+vec2(-1,0))
t:illuminate()
local t = self.runner:getTile(pos+vec2(0,1))
t:illuminate()
local t = self.runner:getTile(pos+vec2(0,-1))
t:illuminate()
]]--
end
My current thinking for the lighting is that the ray casting does amount to an improvement, although it does leave some odd effects in place. I might try implementing shadow casting, because we had an idea for how to TDD it, which could make for an interesting article. On the other hand, I don’t think it would improve the game much if at all.
Oh. Bryan Beecham had put sound into his game, so just because it is trivial, I put a sound call into the player’s interaction with a key:
function Player:startActionWithKey(aKey)
self.keys = self.keys + 1
sound(asset.downloaded.A_Hero_s_Quest.Defensive_Cast_1)
aKey:take()
end
It sounds like this. I just did it to hear Bryan rage at me for popping it in in a couple of seconds. I am not actually a nice person, although I can sometimes play one for several minutes at a time.
Frameworks
The fact that I could just type a few characters and get some effects so quickly with Codea led us to a discussion of frameworks and the affordances of various systems.
Codea is a system aimed, not at general programming, but at the creation of 2D and 3D graphical programs. It is, in essence, a game-creation system. As such, things like sounds and sprites are presented right there at the surface. You want a sound, type sound
and the sound picker lets you pick the one you want.
Python has a far more general purpose, to be a powerful programming/scripting language, able to be used for a very wide range of applications. To build a game, your best choice is probably the pygame framework. The framework is rather large, and it is built on top of Python, with a wide range of capabilities and the assumptions that go with them.
When we pick up Python and a framework like pygame, we are faced with the complexity of Python, and the complexity of the framework. The startup learning costs of a framework are often quite high. Yes, there are simple examples, but the things you really want to do wind up requiring you to learn a lot about the framework before you can be really effective.
Now I like programming, and I don’t much like struggling with other people’s code and trying to figure out how to use it. So, for me, Codea and Lua are a great fit, because they’re so simple, yet so powerful. Python interests me, and I’ll probably try to think of something to do with it one of these years, I should live so long, but I know it’ll be a big investment.
Meanwhile, I get to avoid the heavy framework, and Bryan doesn’t. That puts certain things, like that sound, right at my fingertips. On the other hand, pygame has raycasting, and Codea doesn’t. So I have to roll my own, or scrounge the Internet to find something that Bryan could just use. He’d have to learn a bit, but I’ve used existing raycasting facilities and I can tell you that’s easier than creating one and then using it.
But the team is more important …
Now I do think there are other differences among the Ensemble members that also make a difference. That’s why good teams, good ensembles, and good pairing are so powerful. Last night, among the things I saw were:
- Chet refactored a few chunks of a large method and, using a technique that was simple but that is not at my fingertips, he made some chunks that had difference alike enough to combine them. I’ll try to stay alert for a chance to do that, but it was not at my fingertips. It was at his.
- Bill spotted that batch of 7s that I’d stared at for a long time, and recognized that they probably were wrong. As soon as he mentioned it, I agreed.
- Bryan drew some pictures of how shadows might be calculated, and they gave me an idea for how I might TDD shadow casting without too much tedium.
Things like that happen every time we come together on Zoom for a few hours, even though most of the time is spent just chatting and sharing stories and such. Insights that, added up, are more than any one of us, or even any two, would have had.
It’s quite good.
But I digress …
Let’s see if we can improve the game play just a bit. Let’s review some of the things we might want someday, and see if there’s something we could make a good start at today. And by a good start, I mean something we could release that would actually show up in the game.
- Controls
- The game needs on-screen controls. I basically can’t work it on my old iPad, because it doesn’t have a separate keyboard. We could make some arrow buttons fairly easily.
- Treasures
- We have keys, lots of them. What do they open? Perhaps treasure chests containing weapons, health, or riches.
- Status and Inventory
- If the player can collect things, we should probably have a display of their status and gold and health and such.
- Monsters
- We surely need more monsters than these pink ghostie things.
- Battles
- The rules of battle are that whoever moves into the other creature’s square wins. If I dart at the ghost, I will almost certainly kill it. If I stand around, it will come toward me and ultimately step into my square and poof, another princess down. We could make that more interesting. Perhaps it takes multiple hits to damage either of us. Perhaps there are simple maneuvers the player can make that help them win. Maybe there’s even a separate battle screen that could be created. (OK, probably not.)
Looking at that list, which probably only scratches the surface of things we might do to make this game fun to play, the idea of treasures seems “next”. Let’s add treasure chests to the game, and let’s see if we can arrange to open them with our keys. There will be more to do, but opening them if we have a key should be a good start.
Chests
We populate the dungeon with stuff here:
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()
local tile = self:getTile(vec2(rcx,rcy))
self.player = Player(tile,self)
self:createMonsters(5)
self:createKeys(10)
end
This method is rather awful, having grown up without much attention. It does more than create random rooms: it creates … a level. Let’s call it createLevel
and refactor it.
function setup()
if CodeaUnit then
codeaTestsVisible(true)
runCodeaUnitTests()
end
showKeyboard()
TileLock = false
Runner = GameRunner()
Runner:createRandomRooms(12)
--Runner:createTestRooms()
DisplayToggle = true
TileLock = true
local seed = math.random(1234567)
print("seed=",seed)
math.randomseed(seed)
end
We’ll change that to:
Runner:createLevel(12)
And rename the messy method. Then we’ll pull out the random room creation and call that createRandomRooms
:
function GameRunner:createLevel(count)
self:createRandomRooms(count)
self:connectRooms()
self:convertEdgesToWalls()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx,rcy))
self.player = Player(tile,self)
self:createMonsters(5)
self:createKeys(10)
end
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
end
The random rooms method is still messy but now it just does one thing. The createLevel
method is where we populate the dungeon. Possibly more of that should be extracted, but for now, it’s an improvement.
Let’s look at how we create monsters and keys:
function GameRunner:createKeys(n)
self.keys = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile()
table.insert(self.keys, Key(tile,self))
end
end
function GameRunner:createMonsters(n)
self.monsters = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile()
table.insert(self.monsters, Monster(tile,self))
end
end
I believe I see some duplication here, but I could be wrong …
For now, we’ll create some more duplication, inserting Chests. But we don’t have any Chests to insert yet, so let’s create a class. It’ll be a lot like Key:
-- Key
-- RJ 20201216
Key = class()
function Key:init(tile, runner)
self.tile = tile
self.tile:addContents(self)
self.runner = runner
end
function Key:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(asset.builtin.Planet_Cute.Key,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
function Key:interact(anEntity)
anEntity:startActionWithKey(self)
end
function Key:take()
self.tile:removeContents(self)
end
Player knows what to do with keys: you take them:
function Player:startActionWithKey(aKey)
self.keys = self.keys + 1
sound(asset.downloaded.A_Hero_s_Quest.Defensive_Cast_1)
aKey:take()
end
Let’s do Chest to be much like Key.
-- Chest
-- RJ 20201219
Chest = class()
function Chest:init(tile, runner)
self.tile = tile
self.tile:addContents(self)
self.runner = runner
self.open = false
self.closedPic = asset.builtin.Planet_Cute.Chest_Closed
self.openPic = asset.builtin.Planet_Cute.Chest_Open
self.pic = self.closedPic
end
function Chest:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(self.pic,g.x,g.y,50,50)
popStyle()
popMatrix()
end
function Chest:interact(anEntity)
anEntity:startActionWithChest(self)
end
function Chest:open()
self.pic = self.openPic
end
This is pretty much all boilerplate to me. I suppose we could TDD the open and close logic but is it worth it? I confess it isn’t worth it to me. I hope you are a better person than I am.
Now to populate:
function GameRunner:createChests(n)
self.chests = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile()
table.insert(self.chests, Chest(tile,self))
end
end
And I’ll call this right after placing the keys. I see an issue, which is that we could, in principle, have a key and a chest in the same location. I’ll ignore that for now. I may regret that, it’ll be a rare and difficult bug to duplicate.
function GameRunner:createLevel(count)
self:createRandomRooms(count)
self:connectRooms()
self:convertEdgesToWalls()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx,rcy))
self.player = Player(tile,self)
self:createMonsters(5)
self:createKeys(10)
self:createChests(10)
end
I’m creating lots of chests and keys, because I won’t want to traipse all over just to test them.
Shortly after starting the program, I get this:
Chest:27: attempt to call a nil value (method 'startActionWithChest')
stack traceback:
Chest:27: in method 'interact'
Tile:166: in method 'interactionFrom'
Tile:182: in method 'legalNeighbor'
Monster:83: in method 'makeRandomMove'
Monster:33: in field 'callback'
...in pairs(tweens) do
...
A Monster has stumbled onto a chest. They need to know what to do, same as they do for keys:
function Monster:startActionWithKey(aKey)
end
We provide
function Monster:startActionWithChest(aChest)
end
Did I fail to rename the arg variable in Player? Worse yet: I didn’t provide it?
function Player:startActionWithChest(aChest)
aChest:open()
end
This is a sign that I might have done well to TDD this trivial thing. I’d have had to create a little scenario, and that would have focused me on the task better, and would have discovered missing methods in a more direct fashion. Live and learn. Mostly I learn that I’m not as clever as I think I am.
Now we should have nearly good running behavior.
Well, I had a nice picture set going:
And I was going to step into it and guess what happened:
Player:92: attempt to call a boolean value (method 'open')
stack traceback:
Player:92: in method 'startActionWithChest'
Chest:27: in method 'interact'
Tile:166: in method 'interactionFrom'
Tile:182: in method 'legalNeighbor'
Player:76: in method 'moveBy'
Player:70: in method 'keyPress'
GameRunner:168: in method 'keyPress'
Main:68: in function 'keyboard'
I have a method and a member variable called open. And why do I have the member variable at all? Removed.
It works:
One little thing is that when the princess steps into the square with the chest, it opens, but she remains standing on top of it. I believe that it would be better if she can’t step in when it’s closed. She tries and (if she has a key, not yet checked) the chest opens, but she stays where she was. And if it is open, then she can step into the square and receive the goods. For now, we’ll let that be an unimplemented feature.
The chest looks funny when it’s open. There is a separate open lid sprite, but I think the issue is that our sprite isn’t properly proportioned. Its native size is 101x171. This code is showing it at 50x50. Let’s try it at 50x85, which will give it the right proportions … and make it extend past the top of the tile. We’ll see if we like that.
Looks good but needs to be offset upward. Or we could try CORNER mode:
function Chest:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
local g = self.tile:graphicCorner()
sprite(self.pic,g.x,g.y,50,85)
popStyle()
popMatrix()
end
That looks pretty good. It should really be offset by about 7 pixels in X, to be centered vertically in the tile.
That looks good. I don’t like these pixel adjustments, but they’re necessary given that we’re using tiles that are not hand-crafted to match our game’s dimensions.
I’m going to commit this: player can open chests w/o key.
Diversion
Now I’m going to divert for another purpose. When you walk the princess down a long hallway, or past a long wall of any kind, you can’t tell that she’s moving. She stays centered on the screen, but since the floor and wall tiles are all the same, you don’t see that they’ve shifted.
Bryan suggested some trim on the wall tiles, and Chet suggested perhaps wall torches every so often. One way or another, I’d like to decorate about every third twall tile with something, if only a different color. But since the shadow tinting is going on, putting a color in there would be difficult. What about a different wall tile. I’ll just pick something. The current tile is in the Blocks asset pack, called Dirt. However, I’m not really set up to use more than one tile of a given kind:
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint(tiny))
local sp = self:getSprite()
local center = self:graphicCorner()
sprite(sp, center.x,center.y, self.runner.tileSize)
for k,c in pairs(self.contents) do
c:draw()
end
popStyle()
popMatrix()
end
function Tile:getSprite()
return self.sprites[self.kind]
end
What if there could be more than one sprite for a wall cell? Better yet … what if a wall cell optionally had contents that it drew on top of itself, like some stain or a something?
Interesting. For now, let’s just say that our call to getSprite returns a collection of sprites:
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint(tiny))
local sprites = self:getSprites()
local center = self:graphicCorner()
for i,sp in ipairs(sprites) do
sprite(sp, center.x,center.y, self.runner.tileSize)
end
for k,c in pairs(self.contents) do
c:draw()
end
popStyle()
popMatrix()
end
function Tile:getSprites()
return {self.sprites[self.kind]}
end
So far, it’s a lonely table for one. Still works, though.
Now I want to make a decision based on the coordinates, think. So they need to be passed in:
function Tile:getSprites(pos)
return {self.sprites[self.kind]}
end
Now I’m going to mess up my nice table lookup a bit:
function Tile:getSprites(pos)
local result = {self.sprites[self.kind]}
if self.kind == TileWall then
end
return result
end
Still no effect. But now …
function Tile:getSprites(pos)
local result = {self.sprites[self.kind]}
if self:isWall() and pos.x%3 == pos.y%3 then
table.insert(result,asset.builtin.Blocks.Grass2)
end
return result
end
And we get this:
Much better. And a good time to wrap up. Commit: walls have grass on top occasionally.
Let’s Sum Up
We were able to rather quickly put in a new object, Chest, which has a strange similarity to Key, and a bit less similarity but still some, to Monster. The similarity is due to the fact that all our entities have to support a common protocol for interaction, the startActionWith[Type]
, and of course they all have to draw themselves. Still, these similarities lead to some pretty interesting forms of duplication:
function Key:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(asset.builtin.Planet_Cute.Key,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
function Chest:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
local g = self.tile:graphicCorner()
sprite(self.pic,g.x + 7,g.y,50,85)
popStyle()
popMatrix()
end
These are not identical, but they sure could be. This suggests that there could be a supporting object to which a lot of these objects could delegate drawing, or perhaps some kind of super-class subclass relationship.
At this moment, I’m wiling to live with it, but I already made the mistake of forgetting to implement the Monster’s interaction with the new Chest object. It would be nice if everyone’s behavior, faced with a new object, would be to ignore it.
It would be easy to build a superclass for all the Entities (perhaps excluding the Princess, we’d have to see how it went), and in that superclass, provide default behaviors for everyone. I was taught not to play the subclass card too early, since you can only play it once and you want to make it count. And there are other ways this might be done.
For now, we’ll let it be. We do need to elaborate the Chest behavior a fair amount. We should reject the ability to step into the chest square. (But what about those ones that land in corridors?) And we should require that the player have a key, and we might even imagine different kinds of keys, so that the chest should be given the chance to decide whether to open or not.
All this will come, and as we build it, we’ll learn what structures we need to improve. That’s the main purpose of my game programming here: to explore how far we can push incremental design, given that we work to keep the code well organized and habitable, without putting in generality before we actually need it.
So far so good, over four games here on the site, and two decades of working with the ideas.
See you next time!