ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用Trae从0到1开发Flutter Web版2048游戏

用Trae从0到1开发Flutter Web版2048游戏 前阵子我一直想找个项目把 Trae 的完整工作流摸熟。对比了几个方向之后我选了用 Trae 从 0 到 1 开发一个 Flutter Web 小游戏 2048。这个项目体量不大但状态管理、手势交互、Web 构建、动画反馈这些东西全都涉及非常适合练手。这篇文章既是我这次开发的完整复盘也会把最终整理好的可运行代码直接贴出来。想跟着做的人只要本地有 Flutter SDK就能把下面的代码跑起来。如果没有基础也不慌我会先把 2048 本身的数据结构讲清楚再讲怎么用 Trae 把代码一步步生成出来最后再说 Web 端那些特别容易踩的坑。整个过程我尽量按真实开发的顺序来写而不是事后整理出的“标准答案”。1. 为什么做这个项目2048 用来练手比想象中划算1.1 一个小游戏里藏着的完整前端基本功很多人练 Flutter 都是从待办清单、计数器开始但那样的项目只覆盖了 UI 组件和 setState连键盘事件和页面生命周期都碰不到。2048 不一样它麻雀虽小五脏俱全。玩过 2048 的都知道规则4×4 的棋盘每次滑动所有方块朝同一个方向移动相同数字碰撞后会合并成它们的和每次有效移动后棋盘会随机生成一个新方块目标是把数字堆到 2048。就这么几条规则落到代码层面你要处理的东西包括二维数组的状态表达和更新四个方向移动时“合并”算法的统一抽象随机位置生成新方块游戏结束和胜利条件判断键盘方向键监听触屏滑动的手势识别和阈值控制数字方块的颜色映射、尺寸适配重新开始时的状态重置Flutter Web 的构建产物和部署。这些知识点分布在状态管理、事件处理、UI 布局、工程构建四个层面。做一遍 2048基本能把 Flutter 从界面到逻辑到打包的路线走通一遍比看十篇教程都管用。1.2 Trae 在项目里扮演的角色这次我特意全程用 Trae 来写。Trae 这类 AI 编程工具很多人拿来问“这个报错怎么解决”“帮我写个排序函数”但真让它从空目录开始搭一个完整的小项目时很多人反而不知道该怎么指挥。我自己总结出的用法是把 Trae 当成一个“能马上执行代码变更的结对程序员”而不是搜索引擎。你需要把需求拆成清晰的指令让它生成初版代码然后你自己审查、运行、带着报错回来追问。这个过程里最重要的是反复对话而不是一次性让它给一个“完美文件”。2048 的代码量不算大但逻辑分支很多。直接说“帮我写一个 2048 游戏”Trae 大概率会返回一个能看的 Demo但未必能兼顾 Web 端的键盘事件、手势判断和状态重置。所以我在动手前先把数据建模想清楚了再去写提示词。2. 动手前的数据建模棋盘、移动、合并、判定2.1 棋盘用二维数组还是扁平列表2048 棋盘是 4×4最直觉的存储方式是ListListint也就是二维数组。外层四个元素代表四行内层四个元素代表每行的四个格子值为 0 表示空格值为 2、4、8 等表示数字方块。有人可能会想既然移动和合并只发生在行或列用一个长度为 16 的扁平列表下标从 0 到 15行操作和列操作用索引换算岂不是更省内存省那几字节对前端项目毫无意义。二维数组最大的好处是代码可读性高。board[1][2]一眼就知道是第 1 行第 2 列的内容后续写合并算法、随机生成新方块、判断游戏结束都更不容易出错。在 AI 辅助编程的场景下可读性更加重要因为你要把代码贴给 Trae 让它做二次修改它读起来也更快。2.2 四个方向的移动怎么复用同一套逻辑2048 最核心的地方在于移动合并。向左滑动最简单对每一行从左到右去掉所有 0再把相邻的相同数字合并最后补 0。举个例子一行数据是[0, 2, 2, 4]去掉 0[2, 2, 4]从左开始合并2 和 2 相同合并成 4这一行变成[4, 4]补 0 到长度 4[4, 4, 0, 0]这个例子里要注意一个细节一行中每个方块在一次移动里最多参与一次合并。比如[2, 2, 2, 2]左移之后应该是[4, 4, 0, 0]而不是[8, 0, 0, 0]。很多人第一次写合并算法会忽略这一点导致分数和方块数值都异常。向右移动可以转换成“把行反转左移再反转回来”。比如[0, 2, 2, 4]向右移动先把行反转成[4, 2, 2, 0]左移之后再反转就得到了正确结果。向上和向下呢最省事的做法是转置棋盘。把棋盘的行列互换之后“向上”就变成了“向左”。向下同理转置后“向右”。这套抽象的意义在于你只需要写好一个_slideAndMerge(Listint row)的纯函数然后通过转置和反转的组合把四个方向全部映射到它身上。代码量小正确性也容易验证。2.3 新方块生成与终局判断每次有效移动后棋盘需要随机生成一个新方块。生成规则是先找到所有值为 0 的格子从中随机选一个90% 概率生成 210% 概率生成 4。终局判断分两个方向胜利棋盘中出现 2048失败棋盘满格且任意相邻格子上下左右都没有相同数字。失败判断很多人写错只检查满格是不够的。因为满格时如果还存在可以合并的相邻数字玩家仍能通过合并腾出空格游戏就不该结束。所以必须同时检查空位和相邻相等值。这些逻辑都不依赖 UI可以独立成一个GameBoard类。这个类只负责修改棋盘数据不关心界面怎么渲染后续测试和复用都很方便。3. 提示词才是关键用 Trae 生成第一版代码3.1 我用的初始提示词打开 Trae 新建一个 Flutter 项目之后我在 Chat 模式里给的第一条指令是这样的你是 Flutter 专家。请为我生成一个可直接运行的 Flutter Web 2048 游戏代码放在 /lib/main.dart。 要求 1. 只用一个 main.dart 文件不依赖第三方库 2. 4x4 棋盘使用 StatefulWidget setState 管理状态 3. 支持方向键和 WASD 控制同时支持触屏滑动和鼠标拖拽 4. 数字方块颜色区分2、4、8、16 等不同数字用不同背景色 5. 有得分面板游戏结束和达到 2048 时显示覆盖层提示 6. 有重新开始按钮 7. 生成方块逻辑90% 生成 210% 生成 4 8. 每次移动后判断是否结束失败条件包括满格且无相邻可合并项。这段提示词的重点不是“帮我写个 2048”而是把规则、文件路径、技术选型、UI 要求全部说清楚。Trae 生成的初版代码已经能跑起来但我又在 Chat 里追加了几轮修改包括“字体过大的问题”“重新开始时分数没有清零”“刚才这段键盘监听在 Web 上不生效”它都能针对性地改。我个人的感受是提示词写得越具体Trae 的产出越接近可用状态。尤其是“不依赖第三方库”和“用 setState”这两个约束能避免它自作主张引入 provider 或者 flappy 之类的东西。3.2 Build 模式和 Chat 模式怎么配合Trae 有 Build 模式和 Chat 模式两种用法。Chat 模式是纯对话会给建议和代码片段需要你自己手动应用。Build 模式会直接操作文件新建、覆盖、重构都能落地。我的习惯是刚起步时用 Chat先看它的方案和逻辑是否合理确认没问题再切到 Build 让代码落地。如果一开始就用 Build 模式它可能会一次性生成你并没有完全理解的代码出 bug 后反而不容易排查。到后期改动很小的功能比如“把游戏结束弹窗改成页面内覆盖层”“调整一下颜色”我直接切 Build 模式让它改效率很高。因为上下文明确改动范围可控它很少会碰坏别的地方。3.3 第一版代码的问题与修正Trae 生成的第一版大体能玩但存在两个明显问题。第一个问题是分数计算偏大。原因在于它写的合并逻辑里一行[2, 2, 2, 2]左移后直接变成了[8, 0, 0, 0]还加了 8 分。这在 2048 规则里是不允许的一次移动最多合并出两个新方块所以正确结果应该是[4, 4, 0, 0]得分 8 不变。严格说[2,2,2,2]得分是 448结果一样但它把两个方块合并成了一个 8棋盘状态错了。第二个问题是键盘监听用的是RawKeyboard这在新的 Flutter 版本里已经废弃而且 Web 端经常收不到事件必须换成HardwareKeyboard.instance.addHandler或者KeyboardListener。这两类问题都不难改但很典型。AI 生成代码拿过来直接跑细节上一定会有瑕疵你必须能看懂逻辑知道它哪里错才能让它继续改。这也是我一直坚持“先自己建模再交给 AI”的原因。4. 完整实现可直接拼成一个 main.dart 的核心代码4.1 棋盘与游戏状态下面是我最终整理出的核心代码。模型层用了独立的GameBoard类这样界面重建时不会把游戏状态搞乱。import dart:math; enum Direction { left, right, up, down } class GameBoard { static const int size 4; final Random _random Random(); ListListint grid; int score 0; bool won false; bool gameOver false; GameBoard() { grid List.generate(size, (_) List.filled(size, 0)); _spawnTile(); _spawnTile(); } void restart() { grid List.generate(size, (_) List.filled(size, 0)); score 0; won false; gameOver false; _spawnTile(); _spawnTile(); } bool move(Direction dir) { if (gameOver || won) return false; final before _copyGrid(grid); switch (dir) { case Direction.left: _moveLeft(); break; case Direction.right: _moveRight(); break; case Direction.up: _moveUp(); break; case Direction.down: _moveDown(); break; } if (_sameGrid(before, grid)) return false; _spawnTile(); if (_hasWon()) won true; if (_isGameOver()) gameOver true; return true; } Listint _slideAndMerge(Listint row) { final nums row.where((e) e ! 0).toList(); final merged int[]; for (var i 0; i nums.length; i) { if (i 1 nums.length nums[i] nums[i 1]) { merged.add(nums[i] * 2); score nums[i] * 2; i; } else { merged.add(nums[i]); } } while (merged.length size) { merged.add(0); } return merged; } void _moveLeft() { for (var r 0; r size; r) { grid[r] _slideAndMerge(grid[r]); } } void _moveRight() { for (var r 0; r size; r) { final reversed grid[r].reversed.toList(); grid[r] _slideAndMerge(reversed).reversed.toList(); } } ListListint _transposed() { return List.generate(size, (r) { return List.generate(size, (c) grid[c][r]); }); } void _storeTransposed(ListListint tGrid) { for (var r 0; r size; r) { for (var c 0; c size; c) { grid[c][r] tGrid[r][c]; } } } void _moveUp() { final tGrid _transposed(); for (var r 0; r size; r) { tGrid[r] _slideAndMerge(tGrid[r]); } _storeTransposed(tGrid); } void _moveDown() { final tGrid _transposed(); for (var r 0; r size; r) { final reversed tGrid[r].reversed.toList(); tGrid[r] _slideAndMerge(reversed).reversed.toList(); } _storeTransposed(tGrid); } void _spawnTile() { final empty Listint[]; for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 0) empty.add([r, c]); } } if (empty.isEmpty) return; final pos empty[_random.nextInt(empty.length)]; grid[pos[0]][pos[1]] _random.nextDouble() 0.9 ? 2 : 4; } bool _hasWon() { for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 2048) return true; } } return false; } bool _isGameOver() { for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 0) return false; if (r 1 size grid[r][c] grid[r 1][c]) return false; if (c 1 size grid[r][c] grid[r][c 1]) return false; } } return true; } ListListint _copyGrid(ListListint source) { return List.generate(size, (r) Listint.from(source[r])); } bool _sameGrid(ListListint a, ListListint b) { for (var r 0; r size; r) { for (var c 0; c size; c) { if (a[r][c] ! b[r][c]) return false; } } return true; } }这段代码要特别解释几个细节。_slideAndMerge里的循环用了i跳过一个已合并元素这是保证“一次移动只能合并一次”的关键。score在合并时直接累加而不是留到 UI 层算省掉了遍历计算分值的环节。移动方法move里先复制了棋盘再执行方向移动。如果移动前后一模一样说明这次滑动没有改变任何方块位置就不允许生成新方块。这一步很关键否则玩家往没有空位的方向乱滑时棋盘会不断生成新方块很快把自己卡死。4.2 页面交互键盘、鼠标拖拽、触屏接下来是 UI 层。我用HardwareKeyboard.instance.addHandler监听键盘因为它不依赖页面里某个焦点组件在 Flutter Web 上更稳。import package:flutter/material.dart; import package:flutter/services.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), ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); override StateGamePage createState() _GamePageState(); } class _GamePageState extends StateGamePage { final GameBoard _game GameBoard(); Offset? _dragStart; override void initState() { super.initState(); HardwareKeyboard.instance.addHandler(_handleKey); } override void dispose() { HardwareKeyboard.instance.removeHandler(_handleKey); super.dispose(); } bool _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return false; if (event is KeyRepeatEvent) return false; setState(() { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp: case LogicalKeyboardKey.keyW: _game.move(Direction.up); break; case LogicalKeyboardKey.arrowDown: case LogicalKeyboardKey.keyS: _game.move(Direction.down); break; case LogicalKeyboardKey.arrowLeft: case LogicalKeyboardKey.keyA: _game.move(Direction.left); break; case LogicalKeyboardKey.arrowRight: case LogicalKeyboardKey.keyD: _game.move(Direction.right); break; } }); return false; } void _handlePanStart(DragStartDetails details) { _dragStart details.localPosition; } void _handlePanEnd(DragEndDetails details) { final start _dragStart; if (start null) return; final delta details.localPosition - start; if (delta.distance 20) return; setState(() { if (delta.dx.abs() delta.dy.abs()) { _game.move(delta.dx 0 ? Direction.right : Direction.left); } else { _game.move(delta.dy 0 ? Direction.down : Direction.up); } }); _dragStart null; } 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: GestureDetector( onPanStart: _handlePanStart, onPanEnd: _handlePanEnd, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ const Text( 2048, style: TextStyle( fontSize: 40, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), const Spacer(), _ScoreBoard(score: _game.score), const SizedBox(width: 8), FilledButton( onPressed: () setState(_game.restart), child: const Text(重开), ), ], ), const SizedBox(height: 16), AspectRatio( aspectRatio: 1, child: _buildBoard(), ), const SizedBox(height: 12), const Text( 方向键 / WASD / 滑动棋盘控制, style: TextStyle(color: Colors.black54), ), ], ), ), ), ), ), ), ); }这段 UI 里我把整块棋盘包在GestureDetector里用onPanStart记录起点onPanEnd计算终点偏移再根据 x 和 y 的绝对值大小判断方向。这样一次手势只触发一次移动不会像onPanUpdate那样连续触发。HardwareKeyboard的 handler 里我判断了event is! KeyDownEvent和event is KeyRepeatEvent。这两行是为了屏蔽长按键盘时的重复触发避免玩家按住方向键不放导致棋盘快速乱动。4.3 界面渲染与颜色映射棋盘渲染我用了GridView.builder通过itemCount: 16固定生成 16 个格子。每个格子的颜色由数字决定数字越大颜色越深文字颜色则从深色过渡到白色。Widget _buildBoard() { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.builder( padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemCount: GameBoard.size * GameBoard.size, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: GameBoard.size, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemBuilder: (context, index) { final row index ~/ GameBoard.size; final col index % GameBoard.size; final value _game.grid[row][col]; return TweenAnimationBuilderdouble( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 80), builder: (context, t, child) { return Transform.scale( scale: 0.9 0.1 * t, child: Container( alignment: Alignment.center, decoration: BoxDecoration( color: _cellColor(value), borderRadius: BorderRadius.circular(6), ), child: Text( value 0 ? : $value, style: TextStyle( fontSize: _fontSize(value), fontWeight: FontWeight.bold, color: _textColor(value), ), ), ), ); }, ); }, ), ); } Color _cellColor(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); case 2048: return const Color(0xFFEDC22E); default: return const Color(0xFF3C3A32); } } Color _textColor(int value) { return (value 2 || value 4) ? const Color(0xFF776E65) : Colors.white; } double _fontSize(int value) { if (value 100) return 28; if (value 1000) return 22; return 16; } }这里我用TweenAnimationBuilder做了一次简单的新方块出现动画。每次setState改变棋盘数据后格子的 scale 都会从 0.9 弹到 1.0视觉上能感到新方块“冒”出来。这个动画很轻量不会影响 Web 性能。完整的代码还需要补上分数面板和结束覆盖层。分数面板我直接用Container加圆角覆盖层放在Stack里压在棋盘上方代码如下class _ScoreBoard extends StatelessWidget { const _ScoreBoard({required this.score}); final int score; 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: [ const Text( 分数, style: TextStyle(color: Colors.white, fontSize: 12), ), Text( $score, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ], ), ); } }至于结束覆盖层做法是修改build里棋盘那一块把棋盘包进Stack当_game.gameOver或_game.won为真时叠加一个半透明容器和文字提示。这里就不整段贴重复代码了核心思路就是在棋盘上面盖一层。5. 跑起来之后必须处理的 Web 端特有问题5.1 方向键没反应的真正原因我在本地用flutter run -d chrome测试时第一分钟就发现了问题鼠标点击和按钮都正常但方向键怎么按都没反应。排查询问的路径大概是这样的先确认键盘事件有没有进入到 Flutter。在_handleKey里加print(event.logicalKey)浏览器控制台能打出按键值说明事件进来了。确认有没有被前面的if (event is! KeyDownEvent)拦截。把回调参数改成event.runtimeType打印发现长按时会有KeyRepeatEvent但按一次正常只有KeyDownEvent。继续查发现是我的处理函数返回了false而HardwareKeyboard.instance.addHandler的规则是如果某个 handler 返回 true事件就会被消费返回 false事件会继续传给下一个 handler。我的 handler 虽然执行了移动逻辑但返回 false 导致 Flutter 框架认为“没人处理这个事件”在 Web 端某些情况下会触发默认行为方向键就顺带滚动了页面。不过我这次遇到的问题其实是焦点。Flutter Web 应用在浏览器里如果 canvas 没有取得焦点键盘事件根本不会进到 Flutter 的事件系统。解决办法是在页面加载后手动请求焦点WidgetsBinding.instance.addPostFrameCallback((_) { FocusManager.instance.primaryFocus?.requestFocus(); });这个处理加在initState里页面打开后会自动把焦点放到 Flutter 视图上。如果在 iframe 里嵌入游戏还需要先点击一下页面浏览器才肯把键盘事件交给 iframe。5.2 拖动判断阈值与误触Web 端最容易被忽略的是鼠标拖动和点击按钮的冲突。如果不加阈值玩家在棋盘上轻轻一晃鼠标就触发了一次移动体验非常差。我设置的是 20 像素。具体在_handlePanEnd里如果起点和终点距离小于 20就什么都不做。这个数值不是拍脑袋定的而是根据棋盘在手机屏幕上约 350 像素宽度算出来的。手指按下去到抬起来稍有抖动通常不超过 15 像素20 能避开误触如果棋盘更大比如平板上的 700 像素宽20 依然合理因为判断的是绝对距离不是比例。有人会用onPanUpdate的details.delta来判断方向也就是位移增量。这种方法在每次回调里增量都很小比如一次滑动会触发几十次回调每次只有 5、6 像素。如果要判断最终方向就得自己累加非常麻烦。用onPanStart记录起点、onPanEnd判断终点的方案天然就拿到了整个手势的总体位移。5.3 重新开始后棋盘状态没清干净还有一个容易翻车的地方是重新开始按钮。我让_game.restart()把grid重建为全 0再生成两个新方块score、won、gameOver全部重置。按这个逻辑重开没问题但如果你是在原GameBoard对象上直接修改而不是重建grid比如grid[r][c] 0这种原地赋值要注意把所有格子都清干净。更隐蔽的是很多人只在 UI 层setState(() _game GameBoard())但键盘 handler 和手势回调里持有的是旧_game对象点击重开之后棋盘看起来重置了再一滑动又操作了旧对象状态就乱掉了。我这次刻意把_game声明为final重开时调用它内部的方法重置数据而不是换对象。这是个小细节但能避免一类非常诡异的 bug。5.4 动画闪烁和构建体积Web 端还有一个性能相关的体验问题Flutter Web 首次加载时CanvasKit 渲染器体积不小如果不做优化浏览器会白屏好几秒。我在本地flutter build web之后看了下产物main.dart.js就有 2 MB 多加上 CanvasKit 的 wasm 文件首屏加载确实要等一会儿。如果只是开发自娱这个无所谓但如果要给别人玩建议做两件事部署到支持 gzip 或 brotli 压缩的静态托管平台能显著减小传输体积在构建参数里根据 Flutter 版本选择合适的渲染器新的版本可以尝试flutter build web --wasm或--web-renderer canvaskit不同版本参数有差异先用flutter build web --help看一下当前版本支持什么。至于动画闪烁往往是GridView子项被系统重建导致的。给每个格子加上稳定的 key 能缓解比如ValueKey($row-$col)让 Flutter 知道每个位置上是同一个组件而不是随着数据变化销毁重建。我用TweenAnimationBuilder的写法天然避开了大部分重建问题如果自己手写AnimatedContainer一定要留意 key。6. 构建部署与后续扩展从一个 Demo 变成能给人玩的游戏6.1 flutter build web 构建与部署当代码在flutter run -d chrome下跑通后执行flutter build web --release构建完成后产物在build/web目录里。这个目录就是一个纯静态站点包含index.html、main.dart.js、assets等文件。部署方式不限Nginx、GitHub Pages、对象存储 CDN 都可以。唯一要注意的是如果部署在子路径下比如https://example.com/2048/构建时要指定 base hrefflutter build web --release --base-href /2048/否则刷新页面后资源路径会 404。这个问题我在第一次部署时就踩过Flutter Web 默认按根路径生成资源引用放到子目录必须显式声明。6.2 想让 2048 更像样可以从这几个方向扩展基础版跑通之后我给自己列了几个扩展方向优先级从高到低本地存档用shared_preferences保存最高分和棋盘状态刷新页面不丢进度。撤销一步在每次移动前把棋盘快照存进历史栈做一个Undo按钮。棋盘大小可变把GameBoard.size改成可配置参数支持 3×3、5×5 等变体玩法。动画增强用AnimatedPositioned或自绘布局实现方块滑动动画而不是只做缩放过渡。排行榜如果部署到公网可以接一个后端或者云数据库记录全球玩家的最高分。这些功能里本地存档和撤销对代码结构的锻炼价值最大。加本地存档时你要处理序列化和反序列化加撤销时你要想清楚撤销的边界条件比如已经 gameOver 之后撤销是否允许、撤销后是否要重新生成方块等。6.3 关于 Trae 使用的一点点个人心得最后说回 Trae。这次项目做下来我用 AI 编程工具的心法又更新了一轮。第一AI 生成代码的正确性要以“我能理解”为前提。如果一段代码你完全看不懂那它出 bug 时你也没法精准地把它喂回给 AI 修复。所以我会先自己把数据结构想明白再让 AI 填充实现。第二报错信息是最佳的上下文。运行时报错、控制台输出、lint 警告直接复制粘贴到 Trae 对话框比你自己描述半天的效果都好。它能精准定位文件路径和行号大部分情况一次就改对。第三不要让它一口气写五百行。一次要一个模块跑通再要下一个模块。我用 Trae 从 0 到 1 开发这个 Flutter Web 小游戏 2048 的过程中最顺利的节奏是“给模型逻辑 → 生成核心算法 → 跑起来 → 让它加 Web 事件 → 调样式”。每一步之间都有运行验证问题不会被拖到很后面才集中爆发。现在这个 2048 游戏已经部署上线我平时手机和电脑都会打开玩两把。回顾整个过程最有价值的不是那 4×4 的棋盘而是我把一套“从规则到代码再到部署”的流程完整走了一遍。如果你也想练手我建议别直接复制完整代码跑完就结束而是自己从空白项目开始让 Trae 辅助你把每一步都写出来遇到问题再回头对照这里的代码排查收获会大得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表