尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用 Trae AI IDE 开发 Flutter Web 版 2048:核心算法与部署实战

用 Trae AI IDE 开发 Flutter Web 版 2048:核心算法与部署实战 周末花了大半天时间我用 Trae 这个 AI IDE 从零写出了 2048 的 Flutter Web 版本累计核心代码不到 300 行玩法完整、支持键盘也支持手势构建后就是一个纯静态站点丢到任意静态托管上都能直接运行。这篇我把项目的完整思路、全部代码和踩坑过程一次整理出来。如果你正想尝试 AI 辅助开发或者想快速上手 Flutter Web这份实操记录可以直接照着抄省掉不少弯路。1. 项目背景与整体设计为什么是 2048又为什么是 Flutter Web1.1 选型思考2048 是练手项目的黄金标准做练手项目最怕两件事需求太简单导致没什么可学的或者需求太复杂导致做一半就放弃。2048 恰好站在中间那个最佳位置。它的规则全世界都知道四句话能讲完4x4 棋盘、滑动合并相同数字、每次移动后随机生成新数字、凑出 2048 就赢。规则清晰意味着你不用花时间理解需求可以把全部精力放在算法实现和界面交互上。更重要的是2048 的玩法背后有一套非常典型的状态循环用户输入、棋盘变换、新块生成、胜负判定。这个循环几乎涵盖了所有小游戏甚至大部分前端应用的核心结构。相比之下贪吃蛇的难点在“移动时序”俄罗斯方块难在“方块旋转和消行检测”五子棋虽然逻辑简单但想做好 AI 对手就很深了。只有 2048算法复杂度刚好卡在一个下午能啃下来的区间做出来之后又能明显感觉到自己“真会写游戏逻辑了”。1.2 技术栈Trae 负责写Flutter Web 负责跑这次选 Trae 作为开发工具核心原因是它把“和 AI 结对编程”这件事的门槛降到了很低。它本身就是一款 IDE不是插件打开项目就能在右侧对话框里直接让 AI 生成代码、修改文件、跑终端命令还区分了 Chat 模式和 Build 模式。Chat 模式适合问答和解释代码Build 模式则会真正动手改项目文件这种“AI 帮你写文件、你在编辑器里审查”的工作流比传统的复制粘贴提示词高效得多。Flutter Web 则是另一个关键选择。Flutter 本身是跨端 UI 框架一套 Dart 代码能编译成 iOS、Android、Web、桌面等平台应用其中 Web 产物是纯静态的 HTML、JS、CSS部署成本极低。对游戏场景来说Flutter 的声明式 UI 和统一的 Widget 树让“棋盘网格”这种布局实现起来非常自然一个 GridView 就能搞定而且 Dart 是强类型语言配合空安全机制AI 生成的代码如果类型写错了编译期就会直接报错而不是运行时才炸这对 AI 辅助开发太友好了。1.3 核心规则与数据建模先把我实现的核心规则固定下来后面所有代码都是围绕这套规则展开的棋盘是 4x4每个格子存放一个 2 的幂次方数字0 表示空。向某个方向滑动后所有数字方块向该方向靠拢相邻且值相等的两个方块恰好合并一次合并结果为两者之和。每次“有效移动”之后在随机空位生成一个新方块90% 概率生成 210% 概率生成 4。合并产生的数值累加到总分一旦出现 2048 方块判定为胜利棋盘没有空格且不存在任何相邻等值方块时判定为游戏结束。数据模型我直接选了ListListint两层列表表示 4x4 棋盘。用二维数组而不是一维扁平数组主要是为了直观_board[r][c]直接对应屏幕上第 r 行第 c 列的格子读代码的人不需要做下标换算。Flutter 的 setState 机制对嵌套列表的修改也足够友好只要保证在状态变更后调用刷新即可。2. 环境准备与项目初始化装好工具链跑通 Hello World2.1 Flutter SDK 安装与 Web 支持开启如果你之前没装过 Flutter这一步需要一个多小时重点是网络条件和耐心。装完后在终端验证一下flutter doctor这个命令会检查 Flutter SDK、Dart、编辑器插件等环境是否齐全缺什么它会直接提示。看 Flutter 版本时注意 3.7 以上的版本对 Web 支持已经很成熟直接用即可。接着开启 Web 支持并创建项目flutter config --enable-web flutter create --platforms web game2048 cd game2048 flutter run -d chrome如果一切正常浏览器会自动打开一个 Flutter 默认的计数器 Demo 页面。看到这个页面说明你的工具链已经通了。这一步我没让 Trae 参与因为它是纯环境操作AI 帮不上什么忙反而是手动跑一遍能确认每个环节都没问题后面出了问题也好定位。2.2 安装 Trae 并导入项目Flutter 项目创建好之后打开 Trae选择“打开文件夹”把 game2048 这个目录加载进来。Trae 的界面布局对 VS Code 用户非常友好左侧是文件树中间是编辑器右侧是 AI 对话面板。面板顶部能看到 Chat 模式和 Build 模式的切换入口这两个模式的区别决定了 AI 的“权限”Chat 模式只回答不改文件Build 模式会直接按你的要求写代码、改文件、跑命令。我第一次用的时候在两个模式之间来回切了好几次后来总结出一个适合自己的方法让 AI 动手之前先用 Chat 模式把需求和方案对齐确认理解没有偏差方案清楚了再切到 Build 模式让它一次改到位。这样能减少很多来回返工。2.3 用好 Trae 的第一条提示词当项目打开后我给 Trae 发出了第一条需求请帮我在 lib/main.dart 里写一个 2048 小游戏Flutter Web 版本要求 1. 4x4 棋盘经典 2048 配色 2. 支持键盘方向键和 WASD 控制也支持鼠标拖拽和手机触屏滑动 3. 有分数显示、最高分显示、重新开始按钮 4. 每次有效移动后随机生成 2 或 490% 概率生成 2 5. 出现 2048 判定胜利无空位且无法合并则游戏结束这里有个细节提示词里我把“有效移动”这个条件点出来了。如果不强调这一点很多 AI 生成的版本会在无效滑动后也生成新方块玩起来感觉“棋盘在乱长”。游戏规则的描述越精确AI 生成的代码越接近预期。Trae 切到 Build 模式后它会自动把代码写进 main.dart。这时候不要着急运行先把代码从头到尾读一遍。我的习惯是重点看三处有没有多余依赖、空位生成逻辑、移动后棋盘是否真的发生了变化。AI 写的代码通过编译不代表逻辑正确尤其游戏状态类代码必须靠人眼审查一遍。3. 核心逻辑拆解滑动、合并、生成与胜负判定3.1 状态设计与棋盘表示游戏的状态管理是整个项目的核心所有玩法都围绕这些字段展开static const int gridSize 4; final Random _rng Random(); late ListListint _board; int _score 0; int _best 0; bool _gameOver false; bool _won false;_board是 4x4 的二维列表每个元素存格子里的数字0 表示空位。用 0 表示空位有个好处数字本身就是 2 的幂可以直接用来做 UI 配色映射0 单独处理成背景色即可。_score是当前局分数_best是最高分这两个分开存是为了“重新开始”时不重置最高分。_gameOver和_won是终局状态标志状态一旦置位后续移动直接忽略。3.2 一维合并算法去零、合并、补零2048 最核心的算法其实是处理“一行数组”的逻辑。一次滑动本质上是把一行里的数字先去掉空格挤到一侧再把相邻的相同数字合并一次最后在末尾补零补满四个格子。举个例子一行数据如果是[2, 0, 2, 2]向左滑动后应该是[4, 2, 0, 0]。分三步看去零得到[2, 2, 2]合并相邻相同的 2 和 2 得到 4剩下的 2 继续放在后面得到[4, 2]最后补零成[4, 2, 0, 0]。合并的关键是用 while 循环配合指针跳跃({Listint line, int gained, bool reachedWin}) _mergeLine(Listint input) { final Listint nonZero input.where((v) v ! 0).toList(); final Listint merged []; int gained 0; bool reachedWin false; int i 0; while (i nonZero.length) { if (i 1 nonZero.length nonZero[i] nonZero[i 1]) { final int newValue nonZero[i] * 2; merged.add(newValue); gained newValue; if (newValue 2048) reachedWin true; i 2; // 合并后跳过下一个保证同一轮只合并一次 } else { merged.add(nonZero[i]); i; } } while (merged.length gridSize) { merged.add(0); } return (line: merged, gained: gained, reachedWin: reachedWin); }i 2这一行是这个函数最容易出错的地方。它保证了合并过的数字不会继续参与后面的合并。如果去掉这行[2, 2, 2, 2]会被错误地处理成[8, 0, 0, 0]而正确的结果是[4, 4, 0, 0]。原版 2048 的规则是“每次移动每个格子最多参与一次合并”这个限制必须靠指针跳跃来保证。另外我把本次合并“得了多少分”和“是否出现了 2048”也在这个函数里一并统计返回而不是在外部再遍历一遍棋盘。这样移动逻辑可以只用一次遍历就拿到全部需要的数据状态更新干净利落。3.3 把四个方向统一成“线处理”有了上面这个一维合并函数四个方向的移动就变成了一个“取线、合并、写回”的问题。这个思路借鉴了矩阵转置的手法向左滑动时处理每一行向右滑动时把行反转后当左滑处理向上滑动时取每一列向下滑动时把列反转后当上滑处理。void _move(Direction dir) { if (_gameOver || _won) return; final ListListint allLines []; for (int i 0; i gridSize; i) { switch (dir) { case Direction.left: allLines.add(Listint.from(_board[i])); break; case Direction.right: allLines.add(_board[i].reversed.toList()); break; case Direction.up: allLines.add(Listint.generate(gridSize, (r) _board[r][i])); break; case Direction.down: allLines.add(Listint.generate(gridSize, (r) _board[r][i]).reversed.toList()); break; } } int totalGained 0; bool reachedWin false; final ListListint newLines []; for (final line in allLines) { final result _mergeLine(line); newLines.add(result.line); totalGained result.gained; if (result.reachedWin) reachedWin true; } bool changed false; for (int k 0; k gridSize; k) { Listint line Listint.from(newLines[k]); if (dir Direction.right || dir Direction.down) { line line.reversed.toList(); } if (dir Direction.left || dir Direction.right) { if (!_listEquals(_board[k], line)) changed true; _board[k] line; } else { final Listint oldCol List.generate(gridSize, (r) _board[r][k]); for (int r 0; r gridSize; r) { _board[r][k] line[r]; } if (!_listEquals(oldCol, line)) changed true; } } if (changed) { setState(() { _score totalGained; if (_score _best) _best _score; if (reachedWin) _won true; _addRandomTile(); _gameOver _isGameOver(); }); } }这个设计最大的好处是你只需要维护一个_mergeLine函数四个方向的差异全部通过“反转”这个操作来抹平。如果写成四个独立的移动函数代码量会翻几倍而且每个函数都要单独测试任何一个方向写错下标都很难查。统一成线处理后只要左滑正确右滑、上滑、下滑的正确性就有基础保障。3.4 随机方块、计分与游戏结束新块生成逻辑很简单先收集所有值为 0 的空位坐标随机选一个再按概率写入 2 或 4void _addRandomTile() { final ListPointint emptyCells []; for (int r 0; r gridSize; r) { for (int c 0; c gridSize; c) { if (_board[r][c] 0) { emptyCells.add(Point(r, c)); } } } if (emptyCells.isEmpty) return; final cell emptyCells[_rng.nextInt(emptyCells.length)]; _board[cell.x][cell.y] _rng.nextInt(10) 0 ? 4 : 2; }注意_addRandomTile只会在棋盘变化后被调用。我在_move里先比较了旧棋盘和新棋盘只有changed true才进入 setState 更新状态并生成新块。这是一个很容易漏掉的细节如果用户按了一个方向但没有发生任何移动新方块不应该生成。我在用 Trae 初版生成的代码里就遇到过这个问题棋盘会在无效滑动后莫名多出方块玩起来非常奇怪。游戏结束的判断也不复杂遍历所有格子只要存在一个空格、或者存在一组相邻等值方块就还能继续反之则结束bool _isGameOver() { for (int r 0; r gridSize; r) { for (int c 0; c gridSize; c) { if (_board[r][c] 0) return false; if (r 1 gridSize _board[r][c] _board[r 1][c]) return false; if (c 1 gridSize _board[r][c] _board[r][c 1]) return false; } } return true; }这里有个容易遗漏的点游戏结束不能只看“有没有空格”。有时候棋盘明明满了但相邻格子还能合并游戏就还能继续。所以必须同时检查空格和相邻等值两种情况。4. 界面渲染与交互控制让逻辑变成真正能玩的游戏4.1 布局与经典 2048 配色界面整体结构我用了一个垂直布局顶部是标题和分数区中间是棋盘底部是操作提示和重新开始按钮全部约束在最大宽度 480 的容器里居中显示这样在手机横竖屏、平板、桌面浏览器上都能有不错的观感。棋盘本身我用AspectRatio(aspectRatio: 1)保证它是正方形内部放一个GridView.builder每个格子通过_tileColor(value)映射背景色。经典 2048 的配色规律是数值越大颜色越深越暖文字颜色从深灰变成白色。关键几个颜色值如下数字背景色文字色2#EEE4DA#776E654#EDE0C8#776E658#F2B179#FFFFFF16#F59563#FFFFFF32#F67C5F#FFFFFF64#F65E3B#FFFFFF128#EDCF72#FFFFFF256#EDCC61#FFFFFF512#EDC850#FFFFFF1024#EDC53F#FFFFFF2048#EDC22E#FFFFFF这个配色表我直接让 Trae 按经典版生成几乎不用改。棋盘背景用 #BBADA0页面底色用 #FAF8EF整体就是原版那种“木纹质感的深灰背景 彩色数字块”的风格。4.2 三种操作方式方向键、WASD、滑动手势Web 端和移动端有一个很大的交互差异移动端靠触摸滑动桌面端靠键盘和鼠标。我三样都做了支持。键盘事件用 Flutter 的Focus组件配合onKeyEvent回调实现。这里有个 Web 端常见陷阱如果页面焦点不在游戏区域键盘事件根本不会触发。所以我在最外层包了一个Focus(autofocus: true)让页面加载后焦点默认落在游戏区域同时支持方向键和 WASD 双键位Focus( autofocus: true, onKeyEvent: (node, event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key event.logicalKey; if (key LogicalKeyboardKey.arrowUp || key LogicalKeyboardKey.keyW) { _move(Direction.up); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowDown || key LogicalKeyboardKey.keyS) { _move(Direction.down); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowLeft || key LogicalKeyboardKey.keyA) { _move(Direction.left); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowRight || key LogicalKeyboardKey.keyD) { _move(Direction.right); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: ... )手势滑动我用GestureDetector的onPanEnd监听根据手指抬起时的速度方向判断滑动方向GestureDetector( onPanEnd: (details) { final velocity details.velocity; if (velocity.pixelsPerSecond.dx.abs() velocity.pixelsPerSecond.dy.abs()) { if (velocity.pixelsPerSecond.dx 0) { _move(Direction.right); } else { _move(Direction.left); } } else { if (velocity.pixelsPerSecond.dy 0) { _move(Direction.down); } else { _move(Direction.up); } } }, child: GridView... )用速度方向而不是位移方向来判断手感会好很多因为即使手指滑动的位移很小只要速度方向明确也能响应。同时要注意给 GridView 设置physics: NeverScrollableScrollPhysics()否则在 Web 端用鼠标拖拽时GridView 本身会抢走手势事件导致滑动失灵。4.3 用 Trae 做增量优化第一版功能跑通之后我没有停下来而是继续用 Trae 做了一轮增量优化。我给它提了几个新需求加最高分显示、加重新开始按钮、把分数卡片做成深色圆角样式、底部加操作提示文字。这类 UI 调整的需求对 Trae 来说非常轻松基本一次就能改到位。这个过程中我体会到和 AI 协作开发最顺的顺序是“先功能后样式”。先把核心玩法和数据逻辑跑通因为这部分是建筑物地基界面细节放到后面迭代因为 AI 对“好看”这种主观要求理解不准但对“加一个按钮”“改个颜色”这种具体指令完成度很高。如果你一上来就要求 AI 生成“好看的界面”大概率会得到一个花里胡哨但逻辑有问题的半成品。5. 完整代码与发布部署5.1 完整 main.dart下面这份main.dart就是最终运行版的完整代码整个项目零第三方依赖只需要 Flutter SDK 自带的组件就能跑。我把它直接放在lib/main.dart下即可不需要创建任何额外文件。import dart:math; 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, colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFBBADA0)), ), home: const Game2048Page(), ); } } enum Direction { left, right, up, down } class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); override StateGame2048Page createState() _Game2048PageState(); } class _Game2048PageState extends StateGame2048Page { static const int gridSize 4; final Random _rng Random(); late ListListint _board; int _score 0; int _best 0; bool _gameOver false; bool _won false; override void initState() { super.initState(); _board List.generate(gridSize, (_) Listint.filled(gridSize, 0)); _addRandomTile(); _addRandomTile(); } void _resetGame() { setState(() { _board List.generate(gridSize, (_) Listint.filled(gridSize, 0)); _score 0; _gameOver false; _won false; _addRandomTile(); _addRandomTile(); }); } void _addRandomTile() { final ListPointint emptyCells []; for (int r 0; r gridSize; r) { for (int c 0; c gridSize; c) { if (_board[r][c] 0) { emptyCells.add(Point(r, c)); } } } if (emptyCells.isEmpty) return; final cell emptyCells[_rng.nextInt(emptyCells.length)]; _board[cell.x][cell.y] _rng.nextInt(10) 0 ? 4 : 2; } ({Listint line, int gained, bool reachedWin}) _mergeLine(Listint input) { final Listint nonZero input.where((v) v ! 0).toList(); final Listint merged []; int gained 0; bool reachedWin false; int i 0; while (i nonZero.length) { if (i 1 nonZero.length nonZero[i] nonZero[i 1]) { final int newValue nonZero[i] * 2; merged.add(newValue); gained newValue; if (newValue 2048) reachedWin true; i 2; } else { merged.add(nonZero[i]); i; } } while (merged.length gridSize) { merged.add(0); } return (line: merged, gained: gained, reachedWin: reachedWin); } bool _listEquals(Listint a, Listint b) { if (a.length ! b.length) return false; for (int i 0; i a.length; i) { if (a[i] ! b[i]) return false; } return true; } void _move(Direction dir) { if (_gameOver || _won) return; final ListListint allLines []; for (int i 0; i gridSize; i) { switch (dir) { case Direction.left: allLines.add(Listint.from(_board[i])); break; case Direction.right: allLines.add(_board[i].reversed.toList()); break; case Direction.up: allLines.add(Listint.generate(gridSize, (r) _board[r][i])); break; case Direction.down: allLines.add(Listint.generate(gridSize, (r) _board[r][i]).reversed.toList()); break; } } int totalGained 0; bool reachedWin false; final ListListint newLines []; for (final line in allLines) { final result _mergeLine(line); newLines.add(result.line); totalGained result.gained; if (result.reachedWin) reachedWin true; } bool changed false; for (int k 0; k gridSize; k) { Listint line Listint.from(newLines[k]); if (dir Direction.right || dir Direction.down) { line line.reversed.toList(); } if (dir Direction.left || dir Direction.right) { if (!_listEquals(_board[k], line)) changed true; _board[k] line; } else { final Listint oldCol List.generate(gridSize, (r) _board[r][k]); for (int r 0; r gridSize; r) { _board[r][k] line[r]; } if (!_listEquals(oldCol, line)) changed true; } } if (changed) { setState(() { _score totalGained; if (_score _best) _best _score; if (reachedWin) _won true; _addRandomTile(); _gameOver _isGameOver(); }); } } bool _isGameOver() { for (int r 0; r gridSize; r) { for (int c 0; c gridSize; c) { if (_board[r][c] 0) return false; if (r 1 gridSize _board[r][c] _board[r 1][c]) return false; if (c 1 gridSize _board[r][c] _board[r][c 1]) return false; } } return true; } Color _tileColor(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; } override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFAF8EF), body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key event.logicalKey; if (key LogicalKeyboardKey.arrowUp || key LogicalKeyboardKey.keyW) { _move(Direction.up); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowDown || key LogicalKeyboardKey.keyS) { _move(Direction.down); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowLeft || key LogicalKeyboardKey.keyA) { _move(Direction.left); return KeyEventResult.handled; } if (key LogicalKeyboardKey.arrowRight || key LogicalKeyboardKey.keyD) { _move(Direction.right); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 16), _buildBoard(), const SizedBox(height: 16), _buildFooter(), ], ), ), ), ), ), ), ); } Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 2048, style: TextStyle( fontSize: 52, fontWeight: FontWeight.bold, color: const Color(0xFF776E65), ), ), Text( Flutter Web 版, style: TextStyle(fontSize: 14, color: Colors.grey.shade600), ), ], ), Row( children: [ _scoreCard(分数, _score.toString()), const SizedBox(width: 8), _scoreCard(最高, _best.toString()), ], ), ], ); } Widget _scoreCard(String label, String value) { 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: Colors.white70)), Text( value, style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } Widget _buildBoard() { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GestureDetector( onPanEnd: (details) { final velocity details.velocity; if (velocity.pixelsPerSecond.dx.abs() velocity.pixelsPerSecond.dy.abs()) { if (velocity.pixelsPerSecond.dx 0) { _move(Direction.right); } else { _move(Direction.left); } } else { if (velocity.pixelsPerSecond.dy 0) { _move(Direction.down); } else { _move(Direction.up); } } }, child: GridView.builder( physics: const NeverScrollableScrollPhysics(), itemCount: gridSize * gridSize, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: gridSize, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemBuilder: (context, index) { final r index ~/ gridSize; final c index % gridSize; final value _board[r][c]; return _buildTile(value); }, ), ), ), ); } Widget _buildTile(int value) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: _tileColor(value), borderRadius: BorderRadius.circular(6), ), child: value 0 ? null : Text( $value, style: TextStyle( fontSize: value 100 ? 28 : 36, fontWeight: FontWeight.bold, color: _textColor(value), ), ), ); } Widget _buildFooter() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 方向键 / WASD / 手势滑动, style: TextStyle(fontSize: 13, color: Colors.grey.shade600), ), TextButton.icon( onPressed: _resetGame, icon: const Icon(Icons.refresh), label: const Text(重新开始), ), ], ); } }代码里有一个我刻意没做的功能最高分持久化。如果加了shared_preferences依赖就能把最高分存到浏览器 localStorage 里刷新页面后仍然保留。我没加是为了保持项目零第三方依赖你如果想要这个功能用 Trae 直接让它加几分钟就能搞定。5.2 本机运行、Web 构建与部署开发调试用这条命令它会启动 Chrome 并支持热重载flutter run -d chrome发布到线上用 build 命令flutter build web构建产物在build/web目录下是一整套纯静态文件。由于 2048 是单页应用且没有路由跳转部署极其简单把build/web里的文件原样上传到任意静态托管服务就行。Nginx 直接指向这个目录、GitHub Pages 推一个分支、对象存储开静态网站托管都可以。唯一要提醒的是 Flutter Web 的首次加载体积在 2MB 到 3MB 左右属于正常水平如果在意加载速度服务器上开 gzip 或 brotli 压缩效果非常明显。6. 常见问题与避坑实录6.1 合并算法的经典 Bug我让 Trae 生成第一版代码时它给出的合并实现是用“前后比较 用后即抛弃”的方式写的结果[2, 2, 2, 2]一次滑动直接变成了[8, 0, 0, 0]。这个 bug 最坑的地方在于你玩前面几十步根本发现不了只有遇到四连相同数字时才炸出来而且炸出来的时候会怀疑人生以为是规则本来就是这样。排查思路很简单在纸上手写一个[2, 2, 2, 2]的例子向左滑一步预期的结果是[4, 4, 0, 0]因为 2 和 2 合并成 4 之后这个新 4 不应该再和后面的 2 合并。如果你的代码输出[8, 0, 0, 0]说明合并循环在合并后没有跳过被吞掉的数字。解决办法就是我在 3.2 节写的那个i 2指针跳跃。6.2 Web 端键盘失灵与页面滚动冲突键盘失灵是 Flutter Web 新手最容易撞上的问题。代码逻辑完全正确但按方向键就是没反应。原因在于 Web 页面的焦点问题Flutter 应用加载后键盘事件只发给当前获得焦点的 Widget如果焦点不在游戏区域按键自然无效。解决办法有两个一个是包一层Focus(autofocus: true)让页面加载后自动获得焦点另一个是点击棋盘区域先激活焦点再操作。另一个交互冲突是页面滚动。在手机上玩的时候手指在棋盘上滑动可能会触发页面整体滚动导致棋盘和页面一起动看起来非常乱。解决办法是我在 GridView 上设置了physics: NeverScrollableScrollPhysics()让棋盘自身的滚动被禁用手势事件全部交给外层的 GestureDetector 处理。如果你以后做更复杂的 Flutter Web 游戏这个冲突点几乎一定会遇到。6.3 Trae 使用过程中的几个实际坑和 Trae 协作几次之后我总结出几个比较实际的坑AI 偶尔会生成不存在的 API。我遇到过它引用了某个不存在的BoardModel类编译直接报错。这时候不用慌直接把终端里的报错信息原样复制给 Trae让它自己修复基本一轮就能解决。这也侧面说明用 AI 写代码时“能读懂编译错误”是多么重要的能力。Build 模式大改之后容易出现回归问题。Trae 的 Build 模式是直接改文件有时候它会把你没要求动的代码也顺手重构了。我的习惯是每完成一个功能点立刻运行测试不要攒着一堆改动再统一验证否则出了问题根本不知道是哪一步引入的。关于积分消耗这个项目从生成到迭代大概用了一两千字的对话量Trae 新用户赠送的免费额度完全够用。但如果一次性丢一个超大需求到 Build 模式里让它“全自动完成”积分会消耗得很快而且生成结果往往问题很多反而更费积分修复。更经济的做法是把需求拆成小块分步确认每步控制在一次对话量级。Skills 功能对这类前端小项目有明显的效果提升。Trae 支持加载一些技能包相当于给 AI 预置了行业最佳实践的提示词。我试过加载 Flutter 相关的 Skill 后再让它生成代码生成的代码在结构规范性上明显好于裸写比如它会主动把布局拆成独立 Widget 方法而不是全部堆在 build 里。如果你发现 AI 生成的大文件越来越难维护先检查一下是不是没挂 Skill。6.4 排查问题速查表现象可能原因解决方案一次滑动把 2、2、2、2 合并成 8合并逻辑缺少“每轮一次合并”限制用 while 循环合并后i 2无效滑动也生成新方块未判断棋盘是否变化就执行生成逻辑比较新旧棋盘changed true才生成键盘按键没反应焦点不在游戏区域用Focus(autofocus: true)包裹棋盘鼠标拖拽没反应或页面滚动GridView 抢占了手势事件设置NeverScrollableScrollPhysics()游戏结束判断错误满盘但还能合并只检查空格没检查相邻等值同时检查空位和左右、上下相邻相等棋盘格子里的数字显示不全数值变大后字体超出格子根据位数动态缩小字号我在代码里对 3 位数用了 28 字号AI 生成的代码编译不过使用了不存在的类或属性把编译报错复制给 AI让它自己修Web 构建产物首次加载慢Flutter Web 产物较大服务端开启 gzip/brotli 压缩最后分享一点个人体会。用 AI 写代码最关键的不是提示词多华丽而是你自己心里要有清晰的结构。2048 这个项目逻辑不算难但如果你脑子里没有“线处理”“一次合并”“变化后再生成”这些概念AI 生成出错的时候你根本不知道往哪查。反过来先把游戏规则和数据结构想明白再让 Trae 帮你写具体实现和界面整个过程会顺畅得多。做完这个小游戏之后我对 Flutter 的布局、手势、键盘事件都有了更直观的理解而且这个项目的改造成本很低后面想继续玩的话可以去加滑动动画、加撤销功能、加排行榜代码底子都是现成的。
返回列表