1. 为什么我决定用 Trae 写一个 2048
1.1 这个项目到底在做什么
1.1 这个项目到底在做什么
2048 这个游戏大家都不陌生,一个 4x4 的棋盘,通过上下左右滑动让相同数字合并,目标是凑出 2048。它看起来简单,但真正要自己从零写一遍,牵扯到的核心问题并不少:棋盘的存储结构、滑动合并的算法、新方块的随机生成、胜负判断、动画反馈、跨端运行。这些凑在一起,正好是一个非常适合练手的前端项目。
我这次选择的技术组合是Flutter Web + Trae 。Flutter 大家知道,是跨端 UI 框架,平时更多用来做移动 App,其实它从很早就支持 Web 端编译,用一套 Dart 代码可以同时跑在 iOS、Android、浏览器里。Trae 则是最近很火的 AI 辅助 IDE,它内置了智能对话和自动改写代码的能力,特别适合从 0 到 1 快速搭一个项目原型。 这两个东西放在一起,我实际体验下来有个很直观的感受:以前写这种小游戏,从建项目到写逻辑到调 UI,至少得大半天;这次我用 Trae 来辅助生成和调整代码,整体只花了两三个小时,而且遇到问题直接在编辑器里问 AI 就能快速定位,不需要反复切到浏览器搜索。这篇博文我会把整个流程完整记录下来,包括算法思路、代码实现、踩过的坑,希望能给想用 Flutter Web 练手、或者刚接触 Trae 的朋友一些参考。 先解释一下 Trae 到底是什么,因为有些朋友可能还没用过。Trae 是一个基于 VSCode 生态的 AI 驱动 IDE,它最大的特点是把 AI 能力直接集成在编辑器里,不是简单的代码补全,而是可以让你用自然语言描述需求,它直接帮你生成、修改、解释整个文件甚至整个项目。 它在工作流里有两种典型用法。一种是 Chat 模式,相当于在侧边栏开一个 AI 对话窗口,你可以选中代码然后问“这段逻辑是什么意思”“帮我加一个功能”;另一种是 Builder 模式,你给它一个任务描述,它会自己规划步骤,创建文件、修改文件、运行命令,像一个小型开发代理。 我这次大部分核心逻辑就是用 Builder 模式生成的。比如我先让 Trae 创建 Flutter Web 项目,然后告诉它“给我生成一个 2048 游戏的核心模型类,包含上下左右移动和合并逻辑”,它会直接把整个 Dart 类写出来,我再结合自己的理解去调整。这个过程不是完全扔给它不管,而是“人审 AI 的代码,AI 听人的指令改”,效率很高。 如果你是下面这几类人,这篇文章应该比较有价值: 即使你对 Flutter 完全没基础,只要会一点编程,跟着文章里的思路走,也能把一个能玩的 2048 跑起来。Flutter 的语法其实非常直观,尤其适合从 JavaScript 或 Java 转过来的开发者。 很多人一听说要做 2048,第一反应是“这有什么难的,4x4 数组挪一挪嘛”。但真把需求拆细,你会发现它包含好几个独立环节: 仔细看就会发现,真正有算法深度的是第二点“滑动合并”,其他部分都是常规的状态更新。所以做这个项目,不需要什么高深的图形学知识,考验的是你能不能写出一套干净、无 bug 的二维数组操作逻辑。 有人可能会问,2048 这种小游戏,用纯 JavaScript + HTML + CSS 也能写,或者用 Canvas 画,为什么选 Flutter Web? 我的理由是分场景的。如果只是想要一个能跑的 2048 网页,原生 JavaScript 确实更轻量,甚至几百行代码就能搞定。但 Flutter Web 的优势在于: 当然也有代价:Flutter Web 的首次加载体积比较大(大约 1-2MB),如果你要做极端轻量的 H5 页面,它不是最优选择。但作为个人项目、学习项目,这个代价完全值得。 我在设计代码结构时,刻意把游戏逻辑和 UI 分离了。这样做的原因很简单:2048 的核心是状态机,不是界面。如果我把移动合并逻辑直接写在 Widget 里,代码会又臭又长,而且不好测试。 我的设计分三层: 这种分层模式在真实项目中也经常用,游戏逻辑放到纯 Dart 类里意味着以后可以单独写单元测试,不用启动界面就能验证算法是否正确。 在开始写代码之前,先把环境搭好。我假设你的电脑是 Windows 或 macOS,需要安装: Flutter SDK 的安装有几个注意点:一是要把 显示 Chrome 已安装,那就说明 Web 支持没问题。 提示:如果你网络环境不太好,Flutter 官方仓库可能会下载缓慢,这时候可以考虑配置国内镜像源。具体操作是在系统环境变量里添加 拉取依赖容易卡住。 环境装好后,打开 Trae。它的界面和 VSCode 非常像,如果你用过 VSCode,上手没有任何障碍。第一步,打开终端面板,先确认 Flutter 命令可用: 如果能正常输出版本号,就可以创建项目了。我们可以用命令行创建,也可以直接用 Trae 的 AI 对话来生成。我这次是先用命令行创建了项目骨架,再让 AI 往里面填逻辑: 默认会生成所有平台的目录,包括 android、ios、web、windows、macos 等。如果你只想保留 Web,可以在创建时加参数: 这样项目更干净,不会有一堆暂时用不到的目录。创建完成后,用 Trae 打开这个文件夹,在终端里运行: 看到 Chrome 自动打开并显示默认的 Flutter 示例页面,说明 Flutter Web 环境已经通了。这时候默认页面是一个计数器应用,下一步就是把它替换成我们的 2048。 我的习惯是先用 AI 生成一个能跑的基础版本,再手动优化细节。在 Trae 的 AI 对话窗口输入类似这样的描述: 我要写一个 Flutter Web 的 2048 小游戏,请帮我创建一个完整的 main.dart,包含游戏逻辑和 UI,使用 4x4 棋盘,支持方向键和滑动操作。 Trae 会直接生成一个完整文件。这里要提醒一点:AI 生成的代码质量参差不齐,尤其在这种“一次生成整个文件”的场景下,经常会有小 bug。但不用怕,这正是 Trae 好用的地方——你把报错信息粘贴给它,它会自己检查并修复。 如果你用的是 Builder 模式,它会自动创建或覆盖文件、运行 依赖并改好了相关代码。 2048 的棋盘本质是一个 4x4 的二维数组。在 Dart 里我这样定义: 初始时所有格子都是 0。我们需要在开局时随机生成两个方块,通常在两个不同的空位上。这个逻辑在 方法中实现。 为了后面方便判断某个方块是否移动过,我在实际实现中用一个 。 合并一行的逻辑是整个游戏的核心。我的做法是先把一行中所有非零数字取出来,然后从左到右进行合并。 这个算法的核心思想是这样的:把一行中所有的非零数字压缩成一个数组,然后相邻相同的数字合并。举例说明: 这一行逻辑写对后,其他的方向就都好办了。 左移是最基本的方向,右移、上移、下移都可以通过转换来复用 : 这样只需要写一个左移的逻辑,其他方向都是数学变换,代码大大减少。 这里要留意一点:反转之后合并,再反转回来,结果就是右对齐的效果。我一开始写的时候漏了一层反转,导致右移结果完全不对,后来用 Trae 帮我检查逻辑,一眼就发现了问题。 每次有效移动后,需要在空位随机生成一个新方块。实现思路是收集所有值为 0 的坐标,然后从中随机取一个: 胜负判断分两部分。胜利很简单:遍历棋盘,如果出现 2048,则游戏胜利。失败则要判断是否“没有空位且没有相邻相等的方块”。相邻相等意味着还有合并的可能,所以不能算输。 UI 部分我用 Flutter 的纯 Widget 绘制。整体布局是一个 Column:顶部显示“2048”标题和分数,中间是棋盘,棋盘下面放“重新开始”按钮。 棋盘本身我用了 是最快的。 方块的颜色根据数字大小变化。我定义了一个 函数: 颜色值我是参考了网页版 2048 的经典配色,深色数字配浅色背景,或者浅色数字配深色背景。数字颜色也同理,2 和 4 用深棕色,其他用白色。 每个格子的 UI 是 显示数字。Text 的字体大小根据数字位数调整:一位数用大字,四位数用小字,避免溢出。 Flutter Web 支持键盘事件和手势。桌面上我监听方向键: 移动端和浏览器触屏场景,我用 监听滑动方向。判断滑动方向的方法是计算滑动的 dx 和 dy,哪个方向绝对值更大就往哪个方向移动: 实际的翻页实现中要注意:使用 时用终点和起点做差判断。 我把项目核心代码放在一个 。 这个类干净地封装了所有游戏逻辑。UI 层只需要调用 等方法,完全不关心内部实现。 UI 层我用一个 刷新。 这里需要说明 做差值,误触概率小很多。 棋盘 UI 我用了 ,简单清晰: 这里我特意给每个 一致,让棋盘更精致。方块文字的大小根据数字位数动态调整,避免 2048 这种四位数撑满格子后溢出。 计分板放在棋盘上方: 效果就是经典 2048 风格:左上角大标题,右上角两个分数标签。 当游戏结束或者玩家达到 2048 时,我弹出一个简单的对话框: 这里有个小细节: 的判断。这个小问题如果不处理,会严重影响体验。 在浏览器中运行 后,游戏就能玩了。想生成静态部署文件,运行: 生成的文件在 本地预览,就能得到一个在线可玩的 2048。 构建 Web 时有个优化点:默认 Flutter Web 会有比较重的加载体积,如果你追求加载速度,可以用 参数选择渲染器。默认现在是 canvaskit,效果更好,但体积略大。对于小游戏项目,默认就好,不必刻意优化。 AI 生成代码最大的问题是“看起来像对的,实际上不一定能跑”。比如我第一次让 Trae 生成 2048 代码时,它用了 这种 JavaScript 风格的写法,在 Dart 里根本不存在。这时候把报错信息直接粘贴给 Trae,它会立刻修正。 我的排查经验是:让 AI 生成代码后,先运行 做静态检查,它会列出所有语法错误和类型问题。把输出贴给 Trae 说“修复这些错误”,比你自己一行行改要高效得多。 还有一个高频问题是 Trae 会生成重复的方法定义,或者用了未导入的库。比如它可能在 都会提示,按提示修复即可。 Flutter Web 上监听键盘有一个常见的坑: 组件需要有焦点才能收到键盘事件。页面刚加载时,焦点并不在游戏区域,玩家按方向键没反应。 解决办法就是在 里延迟请求焦点: 另外,点击棋盘区域时焦点可能会被抢走,所以我在 ,确保点击过后方向键依然可用。 我最初用 的值可能不稳定,甚至为 0,导致方向判断失败。 更稳的方式是记录起始位置: 这样方向判断完全基于实际位移,不再依赖速度估算,准确率明显提高。同时我还加了最小滑动距离判断,比如位移绝对值小于 10 就不触发,避免误触。 Flutter Web 和移动端最大的区别是浏览器窗口可以随意缩放。如果把标题、分数栏、棋盘垂直排列,窗口高度不够时会出现 报错。 我的解决办法是用 限制最大宽度 500,让内容在宽度较大的屏幕上居中显示,在高度有限的屏幕上可以滚动。这个适配方案在桌面浏览器和移动浏览器上表现都不错。 默认情况下,刷新页面后最高分会清零。为了让最高分能保存下来,需要用 里添加依赖: 然后在 的读写改成异步方法: 这里要注意 ,就能跨刷新保持最高分。 如果直接用 刷新棋盘,方块的变化是“瞬移”的,没有平滑移动和合并动画。虽然能玩,但少了很多乐趣。 要加入动画,比较基础的做法是把棋盘换成 控制位置。这样当你移动方向时,方块会平滑地滑到新位置。 计算 的自动布局。 我实现的过程中还发现一个细节:合并的两个方块,一个要消失、一个要变大,单靠 ,在检测到某个格子刚产生合并值时,播放一个从 1.0 到 1.2 再到 1.0 的缩放。这个效果让游戏手感提升了一个档次。 如果你是第一次用 Flutter Web 打包,可能会被 目录里几个 MB 的 JS 文件吓到。这是正常的,Flutter Web 的默认产物是 CanvasKit 渲染器,自带渲染引擎,所以体积大。如果你想压缩,可以试试: HTML 渲染器体积更小,但渲染某些复杂效果时会有区别。对于 2048 这种矩形方块游戏,HTML 渲染器完全够用,加载速度还更快。 另外还有一个优化思路:使用 可以去掉 Service Worker 相关的代码。这对静态部署非常有用,否则在部分静态托管环境下,Service Worker 可能会缓存旧版本导致更新不及时。 我这次体验下来,最重要的一个体会是:问 AI 要问得具体 。如果你只说“帮我写一个 2048 游戏”,它可能会给你一个能用但不一定符合你预期的版本;但如果你说“用 Flutter 写 2048,4x4 棋盘,方向键和滑动手势控制,数字方块用经典配色,分数和最高分显示在右上角,支持本地持久化”,它生成的效果会精准很多。 Trae 的对话窗口支持多轮对话,你可以先让它生成一版,然后提出修改意见,比如“把背景色改成浅黄色”“给方块加圆角”“最高分存本地”。这种渐进式打磨的效果远比一次性要一个终极版可靠。 AI 在哪些环节比较强?首先是生成样板代码,比如项目骨架、重复性高的 UI 结构;其次是解释报错信息,让它帮你分析日志可以省去你到处搜索的时间;最后是“翻译”代码,比如把一个 JavaScript 的算法改成 Dart,AI 做得很好。 但有些东西我建议你自己掌握。一是算法正确性,AI 生成的合并逻辑偶尔会有边界 case 错误,比如我遇到过一次“同一行可以连续合并”的错误,就是 和测试用例揪出来。二是用户体验细节,AI 不会主动帮你考虑“弹窗后继续游戏会重复出现”“手势误触”“窗口缩放溢出”这些问题,这些需要你以玩家的视角去测试,然后反馈给 AI 修改。 如果你也在用 Trae 写项目,这里分享几个我实际用着很顺手的操作习惯: Trae 的 AI 功能是有积分体系的,简单说就是 AI 生成和修改代码会消耗积分。我在几天使用中总结了一些省积分的方法: 顺带一提,Trae 有时会不定期有兑换码活动,遇到的话可以兑换积分。这个信息你自己留意官方渠道就行,我不展开讲。 最后说点真心话。Trae 这一类 AI IDE 确实让开发效率上了一个台阶,尤其是对 Flutter Web 这种本就适合快速迭代的技术栈,AI 能帮你省掉大量模板代码的编写时间。但这并不意味着基础能力可以完全交给工具。2048 这种小项目,你如果不懂二维数组操作、不懂合并算法的边界条件,AI 生成的代码即使能跑,你也不知道它哪里埋了雷。 我的建议是:未来写项目可以默认开启 AI 辅助,但每生成一段核心代码,你都应该读懂它、跑通它、用几个边界 case 验证它。这样 AI 是你的杠杆,而不是你的外脑替代品。 这个 2048 项目本身就很小,非常适合拿来练这种“人和 AI 协作”的节奏。你完全可以把它扩展成 6x6 的进阶版、加入撤销功能、接入排行榜后端,每一个扩展都是对 Flutter Web 能力的一次深入探索。按照我上面的代码基础,扩展这些功能并不会花太多时间。1.2 Trae 在其中的定位
1.3 这个内容适合谁来参考
2. 动手前先想清楚:2048 的玩法拆解和技术选型
2.1 游戏的核心逻辑到底是什么
2.2 为什么用 Flutter Web 而不是原生 JS 或 Canvas
2.3 对 2048 算法方案的整体设计
Game2048类,保存 4x4 的棋盘数据List>、当前分数、最高分,提供move(Direction)、isGameOver()、hasWon()等方法。StatefulWidget负责接收用户输入,调用Game2048类的方法,然后调用setState刷新界面。3. Trae 环境准备:从安装到跑起第一个 Flutter Web 项目
3.1 需要安装哪些东西
工具 用途 安装方式 Flutter SDK Flutter 开发的核心工具链 官网下载后解压,配置环境变量 Chrome 浏览器 Flutter Web 运行和调试目标 日常安装即可 Trae AI 辅助 IDE 官网下载安装包,双击安装 Git 项目版本管理(可选) 官网下载或通过包管理器安装 VS Code 扩展(可选) Dart 语法高亮等 Trae 内置支持,不需要额外装 flutter/bin目录加入系统 PATH;二是安装后先运行flutter doctor检查环境是否有缺失,它会提示你缺少 Android SDK、Xcode 或者其他组件,但因为我们目标是 Web,即便缺 Android 工具链也不影响。如果flutter doctorPUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL,指向可用的镜像地址。这个我建议在安装前就配好,否则后面flutter pub get3.2 用 Trae 创建 Flutter Web 项目
flutter --versionflutter create game2048_web cd game2048_webflutter createflutter create --platforms web game2048_webflutter run -d chrome3.3 让 Trae 自动生成基础骨架
flutter analyze检查语法,整个流程比纯 Chat 模式更自动化。我第一次用 Builder 生成完整 2048 代码时,它一次跑通,但分数记录逻辑有 bug,后来我让它“把最高分保存到本地”,它马上加了shared_preferences4. 核心代码实现:2048 的算法和 Flutter 界面
4.1 棋盘的数据模型设计
class Game2048 { static const int gridSize = 4; List> board; int score = 0; int bestScore = 0; bool win = false; bool gameOver = false; Game2048() : board = List.generate( gridSize, (_) => List.filled(gridSize, 0), ); }_addRandomTile()List>存数字,用List>或Set记录本次滑动中已经合并过的方块位置。这里要注意,合并规则是“每个方块在一次滑动中只能合并一次”,比如一行是[2, 2, 2, 2],左移后应该是[4, 4, 0, 0],而不是[8, 0, 0, 0]4.2 一行合并的算法怎么写
List mergeRow(List row) { final nonZero = row.where((v) => v != 0).toList(); final result = []; var i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { result.add(nonZero[i] * 2); i += 2; } else { result.add(nonZero[i]); i++; } } while (result.length < 4) { result.add(0); } return result; }[0, 2, 2, 4]-> 非零数组[2, 2, 4]-> 合并[4, 4, 0, 0][2, 2, 2, 2]-> 非零数组[2, 2, 2, 2]-> 合并[4, 4, 0, 0](这里需要注意,第二对 2 不会和第一对合并后的 4 再合并)[4, 4, 8, 0]-> 非零数组[4, 4, 8]-> 合并[8, 8, 0, 0]4.3 四个方向的滑动怎么统一处理
mergeRowvoid moveLeft() { for (var i = 0; i < 4; i++) { board[i] = mergeRow(board[i]); } } void moveRight() { for (var i = 0; i < 4; i++) { board[i] = mergeRow(board[i].reversed.toList()).reversed.toList(); } } void moveUp() { for (var j = 0; j < 4; j++) { final column = [for (var i = 0; i < 4; i++) board[i][j]]; final merged = mergeRow(column); for (var i = 0; i < 4; i++) { board[i][j] = merged[i]; } } } void moveDown() { for (var j = 0; j < 4; j++) { final column = [for (var i = 0; i < 4; i++) board[i][j]]; final merged = mergeRow(column.reversed.toList()).reversed.toList(); for (var i = 0; i < 4; i++) { board[i][j] = merged[i]; } } }4.4 随机生成新方块和胜负判断
void addRandomTile() { final emptyCells = <(int, int)>[]; for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) { emptyCells.add((i, j)); } } } if (emptyCells.isEmpty) return; final cell = emptyCells[Random().nextInt(emptyCells.length)]; board[cell.$1][cell.$2] = Random().nextInt(10) < 9 ? 2 : 4; }bool isGameOver() { for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) return false; if (i + 1 < 4 && board[i][j] == board[i + 1][j]) return false; if (j + 1 < 4 && board[i][j] == board[i][j + 1]) return false; } } return true; }4.5 Flutter UI 实现
GridView.builder,但更精确的做法是使用AspectRatio+Stack+AnimatedPositioned,因为后者能实现方块移动动画。不过单纯为了先跑通,用GridViewColor getTileColor(int value)Color getTileColor(int value) { switch (value) { case 0: return Color(0xFFCDC1B4); case 2: return Color(0xFFEEE4DA); case 4: return Color(0xFFEDE0C8); case 8: return Color(0xFFF2B179); case 16: return Color(0xFFF59563); case 32: return Color(0xFFF67C5F); case 64: return Color(0xFFF65E3B); case 128: return Color(0xFFEDCF72); case 256: return Color(0xFFEDCC61); case 512: return Color(0xFFEDC850); case 1024: return Color(0xFFEDC53F); case 2048: return Color(0xFFEDC22E); default: return Color(0xFF3C3A32); } }Container,用BoxDecoration设置圆角和背景色,中间一个Text4.6 手势与键盘事件
@override void initState() { super.initState(); _focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } @override Widget build(BuildContext context) { return Focus( focusNode: _focusNode, onKeyEvent: (event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _gameController.move(Direction.left); return KeyEventResult.handled; // 其他方向类似 } } return KeyEventResult.ignored; }, child: // 主界面 ); }GestureDetector的onPanUpdateonPanEnd: (details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { if (dx > 0) _move(Direction.right); else _move(Direction.left); } else { if (dy > 0) _move(Direction.down); else _move(Direction.up); } }onPanEnd获取速度可能不稳定,初次点击不滑动也会有微小的方向。更稳妥的方式是用onPanUpdate累计位移,或者记录onPanStart的起始位置,在onPanEnd5. 完整代码与细节优化
5.1 完整 main.dart 代码结构
main.dart里,方便读者直接复制运行。完整代码分为几个部分:工具类Game2048、游戏页面Game2048Page、子 WidgetTileViewimport '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, scaffoldBackgroundColor: const Color(0xFFFAF8EF), ), home: const Game2048Page(), ); } } enum Direction { left, right, up, down } class Game2048 { static const int gridSize = 4; final List> board; int score = 0; int bestScore = 0; bool gameOver = false; bool win = false; Game2048() : board = List.generate( gridSize, (_) => List.filled(gridSize, 0), ); // 合并一行 List mergeRow(List row) { final nonZero = row.where((v) => v != 0).toList(); final result = []; var i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { result.add(nonZero[i] * 2); score += nonZero[i] * 2; i += 2; } else { result.add(nonZero[i]); i++; } } while (result.length < gridSize) { result.add(0); } return result; } // 四个方向移动 void move(Direction dir) { final original = board.map((row) => row.toList()).toList(); switch (dir) { case Direction.left: for (var i = 0; i < gridSize; i++) { board[i] = mergeRow(board[i]); } break; case Direction.right: for (var i = 0; i < gridSize; i++) { board[i] = mergeRow(board[i].reversed.toList()).reversed.toList(); } break; case Direction.up: for (var j = 0; j < gridSize; j++) { final col = [for (var i = 0; i < gridSize; i++) board[i][j]]; final merged = mergeRow(col); for (var i = 0; i < gridSize; i++) { board[i][j] = merged[i]; } } break; case Direction.down: for (var j = 0; j < gridSize; j++) { final col = [for (var i = 0; i < gridSize; i++) board[i][j]]; final merged = mergeRow(col.reversed.toList()).reversed.toList(); for (var i = 0; i < gridSize; i++) { board[i][j] = merged[i]; } } break; } if (boardChanged(original, board)) { addRandomTile(); if (isGameOver()) gameOver = true; if (checkWin()) win = true; } if (score > bestScore) bestScore = score; } bool boardChanged(List> a, List> b) { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (a[i][j] != b[i][j]) return true; } } return false; } void addRandomTile() { final emptyCells = <(int, int)>[]; for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] == 0) emptyCells.add((i, j)); } } if (emptyCells.isEmpty) return; final cell = emptyCells[Random().nextInt(emptyCells.length)]; board[cell.$1][cell.$2] = Random().nextInt(10) < 9 ? 2 : 4; } bool isGameOver() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] == 0) return false; if (i + 1 < gridSize && board[i][j] == board[i + 1][j]) return false; if (j + 1 < gridSize && board[i][j] == board[i][j + 1]) return false; } } return true; } bool checkWin() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] >= 2048) return true; } } return false; } void reset() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { board[i][j] = 0; } } score = 0; gameOver = false; win = false; addRandomTile(); addRandomTile(); } }move、reset5.2 UI 层代码实现
StatefulWidget持有Game2048实例,每次按钮或手势触发后调用setStateclass Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State createState() => _Game2048PageState(); } class _Game2048PageState extends State { final Game2048 _game = Game2048(); final FocusNode _focusNode = FocusNode(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } void _handleMove(Direction dir) { setState(() { _game.move(dir); }); } void _reset() { setState(() { _game.reset(); }); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500), child: Padding( padding: const EdgeInsets.all(16), child: Focus( focusNode: _focusNode, onKeyEvent: (event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _handleMove(Direction.left); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: _handleMove(Direction.right); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: _handleMove(Direction.up); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: _handleMove(Direction.down); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanEnd: (details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { _handleMove(dx > 0 ? Direction.right : Direction.left); } else { _handleMove(dy > 0 ? Direction.down : Direction.up); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 16), _buildBoard(), const SizedBox(height: 16), _buildFooter(), ], ), ), ), ), ), ), ); } }GestureDetector的手势识别:如果你直接使用onPanEnd,它会吞掉页面内的所有滑动事件。在 2048 这种全屏游戏场景里没问题,但如果页面上还有其他可滚动组件,就会冲突。更优雅的方案是用Listener监听原始指针事件,或者用onPanUpdate累计位移判断方向。我实际调试中发现,直接把GestureDetector包在棋盘区域、并且记录onPanDown的起始坐标再在onPanEnd5.3 方块与棋盘 UI 细节
GridView.countWidget _buildBoard() { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.count( crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, physics: const NeverScrollableScrollPhysics(), children: [ for (var i = 0; i < 4; i++) for (var j = 0; j < 4; j++) _buildTile(_game.board[i][j]), ], ), ), ); } Widget _buildTile(int value) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: getTileColor(value), borderRadius: BorderRadius.circular(6), ), child: value == 0 ? const SizedBox() : Text( '$value', style: TextStyle( fontSize: value >= 1024 ? 22 : value >= 128 ? 28 : 34, fontWeight: FontWeight.bold, color: value <= 4 ? const Color(0xFF776E65) : Colors.white, ), ), ); }Container设置了固定圆角,和背景色Color(0xFFBBADA0)Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _scoreChip('得分', _game.score), const SizedBox(height: 8), _scoreChip('最高分', _game.bestScore), ], ), ], ); } Widget _scoreChip(String label, int value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(4), ), child: Column( children: [ Text( label, style: const TextStyle(fontSize: 12, color: Color(0xFFEEE4DA)), ), Text( '$value', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold), ), ], ), ); }5.4 游戏结束和胜利弹窗
void _checkGameStatus() { if (_game.gameOver) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('游戏结束'), content: Text('最终得分:${_game.score}'), actions: [ TextButton( onPressed: () { Navigator.pop(context); _reset(); }, child: const Text('再来一局'), ), ], ), ); } else if (_game.win) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('你赢了!'), content: const Text('你已经合成 2048,是否继续挑战更高分数?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('继续'), ), TextButton( onPressed: () { Navigator.pop(context); _reset(); }, child: const Text('重新开始'), ), ], ), ); } }showDialog之后如果玩家选择“继续”,弹窗会关闭,但_game.win仍然是 true,每次移动都会弹窗。所以我处理时,当玩家选择继续后,把_game.win置为 false,只保留gameOver5.5 运行效果与构建部署
flutter run -d chromeflutter build webbuild/web目录下,把里面的文件上传到任意静态网站托管服务,或者用python -m http.server 8080--web-renderer html或--web-renderer canvaskit6. 踩坑记录:Trae + Flutter Web 的常见问题与排查
6.1 Trae 生成代码后运行报错怎么办
Math.maxflutter analyzemain.dart里用了dart:math但没加import 'dart:math';。这类问题flutter analyze6.2 Flutter Web 键盘事件的焦点问题
KeyboardListener或FocusinitStateWidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); });GestureDetector的onTap里也加了_focusNode.requestFocus()6.3 手势滑动方向误判
onPanEnd的velocity判断滑动方向,但实测有个问题:如果玩家滑动速度偏慢,pixelsPerSecondOffset _startPos = Offset.zero; onPanDown: (details) { _startPos = details.globalPosition; }, onPanEnd: (details) { final dx = details.globalPosition.dx - _startPos.dx; final dy = details.globalPosition.dy - _startPos.dy; if (dx.abs() > dy.abs()) { _handleMove(dx > 0 ? Direction.right : Direction.left); } else { _handleMove(dy > 0 ? Direction.down : Direction.up); } },6.4 页面溢出和响应式适配
RenderFlex overflowedSingleChildScrollView包住整个 Column,同时用ConstrainedBox6.5 最高分持久化
shared_preferences插件。在pubspec.yamldependencies: flutter: sdk: flutter shared_preferences: ^2.2.2Game2048类里把bestScoreFuture loadBestScore() async { final prefs = await SharedPreferences.getInstance(); bestScore = prefs.getInt('best_score') ?? 0; } Future saveBestScore() async { final prefs = await SharedPreferences.getInstance(); await prefs.setInt('best_score', bestScore); }shared_preferences在 Web 端底层用的是 localStorage,刷新后依然有效。如果是首次加载走网络,可能会有短暂延迟,但不影响正常使用。有了这个功能,每次得分刷新后调用saveBestScore()6.6 动画缺失:为什么方块合并时没有过渡效果
GridViewStack,每个方块用AnimatedPositionedAnimatedPositioned( duration: const Duration(milliseconds: 120), top: top, left: left, child: _buildTile(value), )top和left的位置,用行索引乘以格子大小。格子大小可以用LayoutBuilder动态获取。这个方案实现不算复杂,但它需要你把二维数组遍历后,用一个列表维护每个方块的位置和值,而不是直接依赖GridViewAnimatedPositioned还不够。更完整的效果需要“合并时新方块有一个缩放动画”。我用了ScaleTransition6.7 打包体积太大
build/webflutter build web --web-renderer html --releaseflutter build web --pwa-strategy none7. Trae 使用心得:AI IDE 写项目要注意什么
7.1 明确的需求描述是高效生成的前提
7.2 人和 AI 的分工:哪些可以交给 AI,哪些要自己把握
[2,2,2,2]被算成了[8,0,0,0],这就是典型的 bug,需要人懂算法才能发现并用flutter analyze7.3 用 Trae 调代码的实战技巧
7.4 Trae 积分消耗快,怎么用更省
7.5 工具再好用,基础能力不能丢