Let’s try again today to improve the character sheets. I have a new idea. That rarely goes well.

The little icons for character sheets are quite limited. There’s only room for five or six on a reasonable-sized sheet. I think we need to allow for attributes to range from 0 to 20 to give us room for growth and interesting creatures.

I propose to proceed in two steps.

First, I’ll display the player’s status as a fraction like 2/5 meaning the character has two points left out of a possible 5, or 7/20, etc. The idea is that the creature has some maximum current number of points in that attribute, of which they only have some left. So if a creature (or more likely, the player) gains enough experience, their maximum value in attributes can go up. The princess starts with a maximum health of 5, but that maximum can increase as she grows in experience and power.

Second, I want to experiment with a graphical display of the status. I’m imagining a horizontal bar graph showing one color for the amount she has, another color for the amount she could currently have, and a third color for the amount she could grow into. I’m not sure that will look good, but we’ll try it, time permitting.

I will need advice on the colors to use. I would normally choose green, yellow, red, but would like to choose colors that all viewers can discriminate between. I don’t see a good way to cross-hatch the bar graph in Codea, but I’ll try that as well.

Anyway, to begin, let’s convert what we have to the fractional form.

Fractional Attribute Display

Every creature has a maximum current capacity on their attributes. Their fuel tank, as it were, can hold 5 gallons. They may currently have less than 5 gallons in the tank. Upgrades to the creature can increase the size of the tank, allowing them to accrue more fuel.

At this point, only the player will have the ability to grow in capacity. That could change in the future. Unless it just drops out of something, I’ll be assuming that we don’t need to cater to the increased fuel tank for monsters.

Let’s just begin and see what happens.

Do we want to TDD this feature? Most of it is in the display, and that part is not amenable to TDD. Let’s start with the display and see if there is logic that would benefit from more extensive TDD or tests.

function MonsterSheet:draw()
    pushMatrix()
    pushStyle()
    resetMatrix()
    zLevel(1)
    rectMode(CORNER)
    textMode(CORNER)
    textAlign(LEFT)
    self:drawParchment()
    fill(0)
    self:drawText(self.monster:name())
    self:newLine(2)
    self:drawText("Strength")
    self:drawIcons(self.monster:strength(), asset.builtin.Small_World.Sword)
    self:newLine()
    self:drawText("Health")
    self:drawIcons(self.monster:health(), asset.builtin.Small_World.Heart)
    self:drawPhoto(self.monster:photo())
    popStyle()
    popMatrix()
end

I think we just go ahead and recast this by intention, mostly reordering. First, delete the icons bits and replace with drawFraction:

    self:drawText(self.monster:name())
    self:newLine(2)
    self:drawText("Strength")
    self:drawFraction(self.monster:strength(), asset.builtin.Small_World.Sword)
    self:newLine()
    self:drawText("Health")
    self:drawFraction(self.monster:health(), asset.builtin.Small_World.Heart)
    self:drawPhoto(self.monster:photo())

I decided to leave the icon. Maybe we can draw one copy, just for art’s sake.

Rename and adjust this:

function MonsterSheet:drawIcons(count, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    for i = 1, count do
        sprite(icon, 50+i*30, 10)
    end
    popStyle()
end
function MonsterSheet:drawFraction(count, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    sprite(icon, 80, 10)
    popStyle()
end

We see immediately that we’re going to need the maximum. Fake it till you make it:

    self:drawFraction(self.monster:strength(), 5, asset.builtin.Small_World.Sword)
...

function MonsterSheet:drawFraction(count, max, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    sprite(icon, 80, 10)
    text(count.."/"..max, 100, 0)
    popStyle()
end

That works nicely:

frac

Maybe we would prefer spaces around the slash.

spaced

Yes, I like that better. But there are really three numbers we want to display: current, current max, and possible max. First commit: character sheet displays fraction current / current max (canned).

Now to add the third value. I almost wish I had thought to do it, but fact is, I prefer small steps at the beginning of a feature anyway.

I’m going to change the method signature to this:

function MonsterSheet:drawFraction(icon, current, currentMax, possibleMax)

Moving the icon to the front allows me to default values more readily. Maybe. It may have been unnecessary, but here we are.

Yeah, no, that’s going to turn out to have been wasted motion. Anyway this:

function MonsterSheet:drawFraction(icon, current, currentMax, possibleMax)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    sprite(icon, 80, 10)
    text(current.." / "..currentMax.." / "..possibleMax, 100, 0)
    popStyle()
end

And the calls:

    self:drawFraction(self.monster:strength(), 5, 20, asset.builtin.Small_World.Sword)
etc

Right. Helps to match the calling sequence. I’ll put the method back the way it was.

function MonsterSheet:drawFraction(current, currentMax, possibleMax, icon)

three

OK that looks like what I had in mind. Now to implement the values.

I’m starting not to like the duplication here, and it is only going to get worse:

function MonsterSheet:draw()
    local m = self.monster
    pushMatrix()
    pushStyle()
    resetMatrix()
    zLevel(1)
    rectMode(CORNER)
    textMode(CORNER)
    textAlign(LEFT)
    self:drawParchment()
    fill(0)
    self:drawText(m:name())
    self:newLine(2)
    self:drawText("Strength")
    self:drawFraction(m:strength(), m:strengthMax(), m:strengthPossible(), asset.builtin.Small_World.Sword)
    self:newLine()
    self:drawText("Health")
    self:drawFraction(m:health(), m:healthMax(), m:healthPossible(), asset.builtin.Small_World.Heart)
    self:drawPhoto(m:photo())
    popStyle()
    popMatrix()
end

For every attribute we display, we now have to implement three methods on every creature. That right there is duplication, and we don’t like duplication. For now, however, our job is to get it to work. Only then do we worry about getting it right.

function Player:init(tile, runner)
    self.alive = true
    self.tile = tile
    self.tile:illuminate()
    self.tile:addContents(self)
    self.runner = runner
    self.keys = 0
    self.healthPoints = 5
    self.healthPointsMax = 5
    self.healthPointsPossible = 20
    self.strengthPoints = 2
    self.strengthPointsMax = 2
    self.strengthPointsPossible = 20
    self.playerSheet = MonsterSheet(self,750)
end

This is getting nasty. But we must forge on for now.

function Player:health()
    return self.healthPoints
end

function Player:healthMax()
    return self.healthPointsMax
end

function Player:healthPossible()
    return self.healthPointsPossible
end

And similarly for strength. Here’s the display, picking up the new values.

new values

Now for the Monsters, we need all that info in the monster table. Entries look like this:

    m = {name="Yellow Widow", health=1, strength=4,
    dead=asset.spider_dead, hit=asset.spider_hit,
    moving={asset.spider, asset.spider_walk1,asset.spider_walk2}}

Now, honestly, monsters aren’t going to change their values upward. There might be some future monster, someday, that can do that, but for now, no. So the Yellow Widow should display x/1/1 for health and x/4/4 for strength. But we do need to store that info somehow.

Monsters init like this:

function Monster:init(tile, runner, mtEntry)
    if not MT then self:initMonsterTable() end
    self.alive = true
    self.tile = tile
    self.tile:addContents(self)
    self.runner = runner
    self.mtEntry = mtEntry or self:randomMtEntry()
    self.dead = self.mtEntry.dead
    self.hit = self.mtEntry.hit
    self.moving = self.mtEntry.moving
    self.healthPoints = self.mtEntry.health
    self.strengthPoints = self.mtEntry.strength
    self.movingIndex = 1
    self.swap = 0
    self.move = 0
    self.monsterSheet = MonsterSheet(self)
end

Let’s convert the health and strength entries in the table to be tables themselves. I’m starting to wish that the monster table entries were objects. We’ll manage somehow.

    local attributeTable = self:getTable(self.mtEntry.health)
    self.healthPoints = attributeTable[1]
    self.healthPointsMax = attributeTable[2]
    self.healthPointsPossible = attributeTable[3]
    attributeTable = self.getTable(self.mtEntry.strength)
    self.strengthPoints = attributeTable[1]
    self.strengthPointsMax = attributeTable[2]
    self.strengthPointsPossible = attributeTable[3]

I’ve decided there’s a method getTable that returns a three element table, attribute, max, possible. And I implement it this way:

function Monster:getTable(mtTable)
    if type(mtTable) == table then return mtTable end
    return {mtTable, mtTable, mtTable}
end

Yes, that is nasty. If we’re given a table, return it, if not, create one using three copies of the value given.

This should make everything work fine. (I did implement the max() and possible() methods.

A test is failing:

13: monster can enter player tile only if player is dead -- Monster:241: bad argument #2 to 'random' (number expected, got nil)

As has happened before, I don’t know how long this has been failing. I forget to look and this program doesn’t display Codea errors in your face the way the preceding ones did.

The error is from this code:

function Monster:startActionWithPlayer(aPlayer)
    aPlayer:damageFrom(self.tile,math.random(0,self:strength()))
end

That tells me that self:strength() didn’t return a number, and that tells me that my cute little trick didn’t work.

Now I’m stuck between a rock and a hard place. I “should” write a test, but it’ll be a pain to write. I can simplify that cute function to return an array unconditionally, since I know that all that’s going in there are integers.

I’ll try that, but I’m not entirely optimistic and I’m feeling back because I really should write that test.

The bug is the dot in this statement:

    attributeTable = self.getTable(self.mtEntry.strength)

Should be a colon. Fairly easily found. A test might have helped but not much. No, I’m rationalizing. It might have helped a fair amount, but I do think it would have taken much the same probing around.

The monsters, for now, will all show the same values in the second and third positions: they cannot grow their attributes. Let’s make it such that when the last two attributes are the same in the sheet, the final one does not display.

short display

Commit: monsters can have tables of attributes.

Hm, truth be told, I’ve never tested the table bit. Let’s add a table to some monster and see how it displays.

    m = {name="Yellow Widow", health=1, strength={4,5,10},
    dead=asset.spider_dead, hit=asset.spider_hit,
    moving={asset.spider, asset.spider_walk1,asset.spider_walk2}}

Now let’s see how she displays. Indications are that she breaks.

This time I will do a test.

I need direct access to an MT entry so did this refactoring:

function Monster:randomMtEntry()
    if not MT then self:initMonsterTable() end
    local index = math.random(1,#MT)
    return self:getMtEntry(index)
end

function Monster:getMtEntry(index)
    return MT[index]
end

Formerly, the randomMtEntry returned MT[index] directly.

I’ve modified the first mt entry:

    m = {name="Pink Slime", health=1, strength={1,2,3},
    dead=asset.slime_squashed, hit=asset.slime_hit,
    moving={asset.slime, asset.slime_walk, asset.slime_squashed}}

It now has a table for strength, so I should be able to create a pink slime and test it.

        _:test("Monster attributes can be tables", function()
            local runner = GameRunner()
            local room = Room(1,1,20,20,runner)
            local mt = Tile:room(10,10)
            local mtentry = Monster:getMtEntry(1)
            local monster = Monster(mt, runner, mtentry)
            _:expect(monster:name()).is("Pink Slime")
            _:expect(monster:strength()).is(1)
            _:expect(monster:strengthMax()).is(2)
            _:expect(monster:strengthPossible()).is(3)
        end)

To make this work, I needed to fix my perfect two-line method:

function Monster:getTable(mtTable)
    if type(mtTable) == "table" then return mtTable end
    return {mtTable, mtTable, mtTable}
end

The type function returns the type as a string. I had failed to put “table” in quotes.

Is there a lesson here? Yes, there is. Will I learn it? No, but I’ll try.

Anyway, now I can probably get a decent picture of a monster who does display separate values.

And here we are, a pink slime showing 1,2,3 as expected. So the table trick works, and the display works as intended.

Commit: monster attribute tables actually work. Attribute sheet does not display possible if == max.

Bar Chart

Let’s do a spike of a bar chart. Here’s a sketch of the idea.

A bar chart is a rectangle partitioned into three parts. The leftmost part shows the current value of some attribute, the middle value shows the current max, and the rightmost value shows the possible max.

The space available will be one line of a character sheet, after the name of the attribute, where the text currently goes. The text currently starts at position 100:

function MonsterSheet:drawFraction(current, currentMax, possibleMax, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    sprite(icon, 80, 10)
    local s = current.." / "..currentMax
    if possibleMax ~= currentMax then
        s = s.." / "..possibleMax
    end
    text(s, 100, 0)
    popStyle()
end

Let’s just pretend there’s another attribute and a new function to draw the bar:

    self:drawText("Health")
    self:drawFraction(m:health(), m:healthMax(), m:healthPossible(), asset.builtin.Small_World.Heart)
    self:newLine()
    self:drawText("Courage")
    self:drawBarFraction(3,10,20, asset.builtin.Small_World.Heart_Glow)

First I draw a bar a few times to get the scale:

function MonsterSheet:drawBarFraction(current, currentMax, possibleMax, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    rectMode(CORNER)
    sprite(icon, 80, 10)
    stroke(255)
    fill(255,0,0)
    rect(100,0,120,20)
    popStyle()
end

bar

That looks about right. Now we need to scale everything to fit into that bar. I think I want the bar to always represent 20 points, no matter how many points the player has.The bar’s 120 wide. So a value V along that line will be 120*V/20 pixels long, it seems to me. So let’s try this:

function MonsterSheet:drawBarFraction(current, currentMax, possibleMax, icon)
    pushStyle()
    tint(255)
    spriteMode(CENTER)
    rectMode(CORNER)
    sprite(icon, 80, 10)
    stroke(255)
    fill(0)
    rect(100,0,120,20)
    fill(150,150,150)
    rect(100,0,120*possibleMax/20,20)
    fill(255,255,0)
    rect(100,0,120*currentMax/20,20)
    fill(0,255,0)
    rect(100,0,120*current/20,20)
    popStyle()
end

That looks rather nice:

four

Let’s convert to using that throughout.

thru1

thru2

I think I like that, though I may want to change the leftmost two colors. I’ll await advice on the human factors for a while.

Commit: attributes displayed on bar graph.

Let’s sum up.

RetroSummary

The ever-charming GeePaw Hill says that he likes to apply TDD where there is “branching logic”, and not in places where the result is better checked by looking, as with a bar graph. (I am surely not doing him justice with this brief remark: read his works and learn.)

For me, I find TDD to be of little value when I can inspect to see the result. That includes simple graphical elements, such as we mostly worked on today, but could also describe even fairly complex behavior such as the drawing of the dungeon’s paths. There’s branching logic in there, including the decision whether to go first vertical and then horizontal or vice versa. But the benefits from TDD still seem limited.

I do like to test where there are complex calculations going on, even if they don’t seem to display “branching logic”, such as the code that calculates scaling or the like.

It comes down to a judgment call, and it is a call that I frequently make incorrectly. It is very rare for me to write a test and then regret it. I can’t think of when that ever happened. It is not uncommon for me to skip a test and then get in trouble. Sometimes I should clearly have written some tests, other times not so much.

By and large, I think I would do better if I moved more readily to testing. Part of what holds me back is the rigmarole of setting up a test: look at all the things I had to set up just to ask about the monster’s table.

        _:test("Monster attributes can be tables", function()
            local runner = GameRunner()
            local room = Room(1,1,20,20,runner)
            local mt = Tile:room(10,10)
            local mtentry = Monster:getMtEntry(1)
            local monster = Monster(mt, runner, mtentry)
            _:expect(monster:name()).is("Pink Slime")
            _:expect(monster:strength()).is(1)
            _:expect(monster:strengthMax()).is(2)
            _:expect(monster:strengthPossible()).is(3)
        end)

Now some of that is probably not needed. I set it up more from superstition than from knowing I needed all that underpinning. I’ve been bitten when I didn’t build the underpinning, so I do it, but it makes for more work and it makes it more painful to write the test.

I could invest in improving the framework for my tests. If I were to do that, tests would be easier to write, and I’d write more of them, and I’d probably go faster. But now, you’re not just asking me to write boring tests, but to work on a boring testing framework when there are exciting monsters to be slain. It’s hard for me to want to invest in my tools, even when it would pay off.

Once upon a time, I worked on a team that had a “toolsmith”, an individual who really liked to work on the tooling infrastructure. He’d work on features as needed, but he was always more than willing to improve our tools. That was very useful, to the point where I’d almost suggest a toolsmith role for someone like that if you have them.

But I only work on the tools when I have to, or when I choose to write about improving the tools.

So here I am, almost certainly testing less than would be optimal.

Are you and your team like me in this regard? Is there something, whether testing or some other activity, that you’re not doing as much as would be optimal?

If so, what are you going to do about it?

Me, for today, I’m going to be happy with a rather smooth implementation of what I set out to accomplish.

See you next time!


D2.zip