Background Music in a Flame Game

in #development5 years ago

TheFlame version of Langaw has been released to the Google Play Store. The number one negative feedback I got was that the background music keeps on playing even after the game is closed.

The game has been updated and the background music issue has been fixed but the fix was a custom and non-reusable.

In this article, I’d like to share a class that you can just paste in your Flame game project and use immediately to manage and play looping background music files.

If you want to skip the boring part and just want to add background music to your games, skip to the how to use section below.

Let’s Get Started

We would need a class that will manage the background music. All its properties and methods will be static so we don’t need to create an instance of the class to use it.

Create a class in ./lib/bgm.dart and add the following code:

import 'package:audioplayers/audio_cache.dart';

class BGM {
  static List<AudioCache> _tracks = List<AudioCache>();
  static int _currentTrack = -1;
  static bool _isPlaying = false;
}

Note: The location ./ refers to the root directory of the project. It should be where the file pubspec.yaml should be.

Breakdown: Import statement at the top just gives us access to the AudioCache class from the audioplayers library. What follows is a standard class declaration. The class is named BGM and has three static properties. Static properties (and methods) are like global properties but only for that class. It’s like changing a property of the class itself instead of changing a property of an instance of the class.

All these properties are private though, properties or methods that start with an underscore (_) are private and can only be changed/accessed from within the BGM class itself.

Before all other functionalities, let’s add an update method first. This method will contain the code for managing the state (playing or pausing) the track.

static Future _update() async {
  if (_currentTrack == -1) {
    return;
  }

  if (_isPlaying) {
    await _tracks[_currentTrack].fixedPlayer.resume();
  } else {
    await _tracks[_currentTrack].fixedPlayer.pause();
  }
}

Breakdown: This is method is rather simple. If _currentTrack is set to -1, it means no track is playing. In this case, the method just returns and ends.

If _currentTrack is set to something else, the method checks for the value of _isPlaying. If it’s true, the method then calls resume on the track’s fixedPlayer object. Otherwise, the method calls pause.

Resource management

In a perfect world, we could just load all our audio assets into memory ready to be played at a moment’s notice. But electronic devices have limited memory, not to mention they probably don’t run just a single app or game.

Knowing this, it’s our responsibility as game developers to limit the resources we use to a minimum loading only the things we’re going to need inside a specific view.

Back to the code, we will need access to the AudioPlayer class and the ReleaseMode enum that are defined in the audioplayers.dart file of the audioplayers library. Let’s import that file with the following line:

import 'package:audioplayers/audioplayers.dart';

Then let’s add a method called add to the class. This method will also be static. I/O operations are involved so we’re also making this method asynchronous.

static Future<void> add(String filename) async {
  AudioCache newTrack = AudioCache(prefix: 'audio/', fixedPlayer: AudioPlayer());
  await newTrack.load(filename);
  await newTrack.fixedPlayer.setReleaseMode(ReleaseMode.LOOP);
  _tracks.add(newTrack)
}

Not much of a breakdown: This method involves preloading a background music track from a file. The specifics of each line gets a bit more in-depth with the audioplayers package. For now, just know that what’s happening is we’re creating a new instance of the AudioCacheclass and adding it to the _tracks list.

The method above could be called at the beginning (entry point) of the game to preload resources or on loading screens based on how complex the game you’re developing is.

Now that audio resources can be added to the cache, we should have a way to remove those resources from the memory.

static void remove(int trackIndex) async {
  if (trackIndex >= _tracks.length) {
    return;
  }
  if (_isPlaying) {
    if (_currentTrack == trackIndex) {
      await stop();
    }
    if (_currentTrack > trackIndex) {
      _currentTrack -= 1;
    }
  }
  _tracks.removeAt(trackIndex);
}

Note: We’re calling on the stop method here which doesn’t exist yet. We’ll write it into the class later in the next section.

Breakdown: The method above accepts an int (trackIndex) as a parameter and checks if that index exists. If trackIndex is greater or equal to the number of items in _tracks list, the method just returns and ends.

Next, it checks if a BGM is currently playing. If _isPlaying is true and the currently playing BGM track is the one being removed, call stopfirst. If the currently playing BGM track is lower in the list than the one being removed, we need to update the _currentTrack variable so its value will point correctly to the new index after removing an item above it.

Finally, we remove the track from the _tracks list.

There are scenarios where you want to just unload all BGM tracks and start over. This is useful if you’re changing scenes with a totally different mood or when going back to the main menu.

Let’s write a method to do just that.

static void removeAll() {
  if (_isPlaying) {
    stop();
  }
  _tracks.clear();
}

Breakdown: First, we stop the currently playing BGM track if there’s any. After that, we clear the _tracks list. That’s it.

Play, stop, pause, and resume

To control the BGM tracks, we write the following methods.

The first method is play. This method accepts an int named trackIndex as a parameter that specifies which track should be played.

static Future play(int trackIndex) async {
  if (_currentTrack == trackIndex) {
    if (_isPlaying) {
      return;
    }
    _isPlaying = true;
    _update();
    return;
  }

  if (_isPlaying) {
    await stop();
  }

  _currentTrack = trackIndex;
  _isPlaying = true;
  AudioCache t = _tracks[_currentTrack];
  await t.loop(t.loadedFiles.keys.first);
  _update();
}

Breakdown: The first thing that the play method does is check if the supplied trackIndex is the same as the currently playing track index. If it’s a match and it’s currently playing, the method immediately ends through a return. If it matches but isn’t playing (I can’t really imagine how this scenario could happen, but let’s just put here as a safety net), set the variable _isPlaying to true and call _update() then end through a return.

If the passed trackIndex value is not the same as the current one, it means that the playing track is being changed (even if there isn’t one playing currently). A check is done if there is something playing by checking the variable _isPlaying‘s value. If something is playing, stop is called to make sure the other track is stopped properly.

Note: Again, the stop method does not exist yet, but we’ll get to shortly (right after this breakdown actually).

After all the preparation is done, we set the _currentTrack value to whatever was passed on trackIndex and set _isPlaying to true.

Here’s the most confusing bit, first we get the actual AudioCache “track” and assign it to the variable named t. We then call the loop method of this track and pass in the filename of the first loaded file. To clear things up, when we call the add method above, it adds a new track (AudioCacheobject) to the list. Each track holds a cache of loaded music files in a Map named loadedFiles where the keys are stored as Strings. For each cached music file, the key is set to the filename you supply to it. In this class, one track only holds/caches one music file so we can get the original filename passed by accessing .keys.first of the loadedFilesproperty.

To know more about Dart Maps, check out the Map documentation.

Now let’s go to the stop method.

static Future stop() async {
  await _tracks[_currentTrack].fixedPlayer.stop();
  _currentTrack = -1;
  _isPlaying = false;
}

Breakdown: The method simply calls stop on the track’s fixedPlayerproperty. This method is more on the audioplayers package side so check out the docs if you want to learn more about it.

Next, we have pause.

static void pause() {
  _isPlaying = false;
  _update();
}

And lastly, resume.

static void resume() {
  _isPlaying = true;
  _update();
}

Breakdown: The pause and resume methods just set the value of _isPlaying to false and true, respectively. After that, _update is called and it handles pausing and resuming there.

Solving the background music problem

We now have a class that can manage resources (caching and preloading) and control tracks (play, stop, pause, resume).

Yay! Yeah… no.

The original problem was that the music kept on playing even after exiting the game or switching from the game to another app.

How do we solve this?

We extend a WidgetsBindingObserver and listen to app lifecycle state changes.

Let’s create another class.

class _BGMWidgetsBindingObserver extends WidgetsBindingObserver {
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      BGM.resume();
    } else {
      BGM.pause();
    }
  }
}

Breakdown: This class extends WidgetsBindingObserver and overrides the method didChangeAppLifecycleState. This method is fired every time the lifecycle state of the app is changed. The new state is passed as a parameter. If the app just resumed, BGM.resume() is called. For all other states, BGM.pause() is called.

This class by itself does nothing, we need to create an instance of it first and attach it as an observer to the WidgetsBinding instance. Besides, this class is private (its name starts with an underscore) so it’s no use outside this file.

The BGM class, on the other hand, is a static global singleton, supposedly anyway. So back to the BGM class, let’s add a private property and a getter for that property.

static _BGMWidgetsBindingObserver _bgmwbo;

static _BGMWidgetsBindingObserver get widgetsBindingObserver {
  if (_bgmwbo == null) {
    _bgmwbo = _BGMWidgetsBindingObserver();
  }
  return _bgmwbo;
}

Breakdown: We’re adding two class members here, a private property (_bgmwbo) and a getter (widgetsBindingObserver) which returns the private property. We use this private property–getter combo so we can store instantiate a value if it’s null and store it for referencing later. In the end, if we access widgetsBindingObserver, we’re actually getting the _bgmwbo property. This setup just gives the class a chance to initialize it first.

Next, let’s add a helper function that will bind our observer into the WidgetsBinding instance.

static void attachWidgetBindingListener() {
  WidgetsBinding.instance.addObserver(BGM.widgetsBindingObserver);
}

Breakdown: It’s a pretty standard one-liner. We get the WidgetsBindinginstance and call its addObserver method passing BGM.widgetsBindingObserver (which is a getter that initializes an instance of _BGMWidgetsBindingObserver if there’s none).

Now we have a working, reusable class file that deals with looping BGM tracks.

How to use in your Flame game

First thing’s first, you must have this file in an easily accessible location. Having this file in ./lib/bgm.dart works best. But it’s really up to you. Having it in ./lib/globals/bgm.dart, for example, works too.

Here’s the whole class file:

import 'package:audioplayers/audio_cache.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';

class BGM {
  static List _tracks = List();
  static int _currentTrack = -1;
  static bool _isPlaying = false;
  static _BGMWidgetsBindingObserver _bgmwbo;

  static _BGMWidgetsBindingObserver get widgetsBindingObserver {
    if (_bgmwbo == null) {
      _bgmwbo = _BGMWidgetsBindingObserver();
    }
    return _bgmwbo;
  }

  static Future _update() async {
    if (_currentTrack == -1) {
      return;
    }

    if (_isPlaying) {
      await _tracks[_currentTrack].fixedPlayer.resume();
    } else {
      await _tracks[_currentTrack].fixedPlayer.pause();
    }
  }

  static Future add(String filename) async {
    AudioCache newTrack = AudioCache(prefix: 'audio/', fixedPlayer: AudioPlayer());
    await newTrack.load(filename);
    await newTrack.fixedPlayer.setReleaseMode(ReleaseMode.LOOP);
    _tracks.add(newTrack);
  }

  static void remove(int trackIndex) async {
    if (trackIndex >= _tracks.length) {
      return;
    }
    if (_isPlaying) {
      if (_currentTrack == trackIndex) {
        await stop();
      }
      if (_currentTrack > trackIndex) {
        _currentTrack -= 1;
      }
    }
    _tracks.removeAt(trackIndex);
  }

  static void removeAll() {
    if (_isPlaying) {
      stop();
    }
    _tracks.clear();
  }

  static Future play(int trackIndex) async {
    if (_currentTrack == trackIndex) {
      if (_isPlaying) {
        return;
      }
      _isPlaying = true;
      _update();
      return;
    }

    if (_isPlaying) {
      await stop();
    }

    _currentTrack = trackIndex;
    _isPlaying = true;
    AudioCache t = _tracks[_currentTrack];
    await t.loop(t.loadedFiles.keys.first);
    _update();
  }

  static Future stop() async {
    await _tracks[_currentTrack].fixedPlayer.stop();
    _currentTrack = -1;
    _isPlaying = false;
  }

  static void pause() {
    _isPlaying = false;
    _update();
  }

  static void resume() {
    _isPlaying = true;
    _update();
  }

  static void attachWidgetBindingListener() {
    WidgetsBinding.instance.addObserver(BGM.widgetsBindingObserver);
  }
}

class _BGMWidgetsBindingObserver extends WidgetsBindingObserver {
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      BGM.resume();
    } else {
      BGM.pause();
    }
  }
}

Setup

In your game’s ./lib/main.dart, import the BGM class file:

import 'bgm.dart';

Or if it’s under globals:

import 'globals/bgm.dart';

Note: You could use the more common import format which specifies your package name and a full path to the file. Again, it’s up to you.

Then have the following line AFTER calling runApp:

BGM.attachWidgetBindingListener();

Preloading (and unloading)

Preloading/adding tracks:

await BGM.add('bgm/awesome-intro.ogg');
await BGM.add('bgm/flame-game-level.ogg');
await BGM.add('bgm/boss-fight.ogg');

Unloading all tracks (freeing the memory from BGM tracks):

BGM.removeAll();

Rarely, you may want to unload a single track (let’s say you won’t need the boss fight track anymore):

BGM.remove(2);

Playing (or changing tracks)

To play a track, just call:

// intro or home screen
BGM.play(0);

// boss fight is about to start
BGM.play(2);

It’s okay if a different track is currently playing. The class will handle it by stopping the currently playing one and playing the one specified (as you can see in the code above).

Stopping

To stop the current track, just call:

await BGM.stop();

Note: Some of the function calls above need to be added inside an async function if you care about timing (you should).

Conclusion

That was it. Have fun making games with awesome background music tracks that automatically stop when you exit the game or switch to a different app.

If you have any questions, contact me with an email, drop a comment below, or join my Discord server.

Sort:  

Thank you japalekhin! You've just received an upvote of 40% by artturtle!


Learn how I will upvote each and every one of your posts



Please come visit me to see my daily report detailing my current upvote power and how much I'm currently upvoting.