Dungeon 152
More work on untangling the objects. Perhaps we’ll find some interesting refactoring. Maybe a feature?
Overnight Thoughts
It occurred to me last night that some tests might be easier to write using the new EventBus. We might be able to subscribe to the broadcasts of some items and inspect the message contents. I don’t have anything specific in mind on that. Just a thought.
Do We Need a Better Making App?
In last night’s Zoom Ensemble we were talking mostly about a tiling problem that some of the gang are working on, in addition to our usual general chat. The conversation didn’t apply directly to dungeons, but we did touch on something that struck me as interesting. So I’m going to write about it here and see if I can make sense of it.
If we imagine a game board with our player and monsters on it, as the human player, we can see the whole board and imagine picking up a little princess piece and setting her on the next tile, moving toward something we consider interesting. We can see the board, and the tiles, at the same time, and we can push our princess pawn up or down, left or right, with no problem.
In the computer game, it’s very different. The Tiles are in a big array arranged in rows and columns. There’s basically an array of rows, each containing a tile for each column in that row. (Maybe it’s an array of columns. I don’t even need remember, which is part of the point.)
There’s no knowledge in the program that is like the knowledge that the human player can take in in one glance, box up there, skeleton to my left, door to my right. Take the monsters, for example.
Monsters who are not symmetric always flip their image so that they are facing the player. It would look odd to have them facing away. How do they know?
A tile can return the direction to another tile:
function Tile:directionTo(aTile)
local t = aTile:pos()
return vec2(sign(t.x-self:pos().x), sign(t.y-self:pos().y))
end
That’s a vec2
containing 1, 0, or -1 in each coordinate. And a monster can ask the GameRunner, and then flip itself accordingly:
function GameRunner:playerDirection(aTile)
return aTile:directionTo(self.player:getTile())
end
function Monster:flipTowardPlayer()
local dir = self.runner:playerDirection(self.tile)
if dir.x > 0 then
scale(-1,1)
end
end
Now one of the issues I’m most concerned about in the current design is that the GameRunner knows everyone, and almost everyone knows the GameRunner, and almost everything you want to know, you ask the GameRunner, and it forwards the question to the right objects and then returns the answer to you.
That’s kind of OK but it means that GameRunner has a lot of methods. How many? More than 50. It has a pile of member variables as well, certainly a dozen, perhaps as many as 20.
That’s a lot. It’s hard to make a case that an object is cohesive if it has 20 member variables, especially with names like tiles, monsters, cofloater, player, musicplayer, and so on.
Now the thing is, every object that lives in the game must be held on to by some permanent object. If not, it will be garbage collected, and there goes your favorite treasure. And any object that is drawn has to be told to draw itself, and that needs to be done at the right time. If you were to draw a monster and then draw the tile it was on, the monster would be under the floor. Not usually what one wants.
So between making sure that everything is held on to, the need for some way to get global information into the objects that are essentially local, like entities, and the inevitable inertia of “the way we do it”, we wind up with a sort of God object, in GameRunner, and with nearly everyone pointing at GameRunner in order to get their job done.
And it’s a mess. In play, it’s not bad. And when adding capabilities, it’s not bad. You’re usually dealing with some one or two objects, and you can usually get, or arrange to get, the information you need from GameRunner or someone else you know.
But testing! Testing is a bear, because you need to set up a whole universe in which to test, and that’s tedious.
Now there is a sort of half-decent option. We could take the time to build a TestGame universe, probably with fewer tiles, and somehow only putting in the objects we want to test, or faking them. But if we do that, then we’ll be testing the TestGame, not the real objects, in many cases, and our testing will be weak.
It could still be a good thing to do. We might at least build a function to use to set up a working environment, so that future tests wouldn’t be so hard to set up.
There’s always a tradeoff here. We do need good tools, and some of our work needs to focus on those tools. GeePaw Hill has been writing about the “shipping app” and the “making app”, and it’s a good distinction. Check out some posts at GeePawHill.org.
So there’s a case for making a making app here.
But that won’t fix this messy design. Instead it will just work around it.
Which Way, and Why?
Which way should I go, and why? If could work on a Making App, and it might be fun, but since I’m working in Lua, not many of you would get much benefit, especially since you probably already have tools much better than mine.
But everyone has, or has had, or will have, a bit of messy design. And the things we do to improve a messy design are pretty much the same from language to language, system to system.
So I think we’ll keep focusing on untangling our objects and adding capabilities to the Shipping App, with occasional looks at tools for our Making App. There’s not much we can do there, I suspect, but we’ll try to stay alert.
Yesterday we managed to disconnect the Decor from the GameRunner. A Decor now just knows its tile, the item it gives away, if any, and its image.
The tile is there for two reasons: First, when you set yourself onto a tile, at creation time, you call this:
function Tile:moveObject(thing)
local from = thing:getTile()
if from then from:removeContents(thing) end
self:addDeferredContents(thing)
thing:setTile(self)
end
This code removes a thing from the tile it’s on, and puts it on the new tile. It then tells the thing to set its tile. We do that roundabout thing because we’re enforcing the invariant that an object can only be on one tile. So we encapsulate removing it from any tile it’s on, adding it to the new tile, and then making sure it has set itself to the new tile.
Now it turns out that Decor and other things don’t ever have a previous tile. And I’d like not to have to implement getTile and setTile to do nothing. But the Decor needs to know its tile in draw:
function Decor:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
translate(g.x, g.y)
scale(self.scaleX, 1)
sprite(self.sprite,0,0, 50,50)
popStyle()
popMatrix()
end
Who’s calling this?
function Tile:drawContents(tiny)
for k,c in pairs(self.contents) do
c:draw(tiny)
end
end
Let’s extend this call. We could pass the tile, or we could pass the graphicCenter directly. Let’s do the latter, though we may find later that the other is better.
function Tile:drawContents(tiny)
local center = self:graphicCenter()
for k,c in pairs(self.contents) do
c:draw(tiny, center)
end
end
This won’t bother anyone else. Now in Decor:
function Decor:draw(tiny, center)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
translate(center.x, center.y)
scale(self.scaleX, 1)
sprite(self.sprite,0,0, 50,50)
popStyle()
popMatrix()
end
Now I can leave getTile
and setTile
empty.
A curious thing has happened. I can’t step onto the Decor tile any more.
Put back the setter and getter, and I can. This is irritating.
Darn. There’s really no way out of it, unless we were to revamp a lot of logic. The TileArbiter decides whether you can move to a tile or not, given your interaction with existing tile “residents”. The TileArbiter knows the two interacting objects, but gets the tile information from them.
So there’s an implicit “rule” in the system: everyone knows where they are, in case someone asks. OK, that’s not worth untangling, but I like the change not to use the tile in the draw, so I’ll commit that. “Decor is passed graphic center for draw”.
Return a Few Levels Up
OK, I was cruising along at a high level, then dove down to try to improve things a bit. Didn’t get as far as I had hoped, but a small improvement.
I find that pattern of thought is quite common in how I work. I start out thinking about a large problem–everything’s too connected–then look at specific cases, then see one I may be able to improve, try to improve it, commit if I succeed, then pop back up to somewhere near the higher level.
But then I’ll be informed about some details that weren’t fresh in my mind, and so I’m not where I was, I’m somewhere new and hopefully better.
Like just now, I’m thinking about Loot, the other objects that can be found in the Dungeon. Well, Keys and Chests as well.
One thought is that all of these, Loot, Decor, Key, Chest are rather the same. And they certainly all implement getTile
, setTile
, and draw
in much the same way. They probably have other things in common.
Interrupt
I just remembered something. When a Decor gives you its item, it can give it repeatedly. We should change that right now.
function Decor:actionWith(aPlayer)
local round = CombatRound(self,aPlayer)
self.danger(self,round)
if self.item then self.item:addToInventory() end
end
That becomes:
function Decor:actionWith(aPlayer)
local round = CombatRound(self,aPlayer)
self.danger(self,round)
if self.item then
self.item:addToInventory()
self.item = nil
end
end
Test. Works. I wish I had a test for that. Let’s write one.
Items, it seems, know how to add themselves to inventory. So we’ll need a fake item to handle this in our test.
Writing the test causes me to want new protocol on Decor, a giveItem
method:
_:test("Decor gives item only once", function()
local tile = FakeTile()
local item = FakeItem()
local decor = Decor(tile,item)
decor:giveItem()
_:expect(receivedItem).is("item")
receivedItem = "nothing"
decor:giveItem()
_:expect(receivedItem).is("nothing")
end)
Decor will permit this easily, and arguably it should have been this way all the time. We refactor this:
function Decor:actionWith(aPlayer)
local round = CombatRound(self,aPlayer)
self.danger(self,round)
if self.item then
self.item:addToInventory()
self.item = nil
end
end
Into this:
function Decor:actionWith(aPlayer)
local round = CombatRound(self,aPlayer)
self.danger(self,round)
self:giveItem()
end
function Decor:giveItem()
if self.item then
self.item:addToInventory()
self.item = nil
end
end
Now I think my test should run.
And it does. That’s nice, gives a bit of security about not giving things away twice. Could have saved a whole tour of the dungeon there.
You can see how my general feeling about the difficulty of tests affects me. My first inclination is to code, not test. I know that’s not my best behavior, but since I’ve been programming for six decades or something, some of my habits are hard to break. And if tests were serving me better right here, it would be easier.
Anyway commit: Decor gives item only once.
Where we we?
Oh, yes. Loot, Decor, Key, and Chest all have some things in common, including that they have to have getTile
and setTile
. They all draw, as well, as they are contents. That means they probably all ask for the graphic center. If they do, we can remove that call.
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:drawSprites(tiny)
if not tiny and self.currentlyVisible then self:drawContents(tiny) end
popStyle()
popMatrix()
end
function Tile:drawContents(tiny)
local center = self:graphicCenter()
for k,c in pairs(self.contents) do
c:draw(tiny, center)
end
end
This is a bit odd, since (presently) we don’t draw them if the tiny flag is set. But we could. Let’s change the Loots etc to use this info.
function Loot:draw(tiny, center)
pushStyle()
spriteMode(CENTER)
sprite(Sprites:sprite(self.icon),center.x,center.y+10)
popStyle()
end
That’s good. Commit: Loot draws with given center, does not use its tile for drawing.
Now Key:
function Key:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(asset.builtin.Planet_Cute.Key,g.x,g.y, 50,50)
popStyle()
popMatrix()
end
As before:
function Key:draw(tiny, center)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
sprite(asset.builtin.Planet_Cute.Key,center.x,center.y, 50,50)
popStyle()
popMatrix()
end
Tested, works, commit: Keys draw with given center, not tile access.
OK, Chest.
function Chest:draw(tiny)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CORNER)
local g = self.tile:graphicCorner()
sprite(self.pic,g.x + 7,g.y,50,85)
popStyle()
popMatrix()
end
This guy doesn’t use center, he uses corner. Let’s make him use the center, and adjust his coordinates as needed.
Some fiddling with the sizes and I get something I like, making them as large as will fit nicely on a tile, and adjusting the height because their center seems not to be where it ought to be.
function Chest:draw(tiny, center)
if tiny then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
sprite(self.pic,center.x,center.y+10, 55,75)
popStyle()
popMatrix()
end
That’s good. Commit: Chests use provided graphic center, not referencing tile to get it.
OK, I think they’re all doing the same thing now, except each one accesses its pictures a bit differently, especially the Chest which has an open and closed form. I rather like that. The other items, Loots and Keys, I’m not sure if they should be just lying about or not.
Looking Forward
Looking forward, I think we’ll fold all the powerup loots and the keys into Inventory items and hide them in Decor. We could use some more kinds of Decor images, too. I’ll look for some.
Chests I propose to keep, because I have in mind that some of them will be Mimics and turn into nasty monsters. Maybe there will be a way to tell the difference–but maybe if you manage to down a Mimic you’ll get something good.
All in the future.
I see duplication between the Decor, Loot, Key, and Chest, but I’m not sure we should go after it, since some of them, probably both Loot and Key, are likely to disappear. And we might wind up changing how Chest works. There’s no reason why a Decor couldn’t have two images, one before you’ve messed with it and one after.
What else for today? It’s 1130, and I’ve been at this since 0830 or so, so maybe we’ll wrap it up.
Summary
No great strides today, but lots of little things. I could do lots more if I didn’t record every move in these articles. Or a few more. Or one more. Maybe.
Anyway, our work is best done using lots of tiny steps, not big efforts with big potential rewards. The big rewards are low probability. With small steps, we have a good shot at a small reward, and can usually cut an effort off if it seems not to be going to pay off.
But I’d really like to get to a better design, whether in small steps or not, so that we could look back and see what we might do the next time we find a mess, and what we might do the next time we start making a mess.
See you next time!