Dungeon 135
Let’s make icons in the Inventory do things. Pick something easy, it’s Monday up in here.
I’ve been thinking about Bruce Onder’s idea about packaging the pathfinder in a bottle in a chest and so on and so on. I would like to avoid the combat aspect, and am vaguely considering other ways to make the monsters interesting but without the princess needing to kill them or oh no the reverse. We do have other objects that might be used to solve problems, and with a bit of cleverness we could some up.
For the WayDown, I do like the notion that the WayDown either can’t be seen, or won’t work, until after the pathfinder has found it and gone down. This would mean that you could even visit the WayDown room before you had solved the level’s puzzle, and the WayDown wouldn’t be there, so you’d think you needed to keep looking. It would be good if there were clues that became available over time.
But we’re getting ahead of ourselves. Let’s create a thing that can appear in Inventory, and if you touch it, it rezzes the pathfinder.
How might we do this? I can think of a few ways:
- Give the InventoryItem an object. When the icon is touched, the Item tells the object
execute
or something, and the object does its thing. - Extend InventoryItem to know an object and a message, and to send the message to the object when touched.
- Make other objects capable of acting like InventoryItems.
I think #3 is right out. It would put duplicate code into any object that goes into the Inventory.
Item #1 requires objects to implement a particular method, be it execute
or something else.
I think #2 is the ticket. We’ll give the Item an object and a message to be sent. If we need something like #1 in a given case, we can create a special object then. Otherwise, inventory things just ask for a callback on a method of their choosing.
OK. How do we currently rez the pathfinder? That’s done by the Flee button, which we’ll want to have not do that any more. The button calls this:
function Player:flee()
local map = self.runner:mapToWayDown()
local myTile = self:getTile()
local monsterTile = map:nextAfter(myTile)
self.runner:createPathMonster(map, monsterTile)
end
That code right there is the code we’d like to have spawn our pathfinder. Let’s extract it to its own method, and remove it from flee
while we’re at it.
function Player:spawnPathfinder()
local map = self.runner:mapToWayDown()
local myTile = self:getTile()
local monsterTile = map:nextAfter(myTile)
self.runner:createPathMonster(map, monsterTile)
end
Now what? Let’s see. We have in mind a sort of Loot that will add itself to Inventory (and set up a callback). I think we should assume for now that they’ll all call the Player, but we’ll see.
An issue is that our Loots all assume that their job is to add points to the player:
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
What do we need here? I think we need roughly this, so I’ll type it in.
function Loot:addPathfinderAction(aPlayer)
local item = InventoryItem("blue_jar", "Magic Jar", aPlayer, "spawnPathfinder")
Inventory:add(item)
end
Now Loots init like this:
local LootIcons = {Strength="blue_pack", Health="red_flask", Speed="green_jar"}
function Loot:init(tile, kind, min, max)
self.tile = tile
self.kind = kind
self.icon = self:getIcon(self.kind)
self.min = min
self.max = max
if tile then tile:addDeferredContents(self) end
end
They know their kind. That’s useful. How are they created now?
local RandomLootInfo = {
{"Strength", 4,9},
{"Health", 4,10},
{"Speed", 2,5 }
}
function GameRunner:createLoots(n)
local loots = {}
for i = 1, n or 1 do
local tile = self:randomRoomTile(self.playerRoom)
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
table.insert(loots, Loot(tile, tab[1], tab[2], tab[3]))
end
return loots
end
Let’s refactor that method to give us one that creates a Loot of a given kind.
I’m not fond of the Loot being so tied to a property, min, and max, but let’s work around that for now.
Thinking
I’m considering two ways to go. One is just to create a new entry here, like {"Pathfinder", 0,0}
, and deal with it inside Loot. That’s a bit of a hack.
Another is to have a different creation method for Loot, but I’m not sure how we’d work that from our table.
Another is to put something other than a number in the max or min field, like, say, "spawnPathfinder"
. That’s truly nasty, using a different type in a field assumed to be a number.
Another is to use a code number, like -666. Yuccch.
I think the least ugly is the first way. But I will want that method to have an entry point I can use for Pathfinder, because I want to place one where I can find it.
First I reorder the lines of the method to get the entry first:
function GameRunner:createLoots(n)
local loots = {}
for i = 1, n or 1 do
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
local tile = self:randomRoomTile(self.playerRoom)
table.insert(loots, Loot(tile, tab[1], tab[2], tab[3]))
end
return loots
end
Then I extract the other two lines to do the saving. But now I’m wondering what we do with that collection, up in GameRunner. The answer is that we don’t use it at all. The table is useless.
Remove it and test. Works fine. We’re left with this:
function GameRunner:createLoots(n)
for i = 1, n or 1 do
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
local tile = self:randomRoomTile(self.playerRoom)
Loot(tile, tab[1], tab[2], tab[3])
end
end
We have to create the Loot but when we do it puts itself where it needs to be. Extract:
function GameRunner:createLoots(n)
for i = 1, n or 1 do
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
self:createLootInRandomNonPlayerRoom(tab)
end
end
function GameRunner:createLootInRandomNonPlayerRoom(tab)
local tile = self:randomRoomTile(self.playerRoom)
Loot(tile, tab[1], tab[2], tab[3])
end
Now I can add a Pathfinder loot to the table, but I can also create one wh … I was going to say “wherever I want”, but I can’t. Undo that refactoring, for our hack we can go another way.
local RandomLootInfo = {
{"Strength", 4,9},
{"Health", 4,10},
{"Speed", 2,5 },
{"Pathfinder", 0,0}
}
function GameRunner:createLoots(n)
for i = 1, n or 1 do
local tab = RandomLootInfo[math.random(1,#RandomLootInfo)]
local tile = self:randomRoomTile(self.playerRoom)
Loot(tile, tab[1], tab[2], tab[3])
end
end
Now I’m going to put one in the player room. I patch this in:
self:createLoots(10)
local tile = self.player:getTile()
local pfTile = tile:getNeighbor(vec2(1,1))
Loot(pfTile, "Pathfinder", 0,0)
self:createDecor(30)
That puts a Pathfinder jar by the Princess. Now it needs to know to add an Item.
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
if self.kind == "Pathfinder" then
self:addPathfinderToInventory()
else
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
end
Now this is certainly getting a bit hacked, but I’m trying to stretch a rope across the valley here. We’ll make a decent bridge once we’ve made it to the other side.
This will crash if I step on the blue jar.
Loot:29: attempt to call a nil value (method 'addPathfinderToInventory')
stack traceback:
Loot:29: in method 'actionWith'
Player:218: in local 'action'
TileArbiter:27: in method 'moveTo'
Tile:90: in method 'attemptedEntranceBy'
Tile:355: in function <Tile:353>
(...tail calls...)
Player:175: in method 'moveBy'
Player:124: in method 'executeKey'
Player:169: in method 'keyPress'
GameRunner:324: in method 'keyPress'
Main:38: in function 'keyboard'
I love it when a plan comes together.
I realize that my new method needs the player:
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
if self.kind == "Pathfinder" then
self:addPathfinderToInventory(aPlayer)
else
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
end
And I rename that sketch method I wrote:
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem("blue_jar", "Magic Jar", aPlayer, "spawnPathfinder")
Inventory:add(item)
end
Now InventoryItem needs to deal with its new inputs:
function InventoryItem:init(icon, name, object, message)
self.icon = icon
self.name = name
self.object = object
self.message = message
end
I may need to change the creators to match this pattern. They’re all set up with simplistic inits now. I’ll let the tests tell me. So far so good.
Now touched
looks like this:
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
print(self.name, " touched")
end
end
We do this:
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
if self.object then
self.object[self.message]()
else
print(self.name or self.icon, " touched")
end
end
end
And let’s remove the fake ones from the draw:
function Inventory:draw()
pushMatrix()
pushStyle()
rectMode(CENTER)
spriteMode(CENTER)
--[[
if self:count() == 0 then
self:add(InventoryItem("snake_staff"))
self:add(InventoryItem("blue_bottle"))
self:add(InventoryItem("skull_staff"))
self:add(InventoryItem("green_flask"))
self:add(InventoryItem("gold_bag"))
end
--]]
for i,item in ipairs(inventory) do
item:draw(self:drawingPos(i, self:count()))
end
popStyle()
popMatrix()
end
I think this might work. Well, not quite. The jar does go into inventory nicely. But then.
Player:201: attempt to index a nil value (local 'self')
stack traceback:
Player:201: in field '?'
Inventory:135: in method 'touched'
Inventory:107: in method 'touched'
GameRunner:414: in method 'touched'
Main:43: in function 'touched'
Oh. I have to pass the player into the method:
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
if self.object then
self.object[self.message](self.object)
else
print(self.name or self.icon, " touched")
end
end
end
OK, hold my chai and watch this:
I notice that the inventory looks a bit odd with one item, as if it is blocking the hallway or something. We’ll deal with that someday, if we feel like it. What we have, however, is exactly what we asked for.
We have a kind of Loot that when you touch it, it adds itself to inventory, and when the inventory item is touched, it sends a defined message to whatever object is provided (typically the player).
Let’s commit: Blue Jar Loot creates Pathfinder from Inventory.
And let’s reflect:
Reflection
I did all this without tests. It just sort of followed my nose, and I got away with it. But I do think I could have tested most of this with simple TDD-style microtests. I’m torn, and I’ll tell you why.
I’m here to show you what I do and what happens, so that you can consider what you see and get ideas and maybe decide to try things. So, ideally, I’d do everything in the most simple, precise, perfectly-disciplined way, so that you could admire and be inspired.
Instead, sometimes I do the practices that I really do believe are best, like microtesting, and sometimes … I do not. I try to use good judgment, and I try to explain what I’m thinking, but sometimes, like today, I just feel like going ahead and coding, and I do.
Now, in the olden days, when we were more disciplined about tests, we might do something like this morning’s work, to see how something should be hooked up, but then we’d delete the code and test-drive it. We called those experiments “Spikes” and our team rules were that we never saved them. We probably followed that rule most of the time, too.
The thing is this:
As we start out with this test-driven style, it’s hard to write the tests or even think about what they should be. So we are inclined not to do it. If we keep on trying, we get good at the style, and so it doesn’t seem so hard, so we are more inclined to do it, because we’ve experienced the benefits, and it’s easy to reap them.1
As we get better at the microtesting, it just becomes one more in our huge bag of techniques, and we reach for it readily, because it’s right there near the top. And sometimes, thoughtfully, or reflexively, we don’t reach for it. And, because we’re experienced, it usually turns out OK.
But some readers here are not yet expert at the microtesting approach, and they find it difficult, and I’m concerned that when they see me skipping tests, they’ll think it’s OK to skip tests (which it is) and they’ll skip tests when they probably shouldn’t, and they won’t learn as quickly or as well, and they won’t get the benefits.
I’m not saying “do as I say, not as I do”. I’m saying, note that sometimes when I don’t test, it works out OK, and sometimes it doesn’t.
When I do test, it almost always works out well. There’s a lesson there.
Now What?
Well, maybe we should look at this code, clean it up, and maybe even to some tests.
Here’s the whole flow:
function GameRunner:createLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
...
self:createLoots(10)
local tile = self.player:getTile()
local pfTile = tile:getNeighbor(vec2(1,1))
Loot(pfTile, "Pathfinder", 0,0)
self:createDecor(30)
...
end
function Loot:init(tile, kind, min, max)
self.tile = tile
self.kind = kind
self.icon = self:getIcon(self.kind)
self.min = min
self.max = max
if tile then tile:addDeferredContents(self) end
end
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
if self.kind == "Pathfinder" then
self:addPathfinderToInventory(aPlayer)
else
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
end
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem("blue_jar", "Magic Jar", aPlayer, "spawnPathfinder")
Inventory:add(item)
end
function InventoryItem:init(icon, name, object, message)
self.icon = icon
self.name = name
self.object = object
self.message = message
end
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
if self.object then
self.object[self.message](self.object)
else
print(self.name or self.icon, " touched")
end
end
end
I’d love to get rid of that if statement, but I feel the need to cater to the possibility that an InventoryItem might not have an object and message to send. Let’s init them so that they always have. And let’s test that.
_:test("Inventory item always has target and message", function()
local item = InventoryItem("foo")
_:expect(item.name).is("foo")
_:expect(item.object).is(item)
_:expect(item.message).is("print")
end)
This of course fails a lot.
4: Inventory item always has target and message -- Actual: nil, Expected: foo
And the others as well.
function InventoryItem:init(icon, name, object, message)
self.icon = icon
self.name = name or icon
self.object = object or self
self.message = message or "print"
end
Tests run. Implement print and improve touched
:
function InventoryItem:print()
print(self.name, " touched.")
end
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
self.object[self.message](self.object)
end
end
Everything works as advertised.
Note that technique, by the way. It’s a bit of a pattern that one uses. It’s kind of like the NullObject pattern, where when we don’t have an object to talk to, instead of returning a nil, we return an object that ignores messages sent to it, or emits errors or whatever we want. We could just as easily have the print message be called “ignore” and do nothing.
Doing this allows us to remove an if statement from a method. We like that.
Now let’s do one more thing for fun.
When we add something to Inventory, let’s send a message to the crawl, if we can remember how we do that.
One issue will be that Inventory doesn’t know any other objects, so we can’t send from there. What if when we create an InventoryItem, we send a message to the object we’re passed, maybe “inform”. That’s a bit odd, but we could then implement inform
on the player and she could get the message displayed.
Let’s try it and see how we feel about it. But first commit: improve InventoryItem to always have a target object.
Informing Inventory Owners
I think we’d like to do the informing when we add to inventory, not when we create the item. That would allow us to create inventory items in a batch at any time, and add them as needed. Seems better to me.
function Inventory:add(item)
table.insert(inventory, item)
item:informObject()
end
function InventoryItem:informObject()
local inform = self.object["inform"]
if inform then inform(self.object, "You have received a "..self.name.."!") end
end
We won’t send the message to anyone who doesn’t implement inform
.
function Player:inform(message)
self.runner:addTextToCrawl(message)
end
So that works as advertised. I like two things about this.
First, it’s safe, because if the object doesn’t understand inform
we don’t send it. Second, we just inform the object and it can do what it wants. The player just passes the message up to the runner. Simple, clean, let someone else do your work for you.
Let’s remove the test blue jar and the extra inventory items, then commit.
Oh, one more thing. I want to reserve the jars for magical items, so let’s change the speed guy’s icon.
local LootIcons = {Strength="blue_pack", Health="red_vial", Speed="green_flask",
Pathfinder="blue_jar"}
Commit: remove room 1 init code for Pathfinder Loot.
I’ve noticed some things needing improvement. First, if you find more than one inventory item of the same kind, it gets added to Inventory. I guess that’s OK. Some items might be one-use only, so having a few could be handy. But I’d like to have at most one Pathfinder jar per level. And I’m not sure if it is reusable or not. I think we’ll deal with that by improving the “AI” that places loot, in due time.
Also, the pathfinder doesn’t stand on the WayDown and then disappear, he disappears when he steps toward it. Let’s review that code.
function PathMonsterStrategy:execute(dungeon)
local newTile = self.map:nextAfter(self.monster:getTile())
if newTile then
self.monster:basicMoveToTile(newTile)
self.tile = self.monster:getTile()
else
self.monster:pathComplete()
end
end
Hm. I was sure that it used to stand right on the WayDown. What does the path-finding do?
Ah:
function Map:nextAfter(tile)
local cell = self:getCellFromTile(tile)
local prev = cell.parent
if not prev then return nil end
return self.dungeon:getTile(vec2(prev.x,prev.y))
end
We get the cell we’re on. If it has a parent, we move to it. It seems to me that we should move to the WayDown and stand there until the next move.
Let’s see about the tests for this. Here’s one:
_:test("target accepts tile", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
local t = FakeMapTile(10,10)
map:target(t)
map:run()
t = FakeMapTile(11,10)
local g = map:nextAfter(t)
local pos = g:pos()
_:expect(pos).is(vec2(10,10))
end)
Our target is 10,10, and we’re checking the next after 11,10. And we get 10,10, not nil. What’s up here? I’m missing something.
The TileArbiter has no entrry for WayDown vs Monster, and the default refuses to allow the monster to move in. So that’s likely the issue:
t[WayDown][Monster] = {moveTo=TileArbiter.acceptMove }
This will allow other monsters to step on the WayDown but that’s OK. Let’s see if this resolves the issue in our favor.
Yes, it does. Commit: Pathfinder goes on top of WayDown before going down.
It’s 1130, and I started at about 0830 I think, so that’ll do for the day.
Summary
Again, everything went pretty smoothly. I was briefly confused about the Cloud not going down the WayDown, but that was easily resolved once I thought to check the Arbiteer.
We may decide to do a more advanced moveTo
that would keep other monsters off the WayDown, but that’ll be easily done if we decide to do it.
I wish I had done a little more testing of the new inventory feature, but it did go in smoothly and I think it’ll bear weight pretty well.
We need to start looking at some more dungeon-wide features, such as how many of various Loot types to spread around. I’d like to make the Decor items “interesting”. Some of them might have valuables in them, and some might be dangerous. If you were to rummage around in a skeleton, you might find some loot but there’d be a danger of disease.
Doing more strategic things in the dungeon will be interesting, since there is almost no information in the system about the overall dungeon structure. Perhaps that’s a good enough reason to start working in that direction.
One more thing.
The Codea forum member “ubergoober”, who has some game experience and has had some interesting ideas, pointed out that it is possible to resize graphical items within Codea, by reading the asset and then writing it at another scale into a smaller image, and saving the image. That would save my futzing around in Procreate, so when the next opportunity arises to resize something, we’ll try to remember to do that.
I’m delighted to hear from Bruce and upergoober about these articles and I’d love to hear from anyone and everyone who finds value in them. And, if you don’t find enough value, please let me know what would work better for you. You never know what I might do …
See you next time!
-
Why do we always “reap” benefits? It’s a mystery, ↩