用Flutter构建一个2048小游戏

1. 前言

开发小游戏确实是个很棒的想法!如今,我们有多种选择来开发富客户端或应用程序(App)。跨平台是一个每个开发者都梦寐以求的功能。在众多选择中,Flutter 和 React-Native 无疑脱颖而出。我选择了 Flutter,因为我对 Java 非常熟悉,而 Java 与 Flutter 的编程语言 Dart 在语法方面相似。

在这篇文章中,我将逐步向你展示如何用 Flutter 开发一个 2048 游戏。

2. 2048 简介

2048 是一款风靡全球的滑动游戏。其玩法简洁明了:玩家通过上下左右滑动方块,目标是将数字不断叠加,最终获得2048的方块。

2048_intro

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 列的数字

然后我们得出了下面的图表(类图)。

context.watch()
context.watch()
GameState
-Model model
+int score
+bool done
+num(int i, int j)
+swipeLeft()
+swipeRight()
+swipeUp()
+swipeDown()
+restart()
ChangeNotifier
+notifyListeners()
StatusPane
Board
Widget
StatelessWidget
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

Nums
-final Model model
-final int which
-final bool column
+int get length
+int operator [ ]
+operator [ ]=
/// 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实现一些功能或小部件。
Swipeable
+final VoidCallback? onSwipeLeft
+final VoidCallback? onSwipeRight
+final VoidCallback? onSwipeUp
+final VoidCallback? onSwipeDown
+final double size
+final Widget child
SlideWidget
+final Duration duration;
+final Offset offset;
+final Widget child
TwinkleWidget
+final double begin;
+final double end;
+final Duration speed;
+final bool repeat;
+final Widget child;
StatelessWidget
GameOver
StatefulWidget
Widget
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.

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CODE CAMPING

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值