preface

Recently I have been learning some knowledge about Flutter, and I wanted to write a small example to use what I have learned, so I came up with this project of Flutter version 2048. The full code can be found at Github.

2048 small game was once all the rage, should be played by a lot of people, but we still simply say about the mechanism of this game, there is a webpage version of 2048, we can actually experience. In general, it is a 4-by-4 board with 16 small pieces, each of which is either empty or has a number that the player can slide up, down, left, or right (or control with the arrow keys on the keyboard). When swiping left or right, blocks of numbers on the same line are moved to the left or right, with no space in the middle, and adjacent blocks of the same number are merged into one, with twice as many digits as before. For example, 2 and 2 are merged into 4 and space. Each time the grid moves after the slide, a random number is generated in the empty squares of the board. The goal of the game is to combine 2048.

Ok, after explaining how to play this game, we can get down to business and start implementing our Version of Flutter 2048.

The final effect we achieved is as follows. Is the degree of reduction ok?

UI

So let’s draw the interface first. As we can see from the above renderings, the game interface can be divided into two parts, one is the title at the top, the score, the highest score in history, the restart button and so on:

One is the bottom part of the checkerboard, with 4 by 4 squares:

If the game ends, this section will be covered with a game end mask:

The code for the game’s overall UI, abbreviated, looks like this:

class Game2048Page extends StatefulWidget {
  @override
  _Game2048PageState createState() => _Game2048PageState();
}

class _Game2048PageState extends State<Game2048Page> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      body: Container(
        padding: EdgeInsets.only(top: 30),
        color: GameColors.bgColor1,
        child: Column(
          children: [
            Flexible(child: gameHeader()),
            Flexible(flex: 2,child: Game2048Panel())
          ],
        )
      ),
    );
  }

  // Omit the code
  Widget gameHeader() {
    returnContainer(); }}Copy the code

The Game2048Page is a descendant of StatefulWidget because the interface needs to display the changing current score and the all-time high score. The overall layout structure is a vertical layout of a Column. The gameHeader() above is extracted into a method containing the title, description, score and other information. The Game2048Panel below is the checkerboard, defined as a Widget. Header and Panel have a 1:2 height ratio (more on that later).

The Header section

The Header part is a little bit simpler, so I’ll just go through it. We need to display the current score and the highest score in history, so define two variables in _Game2048PageState and display them in the corresponding UI element, with a New Game button below the score. Click the button to restart the Game. This simplified UI code looks like this, and the logic for updating scores and restarting the game will be implemented later.

class _Game2048PageState extends State<Game2048Page> {

  /// The current score
  int currentScore = 0;
  /// All-time high score
  int highestScore = 0;
 
  Widget gameHeader() {
  	return Row(
    	children: [
        Text("2048"),
        Column(
          children: [
            Text(currentScore.toString()),
            Text(highestScore.toString()),
            ElevatedButton(
            	onPressed: () {
                // Restart the game and implement the logic later
              },
              child: Text("New Game"),)]),])}}Copy the code

A Panel of

The Panel part is defined as a Widget called Game2048Panel, which inherits from the StatefulWidget because it has internal states such as whether the game is over or not. For the UI part, when the game is over, we use the Stack layout, and the mask covers the top of the board. When the game is not over, there is only the board. The width and height ratio of the checkerboard is 1:1. Using AspectRatio component, the checkerboard can recognize the gesture of users sliding up and down, left and right. Therefore, the GestureDetector component is required. The UI structure of Game2048Panel is roughly as shown below:

The overall code simplification (without UI details) looks like this:

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_games/games/2048/game_colors.dart';

class Game2048Panel extends StatefulWidget {
  final ValueChanged<int>? onScoreChanged;

  Game2048Panel({Key? key, this.onScoreChanged}) : super(key: key);

  @override
  Game2048PanelState createState() => Game2048PanelState();
}

class Game2048PanelState extends State<Game2048Panel> {
  /// Number of columns per row
  static const int SIZE = 4;

  /// Determine if the game is over
  bool _isGameOver = false;

  @override
  Widget build(BuildContext context) {
    if (_isGameOver) {
      return Stack(
        children: [
          _buildGamePanel(context),
          _buildGameOverMask(context),
        ],
      );
    } else {
      return _buildGamePanel(context);
    }
  }

  Widget _buildGamePanel(BuildContext context) {
    return GestureDetector(
      child: AspectRatio(
        aspectRatio: 1.0,
        child: Container(
          child: MediaQuery.removePadding(
            /// The default GridView will have the padding at the top, so this will remove the padding at the top
            removeTop: true,
            context: context,
            child: GridView.builder(
              /// Disable GridView sliding
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: SIZE,
              ),
              itemCount: SIZE * SIZE,
              itemBuilder: (context, int index) {
                return _buildGameCell(0); }, (), (), (), (); }/// Child components in the GridView, representing each small block
  Widget _buildGameCell(int value) {
    return Text(
      value == 0 ? "" : value.toString(),
    );
  }

  /// The game ends with a mask over the Panel
  Widget _buildGameOverMask(BuildContext context) {
    return AspectRatio(
        aspectRatio: 1.0,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text("Game Over"),
              ElevatedButton(
                  onPressed: () {
                    // Restart the game
                  },
                  child: Text("ReStart"))],))); }}Copy the code

One is that when you use a GridView, by default, you have a Padding at the top, which affects how you display it, so we’ve wrapped the GridView with the mediaQuery.removepadding () component, And set removeTop to true to remove the padding. The second is the default the GridView can scroll, and here we do not want it to scroll, so give the GridView physics property is set to NeverScrollableScrollPhysics, disable the scroll.

At this point, we can build a UI like this:

The game logic

With the basic UI built, we can start writing the logical implementation step by step.

Data source and game initialization

First of all, let’s construct the data source of the game, a 4 * 4 board, in which each grid is either empty or a number. We can easily think of a two-dimensional array of ints to represent it, and the Spaces are represented by zeros. That’s _gameMap in the code below.

// Game2048Panel
List
      
       >
      
List _gameMap = List.generate(SIZE, (_) => List<int>.generate(SIZE, (_) => 0));
Copy the code

In the small Cell, we extract data from _gameMap to display in the Cell by converting the Index of the GridView to the coordinates of a two-dimensional array.

GridView.builder(
  itemCount: SIZE * SIZE,
  itemBuilder: (context, int index) {
    int indexI = index ~/ SIZE;
    int indexJ = index % SIZE;
    return_buildGameCell(_gameMap[indexI][indexJ]); },),Copy the code
Widget _buildGameCell(int value) {
  return Container(
    decoration: BoxDecoration(
      color: GameColors.mapValueToColor(value),  // There is a mapping between the value and the background color
      borderRadius: BorderRadius.circular(5),
    ),
    child: Center(
      child: Text(
        value == 0 ? "" : value.toString(), // Display an empty string if the number is 0, effect is space, display a number otherwise),),); }Copy the code

And that completes the presentation of our data. Since _gameMap starts with zeros, the board is full of Spaces after this step, so we can Mock out some data to see how it actually looks. Here we write a function _randomNewCellData that generates a non-zero number from random coordinates in _gameMap:

/// Place the specified number randomly in gameMap,
/// To refresh the interface, you need to put this function in setState
void _randomNewCellData(int data) {
  /// When generating a new number (block),
  /// Check whether all numbers in the map are not 0
  /// If none of them is 0, return without generating a new number
  if (isGameMapAllNotZero()) {
    debugPrint("None of the zeros in gameMap can be generated.");
    return;
  }
  while (true) {
    Random random = Random();
    int randomI = random.nextInt(SIZE);
    int randomJ = random.nextInt(SIZE);
    if (_gameMap[randomI][randomJ] == 0) {
      _gameMap[randomI][randomJ] = data;
      break; }}}/// Check whether all the numbers in the Map are not 0
bool isGameMapAllNotZero() {
  bool isAllNotZero = true;
  for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
      if (_gameMap[i][j] == 0) {
        isAllNotZero = false;
        break; }}}return isAllNotZero;
}
Copy the code

This is a simple random algorithm that checks if none of the numbers in _gameMap are 0, and returns if none of them are 0; Otherwise, a random set of row and column coordinates is generated. If the number on this coordinate is 0, the coordinate is assigned a non-zero value. If the number on this coordinate is not 0, the random set of coordinates is generated until the number on the generated coordinate is 0.

We then call this method on initState to initialize the game data, which by default generates a random 2 and 4.

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

/// Initialize data
void _initGameMap() {
  /// Execute random twice
  _randomNewCellData(2);
  _randomNewCellData(4);
}
Copy the code

Now run the program and you can see the effect of randomly placing small numbers:

Sliding gesture recognition

The next very important step is to recognize the user’s gesture of swiping up, down, left and right. Since I do not know the API of GestureDetector, I am not sure whether there is an easier way to do it. The implementation method here is close to the gesture recognition of Android. Record the position of the finger. In onPanUpdate, record the current position. To filter out oblique slippage, two thresholds are defined: the minimum slippage in the main direction and the maximum slippage in the cross direction. If the distance between the current coordinate and the falling coordinate in the horizontal direction is greater than the minimum slip distance in the main direction, and the distance between the current coordinate and the falling coordinate in the vertical direction is less than the maximum slip distance in the cross direction, it is indicated to be a horizontal slide. The reverse is a vertical slide, and by comparing the current and falling coordinates in the main axis, you can further determine whether to slide left or right (up or down).

/// The left-right offset should be less than this threshold when sliding up and down, as well as left-right
double _crossAxisMaxLimit = 20.0;

/// When sliding up and down, the offset in the up and down direction should be greater than this threshold, as should sliding left and right
double _mainAxisMinLimit = 60.0;

/// OnPanUpdate will call back multiple times, only the first time it is valid,
/// Set to true on onPanDown and false after the first valid slide
bool _firstValidPan = true;

GestureDetector(
  onPanDown: (DragDownDetails details) {
    lastPosition = details.globalPosition;
    _firstValidPan = true;
  },
  onPanUpdate: (DragUpdateDetails details) {
    final currentPosition = details.globalPosition;

    /// First distinguish between vertical and horizontal sliding
    if ((currentPosition.dx - lastPosition.dx).abs() > _mainAxisMinLimit &&
        (currentPosition.dy - lastPosition.dy).abs() < _crossAxisMaxLimit) {
      // Slide horizontally
      if (_firstValidPan) {
        debugPrint("Horizontal slip");
        /// Then distinguish whether to swipe left or swipe right
        if (currentPosition.dx - lastPosition.dx > 0) {
          / / slide to the right
          debugPrint("Swipe right");
        } else {
          / / slide to the left
          debugPrint("Swipe left");
        }
        _firstValidPan = false; }}else if ((currentPosition.dy - lastPosition.dy).abs() > _mainAxisMinLimit &&
        (currentPosition.dx - lastPosition.dx).abs() < _crossAxisMaxLimit) {
      // Slide vertically
      if (_firstValidPan) {
        debugPrint("Vertical slip");
        /// Then distinguish between sliding up and sliding down
        if (currentPosition.dy - lastPosition.dy > 0) {
          / / to decline
          debugPrint("Slide down");
        } else {
          / / slip up
          debugPrint("Slide up");
        }
        _firstValidPan = false; }}}},Copy the code

There is also a _firstValidPan variable in this code that needs to be explained, because onPanUpdate keeps calling back during finger swipes, so when we recognize a valid swipe, subsequent onpanUpdates don’t need to be handled. This variable needs to be reset in onPanDown.

Now running the code, we can print out the correct direction of the gesture by sliding our finger up, down, left, and right on the board.

Number block movement and merge

Once the gesture recognition is complete, the next step is to move the blocks of numbers and merge the computational logic of the same blocks (with no other numbers between them).

GestureDetector(
  onPanUpdate: (DragUpdateDetails details) {
    /// First distinguish between vertical and horizontal sliding
    if (horizontalSwipe) {
      // Slide horizontally
      if (_firstValidPan) {
        debugPrint("Horizontal slip");
        /// Then distinguish whether to swipe left or swipe right
        if (currentPosition.dx - lastPosition.dx < 0) {
          / / slide to the left
          debugPrint("Swipe left");
          setState(() {
            /// Merge the same blocks and move the non-zero blocks to the far left
            _joinGameMapDataToLeft();
            if(! _noMoveInSwipe) { _randomNewCellData(2);
            }
            _checkGameState();
          });
        }
        _firstValidPan = false; }}else {
      /// Omit some code}}},Copy the code

In the above code, the _joinGameMapDataToLeft method is called after the swipe to the left, which is the merge and move logic. Again, Other directions include _joinGameMapDataToRight, _joinGameMapDataToTop, and _joinGameMapDataToBottom.

My merge and move algorithms here suck, so there must be a simpler, less complex algorithm to do it. Take swiping left as an example to look at the merge and move algorithm, and the same goes for other directions:

void _joinGameMapDataToLeft() {
  /// To start changing data in the map, set noMoveInSwipe to true
  _noMoveInSwipe = true;
  /// Each row is evaluated, so use the for loop to iterate over each row
  for (int i = 0; i < SIZE; i++) {
    int j1 = 0;
    while (j1 < SIZE - 1) {
      if (_gameMap[i][j1] == 0) {
        j1++;
        continue;
      }
      for (int j2 = j1 + 1; j2 < SIZE; j2++) {
        if (_gameMap[i][j2] == 0) {
          continue;
        } else if(_gameMap[i][j2] ! = _gameMap[i][j1]) {break;
        } else {
          _gameMap[i][j1] = 2 * _gameMap[i][j1];
          _gameMap[i][j2] = 0;

          /// There's a merge of two blocks here, increasing the score
          _currentScore += (_gameMap[i][j1] as int);

          /// The score is called back to the outside worldwidget.onScoreChanged? .call(_currentScore);/// This line must be written after the score, otherwise gameMap[i] [j1] is actually gameMap[i] [j2Lambda is 0
          j1 = j2;

          /// There's a merging of blocks, which means there's movement
          _noMoveInSwipe = false;
        }
      }
      j1++;
    }
    int notZeroCount = 0;
    for (int k = 0; k < SIZE; k++) {
      if(_gameMap[i][k] ! =0) {
        if(k ! = notZeroCount) { _gameMap[i][notZeroCount] = _gameMap[i][k]; _gameMap[i][k] =0;

          /// If a non-zero digit is swapped with a zero, there is movement
          _noMoveInSwipe = false; } notZeroCount++; }}}}Copy the code

Merge the same non-zero numbers: each line needs to be evaluated, so a for loop is used. In the calculation of a line, two subscripts j1 and j2 are defined to point to the two blocks with the same value to be compared. The range of j1 is [0, size-1). If the block pointed to by j1 is 0, it jumps to the next block. J2 = [j1 + 1, SIZE]; j2 = [j1 + 1, SIZE]; j2 = [j1 + 1, SIZE]; Otherwise, the block in j2 has the same number as the block in j1. In this case, the two blocks are merged, the number in j1 is doubled, the number in j2 is 0, and j1 is moved directly to j2. The next outer loop continues from j2 + 1.

Move a non-zero digit to the far left: defines a variable notZeroCount that represents the number of non-zero digits traversed. It also represents the subscript that the NTH (n starting from zero) non-zero digit should place. If a non-zero number is encountered, compare k with notZeroCount. If they are the same, the non-zero number has already been placed in the correct position and no need to move. If the value is not equal, it indicates that the block with a number of 0 has been traversed before, and the non-0 number should be placed at the notZeroCount position, not at the k position, and the two positions should be swapped. The notZeroCount increases when the block with a number of non-0 is encountered.

The following image shows a concrete example that the reader can understand against the code.

Once this is done, the squares can move up, down, left, and right. Here’s what happens at this point:

We haven’t created any new blocks yet, so the natural next step is to create new blocks after sliding.

Generates a new number block

If there is a movement or merging of blocks in a slide, a number needs to be randomly generated in the empty block after the movement or merging. If there is neither movement nor merging of blocks, a new number block will not be generated. We first define a variable of type bool _noMoveInSwipe, which represents whether there is a block movement in a slide. Block movement consists of two cases: block merging and non-zero block moving to the far left. Going back to the code above, in _joinGameMapDataToLeft, _noMoveInSwipe is set to true each time this method is called, and false is set for block merge and block move swap. Then determine after _joinGameMapDataToLeft that if _noMoveInSwipe is false, call _randomNewCellData(2) to generate a random block with a number of 2.

Bool _noMoveInSwipe = true; bool _noMoveInSwipe = true;Copy the code

Once this is done, the main function of 2048 is complete, and we can start sliding to merge the blocks. The demo effect is as follows:

Update the score

Without scores, games would be less fun, so we needed to add a scoring mechanism. When two blocks merge, we need to update the current score, adding the combined value to the current score. Since the score is not displayed in the Game2048Panel, but in the Header section of the Game2048Page, we need a way to pass the latest score to Game2048Page. Here we add an onScoreChanged callback to the Game2048Panel to pass the changed score to the parent container after the number blocks are merged.

class Game2048PanelTest extends StatefulWidget {
  /// A callback to a score change
  final ValueChanged<int>? onScoreChanged;

  Game2048PanelTest({Key? key, this.onScoreChanged}) : super(key: key);
}
Copy the code

In the parent Game2048Page, we first declare two state variables, currentScore and highestScore. In the onScoreChanged callback of the Game2048Panel, we assign the currentScore value. Check whether the value is greater than highestScore. If yes, assign currentScore to highestScore and persist it to disk. Then refresh the interface.

/// The current score
int currentScore = 0;
/// All-time high score
int highestScore = 0;

Column(
  children: [
    Flexible(child: gameHeader()),
    Flexible(flex: 2,child: Game2048Panel(
    	key: _gamePanelKey,
      onScoreChanged: (score) {
        setState(() {
          currentScore = score;
          if(currentScore > highestScore) { highestScore = currentScore; storeHighestScoreToSp(); }}); },))],)Copy the code

Shared_preferences 插件 图 片 shared_preferences 插件 图 片 shared_preferences 插件 图 片 shared_preferences 插件 图 片 shared_preferences 插件 图 片

Future<SharedPreferences> _spFuture = SharedPreferences.getInstance();

void readHighestScoreFromSp() async {
  final SharedPreferences sp = await _spFuture;
  setState(() {
    highestScore = sp.getInt(GAME_2048_HIGHEST_SCORE) ?? 0;
  });
}
Copy the code

When entering the game interface, you need to read the highest score from SP, display it in the Header, and call the readHighestScoreFromSp method in the initState method.

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

void readHighestScoreFromSp() async {
  final SharedPreferences sp = await _spFuture;
  setState(() {
    highestScore = sp.getInt(GAME_2048_HIGHEST_SCORE) ?? 0;
  });
}
Copy the code

Call the game over

Every time a finger swipes, moves/merges blocks, and creates a new number block, we need to check to see if the game is over. Write a _checkGameState method, when all the numbers in _gameMap are not 0, start to check whether there are any numbers in the horizontal and vertical directions that can be merged, if there are none, then the game is over, if there are any numbers that can be merged, then the game is not over.

void _checkGameState() {
  if(! isGameMapAllNotZero()) {return;
  }
  /// If none of the digits in the Map is 0, check whether there are digits that can be combined in the vertical and horizontal directions.
  /// If there are, the game is not over. If there are none, the game is over
  bool canMerge = false;
  for (int i = 0; i< SIZE; i++) {
    for (int j = 0; j< SIZE  - 1; j++) {
      if (_gameMap[i][j] == _gameMap[i][j + 1]) {
        canMerge = true;
        break; }}if (canMerge) {
      break; }}for (int j = 0; j < SIZE; j++) {
    for (int i = 0; i < SIZE  - 1; i++) {
      if (_gameMap[i][j] == _gameMap[i + 1][j]) {
        canMerge = true;
        break; }}if (canMerge) {
      break; }}// If there is nothing to merge, the game ends
  if(! canMerge) { setState(() { _isGameOver =true; }); }}Copy the code

Restart the game

Don’t forget that there are two other places to trigger restarting the Game: the New Game button in the Header section and the Restart button on the Game end mask. The logic for restarting the game is simple, just clear the _gameMap data, initialize the game data once, reset some states, such as the current score and whether the game is over, and then refresh the screen.

void reStartGame() {
  setState(() {
    _resetGameMap();
    _initGameMap();
    // Clear the score
    _currentScore = 0;
    // Callback the score back to the parent containerwidget.onScoreChanged? .call(_currentScore);// Reset the game state, the game is not over, there is no mask
    _isGameOver = false;
  });
}

/// Clear the game data source and set it all to 0
void _resetGameMap() {
  for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
      _gameMap[i][j] = 0; }}}Copy the code

The Restart button is inside the Game2048PanelState, so just call the reStartGame method in the click event, but the New Game button is inside the Game2048Page Header, How do I call this method when it’s not inside Game2018PanelState? We declare a GlobalKey with the template Game2018PanelState in Game2048Page, and pass this GlobalKey to the Key property of the Game2048Panel. You can then get the instance of Game2018PanelState in the Header button and call its public methods.

Note that Game2018PanelState is now accessible to the outside world, so variables and methods that cannot be exposed should be declared private, with variable names and methods preceded by _.

/// Get the Game2048PanelState instance so you can call the restartGame method
GlobalKey _gamePanelKey = GlobalKey<Game2048PanelTestState>();

/// The New Game button
InkWell(
  onTap: () {
    (_gamePanelKey.currentState as Game2048PanelState).reStartGame();
  },
  child: Text("NEW GAME"),),Copy the code

One last detail: vertical and horizontal adaptation

Finally, we will improve a small detail, is the vertical screen adaptation. As mentioned above, the ratio between Header and Panel is 1:2. Why do we need to set a ratio? It is to adapt to the situation of vertical and horizontal screens. In Flutter, you can use OrientationBuilder to determine whether the screen is landscape or vertical. In portrait, we use the Column component, with Header one-third of the way up and Panel two-thirds of the way down. In landscape, use the Row component, with the Header taking up one-third of the width on the left and the Panel taking up two-thirds of the width on the right. This is a simple vertical screen adaptation.

Container(
  padding: EdgeInsets.only(top: 30),
  color: GameColors.bgColor1,
  child: OrientationBuilder(
    builder: (context, orientation) {
      if (orientation == Orientation.portrait) {  / / vertical screen
        return Column(
          children: [
            Flexible(child: gameHeader()),
            Flexible(flex: 2,child: gamePanel)
          ],
        );
      } else {  / / landscape
        return Row(
          children: [
            Flexible(child: gameHeader()),
            Flexible(flex: 2,child: gamePanel) ], ); }},),),Copy the code

The effect under landscape screen is shown as follows:

conclusion

This little game of Flutter version 2048 is made here, learning while playing is also a very interesting experience. A quick summary of some of the essentials involved in this small project:

  • AspectRatio, OrientationBuilder, GridView and other widgets
  • Gesture recognition of Flutter
  • Passing state across components and calling exposed methods of other components

Enjoy your study.