最近被 Trae 种草得厉害,趁着周末把 2048 这个经典小游戏用 Flutter Web 完整重写了一遍。整个过程从创建 Flutter 项目、写游戏逻辑、做 UI 动效,到打包部署,几乎全程在 Trae 里完成,我自己动手改的代码不到十行。这篇文章就完整记录一下这次实操:如何把需求拆给 Trae、2048 的合并算法到底怎么设计、Flutter Web 在交互和动画上有哪些坑,最后整理出可以一键跑起来的完整代码。无论你是刚接触 AI 辅助编程的新手,还是想快速上手 Flutter Web 的开发者,这篇都能给你一套能直接抄的作业。
1. 项目概述与整体思路拆解
1.1 为什么偏偏是 Trae、Flutter Web 和 2048 这个组合
先聊技术选型,这部分决定了整个开发体验,值得多花点篇幅说清楚。
Trae 是目前对中文开发者很友好的一款 AI 编程 IDE,内置了智能体(Agent)、对话(Chat)和 Builder 三种模式,可以边对话边改代码,也能扔一句话让它从零生成整个项目。我的使用感受是:第一,中文提示词理解得好,不用费劲用英文描述需求;第二,Builder 模式可以直接生成多文件项目,而不是像早期工具那样只能生成单个函数;第三,模型切换自由,写 Dart 这种相对小众的语法时,可以选更稳的模型来兜底。如果你是刚接触 AI 编程,Trae 的上手成本确实比 Cursor 这类工具低一截,因为它对中文场景做了不少优化。
选 2048 作为开发对象,原因很实在。它看起来简单,但核心算法里包含数组压缩、方向转换、相邻合并、随机生成、终局判断等典型逻辑,复杂度刚好用来检验 AI 写代码的能力。写个 todo 列表试不出模型的边界,反过来开发完整商业项目 AI 又很难一次搞定,中途调起来也费劲。2048 就是“一句话能描述清楚,但实现起来有很多细节”的经典例子,特别适合拿来体验 AI 辅助开发。
Flutter Web 呢?选择它而不是原生小程序或者纯前端,是因为 Flutter 的布局和动画系统太适合这种网格类游戏了。GridView 两行代码就能画出 4x4 棋盘,AnimatedContainer 天然支持平滑过渡动画,这些要是从零手写 CSS 要折腾很久。而且 Flutter Web 编译产物是现代浏览器可直接运行的,一次写好,后续想套壳成桌面版、移动版也方便。
1.2 2048 的核心玩法:先拆清楚再动手
AI 编程和传统编程最大的区别是:你必须先把规则讲清楚,AI 才能写得出来。所以我做的第一件事不是打开 IDE,而是在纸上把 2048 的玩法拆成几条无歧义的规则。
- 棋盘固定 4x4,每格为空或 2 的 N 次幂。
- 每次操作选择上下左右四个方向之一。
- 所有格子朝该方向移动,遇到相同数字则合并一次。
- 合并最关键的细节:一行里相同数字只能合并一次,且合并优先级由贴近目标方向决定。
- 每次有效操作后,在空白格随机生成一个数字,90% 概率为 2,10% 概率为 4。
- 棋盘满且相邻没有可合并数字时游戏结束,出现 2048 即胜利。
几个边界情况要特别注意。比如[2,2,2,2]往左合并后是[4,4,0,0],不是[8,0,0,0];[2,2,4,4]往左合并是[4,8,0,0];[4,2,2,4]合并之后是[4,4,4,0]。这些细节 AI 第一次写很容易出错,所以后面必须有单元测试兜底。我实际测试的时候发现,模型对“一行只能合并一次”的理解经常有偏差,要么出现连环合并,要么漏掉该合并的相邻对。
1.3 项目文件结构规划
动手前先规划好项目结构,这一步看着简单,但对后续迭代影响很大。我让 Trae 按下面的结构初始化项目,逻辑和界面完全分离,后面调试会轻松很多。
lib/ main.dart // 程序入口 models/ game_model.dart // 核心游戏逻辑,纯 Dart 实现,不依赖 UI screens/ game_page.dart // 主界面,负责布局、手势、动画 widgets/ tile.dart // 单个数字格子的绘制组件把所有游戏状态和算法都放进game_model.dart,界面层只负责渲染和交互。这种分层的好处是:核心逻辑可以直接用dart test测试,不用启动浏览器;将来如果想把游戏改成其他平台,逻辑代码一行都不用动。
2. 环境准备与 Trae 配置实操
2.1 Flutter Web 开发环境搭建
开发环境是老生常谈,但很多新手在这里就会卡住,我按实际操作的顺序过一遍。
首先是确认本机 Flutter 版本。2048 项目用到的 API 在 Flutter 3.10 及以上才稳定支持 Web,我本地用的是 3.22 版本,实测没遇到兼容问题。安装完 Flutter SDK 后先跑flutter doctor检查依赖,然后执行:
flutter config --enable-web flutter devicesflutter devices能看到 Chrome 设备,说明 Web 支持已经生效。接下来创建项目:
flutter create --platforms web,android,ios 2048_game创建完项目后,建议先执行一次flutter precache --web,把 Web 相关的编译器工具下载到本地。这一步不做也行,但首次运行会因为现场下载卡很久。
2.2 Trae 安装与模型选择
Trae 的安装没啥好说的,官网下载对应系统的安装包,装完登录就行。真正影响体验的是模型选择。
打开 Trae 的设置界面,在模型配置里能看到它内置的几个模型选项。我的建议是:代码生成尽量选 Claude 系列或者 GPT 系的大模型,速度快、代码质量稳;如果是日常聊需求、梳理思路,可以选轻量级模型,省积分。实际开发中我发现一个规律:用中文描述需求时,不同模型对中文语义的理解差异挺大,有的模型会把“合并一次”误解成“能合并就连环合并”,遇到这种情况就果断切换模型重新生成。
关于积分消耗,可以给大家一个相对合理的预期。Trae 的 Builder 模式一次生成多个文件、大段替换代码时,消耗积分会比普通 Chat 快,这是正常的。日常小改动用 Chat,不要一上来就派 Builder 大改特改。新用户送的免费资源足够完成 2048 这种量级的项目,遇到积分不够的情况,官方渠道有获取方式,保持正常使用节奏就行。
2.3 让 Trae 搭建初始骨架
环境准备好之后,我在 Trae 里新建了一个项目窗口,把之前规划的目录结构写进提示词,让 Builder 模式生成初始骨架。第一版提示词是这么写的:
请用 Flutter 创建一个 2048 小游戏项目,运行目标为 Web 和移动端。 项目结构要求: lib/main.dart 为入口; lib/models/game_model.dart 负责所有游戏逻辑,纯 Dart 实现,不依赖 UI; lib/screens/game_page.dart 负责界面布局和交互; lib/widgets/tile.dart 负责绘制单个数字方块。 第一版先实现:4x4 棋盘、随机生成数字、上下左右滑动合并、分数统计、重新开始。 界面风格参考经典 2048 深色背景,卡片式数字块。 请先生成完整的可运行代码,然后告诉我运行命令。Trae 跑了一段时间后,直接生成了完整的文件结构和代码。我打开终端执行flutter run -d chrome,第一版居然直接跑起来了。不过运行起来只是开始,接下来才是重头戏——算法对不对,界面好不好看,都要一点点验证和调优。
3. 核心逻辑实现:game_model.dart
3.1 数据结构与状态管理
先看第一版生成的数据结构。我让 Trae 把所有状态封装在一个GameModel类里,界面通过它来读取棋盘和分数,每次操作后由模型返回新的状态。
class GameModel { final int size; List<List<int>> grid; int score; int bestScore; bool gameOver; bool won; GameModel({this.size = 4}) : grid = List.generate(4, (_) => List.filled(4, 0)), score = 0, bestScore = 0, gameOver = false, won = false; factory GameModel.fromState(GameModel old) { final model = GameModel(size: old.size); model.grid = old.grid.map((row) => List<int>.from(row)).toList(); model.score = old.score; model.bestScore = old.bestScore; model.gameOver = old.gameOver; model.won = old.won; return model; } }这里有个值得注意的点:fromState工厂方法做了深拷贝。如果没有这一层深度复制,界面在使用旧状态做动画对比时,新旧状态会引用同一个数组,动画就会失效。这是 AI 生成代码时容易忽略的细节,Review 时需要特别注意。
为了支持动画,界面需要知道“移动前”和“移动后”两份状态。一开始 Trae 生成的是原地修改数组的版本,我追加了一个要求:“每次移动返回操作前后的棋盘状态”,这样 UI 层可以对比两份状态来做过渡动画。
3.2 移动与合并算法:旋转法为什么最稳
2048 的四个方向移动,最优雅的实现方式是旋转法:把任意方向的移动统一转换成“向左移动”。整个思路很简单:
- 如果是向右移动,先把棋盘水平翻转,左移之后再翻转回来。
- 如果是向上移动,先把棋盘逆时针旋转 90 度,左移之后再顺时针转回来。
- 向下移动同理,旋转两次或者反向旋转。
这样只需要实现一个mergeLeft,四个方向都搞定了。Trae 生成的核心代码如下:
List<int> mergeLine(List<int> line) { final nums = line.where((e) => e != 0).toList(); final result = <int>[]; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); i += 2; // 合并后跳过下一个元素,保证一行只合并一次 } else { result.add(nums[i]); i++; } } while (result.length < line.length) { result.add(0); } return result; } List<List<int>> rotate(List<List<int>> grid, {bool clockwise = true}) { final n = grid.length; final result = List.generate(n, (_) => List.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { if (clockwise) { result[j][n - 1 - i] = grid[i][j]; } else { result[n - 1 - j][i] = grid[i][j]; } } } return result; }mergeLine里有几处细节值得展开讲讲。
第一,先过滤掉所有 0,把非零元素压缩到一起,这是移动的本质。比如[0,2,0,4]压缩后就是[2,4],移动完成后补零变成[2,4,0,0]。
第二,合并时用i += 2跳过被合并的元素。这一步保证同一位合并后不会继续参与下一次合并,[2,2,2,2]才能正确得到[4,4,0,0]而不是[8,0,0,0]。这就是“一行只能合并一次”的具体实现。
第三,旋转函数的坐标变换容易写反。我测试时遇到过一个经典问题:向上移动变成了向下移动,根源是旋转方向反了。遇到这种情况快速验证的办法是,准备一个非对称棋盘,比如第一行[2,4,8,16],其余全零,分别测试四个方向,一眼就能看出方向对不对。
四个方向的移动统一封装:
bool moveLeft() { var moved = false; final newGrid = List.generate(size, (_) => List.filled(size, 0)); for (var r = 0; r < size; r++) { final merged = mergeLine(grid[r]); if (merged.toString() != grid[r].toString()) moved = true; newGrid[r] = merged; } if (moved) { grid = newGrid; addRandomTile(); } checkGameState(); return moved; }moved标记非常重要:只有棋盘真正变化了,才允许生成新数字。如果用户往一个没有空间移动的方向滑动,棋盘不能变化,更不能生成新数字。这个细节决定了游戏手感,Trae 第一版就漏了,导致连续往一个方向按,棋盘会一直新增数字。
3.3 随机生成与胜负判断
随机生成新数字的逻辑很直接:收集所有空格子,从里面随机挑一个,按 90%/10% 的概率放 2 或 4。
void addRandomTile() { final emptyCells = <(int, int)>[]; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (grid[r][c] == 0) emptyCells.add((r, c)); } } if (emptyCells.isEmpty) return; final pos = emptyCells[Random().nextInt(emptyCells.length)]; grid[pos.$1][pos.$2] = Random().nextDouble() < 0.9 ? 2 : 4; }这里有个经验:Random实例最好作为模型的一个字段复用,每次调用都new Random()虽然也能用,但在频繁操作时容易出现重复序列,影响随机性。AI 生成代码时习惯每次现用现 new,我一般会手动改成字段级别。
胜负判断分两部分。胜利很简单,只要棋盘上出现 2048;失败则需要遍历所有格子,检查是否还有空白格,以及上下左右是否存在相邻相同数字:
void checkGameState() { for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (grid[r][c] == 2048) won = true; if (grid[r][c] == 0) return; if (r + 1 < size && grid[r][c] == grid[r + 1][c]) return; if (c + 1 < size && grid[r][c] == grid[r][c + 1]) return; } } gameOver = true; }很多新手写失败判断时只检查满盘,结果出现“满盘但还能合并”的情况,游戏提前判定结束。必须同时检查相邻相同数字,这一步不能省。
3.4 用单元测试给算法上保险
算法逻辑是整个游戏的灵魂,光靠肉眼在浏览器里点几下根本测不全。我把核心用例直接写成了 Dart 单测,每次改完代码跑一遍,有问题立刻暴露。
| 测试场景 | 输入 | 期望结果 |
|---|---|---|
| 基本左移 | [2,0,0,2] | [4,0,0,0] |
| 一行只能合并一次 | [2,2,2,2] | [4,4,0,0] |
| 交错合并 | [2,2,4,4] | [4,8,0,0] |
| 不连续数字 | [4,2,2,4] | [4,4,4,0] |
| 无操作允许 | [2,4,8,16]左移 | 棋盘不变,不生成新数 |
| 失败判定 | 满盘且无相邻相同 | gameOver = true |
| 旋转方向 | 非对称棋盘向上 | 向上合并正确 |
用表格列出来之后,测试意图一目了然。跑测试的命令:
flutter testTrae 生成的代码第一次跑测试,通过了大部分用例,但“无操作允许”这个用例挂了——原因就是前面说的moved标记缺失。AI 在“功能实现”上很强,但在“边界行为约束”上需要你主动提需求。把测试用例直接贴给它看,它改起来很快。
4. UI 与交互动效实现
4.1 主界面布局
界面层game_page.dart的布局其实很套路:顶部放标题和分数卡片,中间放棋盘,底部放重新开始按钮。这里我要求 Trae 用LayoutBuilder做响应式适配,让棋盘在手机、平板和浏览器窗口下都能保持正方形并且不溢出。
LayoutBuilder( builder: (context, constraints) { final size = constraints.maxWidth < constraints.maxHeight ? constraints.maxWidth * 0.9 : constraints.maxHeight * 0.7; return Column( children: [ // 顶部分数区 Row( children: [ Text('2048', style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold)), Spacer(), _ScoreCard(label: '分数', value: model.score.toString()), SizedBox(width: 8), _ScoreCard(label: '最高分', value: model.bestScore.toString()), ], ), // 棋盘区 Container( width: size, height: size, child: GridView.builder( padding: EdgeInsets.all(12), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 10, crossAxisSpacing: 10, ), itemBuilder: (context, index) => TileView(value: model.grid[index ~/ 4][index % 4]), ), ), // 重新开始按钮 ElevatedButton(onPressed: () => setState(() => model.reset()), child: Text('重新开始')), ], ); }, )LayoutBuilder是 Flutter Web 做响应式的关键。如果直接写死宽高,窗口一拉伸棋盘就变形或者溢出。我见过很多 AI 生成的 Flutter 小游戏在这个点翻车,原因就是布局没用约束动态计算。
4.2 数字格子的配色与动画
数字格子的经典设计是不同数字对应不同背景色,数字越大颜色越深。这个用 Flutter 的AnimatedContainer可以同时搞定颜色和尺寸的过渡动画。
class TileView extends StatelessWidget { final int value; const TileView({Key? key, required this.value}) : super(key: key); Color get _backgroundColor { return switch (value) { 0 => Color(0xFFCDC1B4), 2 => Color(0xFFEEE4DA), 4 => Color(0xFFEDE0C8), 8 => Color(0xFFF2B179), 16 => Color(0xFFF59563), 32 => Color(0xFFF67C5F), 64 => Color(0xFFF65E3B), 128 => Color(0xFFEDCF72), 256 => Color(0xFFEDCC61), 512 => Color(0xFFEDC850), 1024 => Color(0xFFEDC53F), 2048 => Color(0xFFEDC22E), _ => Color(0xFF3C3A32), }; } @override Widget build(BuildContext context) { if (value == 0) { return Container( decoration: BoxDecoration( color: _backgroundColor, borderRadius: BorderRadius.circular(8), ), ); } return AnimatedContainer( duration: const Duration(milliseconds: 120), curve: Curves.easeInOut, decoration: BoxDecoration( color: _backgroundColor, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( '$value', style: TextStyle( fontSize: value >= 100 ? 22 : 30, color: value <= 4 ? Color(0xFF776E65) : Colors.white, fontWeight: FontWeight.bold, ), ), ); } }数字格子的动画,我让 Trae 加了一个新数字弹出效果:刚生成的数字格子用AnimatedScale从 0 放大到 1,模拟“出现”的视觉反馈。这个细节对游戏手感提升非常明显,没有它,新数字就是突然冒出来,很生硬。
TweenAnimationBuilder<double>( tween: Tween(begin: 0, end: 1), duration: Duration(milliseconds: 180), builder: (context, scale, child) { return Transform.scale(scale: scale, child: child); }, child: tileContent, )4.3 手势与键盘控制
Flutter Web 同时要处理触屏和鼠标拖拽。用GestureDetector的onPanUpdate,比较位移的横纵分量来决定方向,这套逻辑触屏和鼠标拖拽通用。
GestureDetector( onPanUpdate: (details) { final dx = details.delta.dx; final dy = details.delta.dy; if (dx.abs() < 8 && dy.abs() < 8) return; // 忽略微小抖动 if (dx.abs() > dy.abs()) { dx > 0 ? model.moveRight() : model.moveLeft(); } else { dy > 0 ? model.moveDown() : model.moveUp(); } setState(() {}); }, child: boardWidget, )Web 端还有一个提升体验的小细节:支持键盘方向键。Flutter Web 上用KeyboardListener监听按键事件,把上下左右键映射到对应移动。一开始我没想到这个,是 Trae 生成代码时自动加的,后来发现用键盘玩比鼠标拖拽顺手得多,尤其是在笔记本上。
5. 用 Trae 提升开发效率的几个实用姿势
5.1 三套亲测有效的提示词模板
用 Trae 这段时间我总结出一个核心心得:提示词质量直接决定代码质量。同样的需求,描述方式不同,结果可能是“能跑”和“能直接用”的区别。
第一套是“结构先行”模板,适合项目起步阶段。把目标、技术栈、目录结构、功能清单、界面风格一次说清楚。前面的初始骨架提示词就是这个思路,重点是要给出明确的文件划分。
第二套是“给定方案求实现”模板,适合算法和复杂逻辑。比如在实现合并算法时,我先告诉 Trae 思路是旋转法,再让它写代码。这样不是把控制权交给 AI,而是让 AI 做执行者,效果稳定得多。
请按下面的方案实现 2048 移动逻辑: 1. 所有操作统一转换为向左移动; 2. 向左移动时先压缩非零元素,再相邻合并,合并一次后跳过; 3. 使用旋转函数处理上下右方向; 4. 返回是否发生了移动。请生成 game_model.dart 中相关方法。第三套是“报错复述”模板,适合排查问题。把 Trae 自己的报错信息原样贴在对话里,再加上一句“请分析根因并修复”,基本都能快速解决。我见过很多人喜欢自己翻译一遍报错,结果把关键信息弄丢了,没必要。
5.2 让 Trae 改 Bug 的正确姿势
AI 改 Bug 最忌讳的是只给一句“这里有问题”。正确的姿势是:把期望行为和实际行为都描述清楚,最好附上复现步骤。
我之前遇到一个触摸滑动方向错乱的问题,按照下面的描述丢给 Trae,它很快就定位到了问题:
游戏运行在 Web 端,使用鼠标拖拽向右滑动,棋盘的格子却向上移动。请检查手势方向和棋盘移动方向的对应关系,并修复。原因其实是手势的delta.dx和delta.dy的坐标系与旋转函数的坐标系不一致,导致方向映射错位。Trae 迅速给出了修改方案,并顺手加了方向测试。这次体验让我意识到:AI 不是不能用,而是你得给它准确的操作上下文。
5.3 关于积分与模型使用的个人经验
用 Trae 时间长了,对积分消耗也有了一些感受。Builder 模式生成整个项目时消耗较大,但这是必要支出;后续小修小改用 Chat 模式很省。我习惯把大改动集中起来一次让 Builder 执行,而不是改一行就触发一次大范围重写,这样积分消耗可控。
另外,如果某个模型连续两次生成的代码都不对,不要死磕,立刻换模型重试。不同模型对 Dart/Flutter 语法的熟悉程度差异不小,有的模型擅长生成 UI,有的擅长逻辑算法,灵活切换比反复修改高效得多。
6. 常见问题与排查实录
开发过程中踩了不少坑,我把有代表性的问题整理成一个速查表,供大家参考。
| 问题 | 现象 | 解决方案 |
|---|---|---|
| Flutter Web 首次运行很慢 | 执行 flutter run 后长时间白屏 | 提前执行flutter precache --web预下载 Web 编译工具 |
| 棋盘溢出屏幕 | 窄窗口下棋盘变形或被截断 | 使用 LayoutBuilder 根据窗口尺寸动态计算棋盘边长,保持正方形 |
| 滑动方向错乱 | 向左滑动格子却向右移动 | 检查旋转函数的坐标变换,用非对称棋盘分别测试四个方向 |
| 连续按同一方向不停出数字 | 棋盘已满但还在生成新数字 | 移动后先判断moved标志,只有棋盘变化才调用addRandomTile |
| 新数字没有入场动画 | 数字瞬间出现很生硬 | 用 TweenAnimationBuilder 包裹格子,做 scale 从 0 到 1 的动画 |
| 游戏提前结束 | 满盘但还有可合并数字却提示 Game Over | 检查失败判定是否包含相邻相同数字的检测 |
| Trae 报错“检测到内容违反社区规范” | 对话无法继续 | 说明提示词触发了规则,换个表述重新描述需求即可 |
| 保存后部分代码被自动删除或格式化错乱 | 文件内容与刚才不一致 | 少数版本存在编辑竞态问题,改完等 1 到 2 秒再切窗口,必要时用版本回滚恢复 |
有两个问题值得展开说。第一个是 Flutter Web 打包后首屏加载慢的问题,这个几乎每个用 Flutter Web 的人都会遇到。原因是默认构建没有做 gzip 压缩,引擎文件体积较大。部署到服务器时开启静态资源压缩,首屏体验会好很多。
第二个是 Trae 编辑器的自动格式化问题。我遇到过一次保存后大段代码消失的情况,后来查了社区才发现是自动格式化与手动编辑发生了竞态。现在的做法是:涉及大段修改时,先让 Trae 生成到单独文件,人工 Review 后再合并进主项目,避免直接在编辑窗口里反复横跳。
# 构建生产版本 flutter build web --release # 产物目录 build/web/构建产物就是一个纯静态目录,里面的main.dart.js是编译后的核心文件,assets文件夹里是素材资源。把这个目录丢到任意静态文件服务器上就能运行,不需要额外的后端。
7. 完整代码与运行方式
标题说了附完整代码,这里把最核心的两个文件完整贴出来。game_model.dart是纯逻辑,直接复制就能用;game_page.dart给出关键部分,其余布局代码对照前面小节自行补全。
7.1 核心逻辑 game_model.dart 完整代码
import 'dart:math'; class GameModel { final int size; late List<List<int>> grid; int score = 0; int bestScore = 0; bool gameOver = false; bool won = false; final Random _random = Random(); GameModel({this.size = 4}) { reset(); } void reset() { grid = List.generate(size, (_) => List.filled(size, 0)); score = 0; gameOver = false; won = false; addRandomTile(); addRandomTile(); } GameModel copy() { final model = GameModel(size: size); model.grid = grid.map((row) => List<int>.from(row)).toList(); model.score = score; model.bestScore = bestScore; model.gameOver = gameOver; model.won = won; return model; } static List<int> mergeLine(List<int> line) { final nums = line.where((e) => e != 0).toList(); final result = <int>[]; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); i += 2; } else { result.add(nums[i]); i++; } } while (result.length < line.length) { result.add(0); } return result; } static List<List<int>> rotate(List<List<int>> grid, {bool clockwise = true}) { final n = grid.length; final result = List.generate(n, (_) => List.filled(n, 0)); for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { if (clockwise) { result[j][n - 1 - i] = grid[i][j]; } else { result[n - 1 - j][i] = grid[i][j]; } } } return result; } bool moveLeft() { var moved = false; final newGrid = List.generate(size, (_) => List.filled(size, 0)); for (var r = 0; r < size; r++) { final merged = mergeLine(grid[r]); if (merged.toString() != grid[r].toString()) moved = true; newGrid[r] = merged; } if (moved) { grid = newGrid; addRandomTile(); } checkGameState(); return moved; } bool moveRight() { return moveWithTransform(clockwise: false, times: 2); } bool moveUp() { return moveWithTransform(clockwise: true, times: 1); } bool moveDown() { return moveWithTransform(clockwise: false, times: 1); } bool moveWithTransform({required bool clockwise, required int times}) { var temp = grid; for (var i = 0; i < times; i++) { temp = rotate(temp, clockwise: clockwise); } grid = temp; final moved = moveLeft(); var reverse = temp; for (var i = 0; i < (clockwise ? 4 - times : times); i++) { reverse = rotate(grid, clockwise: !clockwise); } grid = reverse; bestScore = max(bestScore, score); return moved; } void addRandomTile() { final emptyCells = <(int, int)>[]; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (grid[r][c] == 0) emptyCells.add((r, c)); } } if (emptyCells.isEmpty) return; final pos = emptyCells[_random.nextInt(emptyCells.length)]; grid[pos.$1][pos.$2] = _random.nextDouble() < 0.9 ? 2 : 4; } void checkGameState() { for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (grid[r][c] == 2048) won = true; if (grid[r][c] == 0) return; if (r + 1 < size && grid[r][c] == grid[r + 1][c]) return; if (c + 1 < size && grid[r][c] == grid[r][c + 1]) return; } } gameOver = true; } }注意moveWithTransform里的方向变换逻辑,这是整个实现里最容易绕晕的地方。如果你直接让 Trae 生成四方向方法,它大概率会复制粘贴四遍moveLeft然后各自改坐标,那样代码又长又容易出错。旋转法虽然理解成本高一点,但代码量小很多,维护起来也省心。
7.2 入口 main.dart 完整代码
import 'package:flutter/material.dart'; import 'screens/game_page.dart'; void main() { runApp(const GameApp()); } class GameApp extends StatelessWidget { const GameApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: '2048', debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.blueGrey, scaffoldBackgroundColor: const Color(0xFFFAF8EF), ), home: const GamePage(), ); } }7.3 运行方式
把上述代码放进项目对应目录,执行:
flutter pub get flutter run -d chrome浏览器打开后即可游玩。如果想打包部署:
flutter build web --releasebuild/web目录就是最终产物。还可以给 Flutter Web 做额外的配置优化,比如在web/index.html里加 loading 样式,公众号或者博客部署时可以配合 nginx 的 gzip 模块开启压缩,首屏体积能省将近 70%。
8. 后续扩展方向
2048 虽然做完了,但整个项目还能往很多方向扩展,我列几个自己计划尝试的方向,给大家提供参考。
一是加入动画回放和“撤销”功能。每次移动前把GameModel的快照压进栈,撤销时弹出来即可。这个功能对 AI 来说实现成本很低,但游戏体验提升很大,关键时刻按错方向真的能救人一命。
二是接入本地排行榜。Flutter Web 可以用shared_preferences或者localStorage保存最高分。这里有个细节:Flutter Web 的shared_preferences底层就是 localStorage,调await prefs.setInt('bestScore', score)就能持久化,不需要后端。
三是给游戏加“AI 自动演示”模式。理论上可以用搜索算法(比如期望极小化算法)让 AI 自动玩 2048,用来生成教学视频或者单纯的观赏效果。这一块需要单独写一个 AI 决策模块,和游戏逻辑完全解耦。
四是界面主题切换。经典 2048 是深色的,但很多人喜欢浅色主题。把配色方案抽成一个ThemeExtension,切换主题就是改一个类名的事。这几个扩展做完,2048 这个小项目基本上就变成一套完整的 Flutter 学习案例了。
最后再分享一个小技巧:如果你想让 Trae 帮你扩展功能,不要只发一句话“加个主题切换”,而是先告诉它现有的文件结构、关键类名和数据流,再提出需求。AI 对上下文的依赖远比你想象中高,给足上下文,它生成的代码才能跟现有架构无缝衔接。
我个人在实际操作中的体会是:用 Trae 开发小程序,最耗时间的往往不是写代码本身,而是“把需求说清楚”以及“验证生成结果的正确性”。尤其像 2048 这种有明确规则的逻辑,单元测试就是最好的校验工具。AI 可以帮你快速产出初版代码,但能不能稳定运行、边界情况处不处理得好,还是得靠你把关。想用 Trae 提升效率,核心是养成写测试、跑测试、再让 AI 修问题的习惯,这套流程跑顺了,后面开发什么都能快人一步。