周末花了大半天时间,我用 Trae 这个 AI IDE 从零写出了 2048 的 Flutter Web 版本,累计核心代码不到 300 行,玩法完整、支持键盘也支持手势,构建后就是一个纯静态站点,丢到任意静态托管上都能直接运行。这篇我把项目的完整思路、全部代码和踩坑过程一次整理出来。如果你正想尝试 AI 辅助开发,或者想快速上手 Flutter Web,这份实操记录可以直接照着抄,省掉不少弯路。
1. 项目背景与整体设计:为什么是 2048,又为什么是 Flutter Web
1.1 选型思考:2048 是练手项目的黄金标准
做练手项目最怕两件事:需求太简单导致没什么可学的,或者需求太复杂导致做一半就放弃。2048 恰好站在中间那个最佳位置。它的规则全世界都知道,四句话能讲完:4x4 棋盘、滑动合并相同数字、每次移动后随机生成新数字、凑出 2048 就赢。规则清晰意味着你不用花时间理解需求,可以把全部精力放在算法实现和界面交互上。
更重要的是,2048 的玩法背后有一套非常典型的状态循环:用户输入、棋盘变换、新块生成、胜负判定。这个循环几乎涵盖了所有小游戏甚至大部分前端应用的核心结构。相比之下,贪吃蛇的难点在“移动时序”,俄罗斯方块难在“方块旋转和消行检测”,五子棋虽然逻辑简单但想做好 AI 对手就很深了。只有 2048,算法复杂度刚好卡在一个下午能啃下来的区间,做出来之后又能明显感觉到自己“真会写游戏逻辑了”。
1.2 技术栈:Trae 负责写,Flutter Web 负责跑
这次选 Trae 作为开发工具,核心原因是它把“和 AI 结对编程”这件事的门槛降到了很低。它本身就是一款 IDE,不是插件,打开项目就能在右侧对话框里直接让 AI 生成代码、修改文件、跑终端命令,还区分了 Chat 模式和 Build 模式。Chat 模式适合问答和解释代码,Build 模式则会真正动手改项目文件,这种“AI 帮你写文件、你在编辑器里审查”的工作流,比传统的复制粘贴提示词高效得多。
Flutter Web 则是另一个关键选择。Flutter 本身是跨端 UI 框架,一套 Dart 代码能编译成 iOS、Android、Web、桌面等平台应用,其中 Web 产物是纯静态的 HTML、JS、CSS,部署成本极低。对游戏场景来说,Flutter 的声明式 UI 和统一的 Widget 树让“棋盘网格”这种布局实现起来非常自然,一个 GridView 就能搞定;而且 Dart 是强类型语言,配合空安全机制,AI 生成的代码如果类型写错了,编译期就会直接报错,而不是运行时才炸,这对 AI 辅助开发太友好了。
1.3 核心规则与数据建模
先把我实现的核心规则固定下来,后面所有代码都是围绕这套规则展开的:
- 棋盘是 4x4,每个格子存放一个 2 的幂次方数字,0 表示空。
- 向某个方向滑动后,所有数字方块向该方向靠拢,相邻且值相等的两个方块恰好合并一次,合并结果为两者之和。
- 每次“有效移动”之后,在随机空位生成一个新方块,90% 概率生成 2,10% 概率生成 4。
- 合并产生的数值累加到总分;一旦出现 2048 方块判定为胜利;棋盘没有空格且不存在任何相邻等值方块时判定为游戏结束。
数据模型我直接选了List<List<int>>,两层列表表示 4x4 棋盘。用二维数组而不是一维扁平数组,主要是为了直观:_board[r][c]直接对应屏幕上第 r 行第 c 列的格子,读代码的人不需要做下标换算。Flutter 的 setState 机制对嵌套列表的修改也足够友好,只要保证在状态变更后调用刷新即可。
2. 环境准备与项目初始化:装好工具链,跑通 Hello World
2.1 Flutter SDK 安装与 Web 支持开启
如果你之前没装过 Flutter,这一步需要一个多小时,重点是网络条件和耐心。装完后在终端验证一下:
flutter doctor这个命令会检查 Flutter SDK、Dart、编辑器插件等环境是否齐全,缺什么它会直接提示。看 Flutter 版本时注意 3.7 以上的版本对 Web 支持已经很成熟,直接用即可。
接着开启 Web 支持并创建项目:
flutter config --enable-web flutter create --platforms web game2048 cd game2048 flutter run -d chrome如果一切正常,浏览器会自动打开一个 Flutter 默认的计数器 Demo 页面。看到这个页面,说明你的工具链已经通了。这一步我没让 Trae 参与,因为它是纯环境操作,AI 帮不上什么忙,反而是手动跑一遍能确认每个环节都没问题,后面出了问题也好定位。
2.2 安装 Trae 并导入项目
Flutter 项目创建好之后,打开 Trae,选择“打开文件夹”,把 game2048 这个目录加载进来。Trae 的界面布局对 VS Code 用户非常友好,左侧是文件树,中间是编辑器,右侧是 AI 对话面板。面板顶部能看到 Chat 模式和 Build 模式的切换入口,这两个模式的区别决定了 AI 的“权限”:Chat 模式只回答不改文件,Build 模式会直接按你的要求写代码、改文件、跑命令。
我第一次用的时候在两个模式之间来回切了好几次,后来总结出一个适合自己的方法:让 AI 动手之前,先用 Chat 模式把需求和方案对齐,确认理解没有偏差;方案清楚了再切到 Build 模式,让它一次改到位。这样能减少很多来回返工。
2.3 用好 Trae 的第一条提示词
当项目打开后,我给 Trae 发出了第一条需求:
请帮我在 lib/main.dart 里写一个 2048 小游戏,Flutter Web 版本,要求: 1. 4x4 棋盘,经典 2048 配色 2. 支持键盘方向键和 WASD 控制,也支持鼠标拖拽和手机触屏滑动 3. 有分数显示、最高分显示、重新开始按钮 4. 每次有效移动后随机生成 2 或 4,90% 概率生成 2 5. 出现 2048 判定胜利,无空位且无法合并则游戏结束这里有个细节:提示词里我把“有效移动”这个条件点出来了。如果不强调这一点,很多 AI 生成的版本会在无效滑动后也生成新方块,玩起来感觉“棋盘在乱长”。游戏规则的描述越精确,AI 生成的代码越接近预期。
Trae 切到 Build 模式后,它会自动把代码写进 main.dart。这时候不要着急运行,先把代码从头到尾读一遍。我的习惯是重点看三处:有没有多余依赖、空位生成逻辑、移动后棋盘是否真的发生了变化。AI 写的代码通过编译不代表逻辑正确,尤其游戏状态类代码,必须靠人眼审查一遍。
3. 核心逻辑拆解:滑动、合并、生成与胜负判定
3.1 状态设计与棋盘表示
游戏的状态管理是整个项目的核心,所有玩法都围绕这些字段展开:
static const int gridSize = 4; final Random _rng = Random(); late List<List<int>> _board; int _score = 0; int _best = 0; bool _gameOver = false; bool _won = false;_board是 4x4 的二维列表,每个元素存格子里的数字,0 表示空位。用 0 表示空位有个好处,数字本身就是 2 的幂,可以直接用来做 UI 配色映射,0 单独处理成背景色即可。_score是当前局分数,_best是最高分,这两个分开存是为了“重新开始”时不重置最高分。_gameOver和_won是终局状态标志,状态一旦置位,后续移动直接忽略。
3.2 一维合并算法:去零、合并、补零
2048 最核心的算法其实是处理“一行数组”的逻辑。一次滑动,本质上是把一行里的数字先去掉空格挤到一侧,再把相邻的相同数字合并一次,最后在末尾补零补满四个格子。
举个例子,一行数据如果是[2, 0, 2, 2],向左滑动后应该是[4, 2, 0, 0]。分三步看:去零得到[2, 2, 2],合并相邻相同的 2 和 2 得到 4,剩下的 2 继续放在后面,得到[4, 2],最后补零成[4, 2, 0, 0]。
合并的关键是用 while 循环配合指针跳跃:
({List<int> line, int gained, bool reachedWin}) _mergeLine(List<int> input) { final List<int> nonZero = input.where((v) => v != 0).toList(); final List<int> merged = []; int gained = 0; bool reachedWin = false; int i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { final int newValue = nonZero[i] * 2; merged.add(newValue); gained += newValue; if (newValue == 2048) reachedWin = true; i += 2; // 合并后跳过下一个,保证同一轮只合并一次 } else { merged.add(nonZero[i]); i++; } } while (merged.length < gridSize) { merged.add(0); } return (line: merged, gained: gained, reachedWin: reachedWin); }i += 2这一行是这个函数最容易出错的地方。它保证了合并过的数字不会继续参与后面的合并。如果去掉这行,[2, 2, 2, 2]会被错误地处理成[8, 0, 0, 0],而正确的结果是[4, 4, 0, 0]。原版 2048 的规则是“每次移动每个格子最多参与一次合并”,这个限制必须靠指针跳跃来保证。
另外我把本次合并“得了多少分”和“是否出现了 2048”也在这个函数里一并统计返回,而不是在外部再遍历一遍棋盘。这样移动逻辑可以只用一次遍历就拿到全部需要的数据,状态更新干净利落。
3.3 把四个方向统一成“线处理”
有了上面这个一维合并函数,四个方向的移动就变成了一个“取线、合并、写回”的问题。这个思路借鉴了矩阵转置的手法:向左滑动时处理每一行,向右滑动时把行反转后当左滑处理,向上滑动时取每一列,向下滑动时把列反转后当上滑处理。
void _move(Direction dir) { if (_gameOver || _won) return; final List<List<int>> allLines = []; for (int i = 0; i < gridSize; i++) { switch (dir) { case Direction.left: allLines.add(List<int>.from(_board[i])); break; case Direction.right: allLines.add(_board[i].reversed.toList()); break; case Direction.up: allLines.add(List<int>.generate(gridSize, (r) => _board[r][i])); break; case Direction.down: allLines.add(List<int>.generate(gridSize, (r) => _board[r][i]).reversed.toList()); break; } } int totalGained = 0; bool reachedWin = false; final List<List<int>> newLines = []; for (final line in allLines) { final result = _mergeLine(line); newLines.add(result.line); totalGained += result.gained; if (result.reachedWin) reachedWin = true; } bool changed = false; for (int k = 0; k < gridSize; k++) { List<int> line = List<int>.from(newLines[k]); if (dir == Direction.right || dir == Direction.down) { line = line.reversed.toList(); } if (dir == Direction.left || dir == Direction.right) { if (!_listEquals(_board[k], line)) changed = true; _board[k] = line; } else { final List<int> oldCol = List.generate(gridSize, (r) => _board[r][k]); for (int r = 0; r < gridSize; r++) { _board[r][k] = line[r]; } if (!_listEquals(oldCol, line)) changed = true; } } if (changed) { setState(() { _score += totalGained; if (_score > _best) _best = _score; if (reachedWin) _won = true; _addRandomTile(); _gameOver = _isGameOver(); }); } }这个设计最大的好处是:你只需要维护一个_mergeLine函数,四个方向的差异全部通过“反转”这个操作来抹平。如果写成四个独立的移动函数,代码量会翻几倍,而且每个函数都要单独测试,任何一个方向写错下标都很难查。统一成线处理后,只要左滑正确,右滑、上滑、下滑的正确性就有基础保障。
3.4 随机方块、计分与游戏结束
新块生成逻辑很简单,先收集所有值为 0 的空位坐标,随机选一个,再按概率写入 2 或 4:
void _addRandomTile() { final List<Point<int>> emptyCells = []; for (int r = 0; r < gridSize; r++) { for (int c = 0; c < gridSize; c++) { if (_board[r][c] == 0) { emptyCells.add(Point(r, c)); } } } if (emptyCells.isEmpty) return; final cell = emptyCells[_rng.nextInt(emptyCells.length)]; _board[cell.x][cell.y] = _rng.nextInt(10) == 0 ? 4 : 2; }注意_addRandomTile只会在棋盘变化后被调用。我在_move里先比较了旧棋盘和新棋盘,只有changed == true才进入 setState 更新状态并生成新块。这是一个很容易漏掉的细节:如果用户按了一个方向但没有发生任何移动,新方块不应该生成。我在用 Trae 初版生成的代码里就遇到过这个问题,棋盘会在无效滑动后莫名多出方块,玩起来非常奇怪。
游戏结束的判断也不复杂,遍历所有格子,只要存在一个空格、或者存在一组相邻等值方块,就还能继续;反之则结束:
bool _isGameOver() { for (int r = 0; r < gridSize; r++) { for (int c = 0; c < gridSize; c++) { if (_board[r][c] == 0) return false; if (r + 1 < gridSize && _board[r][c] == _board[r + 1][c]) return false; if (c + 1 < gridSize && _board[r][c] == _board[r][c + 1]) return false; } } return true; }这里有个容易遗漏的点:游戏结束不能只看“有没有空格”。有时候棋盘明明满了,但相邻格子还能合并,游戏就还能继续。所以必须同时检查空格和相邻等值两种情况。
4. 界面渲染与交互控制:让逻辑变成真正能玩的游戏
4.1 布局与经典 2048 配色
界面整体结构我用了一个垂直布局:顶部是标题和分数区,中间是棋盘,底部是操作提示和重新开始按钮,全部约束在最大宽度 480 的容器里居中显示,这样在手机横竖屏、平板、桌面浏览器上都能有不错的观感。
棋盘本身我用AspectRatio(aspectRatio: 1)保证它是正方形,内部放一个GridView.builder,每个格子通过_tileColor(value)映射背景色。经典 2048 的配色规律是:数值越大,颜色越深越暖,文字颜色从深灰变成白色。关键几个颜色值如下:
| 数字 | 背景色 | 文字色 |
|---|---|---|
| 2 | #EEE4DA | #776E65 |
| 4 | #EDE0C8 | #776E65 |
| 8 | #F2B179 | #FFFFFF |
| 16 | #F59563 | #FFFFFF |
| 32 | #F67C5F | #FFFFFF |
| 64 | #F65E3B | #FFFFFF |
| 128 | #EDCF72 | #FFFFFF |
| 256 | #EDCC61 | #FFFFFF |
| 512 | #EDC850 | #FFFFFF |
| 1024 | #EDC53F | #FFFFFF |
| 2048 | #EDC22E | #FFFFFF |
这个配色表我直接让 Trae 按经典版生成,几乎不用改。棋盘背景用 #BBADA0,页面底色用 #FAF8EF,整体就是原版那种“木纹质感的深灰背景 + 彩色数字块”的风格。
4.2 三种操作方式:方向键、WASD、滑动手势
Web 端和移动端有一个很大的交互差异:移动端靠触摸滑动,桌面端靠键盘和鼠标。我三样都做了支持。
键盘事件用 Flutter 的Focus组件配合onKeyEvent回调实现。这里有个 Web 端常见陷阱:如果页面焦点不在游戏区域,键盘事件根本不会触发。所以我在最外层包了一个Focus(autofocus: true),让页面加载后焦点默认落在游戏区域,同时支持方向键和 WASD 双键位:
Focus( autofocus: true, onKeyEvent: (node, event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.keyW) { _move(Direction.up); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.keyS) { _move(Direction.down); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.keyA) { _move(Direction.left); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight || key == LogicalKeyboardKey.keyD) { _move(Direction.right); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: ... )手势滑动我用GestureDetector的onPanEnd监听,根据手指抬起时的速度方向判断滑动方向:
GestureDetector( onPanEnd: (details) { final velocity = details.velocity; if (velocity.pixelsPerSecond.dx.abs() > velocity.pixelsPerSecond.dy.abs()) { if (velocity.pixelsPerSecond.dx > 0) { _move(Direction.right); } else { _move(Direction.left); } } else { if (velocity.pixelsPerSecond.dy > 0) { _move(Direction.down); } else { _move(Direction.up); } } }, child: GridView... )用速度方向而不是位移方向来判断,手感会好很多,因为即使手指滑动的位移很小,只要速度方向明确,也能响应。同时要注意给 GridView 设置physics: NeverScrollableScrollPhysics(),否则在 Web 端用鼠标拖拽时,GridView 本身会抢走手势事件,导致滑动失灵。
4.3 用 Trae 做增量优化
第一版功能跑通之后,我没有停下来,而是继续用 Trae 做了一轮增量优化。我给它提了几个新需求:加最高分显示、加重新开始按钮、把分数卡片做成深色圆角样式、底部加操作提示文字。这类 UI 调整的需求对 Trae 来说非常轻松,基本一次就能改到位。
这个过程中我体会到,和 AI 协作开发最顺的顺序是“先功能后样式”。先把核心玩法和数据逻辑跑通,因为这部分是建筑物地基;界面细节放到后面迭代,因为 AI 对“好看”这种主观要求理解不准,但对“加一个按钮”“改个颜色”这种具体指令完成度很高。如果你一上来就要求 AI 生成“好看的界面”,大概率会得到一个花里胡哨但逻辑有问题的半成品。
5. 完整代码与发布部署
5.1 完整 main.dart
下面这份main.dart就是最终运行版的完整代码,整个项目零第三方依赖,只需要 Flutter SDK 自带的组件就能跑。我把它直接放在lib/main.dart下即可,不需要创建任何额外文件。
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, colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFBBADA0)), ), home: const Game2048Page(), ); } } enum Direction { left, right, up, down } class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State<Game2048Page> createState() => _Game2048PageState(); } class _Game2048PageState extends State<Game2048Page> { static const int gridSize = 4; final Random _rng = Random(); late List<List<int>> _board; int _score = 0; int _best = 0; bool _gameOver = false; bool _won = false; @override void initState() { super.initState(); _board = List.generate(gridSize, (_) => List<int>.filled(gridSize, 0)); _addRandomTile(); _addRandomTile(); } void _resetGame() { setState(() { _board = List.generate(gridSize, (_) => List<int>.filled(gridSize, 0)); _score = 0; _gameOver = false; _won = false; _addRandomTile(); _addRandomTile(); }); } void _addRandomTile() { final List<Point<int>> emptyCells = []; for (int r = 0; r < gridSize; r++) { for (int c = 0; c < gridSize; c++) { if (_board[r][c] == 0) { emptyCells.add(Point(r, c)); } } } if (emptyCells.isEmpty) return; final cell = emptyCells[_rng.nextInt(emptyCells.length)]; _board[cell.x][cell.y] = _rng.nextInt(10) == 0 ? 4 : 2; } ({List<int> line, int gained, bool reachedWin}) _mergeLine(List<int> input) { final List<int> nonZero = input.where((v) => v != 0).toList(); final List<int> merged = []; int gained = 0; bool reachedWin = false; int i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { final int newValue = nonZero[i] * 2; merged.add(newValue); gained += newValue; if (newValue == 2048) reachedWin = true; i += 2; } else { merged.add(nonZero[i]); i++; } } while (merged.length < gridSize) { merged.add(0); } return (line: merged, gained: gained, reachedWin: reachedWin); } bool _listEquals(List<int> a, List<int> b) { if (a.length != b.length) return false; for (int i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } void _move(Direction dir) { if (_gameOver || _won) return; final List<List<int>> allLines = []; for (int i = 0; i < gridSize; i++) { switch (dir) { case Direction.left: allLines.add(List<int>.from(_board[i])); break; case Direction.right: allLines.add(_board[i].reversed.toList()); break; case Direction.up: allLines.add(List<int>.generate(gridSize, (r) => _board[r][i])); break; case Direction.down: allLines.add(List<int>.generate(gridSize, (r) => _board[r][i]).reversed.toList()); break; } } int totalGained = 0; bool reachedWin = false; final List<List<int>> newLines = []; for (final line in allLines) { final result = _mergeLine(line); newLines.add(result.line); totalGained += result.gained; if (result.reachedWin) reachedWin = true; } bool changed = false; for (int k = 0; k < gridSize; k++) { List<int> line = List<int>.from(newLines[k]); if (dir == Direction.right || dir == Direction.down) { line = line.reversed.toList(); } if (dir == Direction.left || dir == Direction.right) { if (!_listEquals(_board[k], line)) changed = true; _board[k] = line; } else { final List<int> oldCol = List.generate(gridSize, (r) => _board[r][k]); for (int r = 0; r < gridSize; r++) { _board[r][k] = line[r]; } if (!_listEquals(oldCol, line)) changed = true; } } if (changed) { setState(() { _score += totalGained; if (_score > _best) _best = _score; if (reachedWin) _won = true; _addRandomTile(); _gameOver = _isGameOver(); }); } } bool _isGameOver() { for (int r = 0; r < gridSize; r++) { for (int c = 0; c < gridSize; c++) { if (_board[r][c] == 0) return false; if (r + 1 < gridSize && _board[r][c] == _board[r + 1][c]) return false; if (c + 1 < gridSize && _board[r][c] == _board[r][c + 1]) return false; } } return true; } Color _tileColor(int value) { switch (value) { case 0: return const Color(0xFFCDC1B4); 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(0xFF3C3A32); } } Color _textColor(int value) { return (value == 2 || value == 4) ? const Color(0xFF776E65) : Colors.white; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFAF8EF), body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.keyW) { _move(Direction.up); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.keyS) { _move(Direction.down); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.keyA) { _move(Direction.left); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight || key == LogicalKeyboardKey.keyD) { _move(Direction.right); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 16), _buildBoard(), const SizedBox(height: 16), _buildFooter(), ], ), ), ), ), ), ), ); } Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '2048', style: TextStyle( fontSize: 52, fontWeight: FontWeight.bold, color: const Color(0xFF776E65), ), ), Text( 'Flutter Web 版', style: TextStyle(fontSize: 14, color: Colors.grey.shade600), ), ], ), Row( children: [ _scoreCard('分数', _score.toString()), const SizedBox(width: 8), _scoreCard('最高', _best.toString()), ], ), ], ); } Widget _scoreCard(String label, String value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(6), ), child: Column( children: [ Text(label, style: const TextStyle(fontSize: 12, color: Colors.white70)), Text( value, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } Widget _buildBoard() { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GestureDetector( onPanEnd: (details) { final velocity = details.velocity; if (velocity.pixelsPerSecond.dx.abs() > velocity.pixelsPerSecond.dy.abs()) { if (velocity.pixelsPerSecond.dx > 0) { _move(Direction.right); } else { _move(Direction.left); } } else { if (velocity.pixelsPerSecond.dy > 0) { _move(Direction.down); } else { _move(Direction.up); } } }, child: GridView.builder( physics: const NeverScrollableScrollPhysics(), itemCount: gridSize * gridSize, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: gridSize, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemBuilder: (context, index) { final r = index ~/ gridSize; final c = index % gridSize; final value = _board[r][c]; return _buildTile(value); }, ), ), ), ); } Widget _buildTile(int value) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: _tileColor(value), borderRadius: BorderRadius.circular(6), ), child: value == 0 ? null : Text( '$value', style: TextStyle( fontSize: value >= 100 ? 28 : 36, fontWeight: FontWeight.bold, color: _textColor(value), ), ), ); } Widget _buildFooter() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '方向键 / WASD / 手势滑动', style: TextStyle(fontSize: 13, color: Colors.grey.shade600), ), TextButton.icon( onPressed: _resetGame, icon: const Icon(Icons.refresh), label: const Text('重新开始'), ), ], ); } }代码里有一个我刻意没做的功能:最高分持久化。如果加了shared_preferences依赖,就能把最高分存到浏览器 localStorage 里,刷新页面后仍然保留。我没加是为了保持项目零第三方依赖,你如果想要这个功能,用 Trae 直接让它加,几分钟就能搞定。
5.2 本机运行、Web 构建与部署
开发调试用这条命令,它会启动 Chrome 并支持热重载:
flutter run -d chrome发布到线上用 build 命令:
flutter build web构建产物在build/web目录下,是一整套纯静态文件。由于 2048 是单页应用且没有路由跳转,部署极其简单:把build/web里的文件原样上传到任意静态托管服务就行。Nginx 直接指向这个目录、GitHub Pages 推一个分支、对象存储开静态网站托管,都可以。唯一要提醒的是 Flutter Web 的首次加载体积在 2MB 到 3MB 左右,属于正常水平,如果在意加载速度,服务器上开 gzip 或 brotli 压缩效果非常明显。
6. 常见问题与避坑实录
6.1 合并算法的经典 Bug
我让 Trae 生成第一版代码时,它给出的合并实现是用“前后比较 + 用后即抛弃”的方式写的,结果[2, 2, 2, 2]一次滑动直接变成了[8, 0, 0, 0]。这个 bug 最坑的地方在于:你玩前面几十步根本发现不了,只有遇到四连相同数字时才炸出来,而且炸出来的时候会怀疑人生,以为是规则本来就是这样。
排查思路很简单:在纸上手写一个[2, 2, 2, 2]的例子,向左滑一步,预期的结果是[4, 4, 0, 0],因为 2 和 2 合并成 4 之后,这个新 4 不应该再和后面的 2 合并。如果你的代码输出[8, 0, 0, 0],说明合并循环在合并后没有跳过被吞掉的数字。解决办法就是我在 3.2 节写的那个i += 2指针跳跃。
6.2 Web 端键盘失灵与页面滚动冲突
键盘失灵是 Flutter Web 新手最容易撞上的问题。代码逻辑完全正确,但按方向键就是没反应。原因在于 Web 页面的焦点问题:Flutter 应用加载后,键盘事件只发给当前获得焦点的 Widget,如果焦点不在游戏区域,按键自然无效。解决办法有两个,一个是包一层Focus(autofocus: true),让页面加载后自动获得焦点;另一个是点击棋盘区域先激活焦点再操作。
另一个交互冲突是页面滚动。在手机上玩的时候,手指在棋盘上滑动可能会触发页面整体滚动,导致棋盘和页面一起动,看起来非常乱。解决办法是我在 GridView 上设置了physics: NeverScrollableScrollPhysics(),让棋盘自身的滚动被禁用,手势事件全部交给外层的 GestureDetector 处理。如果你以后做更复杂的 Flutter Web 游戏,这个冲突点几乎一定会遇到。
6.3 Trae 使用过程中的几个实际坑
和 Trae 协作几次之后,我总结出几个比较实际的坑:
AI 偶尔会生成不存在的 API。我遇到过它引用了某个不存在的BoardModel类,编译直接报错。这时候不用慌,直接把终端里的报错信息原样复制给 Trae,让它自己修复,基本一轮就能解决。这也侧面说明,用 AI 写代码时“能读懂编译错误”是多么重要的能力。
Build 模式大改之后容易出现回归问题。Trae 的 Build 模式是直接改文件,有时候它会把你没要求动的代码也顺手重构了。我的习惯是每完成一个功能点立刻运行测试,不要攒着一堆改动再统一验证,否则出了问题根本不知道是哪一步引入的。
关于积分消耗,这个项目从生成到迭代大概用了一两千字的对话量,Trae 新用户赠送的免费额度完全够用。但如果一次性丢一个超大需求到 Build 模式里让它“全自动完成”,积分会消耗得很快,而且生成结果往往问题很多,反而更费积分修复。更经济的做法是把需求拆成小块,分步确认,每步控制在一次对话量级。
Skills 功能对这类前端小项目有明显的效果提升。Trae 支持加载一些技能包,相当于给 AI 预置了行业最佳实践的提示词。我试过加载 Flutter 相关的 Skill 后再让它生成代码,生成的代码在结构规范性上明显好于裸写,比如它会主动把布局拆成独立 Widget 方法,而不是全部堆在 build 里。如果你发现 AI 生成的大文件越来越难维护,先检查一下是不是没挂 Skill。
6.4 排查问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 一次滑动把 2、2、2、2 合并成 8 | 合并逻辑缺少“每轮一次合并”限制 | 用 while 循环,合并后i += 2 |
| 无效滑动也生成新方块 | 未判断棋盘是否变化就执行生成逻辑 | 比较新旧棋盘,changed == true才生成 |
| 键盘按键没反应 | 焦点不在游戏区域 | 用Focus(autofocus: true)包裹棋盘 |
| 鼠标拖拽没反应或页面滚动 | GridView 抢占了手势事件 | 设置NeverScrollableScrollPhysics() |
| 游戏结束判断错误(满盘但还能合并) | 只检查空格没检查相邻等值 | 同时检查空位和左右、上下相邻相等 |
| 棋盘格子里的数字显示不全 | 数值变大后字体超出格子 | 根据位数动态缩小字号(我在代码里对 3 位数用了 28 字号) |
| AI 生成的代码编译不过 | 使用了不存在的类或属性 | 把编译报错复制给 AI,让它自己修 |
| Web 构建产物首次加载慢 | Flutter Web 产物较大 | 服务端开启 gzip/brotli 压缩 |
最后分享一点个人体会。用 AI 写代码,最关键的不是提示词多华丽,而是你自己心里要有清晰的结构。2048 这个项目逻辑不算难,但如果你脑子里没有“线处理”“一次合并”“变化后再生成”这些概念,AI 生成出错的时候你根本不知道往哪查。反过来,先把游戏规则和数据结构想明白,再让 Trae 帮你写具体实现和界面,整个过程会顺畅得多。做完这个小游戏之后,我对 Flutter 的布局、手势、键盘事件都有了更直观的理解,而且这个项目的改造成本很低,后面想继续玩的话,可以去加滑动动画、加撤销功能、加排行榜,代码底子都是现成的。