最近用 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 --platforms=web .这个命令会在当前目录生成一个最小的 Flutter Web 项目骨架。我不建议让 AI 帮你执行这步,命令行工具做的事没必要浪费对话,而且手动初始化可以确保项目结构和 Flutter 版本严格匹配。
2. 动手前的设计拆解:2048 的九成难度在算法
2.1 核心数据结构:一维还是二维
2048 棋盘是 4x4,用二维数组是最直觉的表示方式:List>,外层 4 行,内层每行 4 个数字,空位用 0 表示。一维数组List虽然也能做(长度为 16,index ~/ 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 列:
List> rotateClockwise(List> 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% 概率是 2,10% 概率是 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 二维数组 List 表示棋盘,0 表示空格。
- 提供 move(GameDirection direction) 方法,支持 up/down/left/right 四个方向。
- 四个方向的移动复用同一个向左移动逻辑,通过旋转棋盘实现。
- 合并规则:每一行去掉 0,相邻相同数字合并一次,合并结果不再参与后续合并。
- 每次有效移动后,在随机空位生成新方块,90% 概率生成 2,10% 概率生成 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 List> grid; final int scoreGained; final bool moved; MoveResult(this.grid, this.scoreGained, this.moved); } class GameState { List> grid; int score; int bestScore; GameStatus status; final Random _random = Random(); GameState({List>? initialGrid}) : grid = initialGrid ?? List.generate(4, (_) => List.filled(4, 0)), score = 0, bestScore = 0, status = GameStatus.running { _spawnRandomTile(); _spawnRandomTile(); } void restart() { grid = List.generate(4, (_) => List.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) => List.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); } ({List line, List original, int scoreGained}) _mergeLine( List input) { final original = List.from(input); final tiles = input.where((e) => e != 0).toList(); final line = []; 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); } List> _rotateClockwise(List> 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; } bool _sameLine(List a, List 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 内置隐式动画,不需要手动管理AnimationController,AI 生成时不容易出错。
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里累计了位移量,如果速度向量长度小于阈值,就按累计位移的方向判断。两种输入方式都覆盖到了。
键盘方向键监听则用Focus+KeyboardListener,并且要求页面获得焦点后才接收事件:
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 State createState() => _GamePageState(); } class _GamePageState extends State { 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 平台的 localStorage:
import 'package:shared_preferences/shared_preferences.dart'; Future 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前读取一次:
Future 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 生成代码的质量越稳定,后期排查成本越低。这个项目从头到尾,真正花时间的地方不是写代码,而是我反复确认游戏规则、边界条件和状态流转的那几个小时。思路理顺了,代码反而是水到渠成的事。