用Trae开发Flutter Web版2048:AI辅助编程全流程实战

用Trae开发Flutter Web版2048:AI辅助编程全流程实战 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每个格子里要么是空要么是一个数字方块。大多数教程会用一个ListListint也就是一个包含 4 个数组的数组每个数组代表一行。但我在这个项目里选择了更简单的方案一个长度为 16 的Listint通过索引row * 4 col来定位格子。为什么这样选因为移动和合并算法本质上是在处理“一行或一列的四个值”如果用一个扁平数组我只需要写一个针对“4 个元素列表”的处理函数然后从不同方向提取行、列处理完再填回去。这比在二维数组里来回操作要少很多边界判断。class GameBoard { static const int size 4; final Listint 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 个整数输出是新列表和本次得分增量ListObject mergeLine(Listint line) { Listint 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) { Listint line []; for (int col 0; col 4; col) { line.add(get(row, col)); } ListObject merged mergeLine(line); Listint newLine merged[0] as Listint; score merged[1] as int; for (int col 0; col 4; col) { set(row, col, newLine[col]); } } }其他三个方向可以通过坐标映射实现向右移动是“把每行反过来处理再反回去”向上移动是“把每一列提取出来当成行处理”。我在项目里用一个direction参数统一处理而不是复制粘贴四份代码这样后续如果想增加新方向或者调整逻辑只需要改一处。3.4 随机生成新方块与游戏结束判定每次有效滑动之后需要在空白位置随机生成一个方块。这里有两个细节值得注意必须只在有效移动后生成如果用户往左滑但棋盘没有任何变化就不应该生成新方块否则会白白增加格子占用。新方块 90% 概率是 210% 概率是 4这是原版游戏的规则符合大多数玩家的体验预期。bool addRandomTile() { Listint 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 Listint grid List.filled(size * size, 0); int score 0; final Setint 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; } Listint getRow(int row) { return List.generate(size, (col) get(row, col)); } Listint getCol(int col) { return List.generate(size, (row) get(row, col)); } void setRow(int row, Listint values) { for (int col 0; col size; col) { set(row, col, values[col]); } } void setCol(int col, Listint values) { for (int row 0; row size; row) { set(row, col, values[row]); } } Listint mergeLine(Listint line) { Listint 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) { Listint 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; } ListObject merged mergeLine(line); Listint newLine merged[0] as Listint; 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() { Listint 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 StateGame2048Page createState() _Game2048PageState(); } class _Game2048PageState extends StateGame2048Page { 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 辅助开发时少一点迷茫多一点方向。