说实话,2048 这个游戏我前后写过好几个版本,从 Python 命令行版到 React 版都有,但这次用 Trae 直接写 Flutter Web 版倒是第一次。整个项目从安装 Flutter SDK、在 Trae 里建工程、写出全部核心逻辑、到浏览器里能跑通,花了一个下午,其中真正手敲的代码没几行,多数时间在看 AI 生成的代码逻辑对不对、编译报错怎么改。这篇文章把完整过程拆给你看:Trae 到底怎么配合人写代码、Flutter Web 工程从空目录到能玩需要经过哪些环节、2048 的移动合并算法有哪几种思路,最后附一份可以直接运行的完整 main.dart。适合两类人:一类是刚接触 Flutter Web,想找一个难度适中、又能完整跑通的项目练手;另一类是正在用 AI 编程工具,但不知道如何把它用到正经项目里。
1. 项目思路:为什么是 Trae、Flutter 和 2048
1.1 拿 2048 来验证 AI 编程再合适不过
我见过不少朋友第一次用 AI 写代码,上来就搞"一个完整的社交平台"或者"AI 聊天机器人",结果大部分时间在处理需求反复和接口对接,最后得出"AI 写代码不靠谱"的结论。这不公平,问题不在 AI,而在于需求本身太宽泛。2048 这类规则明确的小游戏,边界极其清晰:4x4 棋盘、方向滑动、相同数字合并、随机生成新方块、满了不能合并就结束。每一个规则都能用一条布尔判断或者一段短函数描述清楚,AI 对这种强约束问题的完成度非常高。
换句话说,2048 是测试 AI 编程工具下限的最佳样本:如果连这种逻辑明确的项目都写不明白,那工具确实不适合用来做开发;反之,说明它已经可以进生产流程辅助你干活。我当时和 Trae 说"写一个 2048 游戏",它第一版就给出了基本可运行的代码,后面我只是做了算法重构和交互补全,整体体验比我预期的要顺。
1.2 Trae 在项目里承担的角色
Trae 是一款基于 VSCode 生态的 AI IDE,对中文的支持很友好,也免费,国内直接就能用。它的基本用法分两种模式:Chat 模式和 Build 模式。Chat 模式就是你问它答,适合查资料、解释代码、讨论方案;Build 模式更猛,它可以直接帮你新建文件、改代码,像一个坐在旁边帮你动手的实习生。这次我基本是 Chat 模式聊方案,Build 模式落地代码,遇到编译报错直接把报错信息贴回去让它修,整个流程很顺。
有一个使用心得值得单独说:用 AI 编程,关键不是让它“自动写完全部”,而是让它“按你的约束落地”。我一开始让 Trae 自由发挥,它给了一版带状态管理库、带抽象类、还分离了十几个文件的代码,对一个小游戏来说纯属过度设计。后来我在提示词里明确写了"使用 setState、单文件、无第三方依赖",它生成的代码立刻务实很多。Trae 的能力上限不低,但你需要学会给它划边界。
1.3 技术选型背后的三个理由
做"Web 小游戏"这个需求,最当然的选择是纯前端三件套,或者是 React + Canvas。但 Flutter Web 有它特有的价值,我选它有三个理由:
第一,一套代码多端复用。Flutter 可以编译到 Web、Android、iOS、Windows、macOS、Linux,这意味着我花一个下午写完的逻辑,将来想要一个手机 App 版本,几乎不用重写业务代码,只需要调 UI。第二,Dart 是强类型语言,编译期就能抓住一半以上的低级错误。这对 AI 生成代码特别友好——类型写错了编译器立刻报错,把报错贴回去就能修,而不是运行半天才发现问题。第三,Flutter 的动画和布局系统足够成熟,做 2048 这种交互游戏性能完全够用,不需要自己造轮子。
2. 环境准备:Flutter SDK 与 Trae 初始化
2.1 Flutter 安装与 Web 支持开启
先从 Flutter 官网下载 stable 版 SDK。这里单拎一句:下载后的解压路径不要放在带中文和空格的目录下,Windows 下尤其严重,有时会导致后续编译和调试报一些莫名其妙的路径错误。解压后把 flutter/bin 加到 PATH,然后打开终端执行flutter doctor,它会自动补全 Dart SDK、检查本机有没有 Chrome 和 Android 工具链。这一步首次执行会跑几分钟,耐心等就行。
接着执行flutter config --enable-web开启 Web 支持。新版本 Flutter 默认就带 Web,但保险起见跑一下也无妨。我本机是用 FVM 管理的 Flutter,fvm 的用法是fvm install 3.19.0,再到项目根目录执行fvm use 3.19.0,之后项目内的 flutter 命令都由 fvm 转发。fvm 的核心价值在于一台机器可以装多个 Flutter 版本,团队协作时不会出现 A 用 3.16、B 用 3.22 导致行为不一致。单纯学习的话没必要装 fvm,直接一个 stable 版就够。
注意:Flutter 3.x 之后 Dart 版本跟着大版本走,语法可能有差异。本教程代码基于 Flutter 3.19 和 Dart 3.3 编写,向下兼容一般没问题,但版本明显太老的话建议先升级。
2.2 用 Trae 初始化工程
安装好 Flutter 后,打开 Trae,新建一个目录并打开。我是在 Trae 内置终端里直接执行:
flutter create --platforms=web game_2048 cd game_2048用--platforms=web是为了只生成 Web 平台对应的工程文件,少一堆用不到的 android、ios 目录。创建完成后,Trae 会提示是否信任项目,选信任,AI 的编辑权限才能落到文件上。接着我在 Build 模式输入需求描述,让它重写 main.dart。
这里提示词里我带了几个硬约束:"使用 Material 3,使用 setState 管理状态,不需要第三方依赖,界面包含分数、重新开始按钮,键盘和手势都支持"。你给的约束越具体,AI 生成的代码越贴近可用状态,这是使用这类工具最核心的经验,没有之一。
2.3 看懂 Flutter Web 的工程结构
Flutter Web 工程骨架很固定:lib/ 放 Dart 代码,pubspec.yaml 放依赖和元信息,web/ 下面是浏览器入口。默认lib/main.dart是一个计数器 demo,我们后面直接替换成 2048 游戏的代码。
web/index.html可以修改页面标题、背景色、加载动画;web/manifest.json是 PWA 的清单,决定浏览器能不能把应用"安装"到桌面。理解这些文件的位置不是为了写它们,而是为了后续 AI 改代码时,你知道它在改哪里,出了问题自己可以去确认。哪怕 AI 工具再强,最终负责审查和交付的仍然是人。
3. 核心玩法逻辑:数据模型、合并算法与胜负判断
3.1 棋盘数据结构怎么设计
2048 的棋盘本质就是一个 4x4 矩阵。我用List>表示,0 代表空格,其他整数代表方块值。这个结构直观,合并算法好写。Game2048类里再放一个score字段和几个方法。
class Game2048 { static const int size = 4; final Random _random = Random(); late List> board; (int, int)? lastSpawned; int score = 0; Game2048() { reset(); } void reset() { board = List.generate(size, (_) => List.filled(size, 0)); score = 0; lastSpawned = null; } }有人会把棋盘拍平成List,长度 16,省一点内存,但每一步都要手动算行列索引,得不偿失。这里有一个设计点要提前想清楚:谁负责"每次移动后是否生成新方块"。我的方案是 move 方法里先算出移动后的新棋盘,跟旧棋盘做一次相等判断,只有发生变化才替换棋盘并生成新方块。否则玩家按一个移动不了的方向,棋盘却自动多了一个方块,体验直接崩掉。
3.2 合并算法的核心:先压缩再配对
2048 的移动逻辑,第一眼看起来是"从移动方向开始,一格一格往前推",这种想法很容易写出几十行的循环和大量临时变量。我在实际写的时候一般用三步走:
- 把一行里所有非零数字挑出来,去掉中间的空位;
- 从左到右扫描,相邻两个相等就合并成一个两倍的数,同时把得分加上;
- 末尾补零,把长度补回 4。
List _mergeLine(List line) { final nums = line.where((v) => v != 0).toList(); final result = []; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); score += nums[i] * 2; i += 2; } else { result.add(nums[i]); i++; } } while (result.length < size) { result.add(0); } return result; }这个方式的巧妙点在于:先把问题"压缩"成去零后的序列,再去配对,完全不用考虑移动造成的位置偏移。比如一行[2, 2, 0, 4],左移后应该是[4, 4, 0, 0],先得到[2, 2, 4],再合并第一对得到[4, 4],补零完成。
这里要特别注意原版 2048 的隐含规则:同一次移动中,一个方块不能参与两次合并。比如一行四个 4 左移,正确结果是[8, 8, 0, 0]而不是[16, 0, 0, 0]。扫描时遇到一对相等就合并并跳过两个位置,上面这段代码天然实现了这个规则,不需要额外判断。
3.3 四方向移动的统一套路:转置与反转
有了单行合并,四个方向就可以统一处理。左移最直接;右移就是把每行反转后合并,再反转回来;上移本质是"每一列向上合并",可以先把矩阵转置成行,做行合并后再转置回来;下移就是先转置再反转,合并后再反转回来。
bool move(String direction) { List> next; switch (direction) { case 'left': next = List.generate(size, (i) => _mergeLine(board[i])); break; case 'right': next = List.generate(size, (i) => _mergeLine(board[i].reversed.toList()).reversed.toList()); break; case 'up': next = _transpose(List.generate( size, (i) => _mergeLine(_transpose(board)[i]))); break; case 'down': next = _transpose(List.generate( size, (i) => _mergeLine(_transpose(board)[i].reversed.toList()) .reversed .toList())); break; default: return false; } if (_same(board, next)) { return false; } board = next; addRandomTile(); return true; } List> _transpose(List> matrix) { return List.generate(size, (i) => List.generate(size, (j) => matrix[j][i])); }这套"转置 + 反转"的技巧不是 2048 特有的,做矩阵变换的题目基本都能用上。我在教朋友写这个游戏的时候,多数人卡在了方向处理上,一旦理解了"转置让列变行",代码瞬间少了一半。第一次让 Trae 生成时,它给了个用四套独立循环分别处理四个方向的版本,逻辑没错但重复代码很多,我把思路描述给它之后,它改成了一版复用_mergeLine的方案,这就是前面说的"你能描述思路,AI 就能顺着你的方向优化"。
3.4 随机生成、游戏结束与最高分存档
每次合法移动后,在空位随机生成一个数字,90% 概率是 2、10% 概率是 4。简单实现是收集所有空位索引,然后用 Random 挑一个:
void addRandomTile() { final empties = <(int, int)>[]; for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (board[i][j] == 0) { empties.add((i, j)); } } } if (empties.isEmpty) return; final pos = empties[_random.nextInt(empties.length)]; board[pos.$1][pos.$2] = _random.nextInt(10) == 0 ? 4 : 2; lastSpawned = pos; }游戏结束判断是"没有空格且没有任何相邻格子相等"。注意要同时检查水平和垂直两个方向,只查一个方向会漏判:
bool isGameOver() { for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (board[i][j] == 0) return false; if (i < size - 1 && board[i][j] == board[i + 1][j]) return false; if (j < size - 1 && board[i][j] == board[i][j + 1]) return false; } } return true; }胜利判断我做成"出现 2048 弹窗提示,但允许继续玩",这样追求高分的人不会被强制结束。最高分用 shared_preferences 存到 localStorage,每次分数变化后比较一下,超过就保存。这个功能在 Web 端没什么难度,但它演示了 Flutter 插件的抽象层——同样的代码移植到移动端会自动落到本地存储,逻辑不用改。
4. 界面渲染与操作交互
4.1 用 Column/Row 搭棋盘而不是 GridView
很多人第一反应是用GridView.count来画棋盘,但 2048 的格子数量固定是 16 个,用 GridView 有点杀鸡用牛刀。我更推荐 Column + Row 的方式,固定大小、无滚动、无复用逻辑,代码可读性更高,而且渲染逻辑和棋盘结构一一对应,调试时一眼能看出问题在哪。
Widget _buildBoard() { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisSize: MainAxisSize.min, children: List.generate( 4, (i) => Row( children: List.generate(4, (j) => _buildTile(i, j)), ), ), ), ); }每个格子是一个AspectRatio包着 Container,间距用 Padding 控制。棋盘外层的背景色用#BBADA0,这是 2048 原版的棋盘底色,拿来即用。格子的底色和文字颜色根据数字大小做映射,具体配色表我放在第 5 节。
4.2 键盘和手势双通道输入
Web 端面对两类用户:电脑上按键盘的,和触屏上滑动的。Flutter 里两者都很好实现。键盘我用Focus加onKeyEvent处理,这是 Flutter 3.x 推荐的 API,取代了老的RawKeyboardListener:
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _handleMove('left'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: _handleMove('right'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: _handleMove('up'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: _handleMove('down'); return KeyEventResult.handled; } } return KeyEventResult.ignored; }手势那边用GestureDetector的onPanEnd,比较速度向量在横纵方向上的绝对值:
void _handlePanEnd(DragEndDetails details) { final v = details.velocity.pixelsPerSecond; if (v.dx.abs() > v.dy.abs()) { _handleMove(v.dx > 0 ? 'right' : 'left'); } else { _handleMove(v.dy > 0 ? 'down' : 'up'); } }这里用速度而不是位移判断,是因为扫动结束时速度更能反映用户意图,位移则容易误判。有个小坑值得提醒:如果界面上有按钮,点击按钮后焦点可能会从棋盘上移走,导致键盘失效。我的处理是给棋盘包一个FocusNode,在按钮的 onPressed 里重新requestFocus(),保证玩着玩着键盘突然没反应的情况不再出现。
4.3 分数、结束遮罩与重开按钮
界面结构从上到下是:标题和分数卡片、棋盘、操作提示和重开按钮。分数卡片用两个小容器并排显示"分数"和"最高分",数字大一些,游戏过程中余光就能看到。游戏结束不用弹窗打断,直接在棋盘上用 Stack 盖一层半透明黑色遮罩,中间写"游戏结束"和一个"再来一局"按钮,这样比 AlertDialog 更有沉浸感,也比跳页面轻量。重开按钮做了两处:遮罩里一个,棋盘上方一个,玩家随时可以主动重开。
重开逻辑就是把Game2048的棋盘归零、分数归零,再重新生成两个初始方块。注意每次都要先reset()再addRandomTile()两次,否则棋盘状态会残留上一次对局的痕迹。
5. 动画与视觉细节
5.1 新方块的弹入动效
完整复刻原版 2048 那种滑动动画,需要把数据模型从"格子存值"改成"维护多个活动方块的位置 + 动画控制器",代码量至少翻一倍,这个小项目里我先不做。但"新落下的方块要有个弹入效果"这个体验点成本很低,值得加。
实现思路:Game2048里的addRandomTile已经记录了lastSpawned这个最近一次生成的位置,渲染格子时如果这个位置恰好是 lastSpawned,就包一层TweenAnimationBuilder,从 scale 0.2 弹到 1,时长 160ms,曲线用easeOutBack:
if (value != 0 && isNew) { content = TweenAnimationBuilder( tween: Tween(begin: 0.2, end: 1.0), duration: const Duration(milliseconds: 160), curve: Curves.easeOutBack, builder: (context, scale, child) { return Transform.scale(scale: scale, child: child); }, child: content, ); }每次移动后,新方块会有一个放大弹入的效果,老方块位置不变,视觉上既有反馈又不至于花哨。easeOutBack会带一点回弹,恰好符合 2048 的"落了颗新棋子"的质感。
5.2 数字颜色和字号的调整
数字的可读性比花哨的字体重要得多。2 和 4 用深色文字,因为底色调浅;8 以上用浅色文字,因为背景已经偏深。字号要按位数调整:一位数和两位数用大字号,三位数开始降,否则 1024、2048 这类数字会把格子撑爆。
| 数值 | 背景色 | 文字色 |
|---|---|---|
| 0 | #CDC1B4 | - |
| 2 | #EEE4DA | #776E65 |
| 4 | #EDE0C8 | #776E65 |
| 8 | #F2B179 | #F9F6F2 |
| 16 | #F59563 | #F9F6F2 |
| 32 | #F67C5F | #F9F6F2 |
| 64 | #F65E3B | #F9F6F2 |
| 128 | #EDCF72 | #F9F6F2 |
| 256 | #EDCC61 | #F9F6F2 |
| 512 | #EDC850 | #F9F6F2 |
| 1024 | #EDC53F | #F9F6F2 |
| 2048 及以上 | #EDC22E | #F9F6F2 |
这个配色是 2048 原版的经典方案,直接抄就行,视觉上稳。字号我用的函数是:小于 128 用 36,128 到 1024 用 28,1024 及以上用 22。数字别碰到格子边缘,这个不用太精确,留出边距就行。
5.3 动画取舍:哪些暂时不做
我特意把"完整滑动动画"排除在本次范围之外,理由很现实:一次完整的滑动动画需要把数据模型从"格子存值"改成"维护多个活动方块的位置 + 动画控制器",涉及的自定义 Widget、动画同步、碰撞检测逻辑都不少,对于从 0 到 1 的 Web 项目来说性价比不高。
我的建议是先用隐式动画把反馈做出来,确认核心逻辑和手感都没问题,再考虑要不要上重动画。如果你后面想加,方向也明确:给每个方块一个AnimationController,在棋盘外部维护动画中间的插值位置,逻辑层只负责告诉渲染层"谁从哪移动到哪",而不是继续用 setState 直接重绘整个棋盘。
6. 构建部署与常见问题
6.1 从 flutter build web 到静态托管
开发阶段用flutter run -d chrome热调试。发布时执行:
flutter build web --release产物在build/web目录里,是一堆静态文件:index.html、main.dart.js、assets、flutter_bootstrap.js 等等。部署就是把 build/web 目录丢到任何静态文件服务器上。
本地快速预览可以直接起一个静态服务:
cd build/web python3 -m http.server 8080用 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; } }注意 Flutter Web 默认是 hash 路由,所以刷新页面不会出现 404。如果你后面用了浏览器 API 风格的路由,就需要把未知路径统一回退到 index.html,上面的 try_files 已经做了这件事。
6.2 浏览器端几个高频报错
第一,"This application is not configured to build on the web"。这个是工程创建时没加 Web 平台,执行flutter create --platforms=web .补全平台目录就能解决。
第二,白屏但控制台没有明显报错。常见原因是构建产物部署到了子目录,导致 index.html 里的资源路径引用不对。Flutter 3.x 之后的构建产物默认用相对路径,但如果你自己改过base href,就要留意。先在控制台看 main.dart.js 是不是 404,这是最快的定位方式。
第三,"Service worker registration failed" 的报错。Flutter Web 默认会把应用注册成 PWA,在部分浏览器隐私模式或某些网络环境下,service worker 注册会失败。这个报错一般不影响本地开发,如果不想启用 PWA,可以去掉 web/index.html 里的 service worker 注册初始化逻辑。
第四,CanvasKit 初始化慢。Flutter Web 默认用 CanvasKit 渲染,首次加载需要下载不小的 wasm 文件,弱网下白屏时间会比较长。不同版本的渲染参数有调整,建议以你当前版本执行flutter build web --help的实际输出为准,选择合适的渲染方式。
6.3 用 Trae 高效调试的四个姿势
用 Trae 写完整个项目后,我总结出四条能明显提高效率的做法:
第一,复杂任务拆成小步骤。别一句话要求"实现完整 2048 加动画加存档",而是按"实现数据模型、实现移动逻辑、实现界面、加动画"分四步,每步都得到可运行的结果,出 bug 时定位范围也小。
第二,把编译器报错原封不动贴回对话。Trae 对报错信息的理解能力很强,你贴flutter analyze或者编译器的完整输出,它基本能给出针对性修复代码。
第三,明确告诉它"不要过度设计"。AI 默认会把代码写得很抽象,动不动就引入状态管理库、设计模式、抽象类,对小 demo 来说全是负担。我第一版就限制"使用 setState、单文件、无第三方依赖",生成的代码立刻务实很多。
第四,生成完代码跑一遍flutter analyze。Trae 的代码绝大多数时候能直接编译,但偶尔会有未使用的变量、过时 API 之类的警告,用 analyze 扫一遍心里有底,也方便趁早处理。
7. 附:完整可运行的 main.dart
下面是一份完整可直接运行的lib/main.dart。使用前先添加依赖:
flutter pub add shared_preferences然后把lib/main.dart的内容整体替换成下面这份代码。整体逻辑、界面和交互都在里面,配合前面的讲解应该能看懂每一段在干什么。
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.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( useMaterial3: true, scaffoldBackgroundColor: const Color(0xFFFAF8EF), colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFBBADA0)), ), home: const Game2048Page(), ); } } class Game2048 { static const int size = 4; final Random _random = Random(); late List> board; (int, int)? lastSpawned; int score = 0; Game2048() { reset(); } void reset() { board = List.generate(size, (_) => List.filled(size, 0)); score = 0; lastSpawned = null; } void addRandomTile() { final empties = <(int, int)>[]; for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (board[i][j] == 0) { empties.add((i, j)); } } } if (empties.isEmpty) return; final pos = empties[_random.nextInt(empties.length)]; board[pos.$1][pos.$2] = _random.nextInt(10) == 0 ? 4 : 2; lastSpawned = pos; } bool move(String direction) { List> next; switch (direction) { case 'left': next = List.generate(size, (i) => _mergeLine(board[i])); break; case 'right': next = List.generate(size, (i) => _mergeLine(board[i].reversed.toList()).reversed.toList()); break; case 'up': next = _transpose(List.generate( size, (i) => _mergeLine(_transpose(board)[i]))); break; case 'down': next = _transpose(List.generate( size, (i) => _mergeLine(_transpose(board)[i].reversed.toList()) .reversed .toList())); break; default: return false; } if (_same(board, next)) { return false; } board = next; addRandomTile(); return true; } List _mergeLine(List line) { final nums = line.where((v) => v != 0).toList(); final result = []; var i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { result.add(nums[i] * 2); score += nums[i] * 2; i += 2; } else { result.add(nums[i]); i++; } } while (result.length < size) { result.add(0); } return result; } List> _transpose(List> matrix) { return List.generate(size, (i) => List.generate(size, (j) => matrix[j][i])); } bool _same(List> a, List> b) { for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (a[i][j] != b[i][j]) { return false; } } } return true; } bool isGameOver() { for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (board[i][j] == 0) return false; if (i < size - 1 && board[i][j] == board[i + 1][j]) return false; if (j < size - 1 && board[i][j] == board[i][j + 1]) return false; } } return true; } bool isMaxReached() { for (var i = 0; i < size; i++) { for (var j = 0; j < size; j++) { if (board[i][j] >= 2048) return true; } } return false; } } class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State createState() => _Game2048PageState(); } class _Game2048PageState extends State { final Game2048 _game = Game2048(); final FocusNode _focusNode = FocusNode(); late List> _board; int _score = 0; int _bestScore = 0; bool _gameOver = false; bool _hasWon = false; @override void initState() { super.initState(); _game.addRandomTile(); _game.addRandomTile(); _board = _game.board; _loadBestScore(); } Future _loadBestScore() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) return; setState(() { _bestScore = prefs.getInt('best_score') ?? 0; }); } Future _saveBestScore() async { final prefs = await SharedPreferences.getInstance(); if (_score > _bestScore) { _bestScore = _score; await prefs.setInt('best_score', _bestScore); } } void _handleMove(String direction) { if (_gameOver) return; final moved = _game.move(direction); if (!moved) return; setState(() { _board = _game.board; _score = _game.score; if (_score > _bestScore) { _saveBestScore(); } if (_game.isMaxReached() && !_hasWon) { _hasWon = true; WidgetsBinding.instance.addPostFrameCallback((_) { _showWinDialog(); }); } _gameOver = _game.isGameOver(); }); } void _resetGame() { setState(() { _game.reset(); _game.addRandomTile(); _game.addRandomTile(); _board = _game.board; _score = 0; _gameOver = false; _hasWon = false; }); _focusNode.requestFocus(); } void _showWinDialog() { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('你合成了 2048!'), content: const Text('可以继续挑战更高分,也可以重新开始。'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('继续'), ), TextButton( onPressed: () { Navigator.of(ctx).pop(); _resetGame(); }, child: const Text('重新开始'), ), ], ), ); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _handleMove('left'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: _handleMove('right'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: _handleMove('up'); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: _handleMove('down'); return KeyEventResult.handled; } } return KeyEventResult.ignored; } void _handlePanEnd(DragEndDetails details) { final v = details.velocity.pixelsPerSecond; if (v.dx.abs() > v.dy.abs()) { _handleMove(v.dx > 0 ? 'right' : 'left'); } else { _handleMove(v.dy > 0 ? 'down' : 'up'); } } Color _colorFor(int value) { 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); default: return const Color(0xFFEDC22E); } } double _fontSizeFor(int value) { if (value >= 1024) return 22; if (value >= 128) return 28; return 36; } Widget _buildTile(int row, int col) { final value = _board[row][col]; final bg = _colorFor(value); final fg = value <= 4 ? const Color(0xFF776E65) : Colors.white; final fontSize = _fontSizeFor(value); final isNew = _game.lastSpawned != null && _game.lastSpawned!.$1 == row && _game.lastSpawned!.$2 == col; Widget content = Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(6), ), alignment: Alignment.center, child: value == 0 ? null : Text( '$value', style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, color: fg, ), ), ); if (value != 0 && isNew) { content = TweenAnimationBuilder( tween: Tween(begin: 0.2, end: 1.0), duration: const Duration(milliseconds: 160), curve: Curves.easeOutBack, builder: (context, scale, child) { return Transform.scale(scale: scale, child: child); }, child: content, ); } return AspectRatio(aspectRatio: 1, child: content); } Widget _buildBoard() { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisSize: MainAxisSize.min, children: List.generate( 4, (i) => Row( children: List.generate(4, (j) => _buildTile(i, j)), ), ), ), ); } Widget _buildScoreCard(String label, int value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: const TextStyle(fontSize: 12, color: Color(0xFFEEE4DA)), ), Text( '$value', style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } Widget _buildGameOverOverlay() { return Container( decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( '游戏结束', style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(height: 12), ElevatedButton( onPressed: _resetGame, child: const Text('再来一局'), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), _buildScoreCard('分数', _score), const SizedBox(width: 8), _buildScoreCard('最高分', _bestScore), ], ), const SizedBox(height: 16), Focus( focusNode: _focusNode, autofocus: true, onKeyEvent: _handleKeyEvent, child: GestureDetector( onPanEnd: _handlePanEnd, child: Stack( children: [ _buildBoard(), if (_gameOver) Positioned.fill( child: _buildGameOverOverlay(), ), ], ), ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '方向键 / 滑动操作', style: TextStyle(color: Colors.grey.shade600), ), OutlinedButton( onPressed: _resetGame, child: const Text('重新开始'), ), ], ), ], ), ), ), ), ), ); } }最后再分享一个我实际跑下来的体会:写这个小游戏时,Trae 帮我省下的主要是搭建骨架和调试报错的时间,但真正决定项目成色的,还是合并规则、随机生成、游戏结束判定这些"一不留神就写错"的细节。AI 把代码铺好之后,我用固定布局反复验证了几组关键用例——比如[2,2,4,4]左移必须是[4,8,0,0]、四个 4 左移必须是[8,8,0,0]——确认无误才放心。工具再顺手,测试和审查这一关,永远得自己把住。