Dungeon 207
Maybe I’ll fiddle some more with InventoryItem, Loot, and Decor. Depends what time breakfast is.
Reviewing InventoryItem, we find that the array of keyed values that creates one can contain:
icon
: a Sprite, or possibly an actual graphical assetname
: a short name such as “Rock”description
: a longer name, such as “Mysterious Rock of Dullness”object
: an object upon which a method will be called when the Item is touched in inventorymethod
: the method that will be calledattribute
: first parameter to the methodvalue
: second parameter to the methodused
: a message to be displayed after the generic “used” message.
Not all of these key-value pairs are required. The icon
is needed, so that the Item can display. It’s generally a string, and will be used as the default name
if none exists. The object’s init
is this:
function InventoryItem:init(aTable)
self.icon = aTable.icon
self.name = aTable.name or self.icon
self.object = aTable.object or self
self.message = aTable.method or "print"
self.description = aTable.description or self.name
self.used = aTable.used or nil
self.attribute = aTable.attribute
self.value = aTable.value
end
I think I’ll make those last two explicitly say “or nil”. They’ll wind up nil anyway, but I think there’s value to being explicit that nil is OK. Commit: Added explicit or nil
to init
for clarity.
Let’s review some of the flow for an Item.
When, for example, a Decor gives an item to the Player, it starts this sequence:
function InventoryItem:addToInventory()
if self.icon then
Inventory:add(self)
elseif self.object then
self:informObject("You received nothing.")
end
end
function Inventory:add(item)
self:put(self:get(item):increment())
item:informObjectAdded()
end
function InventoryItem:informObjectAdded()
self:informObject("You have received a "..self.description.."!")
end
So you’ll get a message with the description if the Item has one, and the name if not.
When you touch an Item in inventory, to use it, this happens:
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
Inventory:remove(self)
self:informObjectRemoved()
self:usedMessage()
self.object[self.message](self.object, self.attribute, self.value)
end
end
function Inventory:remove(item)
self:put(self:get(item):decrement())
end
function InventoryItem:informObjectRemoved()
self:informObject("You have used a "..self.description.."!")
end
function InventoryItem:usedMessage()
if self.used then
Bus:inform(self.used)
end
end
This will put out two messages if there is a used
item in the Item’s table, such as
You have used a Mysterious Rock of Dullness.
You have mysteriously dulled all the sharp objects near by.
The first message always comes out. The second is optional.
Hmm …
I’m noticing some asymmetry here. When we add the item, the inform
is triggered in Inventory:add
, but when we remove it, the inform
is triggered in touched
. (The whole contents of touched
needs to be a method, I believe.)
Be that as it may, it seems like either the Item should do the informing in both cases, or the Inventory should.
I also notice this little batch of methods:
function InventoryItem:informObjectAdded()
self:informObject("You have received a "..self.description.."!")
end
function InventoryItem:informObjectRemoved()
self:informObject("You have used a "..self.description.."!")
end
function InventoryItem:informObject(msg)
local implementsInform = self.object["inform"]
if implementsInform then self.object:inform(msg) end
end
This will send those messages to the object
in the Item’s table. So far we have only two such objects, the Player and the Bus. They both implement inform
:
function Player:inform(message)
Bus:inform(message)
end
function EventBus:inform(message)
if type(message) == "string" then
message = splitStringToTable(message)
end
for i,msg in ipairs(message) do
Bus:publish("addTextToCrawl", self, {text=msg})
end
end
So, bottom line, the messages come out. What else do we ask the object
to do? We wind up sending it the method
. In existing InventoryItems, we find these possibilities:
object | method |
---|---|
player | curePoison |
player | spawnPathfinder |
Bus | publish |
player | addPoints |
Adding health or speed or strength to the player might as well be sent to player, as should curePoison. It doesn’t make quite as much sense for the player to spawn the Pathfinder, but it is spawned right next to the player, so it sort of makes sense.
I don’t see much value to sending the inform
messages to the player to get them sent to the Bus. I suppose the player could, in principle, modify the message.
The newest InventoryItem, the Mysterious Rock of Dullness, just sends the message “dullSharpness” via the Bus, and anything that knows how to become dull can subscribe and deal with the message. In a full implementation, I imagine that only sharp objects within some range of the player would be dulled. Or perhaps the dullness would depend on distance, so close ones are totally dulled, but ones further away just reduce their damage capability rather than reduce it to zero.
Anyway, do we want to untangle this inform
asymmetry?
Let’s do. Let’s remove the call from Inventory:add
, and put it into the Item:
function Inventory:add(item)
self:put(self:get(item):increment())
end
function InventoryItem:addToInventory()
if self.icon then
Inventory:add(self)
self:informObjectAdded()
elseif self.object then
self:informObject("You received nothing.")
end
end
Test. At first all seems well, but then I notice that I’m not getting messages when I pick up loot. Things go into inventory, but no messages come out. Did I just break that?
Let’s revert and find out.
Right. Loot now does display its message, where formerly it didn’t. Better look and see how Loot works.
function Loot:addLootToInventory(aPlayer)
local item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
Inventory:add(item)
end
Right. Should tell the item to add itself.
function Loot:addLootToInventory(aPlayer)
local item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
item:addToInventory()
end
Now put that other change back. I should have stashed it or some clever Git thing like that.
Weird. Now the Magic Jar and Pathfinder thing don’t display.
function Loot:actionWith(aPlayer)
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
self:addPathfinderToInventory(aPlayer)
elseif self.kind == "Antidote" then
self:addAntidoteToInventory(aPlayer)
else
self:addLootToInventory(aPlayer)
end
end
Ah, those other two functions didn’t get converted. Duh.
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
item:addToInventory()
end
function Loot:addAntidoteToInventory(aPlayer)
local item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
item:addToInventory()
end
What we have just encountered is one of those things that goes wrong when there’s duplication. The
Inventory:add(item)
Occurred in three places and until I changed all of them, not everything worked. I wonder if there are more … no, there seem not to be.
If we had had only one call, instead of three, we’d have only had this problem once instead of thrice. Of course a more powerful editor, or a more powerful programmer, might have found this the first time, but the fact that I tolerated this tiny bit of duplication is implicated in the difficulty.
Let’s commit what we have and then see if we can improve this. Commit: move calls to inform about inventory additions and removals all into InventoryItem.
Let’s see what we have here:
function Loot:addLootToInventory(aPlayer)
local item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
item:addToInventory()
end
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
item:addToInventory()
end
function Loot:addAntidoteToInventory(aPlayer)
local item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
item:addToInventory()
end
Those are called from here:
function Loot:actionWith(aPlayer)
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
self:addPathfinderToInventory(aPlayer)
elseif self.kind == "Antidote" then
self:addAntidoteToInventory(aPlayer)
else
self:addLootToInventory(aPlayer)
end
end
To make this better, let’s make this worse. Let’s inline all those add
methods:
function Loot:actionWith(aPlayer)
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
local item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
item:addToInventory()
elseif self.kind == "Antidote" then
local item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
item:addToInventory()
else
local item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
item:addToInventory()
end
end
This should be a pure refactoring. Testing says OK. We could commit.
Remove duplicate occurrences of local
:
function Loot:actionWith(aPlayer)
local item
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
item:addToInventory()
elseif self.kind == "Antidote" then
item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
item:addToInventory()
else
item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
item:addToInventory()
end
end
Easy enough. Could commit. Remove duplicate addToInventory
:
function Loot:actionWith(aPlayer)
local item
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
elseif self.kind == "Antidote" then
item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
else
item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
end
item:addToInventory()
end
Still good. Could commit. Extract the if nest:
function Loot:actionWith(aPlayer)
local item
self:getTile():removeContents(self)
item = self:createInventoryItem(self.kind)
item:addToInventory()
end
function Loot:createInventoryItem(kind)
if kind == "Pathfinder" then
return InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
elseif kind == "Antidote" then
return InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
else
return InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
end
end
I kind of wish I had committed earlier. The Loot messages have stopped coming out. I’m going to back these changes out and do again.
This version works:
function Loot:actionWith(aPlayer)
local item
self:getTile():removeContents(self)
if self.kind == "Pathfinder" then
item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
elseif self.kind == "Antidote" then
item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
else
item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
end
item:addToInventory()
end
I honestly don’t see what I did wrong in the extract. I guess I’ll just do it again, more slowly.
I tried again, and again it didn’t work. Ah. I see the problem at last. We need to pass the aPlayer
parameter into our function.
Darn nils. OK, this works, we’re back on the rails:
function Loot:actionWith(aPlayer)
local item
self:getTile():removeContents(self)
item = self:createInventoryItem(aPlayer)
item:addToInventory()
end
function Loot:createInventoryItem(aPlayer)
local item
if self.kind == "Pathfinder" then
item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
elseif self.kind == "Antidote" then
item = InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
else
item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
end
return item
end
Remove duplication:
function Loot:createInventoryItem(aPlayer)
if self.kind == "Pathfinder" then
return InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
elseif self.kind == "Antidote" then
return InventoryItem{icon="red_vase", name="Poison Antidote", object=aPlayer, method="curePoison"}
else
return InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
end
end
Everything is working. Commit: refactoring Loot to remove harmful duplication.
It’s Sunday, and Fathers’ Day, and breakfast is coming right along. Let’s sum up.
Summary
There are a few lessons here, both small and large.
A smaller one is that unnoticed duplication can cause us trouble. We could “be more careful”, but that trick never works. When we change the “right” protocol to do something, and we miss a change, bad things will happen.
A larger lesson is that, at least in Lua, extracting a method is tricky, especially when it’s formatted as weirdly as our InventoryItem lines are. It was difficult to notice that aPlayer
was in there, although a more clever programmer might have wondered sooner why the parameter was sent if it was never used.
A more powerful refactoring tool would have told us to pass aPlayer
down, and we’d have been fine. But our refactoring tool is my brain, and it is only as powerful as it is.
We could, however, beef up InventoryItem to require the object
parameter to be non-nil. I’ve made a card for that.
An even larger lesson is that we can always find code that can be improved, and if it’s code we’re actually working with, that improvement can pay off. We don’t want to be gold-plating, but it’s clear that today’s little task would have gone much faster had those Inventory:add
calls not been duplicated.
Will it pay off to have removed the duplication, after finding and fixing them all? I’m not sure, but I am sure that it pays off often enough that I wish I were more regular about doing it.
And here’s a small but important lesson: if I had committed every time I said “I could commit now”, things would have gone faster. I did at least two reversions by carefully using “undo”. A quick press of the revert button would have sped me up quite a bit.
Anyway, a fun morning. See you next time!