1. 先说结论:为什么是 Trae、Flutter Web 和 2048 这个组合
我花了一个完整的周末,用 Trae 从头做了一款 Flutter Web 小游戏 2048。在此之前,我反复被问到类似的问题:AI 编程工具到底能不能独立产出一个完整项目?哪个工具更值得投入时间?说实话,我的态度是:与其在网上看各种对比评测,不如拿一个自己完全能 hold 住的小项目,真刀真枪跑一遍。2048 就是这种项目的绝佳样本。
先说选型逻辑。Trae 是一款 AI IDE,内置了聊天面板和代写模式,可以针对你的工程项目直接生成代码、修改文件。它的优势不只是“能写代码”,更在于能理解整个项目的目录结构,你在对话框里提出“帮我把这个游戏改成支持键盘操作”,它会找到相关文件、改完、再给你一份变更说明。用这种工具开发一个小游戏,体验跟以前“搜代码、复制粘贴、手动改”完全不一样。
Flutter Web 则是我刻意选的。过去 Flutter 给人印象是“移动端框架”,但它对 Web 的支持已经足够成熟,尤其是在 CanvasKit 渲染器下,动画、绘制、跨平台一致性都做得不错。2048 这个游戏对交互要求不高,却对渲染刷新、动画过渡、响应式布局有明确要求,刚好能把这些能力都测一遍。
至于 2048 本身,则是一个“麻雀虽小、五脏俱全”的项目。它不涉及后端,不需要数据库,也不依赖复杂的第三方库,但它的核心逻辑包含数据结构设计、状态管理、手势/键盘输入、动画和游戏结束判定。你用 5 分钟就能讲完玩法,但把它实现到“能玩且玩起来舒服”的程度,工作量远远超过一个 TODO List。这就是为什么我推荐每一个想尝试 AI 辅助开发的人,都拿 2048 当练手项目。
接下来这篇文章会按照我的真实开发顺序展开:环境准备、核心算法拆解、使用 Trae 写代码的过程、Web 适配和打磨、以及最终构建发布。文章里所有代码片段都是从最终可运行项目里摘出来的,不是演示用的伪代码,你跟着走就能完整复现。
2. 环境准备:把 Trae 和 Flutter Web 跑起来,注意这些隐藏坑
按理说环境准备是最无聊的部分,但恰恰是初学者最容易卡住的地方。我见过很多朋友在“写第一行代码”之前就放弃了,原因几乎都是环境没弄利索。所以这一个章节我讲细一点。
2.1 安装清单:你需要装哪些东西
- Trae:直接去官网下载对应系统的安装包,安装之后它会以 IDE 的形态打开,本质上是一个内置 AI 能力的代码编辑器。你需要用账号登录才能使用 AI 功能,但这个流程在安装时会有引导。
- Flutter SDK:建议到 Flutter 官网下载稳定版。国内开发者经常遇到下载慢的问题,我的建议是先把 Flutter SDK 下好解压到某个纯英文路径,比如
D:\flutter,避免中文路径和空格引发的奇怪问题。 - Chrome 浏览器:Flutter Web 在调试阶段默认用 Chrome 拉起页面,你需要提前装一个。
- VS Code 扩展(可选):如果你以前用 VS Code,也可以在 VS Code 里装 Trae 的插件,但我的体验是直接用 Trae 自带编辑器更顺,不用来回切窗口。
我本次开发用的 Flutter 版本是 3.x 的稳定版,Dart SDK 随 Flutter 一起安装,不需要单独下载。你可以用flutter --version检查版本。
2.2 创建项目并启用 Web 支持
打开终端,进入你想放项目的目录,运行:
flutter create --platforms web trump_game_2048注意项目名不要带大写字母和连字符,必须用小写加下划线。创建完成后,进入目录:
cd trump_game_2048然后用 Trae 打开这个目录。第一个操作可能让你有点意外:我建议你先别急着写代码,先跑一次flutter run -d chrome,确认基础模版能正常启动。这一步能提前验证你的工具链是否完整,而不是让“编译环境有问题”和“代码有问题”混在一起。
如果你打开项目后提示没有配置 Web 支持,运行:
flutter config --enable-web然后重启 Trae 就能看到 Chrome 作为运行目标。
2.3 初始化时我实际遇到的报错
我先说自己踩过的一个很典型的坑:flutter run -d chrome以后,浏览器窗口正常弹出来了,但页面一直白屏。几秒钟之后终端报了一个奇怪的错误。后来排查发现是 Chrome 的远程调试端口被本机安全策略拦了。这个问题的处理办法是关掉浏览器里跟“调试工具”相关的安全选项,或者尝试更换一个端口,但不同系统差异很大,我的建议是先确认同一个命令在 VS Code 终端里能不能跑。
另一个比较常见的问题是 Flutter 依赖拉取失败。如果你是第一次用 Flutter,命令行会提示很多Downloading的过程,如果中途失败,往往会导致后续编译各种报错。处理方式是重新运行flutter pub get,耐心等到依赖全部就位。如果网络质量不稳定,多试几次属于正常现象,不用急着改代码。
在这个阶段,你不需要 AI 帮忙。Trae 的 AI 功能更适合出现在“写业务逻辑”和“查问题”的时候,环境问题往往是机器和环境配置问题,AI 也看不到你的终端全貌。
3. 算法先行:2048 的核心逻辑怎么拆解,才能交给 AI 而不被带偏
现在进入正题。我强烈建议你在让 Trae 写代码之前,先把算法自己理清楚。原因有两点:第一,你只有自己知道“正确结果”长什么样,才能判断 AI 写出来的代码对不对;第二,Trae 的提示词质量,完全取决于你对问题的描述是否清晰。把逻辑拆成可以描述的最小单元,生成出来的代码才靠得住。
3.1 棋盘数据结构的选型
2048 的标准棋盘是 4×4,每个格子里要么是空,要么是一个数字方块。大多数教程会用一个List<List<int>>,也就是一个包含 4 个数组的数组,每个数组代表一行。但我在这个项目里选择了更简单的方案:一个长度为 16 的List<int>,通过索引row * 4 + col来定位格子。
为什么这样选?因为移动和合并算法本质上是在处理“一行或一列的四个值”,如果用一个扁平数组,我只需要写一个针对“4 个元素列表”的处理函数,然后从不同方向提取行、列,处理完再填回去。这比在二维数组里来回操作要少很多边界判断。
class GameBoard { static const int size = 4; final List<int> grid = List.filled(size * size, 0); int score = 0; int get(int row, int col) => grid[row * size + col]; void set(int row, int col, int value) { grid[row * size + col] = value; } }0表示空格,这个选择很关键。因为合并逻辑里,所有关于“有没有数字”的判断都可以统一成!= 0,比用null判断更简洁。
3.2 移动与合并:先压缩,再合并,再压缩
2048 的滑动逻辑可以用一句话概括:把一行里的所有非零数字往滑动方向靠拢,如果相邻两个数字相同,就合并成它们的和,且每个数字在一次移动中只能合并一次。
听起来简单,但细节里坑很多。最典型的错误是:某一行是[2, 2, 4, 0],向左滑动时应该是[4, 4, 0, 0],也就是两个 2 合并成 4,然后原有的 4 不参与这次合并。但如果你的算法在遍历时直接判断“相邻相等就合并”,就会把合并出来的 4 和原来的 4 又合并一次,得到错误的[8, 0, 0, 0]。
正确的做法是分三步:
- 压缩:把一行中的非零元素全部移到左侧,保持原有顺序。
[2, 0, 2, 4]压缩后变成[2, 2, 4, 0]。 - 合并:从左到右遍历,如果当前位置和下一个位置相等,就合并,并把后面置零。注意,合并后要跳过下一个位置。
[2, 2, 4, 0]合并后变成[4, 0, 4, 0]。 - 再次压缩:把合并产生的空洞补上。
[4, 0, 4, 0]最终变成[4, 4, 0, 0]。
我把这个逻辑写成了一个独立的函数,输入是 4 个整数,输出是新列表和本次得分增量:
List<Object> mergeLine(List<int> line) { List<int> result = []; int addScore = 0; for (int i = 0; i < line.length; i++) { if (line[i] != 0) { result.add(line[i]); } } while (result.length < 4) { result.add(0); } for (int i = 0; i < result.length - 1; i++) { if (result[i] == result[i + 1] && result[i] != 0) { result[i] *= 2; addScore += result[i]; result.removeAt(i + 1); result.add(0); } } return [result, addScore]; }这个函数里最关键的一行是i++的跳过逻辑。当result[i]和result[i + 1]合并之后,i + 1位置的内容已经被删除并补了一个 0,所以下一次循环需要跳到i + 2,否则可能出现错误合并。这里用了一个技巧:合并后没有手动让i++,因为for循环会自然递增,而result.removeAt(i + 1)已经让后面的元素前移了。你再顺着这个例子推一遍[2, 2, 4, 0],就能明白为什么结果是对的。
3.3 四个方向的统一处理
有了mergeLine函数之后,四个方向的移动就变成了“如何把棋盘上的数字投影成一个线性的行或列”。
我定义了一个方法:把当前棋盘按照某个方向,拆成 4 组,每组 4 个数字,然后分别调用mergeLine,最后再写回棋盘。
void moveLeft() { for (int row = 0; row < 4; row++) { List<int> line = []; for (int col = 0; col < 4; col++) { line.add(get(row, col)); } List<Object> merged = mergeLine(line); List<int> newLine = merged[0] as List<int>; score += merged[1] as int; for (int col = 0; col < 4; col++) { set(row, col, newLine[col]); } } }其他三个方向可以通过坐标映射实现:向右移动是“把每行反过来处理再反回去”,向上移动是“把每一列提取出来当成行处理”。我在项目里用一个direction参数统一处理,而不是复制粘贴四份代码,这样后续如果想增加新方向或者调整逻辑,只需要改一处。
3.4 随机生成新方块与游戏结束判定
每次有效滑动之后,需要在空白位置随机生成一个方块。这里有两个细节值得注意:
- 必须只在有效移动后生成:如果用户往左滑,但棋盘没有任何变化,就不应该生成新方块,否则会白白增加格子占用。
- 新方块 90% 概率是 2,10% 概率是 4:这是原版游戏的规则,符合大多数玩家的体验预期。
bool addRandomTile() { List<int> empty = []; for (int i = 0; i < grid.length; i++) { if (grid[i] == 0) { empty.add(i); } } if (empty.isEmpty) { return false; } int index = empty[Random().nextInt(empty.length)]; grid[index] = (Random().nextDouble() < 0.9) ? 2 : 4; return true; }游戏结束判定也不能只看“还有没有空格”。还有一种情况是:棋盘满了,但相邻格子之间还有相同的数字可以合并。所以完整判定是:先判断有没有空格,再遍历每一行每一列,检查有没有相邻相等。如果两者都不满足,游戏才真正结束。
这些逻辑我建议你理解透彻之后,再让 Trae 帮你写或者你直接手工写进去。因为一旦你理解了这个算法,后面 AI 生成代码出问题时,你一眼就能定位到具体函数,而不是面对一整个项目无从下手。
4. 实操全记录:三组提示词 + 代码审查,把游戏从 0 写到 1
这一章是大家最想看的实际开发过程。我会把我给 Trae 发的提示词原样贴出来,并说明我为什么这么写,以及拿到结果后怎么审查、怎么修改。真实情况是:AI 生成的代码不可能是零瑕疵,但它能帮你节省大量重复劳动,关键是你得会验收。
4.1 第一轮提示词:搭建 Flutter Web 页面骨架
我先让 Trae 帮我把 UI 框架搭出来。在此之前,我已经创建好项目目录,但没有写任何游戏逻辑。我的提示词是:
帮我用 Flutter 写一个 2048 游戏的 UI 页面,放到 lib/main.dart 里。要求:4x4 的网格,每个格子显示对应的数字,数字为 0 时显示空白。需要有一个分数显示区域。样式可以参考网页版 2048,背景色偏米白色,格子使用圆角卡片,不同数字对应不同背景色。
这段提示词里我特意给出了“具体要求”,而不是空泛地说“写一个 2048”。因为我希望第一版 UI 就接近成品,而不是生成一个缺少样式的呆板网格。Trae 的响应很快,它直接帮我重写了main.dart,生成了一个StatefulWidget,里面包含一个GridView,并根据数字值返回不同颜色的卡片。
拿到代码之后,我没有立刻继续,而是先做了三件事:
- 确认
GridView的shrinkWrap和physics属性。2048 棋盘不应该有滚动效果,如果 AI 用了可滚动列表,必须改成NeverScrollableScrollPhysics。 - 检查数字颜色与背景的对比度。AI 生成的颜色有时候不够协调,我微调了颜色常量。
- 确认布局适配。Trae 生成的代码用的是
Column加固定EdgeInsets,在手机浏览器尺寸下还行,但桌面浏览器会显得偏高。我会在后续优化响应式布局。
第一轮生成的质量能达到“可以预览”的程度,但还不能玩,因为没有任何交互逻辑。这一步的体验和以前“从零手写 UI”完全不同,确实快了很多。
4.2 第二轮提示词:实现核心游戏逻辑
UI 建好之后,我新建了一个game_board.dart文件,把上一章讲解的GameBoard类手工写进去。之所以这次选择手工写而不是交给 AI,是因为核心算法太容易出错了,我看到过太多 AI 生成的错误合并逻辑。自己先写一遍,相当于给项目打了一个“确定性基础”。
接下来我要让 Trae 完成的是“衔接性工作”:把棋盘状态和 UI 绑定起来,并添加手势操作。提示词是这样的:
我已经在 lib/game_board.dart 里实现了 GameBoard 类,包含 moveLeft、moveRight、moveUp、moveDown、addRandomTile、isGameOver 方法,属性有 grid 和 score。请在 main.dart 里接入这些方法。要求在页面首次加载时调用两次 addRandomTile;当用户左右上下滑动时,检测滑动方向,执行对应的 move 方法,如果 addRandomTile 的结果为 true,则刷新界面。如果 isGameOver 返回 true,显示对话框提示游戏结束。
这段提示词有几个我必须强调的关键设计:
- 我先把
game_board.dart告诉我,并且说清了它的公开接口。这样 Trae 不需要猜测类名和方法名,也不会生成一个新的、方法结构不同的类。 - 我明确提出了“滑动后只有棋子变了才调用 addRandomTile”。这个细节在提示词里被写成了“如果 addRandomTile 的结果为 true”,虽然接口返回值并不能直接代表“是否发生了移动”,但它引导了正确的调用逻辑。
- 我要求显示对话框,这让 Trae 负责所有与 UI 弹出框有关的实现。
Trae 生成的代码基本符合要求,但我要检查一个重点:它是否在每次滑动后都把grid数据重新渲染到GridView上。因为GameBoard里的grid是同一个 List 实例,如果 UI 的setState里没有创建新列表,Flutter 可能不会刷新。我看了生成结果之后,手动把它改成了:
setState(() { _board.moveLeft(); _board.addRandomTile(); });同时确保GridView.builder的itemCount始终指向_board.grid.length,而每个格子读取的数字来自_board.grid[index]。这样每次setState都能触发重建,格子内容自然跟着更新。
4.3 第三轮提示词:动画、计分和重新开始
游戏核心逻辑跑通之后,第二轮生成的版本已经能玩,但手感很生硬:数字移动是一瞬间完成的,没有平滑过渡。这是 2048 体验的重要部分,没有动画的 2048 就像一个没有音效的游戏机。
我的第三轮提示词:
给 2048 格子数字变化加上动画效果。要求在数字合并或移动时,格子背景色和数字大小有一个平滑变化的过渡效果。不要改变当前代码逻辑。另外,分数区域需要实时更新,做一个从旧分数变化到新分数的简单动画。顶部需要加一个“重新开始”按钮。
这里我特意强调了“不要改变当前代码逻辑”,目的是限制 Trae 的改动范围,防止它为了加动画顺手重构整个文件。Trae 按照要求,把每个格子从静态Container换成了AnimatedContainer,分数文本换成了TweenAnimationBuilder,并在顶部Row里加了按钮。
可惜生成结果出了一个问题:AnimatedContainer对于同一个位置上的同一个 GridView item,如果color属性变了,过渡是有的;但如果一个格子从“有数字”变成“空格”,它的透明度变化并不明显,看起来像是突兀消失。我在浏览器里试玩之后发现了这个观感问题,但说实话,要修复这个问题需要更复杂的层级布局:让每个数字方块都用一个Stack来定位,滑动时让它们在棋盘上做位移动画。
追求完美动画版本工作量会成倍增加。我的最终处理是:保留AnimatedContainer的过渡效果,同时给空格的背景色和数字颜色设定统一的基础色。实际玩起来虽然不如原生 App 那么丝滑,但作为 Flutter Web 版本,这个表现已经相当能接受。如果你对动画有更高要求,可以参考我后面第 5 章提到的优化方向,自己接着往下做。
4.4 我在验收时发现的两个典型 AI“坑”
第一是手势阈值的问题。Trae 用一个onPanEnd的velocity.pixelsPerSecond来判断滑动方向,但实际在浏览器里,快速点击也会产生一个小幅度的速度向量,导致偶尔出现“点一下棋子就动了”的情况。我后续通过比较位移距离和速度向量解决了这个问题。
第二是随机数导致测试困难。Trae 生成的addRandomTile直接用了Random(),每次运行结果都不同。我调试合并逻辑时,很难复现同一个局面。后来我把Random对象作为可选参数传入GameBoard,测试时就传入固定种子,避免了重复试错。
5. Web 端体验优化:键盘、手势和动画在真实浏览器里怎么调
Flutter Web 跟移动端不太一样。手机上用户用手指滑动,桌面上用户可能更习惯键盘方向键。如果你只做手势支持,桌面用户打开页面后会觉得很别扭。这一部分,我分享一下我在这几个方向上的具体优化和踩坑。
5.1 手势识别:区分“点击”和“滑动”
Flutter 的GestureDetector提供了onPanStart、onPanUpdate、onPanEnd等回调。在最原始的版本里,我直接用onPanEnd返回的velocity.pixelsPerSecond,但测试发现:这个速度值在快速点击时也会很大,容易误触发。
我的解决方案是结合位移和速度一起判断:
onPanEnd: (details) { double dx = details.velocity.pixelsPerSecond.dx; double dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { if (dx > 150) _moveRight(); else if (dx < -150) _moveLeft(); } else { if (dy > 150) _moveDown(); else if (dy < -150) _moveUp(); } }150 这个阈值是我在浏览器里反复试出来比较舒适的值。如果你觉得太灵敏,可以调到 200,核心原则是:滑动必须快速且方向明确,点击不能触发。
5.2 键盘控制:给 Flutter Web 添加桌面体验
Flutter Web 默认不监听键盘方向键,你需要给页面加一个KeyboardListener或者Focus包装。我在main.dart的最外层加了一个Focus包裹,并赋予它autofocus: true,这样页面一加载就能接收键盘事件。
Focus( autofocus: true, onKeyEvent: (KeyEvent event) { if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.arrowUp) _moveUp(); else if (event.logicalKey == LogicalKeyboardKey.arrowDown) _moveDown(); else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) _moveLeft(); else if (event.logicalKey == LogicalKeyboardKey.arrowRight) _moveRight(); } return KeyEventResult.handled; }, child: child, )这里有个细节可能让你踩坑:onKeyEvent返回KeyEventResult.handled之后,页面里的滚动条就不会监听到这些按键了。这对 2048 游戏来说是好事,因为方向键应该只控制游戏,而不是滚动页面。但如果你的页面高度超过视口,用户想用方向键滚动页面时就会发现滚不动了,这时你可以只对arrowUp和arrowDown返回ignored。2048 棋盘通常不需要滚动,所以我全部返回了handled。
5.3 响应式布局:让棋盘在手机和宽屏上都舒服
Flutter Web 运行在不同尺寸的浏览器窗口里,2048 棋盘如果固定 400 像素宽,在手机上会溢出,在宽屏显示器上又会显得特别小。
我的做法是用LayoutBuilder拿到当前可用空间,然后取宽高中较小值,作为棋盘边长参考:
LayoutBuilder( builder: (context, constraints) { double side = constraints.maxWidth < constraints.maxHeight ? constraints.maxWidth : constraints.maxHeight; side = side.clamp(280.0, 500.0); return ...; }, )clamp(280.0, 500.0)表示棋盘最小 280、最大 500 像素,在这个区间内自适应。这个范围我测试过:小于 280 时棋盘上的数字会挤成一团,大于 500 在桌面看反而显得空旷。你完全可以根据自己的审美调整。
5.4 动画细节:数字变大的“弹性反馈”
除了基础的格子过渡,2048 还有一个标志性体验:合并出来的大数字会在出现瞬间稍微“弹”一下。比较简单的实现方式是用TweenAnimationBuilder,当数字变化时给格子一个scale从 1.2 回到 1.0 的动画。
这个效果看着不起眼,但实际玩起来会明显感觉“游戏活了”。我用AnimatedScale实现,关键代码如下:
AnimatedScale( scale: _isNewlyMerged ? 1.2 : 1.0, duration: const Duration(milliseconds: 120), curve: Curves.easeOutBack, child: Container(...显示数字...), )这种动画的代价是要跟踪每个格子是否是“新合并出来的”,我在GameBoard里增加了一个lastMergedIndexes集合,每次 move 后把它更新为本次合并过的索引。UI 在渲染时根据这个集合决定给哪些格子加动画。注意,动画结束后要清空这个集合,否则会一直保持在放大状态。
6. 构建发布与完整代码:从 flutter build web 到线上可玩
功能写完只是第一步,真正把它发布成一个别人能打开的网页,中间还有一些流程。这一章我讲构建命令、文件结构和完整代码组织方式,算是一个可供复用的交付清单。
6.1 构建命令与常见构建问题
Flutter Web 的构建命令非常简单:
flutter build web但命令背后有几个要点:
- 默认生成的是渲染优化过的 release 版本,文件在
build/web目录下。 - 你可以通过
--web-renderer参数选择渲染器:canvaskit和html。Flutter 3 之后的默认行为倾向于 CanvasKit。CanvasKit 的优点是像素级一致性和复杂动画表现更好,缺点是首包体积较大。2048 这种简约风格游戏,用 CanvasKit 也行,实测加载后交互流畅度更好。 - 如果你发现
main.dart.js体积太大,可以看看项目里有没有不小心引入的不需要的依赖。2048 这个小项目按理说在 3MB 左右,加载速度可控。
构建结束后,build/web目录里会有一个index.html和一堆.js、.wasm、.png等资源文件。这个目录可以直接部署到任意静态资源服务器上。我一般会先在本地点开index.html检查一次,注意直接双击index.html通常无法正常运行,因为浏览器安全策略会限制本地文件访问,你需要用一个本地静态服务器来测试:
cd build/web python3 -m http.server 8080然后用浏览器访问http://localhost:8080。如果你不熟悉命令,也可以用 VS Code 的 Live Server 插件启动。
6.2 最终项目文件结构
这个项目最终的结构非常简洁,核心代码就两个文件:
lib/ ├── game_board.dart // 游戏核心逻辑:棋盘、移动、合并、随机生成、结束判定 └── main.dart // Flutter 入口:UI、手势、键盘、动画、弹窗整个项目还有平台相关的目录(web/、linux/、android/等),这些是flutter create自动生成的,本次没有改动。如果你想把项目分享给别人,最方便的方式是托管到 GitHub,别人git clone后直接flutter pub get && flutter run -d chrome就能跑。
6.3 game_board.dart:游戏核心逻辑完整代码
下面是我最终使用的game_board.dart,包含了我前面讲到的所有核心逻辑,代码量不大,但足够稳定:
import 'dart:math'; class GameBoard { static const int size = 4; final List<int> grid = List.filled(size * size, 0); int score = 0; final Set<int> lastMergedIndexes = {}; late Random _random; GameBoard({Random? random}) { _random = random ?? Random(); } int get(int row, int col) => grid[row * size + col]; void set(int row, int col, int value) { grid[row * size + col] = value; } List<int> getRow(int row) { return List.generate(size, (col) => get(row, col)); } List<int> getCol(int col) { return List.generate(size, (row) => get(row, col)); } void setRow(int row, List<int> values) { for (int col = 0; col < size; col++) { set(row, col, values[col]); } } void setCol(int col, List<int> values) { for (int row = 0; row < size; row++) { set(row, col, values[row]); } } List<int> mergeLine(List<int> line) { List<int> result = []; int addScore = 0; for (int i = 0; i < line.length; i++) { if (line[i] != 0) { result.add(line[i]); } } while (result.length < size) { result.add(0); } for (int i = 0; i < result.length - 1; i++) { if (result[i] == result[i + 1] && result[i] != 0) { result[i] *= 2; addScore += result[i]; result.removeAt(i + 1); result.add(0); } } return [result, addScore]; } bool _move(Direction direction) { lastMergedIndexes.clear(); int before = grid.join(','); int addScore = 0; for (int i = 0; i < size; i++) { List<int> line; switch (direction) { case Direction.left: line = getRow(i); break; case Direction.right: line = getRow(i).reversed.toList(); break; case Direction.up: line = getCol(i); break; case Direction.down: line = getCol(i).reversed.toList(); break; } List<Object> merged = mergeLine(line); List<int> newLine = merged[0] as List<int>; addScore += merged[1] as int; if (direction == Direction.right || direction == Direction.down) { newLine = newLine.reversed.toList(); } switch (direction) { case Direction.left: case Direction.right: setRow(i, newLine); break; case Direction.up: case Direction.down: setCol(i, newLine); break; } } if (grid.join(',') != before) { score += addScore; _addRandomTile(); return true; } return false; } bool moveLeft() => _move(Direction.left); bool moveRight() => _move(Direction.right); bool moveUp() => _move(Direction.up); bool moveDown() => _move(Direction.down); bool _addRandomTile() { List<int> empty = []; for (int i = 0; i < grid.length; i++) { if (grid[i] == 0) { empty.add(i); } } if (empty.isEmpty) { return false; } int index = empty[_random.nextInt(empty.length)]; grid[index] = (_random.nextDouble() < 0.9) ? 2 : 4; return true; } void addInitialTiles() { _addRandomTile(); _addRandomTile(); } bool isGameOver() { for (int i = 0; i < grid.length; i++) { if (grid[i] == 0) return false; } for (int row = 0; row < size; row++) { for (int col = 0; col < size - 1; col++) { if (get(row, col) == get(row, col + 1)) return false; } } for (int col = 0; col < size; col++) { for (int row = 0; row < size - 1; row++) { if (get(row, col) == get(row + 1, col)) return false; } } return true; } } enum Direction { left, right, up, down }在这份代码里,我没有实际使用lastMergedIndexes去保存具体索引,而是保留了一个空的集合接口,供 UI 层判断动画状态。如果你不需要动画弹跳效果,可以把集合逻辑全部删掉。反之,如果你要精确知道哪些格子是本次合并产生的,可以在_move的合并阶段记录索引,我留了这个接口就是为了方便扩展。
6.4 main.dart 的核心骨架
main.dart这部分我没有全部贴出来,因为 UI 代码很长,但骨架值得参考:
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'game_board.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, fontFamily: 'Arial', ), home: const Game2048Page(), ); } } class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State<Game2048Page> createState() => _Game2048PageState(); } class _Game2048PageState extends State<Game2048Page> { final GameBoard _board = GameBoard(); @override void initState() { super.initState(); _board.addInitialTiles(); } void _handleMove(Direction direction) { setState(() { bool moved = false; switch (direction) { case Direction.left: moved = _board.moveLeft(); break; case Direction.right: moved = _board.moveRight(); break; case Direction.up: moved = _board.moveUp(); break; case Direction.down: moved = _board.moveDown(); break; } if (_board.isGameOver()) { _showGameOverDialog(); } }); } void _showGameOverDialog() { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('游戏结束'), content: Text('最终得分:${_board.score}'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); setState(() { _resetGame(); }); }, child: const Text('再来一局'), ), ], ), ); } void _resetGame() { final newBoard = GameBoard(); _board.grid ..clear() ..addAll(newBoard.grid); _board.score = 0; _board.addInitialTiles(); } // ... build 方法省略,包含 LayoutBuilder、GestureDetector、Focus 和 GridView }注意_resetGame这个方法:我原本想直接_board = GameBoard(),但因为_board是final字段,不能重新赋值,所以只能清空grid后填充新棋盘的数据。这是改代码时最容易忽视的地方,如果你用 AI 生成,它可能贪方便直接把字段改成非 final,那样也没问题,只是一致性问题。
6.5 把完整代码粘贴到项目后怎么跑起来
如果你从这篇文章拿到了全部代码,操作顺序是这样:
flutter create --platforms web game_2048- 把
lib/game_board.dart复制到项目中。 - 把我提供的
main.dart骨架替换掉项目原有的main.dart,并补全build方法里的棋盘 UI 部分。 flutter run -d chrome调试。- 确认没问题后
flutter build web,部署build/web目录。
如果你不想从头敲这些代码,我也建议至少自己动手把game_board.dart抄一遍。这不是为了练打字,而是让你在抄的过程中体会每一步的逻辑判断,毕竟 AI 可以帮你生成代码,但“游戏逻辑对不对”这件事,最终只能靠你的脑子来判断。
最后说一句我在整个项目里最大的感受:工具确实能大幅缩短“从想法到成品”的距离,但前提是你要能清晰地说出自己想要什么。这个过程并不比写代码轻松,但当你把脑海里的算法拆解成一条条可描述的规则,然后看着 AI 把它们变成真正的可玩页面时,那种成就感是单纯“会写代码”给不了的。2048 只是一个起点,同样的思路,你可以继续挑战俄罗斯方块、扫雷,甚至更复杂的策略小游戏。希望我的这些踩坑记录能让你在尝试 AI 辅助开发时,少一点迷茫,多一点方向。