Let’s display Encounter results before we enhance it further. This may become tricky, but let’s hope not.

The Encounter is going well, and I think it’s nearly done. But the player sees nothing of this charming battle scenario, only the results, which show up in the character sheets or in one’s surprising and disappointing death. I think we should work on displaying the battle as (or as if) it’s going on. That should entail stopping the action while we display, so that the monsters can’t be sneaking up while we read the play-by-play.

I am slightly concerned about stalling play during the encounter, as players may find themselves repeatedly tapping buttons with no result. Maybe we’ll turn off the buttons or something: remains to be seen.

One possible plan is to float the messages up above the battle, letting them rise up the screen slowly. Another possibility is to dedicate part of the screen to an information display. I’m hopeful that the former plan will work well enough. Let’s try that, anyway.

Our Encounter actually happens in an instant, at computer speeds. All the messages are created in one call to the attack method, and the Encounter is resolved. We want to display them sequentially, perhaps even with imposed delays to give the battle more of a sense of reality. (If we do that, we’re going to have to buffer the sounds as well. I guess that could be done easily enough. For now, we’ll concern ourselves with the text.

What do we want? I think we want this: when the encounter completes, it starts floating the messages up on the screen, starting at some location, ideally right above the player. The messages will go up at some small speed, and when there’s room for another one, it will appear. Maybe they’ll fade as they rise.

Let’s get started.

Floating a Message

How shall we do this? We need at least a starting idea. Let’s create a new class that manages the whole process of scrolling an array of messages up onto the screen. That process seems complex enough to justify a class.

What about TDD? I’m not sure how to start with TDD, but let’s stay alert to see if we can benefit from it as we go forward. But how are we going to test this new class? Let’s do this for now: when the game starts, GameRunner will send an array of messages to the new class and they’ll scroll above the payer right at the beginning. Sort of a welcome message. Something like this:

function GameRunner:createLevel(count)
    self:createRandomRooms(count)
    self:connectRooms()
    self:convertEdgesToWalls()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    local tile = self:getTile(vec2(rcx,rcy))
    self.player = Player(tile,self)
    self.monsters = self:createThings(Monster,3)
    for i,monster in ipairs(self.monsters) do
        monster:startAllTimers()
    end
    self.keys = self:createThings(Key,5)
    self:createThings(Chest,5)
    self.buttons = {}
    table.insert(self.buttons, Button("left",100,200, 64,64, asset.builtin.UI.Blue_Slider_Left))
    table.insert(self.buttons, Button("up",200,250, 64,64, asset.builtin.UI.Blue_Slider_Up))
    table.insert(self.buttons, Button("right",300,200, 64,64, asset.builtin.UI.Blue_Slider_Right))
    table.insert(self.buttons, Button("down",200,150, 64,64, asset.builtin.UI.Blue_Slider_Down))
    self.floater = FloatingMessage(self)
    self:addMessages(self:initialMessages())
end

No, I’d better at least create the floater in init.

function GameRunner:init()
    self.tileSize = 64
    self.tileCountX = 85 -- if these change, zoomed-out scale 
    self.tileCountY = 64 -- may also need to be changed.
    self.tiles = {}
    for x = 1,self.tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,self.tileCountY+1 do
            local tile = Tile:edge(x,y, self)
            self:setTile(tile)
        end
    end
    self.floater = FloatingMessage(self)
end

I’m not sure why FM needs to know the GameRunner but most things do. And I’m assuming that everyone calls the GameRunner to display messages. We’re just the first:

function GameRunner:addMessages(anArray)
    self.floater:addMessages(anArray)
end

function GameRunner:initialMessages()
    return {
    "Welcome to the Dungeon.",
    "Here you will find great adventure,",
    "fearsome monsters, and fantastic rewards",
    "Some of you may die, but I am willing",
    "to make that sacrifice."
    }
end

This will crash for FloatingMessage being missing.

GameRunner:18: attempt to call a nil value (global 'FloatingMessage')

We’ll get started. This much seems obvious:

-- FloatingMessage
-- RJ 20200106

FloatingMessage = class()

function FloatingMessage:init()
    self.messages = {}
end

function FloatingMessage:addMessages(messages)
    table.concat(self.messages, messages)
end

function FloatingMessage:draw()
end

Codea provides an empty draw, which reminds me that we need to draw this object:

function GameRunner:draw()
    font("Optima-BoldItalic")
    self:drawLargeMap()
    self:drawButtons()
    self:drawTinyMap()
    self:drawMessages()
end

function GameRunner:drawMessages()
    self.floater:draw()
end

Well. The table.concat doesn’t do what I thought it did. It makes a string of table contents. Nice to have, not what I wanted.

I think this will do it, but clearly this is amenable to testing:

function FloatingMessage:addMessages(messages)
    table.move(messages,1,#messages, self.messages,#self.messages+1)
end

So let’s test. Shall I create a FloatingMessage test? Might as well.

        _:test("addMessage appends", function()
            local fm = FloatingMessage(nil)
            fm:addMessages({"a", "b"})
            fm:addMessages({"c", "x"})
            local m = fm.messages
            _:expect(#m).is(4)
            _:expect(m[1]).is("a")
            _:expect(m[2]).is("b")
            _:expect(m[3]).is("c")
            _:expect(m[4]).is("d")
        end)

I expect this to fail looking for d, finding x. My expectations are dashed:

1: addMessage appends -- FloatingMessage:11: bad argument #4 to 'move' (number expected, got table)

Let’s have another look at that. I’ve never used move, to my recollection, so clearly got it wrong. Yes, the target table is last:

function FloatingMessage:addMessages(messages)
    table.move(messages,1,#messages, #self.messages+1, self.messages)
end

Try again.

1: addMessage appends  -- Actual: x, Expected: d

As expected. Fix the test:

        _:test("addMessage appends", function()
            local fm = FloatingMessage(nil)
            fm:addMessages({"a", "b"})
            fm:addMessages({"c", "d"})
            local m = fm.messages
            _:expect(#m).is(4)
            _:expect(m[1]).is("a")
            _:expect(m[2]).is("b")
            _:expect(m[3]).is("c")
            _:expect(m[4]).is("d")
        end)

And we’re green. I’m glad I decided to do that, found the problem at least as easily as had I not had the test, and probably more easily. Now to see about drawing something.

We need the player’s coordinates. We’ll ask the runner, which I’ve now saved:

function FloatingMessage:init(runner)
    self.runner = runner
    self.messages = {}
end

The game runner doesn’t happen to know that answer, but it knows the player.

function FloatingMessage:draw()
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,64)
    text(self.messages[1], pos.x, pos.y)
end

function GameRunner:playerGraphicCenter()
    return self.player:graphicCenter()
end

I kind of expect to see a message appear. That’s my intention anyway, No such luck. Nothing appears. Fill missing perhaps?

I think we’re drawing at the wrong scale. When I print what’s going on, I get this:

Welcome to the Dungeon.	1376.0	1568.0

Certainly y = 1568 isn’t going to work if we’re outside the translation for the coordinates. We need to do this at the right time.

That’s a bit of a smell, needing to do things at the right moment. Maybe it’s inevitable with the different scales we’re using. Let’s set the scale directly, that seems righteous:

function GameRunner:drawMessages()
    pushMatrix()
    self:scaleForLocalMap()
    self.floater:draw()
    popMatrix()
end

That does the trick:

welcome

Now let’s do the animation. Hm, this gets interesting. I have only given vague thought to how to do this.

I think I could fumble through this but let’s try to express our desires here as tests. That usually pays off.

Here’s a rough notion of what FM should do:

  • on addMessage, if there aren’t any messages in the array already, we’ll initialize a y coordinate for the drawing.
  • we’ll have a method increment that bumps the y coordinate. presumably called on every draw cycle.
  • we’ll examine the y value to see how many messages we can display, and display that many, starting from #1 down to as many as we have room for.
  • probably at some point we remove the first message. (This will wreak havoc with our indexing if we’re not careful.)

Let’s sketch a test that says that:

        _:test("message scrolling adds message", function()
            local fm = FloatingMessage(nil)
            fm:addMessages({"a", "b", "c", "d"})
            _:expect(fm:yOffset()).is(50)
            _:expect(fm:numberToDisplay()).is(1)
            fm:increment(25)
            _:expect(fm:yOffset()).is(75)
            _:expect(fm:numberToDisplay()).is(2)
        end)

I chose to write against messages rather than pull the guts out of the FM. I think this will pay off in a cleaner design. In any case, This is the Way.

We could check initial values, but I suspect we don’t care about them. Let’s run and make these methods work.

I’ve done quite a bit in one go here, but the test runs:

function FloatingMessage:init(runner)
    self.runner = runner
    self.messages = {}
    self.yOffStart = 50
    self.lineSize = 25
end

function FloatingMessage:addMessages(messages)
    if (#self.messages == 0) then
        self:initForMoving()
    end
    table.move(messages,1,#messages, #self.messages+1, self.messages)
end

function FloatingMessage:increment(n)
    self.yOff = self.yOff + n or 1
end

function FloatingMessage:initForMoving()
    self.yOff = self.yOffStart
end

function FloatingMessage:numberToDisplay()
    return 1 + (self.yOff - self.yOffStart) // self.lineSize
end

function FloatingMessage:yOffset()
    return self.yOff
end

I want to enhance it just a tiny bit:

        _:test("message scrolling adds message", function()
            local fm = FloatingMessage(nil)
            fm:addMessages({"a", "b", "c", "d"})
            _:expect(fm:yOffset()).is(50)
            _:expect(fm:numberToDisplay()).is(1)
            fm:increment(24)
            _:expect(fm:yOffset()).is(74)
            _:expect(fm:numberToDisplay()).is(1)
            fm:increment()
            _:expect(fm:yOffset()).is(75)
            _:expect(fm:numberToDisplay()).is(2)
        end)

I’m glad I did that. It found this bug:

function FloatingMessage:increment(n)
    self.yOff = self.yOff + (n or 1)
end

I forgot the parens around n or 1. Test runs. Now let’s enhance the draw and see what happens.

function FloatingMessage:draw()
    pushMatrix()
    pushStyle()
    fill(255)
    fontSize(20)
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,self.yOff)
    for i = 1,self:numberToDisplay() do
        text(self.messages[i], pos.x, pos.y)
        pos = pos - vec2(0,self.lineSize)
    end
    popStyle()
    popMatrix()
    self:increment()
end

I had to enhance this:

function FloatingMessage:numberToDisplay()
    local n = 1 + (self.yOff - self.yOffStart) // self.lineSize
    return math.min(n,#self.messages)
end

Now it won’t allow you to ask for more messages than are in there.

The effect is super:

welcome movie

We are green. This is good enough to commit, but not good enough to end the morning. Commit: welcome messages scrolls off screen.

However, as written, those words will scroll upward forever. I think they’ll also follow the princess as she moves, but that’s OK by me.

We need some way to decide that the top line should be removed. What if we just leave room for four lines? We can check for that in increment and adjust things. I’m just going to swing at this rather than write a test. I may regret this but I’m feeling hot.

function FloatingMessage:increment(n)
    self.yOff = self.yOff + (n or 1)
    if self:rawNumberToDisplay() > 4 then
        self.yOff = self.yOff - self.lineSize
        table.remove(self.messages,1)
    end
end

function FloatingMessage:numberToDisplay()
    return math.min(self:rawNumberToDisplay(),#self.messages)
end

function FloatingMessage:rawNumberToDisplay()
    return 1 + (self.yOff - self.yOffStart) // self.lineSize
end

That works as intended:

movie showing four

I call that good. Commit: messages limited to 4 lines and disappear. Draw may need improvement.

Let’s Sum Up

Well. That went very nicely, didn’t it? I think the little patches of TDD helped, and it might have been wise to TDD that last bit but I was pretty confident that I knew what to do. That’s not a good reason to skip a test, but I’m not here to show you perfect, I’m here to show you a human who tries to pay attention to what works and what doesn’t. You get to decide what to do with what you see, even it if’s just to laugh and point.

Next time we can plug in the Encounter messages, but I think they’ll probably work just fine.

OK, I can’t resist plugging it in.

function Encounter:attack()
    self:log(self.attacker:name().." attacks "..self.defender:name())
    local attackerSpeed = self:rollRandom(self.attacker:speed())
    local defenderSpeed = self:rollRandom(self.defender:speed())
    if attackerSpeed >= defenderSpeed then
        self:firstAttack(self.attacker, self.defender)
    else
        self:firstAttack(self.defender, self.attacker)
    end
    self.attacker.runner:addMessages(self.messages)
    return self.messages
end

The results may surprise you.

ankle biter

There was a lot more action in there than I expected, though I was hammering away at the ankle biter with my buttons. And I suspect we want some more messages because I felt the story told there missed out saying zero damage or something. We might also want to show the before/after health in the messages as well.

Anyway commit: patched in Encounter floating messages.

I am pleased with how this is working out. Let’s see what I believe about why it went well.

I think there were two important decisions that contributed to this going well.

The first was the decision to do the display with a new object. It was tempting to build the display into Encounter, since it was already there. Had we done so, it might have turned out just the same, but there’s no good reason why an Encounter should perform an attack and display it. The “and” tells us we have two bits of capability in one object. Isolating the display to one object is, by my lights, a better design.

The second good idea was to write a few tests for the FM. They helped me focus on how it should work, and the secondary decision to talk to its methods rather than its variables made for a better design, which makes things easier to get right.

All in all a good morning with a delightful result.

See you next time!


D2.zip