最近用 Trae 从零写了一个 2048 小游戏,技术栈选的是 Flutter Web,整个过程从创建项目到部署到浏览器,基本没离开这个 AI 编辑器。这篇记录不是那种一步步教你点按钮的菜谱,而是把我做这个项目时真正卡住的地方、核心算法的推导过程、以及和 AI 协作时踩过的坑都摆出来。适合刚学 Flutter 想做点小东西的同学,也适合已经写了一阵业务代码、想体验 AI 辅助开发的朋友。2048 这游戏看起来简单,规则就四条,但真要用代码干净地实现“移动合并”这个动作,里面有几个容易被忽略的细节,搞懂了会让整个项目豁然开朗。
1. 项目拆解:这个 2048 到底该怎么设计
1.1 为什么选 Trae + Flutter Web 这套组合
先说工具。Trae 是一个 AI 原生的代码编辑器,界面和 VS Code 差不多,但内置了聊天和自动写代码的能力,分 Chat 和 Build 两种模式。我用它是因为它对中文友好、免费,而且直接在编辑器里就能让 AI 生成代码并落地到文件,省去来回复制粘贴的麻烦。Flutter Web 则是把 Flutter 的 Dart 代码编译成可以在浏览器里跑的 Web 应用,好处很明显:一套代码,后面想换个手机 App 也可以继续用;布局用组件树写,写游戏页面比手写 DOM 要直觉很多。
选择这个组合还有一个比较实际的考虑:2048 本身是个逻辑和 UI 高度混杂的小游戏,正好覆盖了布局、状态、手势、动画、打包这些 Flutter 的核心知识点。用它做练手项目,学到的不是某个孤立 API,而是一条完整的开发链路。相比之下,如果直接上手一个复杂应用,很容易被业务逻辑淹没,反而学不到框架本身的精华。
1.2 2048 的规则与最核心的算法抽象
先把 2048 的规则还原一下,确保后面所有代码都对着这套规则来:
- 棋盘是 4x4,每个格子要么为空,要么是 2、4、8、16 这类 2 的幂。
- 玩家朝上下左右四个方向滑动,所有数字块往该方向移动,直到撞到边界或另一个数字块。
- 移动过程中,相邻的两个相同数字会合并成一个新数字,值为两者之和。
- 每次有效移动后,在空白位置随机生成一个新方块,90% 是 2,10% 是 4。
- 当棋盘被填满,且任意相邻格子都不相等时,游戏结束。
这四条规则里最难处理的,是把“移动”和“合并”统一成一个动作。我一开始想的是用二维数组逐个循环处理,写着写着就发现方向一变,所有坐标判断都要重来,代码很快变成一团浆糊。后来换了个思路:能不能不关心方向?只要把一行数字提取出来,处理完再放回去,方向问题交给取行、取列、反转来解决。这个抽象是 2048 整个算法的分水岭,想通了,后面写起来非常快。
1.3 一个简单的文件规划
动手之前,我先在大脑里把项目拆成了三个文件,目的是让 AI 生成代码时能更精准地定位目标:
- lib/main.dart:入口文件,负责组装页面组件。
- lib/board_logic.dart:纯 Dart 逻辑,包含棋盘状态、移动合并、胜负判断。
- lib/game_board.dart:棋盘 UI 组件,负责渲染格子和处理手势。
把逻辑和 UI 拆开,是我个人做小游戏非常推荐的习惯。让 AI 修改逻辑时不用担心动到界面,自己调试时也可以脱离 Flutter 直接跑逻辑测试。后面你会发现,这一个小小的决定,会让整个开发体验完全不一样。AI 在上下文准确的时候,生成的代码质量远高于你给它丢一个几千行的巨型文件让它乱猜。
2. 环境准备:Flutter Web 与 Trae 从安装到跑通
2.1 先把 Flutter Web 环境跑起来
如果你是第一次用 Flutter,这一步按顺序来做就行。去 Flutter 官方网站下载对应系统的 SDK 压缩包,解压到一个不容易乱动的目录,然后把解压目录下的 bin 文件夹路径加到系统环境变量 PATH 里。在命令行里输入 flutter --version,能正常输出版本号就说明安装成功。
接着要打开 Web 支持。老版本 Flutter 默认是不包含 Web 的,需要手动执行 flutter config --enable-web,新版本一般默认支持,但跑一下这个命令也没坏处。然后再执行 flutter doctor,检查结果里 Chrome 那一项要打勾。Flutter Web 开发调试离不开 Chrome,不管你是想热重载还是想模拟手机尺寸,都要靠它。
这里多说一句,我见过很多人在这一步卡住是因为电脑上装了多个 Flutter 版本,或者 PATH 配到了旧版本目录。如果你以后要同时维护多个 Flutter 项目,推荐用 FVM 这种多版本管理工具,项目根目录放一个 .fvmrc 文件,打开就能切到对应版本,比手动改 PATH 省心太多。单项目学习的话,直接装一个稳定版就行,别折腾多版本,那是徒增烦恼。
2.2 安装 Trae 并打开项目
环境配好之后,去 Trae 官网下载对应系统的版本安装。第一次启动会要求登录,这个账号体系同时用于积分和相关功能。登录后你看到的界面跟 VS Code 非常接近,左侧是文件树,右侧是编辑区,底部区域可以唤起终端,侧边则是 AI 对话面板。
要让 Trae 正常开发 Flutter 项目,需要先把 Flutter 和 Dart 两个插件装上。Trae 本身兼容 VS Code 的大部分插件生态,直接在扩展搜索框里搜 “Flutter”,安装官方插件即可。装完之后,命令面板里会出现 Flutter: Run Flutter App 等命令,运行按钮也会亮起来。这一步如果漏掉,Trae 就只是个普通文本编辑器,跟 AI 不 AI 关系不大。
2.3 创建项目并启动默认页面
项目创建我习惯用命令行,因为 flutter create 会生成一个结构完整的 Flutter 项目模板,比自己手写 pubspec.yaml 稳得多。只做 Web 平台的话,可以限制生成范围:
flutter create --platforms=web demo_2048 cd demo_2048 flutter run -d chrome第一条命令会创建一个名为 demo_2048 的项目目录,里面包含 lib/main.dart、pubspec.yaml、web/index.html 等文件。执行完第三条命令后,浏览器会自动打开一个 Flutter 默认的计数器页面,看到那个红色加号按钮,说明 Flutter Web 环境已经完全跑通。接下来要做的,就是把这段模板代码替换成我们自己的 2048。
3. 核心代码解析:棋盘、算法、界面
3.1 棋盘数据模型:一维数组还是二维数组
棋盘用一维数组还是二维数组,这是个值得纠结的问题。我用的是长度 16 的一维数组,下标 row * 4 + col 表示第 row 行第 col 列的格子。原因主要有三个:一是遍历行和列时,可以用 for 循环配合步长,处理起来很干净;二是做方向反转时,在 Dart 里直接 reversed 列表再放回去就行;三是一维数组拷贝起来非常简单,后面如果要做撤销功能,一行 List.of 就能保存快照。
数据模型定义如下:
import 'dart:math'; class Game2048 { final List<int> grid = List.generate(16, (_) => 0); int score = 0; bool gameOver = false; final Random _rnd = Random(); void start() { for (var i = 0; i < 16; i++) { grid[i] = 0; } score = 0; gameOver = false; addRandomTile(); addRandomTile(); } }start 代表开始新一局,清空棋盘、重置分数,再随机生成两个初始方块。addRandomTile 是生成新方块的方法,后面在每次有效移动后也会调用。
3.2 移动合并算法:看懂这一块,游戏就完成 70%
这一节是全文最核心的部分。我的思路是先把“向左移动一行”这个最基础的操作写好,其他方向都转换成它。
先写一个专门处理单行合并的私有函数:
List<int> _mergeRow(List<int> row) { final nums = row.where((n) => n != 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; }这里最关键的是 i += 2 这个跳步。以 [2, 2, 2, 2] 为例,正确的 2048 规则是第一次移动后变成 [4, 4, 0, 0],因为移动只允许每个数字参与一次合并,不能连锁合并成 [8, 0, 0, 0]。如果不跳步,循环就会把新生成的 4 和后面的 4 再合一次,结果就错了。我最初犯的正是这个错误,后来看 AI 生成的代码里用 i += 2 的写法,才意识到规则里“合并过的方块不能再次合并”这个隐藏条件。
除了跳步这里,合并过程中还要注意当前行只剩一个非零数字的情况,这时 while 循环会把它当作无法合并的元素直接放进 result。另外,如果数字不是相邻相等,比如 [2, 4, 4, 0],循环会先把 2 放进去,然后检测到两个 4 合并成 8,最后补零,得到 [2, 8, 0, 0],这也是符合规则的。
有了单行处理方法,四个方向就都能写成对行或列的数据变换:
- 向左移动:对每一行调用 _mergeRow。
- 向右移动:把每一行反转,调用 _mergeRow,再反转回去。
- 向上移动:把每一列提取成一维数组,调用 _mergeRow,再写回原列。
- 向下移动:把每一列反转后处理,再反转回去。
移动方法还要返回一个布尔值,表示这一步到底有没有实际变化。如果没有变化,不能生成新方块,否则玩家乱按方向键棋盘会一直长数字。判断变化可以这样:
final oldGrid = List.of(grid); // ...按方向处理每一行/列... bool changed = false; for (var i = 0; i < 16; i++) { if (grid[i] != oldGrid[i]) { changed = true; break; } } if (changed) addRandomTile();addRandomTile 的逻辑也很简单:收集所有值为 0 的下标,随机选一个,90% 概率填 2,10% 概率填 4。当棋盘没有空位时,直接返回。
判断游戏结束需要两个条件同时满足:棋盘已满,且任意相邻的两个格子都不相等。相邻包括左右相邻和上下相邻,遍历整个棋盘检查一遍就行。
3.3 界面布局与动效:好看的手感是加分项
界面布局我推荐用 Stack + Positioned 而不是 GridView,原因只有一个:动画更好控制。GridView 适合静态列表,但 2048 的方块在滑动过程中会移动、合并、出现,用 Stack 可以精确控制每个方块的位置。虽然手写坐标计算比 GridView 麻烦一点,但自由度完全不同。
棋盘整体的结构大概是这样:
Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xFFFAF8EF), body: SafeArea( child: Column( children: [ // 顶部:标题和分数 Padding( padding: EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('2048', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), _ScorePanel(score: game.score), ], ), ), // 棋盘 Expanded( child: Padding( padding: EdgeInsets.only(bottom: 16), child: AspectRatio( aspectRatio: 1, child: Padding( padding: EdgeInsets.all(8), child: LayoutBuilder( builder: (context, constraints) { return GameBoard(game: game); }, ), ), ), ), ), ], ), ), ); }AspectRatio 是关键,它强制棋盘保持正方形,不管屏幕是手机竖屏还是电脑横屏,都不会变形。GameBoard 内部用 Stack 渲染 16 个格子,每个格子放到 Positioned 对应的坐标上。数字颜色可以根据数值分档,2 和 4 用深灰字配浅底,往后数字越大背景色越深,这个可以参考原版 2048 的配色,抄作业完全没问题。
动画方面,我加了两个很小的效果:新生成的方块做一个淡入动画,合并之后的方块做一个从 0.6 倍缩放到 1 倍的弹性动画。前者用 AnimatedOpacity,后者用 AnimatedScale,都是 Flutter 内置组件,几行代码就能实现。虽然不影响玩法,但配上之后游戏质感提升明显,操作起来会感觉更“顺手”。
4. Trae 实战:让 AI 帮你写代码的正确姿势
4.1 用 Chat 模式从零生成核心逻辑
环境跑通、算法也想清楚之后,剩下的工作基本可以交给 Trae。但我还是要强调一下:我是在自己想明白了数据模型和“按行处理、方向转换”这个抽象以后才开始让 AI 写的。如果什么都不懂就丢一句“给我写个 2048”,AI 也能写出来,但那种代码出问题后你根本不知道怎么修。
我用 Chat 模式时的提示词大致是这样:
在 lib/board_logic.dart 文件中创建一个 Game2048 类,包含: - List<int> grid,长度为 16,0 表示空格 - int score - void start() 方法,初始化棋盘并生成两个随机方块 - bool move(Direction direction) 方法,处理移动和合并,如果棋盘变化则生成新方块并返回 true - bool isGameOver() 方法 - void addRandomTile() 方法 不要使用任何第三方库,只用 Dart 标准库Trae 会按照这个要求生成一个完整的 Dart 文件。生成之后我没有直接跑,而是逐行看了一遍,确认合并逻辑里有 i += 2 这种跳步处理,才继续下一步。AI 写代码现在确实已经很厉害,但它依然需要你来充当“需求评审”的角色。
4.2 用 Build 模式修复我踩过的逻辑 Bug
我第一次让 AI 生成的合并算法就没写对,问题正是前面说的连锁合并。当时的场景是这样的:棋盘上有一行 [2, 2, 2, 2],向左滑动后,AI 的代码把它合成了 [8, 0, 0, 0],而正确结果应该是 [4, 4, 0, 0]。这个 bug 用肉眼在界面上很难看出来,因为数字只是变了一下,规则对不对很难凭感觉判断。后来我用 debugPrint 把每一行移动前后的数据打出来,才定位到问题。
我把这个 bug 原样描述给 Trae 的 Build 模式,重点附上了代码文件路径和实际输出:
lib/board_logic.dart 中 _mergeRow 方法有 bug。输入 [2, 2, 2, 2] 时,输出是 [8, 0, 0, 0],但 2048 的规则是每次移动每个方块只能合并一次,正确结果应该是 [4, 4, 0, 0]。请修复这段逻辑,不要引入第三方库。Build 模式会直接打开文件修改代码,修改完之后我等它报“完成”,再自己读一遍,确认跳步逻辑加上了才继续。这种“描述现象 + 给出文件路径 + 说明期望结果”的提问格式,是我实测下来成功率最高的。很多时候 AI 答非所问,是因为提问者只给了一句话,上下文太少。
4.3 给 Trae 提需求的几个技巧
和 AI 协作快一个月,我总结了几条针对 Trae 的实用经验:
第一,明确告诉它文件路径和函数名,不要让它猜。你说“在 board_logic.dart 里加一个方法”,它就真的只改那个文件;你说“帮我优化一下代码”,它会根据自己的思路到处动,大概率弄出你不想看到的改动。
第二,明确约束“不要做什么”。比如不要引入第三方包,不要生成英文注释,不要修改 pubspec.yaml。这些负面约束比正面要求更重要,AI 默认会追求“完整”,经常顺手加依赖,反而增加维护成本。
第三,报错信息直接贴完整日志,不要自己概括。Trae 的上下文窗口足够大,把 Flutter 运行时的红色报错整个贴进去,它往往能直接定位到哪一行出了问题。
第四,重要逻辑生成后自己读一遍,让 AI 给你解释。你可以直接说“解释这个 _mergeRow 方法的执行过程”,它会逐行说明。这一步既是在复习算法,也是在验证 AI 有没有理解需求。如果它解释得含糊,那说明代码大概率有问题。
5. 运行、调试与 Web 部署
5.1 本地调试与热重载
开发阶段,我直接在 Trae 自带的终端里执行下面的命令:
flutter run -d chrome浏览器会打开游戏页面,此时修改任意 Dart 代码并保存,页面会在一两秒内自动刷新。Flutter 的 hot reload 是很省心的功能,改完代码不用重新编译、不丢页面状态,棋盘上的数字位置都会保留。偶尔遇到改了代码没生效的情况,在终端按一下 R 做一次完整热重启就好。
调试逻辑时我喜欢用 debugPrint 打印棋盘状态。移动一步,就打一次 grid 的内容,对照规则手动检查。这种笨办法在排查合并算法时比任何高级调试工具都管用,因为你能清清楚楚看到每个数字去了哪里。
5.2 打包静态文件并部署到任意 Web 服务器
游戏逻辑完成后,执行打包命令:
flutter build web构建产物在项目目录下的 build/web 里,包含 index.html、main.dart.js、assets 文件夹等纯静态文件。也就是说,你只需要一个能托管静态文件的 web 服务器,就能把游戏部署出去。本地验证可以用:
python3 -m http.server 8080然后浏览器访问 localhost:8080 就能看到你的 2048。这一招在部署前强烈建议先做一次,很多部署后才发现的问题,比如资源路径不对、白屏没报错,用本地静态服务器都能提前暴露出来。
如果自己有服务器,用 nginx 部署也很简单,核心配置就一段:
server { listen 80; server_name your-domain.com; root /var/www/2048; index index.html; location / { try_files $uri $uri/ /index.html; } }把 build/web 下的文件原样上传到 root 指向的目录即可。try_files 那句作用是把路径不存在的请求都指向 index.html,Flutter Web 这种单页应用必须这么做,否则刷新子路径时会 404。
5.3 移动端和桌面的体验优化
游戏做好之后,我发现两个体验问题值得优化。
第一个是手机浏览器上滑动时,页面会跟着滚动。解决方法是给最外层加一个包裹并拦截手势,具体可以在 Scaffold 外面包一层 GestureDetector,设置 behavior: opaque,然后在 onPanEnd 里根据滑动向量判断方向,并且让它不要影响默认滚动。如果觉得手势判断不够灵敏,可以参考整体滑动距离和方向向量,设置一个最小滑动阈值,比如滑动超过 30 像素才触发移动,避免误触。
第二个是桌面端用鼠标玩的时候,没有键盘操作快。我加了一组键盘监听,让上下左右方向键也能触发移动。Flutter 里可以用 Focus 包住页面,也可以简单地用 HardwareKeyboard 正则监听,代码量很小,但对桌面用户体验提升非常明显。
如果想让游戏在电脑浏览器上看起来更协调,还可以把棋盘整体宽度限制一下,比如最大不超过 480 像素,这样在宽屏显示器上不会铺满整个页面,观感更接近原版 2048。
6. 常见问题与避坑指南
6.1 问题速查表
做这个小项目以及跟其他开发者交流时,我整理了下面这些高频问题,多数都能直接照着解决:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| flutter create 后项目里没有 web 目录 | 当前 Flutter 版本未启用 Web 支持 | 执行 flutter config --enable-web,重新创建项目 |
| Trae 终端里 flutter 命令不存在 | PATH 未配置或配置后未重启 | 检查环境变量,重启 Trae 让配置生效 |
| 棋盘在宽屏上拉得很扁 | 没有锁定宽高比 | 棋盘外层加 AspectRatio(aspectRatio: 1) |
| 数字块内容被截断或溢出 | Text 字号太大且未自适应 | 用 FittedBox 包住文本,让文字自动缩放 |
| 手机上滑动游戏导致页面滚动 | 手势被页面滚动事件抢占 | 用 GestureDetector behavior: opaque 包裹并拦截手势 |
| 移动后新方块没生成 | move 方法返回值判断有误 | 检查是否只在 changed 为 true 时调用 addRandomTile |
| 合并结果出现连锁合并 | 合并循环没有跳步 | 合并完成后 i += 2,让每个方块只参与一次合并 |
| 部署后刷新页面 404 | Web 服务器没有做 fallback | nginx 增加 try_files 配置指向 index.html |
| Android 构建报 visual studio tool 相关错误 | 当前环境只做了 Web 开发准备 | 本项目只做 Web 构建,不需要 Android 工具链,忽略即可 |
最后一条多说几句。网上经常出现的 unable to find suitable visual studio tool 这类错误,是 Android 端的 C++ 编译工具链问题,跟 Flutter Web 完全无关。如果你只做 Web 开发,flutter doctor 里 Android 相关的警告可以暂时忽略,不影响任何 Web 构建和部署。
6.2 两个值得长期保持的习惯
第一,核心逻辑独立成纯 Dart 类。这是整个项目下来我最大的体会。board_logic.dart 里没有任何 Flutter 组件,拿个 Dart 命令行就能直接跑测试。你可以临时在项目里写一个 test_main.dart,模拟几十次移动,验证算法正确性。一旦逻辑和 UI 混在一起,AI 写代码时很容易把逻辑藏在组件方法里,后面想复用、想测试,全都麻烦。
第二,用日志把状态变化打出来。我在 addRandomTile 和 move 方法里各加了一条 debugPrint,输出移动前后的 grid。排查 Bug 时,直接用肉眼对比两组数就能判断算法哪一步错了,比抽象推断快得多。这个习惯在 AI 生成代码时尤其重要,因为 AI 生成的代码你不能百分之百信任,必须自己验证。
个人来说,我用 Trae 做这个小游戏最大的收获不是学会了 2048 的实现,而是理解了怎么把一个需求拆给 AI 做还不出乱子。如果你也想练手,建议不要直接抄,先自己把“按行处理单行合并”这个抽象想明白,再让 AI 动手。等这个版本跑通,你还可以再给它加两个功能:撤销上一步、自动求解。前者只需要保存移动前的网格快照,后者可以试试简单的贪心或者搜索。一个下午的时间,能换来一整条 Flutter Web 开发链路的手感,挺划算的。