Dungeon 211
Extremely Artificial Intelligence: Let’s see if we can give our NPC some rudimentary smarts.
Our thin-slice NPC has a few messagse she can give, and she gives them cyclically, as long as you’re willing to keep asking. We need to do better. Let’s think about what we want:
We’d like the interaction, however it works, to see somewhat natural, within the style of the game. So far, you only interact by pressing the ?? button. We can imagine dialogs or the like but that seems like a lot of work for small fun, and a major style change in the game’s operation.
Within the game parameters, there are only three things we can do to/with the NPC. We can type ??; we could deploy an inventory item; we can bump into her. If she’s a credible NPC, she probably needs to react to each of those things.
Her reaction should be a function of her past. She should have some kind of “state” that enables her to react differently to the three stimuli depending on what has gone on before. She has elementary state now, an integer ranging from 1 to the number of messages she knows, causing her to cycle between them when we hit the ??.
I need to level with you here. I already know what I think we should do. We should build a simple state machine, so that we can create a table of states, inputs, actions, and state transitions. I could be wrong. Maybe we don’t need that. But I think we do.
This is a dangerous state of mind to be in, because there’s a good chance that I’ll build something that is too much, or not even a decent idea at all. I’ll try to avoid doing those things, but I can already see some reasons why we need that. Let’s look at some things we probably do want an NPC to do:
- ?? #1
- NPC offers its greeting, which may be friendly, or not. Once the greeting is offered, something different will happen with another ??.
- ?? #2
- NPC may offer further conversation. May become angry, “I just TOLD you…”. Something different will happen with ?? again …
- ?? #3
- NPC behaves differently. Maybe it says “OK, here’s a grenade, please go away”. And so on …
- Bump #1
- NPC may feel attacked, become angry. Might go right off and attack, maybe viciously. Maybe easy-going, “take it easy there, bunkie” … and so on
- Give Item (undesired)
- NPC may be looking for a particular item or a particular kind of item. If given something unsatisfying, might give it back. Might look at it, dismiss it, throw it away. Might just keep it.
- Give Item (desired)
- NPC recognizes that it’s being given the thing it wants. Provides some benefit to the player, such as giving a powerful item, or a key. Might open a secret door. Could be anything.
- Wandering
- NPC should probably stand still when player is near by, to allow interaction. When player is further away, NPC probably will want to wander around, if only to be difficult to find when needed later. We can use the Map feature to give it the ability to navigate to given locations. Maybe it has target locations among which it cycles?
As soon as we start thinking of fun things the NPC should be able to do, we can see that there needs to be some kind of programmed behavior that varies depending on the NPC’s state and on what the player does. Maybe it should even depend on other dungeon items or entities. We’ll have no trouble coming up with cool things an NPC could do. We may have trouble seeing how to program those things.
The closest thing we have to this now is monster behavior and movement strategy.
Monster behavior is usually very simple:
Monster.normalBehavior = {
query=function(monster) return monster:queryName() end,
flip=function(monster) monster:flipTowardPlayer() end,
drawSheet=function(monster) monster:drawSheet() end,
isActive=function(monster) return monster:isAlive() end,
}
The only Monster with different behavior is the Mimic, which needs to lay low until you bump it.
There are a number of different movement strategies:
- AloofMonsterStrategy
- Monster tends to move away from Player. If it gets far enough away, it switches to CalmMonsterStrategy.
- CalmMonsterStrategy
- Monster has a boredom count. If it is between two and four cells of the player for long enough, it become Aloof and decides to move away. This makes monsters stop following you after a while.
-
If you get within 1 cell of a Calm Monster, it will move toward you, essentially initiating an attack.
- NastyMonsterStrategy
- If you get within ten meters of this monster, it will move toward you continually and will attack if it gets close enough.
- HangoutMonsterStrategy
- These monsters stay within a few tiles of a given tile. The strategy is used to keep the Poison Frogs that guard a WayDown near it. They will attack if you get too close.
- PathMonsterStrategy
- This monster is given a Map, typically a map leading to the WayDown. On each move, it moves one step closer to the map target.
- MimicMonsterStrategy
- Even if you bump a Mimic, it will ignore you until you have moved four times or are three tiles away. After that, it will follow you and attack you. You have a chance to avoid conflict with a Mimic, if you move away quickly.
These strategies are all hand-programmed, not table driven in any way. And their logic is often pretty complex, despite the easy description. The Mimic strategy for selecting a move is 20 lines of code, for example.
What About NPCs?
We could, in principle, hand code each NPC. We are, after all, here to program. And maybe we’d be wise to hand code at least one or two, with the plan being to detect common elements and extract them, leading ultimately to a nice table-driven format that our Game Designers (me) could use instead of programming.
Or we might just leap in and start a table-driven scheme, extending the table format as needed, thus ensuring that we wind up with a table scheme, which we think we want.
Huh. I think it would be easier to just program our current NPC.
I propose this scheme: we’ll hand-code this simple quest-giving NPC and when we’re done, we’ll convert it to a table driven format if we possibly can. If we can’t, we’ll know the reason why.
Why do I think this is better than just diving in on the table? Honestly, I’m not sure that it is. But I feel like we have a perfectly good programming language here, let’s use it.
I could be wrong. Maybe I should try it both ways.
We’ll see. I’m goin in.
What’s Our NPC?
We have a very simple NPC class:
NPC = class(DungeonObject)
function NPC:init(tile)
tile:moveObject(self)
self.sprite = asset.builtin.Planet_Cute.Character_Horn_Girl
self.messages = {
"I am your friendly neighborhood Horn Girl!\nAnd do I have a super quest for you!",
"I am looking for my lost Amulet of Cat Persuasion,\nso that I can persuade the Cat Girl to let me pet her kitty.",
"If you can find it and bring it to me,\nI shall repay you generously."}
self.messageNumber = 1
end
function NPC:actionWithPlayer(aPlayer)
print("NPC saw that")
end
function NPC:draw(tiny, center)
if tiny then return end
pushMatrix()
translate(center.x, center.y)
sprite(self.sprite,0,30, 66,112)
popMatrix()
end
function NPC:query()
local m = self.messages[self.messageNumber]
self.messageNumber = self.messageNumber + 1
if self.messageNumber > #self.messages then
self.messageNumber = 1
end
return m
end
I think we’re going to wind up with one of a few situations here. Either we’ll have an abstract class NPC and various subclasses representing different NPCs, or we’ll have a separate class, that uses, or is used by, the NPC class, or we’ll have a table-driven NPC class that we load with a specific table. One way or another, I guess we’re aiming at that last situation.
For now, let’s treat our NPC class as a concrete specific class that implements our desired Horn Girl behavior. We’ll spit it apart.
Enough speculation. Let’s let the code take part here.
Our Horn Girl wants something, an Amulet of Cat Persuasion. I think we are importing some amulets, and if not it is time to do so.
Unfortunately, we import staffs, but not amulets:
sheet = asset.gear_staffs_2
names = {"crossed", "green_staff", "feather_staff",
"purple_staff", "snake_staff", "cudgel_staff", "skull_staff",
"bird_staff", "jewel_staff", "knob_staff", "crystal_staff", "twist_staff"}
Sprites:add(names, sheet, 1,0, 0,1)
Easily corrected. I have them in assets already:
There seem to be 14 of them and like some of the other sprite sheets we have, they have some border info we need to skip over. Here goes. I’ll make up names for them.
sheet = asset.gear_amulets
names = {"arrow_amulet", "jade_amulet", "cross_amulet", "sapphire_amulet", "rocket_amulet", "spike_amulet", "thorn_amulet", "time_amulet", "coral_amulet", "sleep_amulet", "spider_amulet", "shield_amulet", "cat_amulet", "curl_amulet", }
assert(#names == 14, "amulet count wrong")
Sprites:add(names, sheet, 1,0, 0,1)
Now we can make an InventoryItem for this:
local ItemTable = {
pathfinder={ icon="blue_jar", name="Magic Jar", attribute="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature" },
rock={ icon="rock", name="Rock", attribute="dullSharpness", description="Mysterious Rock of Dullness", used="You have mysteriously dulled all the sharp objects near by." },
curePoison={ icon="red_vase", name="Poison Antidote", attribute="curePoison" },
health={icon="red_vial", name="Health", description="Potent Potion of Health", attribute="addPoints", value1="Health", value2=1},
strength={icon="blue_pack", name="Strength", description="Pack of Steroidal Strength Powder", attribute="addPoints", value1="Strength", value2=1},
speed={icon="green_flask", name="Speed", description="Spirits of Substantial Speed", attribute="addPoints", value1="Speed", value2=1},
cat_persuasion = {icon="cat_amulet", name="Precious Amulet of Feline Persuasion", attribute="catPersuasion"},
testGreen={icon="green_staff"},
testSnake={icon="snake_staff"},
}
And we can arrange for there to be one:
function GameRunner:createDecor(n)
local sourceItems = {
InventoryItem("cat_persuasion"),
--[[
InventoryItem("curePoison"),
InventoryItem("pathfinder"),
InventoryItem("rock"),
--
InventoryItem("nothing"),
InventoryItem("nothing"),
InventoryItem("nothing")--]]
}
local items = {}
for i = 1,n or 10 do
table.insert(items, sourceItems[1 + i%#sourceItems])
end
Decor:createRequiredItemsInEmptyTiles(items,self)
end
Now wherever I look I should find one.
Works just fine so far.
I wonder if we should be doing more TDD on that bit. Asset provisioning is always nitty-gritty, and I did make at least two typos above which I spared you.
Must think about that.
Let’s at least see if we can TDD the NPC’s behavior a bit. I have a test frame set for it and never used it. Brilliant.
What do we want? Well, someone says on the bus “catAmulet”, and the NPC is subscribed to that message, and when it gets the message, it should say “Thanks!”.
I start with this:
_:before(function()
_bus = Bus
Bus = FakeEventBus()
end)
_:after(function()
Bus = _bus
end)
_:test("Recognizes cat amulet", function()
local npc = NPC(FakeTile())
npc:catAmulet()
end)
The tricksy bit is in FakeEventBus:
FakeEventBus = class()
function FakeEventBus:subscribe(object, method, event)
_:expect(method).is(NPC.catAmulet)
end
function FakeEventBus:publish(event, sender, arg1, arg2)
_:expect(event).is("inform")
_:expect(arg1).is("Thanks!")
end
We expect that our NPC will subscribe to catAmulet. I don’t think we’re guaranteed that this will be called, though. Any road, for now, this is our error, as expected:
1: Recognizes cat amulet -- NPC:20: attempt to call a nil value (method 'catAmulet')
I realize even before I write this that what I will do is call Bus:inform, so I need to provide that instead of publish.
function NPC:catAmulet()
Bus:inform("Thanks!")
end
function FakeEventBus:inform(message)
_:expect(message).is("Thanks!")
end
I think this passes, but it’s weak. It does pass, and it is weak, because we haven’t subscribed yet. I need a smarter fake object, one that will tell me if anyone is subscribed:
function FakeEventBus:subscribe(object, method, event)
self.subscriber = object
_:expect(method).is(NPC.catAmulet)
end
_:test("Recognizes cat amulet", function()
local npc = NPC(FakeTile())
_:expect(Bus.subscriber).is(npc)
npc:catAmulet()
end)
I expect a fail now:
1: Recognizes cat amulet -- Actual: nil, Expected: table: 0x281bfc640
Improve the test. I understand it now, but won’t tomorrow:
_:test("Recognizes cat amulet", function()
local npc = NPC(FakeTile())
_:expect(Bus.subscriber, "did not subscribe").is(npc)
npc:catAmulet()
end)
1: Recognizes cat amulet did not subscribe -- Actual: nil, Expected: table: 0x281b3aec0
function NPC:init(tile)
Bus:subscribe(self, self.catAmulet, "catAmulet")
tile:moveObject(self)
self.sprite = asset.builtin.Planet_Cute.Character_Horn_Girl
self.messages = {
"I am your friendly neighborhood Horn Girl!\nAnd do I have a super quest for you!",
"I am looking for my lost Amulet of Cat Persuasion,\nso that I can persuade the Cat Girl to let me pet her kitty.",
"If you can find it and bring it to me,\nI shall repay you generously."}
self.messageNumber = 1
end
OK, this should work. Arrgh. The message is catPersuasion, not catAmulet.
I make that change, the test still runs, and now …
OK, not exactly generous repayment, but it’s doing what we asked.
Let’s set the dungeon back to regular initialization and commit: horn girl gives thanks for cat amulet.
I got a late start this morning. It’s about 1120 and there’s a chocolate croissant waiting for me. Williams & Sonoma frozen ones. They turn out quite nicely.
So let’s see where we are and sum up.
Retro / Summary
Well, that went easily. about 30 new lines of code, mostly in setting up the new amulets and decor, and in the test, including a little FakeEventBus to tell us we got the right messages. There may be a way to have used the real one, and I’ll make a note to try that. I’d much prefer not to use Fakes, although they are handy for this kind of action at a distance thing.
We don’t really have much state in our NPC yet: you could give her 20 amulets and she’d just say thanks over and over, otherwise repeating her message sending you out.
We really want her to have a state like “wanting” where she wants the thing, and a state “satisfied” where she doesn’t. Probably she has a few messages while wanting, like she does now, and when satisfied, probably either vanishes from the dungeon, or, if she sticks around, has some generic friendly message.
Or … maybe if you meet her later … she says or does something useful … Maybe she says “Thanks, I owe you one” and disappears, but later, when you’re in some kind of trouble, perhaps beset by cats, she appears and helps you.
And if you get beset by cats before giving her the amulet, maybe she appears and is like “If only I had my Amulet of Cat Persuasion, I could help you out here.”
So there’s more to do, and we’ve taken another tiny step toward a useful NPC. If I were willing to work all day, we’d probably have her in pretty good shape. But I’m not. We’ll do more next time.
For now, so far so good! See you next time!