欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

学习与娱乐并行,一起体验2048小游戏吧

最编程 2024-08-15 10:44:55
...

07f53495b617468a32a7106d91b71677.png

/   今日科技快讯   /

自11月1日以来,天猫双11第一波正式开售,消费热情全面爆发。国货品牌的表现尤为抢眼。鸿星尔克、五菱汽车、小鹏汽车、薇诺娜、云鲸、添可、林氏木业、牧高笛、回力、蕉下、褚橙、方回春堂、太平鸟、bosie等国货品牌,在1小时内就超过了去年全天的销售额。

/   作者简介   /

本篇文章来自沧海一树的投稿,文章主要讲解了如何用Flutter来开发小游戏,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

沧海一树的博客地址:

https://juejin.cn/user/2770425030649662

/   前言   /

最近在学习Flutter的一些知识,想着独立写一个小示例来运用一下所学的东西,所以就有了这个Flutter版本2048小游戏的项目。完整代码请戳Github。

https://github.com/owenleexiaoyu/FlutterCodeLabs/blob/main/flutter_games/lib/games/2048/game_2048_page.dart

2048小游戏曾经也是风靡一时,应该很多人都玩过,不过我们还是简单说一下这个游戏的机制,这里有个网页版的2048,大家可以实际体验一下。一般来说,它是一个4 * 4的棋盘,共有16个小块,每个小块中,要么是空的,要么有一个数字,玩家可以上下左右四个方向去滑动(或者通过键盘方向键控制)。当左右滑动时,同一行上数字小块会被移到最左端或者最右端,中间没有空格,相邻的相同数字的小块合并成一个,数字是之前格子的两倍,比如2和2会合并成4和空格。每次滑动后有格子移动时,就会在棋盘的空的小块里随机生成一个数字。游戏的目标是合并出2048这个数字。

好的,介绍完这个游戏怎么玩后,我们就可以进入正题,开始实现我们Flutter版本的2048了。

我们最终实现的效果如下,还原度是不是还可以?

ad15bbee13ca0e81050e077070996ee3.gif

/   UI 界面   /

首先我们先来画一下界面。从上面的效果图我们可以看到,这个游戏界面可以分为两部分,一个是上方的标题、分数、历史最高分、重新开始游戏按钮等元素:

6347c79630fbcd86ca1f2a6fd2245846.png

一个是下方的棋盘部分,有4 * 4个小格子:

ceb0a06ad3de0a607a460b3375d184e5.png

如果游戏结束时,这部分上面还会盖有一个游戏结束的蒙层:

9176b0d4b72866e5a08301d30f47b194.png

游戏整体UI的代码,精简一下是这样的:

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())
          ],
        )
      ),
    );
  }

  // 省略了代码
  Widget gameHeader() {
    return Container();
  }
}

因为界面里需要展示变化的当前分数和历史最高分数,所以Game2048Page是继承自StatefulWidget。布局结构整体是一个Column的垂直方向布局,上面是gameHeader(),抽成了一个方法,里面就是标题、描述、分数等信息,下面是Game2048Panel表示棋盘,定义成了一个Widget,Header和Panel是1 :2的高度比例(这个后面会提到是干嘛的)。

Header部分

Header部分比较简单,就粗略介绍一下。我们需要展示当前的分数和历史最高分,所以在_Game2048PageState中定义两个变量,并在相应的UI元素中展示出来,分数下方还有个New Game的按钮,点击按钮可以重新开始游戏。这部分UI代码精简后是这样的,更新分数和重新开始游戏的逻辑我们后面再实现。

class _Game2048PageState extends State<Game2048Page> {

  /// 当前分数
  int currentScore = 0;
  /// 历史最高分
  int highestScore = 0;

  Widget gameHeader() {
      return Row(
        children: [
        Text("2048"),
        Column(
          children: [
            Text(currentScore.toString()),
            Text(highestScore.toString()),
            ElevatedButton(
                onPressed: () {
                // 重新开始游戏,这里的逻辑之后再实现
              },
              child: Text("New Game"),
            )
          ]
        ),
      ]
    )
  }
}

Panel 部分

Panel部分被定义成了一个叫做Game2048Panel的Widget,继承自StatefulWidget,因为内部有是否游戏结束等状态。UI 部分,当游戏结束时,我们使用 Stack 布局,蒙层盖在棋盘的上方,游戏没有结束时,只有棋盘。棋盘宽高比是1 : 1,使用AspectRatio组件,棋盘可以识别用户上下左右滑动的手势,所以需要GestureDetector组件,棋盘中4 * 4的小格子,用GridView来实现。Game2048Panel的UI结构大致如下图所示:

6cd7ab9c7f2266eca9d356392a731484.png

这部分整体的代码精简一下(去掉了 UI 细节)是这样的:

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> {
  /// 每行每列的个数
  static const int SIZE = 4;

  /// 判断是否游戏结束
  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(
            /// GridView 默认顶部会有 padding,通过这个删除顶部 padding
            removeTop: true,
            context: context,
            child: GridView.builder(
              /// 禁用 GridView 的滑动
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: SIZE,
              ),
              itemCount: SIZE * SIZE,
              itemBuilder: (context, int index) {
                return _buildGameCell(0);
              },
            ),
          ),
        ),
      ),
    );
  }

  /// GridView 中的子组件,表示每个小块
  Widget _buildGameCell(int value) {
    return Text(
      value == 0 ? "" : value.toString(),
    );
  }

  /// 游戏结束时盖在 Panel 上的蒙层
  Widget _buildGameOverMask(BuildContext context) {
    return AspectRatio(
        aspectRatio: 1.0,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text("Game Over"),
              ElevatedButton(
                  onPressed: () {
                    // 重新开始游戏
                  },
                  child: Text("ReStart"))
            ],
          ),
        ));
  }
}

这里有几个小细节可以说一下,一个是使用GridView的时候,顶部默认会有一个Padding,这样会影响最后展示的效果,所以这里用MediaQuery.removePadding()组件包裹了GridView,并且设置removeTop为 true,就可以去掉这部分的padding。第二个是默认GridView是可以滚动的,而这里我们不希望它滚动,所以给GridView 的physics属性设置为NeverScrollableScrollPhysics,禁用了它的滚动。

到这一步,我们就可以构建出如下所示的UI界面:

2575de6346df17c27cd7b8354fa1c6f1.png

/   游戏逻辑   /

构建好基础的UI,我们就可以开始一步步编写逻辑实现了。

数据源及游戏初始化

首先我们先来构造这个游戏的数据源,4 * 4的棋盘,棋盘中每个格子要么是空的,要么是数字,我们很容易能想到用一个int的二维数组来表示它,空格子我们用0来表示。也就是下面代码里的_gameMap。

GridView.builder(
  itemCount: SIZE * SIZE,
  itemBuilder: (context, int index) {
    int indexI = index ~/ SIZE;
    int indexJ = index % SIZE;
    return _buildGameCell(_gameMap[indexI][indexJ]);
  },
)
Widget _buildGameCell(int value) {
  return Container(
    decoration: BoxDecoration(
      color: GameColors.mapValueToColor(value),  // 数值和背景颜色有个映射
      borderRadius: BorderRadius.circular(5),
    ),
    child: Center(
      child: Text(
        value == 0 ? "" : value.toString(), // 如果数字是0,展示空字符串,效果上就是空格,否则展示数字
      ),
    ),
  );
}

这样我们数据的展示就完成了。因为一开始_gameMap中都是0,所以这步完成后,棋盘里都是空格,我们可以Mock一些数据,看看实际显示效果。这里我们写一个在_gameMap中随机坐标生成一个非0数字的函数_randomNewCellData:

/// 在 gameMap 里随机位置放置指定的数字,
/// 需要刷新界面时,需要将这个函数放在 setState 里
void _randomNewCellData(int data) {
  /// 在产生新的数字(块)时,
  /// 需要先判断下是否map中所有的数字都不为0
  /// 如果都不为0,就直接return,不产生新数字
  if (isGameMapAllNotZero()) {
    debugPrint("gameMap中都不是0,不能生成");
    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;
    }
  }
}

/// 判断Map中的数字是否都不为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;
}

这是一个简单的随机算法,判断_gameMap中的数是否都不为0,如果都不为0,则返回;否则就随机生成一组行列坐标,如果这个坐标上的数是0,就给这个坐标赋一个非0值,如果这个坐标上的数不是0,则继续随机生成一组坐标,直到生成坐标上的数是0为止。

然后在initState调用这个方法,就可以初始化游戏数据啦,我们默认随机生成一个2和一个4。

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

/// 初始化数据
void _initGameMap() {
  /// 执行两次随机
  _randomNewCellData(2);
  _randomNewCellData(4);
}

现在运行程序,就可以看到随机放置数字小块的效果啦:

60b48779077aa4660c7bf2bbe2779f92.png

滑动手势识别

接下来非常重要的一步就是要识别用户上下左右滑动的手势,因为对GestureDetector的API不了解,不确定是否有更简单的做法,我这里的实现方式和Android的手势识别比较接近,在onPanDown时,记录下手指的下落坐标,在onPanUpdate里,记录下当前的坐标。为了过滤掉斜向的滑动,这里定义了两个阈值:主方向的最小滑动距离 和 交叉方向上的最大滑动距离。如果当前坐标 - 下落坐标在水平方向的距离 > 主方向最小滑动距离,并且当前坐标 - 下落坐标在垂直方向的距离 < 交叉方向最大滑动距离,就表示是水平方向的滑动。

反过来则是垂直方向的滑动,再比较在主轴方向上当前坐标和下落坐标的大小,可以进一步判断出是向左滑动还是向右滑动(向上滑动还是向下滑动)。

/// 当上下滑动时,左右方向的偏移应该小于这个阈值,左右滑动亦然
double _crossAxisMaxLimit = 20.0;

/// 当上下滑动时,上下方向的偏移应该大于这个阈值,左右滑动亦然
double _mainAxisMinLimit = 60.0;

/// onPanUpdate 会回调多次,只需要第一次有效的就可以了,
/// 在 onPanDown 时设为 true,第一次有效滑动后,设为 false
bool _firstValidPan = true;

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

    /// 首先区分是垂直方向还是水平方向滑动
    if ((currentPosition.dx - lastPosition.dx).abs() > _mainAxisMinLimit &&
        (currentPosition.dy - lastPosition.dy).abs() < _crossAxisMaxLimit) {
      // 水平方向滑动
      if (_firstValidPan) {
        debugPrint("水平方向滑动");
        /// 然后区分是向左滑还是向右滑
        if (currentPosition.dx - lastPosition.dx > 0) {
          // 向右滑
          debugPrint("向右滑");
        } else {
          // 向左滑
          debugPrint("向左滑");
        }
        _firstValidPan = false;
      }
    } else if ((currentPosition.dy - lastPosition.dy).abs() > _mainAxisMinLimit &&
        (currentPosition.dx - lastPosition.dx).abs() < _crossAxisMaxLimit) {
      // 垂直方向滑动
      if (_firstValidPan) {
        debugPrint("垂直方向滑动");
        /// 然后区分是向上滑还是向下滑
        if (currentPosition.dy - lastPosition.dy > 0) {
          // 向下滑
          debugPrint("向下滑");
        } else {
          // 向上滑
          debugPrint("向上滑");
        }
        _firstValidPan = false;
      }
    }
  },
}

这段代码里还有一个变量_firstValidPan需要解释下,因为onPanUpdate在手指滑动过程中会一直回调,所以当我们识别到一个有效的滑动(判断出明确的方向)时,后续的onPanUpdate就不需要处理了,需要在onPanDown中重置这个变量。

现在运行代码,我们在棋盘里用手指上下左右滑动,可以打印出正确的手势滑动方向。

数字块的移动与合并

完成手势识别后,接下来就是数字块的移动,并且合并相同块(中间没有其他数字的阻隔)的计算逻辑了。

GestureDetector(
  onPanUpdate: (DragUpdateDetails details) {
    /// 首先区分是垂直方向还是水平方向滑动
    if (horizontalSwipe) {
      // 水平方向滑动
      if (_firstValidPan) {
        debugPrint("水平方向滑动");
        /// 然后区分是向左滑还是向右滑
        if (currentPosition.dx - lastPosition.dx < 0) {
          // 向左滑
          debugPrint("向左滑");
          setState(() {
            /// 合并相同的块,移动非0的块到最左边
            _joinGameMapDataToLeft();
            if (!_noMoveInSwipe) {
              _randomNewCellData(2);
            }
            _checkGameState();
          });
        }
        _firstValidPan = false;
      }
    } else {
      /// 省略部分代码
    }
  },
}

上面的代码中,判断出向左滑动后,调用了_joinGameMapDataToLeft方法,这个方法中就是合并和移动的逻辑,同样的,其他方向还有_joinGameMapDataToRight、_joinGameMapDataToTop、_joinGameMapDataToBottom的方法。

这里我的合并和移动算法比较挫,肯定有更简单,复杂度更低的算法来实现。以向左滑动为例,来看看合并和移动的算法,其他方向同理:

void _joinGameMapDataToLeft() {
  /// 开始改变map中的数据时,先将noMoveInSwipe置为true
  _noMoveInSwipe = true;
  /// 每一行都要计算,所以用 for 循环遍历每一行
  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;

          /// 在这里有两个块的合并,增加分数
          _currentScore += (_gameMap[i][j1] as int);

          /// 把分数回调给外界
          widget.onScoreChanged?.call(_currentScore);

          /// 这行要写在记录score之后,不然gameMap[i][j1]实际是gameMap[i][j2],就是0了
          j1 = j2;

          /// 有块的合并,说明有移动
          _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;

          /// 有非0数字和0交换,说明有移动
          _noMoveInSwipe = false;
        }
        notZeroCount++;
      }
    }
  }
}

合并相同的非0数字:每一行都需要计算,所以用了一个for循环。在一行的计算中,定义了j1、j2两个下标,指向要比较数值相同的前后两个块,j1的范围是[0, SIZE -1),如果j1所指向的块是0,则跳到下一个块。

j2的范围是[j1 + 1, SIZE),如果j2所指向的块是0,则跳到下一个块;如果不是0但是不等于j1所指的块,则说明j1块不和后面的块数字相等,无法合并,直接跳出内层循环,让j1移到下一个;否则就是j2所在的块和j1所在的块数字相同,这时候就把这两个块合并,j1所在块数字变为原来的两倍,j2所在块变为0,并且让j1直接移到j2处,下一次的外层循环就从j2 + 1处继续。

移动非0数字到最左侧:定义了一个变量notZeroCount,代表遍历到的非0数字的个数,其实它也表示第n(n 从 0 开始)个非0数字应该放置的下标。

游标k范围是[0, SIZE),依次递增,如果遇到非0数字,需要比较k和notZeroCount,如果两者相同,说明这个非0数字已经放在了正确的位置,不需要移动了;如果不相等,说明之前遍历过数字为0的块,这时候这个非0数字应该被放在下标为notZeroCount的位置,而不是下标为k的位置,需要将两个位置的数字交换。notZeroCount在遇到非0数字的块后会自增。

下面的图片演示了一个具体的示例,读者可以对照着代码进行理解。

0a0371725203c74a9a76159197e317cd.png

完成这一步,棋盘中的小格子就可以在棋盘中上下左右移动了。到这一步运行起来的效果如下:

13fac2b1cab27e88e60aa7def2710092.gif

不过我们还没有新的数字块产生,所以下一步自然是在滑动后产生新的数字块。

产生新的数字块

如果一次滑动,有块的移动或合并,则在移动或合并完后,需要在空的块里随机再产生一个数字,如果既没有块的移动也没有块的合并,则不会产生一个新的数字块。首先定义了一个bool类型的变量_noMoveInSwipe,代表一次滑动中有没有块的移动,块的移动包含两种情况:块的合并和非0块移到最左边。

回到上面的代码,在_joinGameMapDataToLeft中,每次调用该方法时,都会将_noMoveInSwipe置为true,在块的合并时和块的移动交换时,给这个变量设为false。然后在_joinGameMapDataToLeft之后判断如果_noMoveInSwipe为false,则调用_randomNewCellData(2)随机生成一个数字为2的块。

/// 这一次手势滑动,是不是没有块移动,如果没有块移动,就不能产生新的块
bool _noMoveInSwipe = true;

66b555382a7c5cd1e1084425c75bf5ee.png

a89272d0991521b924146e9c587df26b.png

ff9dc8e7419895a6fdd9b787eaa11be3.png

这步完成后,其实2048的主体功能就完成了,我们已经可以开始不断滑动来合并数字块了。演示效果如下:

ba9f43ca0d5737ebc521f8aad875fbae.gif

更新分数

游戏没有分数就少了很多乐趣,所以我们需要给它加上记分机制。当有两个块合并时,我们需要更新当前的分数,在当前分数上加上合并后的数值。因为这个分数的数字并不是显示在Game2048Panel里面,而是显示在Game2048Page的Header部分,所以我们需要通过一种方式将最新的分数传递给Game2048Page。这里我们给Game2048Panel添加一个onScoreChanged的回调,可以在数字块合并后把变化后的分数传给父容器。

class Game2048PanelTest extends StatefulWidget {
  /// 分数变化的回调
  final ValueChanged<int>? onScoreChanged;

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

b1e40e359d0899a278371090868b5fc9.png

在父容器Game2048Page中,首先声明了两个状态变量,currentScore和highestScore,另外在Game2048Panel的onScoreChanged回调里,给currentScore赋值,并判断是否大于highestScore,如果大于,将currentScore的值赋给highestScore并持久化到磁盘中,接着刷新界面。

/// 当前分数
int currentScore = 0;
/// 历史最高分
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();
          }
        });
      },
      )
    )
  ],
)

757fbee98594584cf82948e2af6999fc.gif

保存历史最高分数用到了shared_preferences这个插件来保存数据,它可以保存Key-Value格式的数据,这里不具体展开,看看代码的实现:

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

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

在进入游戏界面时,需要从SP中将历史最高分数读取出来,展示在Header里,在initState方法中调用readHighestScoreFromSp方法。

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

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

判断游戏结束

每次手指滑动,移动/合并了块,产生了新的数字块后,我们都需要检查一下游戏是否结束。写一个_checkGameState的方法,当_gameMap中所有的数字都不为0时,开始检查横纵方向上是否存在可以合并的数字,如果都没有,则代表游戏结束,如果有可以合并的,则游戏没有结束。

void _checkGameState() {
  if (!isGameMapAllNotZero()) {
    return;
  }
  /// 如果 Map 中数字都不为0,则需要判断横纵方向上是否存在可以合并的数字,
  /// 如果有,则游戏不算结束,都没有的话,游戏结束
  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 (!canMerge) {
    setState(() {
      _isGameOver = true;
    });
  }
}

43605adb2c898c26e08f67258309e119.png

重新开始游戏

不要忘了我们还有两个地方可以触发重新开始游戏,一个是Header部分的New Game按钮,一个是游戏结束蒙层上的Restart按纽。实现重新开始游戏的逻辑很简单,我们只需要将_gameMap的数据全部清空,初始化一次游戏数据,并且重置一些状态,比如当前的分数和是否游戏结束的状态,然后刷新界面就可以了。

void reStartGame() {
  setState(() {
    _resetGameMap();
    _initGameMap();
    // 清空分数
    _currentScore = 0;
    // 将分数回调给父容器
    widget.onScoreChanged?.call(_currentScore);
    // 重置游戏状态,游戏没有结束,不会出现蒙层
    _isGameOver = false;
  });
}

/// 清空游戏数据源,全部置为 0
void _resetGameMap() {
  for (int i = 0; i < SIZE; i++) {
    for (int j = 0; j < SIZE; j++) {
      _gameMap[i][j] = 0;
    }
  }
}

Restart按钮是在Game2048PanelState内部,所以在点击事件中直接调用reStartGame方法即可,但New Game按钮是在Game2048Page的 Header 部分,不在Game2018PanelState内部,那该怎么调用到这个方法呢?答案是Global Key,我们在Game2048Page中声明一个范型为Game2018PanelState的GlobalKey,并将这个GlobalKey传给Game2048Panel的key属性,这样就可以在Header的按钮中获取到Game2018PanelState的实例并且调用它的公开方法了。

这里要注意,Game2018PanelState现在是可以被外界访问的了,所以不能暴露的变量和方法都要声明成私有的,变量名和方法明前面加上 _。

/// 用于获取 Game2048PanelState 实例,以便可以调用restartGame方法
GlobalKey _gamePanelKey = GlobalKey<Game2048PanelTestState>();

/// New Game 按钮
InkWell(
  onTap: () {
    (_gamePanelKey.currentState as Game2048PanelState).reStartGame();
  },
  child: Text("NEW GAME"),
)

最后一点细节:横竖屏适配

最后的最后,我们来完善一个小细节,就是横竖屏的适配。前面提到,Header部分和Panel的比例是1 :2,为什么要定一个比例呢,就是为了适配横竖屏的情况。Flutter中,可以通过OrientationBuilder判断出是横屏还是竖屏。在竖屏情况下,我们使用Column组件,Header在上方占三分之一高度,Panel在下方占三分之二的高度。横屏情况下,使用Row组件,Header在左边占三分之一宽度,Panel在右方占三分之二的宽度。这样一个简单的横竖屏适配就完成了。

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

横屏下效果如图:

42609ea0f6102e2a86cf9d41a2e5c84e.png

/   结语   /

到这里这个Flutter版的2048小游戏就制作完成啦,边学边玩也是很有趣的体验。简单总结下这个小项目涉及的一些小知识点:

  • AspectRatio、OrientationBuilder、GridView等Widget的使用

  • Flutter 的手势识别

  • 跨组件传递状态和调用其他组件的公开方法

祝大家学习愉快。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

带倒计时RecyclerView的设计心路历程

Android 12上焕然一新的小组件

欢迎关注我的公众号

学习技术或投稿

f3e51f23b2421c88323e6aa46b913697.png

1fa9ed960e78ccea85338c19f9be03a0.png

长按上图,识别图中二维码即可关注