用Trae从零开发Flutter Web版2048:AI辅助编程实战记录

先交代一下背景:我用 Trae 从零开始写了一个 Flutter Web 版的 2048,从立项到最终能部署到静态站点上,前后花了一个下午加一个晚上。我本来以为这种小程序最难的是“合并逻辑”,结果真正花时间的全在细节上——比如和 Trae 对需求时规则没说清楚导致合并没有按预期执行、Flutter Web 在浏览器里的键盘焦点问题、CanvasKit 渲染下的加载体积优化。这篇就把完整过程拆开来讲,适合想了解 Trae 实际开发工作流的读者,也适合想用 2048 作为 Flutter Web 入门项目的朋友。

我没用现成模板,也没有去 GitHub 找别人写好的 2048 改一改,而是从创建 Flutter 工程开始,每一行核心逻辑都由 Trae 辅助完成。中间经历了几轮“我发现 bug、回贴提示词、Trae 修复、再验证”的循环,这些过程比代码本身更有参考价值。所以这篇文章不会只贴一份完整代码,而是会重点说明每一步我为什么这么设计、怎么和 Trae 沟通、最终落地时踩了哪些坑。

1. 为什么拿 2048 练手:它刚好卡在“玩具”和“工程”之间

2048 看起来简单,4x4 的网格、数字方块、上下左右滑动、相同数字合并。但如果你认真做一遍,会发现它的复杂度很微妙,刚好比普通 Demo 高一个档次,又比业务系统低很多。这种体量非常合适用来测一个 AI 编程工具的真实水平。

我把 2048 拆成了几个独立模块,每块都可以让 Trae 单独完成,然后再拼起来:

  • 棋盘数据模型:一个 4x4 的二维数组,所有移动和合并都是对数组状态的操作。
  • 游戏核心逻辑:判断游戏是否结束、能否移动、随机生成新方块(2 或 4)。
  • 界面渲染:Flutter 里的GridView或手写布局,按状态刷新数字块。
  • 动画层:合并和滑动时的过渡效果。
  • 交互层:移动端手势、PC 端键盘方向键。
  • 状态管理:游戏得分、最高分、重新开始、游戏结束弹窗。

其中“游戏核心逻辑”是最容易出 bug 的地方。很多人写 2048 时会在“合并”这个环节翻车——比如一行里出现[2, 2, 4, 0],按右移后,是按“先移动后合并”还是“边移动边合并”,两种思路结果完全不一样。这就是一个很典型的、适合在和 Trae 对话时讲清楚规则的点。

另一个原因是,2048 的状态流转非常适合单元测试。一个函数输入棋盘状态,输出移动后的状态,纯函数、无副作用。这意味着 Trae 生成的代码我能立刻写测试去验证,验证不过就贴错误信息让它继续改。这种“生成-测试-反馈”的循环,正是 AI 编程工具最擅长的地方。

和 Trae 配合开发这样的项目,我能直观看到它在三个维度上的表现:对 Flutter Widget 树和布局的熟悉程度、对 Dart 语言特性的把握(比如不可变数据、集合操作)、以及对游戏规则这种隐含业务逻辑的理解能力。坦白说,第一轮对话时我踩了一个坑:我以为 AI 会自动理解 2048 的规则,所以第一次提示词写得比较随意,让它“实现 2048 的核心逻辑”,结果合并逻辑写得一塌糊涂。后面我改用结构化描述,明确每一步的规则,代码质量立刻就上来了。这部分会在第 3 节详细展开。

2. 开局准备:装环境、建工程,明确 Trae 的工作边界

2.1 环境安装:比预想中简单,但要注意版本

我这边本机是 macOS,安装流程比较顺。如果你用 Windows,过程也类似,只是环境变量配置稍有一点点差异。

首先是 Flutter SDK。我用的是 stable 分支,当时最新稳定版本是 3.x。这里有个建议:尽量别用 beta 或 master 分支,因为 Trae 这类 AI 工具训练的语料大多基于稳定版本的 API,用非稳定分支时它给出的代码偶尔会踩到行为变更的地方,徒增调试成本。

flutter --version

确认 Flutter 已安装后,还需要确保 Web 支持已经启用。新版本的 Flutter 默认已经包含了 Web 支持,不需要像早期那样手动执行flutter config --enable-web。但保险起见可以看一眼:

flutter config --list

如果列表里enable-web是 true,就不用管。如果不是,执行一下:

flutter config --enable-web

然后是 Trae。我直接用官网下载的安装包,装完后登录账号。这里要注意一点:Trae 是以窗口 IDE 的形式工作的,底层类似 VSCode 的分支,所以如果你是 VSCode 用户,上手几乎没有学习成本。

2.2 创建 Flutter 工程,先别急着写代码

在让 Trae 写逻辑之前,我先把工程初始化好。这一步我没让 Trae 代劳,因为命令行操作直接自己跑更快:

flutter create game_2048 cd game_2048

创建完工程之后,我先跑了一次默认的 counter demo,确认 Flutter Web 在浏览器里能正常跑起来:

flutter run -d chrome

这里有个容易忽略的细节:flutter run -d chrome会启动开发调试模式,首次编译需要一点时间,但后续热重载非常快。我开发全程主要靠 Chrome 调试,没有在模拟器上跑,因为 2048 这种游戏本身就是桌面和移动通吃的交互模型,浏览器里能模拟移动端手势,也更接近最终 Web 发布的形态。

2.3 给 Trae 设定工作边界:哪些让它做,哪些自己写

这一步是我用 AI 编程工具一段时间后总结出的习惯。在打开 Trae 写第一段提示词之前,我在心里先做了分工:

  • 交给 Trae:棋盘数据模型、核心逻辑、基础 UI 布局、动画实现、手势识别、键盘事件。
  • 自己来:项目初始化、样式细节微调、性能优化、构建发布,以及和浏览器特性相关的兼容性处理。

这个分工不是绝对的,但有一条原则很关键:涉及项目骨架的部分,AI 生成后你要亲自 review;涉及独立函数的逻辑,可以放手让 AI 去写。因为骨架错误会导致后续所有代码都在错误的架构上叠加,改起来成本极高;而独立函数就算有问题,报错信息也会直接告诉你哪里不对。

另外,我在 Trae 里开了新的会话,专门给这个项目用。这样做的原因是避免上下文污染——AI 编程工具在一个会话里聊得太久,容易记混你之前的需求。每个大功能模块开一个新会话,效果会好很多。

3. 和 Trae 对需求:写提示词比写代码更需要动脑

3.1 第一轮对话:我犯了太相信“常识”的错

我的第一轮提示词写得特别随意:

帮我实现一个 2048 游戏的核心逻辑,使用 Dart 语言,要有移动、合并、生成随机方块和判断游戏结束的函数。

结果 Trae 确实生成了完整代码,结构也看着像模像样,但跑起来之后我发现了一个经典错误:当一行是[2, 2, 4, 0]时,向左移动后它给出的结果是[4, 4, 0, 0],而正确的 2048 规则下应该是[4, 4, 0, 0]没有错,但换成[4, 4, 4, 0]时它合并成了[8, 4, 0, 0],这个是对的。换成[2, 2, 2, 2]时它变成了[4, 4, 0, 0],这个也没问题。真正出错的是[2, 2, 2, 0]——它给的是[4, 2, 0, 0],符合规则;但[2, 0, 2, 2]它给的答案是[4, 0, 2, 0],这个不对,因为移动后应该先把所有非零元素堆到一侧,也就是变成[2, 2, 2, 0],这时候再合并才得到[4, 2, 0, 0]。

问题出在它没有执行“先移动、后合并”的两步流程。很多人觉得 2048 的核心就是遍历数组然后合并,其实不是。正确逻辑是:

  1. 先把这一行所有非零元素取出来,按方向排列。
  2. 对取出的元素从左到右(或从右到左)进行“相邻相同则合并”操作。
  3. 合并后的结果补零到固定长度。

比如[2, 0, 2, 2]向左移动:

  • 取非零元素:[2, 2, 2]
  • 合并:位置 0 和 1 都是 2,合并为 4;位置 2 是 2,保留。得到[4, 2]
  • 补零:[4, 2, 0, 0]

这样写就完全规避了顺序问题。

3.2 如何把规则“翻译”成 AI 能理解的结构化描述

发现错误后,我没有直接说“你错了,应该先移动再合并”,而是把规则改成结构化描述,再次发给 Trae:

实现一个 2048 游戏的移动和合并逻辑。规则如下:

  1. 每次移动前,先把行/列中的所有非零元素取出,按移动方向排列。
  2. 从排列后的第一个元素开始,如果相邻两个元素值相同,则合并为一个元素(值为两数之和),每个元素在一次移动中只能被合并一次。
  3. 合并完成后,将结果放回原数组长度,不足位置补 0。
  4. 如果移动前后数组没有任何变化,则视为无效移动。

这样一写,Trae 生成的代码就正确了。核心函数大致长这样:

List mergeLine(List line) { // 先去掉所有 0,按行方向排列 List tiles = line.where((e) => e != 0).toList(); List result = []; for (int i = 0; i < tiles.length; i++) { if (i + 1 < tiles.length && tiles[i] == tiles[i + 1]) { result.add(tiles[i] * 2); i++; // 跳过被合并的元素 } else { result.add(tiles[i]); } } // 补零到原长度 while (result.length < 4) { result.add(0); } return result; }

这个函数最关键的两行是tiles[i] * 2和i++。i++保证了每个元素在一次移动中只会参与一次合并。如果你去掉这行,[4, 4, 8, 0]这种输入就会被错误地合并成[16, 0, 0, 0]。

3.3 二次对话:让 Trae 处理四个方向的共性问题

移动方向有四个,但如果直接让 Trae 写四个函数,代码会重复且冗余。更好的做法是定义好方向规则,利用矩阵转置来处理上下方向。

第二轮对话我给的提示词是:

2048 游戏有上、下、左、右四个方向的移动。请实现四个方向的移动逻辑。要求:

  • 左移动直接对每行调用 mergeLine。
  • 右移动对每行先反转,再调用 mergeLine,最后再反转。
  • 上移动将矩阵转置后,对每行调用 mergeLine,再转置回来。
  • 下移动将矩阵转置后,对每行先反转,再调用 mergeLine,再反转,最后转置回来。 请用 Dart 实现,不要重复造轮子。

Trae 很快给出了代码。核心也就是这两个辅助函数:

List> transpose(List> grid) { return List.generate(4, (i) => List.generate(4, (j) => grid[j][i])); } List> reverseLines(List> grid) { return grid.map((row) => row.reversed.toList()).toList(); }

方向处理变成了四个非常轻量的组合函数。这套设计的可读性相当好,后期我想要增加“撤销”功能时,也只需要在移动前保存一份棋盘快照,整体侵入性很小。

3.4 让 Trae 判断“能不能移动”和“游戏是否结束”

接着我继续让 Trae 实现了两个辅助判定函数:

  • canMove(grid):棋盘里是否有空格,或者任意相邻格子值相同。
  • isGameOver(grid):在canMove返回 false 时游戏结束。

这两个函数可以和move逻辑共享判断规则。我给 Trae 的提示词是:

请实现 canMove 和 isGameOver 两个函数。canMove 返回布尔值,表示当前棋盘是否还有可移动的空间;isGameOver 表示游戏是否结束。注意:isGameOver 等于 !canMove,不要重复实现逻辑。

Trae 给出的实现类似:

bool canMove(List> grid) { for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (grid[i][j] == 0) return true; if (j < 3 && grid[i][j] == grid[i][j + 1]) return true; if (i < 3 && grid[i][j] == grid[i + 1][j]) return true; } } return false; }

这个逻辑不难,但让 AI 写能节省一点时间,更重要的是验证 AI 是否理解了我刚才定的规则。验证方式很简单:写一个单测输入一个“没有空格且相邻没有相同值”的棋盘,例如:

[ [2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2] ]

canMove应该返回 false。如果 AI 的逻辑漏掉了相邻格子判断,这个用例会第一时间暴露问题。

4. 核心逻辑落地:数据模型、状态管理和 UI 解耦

4.1 数据模型:用不可变棋盘还是可变棋盘?

和 Trae 讨论数据模型时,我明确让它设计一个Game2048类,持有List> _grid和int _score。

这里有一个设计选择:是让棋盘状态不可变(每次移动生成新棋盘),还是可变(直接修改原棋盘)?

对于 Flutter 的setState模式,可变棋盘是更自然的选择。因为 UI 层只需要在每次移动后调用setState通知刷新即可。而不可变棋盘更适合 Redux 或 Riverpod 这类状态管理方案。我这次选择的是可变棋盘 +ChangeNotifier,原因很简单:这是 Flutter 里最基础、最容易理解的状态管理方式,Trae 对这个模式也最熟。

class Game2048 extends ChangeNotifier { List> _grid = List.generate(4, (_) => List.filled(4, 0)); int _score = 0; bool _gameOver = false; List> get grid => _grid; int get score => _score; bool get gameOver => _gameOver; void reset() { _grid = List.generate(4, (_) => List.filled(4, 0)); _score = 0; _gameOver = false; addRandomTile(); addRandomTile(); notifyListeners(); } }

4.2 随机生成方块:几行代码里的概率陷阱

新方块生成的规则很简单:在空位里随机挑一个位置,以 90% 概率放 2,10% 概率放 4。这个逻辑我让 Trae 一次性生成,但后来检查时发现一个问题:它用Random随机选位置时,有极小概率选不到有效空位,导致死循环风险。

Trae 一开始给的实现是:

void addRandomTile() { final random = Random(); while (true) { final row = random.nextInt(4); final col = random.nextInt(4); if (_grid[row][col] == 0) { _grid[row][col] = random.nextInt(10) == 0 ? 4 : 2; break; } } }

这个实现如果棋盘只有一个空位,会循环很多次才能随机命中。虽然概率上不会无限循环,但这不是最优写法。我让 Trae 优化成“先收集所有空位,再从空位列表中随机选一个”:

void addRandomTile() { final emptyCells = <(int, int)>[]; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (_grid[i][j] == 0) emptyCells.add((i, j)); } } if (emptyCells.isEmpty) return; final random = Random(); final (r, c) = emptyCells[random.nextInt(emptyCells.length)]; _grid[r][c] = random.nextInt(10) == 0 ? 4 : 2; }

这种优化在棋盘刚开局时看不出来,但到游戏后期棋盘快满了,差距会非常明显。我和 Trae 沟通这类优化时,通常会在提示词里点名写“不要使用随机尝试直到成功的方式,要先收集候选列表”,这样 AI 会直接给出正确方案。

4.3 UI 布局:让 Trae 用 Flutter Widget 还原网格

棋盘 UI 我让 Trae 用LayoutBuilder+AspectRatio做了自适配。关键是外层一个正方形网格,内部 4x4 个格子。

Trae 生成的布局大概是这样的结构:

  • Column包含得分区和棋盘区。
  • 棋盘区用LayoutBuilder拿到可用宽高,取较小值设为棋盘尺寸。
  • 每个格子用Expanded均分宽度。
  • 每个格子的背景色根据数值大小从浅到深变化。
  • 数字 2 和 4 用深色文字,大数字用白色文字。

颜色映射表直接让 Trae 按经典 2048 的配色生成。这里我给它的提示词是:

每个格子的背景色根据数值大小变化:2 使用浅黄色,4 使用淡橙色,8 使用橙色,16 使用橙红色,32 使用红色,64 使用深红,128 及以上使用黄色系渐变。文字颜色请根据背景亮度自适应调整。

实际上 Trae 生成的配色表基本不用改动。唯一的微调是 128 以上的字体会显得过大,我在FittedBox里加了自适应缩放。

Widget buildTile(int value) { return Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( color: _bgColor(value), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: value == 0 ? null : FittedBox( fit: BoxFit.scaleDown, child: Text( '$value', style: TextStyle( fontSize: 36, fontWeight: FontWeight.bold, color: value <= 4 ? Colors.black54 : Colors.white, ), ), ), ); }

4.4 状态管理:ChangeNotifier 和 Listener 的配合

我把Game2048类设计为ChangeNotifier,在 Widget 里用ListenableBuilder监听变化,每次移动更新后自动重建棋盘区域。这里有个性能细节:整个棋盘每次移动都会完全重建一次,但在 4x4 的规模下完全没问题,不需要做更精细的局部更新。

这个设计也方便以后加“动画”。每次移动时,我开始是直接刷新数据,后来发现没有动画的 2048 玩起来很生硬。于是让 Trae 在移动函数中给每个 tile 加一个位移偏移量,然后用AnimatedPositioned或AnimationController去驱动。

动画这块我放在第 5 节展开,这里先不过多展开。

5. 交互和动画:键盘、手势和 Flutter Web 的坑

5.1 键盘输入的焦点问题:Flutter Web 特有的坑

我在 PC 上开发,第一件事就是让键盘方向键能控制游戏。Trae 给的建议是用KeyboardListener或Focus包裹棋盘区域,监听onKeyEvent。

实际操作后发现,Flutter Web 页面加载后,焦点并不自动落在Focus节点上,必须点一下页面才能响应按键。这个问题不是 Trae 生成的代码有误,而是 Flutter Web 的焦点机制决定的。

我的做法是在initState里给FocusNode请求焦点:

@override void initState() { super.initState(); _focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); }

同时在GestureDetector上支持点击,点击时也请求焦点,这样用户随便点一下棋盘或页面,方向键就能用。

这里还遇到一个细节:浏览器页面自身有一些快捷键行为。比如 Safari 里按方向键会滚动页面,Chrome 里也有类似行为。解决方式是在onKeyEvent里对方向键事件调用event.event.preventDefault()。Flutter 的KeyEvent没有直接暴露 preventDefault 方法,我换用了HardwareKeyboard提供的KeyEvent处理。确切说,我让 Trae 用的是Focus( onKeyEvent: (node, event) { if (event is KeyDownEvent) { // 处理方向键 } return KeyEventResult.handled; }, )。返回KeyEventResult.handled会阻止事件继续冒泡,也就避免了浏览器默认行为。

5.2 移动端手势:原生手势 vs 自定义手势判断

Flutter 里没有原生的“滑动方向”手势,但我可以用GestureDetector的onPanEnd或onHorizontalDragEnd/onVerticalDragEnd来监听。因为 2048 只关心上下左右四个方向,不关心滑动距离,只关心滑动方向。

我让 Trae 实现的方式是:在onPanStart记录起始点,在onPanEnd比较end - start的向量,取 x 轴和 y 轴中位移更大的那个方向作为移动方向。

Offset _startPos = Offset.zero; void _onPanStart(DragStartDetails details) { _startPos = details.globalPosition; } bool _onPanEnd(DragEndDetails details) { final dx = details.globalPosition.dx - _startPos.dx; final dy = details.globalPosition.dy - _startPos.dy; if (dx.abs() < 20 && dy.abs() < 20) return false; // 过滤误触 if (dx.abs() > dy.abs()) { _move(dx > 0 ? Direction.right : Direction.left); } else { _move(dy > 0 ? Direction.down : Direction.up); } return true; }

20 像素的阈值是目前最舒服的值。太低了容易误触,太高了滑动不灵敏。

5.3 动画:让 Trae 给 tile 加过渡效果

2048 体验不好的一个重要原因是合并和滑动看起来“太瞬间”。我让 Trae 加了两层动画:

  1. 滑块位移:每次移动后,tile 的新位置应该从旧位置平滑过渡过去。
  2. 新生成方块的缩放淡入:每次新增的 2 或 4 应该从小变大出现。
  3. 合并时的“弹一下”效果:两个格子合并成一个时,新格子有短暂放大再缩回的效果。

实现位移动画我采用的是“给每个 tile 用 Stack + AnimatedPositioned 定位”的方案。Trae 生成的代码结构大致是:

Stack( children: boardTiles.map((tile) => AnimatedPositioned( duration: const Duration(milliseconds: 150), curve: Curves.easeOut, left: tile.x * tileSize, top: tile.y * tileSize, child: _buildTile(tile), )).toList(), )

这里有一个需要特别注意的点:合并后的数字是立即变化的,如果只靠位置动画,两个 tile 会在同一时刻重叠并变成新数字,观感上没有“合并”的爆发感。后来我加了合并动画层:在合并发生的那一帧,对新生成的 tile 施加一个从 1.0 放大到 1.2 再回到 1.0 的 scale 动画。这个效果需要AnimationController,Trae 生成的是使用TweenSequence的版本。

不过我后来发现,TweenSequence在 Web 端某些浏览器下可能出现一个轻微卡顿,原因是动画驱动导致重建整个棋盘。我的优化方式是只在合并的 tile 上做动画,而不是对整个棋盘做。

这一轮的提示词大概是:

给 2048 添加滑动和合并动画。滑动持续 150ms,合并后的新 tile 需要在 100ms 内从 1.0 放大到 1.2,再在 50ms 内回到 1.0。请用 Flutter 动画实现,保持代码简洁。

Trae 给出的实现里使用了AnimatedScale的思路,说实话它不是直接用AnimatedScale,而是用ScaleTransition+ 一个_AnimationController。核心代码能跑,但性能一般,我在性能优化那一步做了重点调整。

5.4 无效移动的处理:别让动画和状态不同步

2048 有个隐式规则:如果移动方向没有导致任何变化,不生成新方块,也不切换回合。一开始 Trae 生成的move函数会在每次按键后无条件调用addRandomTile,这就导致了“无效移动也会出现新方块”的 bug。

修复很简单:在移动后对比移动前后的棋盘状态,如果完全一样,则直接返回,不生成新方块。

bool move(Direction direction) { final before = _grid.map((row) => List.from(row)).toList(); // 执行移动逻辑... if (_isSameGrid(before, _grid)) { return false; } addRandomTile(); if (!canMove(_grid)) { _gameOver = true; } notifyListeners(); return true; }

这个改动看似不起眼,但直接影响游戏节奏。如果无效移动也生成新方块,游戏难度会失衡,后期玩家需要精确操作时会被莫名塞方块。

6. 调试、构建和发布:把 Trae 生成的代码推向生产环境

6.1 单元测试的重要性:AI 代码尤其需要验证

我前面说过,2048 的核心逻辑是纯函数,所以非常适合写单元测试。我让 Trae 也生成了一份测试代码。不过我没有完全信任它,而是自己先梳理了测试用例。

关键用例至少包括:

  • 左移:[2, 2, 2, 0]→[4, 2, 0, 0]
  • 右移:[0, 2, 2, 2]→[0, 0, 2, 4]
  • 一次移动合并一次:[2, 2, 2, 2]→ 左移[4, 4, 0, 0]
  • 空棋盘添加随机方块:只添加一个方块
  • 游戏结束判断:满格且无可合并时返回 true
test('mergeLine 左移合并', () { final result = mergeLine([2, 2, 2, 0]); expect(result, [4, 2, 0, 0]); }); test('mergeLine 一次移动只合并一次', () { final result = mergeLine([2, 2, 2, 2]); expect(result, [4, 4, 0, 0]); }); test('canMove 满格不结束', () { final grid = [ [2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2] ]; expect(canMove(grid), false); });

执行:

flutter test

这些测试帮我在改动动画代码后,能快速确认核心逻辑没被碰坏。AI 编程的代码往往看起来对,但边界情况非常容易埋雷,没有测试兜底,我是不敢直接发布的。

6.2 和 Trae 沟通 Bug:把现象和期望结果一起贴给它

开发过程中,我最常用的一种反馈方式是:把错误现象、当前代码片段、期望结果一起发到 Trae 对话框里。比如遇到移动方向反了的问题,我的提示词是:

按下方向键上时,方块向下移动了。请检查 move 方法中方向和矩阵坐标的映射关系。目前移动代码用 dx 和 dy 表示位移,但键盘方向对应的是 -1, 0 / 1, 0 / 0, -1 / 0, 1 的偏移,请修正并确保上移是行号减小。

这种提示方式比单纯说“方向反了”有效得多。Trae 看到具体的方向映射关系后,基本一次就能改对。

还有一个经典问题:移动后得分没有累加。这个 bug 相当隐蔽,因为mergeLine函数只返回新数组,没有返回合并产生的分数。我让 Trae 把 mergeLine 改成返回记录和新数组合在一起的方案:

({List line, int score}) mergeLineWithScore(List line) { int gained = 0; List tiles = line.where((e) => e != 0).toList(); List result = []; for (int i = 0; i < tiles.length; i++) { if (i + 1 < tiles.length && tiles[i] == tiles[i + 1]) { result.add(tiles[i] * 2); gained += tiles[i] * 2; i++; } else { result.add(tiles[i]); } } while (result.length < 4) { result.add(0); } return (line: result, score: gained); }

Dart 3 的 record 语法在这里确实很好用。Trae 对这种新语法支持得也还可以,没出什么错。

6.3 构建体积优化:从 6MB 到 2MB

flutter build web默认使用 CanvasKit 渲染器,这个渲染器效果更好,但加载体积也大。我构建后的输出文件里main.dart.js有 1.9MB,CanvasKit相关资源有 3.5MB,合计接近 6MB。作为一个小游戏,这个体积有点说不过去。

我的优化方案包括:

  1. 开启--web-renderer html模式?实测后发现 html 模式在很多浏览器上的渲染字体和 CanvasKit 有差异,但体积确实更小。最终我还是选择保留 CanvasKit,因为 2048 这种游戏的动画在 CanvasKit 下更流畅,兼容性也更好。
  2. 开启 gzip 压缩。静态服务器对main.dart.js启用 gzip 后,实际传输体积降到 600KB 左右,加载速度已经可以接受。
  3. 用flutter build web --release而不是 debug 模式构建,release 模式的 treeshaking 会让 JS 体积小很多。

发布时我用的是 GitHub Pages,直接把build/web目录传到 gh-pages 分支。这个流程本身很简单,和 Flutter 或者 Trae 无关,但如果你用其他静态托管平台,比如 Cloudflare Pages 或 Vercel,流程是一样的。

一个实测中需要注意的细节:2048 的网页要适配不同屏幕尺寸。我在布局上用了LayoutBuilder,让棋盘始终保持在视口的 90% 宽度以内,同时限制最大宽度为 500 像素,避免在大屏上棋盘显得过大。这个适配在手机上也没问题。

6.4 后续可能的功能扩展

做完基础版后,这个项目还可以扩展,而 Trae 能一件件帮你实现。我在考虑的是:

  • 撤销功能:保存每次移动前的棋盘快照,支持最多撤销一次。
  • 动画回放:记录每一步操作,支持回放整个游戏过程。
  • 最高分持久化:用shared_preferences保存本地最高分,Web 端也可以用 localStorage。
  • AI 自动玩:写一个简单的启发式算法,让电脑自动玩 2048。

其中“AI 自动玩”这个扩展很有意思,核心算法是从当前棋盘开始,模拟每种移动方向并评估结果棋盘的“空位数”和“平滑度”,然后选择最优方向。这类算法代码本身不复杂,让 Trae 写完全没有问题。

7. 几点个人体会

用 Trae 写这个项目的过程中,我最深的体会是:它不是一个能替你思考的工具,而是一个能帮你把思考快速落地的工具。尤其是从 0 到 1 的项目,你脑子里得先有“2048 该怎么做”的草图,知道哪些模块怎么切分、哪些规则必须说清楚,然后 Trae 才能把你的意图翻译成代码。

具体来说,有三条经验是可以复用的:

第一,提示词里要写“规则”,而不是“意图”。如果你只说“实现 2048 的逻辑”,AI 会自动脑补一套自认为正确的规则,但未必是你要的。把“先移动后合并”“每次移动只能合并一次”“无效移动不生成新方块”这些动作边界写明白,生成结果基本不需要返工。

第二,让 AI 生成代码后,一定要自己写测试用例去验证。AI 编程最怕的不是它写不出来,而是它写得“看起来对但边界全错”的代码。2048 这种规则明确、状态可穷举的游戏,是最适合用单元测试兜底的场景。测试通过,后面加动画、加手势、加发布配置才敢放手改。

第三,把大任务拆成小任务,分多次和 Trae 对话。一次对话塞太多需求,容易生成一堆“缝合怪”代码。我这次是按“数据模型 → 移动合并 → 随机生成 → 判定 → UI → 动画 → 发布”这样逐块拆的,每块一个小目标,每块都验证通过后再进下一个。效率和准确率都比一上来让它生成整个项目高很多。