Categories
Flutter

Managing State Of Game In Flutter

We have covered most of the design related development of HangMan game in our previous posts. In this post, we will be making the the game functional by managing the state of game in Flutter.

This is going to be a long post as we will be covering a lot of code in this part. By the end of this post, we will have a working version of the HangMan game.

Game Object States

For our game to run smoothly, we need to keep a few objects in memory.

  • Whether the game has begun, is resuming or is completed.
  • The word that is being guessed.
  • List of letters user has tried for guessing.
  • Next item to draw for the Stick Figure.

So for State management of our HangMan game, we will try out couple different options:

  • Using ValueNotifier and ValueListenableBuilder
  • Using Stream and StreamBuilder

But first we need a random word generator.

Generating Random Words For HangMan

For our game, we need a list of words which can be used to generate a random word for each new game. For the purpose of this tutorial, we will use the names of districts from Nepal as allowed random words.

import 'dart:math';

class GuessWordHelper {
  var _allowedWords = [
    'Bhojpur',
    'Dhankuta',
    'Ilam',
    'Jhapa',
    'Khotang',
    'Morang',
    'Okhaldhunga',
    'Panchthar',
    'Sankhuwasabha',
    'Solukhumbu',
    'Sunsari',
    'Taplejung',
    'Terhathum',
    'Udayapur',
    'Saptari',
    'Siraha',
    'Dhanusa',
    'Mahottari',
    'Sarlahi',
    'Bara',
    'Parsa',
    'Rautahat',
    'Sindhuli',
    'Ramechhap',
    'Dolakha',
    'Bhaktapur',
    'Dhading',
    'Kathmandu',
    'Kavrepalanchok',
    'Lalitpur',
    'Nuwakot',
    'Rasuwa',
    'Sindhupalchok',
    'Chitwan',
    'Makwanpur',
    'Baglung',
    'Gorkha',
    'Kaski',
    'Lamjung',
    'Manang',
    'Mustang',
    'Myagdi',
    'Nawalpur',
    'Parbat',
    'Syangja',
    'Tanahun',
    'Kapilvastu',
    'Parasi',
    'Rupandehi',
    'Arghakhanchi',
    'Gulmi',
    'Palpa',
    'Dang Deukhuri',
    'Pyuthan',
    'Rolpa',
    'Eastern Rukum',
    'Banke',
    'Bardiya',
    'Western Rukum',
    'Salyan',
    'Dolpa',
    'Humla',
    'Jumla',
    'Kalikot',
    'Mugu',
    'Surkhet',
    'Dailekh',
    'Jajarkot',
    'Kailali',
    'Achham',
    'Doti',
    'Bajhang',
    'Bajura',
    'Kanchanpur',
    'Dadeldhura',
    'Baitadi',
    'Darchula',
  ];

  String generateRandomWord() {
    var randomGenerator = Random();
    var randomIndex = randomGenerator.nextInt(_allowedWords.length);

    return _allowedWords[randomIndex].toUpperCase();
  }  
}

Here, the generateRandomWord method of GuessWordHelper class generates a random district.

Truenary Solutions

Setup BLoC For Game State

Now we start by creating a BLoC for our HangMan game. A BLoC is simply a class which holds logic for the underlying widget.

We can handle the events like user tapping on buttons and icons and share the event response across the child widget using the BLoC object.

Learn More:

Start by creating an empty class GameStageBloc.

class GameStageBloc {
  
}

Using ValueNotifier To Manage State

In our game, the first thing we want to maintain is the game state about whether it is in progress or completed states.

We will use ValueNotifier for this purpose.

If you haven’t used ValueNotifier before, checkout: Exploring ValueNotifier In Flutter

import 'package:flutter/widgets.dart';

import 'enum_collection.dart';

class GameStageBloc {
  ValueNotifier<GameState> curGameState = ValueNotifier<GameState>(GameState.idle);
}

The GameState is an enum:

enum GameState {
  idle,
  running,
  failed,
  succeded
}

Similarly, the next thing that we will need is the current word which is being guessed by the player.

ValueNotifier<String> curGuessWord = ValueNotifier<String>(''); 

Finally, we will also use ValueNotifier to track the body parts which have been lost.

ValueNotifier<List<BodyPart>> lostParts = ValueNotifier<List<BodyPart>>([]);

Again, the BodyPart is another enum:

enum BodyPart {
  head,
  body,
  leftLeg,
  rightLeg,
  leftHand,
  rightHand
}

Now for tracking the letters that the user have already guessed, we will use RxDart library implementation of StreamController.

Using StreamController To Manage State

First add reference to the rxdart library by adding it’s dependency on the pubspec file.

rxdart: 0.18.0

Next, in the GameStageBloc create a controller and Stream for guessedCharacters list.

var _guessedCharactersController = BehaviorSubject<List<String>>();
Stream<List<String>> get guessedCharacters => _guessedCharactersController.stream;  

Updating Game State

So far, we have setup different ValueListeners and StreamBuilders for managing the app state.

Next, we need to implement these helpers for maintaining game state.

Function To Create New Game

First, we need a function which will put everything in motion.

void createNewGame() {
    curGameState.value = GameState.running;
    lostParts.value.clear();
    var guessWord = GuessWordHelper().generateRandomWord();
    curGuessWord.value = guessWord;
    _guessedCharactersController.sink.add([]);
  }

The createNewGame function sets the game state to running mode. It clears any game data that was created from previous run and generates a new word for a new game.

Function To Check If Word Guessed Correctly

Next, we need a function to check if player identified the puzzle word.

void _concludeGameOnWordGuessedCorrectly(List<String> guessedCharacters) {
    //check if user identified all correct words    
    var allValuesIdentified = true;
    var characters = curGuessWord.value.split('');
    characters.forEach((letter) {
      if(!guessedCharacters.contains(letter)) {
        allValuesIdentified = false;
        return;
      }
    });

    if(allValuesIdentified) {
      curGameState.value = GameState.succeded;
    }
  }

The _concludeGameOnWordGuessedCorrectly function checks if all the letters of the guess word were correctly by the player. If the word has been identified, then:

curGameState.value = GameState.succeded;

Function To Update Guessed Characters

Next, we need to update the list of characters guessed by user so that we can update the state of pressed characters.

void updateGuessedCharacter(List<String> updatedGuessedCharacters) {
    _guessedCharactersController.sink.add(updatedGuessedCharacters);
    _concludeGameOnWordGuessedCorrectly(updatedGuessedCharacters);
  }

Function To Track Body Parts Removed

Finally, to track the list of body parts that our Stick Figure has lost, we use the updateLostBodyParts function.

void updateLostBodyParts() {
    print('removing ');
    if(!lostParts.value.contains(BodyPart.head)) {
      print('head...');
      lostParts.value.add(BodyPart.head);
      return;
    }

    if(!lostParts.value.contains(BodyPart.body)) {
      print('body...');
      lostParts.value.add(BodyPart.body);
      return;
    }

    if(!lostParts.value.contains(BodyPart.leftLeg)) {
      print('left leg...');
      lostParts.value.add(BodyPart.leftLeg);
      return;
    }

    if(!lostParts.value.contains(BodyPart.rightLeg)) {
      print('right left...');
      lostParts.value.add(BodyPart.rightLeg);
      return;
    }

    if(!lostParts.value.contains(BodyPart.leftHand)) {
      print('left hand...');
      lostParts.value.add(BodyPart.leftHand);
      return;
    }

    if(!lostParts.value.contains(BodyPart.rightHand)) {
      print('right hand...');
      lostParts.value.add(BodyPart.rightHand);

      // player has lost all body parts.
      curGameState.value = GameState.failed;
      return;
    }
  }

Here, we are removing a single body part at a time.

Tying Up Everything

Now that we have the functions ready in the GameStageBloc, the next thing to do is fire up those functions whenever needed.

Update HangManPainter Class

We need to pass the instance of GameStage bloc to our painter class and draw body parts based on the parts that have been lost by the player.

class HangManPainter extends CustomPainter {
  final GameStageBloc _gameStageBloc;

  double _headHeight = 32.0;

  HangManPainter(this._gameStageBloc);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint();

    paint.color = Colors.grey;
    paint.style = PaintingStyle.fill;
    
    _drawFrame(canvas, size, paint);
    _drawNoose(canvas, size, paint);
    if(_gameStageBloc.lostParts.value.contains(BodyPart.head)) {
      _drawHead(canvas, size, paint);
    }
    if(_gameStageBloc.lostParts.value.contains(BodyPart.body)) {
      _drawBody(canvas, size, paint);
    }
    if(_gameStageBloc.lostParts.value.contains(BodyPart.leftLeg)) {
      _drawLeg(canvas, size, paint, Limb.left);
    }
    if(_gameStageBloc.lostParts.value.contains(BodyPart.rightLeg)) {
      _drawLeg(canvas, size, paint, Limb.right);
    }
    if(_gameStageBloc.lostParts.value.contains(BodyPart.leftHand)) {
      _drawHand(canvas, size, paint, Limb.left);
    }
    if(_gameStageBloc.lostParts.value.contains(BodyPart.rightHand)) {
      _drawHand(canvas, size, paint, Limb.right);
    }
  }

Update Puzzle Widget

Next, update the Puzzle widget.

import 'package:flutter/material.dart';

import 'game_stage_bloc.dart';

class Puzzle extends StatefulWidget {
  final String guessWord;

  final GameStageBloc gameStageBloc;

  const Puzzle(
      {Key key, @required this.guessWord, @required this.gameStageBloc})
      : super(key: key);

  @override
  State<StatefulWidget> createState() => _PuzzleState();
}

class _PuzzleState extends State<Puzzle> {

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
        stream: widget.gameStageBloc.guessedCharacters,
        builder: (BuildContext ctxt,
            AsyncSnapshot<List<String>> guessedLettersSnap) {
          if (!guessedLettersSnap.hasData) return CircularProgressIndicator();

          return Container(
            child: Wrap(
              spacing: 8.0,
              runSpacing: 8.0,
              children: List.generate(widget.guessWord.length, (i) {
                var letter = widget.guessWord[i];
                var letterGuessedCorrectly = guessedLettersSnap.data.contains(letter);
                
                return _buildSingleCharacterBox(letter, letterGuessedCorrectly);
              })));
        });
  }

  Widget _buildSingleCharacterBox(String letter, bool letterGuessedCorrectly) {
    return Container(
      height: 48.0,
      width: 48.0,
      decoration: BoxDecoration(
          color: letterGuessedCorrectly ? Colors.limeAccent : Colors.white,
          borderRadius: BorderRadius.circular(4.0)),
      child: letterGuessedCorrectly
          ? Center(
              child: Text(
                letter,
                style: _guessedCharacterStyle,
                textAlign: TextAlign.center,
              ),
            )
          : null,
    );
  }

  TextStyle _guessedCharacterStyle =
      TextStyle(fontSize: 24, fontWeight: FontWeight.bold);
}

Update CharacterPicker Widget

Similarly, update CharacterPicker widget.

import 'package:flutter/material.dart';
import 'package:hangman/game_stage_bloc.dart';

class CharacterPicker extends StatefulWidget {
  final GameStageBloc gameStageBloc;

  const CharacterPicker({Key key, @required this.gameStageBloc})
      : super(key: key);

  @override
  State<StatefulWidget> createState() => _CharacterPickerState();
}

class _CharacterPickerState extends State<CharacterPicker> {
  var _alphabets = 'A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z';

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    var alphabetArr = _alphabets.split(',');

    return StreamBuilder(
      stream: widget.gameStageBloc.guessedCharacters,
      builder: (BuildContext ctxt, AsyncSnapshot<List<String>> guessedLettersSnap) {
        if(!guessedLettersSnap.hasData) return CircularProgressIndicator();

        return Container(
        child: Wrap(
          spacing: 8.0,
          runSpacing: 8.0,
          children: List.generate(alphabetArr.length, (i) {
            var letter = alphabetArr[i];
            return _buildSingleCharacter(guessedLettersSnap.data, letter);
          })));

      });
    
  }

  Widget _buildSingleCharacter(List<String> guessedLetters, String letter) {
    return GestureDetector(
      onTap: () {
        if(!guessedLetters.contains(letter)) {
          guessedLetters.add(letter);  
          widget.gameStageBloc.updateGuessedCharacter(guessedLetters);

          if(widget.gameStageBloc.curGuessWord.value.indexOf(letter) < 0) {
            widget.gameStageBloc.updateLostBodyParts();
          }
        }
      },
      child: Container(
        width: 32.0,
        height: 32.0,
        decoration: BoxDecoration(
            color: guessedLetters.contains(letter)
                ? Colors.grey
                : Colors.white,
            borderRadius: BorderRadius.circular(4.0)),
        child: Center(child: Text(letter)),
      ),
    );
  }
}

Update GameStage Widget

And finally, we update the GameStage widget.

import 'package:flutter/material.dart';
import 'package:hangman/enum_collection.dart';
import 'package:hangman/game_stage_bloc.dart';
import 'package:hangman/hangman_painter.dart';
import 'package:hangman/puzzle.dart';

import 'character_picker.dart';

class GameStage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _GameStage();
  }
}

class _GameStage extends State<GameStage> {
  GameStageBloc _gameStageBloc;

  @override
  void initState() {
    super.initState();
    _gameStageBloc = GameStageBloc();
  }

  @override
  void dispose() {
    _gameStageBloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    var mediaQd = MediaQuery.of(context).size;
    return Scaffold(
      body: Container(
          decoration: BoxDecoration(
              gradient: LinearGradient(
                  begin: Alignment.topRight,
                  end: Alignment.bottomLeft,
                  colors: [Colors.blue, Colors.red])),
          padding: EdgeInsets.all(24.0),
          width: mediaQd.width,
          height: mediaQd.height,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              Container(
                width: 270,
                height: mediaQd.height,
                padding: EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0),
                child: CustomPaint(
                  painter: HangManPainter(_gameStageBloc),
                  size: Size(
                    (270 - 24.0),
                    (mediaQd.height - 24.0),
                  ),
                ),
              ),
              Expanded(
                child: Container(
                    child: ValueListenableBuilder(
                  valueListenable: _gameStageBloc.curGuessWord,
                  builder: (BuildContext ctxt, String guessWord, Widget child) {
                    if (guessWord == null || guessWord == '') {
                      return Center(
                          child: RaisedButton(
                        child: Text('Start New Game'),
                        onPressed: () {
                          _gameStageBloc.createNewGame();
                        },
                      ));
                    }

                    return ValueListenableBuilder(
                        valueListenable: _gameStageBloc.curGameState,
                        builder: (BuildContext ctxt, GameState gameState,
                            Widget child) {
                          if (gameState == GameState.succeded) {
                            return Column(
                                mainAxisAlignment:
                                    MainAxisAlignment.spaceAround,
                                crossAxisAlignment: CrossAxisAlignment.center,
                                children: <Widget>[
                                  Text('Well done! You got the right answer.',
                                      style: TextStyle(
                                          color: Colors.white,
                                          fontWeight: FontWeight.bold,
                                          fontSize: 24.0)),
                                  RaisedButton(
                                    child: Text('Start New Game'),
                                    onPressed: () {
                                      _gameStageBloc.createNewGame();
                                    },
                                  )
                                ]);
                          }

                          if (gameState == GameState.failed) {
                            return Column(
                                mainAxisAlignment:
                                    MainAxisAlignment.spaceAround,
                                crossAxisAlignment: CrossAxisAlignment.center,
                                children: <Widget>[
                                  Text('Oops you failed!',
                                      style: TextStyle(
                                          // color: Colors.red,
                                          fontWeight: FontWeight.bold,
                                          fontSize: 24.0)),
                                  RichText(
                                    text: TextSpan(children: [
                                      TextSpan(
                                          text: 'The correct word was: ',
                                          style: TextStyle(
                                              fontWeight: FontWeight.bold,
                                              color: Colors.black,
                                              fontSize: 16.0)),
                                      TextSpan(
                                          text:
                                              _gameStageBloc.curGuessWord.value,
                                          style: TextStyle(
                                              // color: Colors.red,
                                              fontWeight: FontWeight.bold,
                                              fontSize: 24.0))
                                    ]),
                                  ),
                                  RaisedButton(
                                    child: Text('Start New Game'),
                                    onPressed: () {
                                      _gameStageBloc.createNewGame();
                                    },
                                  )
                                ]);
                          }

                          return Column(
                            mainAxisAlignment: MainAxisAlignment.spaceAround,
                            crossAxisAlignment: CrossAxisAlignment.center,
                            children: <Widget>[
                              Row(
                                mainAxisAlignment:
                                    MainAxisAlignment.spaceBetween,
                                children: <Widget>[
                                  Text(
                                    'Guess the correct district...',
                                    style: TextStyle(
                                        color: Colors.black,
                                        fontWeight: FontWeight.bold,
                                        fontSize: 16.0),
                                  ),
                                  IconButton(
                                    icon: Icon(
                                      Icons.restore,
                                      color: Colors.white,
                                      size: 24.0,
                                    ),
                                    onPressed: () {
                                      _gameStageBloc.createNewGame();
                                    },
                                  ),
                                ],
                              ),
                              CharacterPicker(
                                gameStageBloc: _gameStageBloc,
                              ),
                              Puzzle(
                                guessWord: guessWord,
                                gameStageBloc: _gameStageBloc,
                              )
                            ],
                          );
                        });
                  },
                )),
              )
            ],
          )),
    );
  }
}

Conclusion

Pheww! Now that was quite a lot coding. But hey, it’s finally done.

We now have a working functional version of our own HangMan game that we built with Flutter.

If you followed the series along, you should have achieved a working HangMan game that you can take anywhere with you!

Working HangMan Game In Flutter
Working HangMan Game In Flutter

Leave a Reply

Your email address will not be published. Required fields are marked *