最近用 Trae 从 0 到 1 写了个 Flutter Web 版 2048 小游戏,整个过程基本没离开过 Trae 的编辑器,代码生成、报错修复、Web 部署也都是它陪着干的。这篇文章把我从搭环境到写完并部署到 Nginx 的完整过程、全部核心代码和踩坑记录整理出来,新手可以直接照抄,有一定经验的也可以看看 AI IDE 写小游戏的思路和边界。
先说结论:2048 这种项目拿来作为 Flutter Web 入门非常合适——逻辑不复杂、交互明确、界面可控,而且特别容易验证“AI 写代码到底靠不靠谱”。你能清楚看到哪里是 AI 一次写对的,哪里是它反复改不对、最后需要你手工修的。这种“用人 + 用 AI”的配合感,比任何教程都实在。适合刚准备学 Flutter 的同学,也适合想试试 Trae 这类 AI IDE 但不知道从哪下手的朋友。
1. 项目设计与技术选型
1.1 为什么用 Trae 而不是 Cursor 或传统 IDE
先说 Trae。它是字节推出的一款 AI IDE,基于 VSCode 的代码库改的,所以熟悉 VSCode 的人上手几乎没有成本。国内直接用 Trae CN 版本就行,登录、使用都比较顺。它和 Cursor 最大的区别是内置了两种交互模式:Chat 模式和 Build 模式。Chat 模式适合你问我答、解释代码、改一小段逻辑;Build 模式更像一个“自动执行任务的智能体”,你给它一个需求,它可以直接在当前工作区里创建文件、修改多个文件、执行命令,甚至根据编译报错自动修代码。
这次写 2048,我大部分时间用的是 Build 模式,让它一口气生成核心代码,再通过 Chat 模式逐步加功能。说实话,在没有 AI IDE 之前,这种流程得先自己搭项目骨架、查 API,再一行一行写,中间至少多花一倍时间。Trae 真正帮我省时间的点在于:它可以读取当前项目的文件列表和选中文件内容,我不用把需求描述得特别完整,只要把意图说清楚,它就能在正确的目录里找到该改的地方。这种“懂项目上下文”的能力,比单纯靠对话生成代码更实用。
1.2 为什么选 Flutter Web 做 2048
Flutter 最出名的是跨平台移动开发,但它的 Web 支持这几年已经很能打了。选 Flutter Web,看中的是它声明式 UI 和手势体系的配合:2048 需要频繁更新网格状态,需要监听滑动和键盘操作,这些在 Flutter 里都有现成的组件,不用自己处理浏览器 DOM 和 CSS 的细节。
我承认,如果只是为了做一个能在浏览器里玩的 2048,用原生 JavaScript + Canvas 或者 React/Vue 都可能更轻。但 Flutter Web 的优势是同一套代码可以继续编译成桌面端、移动端,之后想加个手机适配版,成本极低。而且 Flutter 的布局和动画在 Web 上渲染很稳定,尤其是做数字方块这种有圆角、有颜色变化的界面,比手写 CSS 舒服太多。
另外还有一个很重要的原因:调试友好。Flutter Web 可以直接在 Chrome 里跑,Flutter DevTools 能看布局、看层级、看动画帧率,遇到布局问题比移动端调试直观。对初学者来说,浏览器里按 F12 看 console 报错、刷新页面立刻看到改动的体验,会大大降低挫败感。
1.3 功能清单与范围控制
做这个小游戏之前,我先把功能范围划定清楚,避免被 AI 带着越做越复杂。最终确定的功能是:
- 经典 4x4 棋盘,滑动或方向键移动数字方块
- 相同数字合并,每次移动后随机生成一个新方块(90% 是 2,10% 是 4)
- 实时分数统计,记录本局最高分
- 合成 2048 时弹出提示,可继续挑战更高分
- 游戏结束时弹出提示,可重新开始
- 额外加一个“撤销”按钮,方便手滑操作
没有做滑动动画、没有排行榜、没有音效、没有主题切换。这些不是不能做,而是一开始加了会让排错变难。我的原则是先用最小功能跑通核心循环,再考虑扩展。这个原则很重要,等后面你让 Trae 加功能时就会体会到:范围越小,AI 越不会帮你“自由发挥”出奇怪的东西。
2. 环境准备与 Trae 配置
2.1 Flutter SDK 下载与版本管理
开始前先把 Flutter SDK 装好。这一步属于“不性感但必须做”的基础工作,我直接把关键点列出来。
到 Flutter 官网下载对应你操作系统的 SDK 压缩包,Windows 下载 zip 后解压到一个没有中文和空格的路径,比如D:\flutter;macOS 下载 tar.xz,解压到用户目录。然后配置环境变量,把flutter/bin目录加入 PATH。Windows 用户在“系统环境变量”的 Path 里加一条,macOS/Linux 用户在 shell 配置文件里加export PATH="$PATH:$HOME/flutter/bin"。
配置完环境变量后,记得重开终端再执行flutter doctor,它会检查依赖是否齐全。常见的坑有两个:一个是环境变量配置了但终端没重开,导致flutter命令还是找不到;另一个是下载镜像问题导致依赖拉取很慢,这时候可以配置可访问的镜像源,把FLUTTER_STORAGE_BASE_URL和PUB_HOSTED_URL指到对应镜像,再重新执行命令。
版本方面,我写这篇时用的是 Flutter 3.29.x stable。现在新版本迭代很快,你直接用当前 stable 就行。但如果你手上有多个 Flutter 项目,每个项目要求不同 SDK 版本,强烈建议装一个 FVM(Flutter Version Management)来管理多版本。安装很简单:
dart pub global activate fvm fvm install 3.29.2 fvm use 3.29.2 fvm flutter --version装完之后进入项目目录执行fvm use,以后所有flutter命令都用fvm flutter代替,这样项目之间切换版本互不干扰。尤其当你同时维护老项目和新项目时,这个工具能救命。
2.2 Trae 安装与工作区初始化
Trae 的安装没什么特殊,到官网下载对应平台的安装包,装完打开后配置语言和主题。第一次启动会让你登录,国内用户直接用 Trae CN 即可。登录后它会自动安装一些扩展支持,整个过程和 VSCode 首次启动的感觉很像。
进入 Trae 之后,我建议直接用它的内置终端创建 Flutter 项目,不要自己在外面建好再拖进来。这样 Trae 能更快识别工作区结构,之后 Build 模式也更容易定位文件。创建命令很简单:
flutter create --platforms=web .这个命令会在当前目录生成一个 Flutter Web 项目。注意我特意加了--platforms=web,只生成 Web 平台相关的目录,不生成 Android、iOS 等多余工程文件。这么做有两个好处:项目干净,不会出现一堆用不着的平台配置;同时也避开了一些只跟移动端构建相关的报错,比如网上常见的“you are applying flutter's main gradle plugin imperatively using the apply method”这类问题,在纯 Web 项目里根本不会遇到。
项目生成之后,先用 Trae 打开lib/main.dart,那是个 Flutter 默认计数器模板。在 Trae 的 Chat 面板里输入一个简单的需求,比如“帮我把这个 page 改成 2048 游戏页面”,它就能开始工作了。我习惯把需求写细一点,越具体越不容易跑偏。下面是我这次用的提示词模板,供参考:
用 Flutter 实现一个 2048 小游戏,要求: 1. 4x4 网格,数字方块配色参考原版 2048 2. 支持触摸滑动和键盘方向键操作 3. 每次移动后随机生成 2 或 4 4. 显示当前分数和最高分(最高分用内存保存即可) 5. 合成 2048 弹窗提示,游戏结束弹窗 6. 提供“新游戏”和“撤销”按钮 7. 只需要 Web 端能跑,不考虑移动端构建然后切到 Build 模式,把这段需求发过去。它可能会创建文件还会顺带改pubspec.yaml,整个过程它会自己完成,你只需要看输出的日志。我第一次用的时候还担心它把项目搞乱,后来发现它每次改动前会先扫描当前文件,写完还能通过flutter analyze自查一遍,比较让人放心。
2.3 开启 Web 支持并跑通第一个页面
创建完项目之后,确认 Web 支持已经打开。新版本 Flutter 默认就支持 Web,但稳妥起见还是执行一下:
flutter config --enable-web flutter devicesflutter devices输出里如果能看到 Chrome 或 Edge,说明 Web 目标已经就绪。接着直接启动:
flutter run -d chrome第一次运行会拉取一些 Web 相关依赖,耐心等一会儿。启动成功之后,会自动弹出一个 Chrome 窗口,里面是默认计数器页面。到这一步,环境就算完全跑通了。你会发现 Trae 的调试控制台会实时显示 Flutter 日志,如果代码里有没有处理的异常,会以红色报错呈现。这些报错信息不用怕,直接复制粘贴到 Trae 的 Chat 面板里,让它帮你分析,大部分问题都能当场解决。
3. 核心代码实现解析
这一部分是最有看头的。我会先拆解 2048 的核心算法,再展示完整代码。代码最终我统一放在lib/main.dart里,方便你复制后直接flutter run -d chrome。
3.1 棋盘模型与移动合并算法
2048 的棋盘本质是一个 4x4 二维数组,我用List<List<int?>>表示,null代表空格,数字代表方块值。每次移动的逻辑可以抽象成三步:“压缩 -> 合并 -> 再压缩”。但实际实现时,很多方案会直接在一次遍历里完成压缩和合并,我下面的_slideRow就是这么干的:
List<int?> _slideRow(List<int?> row) { final nums = row.where((x) => x != null).toList(); final result = List<int?>.filled(size, null); int writeIdx = 0; int i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { final merged = nums[i]! * 2; result[writeIdx] = merged; _score += merged; i += 2; } else { result[writeIdx] = nums[i]; i += 1; } writeIdx += 1; } return result; }这个方法处理一行:先把非空数字取出来,然后按顺序检查相邻两个是否相等。相等就合并成一个新数,并把分数加上去,索引跳两位;不相等就直接放到结果位置,索引进一位。这样一次遍历就完成了压缩和合并,不会出现“三个相同数字合成两个不同结果”的问题。
左移就是对每一行调用_slideRow,右移则是把每一行反转后_slideRow再反转回来。上移和下移稍微绕一点,我用了一个转置技巧:先把棋盘转置,上移就变成对每一行做左移,下移就变成对每一行做右移,处理完再转置回来。这样不需要为四个方向分别写逻辑,代码量少很多,也不容易出错。
3.2 随机生成与状态判断
每次有效移动后,需要在空白位置生成一个新方块。我的实现是先遍历棋盘收集所有空位坐标,用Random随机选一个,再按 90% / 10% 的概率生成 2 或 4:
void _addRandomTile() { final empty = <(int, int)>[]; for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { if (_board[i][j] == null) empty.add((i, j)); } } if (empty.isEmpty) return; final (r, c) = empty[_random.nextInt(empty.length)]; _board[r][c] = _random.nextInt(10) < 9 ? 2 : 4; }游戏结束判断也很直观:只要棋盘上还有空格,游戏就没结束;如果没有空格,再检查是否存在相邻相等的情况,有则可以继续合并,也算没结束。只有空格为零且没有相邻相等时,才是真正玩不动了:
bool _canMove() { for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { if (_board[i][j] == null) return true; if (j < size - 1 && _board[i][j] == _board[i][j + 1]) return true; if (i < size - 1 && _board[i][j] == _board[i + 1][j]) return true; } } return false; }这里有个细节值得注意:_move方法被调用之前,我们先记录一下移动前棋盘的 key,执行完移动后再比较 key 是否变化。如果没变化,说明用户这个方向根本挪不动,那就不能生成新方块,也没必要刷新状态:
final beforeKey = _boardKey(_board); // 执行移动... final afterKey = _boardKey(_board); if (beforeKey == afterKey) { return; } _addRandomTile();这个判断能避免一个很常见的 bug:用户一直往同一个方向滑,棋盘没有变化却不断生成新方块,游戏很快就塞满了。
3.3 UI 布局与动效
界面部分直接用 Flutter 的 Material 组件搭建。外层是一个GestureDetector,用来监听触摸滑动;内层是一个Focus组件,用来捕获键盘方向键;再往里是Scaffold和SafeArea,整体限制最大宽度 480,避免在宽屏显示器上体验怪异。
棋盘部分用一个GridView.builder,4 列,不可滚动。每个格子通过_Tile组件渲染,依据数字大小映射不同的背景色和文字颜色。表格的排版我用了AspectRatio,强制棋盘区域保持正方形,这样网格间距和方块大小都能随屏幕宽度自动适配。
动效我用了AnimatedContainer,让每个方块的背景色和数字变化有 120 毫秒的过渡。2048 原版的滑动动画我这次没有实现,因为要实现方块平移动画得维护每颗方块的位置状态,复杂度会上升不少。AnimatedContainer虽然只是颜色过渡,但已经能让界面看起来不那么生硬。
3.4 手势与键盘输入
Web 端和移动端最大的差异就在输入上:手机用户习惯滑动屏幕,桌面用户习惯按方向键。两个都要支持。
滑动手势我用的是GestureDetector的onPanStart、onPanUpdate、onPanEnd组合。在onPanStart里把累计位移清零,onPanUpdate里不断累加位移向量,onPanEnd时再根据位移的水平和垂直分量判断滑动方向:
void _handleSwipe() { final dx = _dragOffset.dx; final dy = _dragOffset.dy; if (dx.abs() < 20 && dy.abs() < 20) return; if (dx.abs() > dy.abs()) { _move(dx > 0 ? Direction.right : Direction.left); } else { _move(dy > 0 ? Direction.down : Direction.up); } _dragOffset = Offset.zero; }阈值设成 20,小于这个距离就忽略,可以避免用户点按操作误触发移动。
键盘监听用的是Focus组件的onKeyEvent,监听四个方向键和KeyRepeatEvent,这样长按方向键可以连续移动:
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowLeft) { _move(Direction.left); return KeyEventResult.handled; } // 其他方向同理 return KeyEventResult.ignored; }这里有个 Web 端特有的坑:Focus必须获得焦点,键盘事件才会生效。我加了autofocus: true,但有些浏览器首次加载页面时焦点并不一定在这个 widget 上。如果用户发现方向键没反应,点一下页面区域,焦点获取到之后就能正常操作了。
3.5 完整 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)), scaffoldBackgroundColor: const Color(0xFFFAF8EF), ), home: const Game2048Page(), ); } } enum Direction { up, down, left, right } class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State<Game2048Page> createState() => _Game2048PageState(); } class _Game2048PageState extends State<Game2048Page> { static const int size = 4; late List<List<int?>> _board; late List<List<int?>> _backupBoard; final Random _random = Random(); int _score = 0; int _best = 0; int _backupScore = 0; bool _isGameOver = false; bool _isWin = false; Offset _dragOffset = Offset.zero; @override void initState() { super.initState(); _newGame(); } void _newGame() { _board = List.generate(size, (_) => List<int?>.filled(size, null)); _score = 0; _isGameOver = false; _isWin = false; _backupBoard = _cloneBoard(_board); _backupScore = 0; _addRandomTile(); _addRandomTile(); setState(() {}); } List<List<int?>> _cloneBoard(List<List<int?>> source) { return source.map((row) => [...row]).toList(); } String _boardKey(List<List<int?>> board) { return board.map((row) => row.join(',')).join('|'); } void _addRandomTile() { final empty = <(int, int)>[]; for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { if (_board[i][j] == null) empty.add((i, j)); } } if (empty.isEmpty) return; final (r, c) = empty[_random.nextInt(empty.length)]; _board[r][c] = _random.nextInt(10) < 9 ? 2 : 4; } List<int?> _slideRow(List<int?> row) { final nums = row.where((x) => x != null).toList(); final result = List<int?>.filled(size, null); int writeIdx = 0; int i = 0; while (i < nums.length) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { final merged = nums[i]! * 2; result[writeIdx] = merged; _score += merged; i += 2; } else { result[writeIdx] = nums[i]; i += 1; } writeIdx += 1; } return result; } void _transpose() { final next = List.generate(size, (_) => List<int?>.filled(size, null)); for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { next[i][j] = _board[j][i]; } } _board = next; } void _move(Direction dir) { if (_isGameOver) return; final beforeKey = _boardKey(_board); _backupBoard = _cloneBoard(_board); _backupScore = _score; if (dir == Direction.left) { _board = List.generate(size, (i) => _slideRow(_board[i])); } else if (dir == Direction.right) { _board = List.generate( size, (i) => _slideRow(_board[i].reversed.toList()).reversed.toList(), ); } else { _transpose(); if (dir == Direction.up) { _board = List.generate(size, (i) => _slideRow(_board[i])); } else { _board = List.generate( size, (i) => _slideRow(_board[i].reversed.toList()).reversed.toList(), ); } _transpose(); } final afterKey = _boardKey(_board); if (beforeKey == afterKey) { _board = _cloneBoard(_backupBoard); _score = _backupScore; return; } _addRandomTile(); if (_score > _best) { _best = _score; } _checkWin(); _checkGameOver(); setState(() {}); } void _checkWin() { if (_isWin) return; for (final row in _board) { if (row.contains(2048)) { _isWin = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showDialog<void>( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('你合成 2048 了!'), content: const Text('要不要继续冲更高分?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('继续游戏'), ), TextButton( onPressed: () { Navigator.pop(ctx); _newGame(); }, child: const Text('再来一局'), ), ], ), ); }); } } } void _checkGameOver() { if (_canMove()) return; _isGameOver = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; showDialog<void>( context: context, builder: (ctx) => AlertDialog( title: const Text('游戏结束'), content: Text('最终得分:$_score'), actions: [ TextButton( onPressed: () { Navigator.pop(ctx); _newGame(); }, child: const Text('重新开始'), ), ], ), ); }); } bool _canMove() { for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { if (_board[i][j] == null) return true; if (j < size - 1 && _board[i][j] == _board[i][j + 1]) return true; if (i < size - 1 && _board[i][j] == _board[i + 1][j]) return true; } } return false; } void _undo() { final beforeUndoKey = _boardKey(_board); if (beforeUndoKey == _boardKey(_backupBoard)) return; setState(() { _board = _cloneBoard(_backupBoard); _score = _backupScore; _isGameOver = false; _isWin = false; }); } void _handleSwipe() { final dx = _dragOffset.dx; final dy = _dragOffset.dy; if (dx.abs() < 20 && dy.abs() < 20) return; if (dx.abs() > dy.abs()) { _move(dx > 0 ? Direction.right : Direction.left); } else { _move(dy > 0 ? Direction.down : Direction.up); } _dragOffset = Offset.zero; } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowLeft) { _move(Direction.left); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight) { _move(Direction.right); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowUp) { _move(Direction.up); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown) { _move(Direction.down); return KeyEventResult.handled; } return KeyEventResult.ignored; } @override Widget build(BuildContext context) { return Focus( autofocus: true, onKeyEvent: _onKeyEvent, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (_) => _dragOffset = Offset.zero, onPanUpdate: (details) => _dragOffset += details.delta, onPanEnd: (_) => _handleSwipe(), child: Scaffold( body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ const Expanded( child: Text( '2048', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), ), _ScoreCard(label: 'SCORE', value: _score), const SizedBox(width: 8), _ScoreCard(label: 'BEST', value: _best), ], ), const SizedBox(height: 12), Row( children: [ const Expanded( child: Text( '方向键 / 滑动屏幕 开始游戏', style: TextStyle(color: Color(0xFF776E65)), ), ), OutlinedButton( onPressed: _undo, child: const Text('撤销'), ), const SizedBox(width: 8), FilledButton( onPressed: _newGame, child: const Text('新游戏'), ), ], ), const SizedBox(height: 16), AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(12), ), child: _buildBoard(), ), ), ], ), ), ), ), ), ), ), ); } Widget _buildBoard() { return LayoutBuilder( builder: (context, constraints) { final spacing = 8.0; final itemSize = (constraints.maxWidth - spacing * (size + 1)) / size; return GridView.builder( physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, itemCount: size * size, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: size, crossAxisSpacing: spacing, mainAxisSpacing: spacing, ), itemBuilder: (context, index) { final row = index ~/ size; final col = index % size; final value = _board[row][col]; return _Tile(value: value, size: itemSize); }, ); }, ); } } class _ScoreCard extends StatelessWidget { const _ScoreCard({required this.label, required this.value}); final String label; final int value; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, 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, fontWeight: FontWeight.w600, color: Color(0xFFEEE4DA), ), ), Text( '$value', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } } class _Tile extends StatelessWidget { const _Tile({required this.value, required this.size}); final int? value; final double size; Color _bgColor(int? value) { const colors = <int, Color>{ 2: Color(0xFFEEE4DA), 4: Color(0xFFEDE0C8), 8: Color(0xFFF2B179), 16: Color(0xFFF59563), 32: Color(0xFFF67C5F), 64: Color(0xFFF65E3B), 128: Color(0xFFEDCF72), 256: Color(0xFFEDCC61), 512: Color(0xFFEDC850), 1024: Color(0xFFEDC53F), 2048: Color(0xFFEDC22E), }; return colors[value] ?? const Color(0xFF3C3A32); } Color _fgColor(int? value) { if (value == null || value == 2 || value == 4) { return const Color(0xFF776E65); } return Colors.white; } @override Widget build(BuildContext context) { final valueText = value?.toString() ?? ''; final fontSize = valueText.length >= 4 ? 24.0 : (valueText.length >= 3 ? 28.0 : 34.0); return AnimatedContainer( duration: const Duration(milliseconds: 120), alignment: Alignment.center, decoration: BoxDecoration( color: _bgColor(value), borderRadius: BorderRadius.circular(8), ), child: Text( valueText, style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, color: _fgColor(value), ), ), ); } }这段代码可以直接替换到默认项目的lib/main.dart里,然后flutter run -d chrome就能玩。代码里最高分目前是内存保存,刷新页面会清零。想持久化的话,用shared_preferences插件存一下就行,改动很小。
4. Web 构建与部署
4.1 构建 release 版本
代码调通之后,下一步是构建可以在服务器上部署的静态文件。Flutter Web 的构建命令很简单:
flutter build web --release构建完成后,产物在项目的build/web目录下,里面是index.html、main.dart.js、flutter_bootstrap.js、assets等文件。你只要把这个目录扔给任意 Web 服务器就能跑。
这里有一个容易踩坑的地方:默认构建的index.html里的静态资源路径是根路径/。如果你打算部署到域名根路径,比如https://example.com/,那直接部署没问题。但如果你想部署成子路径,比如https://example.com/games/2048/,就必须在构建时指定 base href。Flutter 用的是--base-href参数:
flutter build web --release --base-href=/games/2048/不指定的话,部署到子路径后页面会白屏,控制台里一堆资源 404。这个问题我遇到过一次,当时还以为是 Nginx 配置写错了,排查了半天才发现是构建参数漏了。
Flutter 新版本默认使用 CanvasKit 渲染,如果你的目标用户浏览器较老,或者想降低 js 体积,可以考虑调整渲染器。老版本 Flutter 支持--web-renderer html或canvaskit参数,新版本这些参数在逐步废弃,渲染器的选择被内置逻辑接管了,比如 Impeller 渲染在 Web 端已经默认启用。普通项目通常不需要管渲染器,只需要知道构建体积会比以前大一点,加载速度会受点影响。
4.2 Nginx 部署多个 Web 项目
我在服务器上已经跑了几个 Web 项目,所以 2048 不是部署到独立域名,而是挂在一个已有域名的子路径下。这就涉及 Nginx 如何同时部署多个项目的问题。
基本原则是:一个server块对应一个域名,多个项目通过location路径区分。比如我的服务器上已经有主站https://example.com/,现在要把 2048 挂到https://example.com/2048/。构建时要带 base href:
flutter build web --release --base-href=/2048/然后把build/web目录整个上传到服务器,假设放到/var/www/2048。Nginx 配置这样写:
server { listen 80; server_name example.com; location /2048/ { root /var/www; try_files $uri $uri/ /2048/index.html; } }注意这里用的是root /var/www,不是alias。因为 URL 里的/2048/路径要直接映射到磁盘的/var/www/2048/目录,用root加上目录层级正好匹配。如果写成alias /var/www/2048;,路径拼接逻辑容易搞混,尤其是配try_files时,很多 404 就是这么来的。
try_files的这一行很关键,它的作用是:先尝试按 URL 找真实文件,如果找不到就回退到/2048/index.html。Flutter Web 是单页应用,虽然这个游戏没有前端路由跳转,但保留这个回退逻辑,以后如果你给它加路由功能,刷新页面就不会 404。
如果你还要挂第二个 Web 项目,比如另一个路径/tools/,就在同一个server块里再加一个location /tools/ { ... },规则完全相同。不同项目之间互不干扰,Nginx 也不需要重启,nginx -s reload即可。
4.3 Service Worker 与浏览器细节
Flutter Web 构建产物里有一个flutter_service_worker.js,它是 Flutter 自动生成的 Service Worker,用来做资源缓存和离线支持。浏览器要求 Service Worker 必须在安全上下文里才能注册,也就是https://或者localhost。如果你的站点是纯http://加 IP 访问,控制台会报告类似“could not register service worker”的警告,甚至报InvalidStateError。
遇到这种报错先别慌,分为两种情况处理:
- 如果只是控制台警告,游戏能正常玩、资源能加载,那可以暂时不管,等以后上了 HTTPS 就自动解决。
- 如果页面白屏或资源加载异常,优先检查是不是
base-href没配对,或者 Nginx 的try_files没生效。Service Worker 在这个场景下通常不是罪魁祸首。
还有一个常见的开发场景:你在localhost上跑flutter run -d chrome,刚改完代码,浏览器还是旧版本资源。这是因为 Service Worker 缓存了上次的资源。我的经验是,开发调试时打开 DevTools 的 Network 面板,勾选 Disable cache;如果实在不行,清一下 site data,或者开一个无痕窗口,避免缓存问题干扰排错。
5. 常见问题与排错实录
我这次开发过程中遇到了不少问题,有些是 Flutter 本身的,有些是 Trae 生成代码后引入的,还有一些直接来自 Web 部署。我整理了一个速查表,方便你对照排查:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
flutter命令找不到 | 环境变量没配置或终端没重开 | 重新配置 PATH,重启终端再试 |
flutter create生成大量 Android/iOS 文件 | 没指定平台参数 | 用flutter create --platforms=web .重新生成 |
| 浏览器运行后白屏 | base-href与部署路径不一致 | 构建时加--base-href=/你的路径/ |
| 页面部署后刷新 404 | Nginx 没有 SPA 回退 | 加try_files $uri $uri/ /index.html; |
| 键盘方向键没反应 | Focus 没有获得焦点 | 点击页面任意位置后再按方向键 |
| 滑动触摸不灵敏 | 阈值设置偏大或累计位移偏小 | 把阈值从 20 调低到 10,或者改用onPanUpdate累计位移 |
| 控制台报 could not register service worker | HTTP 非安全上下文 | 换 HTTPS 或 localhost,警告可暂时忽略 |
| 联网时构建很慢 | 依赖下载慢 | 配置可访问的镜像源 |
| 最高分刷新后丢失 | 没有持久化存储 | 接入shared_preferences保存最高分 |
还有一个我特别想提醒的坑,就是那个“you are applying flutter's main gradle plugin imperatively using the apply method”的报错。如果你和我一样只做 Web 端,这个报错基本不用理会,因为它只出现在 Android 构建流程里。但如果你把 AI 生成的代码拿到老项目上跑,恰好老项目是完整的 Flutter 工程,就可能在构建 Android 时看到这个错误。网上的解法主要是在settings.gradle里改用 plugin management 的方式声明 Gradle 插件,属于 Flutter 老工程升级的兼容性问题。我的建议是:纯 Web 项目直接用--platforms=web创建,别把不参与构建的平台目录也一起生成,能省掉大量无关麻烦。
另外,用 Trae 开发时有一个小技巧:如果它生成的代码在编译时报错,不要只把报错信息丢给它,最好顺便选中相关代码文件再提问,让它能结合上下文分析。比如我遇到过一次棋盘数组越界的报错,直接问它“为什么越界”,它只是泛泛地讲数组索引知识;后来我把lib/main.dart文件加进对话,它立刻指出是_transpose方法在遍历时引用了正在被覆盖的棋盘数组,需要先建一个新数组再交换。这说明 AI IDE 的上下文引用能力是核心优势,你得学会用。
写在最后
整套做完,我最大的体感是 AI IDE 把“从想法到可运行代码”的路径缩短了一大截。但我也强烈建议,别让 Trae 一口气生成一个 500 行的 main.dart 就撒手不管。我试过让它把所有功能一次写完,结果是逻辑能跑但结构非常难读;改成先出棋盘、再出滑动、最后补 UI 和撤销,每一步都只让它改当前模块,代码质量明显高很多。
后期扩展也很简单。想加平滑的方块滑动动画,可以让 Trae 帮你把_Tile换成带AnimatedPositioned的布局;想加本地排行榜,让它在_move完成后把最高分写入shared_preferences;想换主题色,直接改ColorScheme.fromSeed的参数。这个项目最有价值的地方不是 2048 本身,而是你能在一次完整的实践中,同时摸清 Flutter Web 的工程流程、AI IDE 的工作方式和 Web 部署的底层原理。