Dungeon 86
Everyone thinks they know how I should do combat. There are issues that daunt me. I’m sure there’s a pony in here somewhere.
Let’s summarize D&D combat, as I understand it, because I want something along those lines. This info is based on roll20.net.
- Surprise
- It is possible that the monsters surprise the princess, or that the princess surprises the monsters. Surprise only happens if at least one side is trying to be stealthy. If an entity is surprised, they can’t Move or Act in the first round, nor can they React.
- Initiative
- One side or the other has Initiative, which determines who goes first in the encounter. Thereafter, activity proceeds in that order, round robin.
- Player Turn
- in D&D, when you take your turn, you can move a distance that depends on your speed, and you can take one Action (see below). In addition, you can interact with one object, e.g. draw a weapon or open a door. In D&D the DM can determine whether the interaction is free or uses up your Action.
- Movement
- In D&D, movement is used a lot, to gain advantage. You can break up your movement into before Action and after. Thus you might run toward a monster, attack it, and run away. You can sometimes move through another creature’s space, even if it is hostile.
- Combat Actions
- The player can Attack (q.v.) Cast a spell, Dash, Disengage, Dodge, Hide, get Ready, or Use an object. (Some objects can be used without using up your Action, but some require you to use it.)
- Attack
- To Attack, you pick a target within range, and roll d20 to see if you hit it. In D&D, characters have an “Armor Class”, and your roll must exceed that value to count as a hit. We do not presently have that concept but I suspect we need it. In D&D, a roll of 20 always hits and a roll of 1 never hits. A roll of 20 is a Critical Hit (q.v.) D&D has lots more details here.
-
Attacks have a range, whether hand-to-hand or a “ranged” attack such as an arrow shot or the like.
- Opportunity Attack
- When you or a creature are fleeing or just passing by, there can be an “Opportunity Attack” as you pass. This may be more than we want in our rather simple game.
- Cover
- D&D allows for “Cover”, e.g. being behind a tree or wall. Cover basically increases your Armor Class.
- Damage
- At last we get to the good stuff. Entities have “hit points”. We’re using our Health variable for that at present. The value can go down when you’re injured, and up when you’re healed. Generally a player has a current maximum value for hit points.
-
When an entity takes damage, the damage amount is subtracted from their hit points. When hit points drop to zero, bad stuff happens.
-
In D&D, weapons of all kinds have an associated amount of damage, specified in dice: 1d4, 2d6, whatever. When you hit with the weapon, you roll to determine the damage done. If the hit is a Critical Hit, you roll the dice twice and add.
-
Entities can have various vulnerabilities and resistance to different attacks.
-
When your hit points drop to zero, you become unconscious. If the damage of the attack is great enough (enough to consume your max health points again), you die. I’m not sure whether to allow death in our final game or not.
-
D&D has lots of special behavior around what happens while you have zero hit points. I’m not at all sure what, if any, applies to us.
Application to Dung Program
What does all this mean to us? I think we’ll want to allow surprise at some point, perhaps not right away. We’ll want to roll Initiative to see who goes first. We could, in principle, have the player’s move sandwiched between two or more monster moves.
When a monster gets its turn, I envision that it will always attack, but we might want them to have the option to disengage and run away. In any case, it seems straightforward enough to have a little “Combat AI” that decides among a few monster actions.
If the monster attacks, it’ll roll for a hit and if it hits roll damage. Easy enough.
When the player gets their turn, they’ll need to take action using the user interface. I envision a GUI, represented now by the “Flee” and “Fight” buttons, where they choose what to do. There could be any number of options in the GUI, such as selection of a target, a weapon, etc.
After the player has selected what to do, and perhaps touched a “go” button, the game rolls for a hit and damage and reports the result. Then it’s the next entity’s turn.
This seems simple enough. And yet, I’m kind of stuck. If I had a smarter pair than my cat, like you, I’m sure we’d get unstuck. But here’s the issue:
It’s the Damn Floater
A key design aspect of the game is the Floater, messages that waft upward from the player (or, in principle elsewhere), narrating the game. We have three cases now: the initial game narration, a one-line report when you get an addition to an attribute, and the Encounter crawl, which is a multi-line report of an encounter.
Here’s what I wish would happen during combat. Suppose the monster attacks first.
The crawl says something like:
The Serpent attacks!
The attack is good!
You suffer 5 points damage!
Now it’s the player’s turn. I think we’d like the crawl to stop while the player dithers, which takes eons in computer time. Finally the player presses all the necessary buttons, and the crawl continues:
The princess casts Create Bird!
A bird appears and attacks the Serpent!
The serpent suffers 4 points damage! The serpent tries Poison Bite!
The bite succeeds!
You are poisoned!
It’s the player’s turn again. The crawl pauses. You dither. And so on …
Now let’s recall how the Floater works at present. It is started by being given a text provider, which can be called using coroutine.resume
whenever the floater wants another line of text. The floater wants a new line of text after the bottom-most line has risen high enough to make room for another line.
Now the call to resume
must return essentially immediately, or the whole system will stop running, because all this happens during screen draw events. And there’s no way for the code executed to just spin: again, this would lock up the whole system.
Perhaps we have a special return, such as “wait”, which causes the Floater to keep asking, but not to display or scroll upward until text comes back. Then the combat coroutine could check to see if the player has made their selection, and if not return “wait” and when they finally get around to it, roll the dice and return to the script.
However:
I believe it was Chet Hendrickson who commented during one of our Zoom Ensemble meetings that the Floater seemed like the wrong object to be controlling game behavior, and that the control should be at some other level. I’d agree that the name suggests that it’s just an I/O device of a particularly cute kind, but that the behavior is far too significant for a mere output device.
I just don’t have a better idea. Yet.
Maybe I should turn the Floater into a pure output device and just write to it when I feel like it. And maybe it scrolls up as it wishes. If it has another line in its buffer, it’ll display it. If it hasn’t, well, things scroll off. When new lines appear, they start showing up.
That would change the current Encounter scheme into something that sort of makes sense: it would just rip through its protocol, spitting out lines to the Floater, and when it’s done, it’s done. Days later, the floater would finally display the last line.
We’d have an issue, which is that we really don’t want the player to be able to do anything while the Encounter, or a Combat round, is going on. So we probably do need to synchronize the floater and the playerCanMove
flag. We could do that by putting a command into the Floater’s buffer, causing it to send a suitable message to GameRunner.
Too Much Dithering
Too much speculation. We need to do something. Let’s get a decision up in this baby and try it. If it doesn’t work, that’s by Git gave us Revert.
I’m assuming that Floater needs to be changed to run from an array. I’m assuming we can add to the array any time we wish and that that’s how events will work.
But wait. How does the initial crawl work?
self:runCrawl(self.initialCrawl)
...
function GameRunner:initialCrawl()
coroutine.yield("Welcome to the Dungeon.")
coroutine.yield("Here you will find great adventure,")
coroutine.yield("fearsome monsters, and fantastic rewards.")
coroutine.yield("Some of you may die, but I am willing")
coroutine.yield("to make that sacrifice.")
end
Let’s make a new crawl coroutine that deals with an array, and use it here. Then we’ll have an array Floater with little effort.
I think I want to TDD this, and I want to do it in another project so a not to contaminate this one until I’m ready.
Let’s try this:
_:test("Single Item", function()
local ary = {"First Line"}
local provider = Provider()
provider:addItems(ary)
local msg = provider:getItem()
_:expect(msg).is("First Line")
end)
I’m imagining an object, Provider, to which we add items as we wish, and which provides them to a consumer via getItem
. This test requires me to create Provider:
1: Single Item -- Tests:17: attempt to call a nil value (global 'Provider')
Defining the class demands addItems
and I think I know how to do that much:
Provider = class()
function Provider:init()
self.items = {}
end
function Provider:addItems(array)
for i,v in ipairs(array) do
table.insert(self.items, v)
end
end
Now the test demands getItem
:
1: Single Item -- Tests:19: attempt to call a nil value (method 'getItem')
Now I had in mind that I’d make this thing a coroutine, because the current Floater expects a coroutine. But maybe it can just return a line or a signal that it has no line (yet).
function Provider:getItem()
return self.items[1]
end
The test should pass.
1: Single Item -- OK
New test:
_:test("Two items", function()
local ary = { "First Line", "Second Line" }
local provider = Provider()
provider:addItems(ary)
_:expect(provider:getItem()).is("First Line")
_:expect(provider:getItem()).is("Second Line")
end)
This should fail seeing “First Line” and expecting “Second Line”.
2: Two items -- Actual: First Line, Expected: SecondLine
Make it work …
Now what I was going to do was to keep an index and tick it along. But once we return a line, we’ll never need to return it again. Let’s do this:
function Provider:getItem()
return table.remove(self.items, 1)
end
We’ll remove it! We’ve invented the FIFO! Whee! I expect the test to pass.
2: Two items -- OK
Now what should happen if the table is empty? Let’s return a wait token, and let’s allow the creator to specify it.
_:test("waiting", function()
local ary = { "First Line", "Second Line" }
local provider = Provider("...wait...")
provider:addItems(ary)
_:expect(provider:getItem()).is("First Line")
_:expect(provider:getItem()).is("Second Line")
_:expect(provider:getItem()).is("...wait...")
_:expect(provider:getItem()).is("...wait...")
end)
This’ll fail looking for wait:
3: waiting -- Actual: nil, Expected: ...wait...
And we implement:
function Provider:init(default)
self.default = default or "default"
self.items = {}
end
function Provider:getItem()
if #self.items < 1 then return self.default end
return table.remove(self.items, 1)
end
It seems reasonable to think of the value as a default. It’s up to the user of Provider to decide what he wants to do about it. I expect this test to run.
3: waiting -- OK
Now let’s provide some new items and pick up reading:
_:test("adding new items", function()
local ary = { "First Line", "Second Line" }
local provider = Provider("...wait...")
provider:addItems(ary)
_:expect(provider:getItem()).is("First Line")
_:expect(provider:getItem()).is("Second Line")
_:expect(provider:getItem()).is("...wait...")
_:expect(provider:getItem()).is("...wait...")
provider:addItems({"how", "now", "provider"})
_:expect(provider:getItem()).is("how")
_:expect(provider:getItem()).is("now")
_:expect(provider:getItem()).is("provider")
_:expect(provider:getItem()).is("...wait...")
end)
I expect this to run.
4: adding new items -- OK
So this is a nice little object:
Provider = class()
function Provider:init(default)
self.default = default or "default"
self.items = {}
end
function Provider:addItems(array)
for i,v in ipairs(array) do
table.insert(self.items, v)
end
end
function Provider:getItem()
if #self.items < 1 then return self.default end
return table.remove(self.items, 1)
end
Let’s move it over to the Dung game and commit. Then we’ll see if we can use it and get rid of our coroutine stuff.
When I went to commit, I found an uncommitted button refactoring. So commit is: Added Provider class. Also a Button refactoring.
Now let’s see what floater does and how we can adapt it to the Provider notion. I am hopeful that this will go readily, because if it doesn’t, I’ll need another idea, and I’ve already had my idea for the day.
Here’s Floater as it stands:
function Floater:init(runner, yOffsetStart, lineSize, lineCount)
self.runner = runner
self.provider = nil
self.yOffsetStart = yOffsetStart
self.lineSize = lineSize
self.lineCount = lineCount
end
function Floater:draw()
local n = self:numberToDisplay()
if n == 0 then return end
pushStyle()
fill(255)
fontSize(20)
local y = self:yOffset()
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,n do
text(self.buffer[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
self:increment()
popStyle()
end
function Floater:increment(n)
self.yOff = self.yOff + (n or self:adjustedIncrement())
if self:linesToDisplay() > self.lineCount then
table.remove(self.buffer,1)
self.yOff = self.yOff - self.lineSize
end
if #self.buffer < self:linesToDisplay() then
self:fetchMessage()
end
if #self.buffer == 0 then
self.listener:stopFloater()
end
end
function Floater:fetchMessage()
if coroutine.status(self.provider) == "dead" then return end
local tf, msg = coroutine.resume(self.provider)
if tf then
if msg ~= nil then table.insert(self.buffer, msg) end
else
print("error: "..msg)
table.insert(self.buffer, "error: "..msg)
end
end
That’s the display side of things. Here’s how we get it going:
function Floater:runCrawl(aFunction, object)
if self:okToProceed() then
self.provider = coroutine.create(aFunction)
self:startCrawl(object)
else
print("crawl ignored while one is running")
end
end
function Floater:okToProceed()
return self.provider==nil or coroutine.status(self.provider) == "dead"
end
function Floater:startCrawl(listener)
self.listener = listener or FloaterNullListener()
self.yOff = self.yOffsetStart
self.buffer = {}
self.listener:startFloater()
self:fetchMessage()
end
Now I expect that when I get the provider plugged in, the floater will display whatever’s in the array and then perpetually display wait wait wait or whatever the default is. Then we’ll figure out how to make it stop.
I’d like to have a way to change this thing in tiny increments but I don’t see it. I’ll just go ahead, but watch the clock.
It’s 1054.
function Floater:init(runner, yOffsetStart, lineSize, lineCount)
self.runner = runner
self.provider = Provider("...wait...")
self.yOffsetStart = yOffsetStart
self.lineSize = lineSize
self.lineCount = lineCount
end
That much seems reasonable …
function Floater:fetchMessage()
local msg = self.provider:getItem()
table.insert(self.buffer, msg)
end
I took out all the end-play logic. We’ll put it in in due time, when we figure out what we need.
I think increment
is probably just fine. Now to get the ball rolling:
function Floater:runCrawl(array, listener)
self.provider:addItems(array)
self:startCrawl(listener)
end
Now I am sure there’ll be trouble on a second attempt to start the crawl, but one thing at a time here. Let’s now convert the initial crawl and see what happens.
function GameRunner:runCrawl(array)
self.cofloater:runCrawl(array)
end
function GameRunner:initialCrawl()
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
Let’s run and see what explodes. It’s 1105.
attempt to index a function value
stack traceback:
[C]: in for iterator 'for iterator'
Provider:13: in method 'addItems'
Floater:63: in method 'runCrawl'
GameRunner:286: in method 'runCrawl'
GameRunner:85: in method 'createLevel'
Main:18: in function 'setup'
Somehow we’ve handed a function value to our provider. the problem is in GameRunner, which used to pass in a function.
self:runCrawl(self.initialCrawl)
Should now be:
self:runCrawl(self:initialCrawl())
Now it works just as I wanted:
It scrolls “wait” forever. I imagine My inclination is to lave the Floater running all the time, printing nothing, and to leave it saying “wait” for now. That will mean we don’t really want to start the crawl on every attempt to run, but more to the point, we need to convert our other users to the new Provider scheme.
It’s 11:12.
function GameRunner:runBlockingCrawl(aFunction)
self.cofloater:runCrawl(aFunction, self)
end
This one I’ll just convert blindly, to use an array. I have no idea where we’ll get the array at this moment. Something about unwinding an Encounter, I expect.
function GameRunner:runBlockingCrawl(array)
self.cofloater:runCrawl(array, self)
end
function Player:addHealthPoints(points)
local msg = string.format("+%d Health!!", points)
local f = function()
coroutine.yield(msg)
end
self.runner:runCrawl(f)
self.healthPoints = math.min(20, self.healthPoints + points)
end
I think this should have been removed and subsumed into another method coming right up, but for now … I have an idea. How about if we have a new method on GameRunner to add to the crawl, assuming that it’s always running along:
function Player:addHealthPoints(points)
local msg = string.format("+%d Health!!", points)
self.healthPoints = math.min(20, self.healthPoints + points)
self.runner:addToCrawl(msg)
end
And while we’re thinking about it.
function GameRunner:addToCrawl(array)
self.cofloater:addItems(array)
end
And that reminds me that I want my message in an array:
function Player:addHealthPoints(points)
local msg = string.format("+%d Health!!", points)
self.healthPoints = math.min(20, self.healthPoints + points)
self.runner:addToCrawl({msg})
end
Moving right along …
function Player:doCrawl(kind, amount)
local msg = string.format("+%d "..kind.."!!", amount)
local f = function()
coroutine.yield(msg)
end
self.runner:runCrawl(f)
end
That follows the pattern above:
function Player:doCrawl(kind, amount)
local msg = string.format("+%d "..kind.."!!", amount)
self.runner:addToCrawl({msg})
end
I rather expect now that we’ll get scrolling messages about the gems and chests and whatnot.
However, I forgot to put addItems onto Floater. It’s in Provider:
function Floater:addItems(array)
self.provider:addItems(array)
end
Yes! The attribute messages come out. An encounter, of course, crashes us.
It’s 11:27. About a half hour so far. Well outside the danger zone.
We start Encounter this way:
function Monster:startActionWithPlayer(aPlayer)
if aPlayer:isDead() then return end
--Encounter(self,aPlayer):attack()
self.runner:runBlockingCrawl(createEncounter(self,aPlayer))
end
function Player:startActionWithMonster(aMonster)
if aMonster:isDead() then return end
self.runner:runBlockingCrawl(createEncounter(self,aMonster))
end
And …
function createEncounter(player,monster,random)
f = function()
attack(player, monster, random or math.random)
end
return f
end
The easy fix for this will be to have createEncounter
punch the results of the “attack” function into an array, and hand it to the crawl. That’ll be fairly easy, I think.
Hmm … I was going to write code to call coroutine.resume
until it stopped. Instead, let’s edit the encounter functions to add to a provided array. That’ll be more invasive, and will require us to succeed or revert. Best commit: using new Provider logic in crawls. Encounter broken.
OK. Now then:
local result = {}
function createEncounter(player,monster,random)
result = {}
attack(player,monster, random or math.random)
Runner:addToCrawl(result)
end
Now let’s redefine yield
:
local yield = function(msg)
table.insert(result, msg)
end
This could possibly work. Let’s see.
attempt to index a nil value
stack traceback:
[C]: in for iterator 'for iterator'
Provider:13: in method 'addItems'
Floater:67: in method 'runCrawl'
GameRunner:294: in method 'runBlockingCrawl'
Monster:237: in local 'action'
TileArbiter:27: in method 'moveTo'
Tile:82: in method 'attemptedEntranceBy'
Tile:306: in function <Tile:304>
(...tail calls...)
Monster:184: in method 'moveTowardAvatar'
Monster:104: in method 'chooseMove'
GameRunner:256: in method 'moveMonsters'
GameRunner:333: in method 'turnComplete'
Player:210: in method 'turnComplete'
Player:127: in method 'keyPress'
GameRunner:246: in method 'keyPress'
Main:34: in function 'keyboard'
Ah, not connected quite right.
function Monster:startActionWithPlayer(aPlayer)
if aPlayer:isDead() then return end
--Encounter(self,aPlayer):attack()
self.runner:runBlockingCrawl(createEncounter(self,aPlayer))
end
We’re assuming that we have to call the runner from here, not from the create. So we want the create to just return the array, and we’ll have to change the runBlockingCrawl
as well.
function createEncounter(player,monster,random)
result = {}
attack(player,monster, random or math.random)
return result
end
function GameRunner:runBlockingCrawl(array)
self.cofloater:addItems(array)
end
Once we get this wired up, we’ll refine and clean up. Encounter, as it is, will go away anyway but we want the game to work as is until then.
And the encounter works as we had hoped. It jams all its text into the crawl array, and it scrolls up until done.
There are anomalies. Note that in the present Encounter we have effects taking place:
...
defender:displayDamage(false)
defender:damageFrom(attacker.tile, damage)
...
The first line there would flash the player or monster in yellow or red, indicating that it was hurt, and it would make its “I’m hurt” sound.
With the new scheme, that happens instantly, because we’re unwinding the entire encounter into an array, in a zillisecond, so all the sounds and flashes happen in an instant, before the crawl even starts.
Similarly, the damage effect on the attribute sheets takes place instantly. We’d surely prefer that the status sheets change, and the visual and sound effects, take place as the relevant line appears.
I have a tentative plan for that. Now let’s do one little thing: let’s change the default line in the Provider to a blank line:
function Floater:init(runner, yOffsetStart, lineSize, lineCount)
self.runner = runner
self.provider = Provider("")
self.yOffsetStart = yOffsetStart
self.lineSize = lineSize
self.lineCount = lineCount
end
And let’s see what happens:
Here we see that the crawl appears to come and go as events happen. We know the secret: it’s always running, displaying blank lines. Is that a concern? Not for me. I hope you can deal with it.
This is good enough to commit: Converted Floater to array Provider.
It’s 1201. Less than an hour for the final conversion of the hard bit, the encounter. It’s not perfect: we still have to bring the action and sounds all back in sync, and we’ll have to deal with freezing the player during some intervals. But I have an idea for that. And we should really replace the weird coroutine encounter functions, but that whole construct will be replaced by new Combat anyway,.
Let’s sum up.
Summary
Wow. It seems to me that I dithered for days over how to get to a point where Combat could work. My concern was all tied up with the weird but lovely coroutine-floater relationship.
If there’s a big lesson there, it is that code that is cool should be deeply suspect. On the other hand, it was the only idea I had at the time, and it took just a morning to replace it with something simpler. So we can see this as a perfect example of “technical debt”, the notion that we have an adequate, well-written, working solution, and then one day we have a better idea, and we put it into the system, paying off the debt.
(If you think technical debt is about bad code, you’re mistaken. The technical term for that is “bad code”. Don’t write bad code if you can avoid it. And you can avoid it.)
So another big lesson is: if the code is modular and clean, we can generally improve it or replace it without a lot of trouble.
Our Floater tests are broken, I just noticed. I’ll look at those, next time if I remember.
Overall, a good morning. Looking forward, here’s my rough plan:
Things like Combat will generate messages to the crawl as things are determined to happen. To synchronize things in the game, we’ll have a secret language in the Floater, so that we can send commands along with text, saying “flash the creature” or “apply damage”. So those actions will be deferred until the relevant line gets pulled from the array. We’ll use that facility to lock and unlock the player based on whose “turn” it is.
There’s a bit to discover there, but I think we’ll find that it’s pretty straightforward. The Floater already has the concept of a listener, and we’ll use that to field our secret messages.
I’m pleased, and wondering why it took me a few days to see this approach clearly enough to do it. Sometimes you bite the bear, I guess.
See you next time!