十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

用Trae+Flutter Web从零实现2048游戏:完整实践与算法解析

用Trae+Flutter Web从零实现2048游戏:完整实践与算法解析 说实话2048 这个游戏我前后写过好几个版本从 Python 命令行版到 React 版都有但这次用 Trae 直接写 Flutter Web 版倒是第一次。整个项目从安装 Flutter SDK、在 Trae 里建工程、写出全部核心逻辑、到浏览器里能跑通花了一个下午其中真正手敲的代码没几行多数时间在看 AI 生成的代码逻辑对不对、编译报错怎么改。这篇文章把完整过程拆给你看Trae 到底怎么配合人写代码、Flutter Web 工程从空目录到能玩需要经过哪些环节、2048 的移动合并算法有哪几种思路最后附一份可以直接运行的完整 main.dart。适合两类人一类是刚接触 Flutter Web想找一个难度适中、又能完整跑通的项目练手另一类是正在用 AI 编程工具但不知道如何把它用到正经项目里。1. 项目思路为什么是 Trae、Flutter 和 20481.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 管理的 Flutterfvm 的用法是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 --platformsweb game_2048 cd game_2048用--platformsweb是为了只生成 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 矩阵。我用ListListint表示0 代表空格其他整数代表方块值。这个结构直观合并算法好写。Game2048类里再放一个score字段和几个方法。class Game2048 { static const int size 4; final Random _random Random(); late ListListint board; (int, int)? lastSpawned; int score 0; Game2048() { reset(); } void reset() { board List.generate(size, (_) Listint.filled(size, 0)); score 0; lastSpawned null; } }有人会把棋盘拍平成Listint长度 16省一点内存但每一步都要手动算行列索引得不偿失。这里有一个设计点要提前想清楚谁负责每次移动后是否生成新方块。我的方案是 move 方法里先算出移动后的新棋盘跟旧棋盘做一次相等判断只有发生变化才替换棋盘并生成新方块。否则玩家按一个移动不了的方向棋盘却自动多了一个方块体验直接崩掉。3.2 合并算法的核心先压缩再配对2048 的移动逻辑第一眼看起来是从移动方向开始一格一格往前推这种想法很容易写出几十行的循环和大量临时变量。我在实际写的时候一般用三步走把一行里所有非零数字挑出来去掉中间的空位从左到右扫描相邻两个相等就合并成一个两倍的数同时把得分加上末尾补零把长度补回 4。Listint _mergeLine(Listint line) { final nums line.where((v) v ! 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); 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) { ListListint 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; } ListListint _transpose(ListListint 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取代了老的RawKeyboardListenerKeyEventResult _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曲线用easeOutBackif (value ! 0 isNew) { content TweenAnimationBuilderdouble( 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#776E654#EDE0C8#776E658#F2B179#F9F6F216#F59563#F9F6F232#F67C5F#F9F6F264#F65E3B#F9F6F2128#EDCF72#F9F6F2256#EDCC61#F9F6F2512#EDC850#F9F6F21024#EDC53F#F9F6F22048 及以上#EDC22E#F9F6F2这个配色是 2048 原版的经典方案直接抄就行视觉上稳。字号我用的函数是小于 128 用 36128 到 1024 用 281024 及以上用 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 --platformsweb .补全平台目录就能解决。第二白屏但控制台没有明显报错。常见原因是构建产物部署到了子目录导致 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 ListListint board; (int, int)? lastSpawned; int score 0; Game2048() { reset(); } void reset() { board List.generate(size, (_) Listint.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) { ListListint 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; } Listint _mergeLine(Listint line) { final nums line.where((v) v ! 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); score nums[i] * 2; i 2; } else { result.add(nums[i]); i; } } while (result.length size) { result.add(0); } return result; } ListListint _transpose(ListListint matrix) { return List.generate(size, (i) List.generate(size, (j) matrix[j][i])); } bool _same(ListListint a, ListListint 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 StateGame2048Page createState() _Game2048PageState(); } class _Game2048PageState extends StateGame2048Page { final Game2048 _game Game2048(); final FocusNode _focusNode FocusNode(); late ListListint _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(); } Futurevoid _loadBestScore() async { final prefs await SharedPreferences.getInstance(); if (!mounted) return; setState(() { _bestScore prefs.getInt(best_score) ?? 0; }); } Futurevoid _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() { showDialogvoid( 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 TweenAnimationBuilderdouble( 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]——确认无误才放心。工具再顺手测试和审查这一关永远得自己把住。
返回列表