Devlog 19 - Extra Stage Unlocks

Published: December 3rd, 2021

This particular post is going to be more oriented towards coding rather than game design. I did some work on the menu flow for unlocking extra levels this week. Here's what that looks like right now:


This is pretty analogous to the flow for unlocking characters, except with some more complicated logic for handling the song previews. I don't (yet?) have song previews for the main levels, but I figured it would be nice to have for the unlockable bonus levels because you're actually spending in-game currency on them -- song titles alone don't really help that much when deciding which songs to buy. (I may end up displaying some sort of difficulty rating as well, but right now I haven't even charted any of these songs, so that's not relevant yet) As always, the exact UI isn't really locked in yet, it's just a rough draft...


Song Previews

This is the kind of engineering task that is pretty nontrivial yet will probably never show up on any sort of programming interview or anything... In my head this is something that could easily snowball into a pile of weird edge conditions if not handled robustly. In other words, this is the exact sort of thing that gamedev programming is made of...

First, let's outline how things =should= work:

Some edge cases and other issues might already be jumping out at you. For example, how do we load and play the preview clips without having to load all of the audio into memory (causes the initial scene load to slow down), and without any sort of stutter when firing off a new one?

Ideally you could just specify time segments out of the original audio clips and use those as your previews (adding the volume fades programatically). You could use Unity's new Addressables.LoadAssetAsync functionality, which should allow us to load them in on-demand asynchronously, so that there's no stuttering or hitching. ...except, there's actually still a stutter because the disk load operation blocks the main thread. You could just load all of the clips into memory at the beginning of the scene, but I believe that bogs down scene loading (for similar reasons). I ended up just creating separate preview clips that are set up for streaming (this is also covered in https://exceed7.com/native-audio/rhythm-game-crash-course/import-settings.html), since Unity can't use the same audio clip data for streaming + non-streaming usage. I can then just bake in the beginning/end audio fades for each clip as well, so playing them is a simple matter of looping the entire audio clip. (I do still use the Addressables API to load these separate streaming clips on-demand by name)

With that all taken care of, think about how you would handle state management for playing and stopping the audio.

One possible approach would be something like this:

enum AudioState {
    Stopped,
    FadingIn
    Playing,
    FadingOut,
}

AudioSource _audioSource;

AudioState _currentState = AudioState.Stopped;

string _currentClip = "";

Then when a preview button is clicked, you need to check the current state and do the appropriate thing...

void PreviewButtonClicked(string clipName) {
    if (_currentClip != clipName) {
        // We need to transition to the new clip...
        
        // Start loading the new clip asynchronously (?)
        AsyncOperationHandle _asyncHandle = Addressables.LoadAssetAsync(clipName);

        if (_currentState == AudioState.Playing) {
            // Start fading out the current clip...
            _currentState = AudioState.FadingOut;
            BeginFadeAudioSource(_audioSource, 0.0f);

            // Maybe use a coroutine to wait until the volume is 0??
            // What if the preview button gets pressed again in the meantime??
            ...
        } else if (_currentState == AudioState.FadingOut) {
            ...
        }
    }
    ...
}

This can get messy really fast since you have to handle so much bookkeeping. Instead I actually just did away with all of the intermediary state. I can use the current volume of the audio source itself to determine whether I need to fade in / fade out / switch clips. The only other state we need to keep around is the AsyncOperationHandle.

AudioSource _audioSource;

string _targetClip = "";

AsyncOperationHandle _asyncHandle;

// The button click handler changes _targetClip, but does nothing else.
void PreviewButtonClicked(string clipName) {
    if (_targetClip == clipName) {
        // Stop playback.
        _targetClip = "";
    } else {
        _targetClip = clipName;
    }
}

// Frame-based logic determines at each frame what needs to be done
// with the audio source to match _targetClip.
void Update() {
    if (_targetClip == "") {
        // We don't want to play anything.  Fade volume towards 0.
        _audioSource.volume = Mathf.MoveTowards(_audioSource.volume, 0.0f, Time.unscaledDeltaTime);
    } else if (_audioSource.clip.name != clipName) {
        // We're playing the wrong clip.  Fade volume toward 0.
        _audioSource.volume = Mathf.MoveTowards(_audioSource.volume, 0.0f, Time.unscaledDeltaTime);
        
        // Start loading the new clip, if we haven't already.
        if (!_asyncHandle.IsValid()) {
            _asyncHandle = Addressables.LoadAssetAsync(_targetClip);
        }
        
        if (_audioSource.volume == 0.0f && _asyncHandle.IsDone) {
            // New clip is loaded and fading is done.
            // Replace the clip and play.
            _audioSource.clip = _asyncHandle.Result;
            _audioSource.volume = 1.0f;
            _audioSource.Play();
            
            // (This is a struct, not a class)
            _asyncHandle = new AsyncOperationHandle();
        }
    }
}

As always with code examples, I'm handwaving away some details, but hopefully this is illustrative of how this pattern of keeping less intermediary state can help simplify logic. The basic idea is to "recalculate from scratch" what should be done each frame based on the current state of the world rather than maintaining some sort of internal finite state machine. There's often a tradeoff involved here between maintaining less state vs being more performant, but in many simpler cases it's totaly fine to do these sorts of recomputations.

<< Back: Devlog 18 - Character Unlocks
>> Next: Devlog 20 - Speed Zones