用Trae从零开发Flutter Web小游戏2048:完整实战指南

说实话,很早之前就想做一个小游戏练手,但一直懒得从零搭工程。直到我接触了 Trae 这款 AI 编程工具,又恰好碰上了 Flutter Web,才真正把"用 Trae 从 0 到 1 开发 Flutter Web 小游戏 2048"这件事落地。整个项目没有后端,没有复杂依赖,核心就是一块 4x4 棋盘、几套合并算法、一点手势交互。也正因为足够轻,它成了验证 Trae 智能体协作效率的绝佳标的。这篇内容我想把完整过程摊开来说,包括怎么选型、怎么拆逻辑、怎么和 AI 对话、以及 Flutter Web 上线前踩到的几个坑。不管你是想学 Trae 的用法,还是想用 Flutter Web 做点小东西,都可以照着走一遍。

1. 先聊聊选型:为什么是 Trae 加 Flutter Web 来做 2048

1.1 2048 这个项目为什么拿来做练手最合适

2048 的规则大家应该都清楚:4x4 棋盘,滑动让数字方块移动,相同数字碰撞会合并成它们的和,目标是凑出 2048。很多人觉得这游戏简单,但真正写起来,它覆盖的知识点一点都不少。

从逻辑层面看,你需要处理棋盘的移动、合并、随机生成、计分、胜负判断,这些都是非常典型的状态管理问题。从交互层面看,Web 端要处理键盘方向键,移动端要考虑滑动手势,这两个事件体系在 Flutter 里是两套完全不同的 API。从界面层面看,数字方块的颜色渐变、大小反馈、合并动画,又牵扯到 Flutter 的布局和动画体系。

更关键的是,2048 对于逻辑正确性的要求非常高。合并时是先合并靠前的方块还是靠后的方块,多行多列同时滑动时彼此不能互相影响,随机生成方块不能覆盖已有方块,这些细节稍不留神就会写出一个"表面能玩、但分数和规则都对不上"的残次品。正是因为这种复杂度恰到好处,它比单纯的 Todo List 更能检验 AI 编程工具的理解能力,也比大型项目更适合在短时间内从零跑通。

1.2 Trae 在 AI 编程工具里到底特别在哪

Trae 是字节跳动推出的 AI 原生 IDE,也是我用过的 AI 编程工具里,和"从 0 到 1 做项目"这个场景最契合的一个。

市面上大多数 AI 编程工具还是停留在"聊天窗口生成代码、你手动粘贴"的层面。Trae 不一样的地方在于它直接做成了 IDE,左侧是完整编辑器,右侧是 AI 对话面板,中间还能开一个可以自动执行命令的智能体模式。你让它新建 Flutter 项目,它不是光给你一段 flutter create 命令让你自己跑,而是可以真正调起终端执行命令、读取报错、修改文件,形成一个完整的闭环。

这种"智能体"式的协作在开发 Flutter 项目时尤其好用。Flutter 项目文件多,pubspec.yaml、analysis_options.yaml、Android 和 Web 的工程配置散落在各个目录,如果靠人肉复制粘贴 AI 给的代码,效率很低。Trae 能直接读取项目上下文,你只需要在对话框里描述需求,它能自己定位到该改哪个文件。

另外一个让我愿意持续用它的原因是,Trae 对国内开发者非常友好,下载安装、插件体系、模型访问都开箱即用。不需要折腾额外配置,装上就能写代码。对于想尝试 AI 辅助开发又不想花太多时间在环境问题上的人来说,这是实打实的优势。

1.3 Flutter Web 的适用边界:它不是万能的,但做小游戏刚好

Flutter Web 在很多人印象里是"性能不行、体积太大"的代名词。我体验下来,这个评价有些偏颇,但也不是完全没道理。

Flutter Web 的渲染走的是 CanvasKit 方案,本质上是在浏览器里通过 WebAssembly 跑一套 Skia 渲染引擎,跟传统 DOM 网页是两条技术路线。这带来的直接结果就是:首屏加载要比普通网页慢不少,SEO 也很差,搜索引擎基本抓不到你的页面内容。所以如果你的目标是做一个内容型网站、电商落地页,Flutter Web 不是好选择。

但对应地,CanvasKit 方案的好处是渲染能力强,游戏、图形密集型应用、工具型后台都很合适。2048 这种动画交互密集的小游戏,正好卡在 Flutter Web 最擅长的区间。我最终选择 Flutter Web,除了想验证这套技术,也是因为它能让 Dart 代码同时跑在 Web 和移动端,以后想打包成 App 也不需要重写逻辑。

2. 环境准备:先把 Trae 和 Flutter 工具链洗干净,后面才不吵架

2.1 Flutter 装哪个版本:fvm 还是直接装

在正式开始写代码之前,我先把 Flutter 环境整理了一遍。这一步很多人会跳过,但后面遇到奇怪报错时,大概率就要回来查版本。

我推荐用 fvm 管理 Flutter SDK 版本。热词里也提到了 fvm 安装多版本 Flutter,这个工具的价值在于,它把 Flutter SDK 的安装和使用从全局变成了项目级。你可以给当前项目固定一个版本,团队其他人 clone 代码后执行 fvm install 就能切到完全一致的环境,不会出现"我本地能跑、你本地跑不起来"的问题。

如果你不想引入 fvm,直接装稳定版 Flutter 也行。但要注意,Flutter 的版本迭代非常快,我在写这个项目时用的是 3.x 的较新版本,一些老教程里的 API 已经变了。如果有条件,建议用 fvm 锁定一个你能驾驭的版本。我的做法是在项目根目录建一个 .fvmrc 文件,里面写死版本号,所有 flutter 命令都通过 fvm flutter 来执行。

需要提醒的是,国内网络环境下首次运行 flutter 命令时,安装 Dart SDK 和下载 Pub 依赖可能很慢。Flutter 官方本身提供了镜像配置方案,在环境变量里设置 PUB_HOSTED_URL 和 FLUTTER_STORAGE_BASE_URL 就能解决大部分问题。这一步属于常规操作,也不算特殊手段,我一开始就配好了,后面整个开发过程都很顺畅。

2.2 Trae 里配置 Flutter SDK 的关键点

Trae 的界面和 Vs Code 非常接近,可以直接安装 Flutter 和 Dart 扩展。装完之后,最关键的一步是把 Trae 指向正确的 Flutter SDK 路径。

如果你用的是 fvm,SDK 路径通常不在系统 PATH 里,需要手动指定。我踩过一个小坑:直接在命令面板里执行 Flutter: Change SDK,选 "/Users/xxx/fvm/versions/stable",但在终端里调用的还是系统 flutter,版本对不上。后面我把 Trae 的终端配置成每次启动时自动执行 fvm use,并在 pubspec.yaml 同一层放好 .fvmrc,才算彻底解决。

另外有个细节,Windows 上如果只做 Web 开发,不需要安装 Android Studio,也不需要安装 Visual Studio 的 C++ 工具链。网上那个常见的报错——"unable to find suitable visual studio toolc",是编译 Android 原生插件时才需要的,Web 项目用不到。如果你跟我一样只跑 Web,可以忽略这个告警,不会被它卡住。

2.3 首次创建的验证清单

环境配置完,先用一条命令确认 Web 支持是开着的:

flutter config --enable-web flutter doctor

flutter doctor的输出里要确认 Web 一栏显示的是绿色勾选状态。然后创建一个测试项目验证链路:

flutter create hello_web --platforms web cd hello_web flutter run -d chrome

第一次跑起来会比较慢,因为 debug 模式下要下载和编译 CanvasKit,浏览器打开后白屏几秒钟是正常现象。别急着关,等右下角出现 Flutter Debug 的横幅,说明整个链路已经跑通了。这时候再回到你自己的 2048 项目里开始动手,后面遇到问题就不会分不清是环境问题还是代码问题。

3. 2048 的核心算法拆解:别急着让 AI 写代码,先自己想明白

3.1 棋盘模型:二维矩阵其实没有你想的那么复杂

2048 的棋盘就是一个 4x4 的矩阵。数据结构上我选了最直接的List>,外层是行,内层是列,空位用 0 表示。

typedef Board = List>; class Game2048Logic { static const int size = 4; late Board board; int score = 0; int bestScore = 0; bool _justMoved = false; Game2048Logic() { reset(); } void reset() { board = List.generate(size, (_) => List.filled(size, 0)); score = 0; _spawnTile(); _spawnTile(); } }

有人可能会问,为什么不用一维数组加索引换算?对 2048 这种固定 4x4 的棋盘来说,一维数组确实能省一点内存,但可读性会变差。我们要滑动合并的是"行"和"列",二维数组可以直接用board[row]取出一整行,配合后面的行列变换,代码会清晰得多。性能上 4x4 规模根本无所谓,优先保证逻辑可读。

3.2 滑动合并的两条实现路径:我为什么选了"逐行处理"

2048 的滑动合并是核心中的核心。网上很多实现是把四个方向各写一遍,代码又臭又长,还容易出 bug。我采用的是"只实现左移,其他方向通过变换复用左移"的方案。

先说怎么处理一行:

List _mergeLine(List line) { // 去掉所有 0 final cells = line.where((x) => x != 0).toList(); if (cells.isEmpty) return List.filled(4, 0); final result = []; var i = 0; while (i < cells.length) { if (i + 1 < cells.length && cells[i] == cells[i + 1]) { result.add(cells[i] * 2); score += cells[i] * 2; i += 2; } else { result.add(cells[i]); i += 1; } } while (result.length < 4) { result.add(0); } return result; }

这段代码的核心逻辑是这样的:先把一行的非零数字按顺序取出,然后从头开始两两比较。如果相邻两个数字相等,就合并成它们的和,并且跳过这两个数字;如果不相等,就把前一个数字放进结果里。最后补零到长度 4。

这么处理的原因是 2048 的合并规则是"一次滑动中,一个方块只能参与一次合并"。比如一行是[2, 2, 2, 2],左移后应该变成[4, 4, 0, 0],而不是[8, 0, 0, 0]。如果用"边遍历边合并并继续向后比较"的写法,很容易写成后者。上面这段代码用i += 2跳过了已合并的方块,就保证了正确性。

有了左移,剩下三个方向就有通用解法了:

  • 向右:把整行 reverse,做左移,再 reverse 回来
  • 向上:把矩阵转置,对每一行做左移,再转置回来
  • 向下:转置后再 reverse 每行,做左移,再 reverse 回来再转置

转置就是把board[i][j]换成board[j][i],这样"列"就变成了"行",直接复用左移逻辑。这是我强烈推荐的做法,代码量少,而且不容易出方向相关的低级错误。

3.3 随机生成和计分:细节里全是魔鬼

每次玩家成功滑动之后,棋盘会在空位随机生成一个新方块。常规规则是 90% 概率生成 2,10% 概率生成 4。这个概率写死就行,不需要搞什么复杂随机算法。

void _spawnTile() { final empty = <(int, int)>[]; for (var r = 0; r < 4; r++) { for (var c = 0; c < 4; c++) { if (board[r][c] == 0) empty.add((r, c)); } } if (empty.isEmpty) return; final (r, c) = empty[_random.nextInt(empty.length)]; board[r][c] = _random.nextInt(10) == 0 ? 4 : 2; }

需要特别注意的是,生成方块必须在你确认"这次滑动真的改变了棋盘"之后。如果玩家往某个方向滑动但棋盘没有变化(比如整行都是 0,或者已经被占满且无法合并),就不应该生成新方块,也不应该把这次操作算作有效移动。这个判断很关键,很多 2048 的 bug 都出在这——玩家滑动没反应,结果棋盘还多了一个方块。

计分规则同样要严谨。合并两个 2 得 4 分,合并两个 4 得 8 分,也就是说,得分是合并后产生的那个数字,而不是这轮滑动的总增长值。因为一次滑动可能同时发生多组合并,所以要在_mergeLine里每次合并时累加score。

我还会把最高分存到本地,Web 端用shared_preferences包可以同时兼容移动端。不过这个属于锦上添花,基础版先做一个内存里的 bestScore 就够了。

3.4 游戏状态机:输赢判断的三种状态

2048 的状态其实只有三种:正在游戏、赢了、输了。我用一个枚举表示:

enum GameStatus { running, won, lost }

"赢了"很好判断:每次移动后遍历棋盘,发现任何一个格子等于 2048,就置为 won。游戏是否结束的判断要复杂一些:不是棋盘满了就结束,而是要确认"即便满了,也没有任何相邻格子可以合并"。因为 2048 允许一些特殊布局,比如一行是[2, 4, 2, 4],虽然满了,但上下左右都动不了,这才是真正的死局。

判断方法是遍历所有格子,检查每个格子右边和下面的邻居:

bool get canMove { for (var r = 0; r < 4; r++) { for (var c = 0; c < 4; c++) { if (board[r][c] == 0) return true; if (c < 3 && board[r][c] == board[r][c + 1]) return true; if (r < 3 && board[r][c] == board[r + 1][c]) return true; } } return false; }

这个写法省略了一个方向的判断,因为[r][c]和[r][c+1]相等时,在检查右边邻居时会覆盖,但左边邻居其实在上一轮已经检查过了。四个方向通过"右和下"两个方向就能等价覆盖,因为如果存在任何可合并的相邻对,必然会被其中的右或下关系捕捉到。当然,最保险的还是在四个方向都跑一遍模拟,看有没有任何一个方向能让棋盘发生变化。两种方案都可以,前者性能更好,后者更直白。

4. 用 Trae 协作开发的完整过程:提示词这样写,效率翻倍

4.1 给 AI 的提示词,本质上是需求文档

很多人用 AI 写代码时抱怨"生成的不是我要的",问题大多出在提示词上。AI 不是读心术,它只能根据你给的上下文做最合理的猜测。我在和 Trae 协作时,刻意把任务拆成几个阶段,每个阶段只让它做一件事。

第一阶段,我先在 Trae 里描述数据层需求:

我要做一个 2048 小游戏,先帮我建立一个完整的 Dart 数据模型,包括:

  1. 4x4 棋盘,用 List 表示,空位是 0
  2. reset 方法,初始化棋盘并生成两个初始方块
  3. move 方法,接收 Direction 枚举(left/right/up/down),执行滑动合并
  4. 滑动后如果棋盘有变化,在空位随机生成 2 或 4
  5. 维护 score 和 bestScore
  6. 提供 canMove、isWon 等状态判断 用纯 Dart 实现,不要依赖 Flutter UI 相关的东西

这一段的重点是"纯 Dart、不要 UI"。因为数据层是纯逻辑,不依赖 Flutter 框架,方便以后做单元测试。Trae 很准确地生成了数据类,方向枚举也建好了。我检查了一遍_mergeLine的合并逻辑,确认它正确处理了"一次滑动只合并一次"的规则,然后进入第二阶段。

4.2 让 AI 生成 UI 之前,先做一次手写微调

第二个阶段是生成 UI。我在 Trae 里继续提需求:

基于上面的 Game2048Logic 类,生成 main.dart。要求:

  1. 用 GestureDetector 处理上下左右滑动,滑动距离超过 20 才触发
  2. 用键盘方向键也能控制
  3. 棋盘用 GridView.builder 渲染,每个格子显示数字,根据数字大小变化背景色
  4. 顶部显示分数、最高分、重新开始按钮
  5. 当游戏结束弹 Dialog,游戏胜利后显示"继续游戏"按钮

这次 Trae 生成的代码大部分能用,但在细节上有问题。它把滑动判断写成了onPanEnd里计算起始点和结束点的差值,这个思路是对的,但阈值 20 写的是像素,没有区分屏幕密度。更重要的是,它生成的setState覆盖了整个根组件,每次移动棋盘格子自己也没问题,因为是 4x4 小棋盘,性能完全够,这里的 setState 不需要优化。

不过我手动改了一件事:它默认在 Dialog 里重复创建了新的 Game2048Logic 实例,这会导致点"重新开始"后逻辑层和 UI 层状态不同步。我把这个坑改了,重新开始的逻辑直接调用现有 logic 实例的 reset 方法,而不是 new 一个对象。

4.3 报错了不要自己硬扛,把完整报错丢给 Trae

开发过程中有一个很典型的报错长这样:

Unhandled Exception: type 'Null' is not a subtype of type 'List>' in type cast

这种错误新手容易慌,其实原因很简单:某个!断言或者强转失败了。我自己排查的时候习惯看堆栈,但更高效率的做法是直接把 Trae 编辑器里高亮的报错复制到对话框,告诉它"帮我看一下这是什么原因,怎么修"。

Trae 能识别报错信息并定位到对应代码。我当时遇到的坑是在初始化时用了late Board board;,但某条执行路径在 reset 之前就访问了 board。Trae 给出的建议是把board改成非 late 的初始化方式,或者保证所有入口先调 reset。它给的方案是对的,我采纳了。

这个过程中我总结出一个规律:给 AI 报错信息时,不要只贴错误标题,要把堆栈里你项目的部分一起贴过去,最好再附上出错的代码片段。上下文越全,它的定位越准。如果你只贴一句unable to find suitable visual studio toolc,它可能会拉着你装 Visual Studio,实际上这里根本用不到。

4.4 Trae 不是万能的,它的两个"聪明反被聪明误"时刻

AI 写代码有两类典型问题,我这次都遇到了。

第一类是"过度设计"。我在让 Trae 生成随机生成方块逻辑时,它自作主张给整个过程加了"确保每次生成位置尽量分散"的逻辑,代码里出现了好几层循环和权重计算。实际上 2048 的方块生成就应该是纯随机的,不需要复杂化。我直接把这段删了,换成上面那 10 行不到的简洁实现。

第二类是"自以为修复,其实改坏了逻辑"。有一次我发现棋盘左移时出现了一个奇怪的现象。检查之后发现 Trae 在某个版本里把_mergeLine的判断条件改成了if (i + 1 < cells.length) { if (cells[i] != cells[i+1]) i++; else { merge } },看起来逻辑没问题,但去掉 0 之后的列表长度是动态变化的,在某些边界 case 下会导致越界或者漏合并。我最终选择自己手写这段核心逻辑,不让 AI 碰。

这也印证了我最开始那个观点:算法核心要自己先完全理解,AI 只是帮你翻译成代码。如果你自己都说不清规则,AI 更难帮你写对。

5. Flutter Web 上线前的常见坑:Service Worker、首屏加载和内存

5.1 构建产物和渲染方案:默认 CanvasKit 就够了

Flutter Web 的渲染方案在旧版本里有 HTML 和 CanvasKit 两个选择,HTML 渲染器对 DOM 更友好,加载快一些,但绘制复杂界面时性能和一致性较差。新版 Flutter 已经逐渐淘汰 HTML 渲染方案,默认就是 CanvasKit。

我直接用默认配置,构建命令是:

flutter build web --release

产物在build/web目录下,部署时把整个目录丢到任意静态文件服务器就可以。CanvasKit 的 wasm 文件和字体资源会被打包进去,首次访问时浏览器会加载这些资源,所以首屏白屏时间比普通网页长。

如果你很在意首屏体验,可以在 index.html 里加一个简单的 loading 动画,让用户知道页面正在加载,而不是卡死了。Flutter Web 自身加载完成后会遮住这个 loading 动画,这个方案我实测下来效果不错。

5.2 Service Worker 注册失败:不是每次都值得紧张

Flutter Web 默认生成的flutter_service_worker.js会做资源缓存,它会在页面加载时尝试注册 Service Worker。有时候控制台会报类似could not register service worker: invalidstate的错误,或者dsh web authentication required这种和特定环境相关的错误。

先解释一下invalidstate的常见原因:Service Worker 在非 HTTPS 环境下是受限的,除了localhost。如果你是在本地调试,第一次注册可能因为端口变动、旧 Service Worker 未清理导致状态异常。解决办法很简单,浏览器开发者工具里找到 Application -> Service Workers,点 Unregister 清掉旧的,再刷新页面。

如果是部署到线上后报错,首先要确认站点是 HTTPS。如果你用 HTTP 裸奔访问,Service Worker 注册会失败,页面本身还是能正常打开,只是资源缓存和离线能力没有了。这种情况会让后续版本更新时,用户可能一直加载旧资源。我的处理方式是给 nginx 配好 HTTPS,然后在 nginx 层做静态资源缓存策略,避免依赖 Service Worker 的默认行为。

5.3 2048 也需要内存优化?做游戏就得一开始在意

Flutter Web 的内存表现和移动端不太一样,CanvasKit 渲染在浏览器里跑,内存占用通常比原生 App 高。2048 虽然是小游戏,网格只有 16 个格子,但因为每帧都可能触发动画,如果动画或状态管理写得粗糙,内存和 CPU 占用照样会飙。

我做了三层优化。第一层,在_mergeLine和棋盘遍历里避免创建不必要的临时对象,比如用where和toList产生中间列表是不可避免的,但我不会在每帧 build 里重复做这些计算,所有逻辑结果都存进状态里,build 只负责渲染。第二层,用const构造和shouldRepaint优化让不用变化的组件不重绘。第三层,数字格子背后的颜色映射我提前算成一个 const 列表,避免每次 build 都走 RGB 计算。

这三层对 2048 来说可能有点过度,但养成的习惯是好的。Flutter 项目如果从一开始就不注意状态边界,项目大了之后内存问题会像滚雪球一样越来越难收拾。

5.4 键盘事件在 Web 上的细节差异

我在写键盘控制时,先用了 Flutter 的Focus和KeyEventAPI。这里有一个 Web 特有的麻烦:页面加载后,焦点可能不在游戏区域上,按方向键页面会滚动而不是触发游戏。我在 main 函数里主动给一个FocusNode请求焦点,并且把页面 body 的 overflow 设置成 hidden,避免方向键触发滚动。

另一个点是,移动端浏览器里用方向键是没用的,你要通过GestureDetector的onPanStart和onPanEnd做滑动识别。手法是记录手势起始点,滑动结束的时候判断水平位移和垂直位移哪个更大,决定是哪个方向。这个逻辑本身不复杂,但很多教程只写了移动端,没写 Web 端键盘兼容。我这次两个都做了,代码里用逻辑判断当前平台即可。

6. 核心代码与运行效果说明:照着这份组合就能复现

6.1 数据模型和核心逻辑:完整的 game_2048_logic.dart

下面这段是完整的数据层代码,你可以直接创建lib/game_2048_logic.dart放进去:

import 'dart:math'; enum Direction { left, right, up, down } enum GameStatus { running, won, lost } class Game2048Logic { static const int size = 4; static const int winTile = 2048; late List> board; int score = 0; int bestScore = 0; GameStatus status = GameStatus.running; final Random _random = Random(); int _lastGainedScore = 0; Game2048Logic() { reset(); } void reset() { board = List.generate(size, (_) => List.filled(size, 0)); score = 0; status = GameStatus.running; _spawnTile(); _spawnTile(); } bool move(Direction dir) { if (status == GameStatus.lost) return false; final oldBoard = board.map((row) => List.from(row)).toList(); _lastGainedScore = 0; switch (dir) { case Direction.left: board = board.map(_mergeLine).toList(); break; case Direction.right: board = board.map((line) => _mergeLine(line.reversed.toList()).reversed.toList()).toList(); break; case Direction.up: board = _transpose(board.map(_mergeLine).toList()); break; case Direction.down: board = _transpose( board.map((line) => _mergeLine(line.reversed.toList()).reversed.toList()).toList(), ); break; } final changed = !_boardEquals(oldBoard, board); if (changed) { score += _lastGainedScore; if (score > bestScore) bestScore = score; _spawnTile(); _checkStatus(); } return changed; } List _mergeLine(List line) { final cells = line.where((x) => x != 0).toList(); final result = []; var i = 0; while (i < cells.length) { if (i + 1 < cells.length && cells[i] == cells[i + 1]) { final merged = cells[i] * 2; result.add(merged); _lastGainedScore += merged; i += 2; } else { result.add(cells[i]); i += 1; } } while (result.length < size) { result.add(0); } return result; } List> _transpose(List> matrix) { return List.generate(size, (r) => List.generate(size, (c) => matrix[c][r])); } bool _boardEquals(List> a, List> b) { for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (a[r][c] != b[r][c]) return false; } } return true; } void _spawnTile() { final empty = <(int, int)>[]; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (board[r][c] == 0) empty.add((r, c)); } } if (empty.isEmpty) return; final (r, c) = empty[_random.nextInt(empty.length)]; board[r][c] = _random.nextInt(10) == 0 ? 4 : 2; } bool get canMove { for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (board[r][c] == 0) return true; if (c < size - 1 && board[r][c] == board[r][c + 1]) return true; if (r < size - 1 && board[r][c] == board[r + 1][c]) return true; } } return false; } bool get isWon { if (status == GameStatus.won) return true; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (board[r][c] == winTile) return true; } } return false; } void _checkStatus() { if (isWon) { status = GameStatus.won; } else if (!canMove) { status = GameStatus.lost; } else { status = GameStatus.running; } } }

这份代码里我特意用_lastGainedScore把合并得分和最终总分分离,是因为在_mergeLine里直接改score的话,如果这次移动实际上没有改变棋盘(玩家往空方向滑动),分数就错加了。这个边界条件是我自己写的时候踩到过的,所以在这里做了隔离处理。

6.2 依次滑动和状态刷新:UI 层怎么用这套逻辑

main.dart 里最核心的部分是_handleDirection方法:

void _handleDirection(Direction dir) { final moved = _logic.move(dir); if (!moved) return; setState(() {}); if (_logic.isWon && _logic.status == GameStatus.running) { _showWinDialog(); } else if (_logic.status == GameStatus.lost) { _showLoseDialog(); } }

注意这里我做了两段判断。isWon是为了判断是否出现过 2048,但有可能玩家已经超出 2048 但这个状态没有被置为 won(因为达成 2048 和下一次移动是先后关系)。所以我在 UI 层统一处理:只要当前棋盘里有 2048 且还没有弹过胜利框,就弹一次,玩家可以选择继续游戏,继续玩的话把状态重置回 running,但保留现有棋盘,不再生成新方块。

6.3 手势识别:让 Web 端键盘和移动端触摸共存

手势识别我是这样实现的:

final _focusNode = FocusNode(autofocus: true); void _onPanEnd(DragEndDetails details, Offset startOffset) { final dx = details.offset.dx - startOffset.dx; final dy = details.offset.dy - startOffset.dy; if (dx.abs() < 20 && dy.abs() < 20) return; if (dx.abs() > dy.abs()) { _handleDirection(dx > 0 ? Direction.right : Direction.left); } else { _handleDirection(dy > 0 ? Direction.down : Direction.up); } }

然后在外层用一个KeyboardListener或者CallbackShortcuts监听方向键:

CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.arrowUp): () => _handleDirection(Direction.up), const SingleActivator(LogicalKeyboardKey.arrowDown): () => _handleDirection(Direction.down), const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _handleDirection(Direction.left), const SingleActivator(LogicalKeyboardKey.arrowRight): () => _handleDirection(Direction.right), }, child: Focus( focusNode: _focusNode, child: GestureDetector( onPanStart: (d) => _startOffset = d.localPosition, onPanEnd: _onPanEnd, child: Scaffold(...), ), ), )

有一个容易忽略的点:GestureDetector需要onPanStart和onPanEnd成对出现,否则在移动端浏览器上可能被识别成滚动而不是手势。而 Flutter Web 的默认页面滚动是跟随浏览器的,如果你不想让方向键触发页面滚动,在入口 main 函数里设置一下页面背景和滚动策略。

6.4 编译、部署和最终验证

代码写完,回到 Trae 终端里跑:

fvm flutter build web --release

构建完成后,build/web目录就是全部产物。如果你有 nginx,配置大概长这样:

server { listen 80; server_name your-domain.com; root /var/www/2048/build/web; index index.html; location / { try_files $uri $uri/ /index.html; } gzip on; gzip_types application/javascript application/json text/css; }

try_files那行在 2048 这种单页应用里不是必须的,但留着兼容以后加路由的情况。gzip 对 Flutter Web 的优化非常明显,发布包的 js 和 wasm 文件压缩率很高,开启后首屏加载时间能肉眼可见地缩短。

部署完之后,用手机和电脑各访问一遍。电脑上重点测方向键和鼠标滑动是否都能触发移动;手机上重点测滑动灵敏度,如果觉得太灵敏就把 20 像素阈值调大到 30。另外注意快速连续滑动时游戏状态是否正确刷新,我曾经因为 setState 还没执行完就接收了新的手势事件,导致视觉上"两个方块重叠",后来在_handleDirection里加了一个简单的防抖标志位解决。

最后再分享一个我自己用下来的体会

Trae 这类 AI 编程工具最舒服的使用场景,不是让它"替代你思考",而是让它"帮你把已经想清楚的事情快速实现"。这次写 2048,算法和规则是我自己先理清的,Trae 负责把数据模型、UI 布局、手势识别的代码在几分钟内生成出来,省掉了大量重复的模板代码。但整个过程中最值钱的几段代码——合并逻辑、状态机、得分隔离——依然是我逐行校对过的。游戏开发的核心逻辑太容易藏 bug 了,AI 写出来的代码可以帮你跑通流程,但审核和边界情况一定要自己把控。希望这篇内容能帮到正打算尝试 Trae 加 Flutter Web 的朋友,如果你也照着把它跑起来了,大概率能收获和我一样的满足感。