ARTICLE DETAIL

资讯详情

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

用Trae从0到1开发Flutter Web 2048小游戏:完整实践记录

用Trae从0到1开发Flutter Web 2048小游戏:完整实践记录 最近用 Trae 从 0 到 1 搞了一个 Flutter Web 小游戏 2048整个过程从创建项目到能在浏览器里完整跑起来大概花了一个晚上。说实话以 AI IDE 现在的完成度做 2048 这种逻辑完整、但复杂度控制得住的小项目几乎是需求文档到成品的单向通道。但前提是你自己得先把玩法、数据结构、算法边界想清楚然后才知道怎么给 Trae 下指令以及它生成完代码之后从哪些地方开始检查。这篇记录我完整的实操过程从环境准备、2048 核心算法拆解、Trae 的 Chat 和 Build 两种模式怎么配合到 Flutter Web 在浏览器里部署实测的体验适合正在学 Flutter或者想认真体验 AI 编程工作流的朋友。1. 开局选型Trae 和 Flutter Web这个组合到底顺不顺手1.1 Trae 的两种模式如何配合开发Trae 这类 AI 编辑器大家最常用的就是对话窗口。但我实际用下来发现Chat 和 Build 是两种完全不同的工作方式用对了才有效率。Chat 模式适合聊你问它一段逻辑怎么写、某个报错是什么意思、某段代码怎么优化它给你解释和代码片段你自己决定往哪儿贴。Build 模式是干活你说清楚一个功能需求它会直接帮你改文件、建文件把整个改动落到项目里。很多人刚上手时总在 Chat 里让它帮我写一个完整游戏它会给你一大段代码但项目里不会多出任何文件你还得手动复制粘贴。后来我习惯是小范围改动用 Chat从零搭文件结构或者跨文件的改动直接用 Build。另外 Trae 对中文用户确实友好需求用中文描述得很自然时生成结果基本不会跑偏。这一点看起来简单但对国内开发者来说实际节省的时间比想象中多——以前用类似工具时关键指令还得用英文精炼现在基本全程中文对话只有涉及命名的时候我会额外补一句使用英文类名和标识符。1.2 为什么选 2048 当验证项目我选 2048 做 Flutter Web 练手项目主要是三个原因。第一它有一套明确的算法核心。4x4 网格的移动、合并、随机生成、胜负判断这些逻辑边界非常清楚非常适合用来验证 AI 到底有没有理解需求。如果 AI 生成的合并逻辑是错的几把玩下来必现 bug不存在看起来能跑但实际糊弄过去的情况。第二它带交互和 UI 状态。滑动或者键盘操作、分数刷新、游戏结束弹窗、重新开始这些在 Flutter 里会用到手势识别、状态管理、焦点监听、对话框流程覆盖了 APP 开发里相当常见的一批知识点。项目不大但五脏俱全。第三Flutter Web 的构建和部署流程本身也值得折腾一下。2048 这种小游戏放到浏览器里跑正好能暴露 Flutter Web 首屏加载、资源体积、键盘焦点这些移动端不会遇到的问题。做一遍下来你对 Flutter Web 项目的理解比光看文档要深得多。1.3 环境准备清单开发前先把环境准备好这一步别跳。Flutter SDK我用的是 3.x 稳定版。可以在终端里先运行flutter --version确认版本如果版本低于 3.10建议先升级因为 Web 渲染器的行为和参数在老版本里有较大差异。浏览器调试环境Chrome用于flutter run -d chrome调试。Flutter Web 在 Chrome 里的调试体验最好DevTools 的提示也更友好。Trae直接下载安装即可建议用较新的版本旧版本对 Dart/Flutter 项目的识别和索引能力差距蛮大的。如果之前用的 1.0 左右的老版本很多 MCP、Skills 相关的新功能都用不上。可选flutter build web需要网络部署到静态服务器时需要确认服务器支持 gzip / brotli 压缩这一点后面单独讲。准备齐了之后打开 Trae新建一个空目录在终端里执行flutter create --platformsweb .这个命令会在当前目录生成一个最小的 Flutter Web 项目骨架。我不建议让 AI 帮你执行这步命令行工具做的事没必要浪费对话而且手动初始化可以确保项目结构和 Flutter 版本严格匹配。2. 动手前的设计拆解2048 的九成难度在算法2.1 核心数据结构一维还是二维2048 棋盘是 4x4用二维数组是最直觉的表示方式ListListint外层 4 行内层每行 4 个数字空位用 0 表示。一维数组Listint虽然也能做长度为 16index ~/ 4得到行号index % 4得到列号但对 AI 来说二维数组的错误率更低代码也更容易读懂。我选择二维数组还有一个原因Flutter UI 层渲染时遍历二维数组的写法比较直接。for (var row 0; row 4; row) { for (var col 0; col 4; col) { final value gameState.grid[row][col]; // 渲染每个格子 } }2.2 移动合并算法的标准姿势移动合并是整个游戏的核心。很多人第一次写 2048 时会直觉地把移动和合并并成一步其实分开想更清晰。一行四位数字向左移动并合并的完整逻辑是去掉这行中的所有 0得到一个紧凑数组。从左边开始逐个检查如果当前数字和下一个数字相同就合并成两倍放在结果数组里然后跳过下一格如果不同就把当前数字放进结果数组继续检查下一格。用 0 补齐结果数组到 4 位。比如[2, 2, 4, 0]向左移动去掉 0 后是[2, 2, 4]合并前两位得到[4, 4]补零后是[4, 4, 0, 0]。再比如[2, 2, 2, 2]去掉 0 后[2, 2, 2, 2]从左合并是[4, 4]这一步非常关键——它不会继续把两个 4 合并成 8。因为合并完一对之后指针直接跳到了下一对不回头这正是 2048 规则中最容易写错的一点。很多 AI 生成的第一版逻辑在这里会把[2, 2, 2, 2]错误地合并成[8, 0, 0, 0]。我在后面第 4 章会给完整代码这里先理解思路。2.3 四个方向怎么复用同一套逻辑向左移动是最基础的逻辑但游戏需要支持上、下、右三个方向。如果每个方向写一套循环代码会重复到怀疑人生。这里有一个标准技巧旋转棋盘。把整个棋盘顺时针旋转 90 度向上移动就等价于旋转之后的向左移动。移动完成后再旋转回去。同理向右移动把棋盘水平翻转做向左移动再水平翻转回去。向下移动把棋盘顺时针旋转 180 度做向左移动再旋转 180 度回去。向上移动把棋盘顺时针旋转 90 度做向左移动再逆时针旋转 90 度回去。代码里只需要一个旋转棋盘函数和一个向左合并函数其他方向全部复用这两个函数。旋转函数的核心就是坐标映射第 i 行第 j 列的元素顺时针旋转 90 度后会到第 j 行的第 3 - i 列ListListint rotateClockwise(ListListint grid) { final rotated List.generate(4, (_) List.filled(4, 0)); for (var i 0; i 4; i) { for (var j 0; j 4; j) { rotated[j][3 - i] grid[i][j]; } } return rotated; }这个技巧同样适合让你在给 AI 下需求时明确表达四个方向的移动统一通过旋转棋盘后复用向左移动逻辑不要为每个方向单独写一套循环。 它会让生成代码的结构清晰很多。2.4 游戏状态生成、胜利、失败算法之外还剩三个状态问题。每次有效的移动结束后棋盘上要随机生成一个新方块90% 概率是 210% 概率是 4生成位置在所有空位中随机选一个。这里要注意有效移动四个字如果用户向某个方向滑动但棋盘没有任何变化就不能生成新方块。胜利判断简单只要有任意格子等于 2048就算赢。失败判断需要算一下还能不能动棋盘满了并且上下左右四个方向都不存在相邻相同的格子才算死局。注意相邻相同格子只要有一对游戏就还能继续。到这里2048 的算法模型我已经完整想清楚了。接下来就交给 Trae。3. Trae 实战记录从空目录到可玩游戏3.1 第一步先把项目跑起来在 Trae 里打开刚才flutter create生成的目录先让项目在 Chrome 里跑一遍。这一步不做任何 AI 生成目的有两个确认 Flutter Web 环境一切正常以及确认 Trae 正确识别了这个项目的语言和结构。我在 Trae 顶部工具栏选择 Chrome 作为调试目标点击 Run几分钟内浏览器弹出了 Flutter 默认的计数器页面。到这里工具链已经通了。3.2 让 Trae 生成核心逻辑接下来是最关键的一轮交互。我的习惯是先让 Trae 生成游戏核心逻辑文件再生成 UI不要一句话让它生成整个游戏。我打开对话窗口切到 Chat 模式输入了这样一段需求帮我生成一个 Flutter Web 的 2048 游戏核心逻辑文件 game_state.dart要求如下使用 4x4 二维数组 ListList 表示棋盘0 表示空格。提供 move(GameDirection direction) 方法支持 up/down/left/right 四个方向。四个方向的移动复用同一个向左移动逻辑通过旋转棋盘实现。合并规则每一行去掉 0相邻相同数字合并一次合并结果不再参与后续合并。每次有效移动后在随机空位生成新方块90% 概率生成 210% 概率生成 4。提供 canMove 属性用于判断游戏是否还能继续提供 status 字段表示 running / won / lost。提供 restart() 方法重置游戏。使用英文类名和注释不要引入任何第三方包。这段 prompt 里第 3、4、6 条是最关键的。第 3 条限定了实现途径避免 AI 生成四套独立循环第 4 条是 2048 合并逻辑最容易出错的点必须显式声明第 6 条直接决定后面 UI 层好不好写状态判断。Trae 很快给出了完整代码。我快速扫了一遍整体结构类名、方法签名基本符合预期。不过我没有直接信任它而是把生成的代码又通读了一遍对照我的算法模型检查合并逻辑。第一次生成时它的某个版本没有正确处理合并后不再参与合并也就是[2, 2, 2, 2]会被算成[8, 0, 0, 0]。我把这个情况作为例子发给它请检查 [2,2,2,2] 的左移结果应该返回 [4,4,0,0]而不是 [8,0,0,0]。 它很快在 while 循环里修正了指针跳位的问题。这就是为什么我强调你自必须先理解算法AI 生成的代码要能看懂、能提出反例而不是跑一下就完事。3.3 UI 与交互的生成与修正核心逻辑确认无误后我切到 Build 模式让它生成主界面文件 main.dart。这轮的 prompt 比上一轮更偏 UI 和交互使用 Flutter 实现 2048 游戏界面规则如下顶部显示分数和最高分最高分要实时更新。中部显示 4x4 棋盘格子颜色根据数字大小变化参考标准 2048 配色。棋盘要能响应鼠标/触摸滑动通过滑动速度判断方向同时支持键盘上下左右方向键。点击页面任意位置后键盘操作才生效这是 Flutter Web 的焦点机制请正确处理。游戏结束后弹出对话框显示游戏结束或你赢了并提供重新开始按钮。重新开始后分数清零棋盘重置为初始状态。所有交互都用英文标识符页面文案用中文。Build 模式收到需求后直接修改了 main.dart 和其他相关文件我在编辑器的变更面板里能看到它动了哪些代码。运行测试时踩了几个小坑但都不复杂第一个坑是 Flutter Web 的键盘焦点。浏览器里只有页面获得焦点后Flutter 的KeyboardListener才能收到方向键事件。它生成的代码里用了Focus(autofocus: true)包裹棋盘实测下来可行但用户如果点击了浏览器地址栏或者按过 Tab 键焦点就可能丢。我在 build 方法里加了一个全局鼠标落点重新聚焦的处理让任何一次点击都会先把焦点拉回棋盘区域。这个细节在移动端完全不存在是 Flutter Web 特有的问题。第二个坑是滑动方向判断。简单用onPanEnd里的位移量判断如果用户快速划过屏幕位移量不一定大容易被误判成另一个方向。后来我把判断逻辑改成同时参考位移和速度取位移向量和速度向量中绝对值大的那个方向作为滑动方向。这样既照顾了慢速拖拽也照顾了快速轻扫。3.4 动画打磨这里值得多花一轮对话第一版能玩之后棋盘上的方块出现和数字变化都很生硬因为每次setState都是直接重绘。我给 Trae 加了一轮需求给新增的方块加一个从 0 到 1 的缩放动画给数字合并的结果加一个轻微的弹跳效果。其余格子保持静态不要给所有格子都加位移动画。之所以不做完整的滑动位移动画是因为 2048 的方块位移动画需要记录每个方块移动的起点和终点并在动画持续期间维护大量位置状态。对这个项目来说性价比不高而且 AI 一次生成复杂动画的出错率会明显上升。简单缩放动画用AnimatedScale就能实现代码量小、运行稳。这一轮我依然用 Chat 模式让 Trae 给出改动建议确认它给的AnimatedScale包在哪个位置之后手动合入还是因为它对文件路径的理解有偏差直接自动改容易改花。结果很满意新增方块有一个干净的淡入放大效果合并时两层缩放叠加手感比纯重绘好了很多。到这里游戏已经是一个能拿得出手的成品了。4. 核心代码逐段拆解可直接抄的版本4.1 game_state.dart 完整实现游戏逻辑层是整个项目的灵魂。以下是最终稳定版的完整代码逻辑修正过一轮去掉了 AI 初版里的两个隐患一是[2,2,2,2]的合并错误二是每次移动都无条件生成新方块的问题。import dart:math; enum GameDirection { up, down, left, right } enum GameStatus { running, won, lost } class MoveResult { final ListListint grid; final int scoreGained; final bool moved; MoveResult(this.grid, this.scoreGained, this.moved); } class GameState { ListListint grid; int score; int bestScore; GameStatus status; final Random _random Random(); GameState({ListListint? initialGrid}) : grid initialGrid ?? List.generate(4, (_) Listint.filled(4, 0)), score 0, bestScore 0, status GameStatus.running { _spawnRandomTile(); _spawnRandomTile(); } void restart() { grid List.generate(4, (_) Listint.filled(4, 0)); score 0; status GameStatus.running; _spawnRandomTile(); _spawnRandomTile(); } MoveResult move(GameDirection direction) { final turns switch (direction) { GameDirection.left 0, GameDirection.up 1, GameDirection.right 2, GameDirection.down 3, }; var work grid.map((row) Listint.from(row)).toList(); for (var i 0; i turns; i) { work _rotateClockwise(work); } var moved false; var gained 0; for (var i 0; i 4; i) { final lineResult _mergeLine(work[i]); work[i] lineResult.line; gained lineResult.scoreGained; if (!_sameLine(work[i], lineResult.original)) { moved true; } } for (var i 0; i ((4 - turns) % 4); i) { work _rotateClockwise(work); } if (moved) { grid work; score gained; if (score bestScore) bestScore score; _spawnRandomTile(); _updateStatus(); } return MoveResult(grid, gained, moved); } ({Listint line, Listint original, int scoreGained}) _mergeLine( Listint input) { final original Listint.from(input); final tiles input.where((e) e ! 0).toList(); final line int[]; var gained 0; var i 0; while (i tiles.length) { if (i 1 tiles.length tiles[i] tiles[i 1]) { final merged tiles[i] * 2; line.add(merged); gained merged; i 2; } else { line.add(tiles[i]); i; } } while (line.length 4) { line.add(0); } return (line: line, original: original, scoreGained: gained); } ListListint _rotateClockwise(ListListint grid) { final rotated List.generate(4, (_) Listint.filled(4, 0)); for (var i 0; i 4; i) { for (var j 0; j 4; j) { rotated[j][3 - i] grid[i][j]; } } return rotated; } bool _sameLine(Listint a, Listint b) { for (var i 0; i 4; i) { if (a[i] ! b[i]) return false; } return true; } void _spawnRandomTile() { final emptyCells (int, int)[]; for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (grid[i][j] 0) emptyCells.add((i, j)); } } if (emptyCells.isEmpty) return; final cell emptyCells[_random.nextInt(emptyCells.length)]; grid[cell.$1][cell.$2] _random.nextInt(10) 0 ? 4 : 2; } void _updateStatus() { for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (grid[i][j] 2048) { status GameStatus.won; return; } } } if (!canMove) { status GameStatus.lost; } } bool get canMove { for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (grid[i][j] 0) return true; if (i 1 4 grid[i][j] grid[i 1][j]) return true; if (j 1 4 grid[i][j] grid[i][j 1]) return true; } } return false; } }几个值得注意的点_mergeLine返回了original用于判断这行是否发生了变化从而保证只有有效移动才生成新方块。_rotateClockwise是唯一的方向处理函数四个方向都靠它重复调用完成逻辑高度收敛。_updateStatus在每次有效移动后先判断 2048 再判断是否死局顺序不能反。4.2 main.dart 的渲染骨架UI 层的完整代码也不复杂我挑几个关键片段讲清楚结构。完整文件写在最后面可以直接抄。顶层是GamePage一个StatefulWidget持有唯一的GameState实例。棋盘区域用AspectRatio(aspectRatio: 1)保证正方形内部用GridView.builder渲染 4x4 格子。每个格子是一个独立的TileWidget根据数值显示不同背景色和数字class TileWidget extends StatelessWidget { final int value; const TileWidget({super.key, required this.value}); Color _backgroundColor() { 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() { return value 4 ? const Color(0xFF776E65) : Colors.white; } override Widget build(BuildContext context) { return AnimatedScale( scale: value 0 ? 0 : 1, duration: const Duration(milliseconds: 120), curve: Curves.easeOutBack, child: AnimatedContainer( duration: const Duration(milliseconds: 80), decoration: BoxDecoration( color: _backgroundColor(), borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: Text( value 0 ? : $value, style: TextStyle( fontSize: value 1024 ? 20 : 28, fontWeight: FontWeight.bold, color: _textColor(), ), ), ), ); } }AnimatedScale让新出现的方块有从 0 到 1 的放大效果AnimatedContainer负责数字变化时的颜色过渡。这两个都是 Flutter 内置隐式动画不需要手动管理AnimationControllerAI 生成时不容易出错。4.3 手势方向判定实现手势的核心是GestureDetector的onPanEnd。它给出的是结束时的速度向量我结合位移和速度两个指标判定方向void _onPanEnd(DragEndDetails details) { final velocity details.velocity.pixelsPerSecond; final offset details.primaryVelocity ?? velocity.dx; // 优先看速度速度接近 0 时看位移方向 final speed velocity.distance; GameDirection direction; if (speed 50) { // 慢速拖拽按位移方向判断 // 实际项目中这里需要记录 onPanUpdate 的累计位移 } else if (velocity.dy.abs() velocity.dx.abs()) { direction velocity.dy 0 ? GameDirection.down : GameDirection.up; } else { direction velocity.dx 0 ? GameDirection.right : GameDirection.left; } _handleMove(direction); }这里有个细节onPanEnd只能拿到手指抬起那一瞬间的速度和最后一次更新位置慢速拖拽时速度可能很小。所以我另外在onPanUpdate里累计了位移量如果速度向量长度小于阈值就按累计位移的方向判断。两种输入方式都覆盖到了。键盘方向键监听则用FocusKeyboardListener并且要求页面获得焦点后才接收事件Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { final key event.logicalKey; late GameDirection? direction; if (key LogicalKeyboardKey.arrowUp) direction GameDirection.up; else if (key LogicalKeyboardKey.arrowDown) direction GameDirection.down; else if (key LogicalKeyboardKey.arrowLeft) direction GameDirection.left; else if (key LogicalKeyboardKey.arrowRight) direction GameDirection.right; if (direction ! null) { _handleMove(direction); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: GestureDetector(...), )KeyRepeatEvent的监听可以让用户长按方向键持续移动体验接近原生键盘操作。4.4 完整 main.dart 代码为了方便直接跑我把整合后的 main.dart 完整贴在这里。这个文件包含分数展示、棋盘、手势和键盘监听以及游戏结束弹窗import package:flutter/material.dart; import package:flutter/services.dart; import game_state.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( scaffoldBackgroundColor: const Color(0xFFFAF8EF), fontFamily: monospace, ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); override StateGamePage createState() _GamePageState(); } class _GamePageState extends StateGamePage { final GameState _game GameState(); Offset _totalPanDelta Offset.zero; void _handleMove(GameDirection direction) { final result _game.move(direction); if (!result.moved) return; setState(() {}); if (_game.status GameStatus.lost) { _showEndDialog(游戏结束, 无法继续移动); } else if (_game.status GameStatus.won) { _showEndDialog(你赢了, 达到 2048); } } void _showEndDialog(String title, String message) { WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( context: context, barrierDismissible: false, builder: (context) AlertDialog( title: Text(title), content: Text(message), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); setState(() _game.restart()); }, child: const Text(再来一局), ), ], ), ); }); } void _onPanUpdate(DragUpdateDetails details) { _totalPanDelta details.delta; } void _onPanEnd(DragEndDetails details) { final velocity details.velocity.pixelsPerSecond; GameDirection? direction; if (velocity.distance 100) { // 慢速拖拽按位移方向判断 final dx _totalPanDelta.dx; final dy _totalPanDelta.dy; if (dx.abs() dy.abs()) { direction dx 0 ? GameDirection.right : GameDirection.left; } else { direction dy 0 ? GameDirection.down : GameDirection.up; } } else if (velocity.dy.abs() velocity.dx.abs()) { direction velocity.dy 0 ? GameDirection.down : GameDirection.up; } else { direction velocity.dx 0 ? GameDirection.right : GameDirection.left; } _totalPanDelta Offset.zero; if (direction ! null) _handleMove(direction); } override Widget build(BuildContext context) { return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Expanded( child: Text( 2048, style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), ), _ScoreBox(label: 分数, value: _game.score), const SizedBox(width: 8), _ScoreBox(label: 最高, value: _game.bestScore), ], ), const SizedBox(height: 16), Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { final key event.logicalKey; GameDirection? dir; if (key LogicalKeyboardKey.arrowUp) dir GameDirection.up; else if (key LogicalKeyboardKey.arrowDown) dir GameDirection.down; else if (key LogicalKeyboardKey.arrowLeft) dir GameDirection.left; else if (key LogicalKeyboardKey.arrowRight) dir GameDirection.right; if (dir ! null) { _handleMove(dir); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: GestureDetector( onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, child: AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.builder( physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: 16, itemBuilder: (context, index) { final value _game.grid[index ~/ 4][index % 4]; return TileWidget(value: value); }, ), ), ), ), ), const SizedBox(height: 16), TextButton( onPressed: () setState(() _game.restart()), child: const Text(重新开始), ), ], ), ), ), ), ); } } class _ScoreBox extends StatelessWidget { final String label; final int value; const _ScoreBox({required this.label, required this.value}); override Widget build(BuildContext context) { 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: Color(0xFFEEE4DA), ), ), Text( $value, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } }这段代码里_showEndDialog用了addPostFrameCallback确保弹窗不会在 build 过程中直接触发避免 Flutter 报setState called during build的错。这是 AI 初版容易忽略的地方。5. 浏览器端实测与收尾优化Flutter Web 那点事5.1 首屏加载慢体积优化实践把游戏部署到静态服务器之后打开浏览器第一眼很尴尬——页面白屏了好一阵。这是 Flutter Web 的天然短板默认构建出来的主程序包含了完整的引擎文件体积普遍在 2MB 以上在非本地网络下体验会明显掉队。优化手段有两个方向。第一个是用flutter build web --release构建发布版本生产构建会做 tree shaking体积比 debug 小不少。第二个是在服务器端开启 gzip 或 brotli 压缩。Nginx 配置里几行就能打开gzip on; gzip_types text/css application/javascript application/json application/wasm; gzip_min_length 1024;Flutter Web 的产物里.wasm文件占了很大比例application/wasm这个 MIME 类型必须加上否则压缩不生效。我实测过开启 gzip 后 Flutter Web 整体传输体积能下降 70% 左右首屏时间从三秒多降到一秒内。如果你手里的 Flutter 版本还支持--web-renderer html之类的参数老教程会建议你切 HTML 渲染器减小体积。但这已经是过去式了新版 Flutter 统一使用 CanvasKit 渲染没必要在这上面纠结专心做压缩更实际。5.2 刷新丢进度给游戏加存档浏览器端玩 2048 有个很自然的需求刷新页面之后想接上刚才的局。Flutter Web 本身不提供跨页面持久化需要自己存。最简单的方案是shared_preferences包它底层会自动适配 Web 平台的 localStorageimport package:shared_preferences/shared_preferences.dart; Futurevoid saveGame(GameState game) async { final prefs await SharedPreferences.getInstance(); final flat game.grid.expand((row) row).join(,); await prefs.setString(grid, flat); await prefs.setInt(score, game.score); await prefs.setInt(best, game.bestScore); }在每次有效移动之后调用saveGame初始化GameState前读取一次Futurevoid loadGame() async { final prefs await SharedPreferences.getInstance(); final flat prefs.getString(grid); if (flat null) return; final values flat.split(,).map(int.parse).toList(); final grid List.generate(4, (i) values.sublist(i * 4, (i 1) * 4)); setState(() { _game.grid grid; _game.score prefs.getInt(score) ?? 0; _game.bestScore prefs.getInt(best) ?? 0; _game.status GameStatus.running; }); }这个需求不大但对浏览器里玩的小游戏来说很加分。AI 在生成这类功能时容易把shared_preferences的异步初始化放在build里导致每次 build 都读一次存档。我实际把初始化逻辑放在initState里用async方法处理并且用一个_loaded标记避免重复覆盖玩家当前进度。5.3 AI 生成代码的检查清单用 Trae 完成项目之后我整理了一份针对 Flutter 项目的检查清单以后每次让 AI 生成完代码都会过一遍合并逻辑是否有反例风险。2048 的合并规则天然适合用边界用例检验测试[2,2,2,2]、[2,2,4]、[4,4,4]这三种典型输入能筛掉大部分错误实现。是否有状态没复位。restart()方法除了清空棋盘和分数还要重置status。AI 常见的错误是忘了重置状态枚举导致新一局游戏开始后立刻弹出结束对话框。异步操作是否放对了生命周期。SharedPreferences、Future相关的初始化要放在initState或didChangeDependencies里不能在build里反复触发。是否有无意义的setState。每次移动后如果棋盘没有变化就不该刷新 UI。这个项目的算法层通过MoveResult.moved字段保证了这一点UI 层也只在moved true时才调用setState。是否残留调试代码。让 Trae 生成代码时末尾补一句不要生成任何 debugPrint 或调试输出能省去手动清理的时间。以我的体感AI 生成的 Flutter 代码跑通 UI 通常不难真正出问题都在逻辑边界和生命周期这两个地方。这两块恰恰是提示词里最难一句话约束的只能靠人来检查。6. 这个项目还能怎么玩下去游戏已经能玩但离完整还有很长的路。这里列几个我自己接下来想做的方向给你参考。撤销功能是 2048 玩家最常提的需求。实现思路是在每次移动前把当前的棋盘和分数压入一个历史栈撤销时弹栈恢复。这个方向能很好地考验状态管理设计——如果GameState是可变对象得手动 copy 一份如果改成不可变对象每次移动返回新状态历史栈的实现会自然很多。后端排行榜也可以接。Flutter Web 部署之后如果想让玩家提交分数需要写一个简单的后端接口用 HTTP 请求把分数和玩家昵称发过去再拉取排行榜数据展示。顺手就能练到dart:io或package:http以及 CORS 的处理——Flutter Web 的跨域限制跟浏览器一样严格本地调试和线上部署的 API 地址配置得分开。2048 的 AI 自动求解也是经典玩法。给游戏加一个自动玩按钮用蒙特卡洛树搜索或者简单的期望最大化策略让 AI 自己推算下一步并调用move()。这个方向做起来比想象中有意思还能顺便验证你写的算法层是否足够干净——如果游戏逻辑和 UI 耦合太深AI 自动玩几乎不可能实现。我的建议还是先从简单功能开始改。给TileWidget加个深浅主题切换或者做一套节日配色改起来快成就感来得也直接。核心算法和 UI 骨架已经稳定在这个基础上折腾基本不会翻车。最后分享一个个人体会用 Trae 做项目最大的效率提升不在它能直接写出完整代码而在于它能让你把精力集中在拆解需求和验证结果上。算法想不清楚AI 写得再快也只能跑出一个满是逻辑漏洞的玩具。反过来需求拆得越干净AI 生成代码的质量越稳定后期排查成本越低。这个项目从头到尾真正花时间的地方不是写代码而是我反复确认游戏规则、边界条件和状态流转的那几个小时。思路理顺了代码反而是水到渠成的事。
返回列表