Dungeon 200
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:
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.
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!