Dungeon 283
Pushing the object further down. What does he mean by that?
Last time we were working on the general problem of making it clear who is speaking when a message from an NPC (or other source) enters the message crawl. We got part way there, implementing one way of doing it. Now when Horn Girl “speaks”, the message is prefixed with her name:
Horn Girl: I am your friendly neighborhood Horn Girl!
We wanted to try offsetting the text from a given source so that it would rise centered above that source, rather than always above the player. Honestly, I don’t expect to like that, but whatever way we finally figure out, we need some more work.
Right now, the crawl, implemented by cooperation between Provider and Floater, expects text strings in this code:
function Floater:draw(playerCenter)
local n = self:numberToDisplay()
if n == 0 then return end
pushStyle()
fill(255)
fontSize(20)
local y = self:yOffset()
local pos = playerCenter + vec2(0,self.yOff)
for i = 1,n do
text(self.buffer[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
self:increment()
popStyle()
end
The buffer
contains text, as we can glean from this code:
function Floater:fetchMessage()
local msg = self.provider:getItem()
table.insert(self.buffer, msg)
end
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if item.op == "display" then
return item:message()
elseif item.op == "extern" then
self:privExecute(item)
elseif item.op == "op" then
self:privAddItems(self:execute(item))
else
assert(false, "unexpected item in Provider array "..(item.op or "no op"))
end
return self:getItem()
end
function OP:message()
if not self.speaker then
return self.text
else return self.speaker:name()..": "..self.text
end
end
The OP
instance has the actual speaker in it, i.e. the DungeonObject that’s doing the saying, the NPC in our case. Suppose that in the draw
above, we wanted to translate over to the X position of the speaker’s tile. We’d need the OP
to be in Floater’s buffer, not the text1.
What we want, naively, is to change the code to be this:
function Provider:getItem()
if #self.items < 1 then return self.default end
local item = table.remove(self.items,1)
if item.op == "display" then
return item -- <=== we return the op to be drawn
elseif item.op == "extern" then
self:privExecute(item)
elseif item.op == "op" then
self:privAddItems(self:execute(item))
else
assert(false, "unexpected item in Provider array "..(item.op or "no op"))
end
return self:getItem()
end
function Floater:draw(playerCenter)
local n = self:numberToDisplay()
if n == 0 then return end
pushStyle()
fill(255)
fontSize(20)
local y = self:yOffset()
local pos = playerCenter + vec2(0,self.yOff)
for i = 1,n do
text(self.buffer[i]:message(), pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
self:increment()
popStyle()
end
Note that in the draw loop, we’re now sending message
to the buffer item, which will now be an OP. This won’t quite work, but I know why. I figured it out last night while messing around, and I’m not going to fake discovering it now.
Provider returns a default string when there is nothing to provide. The crawl displays it, scrolling blank lines up the screen forever. It seems harmless, but now of course we have to return an OP.
function Provider:init(default)
self.default = default or "default"
self.items = {}
Bus:subscribe(self,self.privAddTextToCrawl, "addTextToCrawl")
Bus:subscribe(self,self.privAddItemsFromBus, "addItemsToCrawl")
end
There’s the default up there. It needs to be an OP. Here’s all it takes:
function Provider:init(default)
self.default = OP:display(default or "default")
self.items = {}
Bus:subscribe(self,self.privAddTextToCrawl, "addTextToCrawl")
Bus:subscribe(self,self.privAddItemsFromBus, "addItemsToCrawl")
end
The game now works, but tests are failing, because they expect to be provided text. Here’s a typical chunk:
fl:increment(25)
_:expect(fl:yOffset()).is(125.5)
_:expect(#fl.buffer).is(4) -- once at max, never goes down
_:expect(fl.buffer[1]).is("Message 3")
The test increments “time” (we need to talk about that) by 25 and checks the vertical offset and the message that is in the buffer. The thing is, now we need to send message to the buffer contents. This should be easy.
fl:increment(25)
_:expect(fl:yOffset()).is(125.5)
_:expect(#fl.buffer).is(4) -- once at max, never goes down
_:expect(fl.buffer[1]:message()).is("Message 3")
I expect my error count to drop. I think I’ll check just this case before a mass edit. And it checks out. Change the rest … and we are green. I’ll double-check the game, but I’m quite confident that we’re OK. And we are. Commit: Provider provides OP to Floater, formerly just text.
Now Then …
Now, since the sender is in the OP (if there is one), I can try something. One of the ideas for making it more clear what the crawl is referring to is to center the message over the speaker (which could be a set of bones or a chest).
To accomplish that, we need to do a small x translation in the draw code, over to the x position of the speaker. First, I want to change this:
function Floater:draw(playerCenter)
local n = self:numberToDisplay()
if n == 0 then return end
pushStyle()
fill(255)
fontSize(20)
local y = self:yOffset()
local pos = playerCenter + vec2(0,self.yOff)
for i = 1,n do
text(self.buffer[i]:message(), pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
self:increment()
popStyle()
end
To something like this:
function Floater:draw(playerCenter)
local n = self:numberToDisplay()
if n == 0 then return end
pushStyle()
fill(255)
fontSize(20)
local y = self:yOffset()
local pos = playerCenter + vec2(0,self.yOff)
for i = 1,n do
self:drawMessage(self.buffer[i])
pos = pos - vec2(0,self.lineSize)
end
self:increment()
popStyle()
end
function Floater:drawMessage(op)
text(op:message(), pos.x, pos.y)
end
I decided to pull out the message drawing bit, because I’m going to make it more complicated. Everything should be the same now. Shall I test and commit? I’ll surely test.
Good thing that I did, isn’t it? Did you notice that I failed to pass pos
in? If so, why didn’t you mention it? Anyway:
function Floater:drawMessage(op, pos)
text(op:message(), pos.x, pos.y)
end
OK, so that should be just dandy, and it is. Since it was possible to do it wrong, I will commit: Extract Method drawMessage in Floater, for enhancement.
I’m curious how we got our pos
value. Well, it’s the player center, and it’s a parameter. I think I’ll just try sending a message to the op, asking for its x-offset from pos.
function Floater:drawMessage(op, pos)
local deltaX = op:deltaX(pos)
text(op:message(), pos.x+deltaX, pos.y)
end
And now in OP, perhaps this:
function OP:deltaX(pos)
if not self.speaker then return 0 end
local speakerPos = self.speaker:getPos()
return speakerPos.x - pos.x
end
I am quite certain that there is no getPos
in NPC but I’ll let the errors drive me to the solution.
CombatRound:225: attempt to call a nil value (method 'getPos')
stack traceback:
CombatRound:225: in method 'deltaX'
Floater:36: in method 'drawMessage'
Floater:28: in method 'draw'
GameRunner:171: in method 'drawMessages'
GameRunner:134: in method 'draw'
Main:102: in function 'draw'
Let’s just put that right on NPC for now.
function NPC:getPos()
return self:getTile():pos()
end
However, we’re going to find that NPC does not understand getTile
.
NPC:56: attempt to call a nil value (method 'getTile')
stack traceback:
NPC:56: in method 'getPos'
CombatRound:225: in method 'deltaX'
Floater:36: in method 'drawMessage'
Floater:28: in method 'draw'
GameRunner:171: in method 'drawMessages'
GameRunner:134: in method 'draw'
Main:102: in function 'draw'
That’s because getTile
is implemented on Entity, not DungeonObject. Can we safely let NPC be an Entity? I fear not but let’s try it and see what happens.
NPC = class(Entity)
Well, the good news is that nothing crashes. The bad news is that the message does not appear. I suspect we need some scaling and translating here.
To find out what’s going on, I’ll do this:
That tells me that I’m getting a -975 for deltaX. Obviously playerCenter isn’t useful.
I’m improvising here. If we had the manhattan distance between player and NPC, we could multiply it by a suitable constant and that would be fine. Or, if we had the actual coordinates of the Player, we could subtract the tile coordinate and that would do the job for us.
In exploring, I find that we are using graphicCenter
, which is something that Tile knows. So try this:
function NPC:graphicCenter()
return self:getTile():graphicCenter()
end
function OP:deltaX(pos)
if not self.speaker then return 0 end
local speakerPos = self.speaker:graphicCenter()
return speakerPos.x - pos.x
end
function Floater:drawMessage(op, pos)
local deltaX = op:deltaX(pos)
local txt = op:message()
text(txt, pos.x+deltaX, pos.y)
end
This works. It is also not interesting. If I look carefully, I can tell that Horn Girl’s text is now centered over her, the effect is not particularly helpful. We could try other effects, tinting the text, and so on.
Despite not loving the particular idea … oh I just got another one … what if we were to draw a line from the speaker to the text, like you might do with a cartoon.
Oh I just have to try this.
function Floater:drawMessage(op, pos)
local delta = op:delta(pos)
local txt = op:message()
strokeWidth(3)
if delta.x > 0 then
line(pos.x+delta.x,pos.y+delta.y, pos.x,pos.y)
end
text(txt, pos.x, pos.y)
end
function OP:delta(pos)
if not self.speaker then return vec2(0,0) end
local speakerPos = self.speaker:graphicCenter()
return speakerPos - pos
end
Oh this is nearly good. Let me make a small movie for you.
With a little adjustment, that will be terrific. Commit: Dialog line drawn from NPC to her text in the crawl. Nearly good.
OK, the adjustments are a bit ad hoc, but I’ll take it for now.
function Floater:drawMessage(op, pos)
local delta = op:delta(pos)
local txt = op:message()
strokeWidth(3)
if delta.x > 0 then
line(pos.x+delta.x,pos.y+delta.y+32, pos.x,pos.y-8)
end
text(txt, pos.x, pos.y)
end
Commit: NPC dialog lines adjusted with magic numbers, looks good.
It’s time to stop. This has been a nice result and there’s a lesson here to be had. At least one.
Summary
It may be hard to make out this lesson among all the trees and weeds, but what has made this result possible was passing an object all the way down to the drawing code, rather than unwinding the object and passing raw text. Along the way, we beefed up the OP object, giving it a bit more intelligence, since it wasn’t much more than a table to begin with.
And we did re-root the NPC at Entity rather than DungeonObject, which makes some sense. I was concerned that rooting it at Entity would make it need more capability but that turned out not to be the case.
So the lesson, and I learn it again and again, is to strongly prefer passing real objects around, not simple values or even dumb structures like tables. Objects can help. The other things cannot.
It generally seems easier to pass values around, especially in the early days of a design, when we don’t have a good sense of what our objects will be or what they will want. But doing so leads to code that calculates things far away from those who know the facts, and generally results in duplicating those calculations.
So I keep learning this lesson, and I wish I had learned it sooner and more often in the Dungeon program. But improving the situation is generally easy, and can be done incrementally. So … it’s all good. We do the best we can with the brain we have at the time.
That said, what about what we just wrote. Are our objects helping us enough?
function Floater:drawMessage(op, pos)
local delta = op:delta(pos)
local txt = op:message()
strokeWidth(3)
if delta.x > 0 then
line(pos.x+delta.x,pos.y+delta.y+32, pos.x,pos.y-8)
end
text(txt, pos.x, pos.y)
end
We have a lot of messing about with the value, a vec2
, that came back. And we disassemble it and adjust it to use it. Let’s fix that up before breakfast is called.
I happen to know that a zero-length line will draw a dot, so we will probably have to check here to decide whether to draw.
What makes more sense, asking for the line, or asking the OP (or sender) to draw the line? I think OP is not a graphical object but we can certainly ask it for its line origin, relative to the provided pos.
Let me recast the method this way:
function Floater:drawMessage(op, pos)
local txt = op:message()
local origin = op:speechOrigin(pos)
strokeWidth(3)
if origin.x ~= 0 then
line(origin.x, origin.y, pos.x,pos.y-8)
end
text(txt, pos.x, pos.y)
end
I changed the check for x to not equal. I suspect that as written the line would not appear if NPC was to our left.
Now the new method:
function OP:speechOrigin(pos)
if not self.speaker then return vec2(0,0) end
local speakerPos = self.speaker:graphicCenter()
return speakerPos - pos + vec2(0,32)
end
I expect that to be just dandy. It wasn’t, quite. Drew from roughly 0,0. I think we can just do this:
function OP:speechOrigin(pos)
if not self.speaker then return vec2(0,0) end
local speakerPos = self.speaker:graphicCenter()
return speakerPos + vec2(0,32)
end
That works perfectly. I’m a little concerned about using the real coordinates but since it works perfectly, I think we’re good.
Commit: OP returns speechOrigin, 32 above speaker tile.
OK, the objects are helping us more. So that’s good. Let’s call it a morning. We’ll reflect more tomorrow, or do something else. I’m about ready for something else.
Again, as always, we have improved our design—and even added a nice new capability—in small incremental steps. Big changes, step by step. This is the way.
-
I only just now realized why people giggle when I talk about a Floater in the Dungeon. Fortunately I usually call it the Crawl. Sorry. ↩