Dungeon 206
Some consolidation and refactoring is what I have in mind. What will I really do? At this moment, I have little or no idea.
Yesterday’s summary created quite a list of things that might need improvement. Short form:
- New inventory item creation involves several edits in different places;
- Monsters are just one class, but they are developing more and more unique per-type behavior. Suggests a subclass or delegate?
- Publish-Subscribe nicely reduces direct connections, but it also requires coordination between remote parts of the code so that they use the same event names and such.
Looking at these topics one way makes me think that, at least in a real game application, we’d want more capability in our “Making App”, the app that helps us make the “Shipping App”, the actual game. That might be fun to think about. Let’s use our Rock as an example.
I began, if I recall, by finding a Rock. I was looking for a Brick, but couldn’t find one of those, but I did stumble, as it were, across a rock.
I began by looking at the Decor creation, because I’m trending toward having all discoverable items inside Decor, rather than just lying about as loose Loot. (Trending: Might get there, might not. Anyway, decided to do it inside Decor:)
function GameRunner:createDecor(n)
local sourceItems = {
--
InventoryItem{ icon="red_vase", name="Poison Antidote", object=self.player, method="curePoison" },
InventoryItem{ icon="blue_jar", name="Magic Jar", object=self.player, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature" },--]]
InventoryItem{ icon="rock", name="Rock", object=self.player, method="dullSharpness", description="Mysterious Rock of Dullness" },
--
InventoryItem{object=self.player},
InventoryItem{object=self.player},
InventoryItem{object=self.player},--]]
}
local items = {}
for i = 1,n or 10 do
table.insert(items, sourceItems[1 + i%#sourceItems])
end
Decor:createRequiredItemsInEmptyTiles(items,self)
end
Here we create InventoryItems, which expect an input table telling them what they need to know. Using our Rock as an example, we can imagine an outline for creating the Item like this:
InventoryItem
name: Rock
icon: rock
object: player
method: dullSharpness
What we’re saying here is that we have an InventoryItem whose name is Rock, whose icon is something (a Sprite) named rock, and that when it is clicked, it sends “dullSharpness” to the player.
“rock” might not be a good name for the Sprite, and perhaps “Rock” isn’t even a good name for the item. But the computer doesn’t care, and for right now, those are the names we used.
But there is no Sprite called “rock”. So I imported that graphic I found as a Sprite:
sheet = asset.Rock
names = {"rock"}
Sprites:add(names,sheet,0,0,0,0)
The above is an entry in Sprites:initializeSprites
, which reads in the asset sprite sheets and divides them up if necessary. This one doesn’t need division, which is represented by the fact that there’s only one name in its names table: each slice of a sheet gets its own name. The zeros are ad-doc values to adjust how much of the sprite sheet is to be skipped over when slicing. It amounts to cropping the sheet before slicing.
So how might we extend our outline to allow for the Sprite? I can imagine two ways:
InventoryItem
name: Rock
icon: rock
object: player
method: dullSharpness
...
Sprite
name: rock
sheet: asset.Rock
crop(0,0,0,0)
Here we have the item and sprite defined separately, but in the same outline. We might also do it this way:
InventoryItem
name: Rock
icon: Sprite
name: rock
sheet: asset.Rock
crop(0,0,0,0)
object: player
method: dullSharpness
Here, we’ve embedded the Sprite definition inside the InventoryItem’s outline. We could even leave the name out, perhaps auto-generating it as “Rock-sprite” or something. And of course, crop could default to all zeros.
I’m wondering how InventoryItem uses the info it has. Let’s look.
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.attribute = aTable.attribute
self.value = aTable.value
end
function InventoryItem:draw(pos)
fill(136,129,107)
rect(pos.x,pos.y,64,64)
sprite(Sprites:sprite(self.icon), pos.x,pos.y, 40)
end
The icon
variable is used to look up the sprite, here:
function Sprites:sprite(name)
if type(name) == "string" then
local spr = sprites[name] or asset.banner_2
return spr
else
return name
end
end
According to that code, if you pass in a string, it looks it up in a table called sprites
, a local table that it uses to store all the sprites it knows by name:
Sprites = class()
local sprites = {}
But that code also allows a sprite to be passed in that is not a string. Does that happen, and if so, where and why?
function Button:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
translate(self.x,self.y)
tint(255,255,255,128)
sprite(Sprites:sprite(self.img), 0,0, self.w, self.h)
popStyle()
popMatrix()
end
Here, the img
is in fact of type image
and has been drawn to be a rectangle containing the button text.
function WayDown:draw(tiny, center)
sprite(Sprites:sprite(asset.steps_down),center.x,center.y,64,64)
end
Here, the WayDown knows its asset directly, passes it to Sprites, which returns it.
Why are we doing this? We could just as well have said this:
function WayDown:draw(tiny, center)
sprite(asset.steps_down,center.x,center.y,64,64)
end
The effect is the same.
If I recall–or making up a possible reason now–the idea is to have all the draw
functions become more and more alike, so that we can move them upward in the hierarchy, making them less specialized, reducing the need to have so many of them, and making them less error prone.
So I believe that I made the decision that we’d “always” use Sprites, so as to leave the code agnostic to what particular kind of thing we have as our icon, a sprite name, an asset, or (in principle) some other thing that the sprite
function might return.
We might never get there. But we are in the business of changing code, and one of the ways we change it is to be more and more similar when we’re doing similar things, so that we’ll understand what we see more readily and so that we can, when the time comes, consolidate similar notions.
But I digress.
Next, we had to implement dullSharpness
on Player:
function Player:dullSharpness()
local msg = "You have dulled all the sharp objects near by."
Bus:publish("addTextToCrawl", self, {text=msg})
Bus:publish("dullSharpness")
end
Here, we give a specialized message to the user, saying what happened. We also have the general message “You have used the Mysterious Rock of Dullness”, which was issued by the Inventory.
And then we publish the general message “dullSharpness”. That has to be subscribed to by the appropriate Monsters:
function Monster:initSubscriptions()
if self:name() == "Ankle Biter" then
Bus:subscribe(self, self.becomeDull, "dullSharpness")
end
end
We could imagine other monsters that become dull. Perhaps the Death Fly can’t bite, or the Toothhead, or the Murder Hornet. Or the Vampire Bat or Serpent or Yellow Widow. There’s a lot of bitey stuff up in this um dungeon. And we implemented becomeDull
:
function Monster:becomeDull()
self.damageRange = {0,0}
end
All this does, for now, is reduce the possible damage that the Ankle Biter can do down to zero. He still saws and cuts and grinds, but nothing happens. He’s dull, you see.
So we have to add material, somehow, to our outline, including the actions these guys take, or at least referring to the specific that the programmers have provided. The outline might start to look like this:
InventoryItem
name: Rock
icon: Sprite
name: rock
sheet: asset.Rock
crop(0,0,0,0)
object: player
method: dullSharpness
say: You have dulled all the sharp objects near by
publish: dullSharpness
Monster
type: Ankle Biter, Murder Hornet
subscribe:dullSharpness
do:
damageRange = 0,0
The Monster stuff might be right here near the item, or it might be down among other monster stuff. It would be nice, I imagine, if we could keep it distributed like the above, where a given idea could be kept together.
One nice thing about having this outline form of specification might be that it wouldn’t require us to think of and sort out so many method names. You can at least imagine that the various names we use currently to connect things, rock, dullSharpness, becomeDull, and so on, could be either auto-generated, or perhaps these linkages would be implemented in some kind of control table that was “above” the objects.
I’m not sure at this moment that I want to do an outline table like that, and I’m even less sure what form it should really take. It needs some kind of structure, and probably free-form isn’t it, and I hope YAML isn’t it.
I do wonder whether we can simplify what we have here.
Right now, the InventoryItem’s table is this:
InventoryItem{ icon="rock", name="Rock", object=self.player, method="dullSharpness", description="Mysterious Rock of Dullness" },
Maybe there’s no need to go through the player for this. We operate the item with this code:
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
Inventory:remove(self)
self:informObjectRemoved()
self.object[self.message](self.object, self.attribute, self.value)
end
end
There we send the message (which is method
in the table to the
object. Imagine that we had another element in the table, the
appliedMessage, and that
object was
Bus and
method was
publish and that
attribute and
value were set such that we just published the
dullSharpness` from here instead of through Player.
That would be more direct, and while the table would be a bit more complicated, we’d save pushing random methods down into Player.
Shall we try that?
Let’s do.
I’ll try this:
InventoryItem{ icon="rock", name="Rock", object=Bus, method="publish", attribute="dullSharpness", description="Mysterious Rock of Dullness" },
I’ll turn off other options in Decor, so that all Decor have rocks, and I need to make the Monsters mostly Ankle Biters.
So that’s nearly good. We don’t get any message out when we use the Rock, but we can see the count go down. I expected the “You have used” message, so we need to check that. But the publish worked, because the Ankle Biter scored no more points on us after we touched the Rock.
So this scheme is semi-good. Why didn’t I see that message?
function InventoryItem:touched(aTouch, pos)
if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
Inventory:remove(self)
self:informObjectRemoved()
self.object[self.message](self.object, self.attribute, self.value)
end
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
Ah. I think we should just send that message directly, rather than through inform
. Or, of course, we could implement inform
on Bus, and retain the ability NOT to get the message out.
function Player: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
That could be useful on Bus. Let’s try it. We can always revert.
Yes:
-- class methods
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
Now I can do this:
function Player:inform(message)
Bus:inform(message)
end
So that’s nice.
We could enhance the InventoryItem to allow a message when used.
Let’s first see why a couple of tests are whining.
1: Create the monsters -- Monster:127: attempt to index a nil value (global 'Bus')
1: Can select monsters by level -- Actual: 5, Expected: 7
The first is pretty clear: we don’t have an EventBus initialized inn those tests. The second is because I’ve set the Ankle Biter to be a level one monster, but have turned off a bunch of others. I’m not quite clear how we get five. Let’s see the code:
_:test("Can select monsters by level", function()
local t = Monster:getMonstersAtLevel(2)
_:expect(#t).is(7) -- four #1, three #2
end)
I’m thinking that will come back when I turn off my handy debug settings. But if I want to commit, I have to do that. OK.
Bizarrely, I get a new batch failing:
3: rollDamage -- Actual: 2, Expected: 4
3: rollDamage -- Actual: Spider takes 2 damage!, Expected: Spider takes 4 damage!
Oh, I think I know what may be going on. I think we have a fake RNG going in here.
_:test("rollDamage", function()
local result
local i,r
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
randomNumbers = {4}
local co = CombatRound(player, monster, fakeRandom)
co:rollDamage()
result = co.commandList
i,r = next(result,i)
_:expect(r.op).is("extern")
_:expect(r.receiver).is(monster)
_:expect(r.method).is("damageFrom")
_:expect(r.arg1).is(4)
i,r = next(result,i)
_:expect(r.op).is("display")
_:expect(r.text).is("Spider takes 4 damage!")
end)
Yes. We tweaked CombatRound:rollDamage
, didn’t we?
function CombatRound:rollDamage()
if self.attacker.rollDamage then
damage = self.attacker:rollDamage()
else
local damage = self.random(1,6)
end
self:applyDamage(damage)
end
We’re now allowing attackers to have their own rollDamage
, and Monster has it. Let’s reduce the test a bit, to read out the damage as rolled rather than try to force it.
Had to fix this to use the local correctly:
function CombatRound:rollDamage()
local damage
if self.attacker.rollDamage then
damage = self.attacker:rollDamage()
else
damage = self.random(1,6)
end
self:applyDamage(damage)
end
The test was actually finding a bug. And then I modify the test to accept whatever is rolled:
_:test("rollDamage", function()
local result
local i,r,rr
local player = FakeEntity("Princess")
local monster = FakeEntity("Spider")
--randomNumbers = {4}
local co = CombatRound(player, monster)
co:rollDamage()
result = co.commandList
i,r = next(result,i)
_:expect(r.op).is("extern")
_:expect(r.receiver).is(monster)
_:expect(r.method).is("damageFrom")
--_:expect(r.arg1).is(4)
i,rr = next(result,i)
_:expect(rr.op).is("display")
_:expect(rr.text).is("Spider takes "..r.arg1.." damage!")
end)
Test runs. I remove the fake random number function (just commented out, call me old school) and mark it as unused in CombatRound init:
function CombatRound:init(attacker,defender, rngNotUsed)
self.attacker = attacker
self.defender = defender
self.random = rngNotUsed or math.random
self.commandList = {}
end
I don’t think that will come back, but at the time we used it, I wanted to be able to know what the RNG was going to produce.
Tests green. Commit: Rock InventoryItem no longer calls through Player, instead publishes “dullSharpness” direct from Inventory. Still need use-specific message, see strong letter to follow.
Now let’s add a usedMessage
element to the InventoryItem.
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 set it explicitly nil as an attempt at clarity. YMMV.
Now to use it:
InventoryItem{ icon="rock", name="Rock", object=Bus, method="publish", attribute="dullSharpness", description="Mysterious Rock of Dullness", used="You have dulled all the sharp objects near by." },
Now to make it appear:
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 InventoryItem:usedMessage()
if self.used then
Bus:inform(self.used)
end
end
Sure enough, the message comes out:
Super. Commit: Rock now displays “You have dulled all the sharp objects near by”. All Ankle Biters presently alive will be dulled.
Let’s review and call it a morning. I’ll publish this on Sunday, to allow for Juneteenth voices to be better heard today.
Review
We haven’t done anything about our “outline” idea, but we do have a better sense of the things that need to come together. And we’ve made the overall job easier, by allowing inventory items direct access to Bus:publish
, which will let them have their affect without being mediated by the Player. We’ll want to change all the Items to work that way if they can.
So we’ve learned a little and improved the code a little. Not bad for a Saturday. Now I’m gonna go see what Juneteenth has to offer in terms of ideas and learning.
See you next time!