ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

用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 来做 20481.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 路径。如果你用的是 fvmSDK 路径通常不在系统 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 doctorflutter 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 的矩阵。数据结构上我选了最直接的ListListint外层是行内层是列空位用 0 表示。typedef Board ListListint; 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, (_) Listint.filled(size, 0)); score 0; _spawnTile(); _spawnTile(); } }有人可能会问为什么不用一维数组加索引换算对 2048 这种固定 4x4 的棋盘来说一维数组确实能省一点内存但可读性会变差。我们要滑动合并的是行和列二维数组可以直接用board[row]取出一整行配合后面的行列变换代码会清晰得多。性能上 4x4 规模根本无所谓优先保证逻辑可读。3.2 滑动合并的两条实现路径我为什么选了逐行处理2048 的滑动合并是核心中的核心。网上很多实现是把四个方向各写一遍代码又臭又长还容易出 bug。我采用的是只实现左移其他方向通过变换复用左移的方案。先说怎么处理一行Listint _mergeLine(Listint line) { // 去掉所有 0 final cells line.where((x) x ! 0).toList(); if (cells.isEmpty) return Listint.filled(4, 0); final result int[]; 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% 概率生成 210% 概率生成 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][c1]相等时在检查右边邻居时会覆盖但左边邻居其实在上一轮已经检查过了。四个方向通过右和下两个方向就能等价覆盖因为如果存在任何可合并的相邻对必然会被其中的右或下关系捕捉到。当然最保险的还是在四个方向都跑一遍模拟看有没有任何一个方向能让棋盘发生变化。两种方案都可以前者性能更好后者更直白。4. 用 Trae 协作开发的完整过程提示词这样写效率翻倍4.1 给 AI 的提示词本质上是需求文档很多人用 AI 写代码时抱怨生成的不是我要的问题大多出在提示词上。AI 不是读心术它只能根据你给的上下文做最合理的猜测。我在和 Trae 协作时刻意把任务拆成几个阶段每个阶段只让它做一件事。第一阶段我先在 Trae 里描述数据层需求我要做一个 2048 小游戏先帮我建立一个完整的 Dart 数据模型包括4x4 棋盘用 ListList 表示空位是 0reset 方法初始化棋盘并生成两个初始方块move 方法接收 Direction 枚举left/right/up/down执行滑动合并滑动后如果棋盘有变化在空位随机生成 2 或 4维护 score 和 bestScore提供 canMove、isWon 等状态判断 用纯 Dart 实现不要依赖 Flutter UI 相关的东西这一段的重点是纯 Dart、不要 UI。因为数据层是纯逻辑不依赖 Flutter 框架方便以后做单元测试。Trae 很准确地生成了数据类方向枚举也建好了。我检查了一遍_mergeLine的合并逻辑确认它正确处理了一次滑动只合并一次的规则然后进入第二阶段。4.2 让 AI 生成 UI 之前先做一次手写微调第二个阶段是生成 UI。我在 Trae 里继续提需求基于上面的 Game2048Logic 类生成 main.dart。要求用 GestureDetector 处理上下左右滑动滑动距离超过 20 才触发用键盘方向键也能控制棋盘用 GridView.builder 渲染每个格子显示数字根据数字大小变化背景色顶部显示分数、最高分、重新开始按钮当游戏结束弹 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 ListListint 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[i1]) 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 ListListint 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, (_) Listint.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) Listint.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; } Listint _mergeLine(Listint line) { final cells line.where((x) x ! 0).toList(); final result int[]; 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; } ListListint _transpose(ListListint matrix) { return List.generate(size, (r) List.generate(size, (c) matrix[c][r])); } bool _boardEquals(ListListint a, ListListint 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 的朋友如果你也照着把它跑起来了大概率能收获和我一样的满足感。
返回列表