Dungeon 60
More work on Encounter. I still want the fighters to separate after a round.
I burned over an hour yesterday, trying for a fairly rudimentary addition to the Encounter, that would move the attacker away from contact with the defender at the end of an Encounter round. I think I’d have done well to have had a timer running, reminding me every N minutes that I need to commit some code or admit that I’m in trouble. I’ll try ten minutes this morning just to see how it works.
A bit of study in the clear light of day showed me the main issue with implementing the feature. The move flow goes like this:
function Tile:legalNeighbor(anEntity, aStepVector)
local newTile = self:getNeighbor(aStepVector)
return self:validateMoveTo(anEntity,newTile)
end
function Tile:validateMoveTo(anEntity, newTile)
if newTile:isRoom() then
local tile = newTile:attemptedEntranceBy(anEntity, self)
self:moveEntrant(anEntity,tile)
return tile
else
return self
end
end
Setting aside all the details, this function adjusts the tile contents to move the entity in the view of the dungeon, and returns either the tile the entity has requested to move to, or the original tile. Returning the original tile refuses the move.
This is used like this:
function Monster:makeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
self.tile = self.tile:legalNeighbor(self,move)
end
Everyone who uses legalNeighbor
, namely monsters or the player, calls the method above and then sets their tile to the returned value.
For yesterday’s work, this implies that no matter what I might have tried to do to set the entity’s tile during the Encounter, the code above would place it either on the attempted tile, or the tile the entity started on. Since I had, at some points, tried adjusting tile contents directly, I wound up with the floor and the entity not even agreeing on where the entity was. Nasty.
The question becomes–or remains–if we want to cause the attacker (or defender) to withdraw from an Encounter, what changes have to be made to make this happen?
One simple idea comes to mind. Maybe it’s one-and-a-half ideas.
First, we could change the legalNeighbor
method to set the entrant’s tile directly. As it’s written now, it assumes that the entrant will set its tile to the chosen one, and it has changed tile contents accordingly. Since we want the entity’s tile always to be the tile whose contents include the entity, moving that setting would ensure that invariant. We should probably do that in any case.
Second, what if, when we decide that the proposed tile cannot be used, instead of using the tile to which the original legalNeighbor
message was sent, we move the entity to the tile which it then holds as its member variable. Let me rephrase that until at least one of us understands it.
Suppose we think of the method as “move me to the specified tile if possible, otherwise move me to the tile I’m now on (or pretending to be on).
Consider three tiles, P, M, and R, for Player, Monster, and Retreat.
- Player attacks monster, requesting a move to M. Player is currently on tile P.
- Things wander around in the code a while, starting and executing the Encounter.
- The Encounter, in the course of its proceedings, decides that the Player should retreat. It sets Player’s tile to R.
- The Encounter ends, with the result “No, you can’t move into M”.
- The legal move code says OK, I have to move you from your starting point to your own current tile, and moves the Player from P to R.
That could work. But it’s weird, because it relies on setting the entity to an illegal state, namely pointing to one tile while contents indicate otherwise. We could make it better–or arguably worse–by covertly moving the entity contents as well.
This is all stuff going on behind the curtains, and relies on an illegal albeit temporary state. So while it would probably work, it’s a hack. Meh.
What else might work?
- Backup Plan
- What if we did this: When a requested move is not allowed, the tile sends a message to the entity saying “move refused, what’s your backup plan?” The entity, today, would return its current tile. But we could have a “backup tile” member variable and could return it.
- Return “No” from
legalNeighbor
- What if, instead of moving the entity either to the proposed location or its old location, we just returned a “didn’t move” result, and the entity can attempt another move if it wishes. That should be safe, because our invariant is that the entity is on a legal tile. So we can safely just refuse a move at any time.
This last idea sounds almost good. Instead of hacking something to move an entity during battle, we could move them explicitly, during or after the proceedings. We’d probably want to be sure that the original move wasn’t accepted, but even that could be dealt with.
There is another glitch, however. I’ve changed the table entries so that, for example, the player can’t enter a monster’s cell, even if the monster is dead. This avoids the player standing on the monster during the battle if the player wins: it just looks odd.
And here … I must digress.
Encounter Graphics
I demonstrated the encounter with its crawl play-by-play to the Zoom Ensemble last night. They were duly impressed but shared with me the oddness of standing watching the play-by-play go by with the monster or player already showing as dead on the screen. That happens because the encounter is resolved instantly, and the play-by-play takes seconds to display.
I had thought of somehow delaying the steps of the Encounter, to keep the graphics and reporting in sync. That would be difficult, requiring that the Encounter maintain state over a long interval. It would probably mean that the Encounter would have to become some kind of finite state machine.
Bryan Beecham had a much better idea. Instead of slowing down the Encounter, animate the entities on the screen as the Encounter crawl plays back. When the crawl says “Princess strikes”, highlight the princess, or jiggle her sprite, or make her dart at the monster, or get better sprites and have her swing a +2 sword.
This seems to me to be quite doable. We could enhance the FloatingMessage object (which is really a FloatingMessageList) to contain not just the text to be displayed, but an indication of a graphical effect to perform during the display of that line. Probably we’d run the effect while, and only while, that line was at the bottom of the crawl. When the next line pops into view, we run that line’s effect.
With this model, we could even have a new message “Princess leaps back”, and its associated effect would be to cause her to move.
As I fantasize about this capability, I can imagine other uses for the ability to define entity operations and have them done in sequence. Best to calm down and do something relatively simple first.
However …
Let’s Do Encounter Graphics First
Given that we’re going to do Encounter Graphics–and we are–maybe we should do it before doing the withdrawal move in Encounter. Now, in principle, I believe we can do things in any order, and come up with a fine design either way, but give the choice, we might want to choose to do things in an order that we prefer for “technical” reasons.
OK, I consider this settled. I’ll do the withdrawal from an Encounter using the Encounter Graphics idea.
Time to code. Starting the timer.
First step: Change FloatingMessage to FloatingMessageList, leaving room for a FloatingMessage object.
Done, less than five minutes. Commit: Rename FloatingMessage to FloatingMessageList. Restart Clock.
Now we need a FloatingMessage object that amounts to a text string, for our FML to use.
This is done here:
function GameRunner:addMessages(anArray)
self.floater:addMessages(anArray)
end
That’s called from a few places, such as:
function Encounter:attack()
self:log(self.attacker:name().." attacks "..self.defender:name().."!")
local attackerSpeed = self:rollRandom(self.attacker:speed())
local defenderSpeed = self:rollRandom(self.defender:speed())
if attackerSpeed >= defenderSpeed then
self:log(self.attacker:name().." is faster!")
self:firstAttack(self.attacker, self.defender)
else
self:log(self.defender:name().." is faster!")
self:firstAttack(self.defender, self.attacker)
end
self.attacker.runner:addMessages(self.messages)
return self.messages
end
I think I’d like to create the new FloatingMessage object and use it somewhere … but to allow the FML object to accept strings and FMs, at least for now, for backward compatibility.
-- FloatingMessage
-- RJ 20210109
FloatingMessage = class()
function FloatingMessage:init(message, operation)
self.message = message
self.operation = operation
end
Now in FML:
function FloatingMessageList:draw()
if #self.messages == 0 then return end
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self.messages[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
The timer is down to 2 minutes from 10. I’m feeling pressure. But I don’t plan to hold myself to a ten minute commit, but two ten minute intervals. I’m still feeling pressure, especially since I’ve got writing to do as well as coding. I’m trying to code calmly. It will help to let the timer go off and observe that the sky doesn’t fall.
function FloatingMessageList:draw()
if #self.messages == 0 then return end
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self:getText(i), pos.x, pos.y) -- <----
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
function FloatingMessageList:getText(anInteger)
local msg = self.messages[i]
if type(msg) == "FloatingMessage" then
return msg:text()
else
return msg
end
end
This isn’t tested yet. (The timer went off and the sky did fall. Or, at least, I couldn’t figure out how to make it stop ringing.)
OK let’s test at least the opening crawl as text. Surprisingly, it doesn’t work.
FloatingMessageList:30: bad argument #1 to 'text' (string expected, got nil)
stack traceback:
[C]: in function 'text'
FloatingMessageList:30: in method 'draw'
GameRunner:158: in method 'drawMessages'
GameRunner:121: in method 'draw'
Main:23: in function 'draw'
Full disclosure: accidentally reset the second ten minute timer. Oh well, I need the time. I see no way this cleverly devised code could fail.
Putting a print in the getText
, I find that self.messages[i] is returning nil, which OF COURSE IT WOULD:
function FloatingMessageList:getText(anInteger)
local msg = self.messages[anInteger]
if type(msg) == "FloatingMessage" then
return msg:text()
else
return msg
end
end
Crawl works. Let’s change it to use FM:
function GameRunner:initialMessages()
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
This becomes …
function GameRunner:initialMessages()
return {
FloatingMessage("Welcome to the Dungeon."),
FloatingMessage("Here you will find great adventure,"),
FloatingMessage("fearsome monsters, and fantastic rewards."),
FloatingMessage("Some of you may die, but I am willing"),
FloatingMessage("to make that sacrifice.")
}
end
And produces the expected message about text
not being defined yet:
function FloatingMessage:text()
return self.message
end
However that doesn’t fix it. The message is:
FloatingMessageList:30: bad argument #1 to 'text' (string expected, got table)
stack traceback:
[C]: in function 'text'
FloatingMessageList:30: in method 'draw'
GameRunner:158: in method 'drawMessages'
GameRunner:121: in method 'draw'
Main:23: in function 'draw'
I am surprised and confused. And my timer is running out. I’m going to reset it when it triggers. I shouldn’t but I’m sure this is a silly defect and it’s taking me forever to write this rationalization for what I’m about to do.
Reset timer.
My text message isn’t being called. Type doesn’t work as I had hoped: it returns “table”. OK, I can deal with it:
function FloatingMessageList:getText(anInteger)
local msg = self.messages[anInteger]
if type(msg) == "string" then
return msg
else
return msg:text()
end
end
Works. Commit: FloatingMessage works in FloatingMessageList.
Stop timer. Regroup.
Regroup
We now have a simple object in our FloatingMessageList, namely FloatingMessage, which is ready to include an operation of some kind, to be executed, well, whenever we choose.
I think what we want to choose is to start the operation when the message first appears, and end it when the next message appears, or would appear if our message is the last one. Let’s look at FML:
function FloatingMessageList:draw()
if #self.messages == 0 then return end
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self:getText(i), pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
function FloatingMessageList:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
self:startMonsters()
end
end
end
function FloatingMessageList:rawNumberToDisplay()
return 1 + (self.yOff - self.yOffStart) // self.lineSize
end
This is frankly a bit odd. The rawNumberToDisplay
is how many messages there is room for, given how far yOff
has been incremented. When we do the table.remove
, we’re removing the top message and setting yOff
back down by one line. Since we display up to 4 lines, we’ll start displaying the next message. Therefore, at the point of table.remove
is where we should start our operation (and stop the preceding one).
But what is an “operation”, and what do we mean by starting and stopping it? I’m hoping the second question will become obvious. But what is the thing? Let’s think of some examples of what we might want to do, and see if we can begin to understand what the operation is.
- Kill an Entity
- We don’t want the entities to flash to dead at the beginning of the encounter. Instead we’ll want to mark them dead as the effect of the “Pink Slime is dead” message. That operation needs a start and not a stop. It is a message to the entity.
- Color an Entity
- Maybe when an entity takes damage we want to briefly color it red. Start would send a message to it to color red, stop would send it a message to color white. Again, could be a message to entity.
- Play a Sound
- At least some actions already play a sound, and we clearly want that sound to be played more in time with the crawl. Sound is presently triggered in the
damageFrom
method on the entity. And … - Take Damage
- There’s a real issue here. We would like to use the entity’s own health accounting to know when the encounter ends in an entity’s demise. But if we do that, the entity’s attribute sheet will update immediately when the encounter terminates, not when the appropriate message crawls. That would be bad.
Popping up to the larger concern
At this moment, I suspect that if we want the screen to track a battle at a human-perception speed, step by step, we simply must accept that the Encounter object works step by step, not all in a batch as it does now.
The good news may be that the FloatingMessageList knows when to “pull” for another message.
The scary news is that I know a deep-in-the-bag way that might be good for doing this: coroutines, or at least, iterators.
A very rough description
Let’s imagine that the Encounter is seen from the outside as an iterator over a list of FloatingMessages, The consuming object, our FML:draw, can say next
to the iterator when it wants the next message to draw. The iterator can return some kind of EOF object when the list is empty. But when next
is called, what happens is that the Encounter picks up where it left off in its internal procedure. It returns a value to the caller by calling yield()
or some equivalent function. This returns the next value to the caller.
We’ll leave the details for next time. This is certainly more than I can undertake right now: I need to consider options.
But I am convinced at this moment that I’m going to have to execute the Encounter at human speed rather than let it fly through the whole battle as it does now, so that we can display what’s going on–and perhaps even control it as the player.
The official solution to such a thing is probably Coroutine, or at least Iterator. Those are deep in the bag, however, so it will require some thinking, and some experimentation.
Next time … for now, have a good day and stop back in soon. Stay safe!