Dungeon 100
I decide to redo the music playing logic with TDD. Results are mixed.
Last night at the Zoom Ensemble, I showed MusicPlayer to the group. My main question was something like “I didn’t TDD this. Years ago I’d have said to TDD everything. I don’t see the value of TDD here. Whazzup???”
Here’s the code for your review:
MusicPlayer = class()
function MusicPlayer:init(runner, dungeon,battle)
self.runner = runner
assert(runner~=nil, "runner nil")
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
function MusicPlayer:battleMusic()
if self.locked or self.playing == self.battle then return end
self:play(self.battle)
end
function MusicPlayer:decide()
if self:hasMonsterNearPlayer() then
self:battleMusic()
else
self:dungeonMusic()
end
tween.delay(1,self.decide,self)
end
function MusicPlayer:dungeonMusic()
if self.locked or self.playing == self.dungeon then return end
self:play(self.dungeon)
end
function MusicPlayer:hasMonsterNearPlayer()
local range = 5
return self.runner:hasMonsterNearPlayer(range)
end
function MusicPlayer:play(tune)
music(tune,true)
self.playing = tune
self.locked = true
tween.delay(3,self.unlock, self)
end
function MusicPlayer:unlock()
self.locked = false
end
Now, it’s odd. This code is pretty simple on the surface, and in fact I wrote it without tests and without difficulty. It’s more complex than it appears, however, because of the tween.delay
calls which make things happen “later”. The check to see if there are monsters close by is done once per second, and once a tune is started, it is “locked” for 3 seconds and then unlocked. This ensures that we don’t just stutter back and forth between tunes in too irritating a fashion.
So the flow here is more complicated than it may appear, and it would certainly be possible to get it wrong. This is a good argument for TDD during its creation, or testing after the fact.
And yet, I, one of nature’s greatest proponents of TDD, didn’t use TDD to create it. Whazzup???
In the Zoom Ensemble, I wondered whether there are code chunks that are too small to TDD, and we all agreed, for example, that we wouldn’t TDD a getter or setter. (Most of us would wind up with the getter/setter covered by another test.) GeePaw Hill focuses his TDD on “branching logic” and the only visible real branching here is in the decide
method, which is at least as hard to test as it is to write:
function MusicPlayer:decide()
if self:hasMonsterNearPlayer() then
self:battleMusic()
else
self:dungeonMusic()
end
tween.delay(1,self.decide,self)
end
Even if we were to adjust the code for easier testing, we still have nothing but an if that’s harder to test than to write.
I believe it was GeePaw who made another point, which was that what we see in the world isn’t people writing methods that are so small and simple they don’t need testing. We can’t get them to write small simple methods at all. So perhaps my “too small” theory is moot. But the fact remains:
I, a proponent of TDD, didn’t TDD this, and have skipped TDDing for many other things here in the dungeon and other programs, and WHAZZUP???
Again, from GeePaw, we have what he calls “the money principle”, the fact that we do TDD because it is the fastest way we know to get working code. Why doesn’t he call it “the speed principle”? I have no explanation. But I do agree entirely that, by and large, TDD is the best way I know to get quickly to code that is working, easily seen to be working, and protected against future edits breaking it.
Now, I’m not here to tell you what to do, or even to advise you, but if I will say that if someone were to get as good at TDD as GeePaw, or Bill Wake, or even me, and then there were to decide not to TDD something, I’d be OK with it. If someone barely could TDD at all decided not to TDD something, I’d be less sanguine about it.
Now at various times, both Chet and Bryan suggested that if I had started with TDD for my music player, it might have turned out to be just as good or better, and it certainly would have been testable. We didn’t try that last night, but just for fun, let’s try it this morning.
TDDing a MusicPlayer
I’m going to do this in a separate Codea project, but I’ll be assuming that it’s really part of Dungeon and can use Dungeon objects and must adhere to the general structure.
The requirements are:
- Usually the tune “dungeon” plays as the game proceeds.
- If monsters are near the player, the tune “battle” plays instead.
- No tune plays for less than three seconds, to avoid bouncing back and forth erratically.
What would be a reasonable first test? Maybe something like this:
_:test("Initial song is dungeon", function()
local mp = MusicPlayer()
_:expect(mp:nowPlaying()).is("dungeon")
end)
That forces this:
MusicPlayer = class()
function MusicPlayer:init()
end
function MusicPlayer:nowPlaying()
return "dungeon"
end
Test is green. Now we need to decide a bit of interface. Let’s assume a method, monsterIsNear
, sent when there’s a nearby monster.
_:test("Song is battle when monster is near", function()
local mp = MusicPlayer()
mp:monsterIsNear()
_:expect(mp:nowPlaying()).is("battle")
end)
This fails, and drives me to code this:
function MusicPlayer:init()
self.playing = "dungeon"
end
function MusicPlayer:monsterIsNear()
self.playing = "battle"
end
function MusicPlayer:nowPlaying()
return self.playing
end
I guess, in for a penny, we should have a method monsterIsFar
:
_:test("Song goes back to dungeon when monster is far", function()
local mp = MusicPlayer()
mp:monsterIsNear()
mp:monsterIsFar()
_:expect(mp:nowPlaying()).is("dungeon")
end)
This fails and requires:
function MusicPlayer:monsterIsFar()
self.playing = "dungeon"
end
Tests are all nice and green. Of course no music is playing, and we’ve not done anything about the timers. Nor do we have any idea how monsterIsNear
and monsterIsFar
work. Even a quick review of the Sesame Street bit on “near” and “far” is no help.
But, I have to say, there is a kind of confidence building up in me, that this thing is shaping up as I’d like it to. That said, it does feel like it’s taking a lot of typing and a lot of time.
Go slow to go fast and all that.
Now there’s the notion of the three-second delay before the player switches tunes. What if we were to say that that’s not MusicPlayer’s problem. If you ask it to switch tunes every millisecond, it’ll do it. Timing is someone else’s problem.
We do want to have it play the actual tunes. Now my original design was that the MusicPlayer knows the “playlist”, i.e. the two tunes it can currently play. But we can test that as well.
_:test("MP knows the default tunes", function()
local mp = MusicPlayer()
_:expect(mp:tuneFor("dungeon")).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
_:expect(mp:tuneFor("battle")).is(asset.downloaded.A_Hero_s_Quest.Battle)
end)
That requires me to code something like this:
function MusicPlayer:init()
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self.playing = "dungeon"
end
function MusicPlayer:tuneFor(tuneName)
return self.tunes[tuneName]
end
Tests still green. Now this still won’t play anything. Works great except for that detail and a few others, like the timing.
Now we know that we want to play the song, looping, when we play it. And we know that we do that this way:
music(asset,true)
Where asset is whatever song we want to play. The music
function is built in to Codea. But we could pass in the function to call in the init:
_:test("MP actually plays the tune", function()
local mp = MusicPlayer(fakeMusic)
_:expect(tunePlaying).is(mp:tuneFor("dungeon"))
end)
Now I need to define my fakeMusic
function:
function fakeMusic(tune,loop)
tunePlaying = tune
end
The test doesn’t run yet, providing the excellent message:
5: MP actually plays the tune -- Actual: nil, Expected: Asset Key: Dungeon.m4a (path: "/private/var/mobile/Containers/Data/Application/7AE638E9-80DA-41C0-93BC-01F4D3CF1781/Library/AssetPackDownloads/A Hero's Quest.assets/Dungeon.m4a")
We need to make the player play.
function MusicPlayer:init(musicFunction)
self.music = musicFunction or music
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self.playing = "dungeon"
end
Now we have the function or the fake one saved … so we need to call it …
function MusicPlayer:play()
self.music(self.playing, true)
end
And we need to call that:
function MusicPlayer:monsterIsFar()
self.playing = "dungeon"
self:play()
end
function MusicPlayer:monsterIsNear()
self.playing = "battle"
self:play()
end
I expect the test to run or to provide an interesting error.
2: Song is battle when monster is near -- Tests:70: attempt to call a nil value (field 'music')
5: MP actually plays the tune -- Actual: nil, Expected: Asset Key: Dungeon.m4a (path: "/private/var/mobile/Containers/Data/Application/7AE638E9-80DA-41C0-93BC-01F4D3CF1781/Library/AssetPackDownloads/A Hero's Quest.assets/Dungeon.m4a")
Hm. What have I done wrong? If I were a T&C|R person, I’d revert and do over. I’m not, so I’ll look.
Line 70 is this:
function MusicPlayer:play()
self.music(self.playing, true)
end
So music is nil. That seems odd, since:
function MusicPlayer:init(musicFunction)
self.music = musicFunction or music
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self.playing = "dungeon"
end
Ah. False alarm that test said
local mp = MusicPlayer
And should have said:
local mp = MusicPlayer()
Now for the problem that’s more like I expected.
5: MP actually plays the tune -- Actual: nil, Expected: Asset Key: Dungeon.m4a (path: "/private/var/mobile/Containers/Data/Application/7AE638E9-80DA-41C0-93BC-01F4D3CF1781/Library/AssetPackDownloads/A Hero's Quest.assets/Dungeon.m4a")
Well, we didn’t really init the class correctly, since we just set the playing
variable but we don’t play it.
function MusicPlayer:init(musicFunction)
self.music = musicFunction or music
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self.playing = "dungeon"
self:play()
end
That gives me a new and informative error:
5: MP actually plays the tune -- Actual: dungeon, Expected: Asset Key: Dungeon.m4a (path: "/private/var/mobile/Containers/Data/Application/7AE638E9-80DA-41C0-93BC-01F4D3CF1781/Library/AssetPackDownloads/A Hero's Quest.assets/Dungeon.m4a")
I’m not sending the tune to music
, I’m sending the thing the tune is called. So:
function MusicPlayer:play()
self.music(self:tuneFor(self.playing), true)
end
Now the test runs. Let’s extend it:
_:test("MP actually plays the tune", function()
local mp = MusicPlayer(fakeMusic)
_:expect(tunePlaying).is(mp:tuneFor("dungeon"))
mp:monsterIsNear()
_:expect(tunePlaying).is(mp:tuneFor("battle"))
end)
And the test still runs.
Let’s review the class we have so far:
MusicPlayer = class()
function MusicPlayer:init(musicFunction)
self.music = musicFunction or music
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self.playing = "dungeon"
self:play()
end
function MusicPlayer:monsterIsFar()
self.playing = "dungeon"
self:play()
end
function MusicPlayer:monsterIsNear()
self.playing = "battle"
self:play()
end
function MusicPlayer:nowPlaying()
return self.playing
end
function MusicPlayer:play()
self.music(self:tuneFor(self.playing), true)
end
function MusicPlayer:tuneFor(tuneName)
return self.tunes[tuneName]
end
Pretty simple so far. No real logic.
What might we object to? Well, a pure music player wouldn’t know about monsters being near or far, it would just know to play certain tunes if told to. So let’s refactor first:
function MusicPlayer:init(musicFunction)
self.music = musicFunction or music
self.tunes = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self:play("dungeon")
end
function MusicPlayer:monsterIsFar()
self:play("dungeon")
end
function MusicPlayer:monsterIsNear()
self:play("battle")
end
function MusicPlayer:play(tuneName)
self.playing = tuneName
self.music(self:tuneFor(self.playing), true)
end
I notice that I’m getting sound, because some of my MPs are called without the fakeMusic function, and they all should be using it. So I’ll make that change to the tests.
Now, we’re trying to untangle “monster is near” from the decisions made in the player, which should just have to do with playing whatever tune whose name we provide:
So let’s change the tests just to request the tune they want. Maybe that would have been a better design all along.
This makes the tests look a bit silly:
_:test("Initial song is dungeon", function()
local mp = MusicPlayer(fakeMusic)
_:expect(mp:nowPlaying()).is("dungeon")
end)
_:test("Song is battle when monster is near", function()
local mp = MusicPlayer(fakeMusic)
mp:play("battle")
_:expect(mp:nowPlaying()).is("battle")
end)
_:test("Song goes back to dungeon when monster is far", function()
local mp = MusicPlayer(fakeMusic)
mp:play("battle")
mp:play("dungeon")
_:expect(mp:nowPlaying()).is("dungeon")
end)
_:test("MP knows the default tunes", function()
local mp = MusicPlayer(fakeMusic)
_:expect(mp:tuneFor("dungeon")).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
_:expect(mp:tuneFor("battle")).is(asset.downloaded.A_Hero_s_Quest.Battle)
end)
_:test("MP actually plays the tune", function()
local mp = MusicPlayer(fakeMusic)
_:expect(tunePlaying).is(mp:tuneFor("dungeon"))
mp:play("battle")
_:expect(tunePlaying).is(mp:tuneFor("battle"))
end)
The names don’t match the code, because we’ve discovered that we don’t want our music player ideas tangled up with the notion of close and distant monsters.
So we are faced with a design question, because our code told us we had things tangled up.
So let’s remove the game-oriented methods from MusicPlayer:
function MusicPlayer:monsterIsFar()
self:play("dungeon")
end
function MusicPlayer:monsterIsNear()
self:play("battle")
end
Now for the object in hand, MusicPlayer, we have more tests than we need, and they have bad names. I think we’ll push this thing to its limit, so let’s simplify the tests down to something minimal but complete.
_:test("Initial song is dungeon", function()
local mp = MusicPlayer(fakeMusic)
_:expect(mp:nowPlaying()).is("dungeon")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
_:test("Song is battle when requested", function()
local mp = MusicPlayer(fakeMusic)
mp:play("battle")
_:expect(mp:nowPlaying()).is("battle")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Battle)
end)
_:test("Song goes back to dungeon when requested", function()
local mp = MusicPlayer(fakeMusic)
mp:play("battle")
mp:play("dungeon")
_:expect(mp:nowPlaying()).is("dungeon")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
This packet tells us that the MP knows the name of the tune that it is playing, and that it is playing the right tune for that name. We might want to deal with a name that isn’t in our table.
Let’s have it ignore tunes it doesn’t know.
_:test("Player plays nothing if tune is unknown", function()
local mp = MusicPlayer(fakeMusic)
tunePlaying = "never set"
mp:play("unknown")
_:expect(tunePlaying).is("never set")
end)
That fails:
4: Player plays default tune if necessary -- Actual: nil, Expected: never set
And we change play
:
function MusicPlayer:play(tuneName)
local tune = self:tuneFor(tuneName)
if tune then
self.playing = tuneName
self.music(tune, true)
end
end
Tests all run. So, here we are at 1015 with a fully tested MusicPlayer that knows two tunes and can play them. Clearly what I must have had in mind with that dictionary in init
is that we could replace the dictionary with some other playlist.
We’re TDDing here, so we might as well test that.
I realize right away that I want the paylist to be the first parameter, since otherwise everyone would have to pass in music
and we really have in mind that no one has to pass anything. So the test starts like this:
_:test("Player accepts alternative playlist", function()
local mp = MusicPlayer(playList, fakeMusic)
end)
And the others have to change:
_:test("Player plays nothing if tune is unknown", function()
local mp = MusicPlayer(nil,fakeMusic)
tunePlaying = "never set"
mp:play("unknown")
_:expect(tunePlaying).is("never set")
end)
And so on … so I have to change MP:
function MusicPlayer:init(playList, musicFunction)
self.music = musicFunction or music
self.tunes = playList or {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
self:play("dungeon")
end
Tests are good again, so let’s continue with our new one … and I get this far:
_:test("Player accepts alternative playlist", function()
local playList = {a=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_1, b=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_2 }
local mp = MusicPlayer(playList, fakeMusic)
_:expect(...
end)
When I realize that I have no way of knowing which tune is first. The MP is still assuming that it knows. Either I have to remove the initial default call, or I have to provide the name of the default tune.
This is irritating. I’ve had to change the protocol of this object already, and it’s way beyond the original requirement, which was just to play “dungeon” most of the time and “battle” when monsters were near.
Yes, this object will be much more robust, much more usable, much more reusable, than the one yesterday. But it still can’t do what I actually need in the game, and it’s getting more and more general than I need.
Therefore YAGNI. We’re not gonna need it. But we’re so close. If I’d accept playing nothing until requested, unless you happen to have a tune named “dungeon”, I’d be done now, with the player part anyway.
No. Rip it out. No. Leave it in, we’re so close. No, you’re in this far, complete it, make it general. OK, new rule. If there is a tune named “default”, we’ll play it at the beginning. We’ll never play it again unless asked to. And we’ll default it, in our table, to the same as “dungeon”.
_:test("Player accepts alternative playlist", function()
local playList = {a=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_1, b=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_2, default=asset.downloaded.A_Hero_s_Quest.Curse }
local mp = MusicPlayer(playList, fakeMusic)
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Curse)
end)
This will fail because it’ll play nothing because it doesn’t have a “dungeon” tune.
5: Player accepts alternative playlist -- Actual: never set, Expected: Asset Key: Curse.caf (path: "/private/var/mobile/Containers/Data/Application/7AE638E9-80DA-41C0-93BC-01F4D3CF1781/Library/AssetPackDownloads/A Hero's Quest.assets/Curse.caf")
Right. Now the fix:
function MusicPlayer:init(playList, musicFunction)
self.music = musicFunction or music
self.tunes = playList or {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon, default=asset.downloaded.A_Hero_s_Quest.Dungeon}
self:play("default")
end
First test fails now:
1: Initial song is dungeon -- Actual: default, Expected: dungeon
Test is:
_:test("Initial song is dungeon", function()
local mp = MusicPlayer(nil,fakeMusic)
_:expect(mp:nowPlaying()).is("dungeon")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
Should be:
_:test("Initial song is default", function()
local mp = MusicPlayer(nil,fakeMusic)
_:expect(mp:nowPlaying()).is("default")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
OK, tests are green and MP is working and a bit more general than we really need.
And we still don’t have anything happening about whether monsters are close or distant, nor the locking of a tune for at least three seconds.
Our MusicPlayer, by design, will just switch tunes on a dime. If we want that other functionality, we could jam it in here, or put it elsewhere.
And at this point we see one decent reason why what we’re doing this morning might be better than what we did yesterday.
Yesterday’s MusicPlayer combines at least these ideas:
- play selected tunes
- play one when monsters are far, another when near
- never switch tunes faster than every three seconds
That’s not very cohesive. If we’re going for small, single-purpose objects, yesterday’s code isn’t that.
Let’s see if we can deal with the notion of not switching until three seconds have elapsed. That leaves a question open:
When we are given a new tune to play, and the three seconds aren’t up, should we ignore the new tune, or wait until the time is up and then play it?
I favor the second answer. Now, I’m sure that in our situation, another call to battle will come along, but we don’t need to require that, so let’s not.
I’m going to create a new object: SmoothMusicPlayer. At least that’s the name I have in mind for now, the idea of “smooth” being that it doesn’t jitter back and forth.
Now we do have a serious issue here, which is how do we do timing in a TDD test.
I suspect we’re going to need another test double, this one for timing. And because I like tweens, I’m going to use them. So we’ll probably have to fake a tween.
Let’s test:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer()
sp:play("battle")
_:expect(sp:nowPlaying()).is("default")
sp:timeElapsed()
_:expect(sp:nowPlaying()).is("battle")
end)
Now, honestly, I’m getting bored with this and I’m going to build out a lot of this object here and now. I may regret that.
SmoothPlayer = class()
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.request = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:play(tune)
self.request = tune
self:setTimer()
end
function SmoothPlayer:setTimer()
if self.delay > 0 then tween.delay(self.delay, self.timeElapsed,self) end
end
function SmoothPlayer:timeElapsed()
if self.play ~= self.mp:nowPlaying() then
self.mp:play(self.request)
end
end
That’s a fairly big batch, but I feel pretty good about it. Note that if I create my SP with no parms, it doesn’t set its timer, but it is still sensitive to calls to timeElapsed. The test might actually work, but I’m not that optimistic.
6: Smooth player doesn't accept new tune until time elapsed -- Tests:52: attempt to call a nil value (method 'nowPlaying')
Hm, probably I should implement nowPlaying
on SmoothPlayer if I plan to call it:
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
Test is green but plays music. I need to call SP correctly:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,nil,fakeMusic)
sp:play("battle")
_:expect(sp:nowPlaying()).is("default")
sp:timeElapsed()
_:expect(sp:nowPlaying()).is("battle")
end)
Test is green and silent. I am confident, though, of course, I’ve not actually tested the tween itself. If I were less confident than I am in the code, I’d probably have to create a test double for the tween, and while a purist might do that, I’m here to do good work, not to be a purist.
So that’s done, SmoothPlayer works like MusicPlayer but requires three seconds between tunes, and will then play whatever is on deck if it’s not already playing.
Maybe the term “on deck” is a good one. Let’s refactor:
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
function SmoothPlayer:play(tune)
self.onDeck = tune
self:setTimer()
end
function SmoothPlayer:setTimer()
if self.delay > 0 then tween.delay(self.delay, self.timeElapsed,self) end
end
function SmoothPlayer:timeElapsed()
if self.onDeck ~= self.mp:nowPlaying() then
self.mp:play(self.onDeck)
end
end
Now as I refactored that to use the onDeck
name, I noticed something: when you call our play
function, we set the timer then. That means that before your new tune comes up, three more seconds must elapse before it runs, even if they already have.
Furthermore, If there’s already a timer running, we’ll start another, not having stopped the first one. That’s not good.
SmoothTimer is flaky. Typing it all in did not go as well as I thought it would.
Nothing for it but to revert and move in smaller steps. I carve it down to here:
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
And will build it up again.
The test:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,nil,fakeMusic)
sp:play("battle")
_:expect(sp:nowPlaying()).is("default")
sp:timeElapsed()
_:expect(sp:nowPlaying()).is("battle")
end)
I think this much is nearly good. I’ll implement a null ‘smoothTimer and
timeElapsed` and get the error on “battle”.
6: Smooth player doesn't accept new tune until time elapsed -- Tests:51: attempt to call a nil value (method 'play')
OK, play.
function SmoothPlayer:play(tune)
self.onDeck = tune
end
This stills seems OK. The test now says:
6: Smooth player doesn't accept new tune until time elapsed -- Actual: default, Expected: battle
OK, that’s as expected. We could also invasively check SP’s onDeck
variable, but it’s none of our business. If he manages his variables correctly, our tests will run.
Let’s posit that the SP has a locked variable that it locks when it plays something, and unlocks after the time is elapsed.
I come up with this, again, a big bite:
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
function SmoothPlayer:play(tune)
self.onDeck = tune
end
function SmoothPlayer:playOnDeckTune()
if self:nowPlaying() == self.onDeck then return end
self.mp:play(self.onDeck)
self:setTimer()
end
function SmoothPlayer:setTimer()
if self.delay == 0 then return end
self.locked = true
tween.delay(self.delay, self.unlock, self)
end
function SmoothPlayer:unlock()
self.locked = false
self:playOnDeckTune()
end
My test works. Does the code work? I wonder. I can, however test that “manually”. I put this in Main:
local sp = SmoothPlayer(10)
sp:play("battle")
The dungeon tune plays for a while and then the battle tune. So I believe this class works.
We now need some kind of an object that plays the dungeon tune most of the time, and plays battle music when monsters are near.
I hope you’ll forgive me if I just code this:
MonsterPlayer = class()
function MonsterPlayer:init(runner)
self.runner = runner
self.sp = SmoothPlayer(3)
tween.delay(1, self.everySoOften, self)
end
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
if close then sp:play("battle") else sp:play("dungeon") end
end
function MonsterPlayer:everySoOften()
self:checkForDanger()
tween.delay(1, self.everySoOften, self)
end
I can’t readily test this here in my separate project. Instead, I’m going to go nuts and replace my existing MusicPlayer tab with the complete contents of this one, tests, classes, and all.
Now I need to find where I created the MusicPlayer and create a MonsterPlayer.
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(self)
end
This last line becomes, of course:
self.musicPlayer = MonsterPlayer(self)
Now I’m just going to run the game and see what happens.
What happens is this:
MusicPlayer:70: attempt to index a nil value (global 'sp')
stack traceback:
MusicPlayer:70: in method 'checkForDanger'
MusicPlayer:74: in field 'callback'
...in pairs(tweens) do
c = c + 1
end
return c
end
:158: in upvalue 'finishTween'
...in pairs(tweens) do
c = c + 1
end
return c
end
:589: in function <...in pairs(tweens) do
c = c + 1
end
return c
end
:582>
Because I wrote this untested line:
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
if close then sp:play("battle") else sp:play("dungeon") end
end
This might be better:
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
if close then self.sp:play("battle") else self.sp:play("dungeon") end
end
Run again:
Well, the battle music starts, but then it never goes back to dungeon. Darn. I was really hoping this would just plug in and go. Let’s hope the problem is in my untested MonsterPlayer, but it sure looks pretty simple.
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
print("checking ", close)
if close then self.sp:play("battle") else self.sp:play("dungeon") end
end
This really ought to print checking and true or false, every second. The prints seem correct. But this time the music stayed in dungeon mode even when monsters were present.
Ah, I see the bug:
function SmoothPlayer:playOnDeckTune()
if self:nowPlaying() == self.onDeck then return end
self.mp:play(self.onDeck)
self:setTimer()
end
function SmoothPlayer:setTimer()
if self.delay == 0 then return end
self.locked = true
tween.delay(self.delay, self.unlock, self)
end
function SmoothPlayer:unlock()
self.locked = false
self:playOnDeckTune()
end
If the timer ever expires while the right tune is playing, we don’t reset the timer. That is not OK. I think we either need to keep that timer running, or start it when called to play something different. Keeping it running seems better. And the locked flag is unused. I’ll remove it.
function SmoothPlayer:playOnDeckTune()
if self:nowPlaying() ~= self.onDeck then
self.mp:play(self.onDeck)
end
end
function SmoothPlayer:setTimer()
if self.delay == 0 then return end
tween.delay(self.delay, self.unlock, self)
end
function SmoothPlayer:unlock()
self:playOnDeckTune()
self:setTimer()
end
Now I expect this to work. And it does work as advertised. There is always a three second delay between escaping a monster or killing it, and the music going back to “dungeon”. That’s because I chose to keep the timer running and to remove the locked flag. I think for now we’ll leave this be.
Now it’s time to look at what we have and assess which approach was better. It is 1200, and it was surely no later than 0900 when I started, probably earlier, more like 0830. So we have three or four hours in this version and two rather simple defects, one in untested code and one in tested code.
Retro
But let’s look at the classes and see what we see:
MonsterPlayer = class()
function MonsterPlayer:init(runner)
self.runner = runner
self.sp = SmoothPlayer(3)
tween.delay(1, self.everySoOften, self)
end
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
if close then self.sp:play("battle") else self.sp:play("dungeon") end
end
function MonsterPlayer:everySoOften()
self:checkForDanger()
tween.delay(1, self.everySoOften, self)
end
This is the main thing we’ll encounter, since it’s the only one called outside this class. It seems pretty clear to me, it gets a SmoothPlayer with a 3 second cycle, and it checks every so often (1 second) for danger. If it sees danger, it plays the battle tune otherwise plays the dungeon tune.
That seems quite cohesive and it’s only three methods. What about SmoothPlayer?
SmoothPlayer = class()
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
function SmoothPlayer:play(tune)
self.onDeck = tune
end
function SmoothPlayer:playOnDeckTune()
if self:nowPlaying() ~= self.onDeck then
self.mp:play(self.onDeck)
end
end
function SmoothPlayer:setTimer()
if self.delay == 0 then return end
tween.delay(self.delay, self.unlock, self)
end
function SmoothPlayer:unlock()
self:playOnDeckTune()
self:setTimer()
end
This is a bit more complex, and I almost suspect it could be simpler. Its job seems simple enough: play whatever tune you were most recently asked to play, but be sure to play all tunes for at least self.delay
seconds.
This code was built with TDD, and I did the best job I was capable of at the time, and it still had a defect in that it never played a new tune if the timer had once expired.
Finally, we have our bottom-level MusicPlayer:
MusicPlayer = class()
function MusicPlayer:init(playList, musicFunction)
self.music = musicFunction or music
self.tunes = playList or {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon, default=asset.downloaded.A_Hero_s_Quest.Dungeon}
self:play("default")
end
function MusicPlayer:nowPlaying()
return self.playing
end
function MusicPlayer:play(tuneName)
local tune = self:tuneFor(tuneName)
if tune then
self.playing = tuneName
self.music(tune, true)
end
end
function MusicPlayer:tuneFor(tuneName)
return self.tunes[tuneName]
end
This, again, is quite simple. It just plays whatever you ask it to play, then and there. It has a default playlist, but accepts a different one, and it plays nothing if you ask it to play a song it doesn’t know. (That will leave whatever is already playing running, if there is anything.)
Am I going to accept this new version? The diff says -30+120 lines so we have net added 90 lines. The tab is now 139 lines of test and code. It used to be 49.
This new version has taken me four hours for sure, counting writing this article. It’s better tested but it has grown the solution to this problem from about 50 lines to 140.
I’m going to paste this fixed version back into the separate project where I wrote it, and then I think I’ll revert.
Yes. Revert. Let’s talk about whether and why.
Whether to Revert, and Why
Now that I’ve done it, I’m wondering. There are 50 lines of tests in today’s version, so the code is only 40 lines longer than yesterday’s. That’s still nearly double, as yesterday’s was only 49 lines.
Maybe the separation of concerns is worth it. And maybe we can improve that SmoothPlayer code a bit. And we do have some testing, though it’s not perfect by any means.
I’ve certainly not cracked the code on testing timed objects with tweens, and that’s probably why I left a defect in the tested code. So I have things to learn.
All that said, though, I think having the tests in place, even still a bit weak, and having the concerns separated, is probably better. I’m so tempted to use the new one but there are things not to like.
One is that, because of the default vs dungeon thing, It plays the default tune for 3 seconds and then the dungeon tune, i.e. starting over. Second, the forced three second delay after monsters get close won’t do.
I’m sticking with the decision to revert, but it’s a near call. I think we got somewhat better code, but it’s not quite working to spec, so it needs further testing. And I need to think a bit more about how I might test timing.
I could imagine coming back to this spike and pushing it further and I can imagine that it might work well enough to install it into the game.
We’ll see. For now, we’ll stick with the cards we have.
See you next time!