davidanaya.io

Simon implementation in Flutter III

February 17, 2019 | 9 Minute Read

back-to-the-flutter

In the first article of the Simon series I presented the architecture, models and basic widgets. In the second one we dig deep into the reactiveness of the GameBloc and how to integrate all components together.

In this final article I bring the game alive by adding sound to the buttons. You can also check the final version of the game in my github repo with a few extra features that I don’t cover here (feedback on a failed play, overlay screen with score, status bar and best score saved in the device storage).

How do I play sounds?

First things first, we need some cool sounds for our game, and I think the best option is going for the original ones, so I used the video in my first article to get the mp3 track (there are many online sites that do that). With that, I just used that to clip the exact sound for each button and added to my project.

Flutter does not have a de facto solution to play sounds, but there are a few interesting libraries we can use. I chose audioplayer2, which works just fine, but there’s even an audioplayers which allows you to play multiple sounds simultaneously and also gets rid of some annoying log messages from android.

In order to use this library, I created a service SoundPlayer.

import 'dart:io';

import 'package:flutter/material.dart';

import 'package:audioplayer2/audioplayer2.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:simon_says/src/models/constants.dart';

class SoundPlayer {
  AssetBundle _bundle;
  AudioPlayer _audioPlayer = AudioPlayer();

  Map<GameColor, String> _sounds = {};

  Stream<AudioPlayerState> get state$ => _audioPlayer.onPlayerStateChanged;

  Future<void> loadSounds(BuildContext context) async {
    _bundle = DefaultAssetBundle.of(context);
    Directory tempDir = await getApplicationDocumentsDirectory();
    return Future.wait(
        GameColor.values.map((color) => _loadSound(color, tempDir)));
  }

  Future<void> play(GameColor color) {
    var uri = _sounds[color];
    return uri != null
        ? _audioPlayer.play(_sounds[color], isLocal: true)
        : null;
  }

  Future<void> stop() {
    return _audioPlayer.stop();
  }

  Future<void> _loadSound(GameColor color, Directory tempDir) async {
    String name = gameColors[color].soundFileName;
    ByteData data = await _bundle.load('assets/sounds/$name.mp3');
    File tempFile = File('${tempDir.path}/$name.mp3');
    await tempFile.writeAsBytes(data.buffer.asUint8List(), flush: true);
    _sounds[color] = tempFile.path;
  }
}

I want my app to work in offline mode, so I can’t use urls for my sound files, but the library only accepts url paths, so I need to copy my files to a temporary application folder for my app. Also, to avoid the load time when I play a sound, I want to preload all my files. This is what the asynchronous method loadSounds() is doing, preloading the files and storing them in the class in a map. Every time I want to play a sound, I just have to execute play with the name of the sound.

Include sound files

For this to work, I need to include the mp3 files in my project. So I added them inside /assets/mp3 and included them in pubspec.yaml, like so:

sounds-folder

Who is playing the sounds?

We don’t need to play multiple sounds at once, so we only need an instance of our service. The best way to instantiate it is in our main() function. But remember we want to preload the sounds so that we don’t need to wait later on when we want to play them. For that, I use a FutureBuilder in my main widget.

void main() {
  SoundPlayer soundPlayer = SoundPlayer();

  // hide status bar
  SystemChrome.setEnabledSystemUIOverlays([]);

  final appBloc = AppBloc(soundPlayer);

  runApp(MyApp(appBloc, soundPlayer));
}

class MyApp extends StatelessWidget {
  final AppBloc bloc;
  final SoundPlayer _player;

  MyApp(this.bloc, this._player);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: _player.loadSounds(context),
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          return snapshot.connectionState == ConnectionState.done
              ? _buildApp()
              : Center(child: CircularProgressIndicator());
        });
  }

  Widget _buildApp() {
    ...
  }
}

See that I pass the player to the MyApp widget, and there I use loadSounds() as the future, and only build the app when this future is finished. Instead of a CircularProgressIndicator we could use a static image, like a splash screen, but you get the idea.

I don’t want the buttons to mess with sounds, so my GameBloc will be once again in charge on handling things. Every time one of the buttons is pressed or activated, GameBloc will play the associated sound. So far, every time the user presses a button we get an event with UserPlay sink, but that’s not enough, we want to play sounds also for Simon. We need another sink.

class GameBloc {
  ...

  final _playPressAnimationStart = StreamController<GameColor>();

  // play sound when key is pressed
  Sink<void> get playPressAnimationStart => _playPressAnimationStart.sink;

  ...

  GameBloc(this._soundPlayer) {
    ...

    _playPressAnimationStart.stream.listen(_playSound);

    ...
  }

  ...

  void _playSound(GameColor play) async {
    await _soundPlayer.stop();
    _soundPlayer.play(play);
  }

  ...
}

See how we expose the sink playPressAnimationStart, and on every event we execute _playSound(), which will stop any current sound playing and play the new sound.

How do we activate the sounds?

We just need to send an event to playPressAnimationStart when the buttons are activated. Let’s see how.

class SimonButton extends StatefulWidget {
  ...
}

class _SimonButtonState extends State<SimonButton>
    with TickerProviderStateMixin {
  ...

  @override
  didChangeDependencies() {
    super.didChangeDependencies();

    ...

    // subscribe to simon plays and animate button
    _simonPlaySubs = Observable(bloc.simonPlay$)
        .where((gamePlay) => gamePlay.play == widget.gameColor)
        .listen((play) => _handleSimonPlay(play, bloc.playPressAnimationStart));

    // subscribe to user plays and animate only if it's the user turn
    _userTapSubs = _userTap$
        .withLatestFrom(bloc.state$, (tap, state) => [tap, state])
        .where((data) => data[1].state == GameState.UserSays)
        .map((data) => data[0] as Tap)
        .listen((Tap tap) =>
            _handleUserTap(tap, bloc.userPlay, bloc.playPressAnimationStart));
  }

  void _handleUserTap(Tap tap, Sink<GameColor> userPlay,
      Sink<GameColor> playPressAnimationStart) {
    if (tap == Tap.down) {
      _animationController.forward();
      playPressAnimationStart.add(widget.gameColor);
    } else {
      userPlay.add(widget.gameColor);
      _animationController.reverse();
    }
  }

  void _handleSimonPlay(
      GamePlay play, Sink<GameColor> playPressAnimationStart) {
    _isFailedPlay = play.isFailedPlay;
    _animationController.forward();
    playPressAnimationStart.add(widget.gameColor);
    Timer(
        Duration(
            milliseconds: play.isFailedPlay
                ? failedPlayButtonAnimationMs
                : buttonAnimationMs), () {
      _animationController.reverse();
    });
  }

  ...
}

See like now, every time we need to handle a play from Simon or the user, we also pass a reference to the sink. In there, on tap down for the user and on animation forward for Simon, we just add a new event to playPressAnimationStart.

It’s alive!

No more coding! These sounds make a difference!

Please check my github repo for the full version of the game and check it out in Google Play. Thanks for reading!