1. 为什么我会用 Trae 写一个 Flutter Web 2048
1.1 一次关于“AI IDE 能不能落地写完整项目”的测试
最近我一直在琢磨一件事:AI 编程工具已经喊了这么久,到底能不能真的从一个空目录开始,辅助开发出一个可以正常玩、可以打包发布的小项目?不是写个函数片段,不是补全几行代码,而是从环境配置到核心逻辑、从界面布局到交互事件全都跑通。为了验证这件事,我选了 Flutter Web 和 2048 这个组合。
选择 Trae 的原因很简单,它把对话式编程、代码生成、项目上下文理解这些东西集成在了一个 IDE 里。你不需要在聊天窗口和编辑器之间来回复制粘贴,它可以看着你的整个项目文件做改动。相比纯插件式的补全,这种方式更接近“结对编程”的体验。2048 又是一个非常适合做验证的项目:规则全世界都懂、状态变化清晰、不需要后端、不用数据库,一个main.dart就可以写完。正因如此,它能逼着 AI 真实处理“移动合并顺序”“游戏结束判定”“键盘与触摸事件同时支持”这些具体问题,而不是背一个 Hello World 模板。
1.2 2048 这个项目为什么适合作为 Flutter Web 练手
很多人觉得 2048 太简单,不值得写。但恰恰相反,2048 是一个“逻辑密度”很高的项目。它的核心规则只有四条:随机生成数字、移动所有方块、相同数字合并、判断游戏结束。但要把这四条在代码里写对,需要处理二维数组、方向变换、合并去重、边界条件,这些正是很多项目中都会遇到的典型逻辑。
从 Flutter Web 的角度看,2048 还天然适合用来练习布局和交互。4x4 的网格、卡片颜色变化、分数更新、新方块的弹出效果,这些都能用 Flutter 的基本组件实现。同时还要处理键盘方向键、鼠标拖拽、触摸滑动三类输入方式,这对 Web 端和移动端跨端适配是一次很好的实践。就算你已经写过好几个 Flutter 页面,把 2048 完整跑通,对状态管理和事件处理的理解都会上一个台阶。
1.3 我最终的工作流和本文定位
实际开发时,我并不是直接把完整需求丢给 Trae 让它“一键生成”。我采用的是边写边聊的模式:先自己画出棋盘模型和核心逻辑的草图,再让 Trae 帮忙补全 UI 代码和细节实现;遇到合并算法顺序不对,或者键盘监听不灵敏时,我直接把问题描述给它,它会基于当前代码给出修改建议。这种方式比纯手写快,也比完全依赖 AI 更可控,因为你必须自己能看懂每一段代码,否则出错后连问题都描述不清。
这篇文章会完整记录这个从 0 到 1 的过程,包括环境准备、核心算法设计、UI 交互实现、完整可运行代码,以及我在实际运行时踩过的几个坑。如果你是第一次接触 Flutter Web,或者想看看 AI 编程工具到底能帮到什么程度,这篇内容都应该能给你一些参考。代码部分我会尽量放在一起,方便你复制后直接运行。
2. 开发前准备:环境搭建里最容易卡住的三个地方
2.1 Flutter SDK 安装与版本选择
我第一个卡住的地方不是代码,而是 Flutter 环境本身。如果你已经装过 Flutter,建议先跑一遍flutter doctor确认 Web 支持是否完整。如果没有装,直接从 Flutter 官网下载当前 Stable 版本即可,注意不要用 Beta 或 Master 分支来做项目,小版本更新太快,依赖容易出问题。
下载解压后需要把flutter/bin目录加到系统 PATH。这一步很多教程都写了,但真正容易忽略的是:配置完 PATH 后要重新打开终端,否则命令不生效。之后执行flutter doctor --android-licenses之类的命令可能还会提示 Java 环境问题,如果只是做 Web 开发,Android SDK 可以先不装,但 Chrome 浏览器一定要有,因为本地运行 Flutter Web 默认会在 Chrome 里启动。
2.2 Web 支持没启用:最常见的翻车点
即使 Flutter SDK 装好了,第一次运行 Web 项目也可能提示没有启用 Web。这是因为某些版本的 Flutter 默认不开启 Web 支持。你需要在命令行执行:
flutter config --enable-web然后再执行:
flutter devices确认列表里出现Chrome或Web Server就说明环境 OK。这一步非常关键,很多新手在这一步就放弃了,其实只是少跑了一个配置命令。
如果你在国内网络环境,拉取 Pub 依赖可能会非常慢,甚至超时。我设置了PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL两个环境变量指向镜像地址,之后再执行flutter pub get就快很多。注意这只是标准做法,不涉及任何非常规代理工具,只是让依赖下载走国内镜像。
2.3 在 Trae 中创建项目和代码目录
Trae 目前对 Flutter 的支持方式很简单:直接在它的终端里执行创建命令。我没有先用命令行手动创建项目,而是直接在 Trae 的终端里运行:
flutter create flutter_2048_web cd flutter_2048_web然后用 Trae 打开这个目录,它会自动识别项目结构。之后我可以一边编辑代码,一边在终端里运行flutter run -d chrome,Trae 的终端输出会直接展示 Flutter 的日志,运行过程中如果有编译错误,能第一时间看到。
创建项目后,默认代码是一堆无用的计数器示例。我会先删掉test/widget_test.dart里的默认测试,再清空lib/main.dart,准备从零开始写 2048。这个清空的动作让 Trae 知道“这是一个全新的玩法”,避免它按照模板去补计数器相关代码。
3. 2048 的核心逻辑是怎么设计出来的
3.1 棋盘模型:为什么用 4x4 二维数组
2048 的棋盘本质上是一个 4x4 的矩阵,每个格子存一个整数,0 表示空。Dart 里最直观的表示方式就是List<List<int>>,外层是行,内层是列。我定义了board作为整个游戏的状态,同时在 UI 的build方法里通过读取board[r][c]来渲染每一个格子。
你可能想问,为什么不用一维数组?因为二维数组在“行移动”和“列移动”时语义更清楚。左移时直接遍历每一行,上移时遍历每一列,代码和人的思维习惯一致。虽然一维数组也能做,但调试起来要反复计算索引,很容易出错。在这个项目里,可读性比微小的性能提升更重要。
初始化棋盘时,所有值都是 0,然后调用两次_addRandomTile(),让开局有两个数字。这个顺序不能反,否则棋盘一开始是空的。
3.2 左移合并算法:最容易写错的核心函数
2048 最核心的算法是“把一行非零数字向左移动并合并”。很多人第一次写会自然想到“遍历数组,如果相邻相同就合并”,但有个关键细节:合并是从左到右依次进行的,而且合并后的方块不能再次合并。比如[2, 2, 2, 2]左移后应该是[4, 4, 0, 0],而不是[8, 0, 0, 0]。
我的做法是写一个_mergeRow函数,专门处理“一行数字向左合并”的完整流程。基本思路是:先把 0 全部过滤掉,只留下非零数字,这样移动就被简化成“拼凑数字”;然后用一个while循环从左往右扫,如果当前数字和下一个数字相等,就把它们合并成当前数字 * 2,同时跳过被合并掉的那个位置;如果不等,就把当前数字放进结果里。最后在结果后面补 0 补满 4 个位置。
List<int> _mergeRow(List<int> row) { final nums = row.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++; } } while (result.length < size) { result.add(0); } return result; }这段代码里有一个很容易被忽略的细节:得分是在_mergeRow里累加的,而不是在 UI 层计算。这样做的原因是,合并只发生在移动过程中,分数和合并动作天然绑定在一起。如果你把得分放在 UI 层统计,当用户快速连续操作时可能会因为异步问题导致分数丢失。
3.3 右移、上移、下移如何复用同一套逻辑
如果为四个方向分别写四套算法,代码会冗余,而且更容易出 bug。我采用了一个取巧的方案:通过“反转”把四个方向都变成左移。
右移就是把每一行反转后执行左移,再反转回来。比如原行是[0, 2, 0, 2],反转成[2, 0, 2, 0],左移得[4, 0, 0, 0],再反转回[0, 0, 0, 4],正好是右移的结果。上移和下移同理,只是把“行”换成“列”。
case Direction.up: for (var c = 0; c < size; c++) { final column = [for (var r = 0; r < size; r++) board[r][c]]; final newColumn = _mergeRow(column); if (!_sameRow(newColumn, column)) { for (var r = 0; r < size; r++) { board[r][c] = newColumn[r]; } changed = true; } } break;这个“反转统一”的技巧可以用在很多二维网格游戏里,比如消消乐、华容道、推箱子。理解了轴反转的思想,以后处理四方向移动时就不用重复造轮子。
3.4 随机生成数字与游戏结束判定
每次移动成功(也就是棋盘发生了变化)之后,都需要在空位里随机生成一个数字。我使用dart:math里的Random,先收集所有值为 0 的格子坐标,再随机选一个。数字的大小遵循经典规则:90% 概率生成 2,10% 概率生成 4。
游戏结束的判断相对直接:如果棋盘里没有空格子,并且任意相邻的两个格子(上下或左右)都不相等,那就不可能再移动了。注意这里不能只判断空格子,因为即使有空格子也可能无法移动?实际上只要有一个空格子,就能移动。但稳一点的做法是同时检查空格和相邻相等,因为空格的存在意味着可以往空位移动,所以只要有空位就未结束。我的代码里先判断是否有 0,有 0 直接返回 false,高效且清晰。
4. 用 Trae 的对话式编程把 UI 和交互搭出来
4.1 页面布局:顶部信息栏和中间的棋盘
整体布局我用了一个居中的Column,外面包着ConstrainedBox限制最大宽度为 480,这样在浏览器里最大化窗口时不会拉伸得很难看。顶部是一行Row,左侧放“2048”大标题,右侧是得分容器和“新开局”按钮。
得分容器用Container包裹,背景色是深灰色,圆角 8,文字用白色加粗。按钮我用了ElevatedButton,背景色和经典的 2048 按钮保持一致,点击后调用_newGame()。这一块的代码虽然基础,但很能体现 Flutter 布局的层级感:最外层是 Padding,然后是 Column,Column 里通过 Row 分左右,右侧再用 Column 上下堆叠得分和按钮。
4.2 棋盘和数字卡片的渲染
棋盘我用了AspectRatio(aspectRatio: 1)保证它是一个正方形,然后内部放一个圆角Container,背景是棋盘槽的颜色。真正绘制格子的是GridView.builder,格子数量是 16,交叉轴和主轴间距都是 8,禁用滚动。
每个格子根据board[r][c]的值显示不同的背景色和文字。颜色的映射关系我单独写了一个_backgroundColorFor函数:2 对应浅米色,4 对应深米色,8 开始是橙色系,2048 对应金黄色。这里有一个小技巧:默认空格的背景色要选择比棋盘槽颜色更浅的颜色,让空位看起来是“凹陷”的,有立体感。
数字文字用了AnimatedSwitcher包裹,并给Text设置一个ValueKey(value)。这样每次数字变化时,Flutter 会做一次淡入淡出过渡,而不是生硬地“跳到新数字”。卡片背景则用AnimatedContainer让颜色变化也带一点动画。这两个动画组合起来,虽然不如原生 2048 的滑动那么顺滑,但不会显得廉价。
4.3 键盘事件和手势滑动的事件冲突处理
Web 端最自然的输入方式是键盘方向键。Flutter 里监听方向键需要让组件获得焦点。我使用了Focus包裹整个游戏区域,设置autofocus: true,然后在onKeyEvent里判断按下的键是否是四个方向键之一。
if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowUp) { _move(Direction.up); } // 其他方向同理 }需要注意:RawKeyboard在 Flutter 3.x 已经废弃,尽量使用KeyEvent这套新 API。同时,如果组件没有获得焦点,键盘事件是收不到的。有时候你在浏览器里点击了页面但焦点跑到别处,导致按方向键没反应,我的解决办法是在棋盘区域周围再包一个GestureDetector,点击一下让它重新聚焦。
手势方面,我使用onPanEnd读取滑动速度向量,比较横向和纵向速度的绝对值,哪个大就沿哪个方向移动。这个方案比手动计算按下和抬起坐标更简单,还能自然区分轻微抖动和真正意图明确的滑动。
4.4 胜利和结束的弹窗逻辑
当合成 2048 时,很多玩家期望看到一个“你赢了”的提示。我是在_move成功改变棋盘后,用_hasWon()检查是否出现了 2048。为了避免刚移动完就立刻弹窗导致界面卡顿,我用了Future.delayed(Duration(milliseconds: 120))延迟弹窗,这样玩家能先看到数字合并的画面。弹窗里提供“再来一局”和“继续游戏”两个按钮,选择继续时把won状态重置为 false,允许后续继续积累分数。
游戏结束后也会弹窗,显示最终得分。这里有个反直觉的设计:如果一局里既出现了 2048,又恰好没有可移动的格子了,我优先显示“游戏结束”,因为继续游戏已经没有意义。这个优先级要在代码里明确写出来,否则会出现弹窗重叠的问题。
5. 完整代码:一个 main.dart 跑通整个游戏
5.1 如何运行这份完整代码
在你按第 2 章完成环境配置后,新建一个 Flutter 项目,用下面这份代码覆盖lib/main.dart,然后在项目根目录执行:
flutter run -d chrome运行后浏览器会自动打开一个 2048 页面。你可以使用键盘方向键,也可以用鼠标拖拽滑动,或者触摸屏直接滑动。由于代码里没有依赖任何第三方包,所以打包也非常简单:
flutter build web打包后的文件在build/web目录下,可以直接部署到任何静态文件服务器。完整代码我放在下面,整体有点长,但注释和结构已经拆得很清楚,建议复制后从头到尾读一遍,不要只复制不消化。
5.2 main.dart 完整代码
import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.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( useMaterial3: true, scaffoldBackgroundColor: const Color(0xFFFAF8EF), ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); @override State<GamePage> createState() => _GamePageState(); } class _GamePageState extends State<GamePage> { static const int size = 4; late List<List<int>> board; late int score; final Random _random = Random(); bool won = false; bool gameOver = false; @override void initState() { super.initState(); _newGame(); } void _newGame() { board = List.generate(size, (_) => List.generate(size, (_) => 0)); score = 0; won = false; gameOver = false; _addRandomTile(); _addRandomTile(); setState(() {}); } void _addRandomTile() { final List<List<int>> emptyCells = []; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (board[r][c] == 0) { emptyCells.add([r, c]); } } } if (emptyCells.isEmpty) return; final pos = emptyCells[_random.nextInt(emptyCells.length)]; board[pos[0]][pos[1]] = _random.nextDouble() < 0.9 ? 2 : 4; } bool _move(Direction dir) { var changed = false; switch (dir) { case Direction.left: for (var r = 0; r < size; r++) { final newRow = _mergeRow(board[r]); if (!_sameRow(newRow, board[r])) { board[r] = newRow; changed = true; } } break; case Direction.right: for (var r = 0; r < size; r++) { final newRow = _mergeRow(board[r].reversed.toList()).reversed.toList(); if (!_sameRow(newRow, board[r])) { board[r] = newRow; changed = true; } } break; case Direction.up: for (var c = 0; c < size; c++) { final column = [for (var r = 0; r < size; r++) board[r][c]]; final newColumn = _mergeRow(column); if (!_sameRow(newColumn, column)) { for (var r = 0; r < size; r++) { board[r][c] = newColumn[r]; } changed = true; } } break; case Direction.down: for (var c = 0; c < size; c++) { final column = [for (var r = 0; r < size; r++) board[r][c]]; final newColumn = _mergeRow(column.reversed.toList()).reversed.toList(); if (!_sameRow(newColumn, column)) { for (var r = 0; r < size; r++) { board[r][c] = newColumn[r]; } changed = true; } } break; } if (changed) { _addRandomTile(); final isGameOver = _hasNoMoves(); final hasWon = _hasWon(); gameOver = isGameOver; if (isGameOver) { Future.delayed(const Duration(milliseconds: 120), () { if (mounted) _showEndDialog(won: false); }); } else if (hasWon && !won) { won = true; Future.delayed(const Duration(milliseconds: 120), () { if (mounted) _showEndDialog(won: true); }); } setState(() {}); } return changed; } List<int> _mergeRow(List<int> row) { final nums = row.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++; } } while (result.length < size) { result.add(0); } return result; } bool _sameRow(List<int> a, List<int> b) { for (var i = 0; i < size; i++) { if (a[i] != b[i]) return false; } return true; } bool _hasWon() { for (var row in board) { if (row.contains(2048)) return true; } return false; } bool _hasNoMoves() { for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (board[r][c] == 0) return false; if (c + 1 < size && board[r][c] == board[r][c + 1]) return false; if (r + 1 < size && board[r][c] == board[r + 1][c]) return false; } } return true; } Future<void> _showEndDialog({required bool won}) { return showDialog<void>( context: context, barrierDismissible: false, builder: (context) { return AlertDialog( title: Text(won ? '你赢了!' : '游戏结束'), content: Text( won ? '已经合成2048,是否继续挑战更高分数?' : '最终得分:$score'), actions: [ TextButton( onPressed: () { Navigator.pop(context); _newGame(); }, child: const Text('再来一局'), ), if (won) TextButton( onPressed: () { Navigator.pop(context); setState(() => won = false); }, child: const Text('继续游戏'), ), ], ); }, ); } void _handleKeyEvent(KeyEvent event) { if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowUp) { _move(Direction.up); } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { _move(Direction.down); } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { _move(Direction.left); } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { _move(Direction.right); } } } void _handlePanEnd(DragEndDetails details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { if (dx > 0) { _move(Direction.right); } else { _move(Direction.left); } } else { if (dy > 0) { _move(Direction.down); } else { _move(Direction.up); } } } Color _backgroundColorFor(int value) { switch (value) { case 2: return const Color(0xFFEEE4DA); case 4: return const Color(0xFFEDE0C8); case 8: return const Color(0xFFF2B179); case 16: return const Color(0xFFF59563); case 32: return const Color(0xFFF67C5F); case 64: return const Color(0xFFF65E3B); case 128: return const Color(0xFFEDCF72); case 256: return const Color(0xFFEDCC61); case 512: return const Color(0xFFEDC850); case 1024: return const Color(0xFFEDC53F); case 2048: return const Color(0xFFEDC22E); default: return const Color(0xFFCDC1B4); } } Color _textColorFor(int value) { return value <= 4 ? const Color(0xFF776E65) : Colors.white; } @override Widget build(BuildContext context) { return Scaffold( body: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent && (event.logicalKey == LogicalKeyboardKey.arrowUp || event.logicalKey == LogicalKeyboardKey.arrowDown || event.logicalKey == LogicalKeyboardKey.arrowLeft || event.logicalKey == LogicalKeyboardKey.arrowRight)) { _handleKeyEvent(event); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: GestureDetector( onPanEnd: _handlePanEnd, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65)), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: Text( '得分: $score', style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), ), ), const SizedBox(height: 8), ElevatedButton( onPressed: _newGame, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF8F7A66), foregroundColor: Colors.white, ), child: const Text('新开局'), ), ], ), ], ), const SizedBox(height: 24), AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(12), ), child: GridView.builder( physics: const NeverScrollableScrollPhysics(), itemCount: size * size, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: size, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemBuilder: (context, index) { final r = index ~/ size; final c = index % size; final value = board[r][c]; return AnimatedContainer( duration: const Duration(milliseconds: 100), decoration: BoxDecoration( color: _backgroundColorFor(value), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: AnimatedSwitcher( duration: const Duration(milliseconds: 100), child: value == 0 ? const SizedBox.shrink() : Text( '$value', key: ValueKey(value), style: TextStyle( fontSize: value >= 1024 ? 24 : 32, fontWeight: FontWeight.bold, color: _textColorFor(value), ), ), ), ); }, ), ), ), const SizedBox(height: 24), const Text( '使用方向键或滑动操作', style: TextStyle(color: Color(0xFF776E65), fontSize: 14), ), ], ), ), ), ), ), ), ); } } enum Direction { left, right, up, down }5.3 代码结构说明
这份代码本质上只有一个页面状态类_GamePageState。它同时承担了游戏状态管理和页面渲染两项职责。在小游戏项目里这样做完全没问题,结构简单,逻辑清晰。如果你后续想扩展,比如加入“撤销上一步”“难度选择”“排行榜”,再把状态管理提取到单独的ChangeNotifier或Provider里也不迟。
Direction是一个简单的枚举,定义了四个移动方向。移动核心逻辑都在_move方法里,先根据方向取出对应的行或列,经过_mergeRow处理,再判断是否有变化。注意我的_move是有返回值的,返回bool表示本次是否真的发生了移动。如果没有任何移动,就不会生成新的随机数字,这一点很重要,否则会出现“按了方向键但没移动,棋子却在增加”的 bug。
5.4 绘制和状态更新
每次_move成功后,我会调用setState触发页面重绘。在build方法里,GridView.builder的itemBuilder根据index计算出所在的行列,然后读取board[r][c]的值来渲染对应卡片。为了增强视觉效果,我用AnimatedContainer让背景色变化有 100 毫秒的过渡,用AnimatedSwitcher让数字切换有轻微的淡入淡出。
这里有一个性能细节:AnimatedSwitcher的child如果在 0 和数字之间切换,0 时使用的是一个没有ValueKey的SizedBox.shrink(),这样能让过渡动画正确执行。如果给空位也加ValueKey(0),动画可能不会按预期触发,因为在AnimatedSwitcher看来 key 一直是 0,没有变化。
6. 实测中遇到的坑与优化经验
6.1 合并顺序错误:连续等值方块被错误合并
我第一次运行测试时,出现了[2, 2, 2, 2]左移变成[8, 0, 0, 0]的情况。这不是 Trae 生成的代码有问题,而是我在描述合并逻辑时不够精确,没有说明“每个方块在一次移动中只能参与一次合并”。传统 2048 里[2,2,2,2]应该得到[4,4,0,0]。
排查过程比较直观。我打印出移动前后的board,发现算法把四个 2 一次性全部合成了一个 8。问题出在循环里,判断nums[i] == nums[i+1]合并后,没有正确跳过已经合并过的那一项。比如i=0时合并了第一个 2 和第二个 2,结果放入了 4,但没有让游标跳过第二个 2,导致i=1时又拿第二个 2 和第三个 2 合并,于是连续触发。修正方法就是在合并后执行i += 2,手动跳过被合并的项。
这个坑很经典,几乎所有从零实现 2048 的人都会遇到。我建议你在写_mergeRow时,脑海里把“每个数字在同一轮移动中只能合并一次”这句话刻下来。
6.2 键盘方向键偶尔失灵:焦点问题
在浏览器里运行后,我点了一下页面其他位置,然后按方向键,突然没有任何反应。从 Flutter 的日志看,应用本身没有报错,那么问题一定出在键盘事件没有被正确的组件接收。
罪魁祸首是Focus没有保持焦点状态。当页面被点击后,焦点可能落到了浏览器的地址栏或者其他元素上,Flutter 的Focus组件收到不到按键事件。我的解决办法是调整Focus的写法:让整个游戏区域包裹在GestureDetector里,任何手势触发时调一下FocusScope.of(context).requestFocus(node)。但更简单的方式是给Focus添加一个全局的Key,然后在onPanEnd里调用FocusScope.of(context).requestFocus(FocusManager.instance.primaryFocus)。不过这样有时还是不够稳定,我在实际代码里选择了最省心的方案:允许键盘事件在Focus外也能被onKeyEvent捕获。
实际上,在 Flutter Web 上,浏览器自身会处理方向键导致页面滚动。如果页面可以滚动,方向键会被浏览器吞掉,Flutter 接收不到。解决方法是确保整个 body 不滚动,或者把游戏区域占满视口。在完整代码里,我使用了Scaffold的默认布局,没有设置滚动,因此这个坑没有再出现。
6.3 动画与弹窗的时序:为什么需要延时弹窗
如果移动后立刻弹窗,你会看到什么?数字刚合并,还没等玩家看清,弹窗就出现了。这看起来非常“机械”。更严重的是,在极短时间内连续弹窗会引发setState called after dispose之类的错误,因为动画还在进行,页面却在准备弹窗。
我最后选择了Future.delayed加mounted判断,这是一个稳妥的组合。延迟 120 毫秒可以给玩家一个短暂的视觉缓冲,也能保证动画过渡至少开始执行。弹窗内部用barrierDismissible: false禁止点击外部关闭,避免误触导致状态混乱。在按钮回调里,移动后执行_newGame()或setState时,一定要确保context依然有效,否则在异步回调中使用context会触发 Flutter 的 lint 警告。这里因为是延迟后弹窗,弹窗内部的 context 来自showDialog的 builder,生命周期是安全的。
6.4 打包体积和渲染引擎
flutter build web打包出来的main.dart.js默认会比较大,原因是 Flutter Web 的 CanvasKit 渲染器自带了不少 WebAssembly 资源。2048 这种小游戏其实用不到太多特性,但仍无法通过简单配置把体积降到很低。可以做的常规优化包括:使用--web-renderer canvaskit显式指定渲染模式、开启 tree shaking 图标字体、压缩图片资源。对于本项目,我保持默认设置,因为功能比体积重要。
另一个值得关注的点是 Flutter Web 在移动端浏览器上的性能表现。2048 的动画很少,只要在移动设备上测试一下拖动是否跟手即可。我发现在 iOS Safari 上,快速滑动偶尔会触发浏览器的回弹效果,导致onPanEnd的偏移量不准确。解决办法是设置PageStorage或者禁用滚动行为,但最简单的是在GestureDetector里面同时加一个onPanCancel来处理异常中断。
7. 从 2048 到更多:Trae 辅助开发的进阶玩法
7.1 让 Trae 帮你做代码重构
写完第一版能跑的 2048 后,我开始尝试让 Trae 做重构。我给它提了一个需求:“把_GamePageState里的移动逻辑抽出来,放到独立的类里,方便单元测试。”它很快给出一个重构方案,把board、score、_move等方法挪到了一个Game2048Model类中。
这个过程给了一个启发:AI 编程工具不仅仅是“写代码”,还能作为重构引擎使用。你只要描述清楚“现状是什么、希望变成什么”,它会基于对代码上下文的理解,生成一套改动方案。但要注意,重构后必须自己跑一遍测试,AI 不会保证重构前后行为完全一致。尤其像score这种在多个方法间共享的状态,抽离时很容易出现漏传参的情况。
7.2 用 MCP 和扩展能力丰富场景
Trae 支持一些插件和 MCP 能力,可以接入外部工具。如果你对 AI 编程工具的使用已经比较熟练,可以让它自动调用一些本地命令,比如在终端执行flutter analyze、flutter test,然后根据输出修复静态分析错误。这个模式相当于给 AI 装上了“眼睛”,它能自己看到编译结果,不需要你复制错误信息。
我实际测试时,让 Trae 在生成代码后自动运行flutter analyze,它发现了几处prefer_const_constructors之类的 lint 提示,然后主动帮我修改代码。这种闭环能力对保持代码风格统一很有帮助。只要注意,让 AI 执行命令前确认命令是否安全,别让它去运行你都不认识的脚本。
7.3 一些真实心得和建议
用 Trae 完成这个 2048 项目后,我的感受是:AI 能大幅缩短编码时间,但无法替代你对问题的理解。每次报错后,如果你直接把错误信息丢给 AI,它能给出修复建议;但如果你连自己写的逻辑都说不清楚,AI 改出来的代码可能越改越奇怪。
所以我的建议是:哪怕用 AI 辅助编程,也一定要先自己把“数据结构”和“核心流程”想清楚。2048 这个项目最好的学习方式,是先不看完整代码,自己凭印象写一版_mergeRow,再去和 AI 生成的版本做对比。你会意外发现,同样一段逻辑,不同人的写法差异巨大。AI 喜欢用while循环加补零,你可能更习惯用for加where过滤,两者都能跑,但可读性完全不同。
最后再分享一个小技巧:如果你想在这个项目上加更多功能,比如高分排行榜、动画改进、多主题皮肤,可以先把完整代码跑通,然后每次只对 Trae 提一个具体的小需求,改完立刻运行验证。一次只改一个功能,出问题时定位成本最低。我就因为一次想同时加“撤销”和“动画”,导致 bug 定位花了一个晚上。循序渐进,永远比大步狂奔靠谱。