Flutter Web实战:用Trae从零开发2048小游戏(含核心算法)
2026/9/15 10:48:07 网站建设 项目流程

最近我用 Trae 把一个经典的 2048 复刻到了 Flutter Web 上,从创建工程到核心算法、手势交互、打包部署,整个流程走下来非常顺。这篇文章就把我的完整实现思路和可运行代码整理出来,包含核心合并算法、界面渲染、键盘与滑动手势,以及 Flutter Web 开发里常见的几个坑。适合正在学 Flutter、想用 AI 编程工具做点实物的同学参考,代码逻辑也很适合作为入门小游戏的练手项目。

1. 项目定位:2048 小游戏为什么适合用 Flutter Web 做

1.1 选 Trae、Flutter、Web 三方组合的理由

先聊选型。2048 这个游戏规则简单,但内部藏着数组操作、状态管理、手势识别、渲染刷新这些基本功,是练习 Flutter Web 非常合适的项目。相比写一个商城页面或者后台管理系统,2048 不用处理网络请求、不用设计复杂的数据模型,可以专心把交互和状态玩明白。

Trae 在这个项目里的角色是“AI 编程伙伴”。我用它来生成初始代码、补全重复性界面代码、分析报错信息。实际体验下来,它能省掉很多查文档的时间,特别是在生成网格布局、动画容器这类样板代码时,效率提升很明显。但要注意,AI 生成的代码不等于可靠代码,核心算法必须自己 review,这也是我把 2048 当作练手项目的原因之一:你可以放心大胆地让 Trae 写代码,然后自己再去做逻辑验证,这个过程本身就很能锻炼工程判断力。

Flutter Web 的优势在于一套代码可以同时跑在浏览器、桌面和移动端,开发时用热重载改样式特别舒服。虽然首屏加载体积比纯 HTML/JS 大,但这种轻量小游戏根本感觉不到,作为学习项目完全够用。

1.2 把 2048 的游戏规则拆成可编码的模块

2048 的核心规则可以分成四个部分:

  • 一个 4x4 网格,初始时随机出现两个数字方块,通常是 2 或 4。
  • 玩家通过滑动(或方向键)让所有方块朝一个方向移动,相同数字相遇时合并成它们的和,每个数字每一轮只能参与一次合并。
  • 每次有效移动后,会在空格子里随机生成一个新方块。
  • 当任意方块达到 2048 时玩家获胜,当网格被填满且上下左右都没有相邻相同数字时游戏结束。

这四句话看起来简单,翻译成代码时却是两个完全不同的模块。一个是“游戏状态”,负责存储矩阵数据、分数、游戏是否结束;另一个是“交互表现”,负责接收手势或键盘事件、渲染 4x4 网格、播放简单动画。

我从一开始就把这两个模块分开。游戏状态部分独立成一个不依赖 Flutter 的 Dart 类,这样不仅可以在 Dart 侧做单元测试,以后想换 UI、接 AI 自动演示、或者改成命令行版都特别方便。很多新手容易把数据逻辑直接写进 Widget 里,结果改一个功能要翻半天代码,这个教训我踩过。

1.3 整体数据流与状态管理方式

整个应用的数据流其实是一条直线:用户滑动屏幕,手势回调通知 Game 对象执行移动操作,Game 内部更新二维数组和分数,最后通知 Flutter 界面重新渲染。

状态管理我选了最简单的 ChangeNotifier 思路:Game 对象持有一份 4x4 数据,界面通过 setState 触发重建。这个项目规模不大,用不到 Riverpod 或 Bloc 这种重型方案,否则光学习状态管理框架的成本就超过了游戏本身。等你想给项目加“撤销上一步”或“自动演示”功能时,再引入更系统的状态管理也不迟。

2. 环境准备:Trae 与 Flutter Web 开发环境搭建

2.1 安装 Flutter 并启用 Web 支持

环境搭建这个环节看似基础,但第一次做 Flutter Web 的人经常会卡在这里。我列一下我的操作步骤。

先下载 Flutter SDK,解压到指定目录后把 bin 目录加入系统 PATH。然后打开终端执行flutter doctor,它会帮你检查 Flutter 依赖的环境是否齐全。如果你附近的网络下载官方 SDK 比较慢,可以配置国内的镜像源,主要是两个环境变量:PUB_HOSTED_URLFLUTTER_STORAGE_BASE_URL,指向可用的镜像地址即可。

接着执行:

flutter config --enable-web flutter doctor

看到 Chrome 那一项正常就可以继续了。注意,flutter doctor里经常会提示 Visual Studio 找不到或者 Android toolchain 缺失,这不是 Web 开发必需的,尤其是“unable to find suitable visual studio toolc”这种提示,只有在你要构建 Windows 桌面端的时候才需要处理,做 Web 版可以直接忽略。

版本方面建议直接用稳定版。Flutter 的版本迭代很快,有些 API 在不同版本间有差异,我后面讲代码时也会提到几个典型的版本坑。

2.2 配置 Trae:安装 Flutter 插件与调试器

Trae 本身是一个 AI IDE,内置了代码补全和对话功能,但 Flutter 相关的扩展还是需要装一下。进入插件市场搜索“Flutter”和“Dart”,安装后 Trae 会自动识别本机的 Flutter SDK。

这里有一个关键配置:要确保 Trae 右下角显示的 Flutter SDK 路径正确。如果之前机器上装过多个 Flutter 版本,路径指错了会出现莫名其妙的问题。另外建议在 Trae 的设置里把默认终端切换成系统终端,这样能直接使用你配置好的 PATH 环境变量,避免终端里找不到flutter命令。

Web 调试目标也要提前选好,我一般直接用 Chrome。运行项目时用内置终端执行:

flutter run -d chrome

第一次运行会做 Web SDK 的编译,需要等一会儿,之后再热重载就快很多了。

2.3 创建项目与代码目录规划

用命令创建工程时,我特意指定了平台,避免生成一堆用不到的原生工程目录:

flutter create --platforms=web game_2048

生成的 lib 目录里我会按这样的结构组织代码:

  • main.dart:应用入口和主界面布局。
  • game_logic.dart:2048 核心逻辑,包括数据结构、移动合并、随机生成和胜负判断,不依赖 Flutter。
  • board_widget.dart:棋盘网格渲染组件。

这个分层的好处是游戏逻辑可以脱离 UI 单独测试,我在改 UI 配色的时候完全不用担心搞坏算法。

3. 核心算法与 UI 实现细节

3.1 4x4 网格的数据结构与运动规则

我直接用List<List<int>>表示整个棋盘,外层是行,内层是列,空位用 0 表示。这样一个二维数组就能描述全部游戏状态。

真正的难点在于“滑动”这个操作。看似四个方向,本质只有一种操作:把一排非零数字按顺序压缩到一侧,并且相邻且相同的数字合并一次。比如一行[0, 2, 2, 4]向左移动后应该是[4, 4, 0, 0],向右移动后是[0, 0, 4, 4]

四个方向可以分别写四个函数,但那样代码冗余,还容易在某个方向写错逻辑。更优雅的做法是利用矩阵旋转:把矩阵旋转到某个角度后,四次方向都统一成“左移”,执行完再旋转回去。

比如上移操作就是把棋盘顺时针旋转 90 度,执行左移,再逆时针旋转 90 度恢复。右移则可以先把每一行反转,左移,再反转回来。这样核心算法只需要写一个左移和一个旋转函数,既好理解,也方便测试。

3.2 合并逻辑与常见陷阱

合并逻辑是这个小游戏里最容易写错的地方。先从一维行处理开始:

List<int> _mergeLine(List<int> line) { final nums = line.where((e) => e != 0).toList(); final result = <int>[]; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); score += nums[i] * 2; i += 2; } else { result.add(nums[i]); i += 1; } } while (result.length < 4) { result.add(0); } return result; }

这段代码的注意力集中在第 6 行的条件判断。当两个相邻数字相等时,合并成它们的和,然后下标跳过两个数。这样做避免了经典的“连消”错误:比如[2, 2, 2, 2]向左移动,正确结果是[4, 4, 0, 0],而不是[8, 0, 0, 0]。如果不用标记数组,也不跳下标,就会出现一个方块被反复合并的问题。

原始的数字 2048 规则里,每个方块每轮只能合并一次,所以这种“从左到右两两配对”的算法是最稳妥的。做完一维合并,再借助旋转处理二维棋盘。

矩阵旋转 90 度的公式不复杂,顺时针旋转后,原位置(i, j)的元素会落到(j, n-1-i)

List<List<int>> _rotateClockwise(List<List<int>> grid) { final n = grid.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { rotated[j][n - 1 - i] = grid[i][j]; } } return rotated; } List<List<int>> _rotateCounterClockwise(List<List<int>> grid) { final n = grid.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { rotated[n - 1 - j][i] = grid[i][j]; } } return rotated; }

然后移动处理就能统一成:

bool move(String direction) { final before = board.map((row) => List<int>.from(row)).toList(); if (direction == 'left') { board = board.map(_mergeLine).toList(); } else if (direction == 'right') { board = board .map((row) => row.reversed.toList()) .map(_mergeLine) .map((row) => row.reversed.toList()) .toList(); } else if (direction == 'up') { var rotated = _rotateClockwise(board); rotated = rotated.map(_mergeLine).toList(); board = _rotateCounterClockwise(rotated); } else if (direction == 'down') { var rotated = _rotateCounterClockwise(board); rotated = rotated.map(_mergeLine).toList(); board = _rotateClockwise(rotated); } final changed = !_sameBoard(before, board); if (changed) { addRandomTile(); checkWonAndGameOver(); } return changed; }

这里有个很重要的细节:每次移动前要把原始棋盘存一份快照,移动后比较是否发生了变化。如果玩家往一个完全没有空隙的方向滑动,本质上是无效操作,此时不应该生成新方块,也不应该刷新界面。这个判断很多初学者会漏掉,导致棋盘越玩越多方块。

3.3 新方块生成、得分与胜负判定

有效移动之后需要生成新方块。最简单的做法是先收集所有空位,随机挑一个填上数字:

void addRandomTile() { final empty = <(int, int)>[]; for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) empty.add((i, j)); } } if (empty.isEmpty) return; final cell = empty[Random().nextInt(empty.length)]; board[cell.$1][cell.$2] = Random().nextDouble() < 0.9 ? 2 : 4; }

生成 2 的概率是 90%,生成 4 的概率是 10%,这个比例是 2048 比较经典的设计。注意这里用到了 Dart 的 record 语法(int, int),如果你的 Flutter 版本比较老不支持这种写法,可以改成一个简单的坐标类或者直接用两个变量保存下标。

得分规则比较简单:每当两个方块合并成一个,就把合并后的数值累加到总分里,而不是移动一次加固定分数。这样做更符合游戏直觉,分值也更能反映玩家的操作质量。

胜负判断分两步:

  • 胜利条件:棋盘里有任一格子达到 2048。
  • 失败条件:棋盘没有空格,且任意相邻格子都不相等。
void checkWonAndGameOver() { for (var row in board) { if (row.contains(2048)) { won = true; return; } } for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) return; if (j + 1 < 4 && board[i][j] == board[i][j + 1]) return; if (i + 1 < 4 && board[i][j] == board[i + 1][j]) return; } } gameOver = true; }

注意胜利和失败并不是互斥的,玩家完全有可能生成了 2048 之后继续玩到棋盘填满。所以判断胜利后不要立刻锁住棋盘,而是把控制权留给玩家选择是否继续。

3.4 Flutter Web 界面渲染与手势识别

界面部分最核心的是 4x4 网格渲染。我用GridView.builder来画棋盘,每个格子根据数值显示不同的背景色和文字。Flutter 的网格布局相当方便,指定crossAxisCount: 4就能完成棋盘骨架。

为了给方块一点交互反馈,我给每个格子套了AnimatedContainer,数值变化时颜色和大小会有 150 毫秒的过渡动画。这个动画量级不会干扰游戏节奏,也不会让代码变得复杂。

手势识别用GestureDetectoronPanStartonPanEnd就行:

void _handlePanStart(DragStartDetails details) { _startX = details.globalPosition.dx; _startY = details.globalPosition.dy; } void _handlePanEnd(DragEndDetails details) { final dx = details.globalPosition.dx - _startX; final dy = details.globalPosition.dy - _startY; if (dx.abs() < 20 && dy.abs() < 20) return; setState(() { if (dx.abs() > dy.abs()) { game.move(dx > 0 ? 'right' : 'left'); } else { game.move(dy > 0 ? 'down' : 'up'); } }); }

这个实现的核心思路是记录手势起点和终点,计算横向和纵向位移,哪边位移大就判定为哪个方向。20 像素的阈值是为了防止误触。

Web 端还应该支持键盘操作,方向键上下左右控制移动,这样才能在电脑上玩得顺手。Flutter 里可以用KeyboardListener监听按键事件,配合Focus组件让页面自动获得焦点。

4. 完整实操记录:从空白工程到可玩 Web 小游戏

4.1 用 Trae 自然语言生成核心代码

新建工程之后,我第一步并不是直接写代码,而是先打开 Trae 的对话框输入需求:

“请用 Dart 写一个 2048 核心逻辑类,包含 move 方法、随机生成方块、胜负判断,不要依赖 Flutter UI。”

Trae 很快生成了一个类,整体结构是可用的,但它的初始版本里有两个典型问题:

  • move方法没有判断棋盘是否真的发生了变化,无效滑动也会生成新方块。
  • 合并逻辑没有处理“一个方块一轮只能合并一次”的限制,[2, 2, 2, 2]这种情况会被一次吞成 8。

这两个问题恰好是 2048 的经典算法考点。我把问题反馈给 Trae,让它重新生成,同时自己也手动改了一遍_mergeLine。这里想强调一下 AI 辅助开发的边界:让 AI 写重复样板和初步骨架完全没问题,但核心算法必须自己懂原理,否则连它哪里写错了都看不出来。

4.2 核心代码文件:game_logic.dart

我把game_logic.dart整理成下面这个完整版本,它不依赖 Flutter,只负责游戏状态管理:

import 'dart:math'; class Grid2048 { List<List<int>> board; int score = 0; bool gameOver = false; bool won = false; Grid2048({List<List<int>>? initialBoard, this.score = 0}) : board = initialBoard ?? List.generate(4, (_) => List.filled(4, 0)) { if (initialBoard == null) { addRandomTile(); addRandomTile(); } else { checkWonAndGameOver(); } } void reset() { board = List.generate(4, (_) => List.filled(4, 0)); score = 0; gameOver = false; won = false; addRandomTile(); addRandomTile(); } List<int> _mergeLine(List<int> line) { final nums = line.where((e) => e != 0).toList(); final result = <int>[]; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); score += nums[i] * 2; i += 2; } else { result.add(nums[i]); i += 1; } } while (result.length < 4) { result.add(0); } return result; } List<List<int>> _rotateClockwise(List<List<int>> grid) { final n = grid.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { rotated[j][n - 1 - i] = grid[i][j]; } } return rotated; } List<List<int>> _rotateCounterClockwise(List<List<int>> grid) { final n = grid.length; final rotated = List.generate(n, (_) => List<int>.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { rotated[n - 1 - j][i] = grid[i][j]; } } return rotated; } bool move(String direction) { final before = board.map((row) => List<int>.from(row)).toList(); if (direction == 'left') { board = board.map(_mergeLine).toList(); } else if (direction == 'right') { board = board .map((row) => row.reversed.toList()) .map(_mergeLine) .map((row) => row.reversed.toList()) .toList(); } else if (direction == 'up') { var rotated = _rotateClockwise(board); rotated = rotated.map(_mergeLine).toList(); board = _rotateCounterClockwise(rotated); } else if (direction == 'down') { var rotated = _rotateCounterClockwise(board); rotated = rotated.map(_mergeLine).toList(); board = _rotateClockwise(rotated); } final changed = !_sameBoard(before, board); if (changed) { addRandomTile(); checkWonAndGameOver(); } return changed; } bool _sameBoard(List<List<int>> a, List<List<int>> b) { for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (a[i][j] != b[i][j]) return false; } } return true; } void addRandomTile() { final empty = <(int, int)>[]; for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) empty.add((i, j)); } } if (empty.isEmpty) return; final cell = empty[Random().nextInt(empty.length)]; board[cell.$1][cell.$2] = Random().nextDouble() < 0.9 ? 2 : 4; } void checkWonAndGameOver() { for (var row in board) { if (row.contains(2048)) { won = true; return; } } for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) return; if (j + 1 < 4 && board[i][j] == board[i][j + 1]) return; if (i + 1 < 4 && board[i][j] == board[i + 1][j]) return; } } gameOver = true; } }

这个文件可以直接用dart test做单元测试,也可以被任意 UI 包装。

4.3 核心代码文件:main.dart

接着是主界面。我把整个界面写在一个文件里,方便阅读。项目结构简单的时候,不用过早纠结拆分。

import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'game_logic.dart'; void main() => runApp(const Game2048App()); class Game2048App extends StatelessWidget { const Game2048App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '2048', debugShowCheckedModeBanner: false, theme: ThemeData(colorSchemeSeed: Colors.brown, useMaterial3: true), home: const HomePage(), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { final Grid2048 game = Grid2048(); double _startX = 0; double _startY = 0; void _handlePanStart(DragStartDetails details) { _startX = details.globalPosition.dx; _startY = details.globalPosition.dy; } void _handlePanEnd(DragEndDetails details) { final dx = details.globalPosition.dx - _startX; final dy = details.globalPosition.dy - _startY; if (dx.abs() < 20 && dy.abs() < 20) return; setState(() { if (dx.abs() > dy.abs()) { game.move(dx > 0 ? 'right' : 'left'); } else { game.move(dy > 0 ? 'down' : 'up'); } }); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: setState(() => game.move('left')); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: setState(() => game.move('right')); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: setState(() => game.move('up')); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: setState(() => game.move('down')); return KeyEventResult.handled; default: break; } } return KeyEventResult.ignored; } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Focus( autofocus: true, onKeyEvent: _handleKeyEvent, child: GestureDetector( onPanStart: _handlePanStart, onPanEnd: _handlePanEnd, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '2048', style: Theme.of(context).textTheme.headlineMedium, ), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.brown.shade200, borderRadius: BorderRadius.circular(8), ), child: Text('分数 ${game.score}'), ), const SizedBox(width: 8), TextButton( onPressed: () { setState(() => game.reset()); }, child: const Text('重开'), ), ], ), ], ), const SizedBox(height: 16), Expanded(child: BoardView(game: game)), const SizedBox(height: 16), const Text('滑动屏幕或使用方向键操作'), ], ), ), ), ), ), ), ), ); } } class BoardView extends StatelessWidget { final Grid2048 game; const BoardView({super.key, required this.game}); Color _colorFor(int value) { const colors = { 0: Color(0xFFCDC1B4), 2: Color(0xFFEEE4DA), 4: Color(0xFFEDE0C8), 8: Color(0xFFF2B179), 16: Color(0xFFF59563), 32: Color(0xFFF67C5F), 64: Color(0xFFF65E3B), 128: Color(0xFFEDCF72), 256: Color(0xFFEDCC61), 512: Color(0xFFEDC850), 1024: Color(0xFFEDC53F), 2048: Color(0xFFEDC22E), }; return colors[value] ?? const Color(0xFF3C3A32); } @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.brown.shade400, borderRadius: BorderRadius.circular(12), ), child: GridView.builder( physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: 16, itemBuilder: (context, index) { final row = index ~/ 4; final col = index % 4; final value = game.board[row][col]; return AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: _colorFor(value), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( value == 0 ? '' : '$value', style: TextStyle( fontSize: value >= 1024 ? 20 : 32, fontWeight: FontWeight.bold, color: value <= 4 ? Colors.black54 : Colors.white, ), ), ); }, ), ), ); } }

关于AnimatedContainer有个说明:它只对颜色、尺寸这类属性做过渡,真正实现“方块滑动位移”需要更复杂的布局计算。我这个版本追求的是逻辑清晰和代码可读,所以没有做完整的滑动位移动画,但核心玩法完全不受影响。想加动画的话,后续可以在map阶段记录每个格子的旧位置和新位置,再用TransformAnimationController实现移动,这是一个很好的扩展练习。

4.4 编译运行与打包部署

开发模式下直接运行:

flutter run -d chrome

看到棋盘初始生成两个方块,用方向键和鼠标拖动都能操作,说明基本功能已经完成。

发布版本的构建命令是:

flutter build web --release

构建产物在build/web目录下,里面的文件可以直接扔到任意静态服务器上。我在本地用 nginx 做过托管测试,只需要把location /指向build/web即可。由于这个项目没有用路由,刷新页面不会出现 404 问题。如果你想部署到 GitHub Pages 之类的地方,也可以直接把build/web的内容推到仓库里。

需要注意 Flutter Web 的产物包含service worker相关逻辑,它是用来做资源缓存的。如果你更新版本后浏览器仍然显示旧页面,可以试试强制刷新或者在部署时清理浏览器的站点数据,这个缓存机制在开发小游戏时偶尔会给你“明明改了代码却看不到变化”的错觉。

5. 常见问题与排查技巧实录

5.1 滑动方向不准与键盘事件失效

滑动方向不准通常有两个原因:一个是阈值设得太小,手指轻微抖动就被判定为一次移动;另一个是页面里的滚动组件抢占了手势。我的方案是把阈值设为 20 像素,并计算水平和纵向位移的绝对值差,取较大者为移动方向。

键盘事件失效的场景多半是页面没有获得焦点。我在根布局包了一层Focus并设置autofocus: true,这样打开页面时键盘事件就能被捕获。如果按了方向键没反应,先点一下页面任意位置,再按方向键。

5.2 合并逻辑错误与数组越界

合并逻辑最常见的问题是“连消”。比如某一行变成[4, 4, 8, 0]时,你发现它被一次合并成[16, 0, 0, 0],说明你的合并函数让当前方块参与了多次合并。解决办法就是我上面_mergeLine里写的:合并成功时下标i += 2,否则i += 1,保证每个数字一轮最多被合并一次。

数组越界多半发生在尝试修改二维数组时忘记处理边界。旋转函数里很容易出现下标反了的情况,我的建议是先用一个 2x2 的小矩阵在纸上手动验算一遍:把(0,1)放到旋转后的哪个位置,然后对着代码检查。

5.3 Trae 生成代码的版本适配问题

Trae 在生成 Dart 代码时经常默认使用最新语法。比如我在第 3 章用到的 record(int, int),在较老的 Flutter 版本里是编译不过的。遇到这种情况不要慌,直接把报错信息复制到 Trae 对话框,它会帮你改写。

另一个典型问题是onKeyEvent这个 API 在 Flutter 3.x 不同版本里的参数类型有差异。如果你用的是旧版本,可以改成onKey或参考当前版本的 Flutter API 文档。这里想强调,AI 工具生成代码的版本敏感度完全依赖你的环境,用之前先确认自己 Flutter SDK 版本,或者让 Trae 限定“兼容 Flutter 3.10 版本”再生成。

5.4 排错速查表

我把这一路踩过的坑总结成表,方便你对照排查:

现象可能原因解决办法
方向键无反应页面没有焦点用 Focus + autofocus 包裹棋盘
滑动一次触发多次移动阈值太小位移差绝对值小于 20 时忽略
方块被连续合并合并后未跳下标合并时让下标 i += 2
无效滑动也会生成新方块没有判断棋盘前后变化移动前后比较棋盘快照
编译报 record 语法错误Flutter 版本过旧改用坐标类或升级 Flutter
浏览器显示旧版本Service Worker 缓存强制刷新或清理站点数据
构建缓慢或卡住首次下载 Web SDK检查网络,配置镜像源后重试

一点后续扩展思路

做完基础功能后,我给这个项目又加了几个升级方向,你可以按兴趣选一个继续练手。

第一个是操作历史与撤销功能。只需要在每次移动前把棋盘和分数放入一个栈,就能实现 Ctrl+Z 式的撤销,这对游戏体验提升非常大,实现成本也不高。

第二个是自动演示模式。写一个简单的 AI 策略,让它在浏览器里自己玩 2048,规则很简单:优先向左移动,不能移动时再尝试其他方向,高分区域保持在角落。然后结合 5.2 的动画改造,就能做出一个“看 AI 玩 2048”的演示页面,这个项目用来练习 Dart 异步和动画控制非常合适。

第三个是本地记录。把最高分存到 localStorage,Flutter Web 里可以用shared_preferences插件,或者直接通过dart:js_interop调用 localStorage,这个需求能帮你理解 Web 端与浏览器 API 的交互方式。

我个人更推荐从撤销开始,因为它的逻辑清晰,又能加深对状态管理的理解。总之这个标题虽然叫“开发 2048”,但真正有价值的是过程中关于数据结构、状态管理、手势交互、AI 辅助开发的整套实践,这些经验换个项目照样能用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询