Some odds and ends, a nice feature, and a bit of trouble. And the most distracting thing ever!

I don’t know what we’ll do today.

I’ve been vaguely musing about the “quest” idea, maybe that would be fun.

I have more information on what’s going on with Codea zLevel, and it’s tempting to fool with it. But we don’t need it, drawing in bottom to top order does the job, and we’re there.

Some quick game play gives me this list of reminders:

  • Remove test announcer in player room
  • Do something more clever with lever
  • Doors
  • Following monsters should get bored
  • Some way to block monsters
  • Loot should go to inventory to be used later

Relating to that, combat is somewhat conditioned by relative speed:

function CombatRound:attemptHit()
    local msg
    if self.attacker:attackerSpeedAdvantage() + self.attacker:speedRoll() >= self.defender:speedRoll() then
        msg = string.format("%s %s %s!", self.attacker:name(), self.attacker:attackVerb(), self.defender:name())
        self:append(self:display(msg) )
        self:rollDamage()
    else
        msg = string.format("%s evades attack!", self.defender:name())
        self:append(self:display(msg))
    end
end

function Entity:attackerSpeedAdvantage()
    return 2
end

function Entity:speedRoll()
    return math.random(0,self:speed())
end

Damage, however, is always 1-6:

function CombatRound:rollDamage()
    local damage = self.random(1,6)
    self:applyDamage(damage)
end

This might be a function of attacker strength, or modified by defender strength. Right now, strength is updated but not really used.

None of these appeal to me this morning. Let’s do some of the little tidying tasks, which will get us looking at code.

Remove Announcer in player room.

In looking at this I see lots of duplication between createLearningLevel and createLevel. That reminds me that the learning level is a very rudimentary work in progress. Needs to be elaborated.

Here’s the placement of the player room test announcer, just patched into the placeLever, which is itself just a test.

function GameRunner:placeLever()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    local leverTile = tile:getNeighbor(vec2(0,2))
    local lever = Lever(leverTile, "Lever 1")
    local annTile = tile:getNeighbor(vec2(0,-2))
    local ann = Announcer({"This is the mysterious level called", "Soft Wind Over Beijing,",
    "where nothing bad will happen to you", "unless you happen to be a princess.", "Welcome!" })
    annTile:addContents(AnnouncerProxy(ann))
    annTile = tile:getNeighbor(vec2(1,-2))
    annTile:addContents(AnnouncerProxy(ann))
    annTile = tile:getNeighbor(vec2(-1,-2))
    annTile:addContents(AnnouncerProxy(ann))
end

Let’s at least comment that out, saving it as a demo of how to do it. Really should delete it, but I’m feeling old-fashioned today.

Commit: no longer put test announcer in room 1.

Browsing, I randomly decide to change Spikes so that if they are turned off, they do no damage at all. I also change them so they can be turned back on:

function Spikes:leverHandler(event, sender, info)
    self.stayDown = false
    local pos = info.position or 1
    if pos == 4 then
        self.stayDown = true
    end
end

function Spikes:actionWith(player)
    if self.stayDown then return end
    local co = CombatRound(self,player)
    co:appendText("Spikes ".. self.verbs[self.state]..player:name().."!")
    local damage = math.random(self:damageLo(), self:damageHi())
    co:applyDamage(damage)
    co:publish()
end

Commit: Spikes can turn back on. When turned off, no damage.

Wandering, I notice that sometimes the Player is underneath the Spikes. Something about zLevel?

Upon searching, I find that neither Monster nor Player have zLevel defined. I am certain that I defined those. Maybe it was on the other iPad where I don’t save source.

Commit: Player and Monster now have zLevel 10 and 9.

I want monsters who are following the player to get bored after a while and move away. Let’s see how the “AI” works.

function CalmMonsterStrategy:selectMove(range)
    if range > 10 then
        return "basicMoveRandomly"
    elseif range >= 4 then
        return "basicMoveTowardPlayer"
    elseif range == 3 then
        return "basicMaintainRangeToPlayer"
    elseif range == 2 then
        return "basicMoveAwayFromPlayer"
    elseif range == 1 then
        return "basicMoveTowardPlayer"
    else
        return "basicMoveAwayFromPlayer"
    end
end

I presume everyone gets their own instance of this … yes they do. So, what do we really want here?

If the range has ever been 1, we’re actually trying to get the monster to stick to the player and therefore fight. If you’re far apart, the monster wanders randomly. Otherwise, it keeps its distance between 2 and 4 tiles, which results in it following you around: whenever you get close to a monster, it will follow you, but it won’t attack unless you get adjacent to it.

So, what if we had a “boredom” counter that ticks downward if 2<=range<=4, and if it gets to zero, we want to move away for a while. If range is 10 or 1, we reset the boredom counter to its max. How could we keep the monster moving away or at least keeping its distance?

It seems clear that this is a different strategy. Let’s call it AloofMonsterStrategy. We’ll stay in that strategy for N moves, and then go to Calm.

First the strategy. Let’s TDD it for the record, though I think it’s rather wasteful.

I add this helper data and class:

local newStrategy
local FakeMonster = class()

function FakeMonster:setMonsterStrategy(strategy)
    newStrategy = strategy
end

This will let me check that the strategy changes as desired.

        _:test("Aloof strategy", function()
            local method
            local strat = AloofMonsterStrategy(nil,nil)
            method = strat:selectMove(1)
            _:expect(method, "1").is("basicMoveAwayFromPlayer")
            method = strat:selectMove(10)
            _:expect(method,"10").is("basicMoveAwayFromPlayer")
            _:expect(newStrategy:is_a(CalmMonsterStrategy),"not calm").is(true)
        end)

In writing this I see that I don’t need a timeout for this strategy: I can time it out when the distance gets to 10 or more.

Build the class:

AloofMonsterStrategy = class(MonsterStrategy)

function AloofMonsterStrategy:init(monster)
    self.monster = monster
end

function AloofMonsterStrategy:execute(dungeon)
    local method = self:selectMove(self.monster:manhattanDistanceFromPlayer())
    self.monster[method](self.monster, dungeon)
end

function AloofMonsterStrategy:selectMove(range)
    if range >= 10 then
        self.monster:setMonsterStrategy(CalmMonsterStrategy(self.monster))
    end
    return "basicMoveAwayFromPlayer"
end

I expect this to work, but anticipate that it’ll blow up for some reason. Do I contradict myself?

4: Aloof strategy -- MonsterStrategy:27: attempt to index a nil value (field 'monster')

Right. Best use the FakeMonster, not just define it.

        _:test("Aloof strategy", function()
            local method
            local strat = AloofMonsterStrategy(FakeMonster())
            method = strat:selectMove(1)
            _:expect(method, "1").is("basicMoveAwayFromPlayer")
            method = strat:selectMove(10)
            _:expect(method,"10").is("basicMoveAwayFromPlayer")
            _:expect(newStrategy:is_a(CalmMonsterStrategy),"not calm").is(true)
        end)

Test runs.

Now let’s test the CalmStrategy and install boredom at, oh, 15 moves of following.

        _:test("Calm converts to Aloof", function()
            local method
            local strat = CalmMonsterStrategy(FakeMonster())
            newStrategy = strat
            for i = 1,5 do
                strat:selectMove(3)
                strat:selectMove(2)
                strat:selectMove(4)
            end
            _:expect(newStrategy,"set early?").is(strat)
            strat:selectMove(4)
            _:expect(newStrategy:is_a(AloofMonsterStrategy),"not set").is(true)
        end)

This fails as expected:

5: Calm converts to Aloof not set -- Actual: false, Expected: true

Implement:

function CalmMonsterStrategy:selectMove(range)
    if 2 <= range and range <= 4 then
        self.boredom = self.boredom - 1
        if self.boredom < 0 then
            self.monster:setMonsterStrategy(AloofMonsterStrategy(self.monster))
        end
    else
        self.boredom = 15
    end
    if range > 10 then
        return "basicMoveRandomly"
    elseif range >= 4 then
        return "basicMoveTowardPlayer"
    elseif range == 3 then
        return "basicMaintainRangeToPlayer"
    elseif range == 2 then
        return "basicMoveAwayFromPlayer"
    elseif range == 1 then
        return "basicMoveTowardPlayer"
    else
        return "basicMoveAwayFromPlayer"
    end
end

Test runs. Let’s go try it.

MonsterStrategy:50: attempt to call a nil value (method 'setMonsterStrategy')
stack traceback:
	MonsterStrategy:50: in method 'selectMove'
	MonsterStrategy:42: in method 'execute'
	Monster:312: in method 'executeMoveStrategy'
	Monster:270: in method 'chooseMove'
	Monsters:113: in method 'move'
	GameRunner:489: in method 'playerTurnComplete'
	Player:305: in method 'turnComplete'
	Player:203: in method 'keyPress'
	GameRunner:421: in method 'keyPress'
	Main:92: in function 'keyboard'

I suspect that’s not the right method.

LOL, right. It’s setMovementStrategy.

That works as intended. The monster follows for a while, then buggers off. If you go back toward it, it starts following you again.

Commit: Calm monsters become aloof after a period of following you.

So That’s Nice. Now What?

I think I’d like the things we find in the Dungeon to go to inventory rather than serve as immediate powerups.

This could get tricky. Decor does this with its items but there are only a couple of possibilities now. Let’s review how Decor works:

function Decor:giveItem()
    if self.item then
        self.item:addToInventory()
        self.item = nil
    end
end

Decor doesn’t care what its item is. What is it?

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" },
        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

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"
end

function InventoryItem:addToInventory()
    if self.icon then
        Inventory:add(self)
    elseif self.object then 
        self:informObject("You received nothing.")
    end
end

So an inventory item has a name, an icon (a sprite), an object (always the player for now) and a method. When it’s added to the inventory, it’s just saved. Duplicates are counted. What happens when the inventory item is used?

function InventoryItem:touched(aTouch, pos)
    if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
        Inventory:remove(self)
        self.object[self.message](self.object)
    end
end

Our Loot items are a bit different now. Most significant is what happens when you try to step on them:

function Loot:actionWith(aPlayer)
    self:getTile():removeContents(self)
    if self.kind == "Pathfinder" then
        self:addPathfinderToInventory(aPlayer)
    elseif self.kind == "Antidote" then
        self:addAntidoteToInventory(aPlayer)
    else
        aPlayer:addPoints(self.kind, math.random(self.min, self.max))
    end
end

The Pathfinder and Antidote add to inventory, the rest apply themselves. Their kind is the name of an attribute, namely “Strength”, “Health”, or “Speed”. They have a random value that’s computed at the time they’re stepped on. Why not wait till the last minute, right?

The InventoryItem doesn’t have a value. But it could have parameters. Let’s give it two parameters attribute and value:

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.attribute = aTable.attribute
    self.value = aTable.value
end

And use the parms if they exist:

function InventoryItem:touched(aTouch, pos)
    if aTouch.state == ENDED and manhattan(aTouch.pos,pos) < ItemWidth//2 then
        Inventory:remove(self)
        self.object[self.message](self.object, self.attribute, self.value)
    end
end

Now let’s change what happens in Loot:

function Loot:actionWith(aPlayer)
    self:getTile():removeContents(self)
    if self.kind == "Pathfinder" then
        self:addPathfinderToInventory(aPlayer)
    elseif self.kind == "Antidote" then
        self:addAntidoteToInventory(aPlayer)
    else
        self:addLootToInventory(aPlayer)
    end
end

function Loot:addLootToInventory(aPlayer)
    local item = InventoryItem{icon=self.icon, name=self.kind, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
    Inventory:add(item)
end

I thought briefly of using kind as the first parameter to the method instead of attribute, but this made more sense to me.

Commit: Loot items now go to inventory and are applied upon removal.

I noticed the messages, which are like “You have received a Health!”, which isn’t particularly good English. The Health and Speed are potion icons but Strength is some kind of packet.

Here’s some relevant code:

local LootIcons = {Strength="blue_pack", Health="red_vial", Speed="green_flask",
Pathfinder="blue_jar", Antidote="red_vase"}

function Loot:init(tile, kind, min, max)
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    self.message = "I am a valuable "..self.kind
    if tile then tile:moveObject(self) end
end

function InventoryItem:informObjectAdded()
    self:informObject("You have received a "..self.name.."!")
end

Loots want to know package info: potion, powder, amulet, jewel, and so on. And probably a modifier.

Let’s extend Inventory item again, to include a description. If it’s provided, it’ll be used.

function InventoryItem:init(aTable)
    self.icon = aTable.icon
    self.name = aTable.name or self.icon
    self.description = aTable.description or self.name
    self.object = aTable.object or self
    self.message = aTable.method or "print"
    self.attribute = aTable.attribute
    self.value = aTable.value
end

function InventoryItem:informObjectAdded()
    self:informObject("You have received "..self.description.."!")
end

function InventoryItem:informObjectRemoved()
    self:informObject("You have used a "..self.description.."!")
end

I don’t recall the informObjectRemoved message coming out. We’ll check that.

Back to Loot:

Somehow I messed this up. Revert.

Step by step:

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
    self.attribute = aTable.attribute
    self.value = aTable.value
end

function InventoryItem:getMessage()
    return self.description or self.name
end

function InventoryItem:informObjectAdded()
    self:informObject("You have received a "..self:getMessage().."!")
end

function InventoryItem:informObjectRemoved()
    self:informObject("You have used a "..self:getMessage().."!")
end

Yikes!

In testing that I happened to rez a pathfinder, and got this:

Monster:65: attempt to index a nil value (local 'tile')
stack traceback:
	Monster:65: in field 'init'
	... false
    end

    setmetatable(c, mt)
    return c
end:24: in global 'Monster'
	Monster:45: in method 'getPathMonster'
	Monsters:101: in method 'createPathMonster'
	GameRunner:263: in method 'createPathMonster'
	Player:254: in field '?'
	Inventory:271: in method 'touched'
	Inventory:206: in method 'touched'
	GameRunner:555: in method 'touched'
	Main:97: in function 'touched'

After a pretty hard look at this, I think the problem is deep. I’ve shipped a non-working Pathfinder. Wonder when. Probably days ago.

A bit of bisecting and I find this:

function GameRunner:mapToWayDown()
    return Map(self:getDungeon(), self.wayDown.tile)
end

Should be:

function GameRunner:mapToWayDown()
    return Map(self:getDungeon(), self.wayDown:getTile())
end

Commit: fix pathfinder failure from 20210608.

Unyikes but remind me to discuss in summary

Where were we? Oh yes, descriptions of the inventory items created from Loots.

I’ve gone chaotic. Need to get back under control. Trying to move too fast or something. Maybe I’m hungry.

Anyway we are here now:

local LootDescriptions = {Strength="Pack of Steroidal Strength Powder", Health="Potent Potion of Health", Speed="Potion of Monstrous Speed" }

function Loot:init(tile, kind, min, max)
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    self.desc = LootDescriptions[self.kind] or self.kind
    self.message = "I am a valuable "..self.desc
    if tile then tile:moveObject(self) end
end

function Loot:addLootToInventory(aPlayer)
    local item = InventoryItem{icon=self.icon, name=self.kind, description=self.desc, object=aPlayer, attribute=self.kind, value=math.random(self.min,self.max), method="addPoints"}
    Inventory:add(item)
end

This gives us nice messages like these:

two messages from query

one message from taking

I’m not sure why, when we touch an item, we don’t get the inform message. Ah. Never called the method. Here’s that:

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

I believe we are solid.

Commit: Loots now become InventoryItems. Loot messages enhanced for powerups.

I’m for a break. Maybe out for a sandwich. Then I’ll sum up or (very unlikely) do more work.

Summary

It’s kind of fun to just pick off little things. And honestly, despite having to take two runs at it, I’m pleased with how easy the Loot to Inventory story was. Just create an InventoryItem and pop it in. Then some easy extension to provide better messages. That will probably want to get extended as we go forward, just to keep the text output interesting.

On the other hand, discovering the Pathfinder was broken was quite disruptive. I really hate it when a defect gets released. And while it was a trivial change to fix it, it wasn’t obvious to me what caused it, and I am still concerned because neither a test nor a useful run-time error caught it.

The problem was that the Map allows itself to be built and provided a target later, instead of using a complete creation method. That capability is used only in tests, and is probably not necessary there. We should just declare that a Map isn’t legit without a target, but of course we don’t generally instrument objects to check their arguments and blow up at run time. Anyway, I’ve made a note to deal with it.

Shiny! Squirrel!
OMG a Shiny Squirrel!

I think it was probably a mistake to stop in the middle of the Inventory changes to chase the Pathfinder. But it felt like an emergency. I am subject to being drawn off-task by tempting items like this Shiny Squirrel here on my shelf, the most attention-grabbing object conceivable.

Shiny Squirrel!

But breaking into the middle of a session leaves a jagged tear mark in what should be a continuous stream of thought. The common result of an interruption is code that isn’t quite as good as it could be, because it was written in two chunks with only some large fraction of one’s understanding in play on either chunk.

I think it’s OK, but the construction and use of the more complicated messages strikes me as having been built out of scrap lumber and baling wire. We’ll take another look at that in the future as well.

I think we got out OK. If not, more trouble for next time.

See you then!


D2.zip