Dungeon 99
Music? And then, idk, something else. Maybe refactoring? Poison?
I do think I’d like for the poisonous creatures to set up a health-draining effect on the princess, but I’ve not decided how to fix it. Maybe any health Loot should cure poison?
Anyway, I was going to start with music. I want to provide at least two tunes, one ongoing and another more rousing one when monsters are close by. We’ll evolve our way into that, of course.
Music in Codea is played using the music
function, which includes this calling sequence, which I think we’ll use:
music(tune,loop)
The tune
parameter will be the asset to be played, and loop
will be set to true to loop the tune.
To begin, I guess I’ll just start it going in createLevel
:
...
music(asset.downloaded.A_Hero_s_Quest.Dungeon,true)
That’s a suitably eerie selection, as you’ll hear in the videos from now on:
Now this is just One More Darn Thing that’s going on inside GameRunner, which is already loaded with all kinds of gadgetry. So let’s quickly move on to a little object to deal with the music. We’ll need some interesting logic to deal with monster proximity.
So let’s assume a little object by intention, changing this:
music(asset.downloaded.A_Hero_s_Quest.Dungeon,true)
To this:
self.musicPlayer:dungeonMusic()
I’m just supposing we’ll have a music player that GameRunner knows about. It’s pretty much the central server for info like that. So in its init:
function GameRunner:init()
self.tileSize = 64
self.tileCountX = 85 -- if these change, zoomed-out scale
self.tileCountY = 64 -- may also need to be changed.
self.tiles = {}
for x = 1,self.tileCountX+1 do
self.tiles[x] = {}
for y = 1,self.tileCountY+1 do
local tile = Tile:edge(x,y, self)
self:setTile(tile)
end
end
self.cofloater = Floater(self, 50,25,4)
self.musicPlayer = MusicPlayer()
end
Nothing for it but to create a MusicPlayer, I guess. Naively, this:
-- MusicPlayer
-- RJ 20210219
MusicPlayer = class()
function MusicPlayer:init(dungeon,battle)
self.dungeon = dungeon or asset.downloaded.A_Hero_s_Quest.Dungeon
self.battle = battle or asset.downloaded.A_Hero_s_Quest.Battle
end
function MusicPlayer:dungeonMusic()
music(self.dungeon,true)
end
function MusicPlayer:battleMusic()
music(self.battle, true)
end
This will be enough to play the dungeon music still. But what about the danger / battle music? I think we’ll want it to start whenever a live monster is within range of the player, and to stop a short while after no live monsters are in range. We’ll want some kind of time delay to ensure that we don’t switch back and forth every second, and the Player will need to know what it’s currently playing.
We could TDD this. Honestly, I know I’m a bad person, but I’m just not seeing the value. So sue me, I’m here to show you what I do, and when I get in trouble, you get to see that as well.
So I code this:
function MusicPlayer:init(dungeon,battle)
self.dungeon = dungeon or asset.downloaded.A_Hero_s_Quest.Dungeon
self.battle = battle or asset.downloaded.A_Hero_s_Quest.Battle
self.playing = nil
self.locked = false
end
function MusicPlayer:dungeonMusic()
if self.locked or self.playing == self.dungeon then return end
music(self.dungeon,true)
self.playing = self.dungeon
self.locked = true
tween.delay(3,self.unlock, self)
end
function MusicPlayer:battleMusic()
if self.locked or self.playing == self.battle then return end
music(self.battle, true)
self.playing = self.battle
self.locked = true
self.delay(3,self.unlock, self)
end
function MusicPlayer:unlock()
self.locked = false
end
And that clearly needs some refactoring, so before I go further, I do it.
function MusicPlayer:dungeonMusic()
if self.locked or self.playing == self.dungeon then return end
self:play(self.dungeon)
end
function MusicPlayer:battleMusic()
if self.locked or self.playing == self.battle then return end
self:play(self.battle)
end
function MusicPlayer:play(tune)
music(tune,true)
self.playing = tune
self.locked = true
tween.delay(3,self.unlock, self)
end
That looks reasonable. Now let’s see about switching the tune:
How about we turn on the battle music when a monster moves and is in range of the player?
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
if dxdy.x == 0 or dxdy.y == 0 then
self.tile = self.tile:legalNeighbor(self, dxdy)
elseif math.random() < 0.5 then
self.tile = self.tile:legalNeighbor(self,vec2(0,dxdy.y))
else
self.tile = self.tile:legalNeighbor(self,vec2(dxdy.x,0))
end
end
We can start the music right there. Where can we stop it? In the other move, I guess.
function Monster:makeRandomMove()
self.runner:playDungeonMusic()
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
function Monster:moveTowardAvatar()
self.runner:playBattleMusic()
local dxdy = self.runner:playerDirection(self.tile)
if dxdy.x == 0 or dxdy.y == 0 then
self.tile = self.tile:legalNeighbor(self, dxdy)
elseif math.random() < 0.5 then
self.tile = self.tile:legalNeighbor(self,vec2(0,dxdy.y))
else
self.tile = self.tile:legalNeighbor(self,vec2(dxdy.x,0))
end
end
And:
function GameRunner:playBattleMusic()
self.musicPlayer:battleMusic()
end
function GameRunner:playDungeonMusic()
self.musicPlayer:dungeonMusic()
end
A quick test tells me what’s wrong with this arrangement: there is always a monster out of range, so even if there is one in range, the out-of-range ones will turn the battle music back off. We’re going to have to be more clever than this.
Which may mean being more clever than I am, which I’m not sure I’m up to.
I also noticed that there can be monsters within range of the player but not visible, such as in nearby rooms or hallways. I think I’ll call that OK.
What we need to know is whether there are any monsters within range. That’s something that the GameRunner should figure out for us. So I’ll remove the two calls I just put in, and then:
I think we’ll check in moveMonsters
, which goes like this:
function GameRunner:moveMonsters()
for k,m in pairs(self.monsters) do
m:chooseMove()
--if math.random() > 0.67 then m:chooseMove() end
--if math.random() > 0.67 then m:chooseMove() end
end
end
I left those two commented lines in to remind me that monsters used to be able to move more than once.
By intention:
function Monster:chooseMove()
-- return true if in range
if not self.alive then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
And …
function GameRunner:moveMonsters()
local inRange = false
for k,m in pairs(self.monsters) do
if m:chooseMove() then inrange = true end
--if math.random() > 0.67 then m:chooseMove() end
--if math.random() > 0.67 then m:chooseMove() end
end
if inRange then self:playBattleMusic() else self:playDungeonMusic() end
end
I don’t love this but I think it works. Let me see. It does not. Why not? Oh. That typo:
function GameRunner:moveMonsters()
local inRange = false
for k,m in pairs(self.monsters) do
if m:chooseMove() then inRange = true end
--if math.random() > 0.67 then m:chooseMove() end
--if math.random() > 0.67 then m:chooseMove() end
end
if inRange then self:playBattleMusic() else self:playDungeonMusic() end
end
Now then.
So that’s nearly good. However, I don’t like requiring the side effect in chooseMove
, and I’m not overly fond of the boolean either. Let’s just do the job separately, it won’t take much longer:
function GameRunner:moveMonsters()
self:selectMusic()
for k,m in pairs(self.monsters) do
if m:chooseMove() then inRange = true end
--if math.random() > 0.67 then m:chooseMove() end
--if math.random() > 0.67 then m:chooseMove() end
end
end
function GameRunner:selectMusic()
local battleDistance = 5
local tune = self.musicPlayer.dungeonMusic
for k,m in pairs(self.monsters) do
local d = m:distanceFromPlayer()
if m:isAlive() and d <= battleDistance then
tune = self.musicPlayer.battleMusic
end
end
tune(self.musicPlayer)
end
This works nicely. The monster sheets do appear before the music, and I think we should change that value as well. Let me explain the code above, just in case it’s not clear.
The local tune
is initialized to the musicPlayer’s method dungeonMusic
, which is a function expecting the music player as its first argument. If we find a live monster within battleDistance
, we set the tune to battleMusic
. When we’ve checked all the monsters, we call the tune function, passing the expected musicPlayer to it, which plays the tune.
Because of the interlocking in MusicPlayer
, we don’t mind the redundant calls to play the same tune, but after the tween delay, we will allow a switch.
Commit: We now have dungeon and battle music.
Let’s change the monster sheets to use the same distance value, if it’s not too difficult:
function Monster:drawSheet()
if self:distanceFromPlayer() <= 5 then
self.attributeSheet:draw()
end
end
That’s already using 5. That’s fine.
Let’s reflect on this code, see if it’s OK.
function GameRunner:selectMusic()
local battleDistance = 5
local tune = self.musicPlayer.dungeonMusic
for k,m in pairs(self.monsters) do
local d = m:distanceFromPlayer()
if m:isAlive() and d <= battleDistance then
tune = self.musicPlayer.battleMusic
end
end
tune(self.musicPlayer)
end
Hm. We have methods of our own in GameRunner to set the music. We should either use them or lose them. Let’s lose them for now. We have no external callers.
I could imagine that MusicPlayer could be made autonomous rather than having GameRunner calling it. That sounds like fun, let’s do it. I’ll create a MusicPlayer and keep it, but let it do its own thing.
In createLevel
, I’ll remove the line that started the music and in init
save a MusicPlayer linked back to GameRunner.
self.musicPlayer = MusicPlayer(self)
The music player will need to get info form the GameRunner. And upgrade MusicPlayer:
function MusicPlayer:init(dungeon,battle)
self.dungeon = dungeon or asset.downloaded.A_Hero_s_Quest.Dungeon
self.battle = battle or asset.downloaded.A_Hero_s_Quest.Battle
self.playing = nil
self.locked = false
tween.delay(1, self.decide,self)
end
We’ll decide once per second.
function MusicPlayer:decide()
if self:hasMonsterNearPlayer() then
self:battleMusic()
else
self:dungeonMusic()
end
tween.delay(1,self.decide,self)
end
How will we decide? We’ll ask GameRunner:
function MusicPlayer:hasMonsterNearPlayer()
local range = 5
return self.runner:hasMonsterNearPlayer(range)
end
And …
function GameRunner:hasMonsterNearPlayer(range)
for k,m in pairs(self.monsters) do
if m:isAlive() and m:distanceFromPlayer() <= range then return true end
end
return false
end
This works a treat. Very pleasing. Commit: MusicPlayer is now autonomous.
I am pleased with this. One minor objection might be the return of a boolean answering whether there’s a monster near the player but I don’t see anything better to do.
What shall we do now?
One possibility that appeals to me would be to cal it a morning, but let’s take a glance at GameRunner, since we’re here, and see if we can clean it up. I’m sure it at least has some methods out of order.
GameRunner also has a raft of creation methods including:
- createCombatButtons
- createInitialRoom
- createLevel
- createLoots
- createRandomRooms
- createThings
- createTestRooms
Some of these are used in the creation of the level, others are for testing.
I see that createTestRooms
isn’t used other than in a commented-out call in Main. Let’s remove that.
The createInitialRoom
is also some testing hokum, so let’s remove that and the reference.
A bit of testing assures me that we’re all solid, so commit: remove unneeded code from GameRunner.
I should probably quit while I’m ahead, but let’s at least think about how poison might work. One possibility is to give the Player some kind of poisoned attribute that she knows about. But it just popped into my head that maybe we could create an invisible “poison” monster that is created when a poisonous creature bites, that follows the player around taking a health point every so often, until she dies or grabs a health heart somewhere.
There should be only one such thing, I suppose, and there’s no real reason for it to follow around, or move at all, since it can have access to the player object and ding her whenever it wants. But a poison object could still be a good way to do it.
We’ll think about that for tomorrow or some future day. For now, a quick summary and let’s ship this baby.
Summary
Again, we did something new and it sent in fine. We wound up with an autonomous music player that changes the music based on the situation. It only considers two aspects now, monsters close to player or not, but since it has access to the GameRunner, there’s no limit to what it might know about.
But we started with a very simple object that played under remote control and only knew one tune. Then we added another tune, and drove it remotely. Finally, we moved the decision into the music player itself.
Could we have gone that way right off the bat? Sure, had we thought of it. But I prefer to do things incrementally, and I generally find that that lets me take smaller steps, with everything working all along. And once we have the thing somewhere, in some kind of decent shape, it’s usually more clear how it might work.
In the case in hand, I had not thought of using a tween delay to lock a tune for a respectable interval, but once timing came to mind, I thought of tween. (I had been thinking of keeping track of time more manually, though I didn’t mention it at all.)
Then, having thought of tween, I guess it was natural to think of using it to check whether we should switch tunes. And there we were.
Then just a bit more cleaning up, to leave the campground better than we found it.
That’ll do for today. See you tomorrow, I imagine.