Dungeon 182
A bit more on the Learning Level, I think, but I’d really like to make some progress on untangling some mistakes, er, learning opportunities in the code.
My FTP has been down since before publication time yesterday, so I don’t know when you’ll get to see this. I promise not to be typing bad things about you behind your back.
Forgive me for reversing the order of my plan here in paragraph 2, but I want to review some code and see what improvements could be made. Yesterday, we had that supposedly trivial test for the room announcer, and I noticed that the tests are running pretty long, about 9 seconds. (Lua’s os.time
function is only good to seconds.) Anyway, 9 seconds feels long, and it’s slowing me down.
In a more robust system, I might have the ability to run a subset of my suite, but CodeaUnit isn’t that powerful. It’s “only” an iPad, y’know. Watching the tests run, I notice pauses running testDungeon
and testDungeon2
. Let’s have a glance at those.
There are five tests in testDungeon
, and this is its setup:
_:before(function()
_bus = Bus
Bus = EventBus()
_runner = Runner
local gm = GameRunner()
Runner = gm
gm:createLevel(12)
dungeon = gm:getDungeon()
_TileLock = TileLock
TileLock = false
end)
The big item there will be in gm:createLevel
, which creates our array of tiles, 86x65 or whatever it is.
I’m noticing something odd about the tests. Here’s one:
_:test("is open hallway tile up down", function()
local tile, up, down, left, right
tile = Tile:wall(100,100, Runner)
up = Tile:wall(100,101, Runner)
down = Tile:wall(100,99, Runner)
left = Tile:wall(99,100, Runner)
right = Tile:wall(101,100, Runner)
_:expect(tile:isOpenHallway(up,down,left,right)).is(false)
up = Tile:room(100,101, Runner)
down = Tile:room(100,99, Runner)
_:expect(tile:isOpenHallway(up,down,left,right), "central isn't room").is(false)
tile = Tile:room(100,100, Runner)
_:expect(tile:isOpenHallway(up,down,left,right), "up down OK").is(true)
left = Tile:room(99,100, Runner)
right = Tile:room(101,100, Runner)
_:expect(tile:isOpenHallway(up,down,left,right), "up down left right not OK").is(false)
end)
First of all, we’re just creating tiles free in the air here: they don’t get put into the dungeon. This test doesn’t require a live runner at all, except that we have this:
function Tile:init(x,y,kind, runner)
if runner ~= Runner then
print(runner, " should be ", Runner)
error("Invariant Failed: Tile must receive Runner")
end
self.position = vec2(x,y)
self.kind = kind
self.runner = runner
self:initDetails()
end
Second, those room coordinates aren’t legit, being outside the 85x64 range. Which is harmless here but weird.
Of the five tests in here, I think that none of them actually need a gameRunner, and the ones that need a Dungeon instance don’t need one with a fully populated dungeon.
Let’s change GameRunner thus:
function GameRunner:init(testX, testY)
self.tileSize = 64
self.tileCountX = testX or 85 -- if these change, zoomed-out scale
self.tileCountY = testY or 64 -- may also need to be changed.
self:createNewDungeon()
I just added those two new parameters, testX and testY. This will let me create a dungeon with fewer tiles. Now let’s try that in the testDungeon code.
Well, that’s useless, and I’ll remove it, but we can do this:
_:before(function()
_bus = Bus
Bus = EventBus()
_runner = Runner
local gm = GameRunner()
Runner = gm
dungeon = gm:getDungeon()
dungeon:createTiles(25,15)
_TileLock = TileLock
TileLock = false
end)
GameRunner doesn’t create tiles until the call to createLevel
, and then it tells Dungeon to do the job. So we’ll create just 20x20. That means this test needs correction (or deletion, it’s not terribly useful):
-- as corrected
_:test("Dungeon has tiles", function()
_:expect(#dungeon.tiles).is(26)
_:expect(dungeon.tileCountX).is(25)
_:expect(dungeon.tileCountY).is(15)
end)
However, I get a run-time failure:
GameRunner:312: attempt to index a nil value (field 'monsters')
stack traceback:
GameRunner:312: in method 'hasMonsterNearPlayer'
MusicPlayer:76: in method 'checkForDanger'
MusicPlayer:81: in field 'callback'
...in pairs(tweens) do
...
I’m not sure what this is, but I think that the test change caused it. Ah. I bet we need to have a monsters field initialized in GameRunner, even though we didn’t really use it.
After a bit of messing about, I decide that what we really need is a method on GameRunner to create a simplified level. So:
function GameRunner:createTestLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
TileLock=false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
self.rooms = dungeon:createRandomRooms(count)
dungeon:connectRooms(self.rooms)
dungeon:convertEdgesToWalls()
--self:placePlayerInRoom1()
--self:placeWayDown()
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
self.monsters = Monsters()
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
--self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
--self:startTimers()
self.playerCanMove = true
TileLock = true
end
And we use it here:
_:before(function()
_bus = Bus
Bus = EventBus()
_runner = Runner
local gm = GameRunner(25,15)
Runner = gm
gm:createTestLevel(0)
dungeon = gm:getDungeon()
_TileLock = TileLock
TileLock = false
end)
Test time is somewhat reduced. Let’s commit: simplified testDungeon
for reduced test time.
Let’s see about that other one, testDungeon2
:
_:before(function()
_TileLock = TileLock
TileLock = false
_tweenDelay = tween.delay
tween.delay = fakeTweenDelay
_Runner = Runner
_bus = Bus
Bus = EventBus()
Runner = GameRunner()
Runner:getDungeon():createTiles(Runner.tileCountX, Runner.tileCountY)
end)
Looks like we needed far less setup here but the tile size is still an issue. Let’s just reduce those values:
_:before(function()
_TileLock = TileLock
TileLock = false
_tweenDelay = tween.delay
tween.delay = fakeTweenDelay
_Runner = Runner
_bus = Bus
Bus = EventBus()
Runner = GameRunner(25,15)
Runner:getDungeon():createTiles(Runner.tileCountX, Runner.tileCountY)
end)
But we’re testing hallways, so we’ll need to change the tests to use smaller values.
I’ll spare you looking at all these trivial but nitty-gritty changes to tests. Suffice to say, I’ve been at it about an hour and a half, and have the test time down from 9 seconds to 8. This is what happens when your tests encompass too many objects.
Most of the time now is still in testDungeon2
, about 6 seconds. Some simple profiling doesn’t show me any tall grass, though it’s hard to be sure with the limited granularity of the Lua clock.
I guess sometimes the bear bites you. Commit: tests are a bit faster. Let’s see about the Learning Level.
Learning Level
One thing we’ll clearly want is for the Learning Level to have its own starting announcement and so on. Plus, it should be a unique level, outside the existing 1-4 that we cycle between. It thinks it’s level 1 right now. What would happen if we made it level 99, I wonder.
function GameRunner:createLearningLevel()
self.dungeonLevel = 99
TileLock = false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
self:createLearningRooms()
self:connectLearningRooms()
dungeon:convertEdgesToWalls()
self.monsters = Monsters()
self:placePlayerInRoom1()
self:placeWayDown()
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
self:startTimers()
self.playerCanMove = true
TileLock = true
end
I’ve commented out most of the standard setup, since this level will have specialized setup throughout. Now we need to adjust the crawl message:
function GameRunner:crawlMessages(level, codeaUnit)
local msgs = { { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
"Some of you may die, but I am willing",
"to make that sacrifice."},
{ "This is the fearsome Segundo Level,",
"where the monsters are even more terrible,",
"and the rewards harder to earn.",
"This could be the end for you." },
{ "I am frankly amazed that you have made",
"it this far, as feeble and weak as you are.",
"I suppose you must be commended for luck,",
"if not for skill.",
"I suspect you'll not last much longer."},
{ "You have delved deeper here than we",
"could have imagined. There's no telling what",
"you may find, nor what creatures you may",
"encounter.",
"Prepare to die!"}}
if level > #msgs then level = #msgs end
local m = msgs[level]
return m
end
We don’t really expect to have a level 99 coming in here. Let’s allow for it, though, and set it up.
function GameRunner:crawlMessages(level, codeaUnit)
local msgs = { { "Welcome to the Dungeon.",
"Here you will find great adventure,",
"fearsome monsters, and fantastic rewards.",
"Some of you may die, but I am willing",
"to make that sacrifice."},
{ "This is the fearsome Segundo Level,",
"where the monsters are even more terrible,",
"and the rewards harder to earn.",
"This could be the end for you." },
{ "I am frankly amazed that you have made",
"it this far, as feeble and weak as you are.",
"I suppose you must be commended for luck,",
"if not for skill.",
"I suspect you'll not last much longer."},
{ "You have delved deeper here than we",
"could have imagined. There's no telling what",
"you may find, nor what creatures you may",
"encounter.",
"Prepare to die!"},
[99] = { "This is the Learning Level.",
"As you move from room to room,",
"you'll receive messages and be given",
"instructions about how things work,",
"and things to try.",
"Have fun!",
"Please use the arrow buttons to move",
"to the next room, to your right." }
}
local m = msgs[level] or {"Unexpected level "..level}
return m
end
This code accepts levels 1-however many are at the beginning of the list, plus level 99. If it gets some other number, like 92, it’ll use “Unexpected level 92” as the message.
Works. We’ll probably want a way to define the initial message in our setup as we begin to build up our Making capability.
Commit: Learning Level has unique starting message (programmed).
Now let’s think. Presumably the player will manage to move to the next room. What should the next room say and do? I suggest that it should contain a couple of items, and advise the player to move next to an item and hit the ?? to inquire about it. The items in question should have learning-specific messages, but probably behave like an ordinary item otherwise.
Let’s get specific. In the room, a ways away from the entrance, we’ll place a Health power-up. Let’s begin with a vanilla one and see how it goes. My basic plan here is to build up our learning level very incrementally, observing what I need to do, and thinking about how to make it both easier for a programmer (me) and for a non-programming level designer (me in the future).
I think I might like to write something like this:
self.rooms = self:makeRoomsFromXYWH(t, announcements)
local r2 = self.rooms[2]
local lootTile = r2:tileAt(2,2)
Loot(lootTile, "Health", 5,5)
The idea is to get a tile relative to the room center, and rez a Loot there. We’ll need the tileAt
method in Room:
function Room:tileAt(dungeon,x,y)
local tilePos = self:centerPos() + vec2(x,y)
return dungeon:getTile(tilePos)
end
It becomes clear that I’ll want a dungeon to access my room’s tiles, so we need to change the call:
self.rooms = self:makeRoomsFromXYWH(t, announcements)
local r2 = self.rooms[2]
local lootTile = r2:tileAt(self.dungeon, 2,2)
Loot(lootTile, "Health", 5,5)
That works as advertised. Commit: learning room 2 has a health loot in it.
It’s not too hard to imagine a textual format that could do this work. I’m not sure about the desirability of keeping the announcements in that separate table, but either way a little “compiler” from some kind of YAML-like format shouldn’t be an issue so far.
We’ll push a bit further. Let’s extend loots to accept an optional query message. First phase:
function Loot:init(tile, kind, min, max)
self.tile = nil
self.kind = kind
self.icon = self:getIcon(self.kind)
self.min = min
self.max = max
self.message = "I am a valuable "..self.kind
if tile then tile:moveObject(self) end
end
function Loot:query()
return self.message
end
function Loot:setMessage(message)
self.message = message
end
And now we can do this:
self.rooms = self:makeRoomsFromXYWH(t, announcements)
local r2 = self.rooms[2]
local lootTile = r2:tileAt(self.dungeon, 2,2)
local loot = Loot(lootTile, "Health", 5,5)
loot:setMessage("This is a Health Power-up of 5 points.\nStep onto it to receive its benefit.")
This is a bit of a hack, because we have made a single message encompass two lines. This scrolls correctly but both lines vanish as soon as the top one gets to the top of the crawl. We’ll want to turn this into an array of lines somewhere.
Commit: Learning Room 2 Health has custom message.
One more thing, let’s change Room 2’s message. No, two more, let’s turn off the ? display as well.
local announcements = {}
local a2 = { "In this room, there is a health power-up.",
"Please walk next to it (not onto it)",
"and press the ?? button.",
"You'll get an informative message about whatever you're next to." }
announcements[2] = a2
function Announcer:draw(ignored, center)
--[[
pushMatrix()
pushStyle()
textMode(CENTER)
fontSize(40)
fill(255)
text("?", center.x, center.y)
popStyle()
popMatrix()
--]]
end
I commented out the code, thinking that we might want it someday. Silly of me, but I’m old and strange.
Here we are, the first lessons of the learning level:
Commit: Learning Level takes you through receiving a Health Loot.
Summary
Well, optimizing the tests didn’t profit me much. That’s irritating, because 8 seconds feels like a long time. I have a new iPad coming soon, maybe that will help.
We will often find, when we have tests like mine, that too ften involve many collaborating objects, that the tests run too long. And we’ll often find that it’s hard to make them faster. And we’ll often find ourselves tempted not to test, or to turn them off. And we’ll often get in trouble when we do that and some problem slips through.
Chet Hendrickson likes to say that if we ship a serious bug and management asks “What about all those tests you told us about”, we don’t want to have to say “Um, we didn’t run them.”
So I’m going to consider this an open ticket and try to spend a little time off and on to improve the tests, and the objects underneath. I already have a couple of ideas.
Then we went on to add a new dungeon-level message for our Learning Level, and then to add a sensible message in Room 2, with a tiny quest, to move to a Health loot and query it, and then collect it. An actual tiny bit of learning.
A good thing, in my view. And so far, I don’t see anything keeping us from building a sort of text-driven dungeon creation “language”.
In due time, in due time.
See you … in due time!