文章目录
1. 前言
开发小游戏确实是个很棒的想法!如今,我们有多种选择来开发富客户端或应用程序(App)。跨平台是一个每个开发者都梦寐以求的功能。在众多选择中,Flutter 和 React-Native 无疑脱颖而出。我选择了 Flutter,因为我对 Java 非常熟悉,而 Java 与 Flutter 的编程语言 Dart 在语法方面相似。
在这篇文章中,我将逐步向你展示如何用 Flutter 开发一个 2048 游戏。
2. 2048 简介
2048 是一款风靡全球的滑动游戏。其玩法简洁明了:玩家通过上下左右滑动方块,目标是将数字不断叠加,最终获得2048的方块。
3. 准备
要开始这个小项目,首先需要准备一台电脑,并掌握 Dart 编程语言 的基础知识。
接着,请参考 Flutter安装指南配置开发环境.
最后, 选择你喜欢的IDE,我使用的是VSCode(Visual Studio Code
).
4. 编程
4.1 创建一个 Flutter 项目
打开终端,使用以下命令创建项目:
flutter create -e g2048
g2048
是当前项目的名称。
我们可以通过 VSCode 来创建这个项目。具体操作如下:打开 视图 > 命令面板
,输入 flutter
,选择 Flutter: 新建项目
,然后点击 空应用程序
选项。
至此,我们已经成功创建了一个 Hello World
应用程序。要查看运行效果,只需执行 flutter run
命令即可。
4.2 图形用户界面 (GUI)
我们将实现一个简单的图形用户界面(GUI)
2048 | Score
0
-----------
| |
| Board |
| (4x4) |
| |
-----------
用 Flutter的术语来表达:
有关 Flutter 的基本 UI 概念,请参阅 使用 Flutter 构建用户界面。
4.2.1 第一个Widget(StatusPane)
让我们来创建第一个无状态的小部件 StatusPane
。
文件: lib/src/status_pane.dart
import 'package:flutter/material.dart';
class StatusPane extends StatelessWidget {
const StatusPane({
super.key,
});
Widget build(BuildContext context) {
var theme = Theme.of(context).textTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text('2048', style: _textStyle(theme.displayLarge!)),
Column(
children: [
Text('SCORE', style: _textStyle(theme.bodyMedium!)),
// TODO score
Text('0', style: _textStyle(theme.displayMedium!)),
],
),
],
);
}
TextStyle _textStyle(TextStyle style) {
return style.copyWith(
color: Colors.brown,
fontWeight: FontWeight.bold,
);
}
}
4.2.2 第二个Widget (Board)
接下来,让我们创建另一个无状态的 Widget Board
。
文件: lib/src/board.dart
import 'package:flutter/material.dart';
class Board extends StatelessWidget {
const Board({super.key});
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
width: 374,
height: 374,
decoration: const BoxDecoration(
color: Colors.brown,
borderRadius: BorderRadius.all(Radius.circular(4 * 4)),
),
child: _board(theme),
);
}
Widget _board(ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < 4; i++)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var j = 0; j < 4; j++)
Tile(
num: 0, // TODO
theme: theme,
)
],
)
],
);
}
}
class Tile extends StatelessWidget {
const Tile({
super.key,
required this.num,
required this.theme,
});
final int num;
final ThemeData theme;
Widget build(BuildContext context) {
return Container(
width: 81,
height: 81,
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.brown.shade400,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: Align(
alignment: Alignment.center,
child: Text(
num > 0 ? '$num' : '',
style: theme.textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black54,
fontSize: theme.textTheme.displayMedium!.fontSize!,
),
),
),
);
}
}
4.2.3 程序入口 main.dart
让我们更新 main.dart
。然后运行 flutter run
或者在 VSCode 中点击 运行 > 运行而不调试
。
import 'package:flutter/material.dart';
import 'package:g2048/src/board.dart';
import 'package:g2048/src/status_pane.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: '2048',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StatusPane(),
SizedBox(height: 81 / 2),
Board(),
],
),
),
);
}
}
4.2.4 首次重构
我们已经创建了三个文件:
lib/main.dart
lib/src/status_pane.dart
lib/src/board.dart
这些文件中包含多个公共常量值。建议将这些常量统一定义在一个可被其他文件引用的文件中。这种做法不仅能避免硬编码,还能提高代码的可维护性和复用性。
文件 lib/src/constants.dart
import 'package:flutter/material.dart';
const kTileSize = 81.0;
const kMainColor = Colors.brown;
const kMargin = 4.0;
修改文件 lib/src/status_pane.dart (第29行)
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
class StatusPane extends StatelessWidget {
const StatusPane({
super.key,
});
Widget build(BuildContext context) {
var theme = Theme.of(context).textTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text('2048', style: _textStyle(theme.displayLarge!)),
Column(
children: [
Text('SCORE', style: _textStyle(theme.bodyMedium!)),
// TODO score
Text('0', style: _textStyle(theme.displayMedium!)),
],
),
],
);
}
TextStyle _textStyle(TextStyle style) {
return style.copyWith(
color: kMainColor,
fontWeight: FontWeight.bold,
);
}
}
修改文件 note lib/src/board.dart (lines=14 15 56 59)
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
class Board extends StatelessWidget {
const Board({super.key});
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
width: 374,
height: 374,
decoration: const BoxDecoration(
color: kMainColor,
borderRadius: BorderRadius.all(Radius.circular(4 * kMargin)),
),
child: _board(theme),
);
}
Widget _board(ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < 4; i++)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var j = 0; j < 4; j++)
Tile(
num: 0, // TODO
theme: theme,
)
],
)
],
);
}
}
class Tile extends StatelessWidget {
const Tile({
super.key,
required this.num,
required this.theme,
});
final int num;
final ThemeData theme;
Widget build(BuildContext context) {
return Container(
width: kTileSize,
height: kTileSize,
margin: const EdgeInsets.all(kMargin),
decoration: BoxDecoration(
color: Colors.brown.shade400,
borderRadius: const BorderRadius.all(Radius.circular(kMargin)),
),
child: Align(
alignment: Alignment.center,
child: Text(
num > 0 ? '$num' : '',
style: theme.textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black54,
fontSize: theme.textTheme.displayMedium!.fontSize!,
),
),
),
);
}
}
修改文件 lib/main.dart (lines=19 38)
import 'package:flutter/material.dart';
import 'package:g2048/src/board.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/status_pane.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: '2048',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kMainColor),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StatusPane(),
SizedBox(height: kTileSize / 2),
Board(),
],
),
),
);
}
}
4.3 游戏状态
我们刚刚完成了GUI的第一稿。现在是时候考虑游戏状态了,也就是这个应用的模型部分。
4.3.1 定义类型
为了方便起见,我们先定义2个类型, Model 和 Point。
文件 lib/src/types.dart
typedef Model = List<List<int>>;
typedef Point = ({int x, int y});
4.3.2 思考App的状态
现在让我们思考一下这个应用的状态。
- 数据
- score (分数)
- (4x4) 数字 (Model)
- 改变上述数据的行为(方法) :
- 向左/向右/向上/向下滑动
- 重启游戏
- UI相关的功能
- 第
i
行 第j
列的数字
- 第
然后我们得出了下面的图表(类图)。
4.3.3 game_state.dart(草稿)
文件 lib/src/game_state.dart
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/types.dart';
const _rank = 4;
class GameState extends ChangeNotifier {
GameState()
: _model = List.generate(
_rank,
(_) => List.filled(_rank, 0, growable: false),
growable: false,
) {
_init();
}
int get size => _rank;
double get boardSize => size * (kTileSize + 3 * kMargin);
final Model _model;
int score = 0;
bool done = false;
void _init() {
_model[size - 1][0] = 2;
_model[size - 2][0] = 2;
}
void _reset() {
for (var i = 0; i < size; i++) {
for (var j = 0; j < size; j++) {
_model[i][j] = 0;
}
}
score = 0;
done = false;
}
void restart() {
_reset();
_init();
notifyListeners();
}
int num(int i, int j) => _model[i][j];
void swipeLeft() {
// TODO
}
void swipeRight() {
// TODO
}
void swipeUp() {
// TODO
}
void swipeDown() {
// TODO
}
}
4.3.4 向Widgets注入 GameState
我们将使用 provider
来管理对象依赖。请阅读文章 简单的应用状态管理 以了解更多信息。
运行以下命令来添加 provider
包。
flutter pub add provider
修改文件 “lib/main.dart” (lines=“4 6 26 27 28”)
import 'package:flutter/material.dart';
import 'package:g2048/src/board.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_state.dart';
import 'package:g2048/src/status_pane.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: '2048',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kMainColor),
useMaterial3: true,
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.transparent,
)),
home: ChangeNotifierProvider(
create: (context) => GameState(),
child: const HomeScreen(),
),
);
}
}
// class HomeScreen extends StatelessWidget {
// ... ...
修改文件 “lib/src/status_pane.dart” (lines=“3 4 13 22”)
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_state.dart';
import 'package:provider/provider.dart';
class StatusPane extends StatelessWidget {
const StatusPane({
super.key,
});
Widget build(BuildContext context) {
var state = context.watch<GameState>();
var theme = Theme.of(context).textTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text('2048', style: _textStyle(theme.displayLarge!)),
Column(
children: [
Text('SCORE', style: _textStyle(theme. bodyMedium!)),
Text('${state.score}', style: _textStyle(theme. displayMedium!)),
],
),
],
);
}
TextStyle _textStyle(TextStyle style) {
return style.copyWith(
color: kMainColor,
fontWeight: FontWeight.bold,
);
}
}
修改文件 “lib/src/board.dart” (lines=“3 4 11 14 15 20 27 31 33”)
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_state.dart';
import 'package:provider/provider.dart';
class Board extends StatelessWidget {
const Board({super.key});
Widget build(BuildContext context) {
var state = context.watch<GameState>();
var theme = Theme.of(context);
return Container(
width: state.boardSize,
height: state.boardSize,
decoration: const BoxDecoration(
color: kMainColor,
borderRadius: BorderRadius.all(Radius.circular(4 * kMargin)),
),
child: _board(state, theme));
}
Widget _board(GameState state, ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < state.size; i++)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var j = 0; j < state.size; j++)
Tile(
num: state.num(i, j),
theme: theme,
),
],
)
],
);
}
}
// class Tile extends StatelessWidget {
// ... ...
```
### 4.4 核心逻辑
核心逻辑由以下方法实现。核心逻辑由以下方法实现。
```mermaid
classDiagram
class GameState{
+swipeLeft()
+swipeRight()
+swipeUp()
+swipeDown()
}
以 swipeLeft()
方法为例,我们至少需要做五件事:以 swipeLeft()
方法为例,我们至少需要做五件事:
_moveZeros
: 将所有非零数字移到左侧_mergeNumbers
: 将相邻的非零数字从左到右合并成一个更大的数字(如果它们的值相同)- 累加分数
_nextNum
:生成下一个数字 (2 or 4)_checkDone
: 检查游戏是否结束
4.4.1 Helper class Nums
在实现 swipeXxx
方法之前,我们先定义一个辅助类 Nums
。
/// The view of the numbers in a row or a column
class Nums {
Nums(this.model, this.which, {this.column = false});
final Model model;
/// Which row or column
final int which;
final bool column;
int get length => column ? model[0].length : model.length;
int operator [](int k) => column ? model[k][which] : model[which][k];
operator []=(int k, int value) {
if (column) {
model[k][which] = value;
} else {
model[which][k] = value;
}
}
}
4.4.2 _nums
方法
Nums _numsAtColumn(int j) => _nums(j, column: true);
Nums _nums(int which, {bool column = false}) =>
Nums(_model, which, column: column);
4.4.3 _moveZeros
方法
我们举例来说明如何移动数字0:
0 2 0 4 swipeLeft --> 2 4 0 0
0 2 0 4 swipeRight --> 0 0 2 4
0 4
4 swipeUp 8
0 ----> 0
8 0
0 0
4 swipeDown 0
0 ----> 4
8 8
代码如下:
/// Move the non-zero numbers to the left side if [reverse] is false,
/// or to the right side if [reverse] is true
List<int> _moveZeros(Nums nums, {bool reverse = false}) {
var moves = List.filled(nums.length, 0, growable: false);
if (reverse) {
// TODO
} else {
for (var k = 1; k < nums.length; k++) {
if (nums[k] == 0) continue;
var i = k - 1;
for (; i >= 0 && nums[i] == 0; i--) {}
var count = (k - 1) - i;
if (count > 0) {
nums[i + 1] = nums[k];
nums[k] = 0;
moves[i + 1] = count;
}
}
}
return moves;
}
4.4.4 _mergeNumbers
方法
同样, 举例说明:
2 2 2 4 swipeLeft --> 4 2 4 0
2 2 2 4 swipeRight --> 0 2 4 4
0 8
4 swipeUp 0
0 ----> 0
4 0
0 0
4 swipeDown 0
0 ----> 0
4 8
代码如下:
/// Merge the adjacent non-zero number into a bigger one,
/// from left to right if [reserve] is false,
/// or from right to left if [reserve] is true.
/// Return the score to be accumulated.
int _mergeNumbers(Nums nums, {bool reserve = false}) {
var gotScore = 0;
if (reserve) {
// TODO
} else {
for (var k = 0; k < nums.length - 1; k++) {
if (nums[k] == 0) continue;
if (nums[k] == nums[k + 1]) {
nums[k] *= 2;
nums[k + 1] = 0;
gotScore += nums[k];
}
}
}
return gotScore;
}
4.4.5 _nextNum
方法
// import 'dart:math' as math;
static final _rand = math.Random();
Point? _newPostion;
void _nextNum() {
List<Point> points = [];
for (var i = 0; i < size; i++) {
for (var j = 0; j < size; j++) {
if (0 == _model[i][j]) points.add((x: i, y: j));
}
}
if (points.isEmpty) return;
var p = points[_rand.nextInt(points.length)];
_model[p.x][p.y] = _rand.nextDouble() < 0.1 ? 4 : 2;
_newPostion = p;
}
4.4.6 _checkDone
方法
void _checkDone() {
for (var k = 0; k < size; k++) {
if (!_isDone(_nums(k)) || !_isDone(_numsAtColumn(k))) {
return;
}
}
done = true;
}
bool _isDone(Nums nums) {
for (var k = 0; k < nums.length; k++) {
if (0 == nums[k] || (k > 0 && nums[k - 1] == nums[k])) {
return false;
}
}
return true;
}
4.4.7 swipeLeft
方法
void swipeLeft() {
_swipe(_swipeLeft);
}
bool _swipeLeft(final int i) {
var hasMoved = false;
var moves = _moveZeros(_nums(i));
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
}
return hasMoved;
}
void _swipe(bool Function(int) swipeAction) async {
// move zeros
_resetNewPosition();
var hasMoved = false;
for (var i = 0; i < size; i++) {
hasMoved |= swipeAction(i);
}
if (hasMoved) notifyListeners();
// merge numbers
var gotScore = 0;
for (var k = 0; k < size; k++) {
final vertical = swipeAction == _swipeUp || swipeAction == _swipeDown;
var nums = _nums(k, column: vertical);
gotScore += _mergeNumbers(
nums,
reserve: swipeAction == _swipeRight || swipeAction == _swipeDown,
);
swipeAction(k);
}
// score & next number
if (hasMoved || gotScore > 0) {
score += gotScore;
_nextNum();
_checkDone();
notifyListeners();
}
}
void _resetNewPosition() {
_newPostion = null;
}
4.4.8 其他 swipe
方法
我们使用与 swipeLeft
相同的方法来实现:
swipeRight
swipeUp
swipeDown
See 4.5.7 实现缺失的方法
4.5 回到GUI
我们需要为GUI实现一些功能或小部件。
Swipeable
:一个帮助向左/右/上/下滑动的小部件,这是必不可少的SlideWidget
:一个实现滑动方块(数字)的滑动动画的小部件TwinkleWidget
:一个实现新生成数字的动画功能的小部件GameOver
:一个仅在游戏结束时显示的小部件,其中包含一个Restart
按钮- 方块(数字)的字体颜色、字体大小和背景颜色我们需要为GUI实现一些功能或小部件。
4.5.1 Widget Swipeable
文件 “lib/src/style/swipeable.dart”
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class Swipeable extends StatelessWidget {
const Swipeable({
super.key,
required this.child,
required this.size,
this.onSwipeLeft,
this.onSwipeRight,
this.onSwipeUp,
this.onSwipeDown,
});
final Widget child;
final double size;
final VoidCallback? onSwipeLeft;
final VoidCallback? onSwipeRight;
final VoidCallback? onSwipeUp;
final VoidCallback? onSwipeDown;
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
child,
Dismissible(
key: Key('${key?.toString()}-Dismissible-horizontal'),
direction: DismissDirection.horizontal,
confirmDismiss: (direction) {
switch (direction) {
case DismissDirection.endToStart:
onSwipeLeft?.call();
case DismissDirection.startToEnd:
onSwipeRight?.call();
case _:
;
}
return Future.value(false);
},
child: Dismissible(
key: Key('${key?.toString()}-Dismissible-vertical'),
direction: DismissDirection.vertical,
confirmDismiss: (direction) {
switch (direction) {
case DismissDirection.up:
onSwipeUp?.call();
case DismissDirection.down:
onSwipeDown?.call();
case _:
;
}
return Future.value(false);
},
child: SizedBox.square(dimension: size),
),
),
],
);
}
}
4.5.2 Widget SlideWidget
文件 “lib/src/style/slide_widget.dart”
import 'package:flutter/material.dart';
class SlideWidget extends StatefulWidget {
const SlideWidget({
super.key,
required this.child,
this.offset = const Offset(1, 0.0),
this.duration = const Duration(milliseconds: 500),
});
final Widget child;
final Duration duration;
final Offset offset;
State<SlideWidget> createState() => _SlideWidgetState();
}
class _SlideWidgetState extends State<SlideWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
void initState() {
_controller = AnimationController(
duration: widget.duration,
vsync: this,
)..forward();
_offsetAnimation = Tween(
begin: widget.offset,
end: Offset.zero,
).animate(_controller);
super.initState();
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return SlideTransition(
position: _offsetAnimation,
child: widget.child,
);
});
}
}
4.5.3 Widget TwinkleWidget
文件 “lib/src/style/twinkle_widget.dart”
import 'package:flutter/material.dart';
class TwinkleWidget extends StatefulWidget {
const TwinkleWidget({
super.key,
this.begin = 1,
this.end = 0.5,
this.speed = const Duration(milliseconds: 1000),
this.repeat = true,
required this.child,
});
final double begin;
final double end;
final Duration speed;
final Widget child;
final bool repeat;
State<TwinkleWidget> createState() => _TwinkleWidgetState();
}
class _TwinkleWidgetState extends State<TwinkleWidget>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late Animation<double> opacity;
void initState() {
super.initState();
controller = AnimationController(
duration: widget.speed,
vsync: this,
);
if (widget.repeat) {
controller.repeat(reverse: true);
} else {
controller.forward();
}
opacity = Tween(
begin: widget.begin,
end: widget.end,
).animate(controller);
}
void dispose() {
controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) => Opacity(
opacity: opacity.value,
child: widget.child,
),
);
}
}
4.5.4 Widget GameOver
文件 “lib/src/game_over.dart”
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_state.dart';
import 'package:provider/provider.dart';
class GameOver extends StatelessWidget {
const GameOver({super.key});
Widget build(BuildContext context) {
var state = context.watch<GameState>();
var theme = Theme.of(context).textTheme;
return Visibility(
visible: state.done,
child: SizedBox(
width: state.boardSize,
height: state.boardSize,
child: Container(
color: Colors.black38,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'GAME OVER',
style: theme.displaySmall!.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
IconButton(
icon: const Icon(Icons.restart_alt_outlined),
iconSize: kTileSize,
onPressed: () => state.restart(),
color: Colors.white70,
)
],
),
),
),
);
}
}
4.5.5 Widget Tile
接下来让我们调整图标的字体颜色、字体大小和背景颜色。
文件 “Widget Tile
”
class Tile extends StatelessWidget {
const Tile({
super.key,
required this.num,
required this.isNew,
required this.offset,
required this.theme,
});
final int num;
final bool isNew;
final Offset offset;
final ThemeData theme;
Widget build(BuildContext context) {
var w = Container(
width: kTileSize,
height: kTileSize,
margin: const EdgeInsets.all(kMargin),
decoration: BoxDecoration(
color: _bgColor,
borderRadius: const BorderRadius.all(Radius.circular(kMargin)),
),
child: Align(
alignment: Alignment.center,
child: Text(
num > 0 ? '$num' : '',
style: theme.textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.bold,
color: _fontColor,
fontSize: _fontSize,
),
),
),
);
if (num <= 0) return w;
return SlideWidget(
key: Key('${key?.toString()}-${DateTime.now().microsecond}'),
offset: offset,
duration: const Duration(milliseconds: kSlideMilliseconds),
child: TwinkleWidget(
begin: isNew ? 0.5 : 1.0,
end: 1.0,
repeat: false,
speed: const Duration(milliseconds: kTwinkleMilliseconds),
child: w,
),
);
}
Color get _bgColor => switch (num) {
-1 => kMainColor.shade400,
0 => Colors.transparent,
2 => kMainColor.shade200,
4 => Colors.indigoAccent.shade100,
8 => Colors.lightBlue.shade500,
16 => Colors.cyan.shade500,
32 => Colors.deepOrange.shade500,
64 => Colors.deepPurple.shade500,
128 => Colors.green.shade500,
256 => Colors.indigo.shade500,
512 => Colors.lime.shade500,
1024 => Colors.orangeAccent.shade400,
2048 => Colors.pinkAccent.shade200,
4096 => Colors.tealAccent.shade400,
8192 => Colors.yellow.shade500,
_ => kMainColor.shade600,
};
Color get _fontColor => num > 4 ? Colors.white70 : Colors.black54;
double? get _fontSize {
if (num < 100) return null;
var s = theme.textTheme.displayMedium!.fontSize!;
return 2.5 * s / '$num'.length;
}
}
4.5.6 修改相关代码
文件 “lib/src/constants.dart”
import 'package:flutter/material.dart';
const kTwinkleMilliseconds = 400;
const kSlideMilliseconds = 100;
const kTileSize = 81.0;
const kMainColor = Colors.brown;
const kMargin = 4.0;
文件 “lib/src/board.dart”
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_state.dart';
import 'package:g2048/src/style/slide_widget.dart';
import 'package:g2048/src/style/twinkle_widget.dart';
import 'package:g2048/src/style/swipeable.dart';
import 'package:provider/provider.dart';
class Board extends StatelessWidget {
const Board({super.key});
Widget build(BuildContext context) {
var state = context.watch<GameState>();
var theme = Theme.of(context);
return Container(
width: state.boardSize,
height: state.boardSize,
decoration: const BoxDecoration(
color: kMainColor,
borderRadius: BorderRadius.all(Radius.circular(4 * kMargin)),
),
child: Stack(children: [
_boardBackground(state, theme),
_board(state, theme),
]));
}
Widget _board(GameState state, ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < state.size; i++)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var j = 0; j < state.size; j++)
Swipeable(
key: Key('tile-$i-$j'),
onSwipeLeft: state.swipeLeft,
onSwipeRight: state.swipeRight,
onSwipeUp: state.swipeUp,
onSwipeDown: state.swipeDown,
size: kTileSize,
child: Tile(
num: state.num(i, j),
isNew: state.isNewPosition(i, j),
offset: state.slideOffset(i, j),
theme: theme,
),
)
],
)
],
);
}
Widget _boardBackground(GameState state, ThemeData theme) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < state.size; i++)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var j = 0; j < state.size; j++)
Tile(
key: Key('backgroud-$i-$j'),
num: -1,
isNew: false,
offset: Offset.zero,
theme: theme,
)
],
)
],
);
}
}
// class Tile extends StatelessWidget {
// ... ...
文件 “lib/main.dart”
import 'package:flutter/material.dart';
import 'package:g2048/src/board.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/game_over.dart';
import 'package:g2048/src/game_state.dart';
import 'package:g2048/src/status_pane.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: '2048',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kMainColor),
useMaterial3: true,
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.transparent,
)),
home: ChangeNotifierProvider(
create: (context) => GameState(),
child: const HomeScreen(),
),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StatusPane(),
SizedBox(height: kTileSize / 2),
Stack(children: [
Board(),
GameOver(),
]),
],
),
),
);
}
}
4.5.7 实现缺失的方法
如你所见,方法 GameState.slideOffset(i,j)
和 GameState.isNewPosition(x,y)
尚未创建。因此,IDE 提示有错误。让我们来fix它。
// ... ...
class GameState extends ChangeNotifier {
GameState()
: _model = List.generate(
_rank,
(_) => List.filled(_rank, 0, growable: false),
growable: false,
),
_offsets = List.generate(
_rank,
(_) => List.filled(_rank, Offset.zero, growable false),
growable: false,
) {
_init();
}
final List<List<Offset>> _offsets;
Offset slideOffset(int i, int j) => _offsets[i][j];
bool _swipeLeft(final int i) {
var hasMoved = false;
var moves = _moveZeros(_nums(i));
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
_offsets[i][k] = Offset(moves[k].toDouble(), 0);
}
return hasMoved;
}
bool isNewPosition(int x, int y) {
return _newPostion == (x: x, y: y);
}
// ... ...
}
完整的 “lib/src/game_state.dart”
import 'package:flutter/material.dart';
import 'package:g2048/src/constants.dart';
import 'package:g2048/src/types.dart';
import 'dart:math' as math;
const _rank = 4;
class GameState extends ChangeNotifier {
GameState()
: _model = List.generate(
_rank,
(_) => List.filled(_rank, 0, growable: false),
growable: false,
),
_offsets = List.generate(
_rank,
(_) => List.filled(_rank, Offset.zero, growable: false),
growable: false,
) {
_init();
}
int get size => _rank;
double get boardSize => size * (kTileSize + 3 * kMargin);
final Model _model;
final List<List<Offset>> _offsets;
int score = 0;
bool done = false;
void _init() {
_model[size - 1][0] = 2;
_model[size - 2][0] = 2;
}
void _reset() {
for (var i = 0; i < size; i++) {
for (var j = 0; j < size; j++) {
_model[i][j] = 0;
_offsets[i][j] = Offset.zero;
}
}
score = 0;
done = false;
}
void restart() {
_reset();
_init();
notifyListeners();
}
int num(int i, int j) => _model[i][j];
Offset slideOffset(int i, int j) => _offsets[i][j];
void swipeLeft() {
_swipe(_swipeLeft);
}
void swipeRight() {
_swipe(_swipeRight);
}
void swipeUp() {
_swipe(_swipeUp);
}
void swipeDown() {
_swipe(_swipeDown);
}
void _swipe(bool Function(int) swipeAction) async {
// move zeros
_resetNewPosition();
var hasMoved = false;
for (var i = 0; i < size; i++) {
hasMoved |= swipeAction(i);
}
if (hasMoved) notifyListeners();
// merge numbers
await _sleep(kSlideMilliseconds);
var gotScore = 0;
for (var k = 0; k < size; k++) {
final vertical = swipeAction == _swipeUp || swipeAction == _swipeDown;
var nums = _nums(k, column: vertical);
gotScore += _mergeNumbers(
nums,
reserve: swipeAction == _swipeRight || swipeAction == _swipeDown,
);
swipeAction(k);
}
// score & next number
if (hasMoved || gotScore > 0) {
score += gotScore;
_nextNum();
_checkDone();
notifyListeners();
}
}
void _checkDone() {
for (var k = 0; k < size; k++) {
if (!_isDone(_nums(k)) || !_isDone(_numsAtColumn(k))) {
return;
}
}
done = true;
}
bool _isDone(Nums nums) {
for (var k = 0; k < nums.length; k++) {
if (0 == nums[k] || (k > 0 && nums[k - 1] == nums[k])) {
return false;
}
}
return true;
}
void _resetNewPosition() {
_newPostion = null;
}
Future<void> _sleep(int milliseconds) async {
await Future.delayed(Duration(milliseconds: milliseconds));
}
bool _swipeLeft(final int i) {
var hasMoved = false;
var moves = _moveZeros(_nums(i));
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
_offsets[i][k] = Offset(moves[k].toDouble(), 0);
}
return hasMoved;
}
bool _swipeRight(final int i) {
var hasMoved = false;
var moves = _moveZeros(_nums(i), reverse: true);
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
_offsets[i][k] = Offset(-moves[k].toDouble(), 0);
}
return hasMoved;
}
bool _swipeUp(final int j) {
var hasMoved = false;
var nums = _numsAtColumn(j);
var moves = _moveZeros(nums);
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
_offsets[k][j] = Offset(0, moves[k].toDouble());
}
return hasMoved;
}
bool _swipeDown(final int j) {
var hasMoved = false;
var nums = _numsAtColumn(j);
var moves = _moveZeros(nums, reverse: true);
for (var k = 0; k < size; k++) {
hasMoved |= moves[k] > 0;
_offsets[k][j] = Offset(0, -moves[k].toDouble());
}
return hasMoved;
}
/// Merge the adjacent non-zero number into a bigger one,
/// from left to right if [reserve] is `false`,
/// or from right to left if [reserve] is `true`.
/// Return the score to be accumulated.
int _mergeNumbers(Nums nums, {bool reserve = false}) {
var gotScore = 0;
if (reserve) {
for (var k = nums.length - 1; k > 0; k--) {
if (nums[k] == 0) continue;
if (nums[k] == nums[k - 1]) {
nums[k] *= 2;
nums[k - 1] = 0;
gotScore += nums[k];
}
}
} else {
for (var k = 0; k < nums.length - 1; k++) {
if (nums[k] == 0) continue;
if (nums[k] == nums[k + 1]) {
nums[k] *= 2;
nums[k + 1] = 0;
gotScore += nums[k];
}
}
}
return gotScore;
}
/// Move the non-zero numbers to the left side if [reverse] is false,
/// or to the right side if [reverse] is `true`
List<int> _moveZeros(Nums nums, {bool reverse = false}) {
var moves = List.filled(nums.length, 0, growable: false);
if (reverse) {
for (var k = nums.length - 2; k >= 0; k--) {
if (nums[k] == 0) continue;
var i = k + 1;
for (; i < nums.length && nums[i] == 0; i++) {}
var count = i - (k + 1);
if (count > 0) {
nums[i - 1] = nums[k];
nums[k] = 0;
moves[i - 1] = count;
}
}
} else {
for (var k = 1; k < nums.length; k++) {
if (nums[k] == 0) continue;
var i = k - 1;
for (; i >= 0 && nums[i] == 0; i--) {}
var count = (k - 1) - i;
if (count > 0) {
nums[i + 1] = nums[k];
nums[k] = 0;
moves[i + 1] = count;
}
}
}
return moves;
}
static final _rand = math.Random();
Point? _newPostion;
void _nextNum() {
List<Point> points = [];
for (var i = 0; i < size; i++) {
for (var j = 0; j < size; j++) {
if (0 == _model[i][j]) points.add((x: i, y: j));
}
}
if (points.isEmpty) return;
var p = points[_rand.nextInt(points.length)];
_model[p.x][p.y] = _rand.nextDouble() < 0.1 ? 4 : 2;
_newPostion = p;
}
bool isNewPosition(int x, int y) {
return _newPostion == (x: x, y: y);
}
Nums _numsAtColumn(int j) => _nums(j, column: true);
Nums _nums(int which, {bool column = false}) =>
Nums(_model, which, column: column);
}
/// The view of the numbers in a row or a column
class Nums {
Nums(this.model, this.which, {this.column = false});
final Model model;
/// Which row or column
final int which;
final bool column;
int get length => column ? model[0].length : model.length;
int operator [](int k) => column ? model[k][which] : model [which][k];
operator []=(int k, int value) {
if (column) {
model[k][which] = value;
} else {
model[which][k] = value;
}
}
}
这便是所有的代码了。
5. 其他功能
在一个完整的游戏中,还需要音效、分数存储等功能,本文并没有实现相关功能。我们可以使用audioplayers 包来给游戏添加音效;鉴于该包的使用较为简单,本文不再赘述其使用细节。
6. 结束语
掌握一门新的编程语言或框架(开发工具包)往往充满挑战。编写一个小游戏是一个很好的开始,通过游戏开发,我们能够更好地理解编程的核心概念。游戏2048很容易理解,这就是为什么我选择它作为在Flutter编程练习的例子。为了更清晰地阐述我的思路,我精心绘制了多张图表,希望这些图表能帮助你更好地理解本文中的代码。
Here is the full source code g2048.