学习与娱乐并行,一起体验2048小游戏吧
/ 今日科技快讯 /
自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了。
我们最终实现的效果如下,还原度是不是还可以?
/ UI 界面 /
首先我们先来画一下界面。从上面的效果图我们可以看到,这个游戏界面可以分为两部分,一个是上方的标题、分数、历史最高分、重新开始游戏按钮等元素:
一个是下方的棋盘部分,有4 * 4个小格子:
如果游戏结束时,这部分上面还会盖有一个游戏结束的蒙层:
游戏整体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结构大致如下图所示:
这部分整体的代码精简一下(去掉了 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界面:
/ 游戏逻辑 /
构建好基础的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);
}
现在运行程序,就可以看到随机放置数字小块的效果啦:
滑动手势识别
接下来非常重要的一步就是要识别用户上下左右滑动的手势,因为对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数字的块后会自增。
下面的图片演示了一个具体的示例,读者可以对照着代码进行理解。
完成这一步,棋盘中的小格子就可以在棋盘中上下左右移动了。到这一步运行起来的效果如下:
不过我们还没有新的数字块产生,所以下一步自然是在滑动后产生新的数字块。
产生新的数字块
如果一次滑动,有块的移动或合并,则在移动或合并完后,需要在空的块里随机再产生一个数字,如果既没有块的移动也没有块的合并,则不会产生一个新的数字块。首先定义了一个bool类型的变量_noMoveInSwipe,代表一次滑动中有没有块的移动,块的移动包含两种情况:块的合并和非0块移到最左边。
回到上面的代码,在_joinGameMapDataToLeft中,每次调用该方法时,都会将_noMoveInSwipe置为true,在块的合并时和块的移动交换时,给这个变量设为false。然后在_joinGameMapDataToLeft之后判断如果_noMoveInSwipe为false,则调用_randomNewCellData(2)随机生成一个数字为2的块。
/// 这一次手势滑动,是不是没有块移动,如果没有块移动,就不能产生新的块
bool _noMoveInSwipe = true;
这步完成后,其实2048的主体功能就完成了,我们已经可以开始不断滑动来合并数字块了。演示效果如下:
更新分数
游戏没有分数就少了很多乐趣,所以我们需要给它加上记分机制。当有两个块合并时,我们需要更新当前的分数,在当前分数上加上合并后的数值。因为这个分数的数字并不是显示在Game2048Panel里面,而是显示在Game2048Page的Header部分,所以我们需要通过一种方式将最新的分数传递给Game2048Page。这里我们给Game2048Panel添加一个onScoreChanged的回调,可以在数字块合并后把变化后的分数传给父容器。
class Game2048PanelTest extends StatefulWidget {
/// 分数变化的回调
final ValueChanged<int>? onScoreChanged;
Game2048PanelTest({Key? key, this.onScoreChanged}) : super(key: key);
}
在父容器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();
}
});
},
)
)
],
)
保存历史最高分数用到了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;
});
}
}
重新开始游戏
不要忘了我们还有两个地方可以触发重新开始游戏,一个是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)
],
);
}
},
),
)
横屏下效果如图:
/ 结语 /
到这里这个Flutter版的2048小游戏就制作完成啦,边学边玩也是很有趣的体验。简单总结下这个小项目涉及的一些小知识点:
AspectRatio、OrientationBuilder、GridView等Widget的使用
Flutter 的手势识别
跨组件传递状态和调用其他组件的公开方法
祝大家学习愉快。
推荐阅读:
我的新书,《第一行代码 第3版》已出版!
带倒计时RecyclerView的设计心路历程
Android 12上焕然一新的小组件
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注
上一篇: 随机小游戏
下一篇: 用C++编写2048小游戏的简易版本
推荐阅读