用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,最直觉的存储方式是List>,也就是二维数组。外层四个元素代表四行,内层四个元素代表每行的四个格子,值为 0 表示空格,值为 2、4、8 等表示数字方块。

有人可能会想:既然移动和合并只发生在行或列,用一个长度为 16 的扁平列表,下标从 0 到 15,行操作和列操作用索引换算,岂不是更省内存?

省那几字节对前端项目毫无意义。二维数组最大的好处是代码可读性高。board[1][2]一眼就知道是第 1 行第 2 列的内容,后续写合并算法、随机生成新方块、判断游戏结束,都更不容易出错。在 AI 辅助编程的场景下,可读性更加重要,因为你要把代码贴给 Trae 让它做二次修改,它读起来也更快。

2.2 四个方向的移动怎么复用同一套逻辑

2048 最核心的地方在于移动合并。向左滑动最简单:对每一行从左到右去掉所有 0,再把相邻的相同数字合并,最后补 0。

举个例子,一行数据是[0, 2, 2, 4]:

  1. 去掉 0:[2, 2, 4]
  2. 从左开始合并:2 和 2 相同,合并成 4,这一行变成[4, 4]
  3. 补 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(List row)的纯函数,然后通过转置和反转的组合,把四个方向全部映射到它身上。代码量小,正确性也容易验证。

2.3 新方块生成与终局判断

每次有效移动后,棋盘需要随机生成一个新方块。生成规则是:先找到所有值为 0 的格子,从中随机选一个,90% 概率生成 2,10% 概率生成 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% 生成 2,10% 生成 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]得分是 4+4=8,结果一样,但它把两个方块合并成了一个 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(); List> 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; } List _slideAndMerge(List row) { final nums = row.where((e) => e != 0).toList(); final merged = []; 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(); } } List> _transposed() { return List.generate(size, (r) { return List.generate(size, (c) => grid[c][r]); }); } void _storeTransposed(List> 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 = >[]; 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; } List> _copyGrid(List> source) { return List.generate(size, (r) => List.from(source[r])); } bool _sameGrid(List> a, List> 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 State createState() => _GamePageState(); } class _GamePageState extends State { 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 TweenAnimationBuilder( 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测试时,第一分钟就发现了问题:鼠标点击和按钮都正常,但方向键怎么按都没反应。

排查询问的路径大概是这样的:

  1. 先确认键盘事件有没有进入到 Flutter。在_handleKey里加print(event.logicalKey),浏览器控制台能打出按键值,说明事件进来了。
  2. 确认有没有被前面的if (event is! KeyDownEvent)拦截。把回调参数改成event.runtimeType打印,发现长按时会有KeyRepeatEvent,但按一次正常只有KeyDownEvent。
  3. 继续查,发现是我的处理函数返回了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 文件,首屏加载确实要等一会儿。

如果只是开发自娱,这个无所谓;但如果要给别人玩,建议做两件事:

  1. 部署到支持 gzip 或 brotli 压缩的静态托管平台,能显著减小传输体积;
  2. 在构建参数里根据 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 href:

flutter 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 辅助你把每一步都写出来,遇到问题再回头对照这里的代码排查,收获会大得多。