在终端敲下flutter create flutter_2048,我用 Trae 这个 AI 编程 IDE 把经典小游戏《2048》从零实现了出来,目标平台锁定 Flutter Web——也就是打开浏览器就能玩、不需要安装任何客户端的 Web 小游戏。从环境准备到最终部署上线,整个流程跑下来比我预想中顺畅得多。
这篇内容会把三件事讲透:第一,Trae 和 Flutter Web 开发环境怎么搭配、有哪些坑要提前避开;第二,2048 的核心算法完整拆解,包括最容易出错的移动合并逻辑;第三,用 AI 辅助编码(也就是大家常说的 vibe coding)开发完整项目的真实体验和最终代码。如果你正好想用一个小游戏入门 Flutter,又想看看 Trae 这类 AI IDE 到底能把开发效率提升到什么程度,这篇文章就是为你准备的。
1. 项目概述:为什么选 Trae + Flutter Web + 2048
1.1 这个组合能解决什么问题
Flutter 在移动端已经非常成熟,但很多人不知道它的 Web 能力这两年也突飞猛进。Flutter Web 会把 Dart 代码编译成 JavaScript 或 WebAssembly,最终输出一套纯静态资源,扔到任意 Web 服务器上就能访问。用同一套代码以后还能编到 Android、iOS、Windows、macOS,这种“一次编写、多端运行”的特性,对小游戏和个人项目来说性价比极高。
Trae 是字节跳动推出的 AI 原生 IDE,底层兼容 VS Code 的生态,界面、快捷键、扩展机制几乎不用重新学。它内置了 AI 对话、代码生成、代码解释、乃至自动修改文件的能力。跟传统“搜索代码片段再手动粘贴”的方式不同,Trae 的 Builder 模式可以直接根据描述生成完整文件,也可以选中一段代码让它重构、解释、补全。这对开发一个小型游戏项目来说非常合适,因为 2048 这种项目既有清晰的算法逻辑,又有不少重复的 UI 代码,正是 AI 最擅长的部分。
选择 2048 这个游戏还有一个实际考虑:它的核心规则不复杂,但足够有代表性。棋盘数据结构、方向变换、数组合并、状态刷新、用户输入监听、弹窗交互,这些几乎是所有前端小项目都会遇到的通用能力。做完这个项目,你对 Flutter 的 StatefulWidget 生命周期和 setState 刷新机制会有一个非常直观的理解。
1.2 2048 游戏机制与开发难点拆解
2048 的规则一句话就能说清:4x4 的棋盘,每轮在空白格子随机生成一个 2 或 4,玩家通过上下左右滑动让所有方块朝指定方向移动,相邻且相同的数字会合并为两者之和,分数累加。当棋盘上出现 2048 时游戏胜利,当棋盘被填满且任意两个相邻格子都不相等、无法再合并时游戏结束。
从开发角度拆解,难点集中在三块:第一,四个方向的移动看似不同,但如果为每个方向单独写一套逻辑,代码会变得又臭又长且极易出错;第二,合并操作必须遵循“同一次移动中每个格子只能合并一次”的规则,否则会出现连锁合并的错误结果;第三,Web 端的用户输入既包括键盘方向键,也包括触屏滑动,两者需要统一到同一套移动逻辑上。这三个问题在后面的章节里都会有对应的解决方案。
2. 环境准备:Trae + Flutter Web 开发环境搭建
2.1 Flutter SDK 安装与版本管理
Flutter 的安装过程网上资料很多,我讲几个容易忽略的细节。首先是下载渠道,直接去 Flutter 官方站点下载对应操作系统的 SDK 压缩包即可。解压路径有讲究,macOS 建议放在~/development这类用户目录下,Windows 建议放在C:\src这类不含空格的路径,避免以后编译时出现路径解析问题。
解压后需要把flutter/bin目录加入系统 PATH 环境变量。配置完成后在终端执行flutter doctor,它会自动检查 Flutter 运行所需的所有依赖。如果看到Chrome - develop for the web这一项正常,说明 Flutter Web 的开发环境已经就绪。
版本选择方面,我建议直接使用 stable 分支的最新稳定版,不需要追新。如果你手上同时有好几个 Flutter 项目,不同项目可能依赖不同版本的 SDK,这时可以考虑用 FVM(Flutter Version Manager)来管理多版本。FVM 的好处是每个项目目录下可以锁一个.fvmrc文件记录版本号,切项目时自动切换对应 SDK,避免“升级 Flutter 后老项目突然跑不起来”这种尴尬情况。对于 2048 这种独立小项目,FVM 不是必需品,但如果你打算长期做 Flutter 开发,建议提前用起来。
2.2 Trae 安装与 AI 能力配置
Trae 从官网下载安装,支持 Windows 和 macOS。装好后第一次打开,界面跟 VS Code 几乎一模一样,左侧活动栏、顶部菜单、底部状态栏,熟悉 VS Code 的人半小时内就能适应。
进入 Trae 后有几项配置值得花点时间。首先是登录账号,Trae 的 AI 功能依赖云端模型,不登录是无法使用的。登录后你会获得一定数量的免费积分,每次 AI 对话和代码生成会消耗少量积分,日常小项目的开发量基本够用。如果后续积分不够,Trae 社区偶尔会有积分兑换码活动,可以留意官方渠道。
其次是配置 Flutter SDK 路径。在 Trae 的设置面板里搜索dart或flutter,把 Flutter SDK 目录指定好。这样内置终端、调试器、代码分析器才能正确识别 Flutter 命令。配置完成后,在 Trae 的终端里执行flutter --version,输出正常就说明环境打通了。
Trae 的 AI 对话窗口里有两种模式需要区分:Ask 模式适合提问,比如“这个 merge 函数哪里写得不对”;Builder 模式适合让它直接干活,比如“帮我把 main.dart 改成支持键盘方向键的页面”。Builder 模式下 AI 不仅能生成代码,还会自动修改文件甚至创建新文件,这个能力在项目初始化阶段特别有用。
2.3 创建 Flutter Web 项目骨架
在 Trae 内置终端执行flutter create flutter_2048,几秒钟后项目就创建好了。这个命令会生成完整的 Flutter 工程结构,其中web/目录存放 Web 平台的入口文件,lib/目录存放 Dart 源码。
创建完后先跑一次flutter run -d chrome,确认基础工程能在 Chrome 里正常打开。第一次运行会编译整个 Flutter 引擎相关的代码,通常要等几十秒甚至更久,看到浏览器窗口弹出熟悉的 Flutter 计数 Demo 就说明链路没问题。
这里有个经验:不要在创建项目后立刻就用 AI 大改特改,先把默认工程跑通,确认环境完全正常,再进入 AI 辅助开发阶段。这样一旦后续出现奇怪的问题,至少能确定不是环境本身的原因。
3. 核心算法解析:2048 的移动与合并实现
3.1 棋盘数据结构与状态设计
2048 的棋盘本质就是一个 4x4 的二维数组。我在代码里用List>表示,0 代表空格子,其他数字代表对应方块的值。选择这个数据结构的原因很简单:直观、好调试、跟 UI 渲染天然对应。
除了棋盘本身,游戏状态还需要记录三个信息:当前分数score、是否已经达成 2048(hasWon)、是否游戏结束(isGameOver)。这三个状态会在 UI 层直接驱动界面更新。
在 Flutter 里不需要引入 Provider、Bloc 这类重量级状态管理框架,直接用 StatefulWidget 的setState就足够了。每次用户滑动后调用setState刷新整个页面,棋盘数据变了,UI 跟着变,简单直接。
3.2 用一个变换技巧处理四个方向的移动
处理四个方向的移动,笨办法是写四套逻辑,每套都基于数组遍历和边界判断。但有一个更优雅的技巧:通过“转置”和“翻转”操作,把四个方向的移动统一成“向左移动”这一种。
具体来说,定义两个基本操作:
transpose:将矩阵转置,即行列互换,grid[r][c]变为grid[c][r]reverseRows:将每一行的元素顺序反转,[1,2,3,4]变为[4,3,2,1]
有了这两个操作,四个方向可以这样实现:
方向变换对照表
| 方向 | 变换步骤 |
|---|---|
| 向左移动 | 直接对每行执行“向左合并” |
| 向右移动 | 每行反转,执行“向左合并”,再反转回来 |
| 向上移动 | 矩阵转置,执行“向左合并”,再转置回来 |
| 向下移动 | 矩阵转置,每行反转,执行“向左合并”,再反转、转置回来 |
这个技巧最直接的好处是核心算法只维护一份。以后想改进合并逻辑、调整计分规则,只需要改一个函数,四个方向全部同步生效。
3.3 合并算法与“一次合并”约束
向左合并一行是整个算法的核心。以一行[2, 2, 4, 0]为例,合并过程分三步:
- 过滤掉所有 0,得到
[2, 2, 4] - 从左到右扫描,如果当前数字与下一个数字相同,则合并为两者之和,同时跳过一个元素
- 在数组尾部补 0,恢复到 4 个长度
第 2 步中的“跳过一个元素”非常关键,它保证了一次移动中每个格子只能参与一次合并。来看一个容易出错的例子:[2, 2, 2, 2],正确合并结果是[4, 4, 0, 0],而不是[8, 0, 0, 0]。如果不用跳跃索引,单纯遍历合并,就会出现后两个 2 继续与前面的 4 合并的连锁错误。
每一步的索引细节也很值得注意。从过滤后的列表开始,用idx表示结果数组当前写入位置,用i表示扫描位置。遇到相邻相同数字时,把合并结果写入第idx位,然后i额外跳一次;否则直接写入第idx位。这种写法既简洁又准确。
3.4 随机方块生成与胜负判定
每次有效移动后,需要在空白格子中随机生成一个新的方块。生成位置从所有值为 0 的格子中随机挑选,新方块的值按概率设定,90% 是 2,10% 是 4,这个比例跟原版游戏保持一致。
胜负判定分两部分。胜利很好判断:只要任一格子合并出 2048,立刻标记为胜利。失败判定稍微复杂一点:首先要检查整个棋盘是否已满,如果还有空格,游戏一定没结束;如果已满,再检查是否存在任意两个相邻格子数值相等,只要有一对相邻且相等,说明还能合并,游戏继续。只有当棋盘完全填满且所有相邻格子的值都不一样时,游戏才算真正结束。
4. 完整代码落地:Trae 辅助下的项目实施
4.1 用 Trae AI 生成第一版核心逻辑
我实际开发时,并没有直接手动敲完整份代码,而是先在 Trae 的 AI 对话窗口里描述需求,让 Builder 模式生成第一版。当时的提示词大概是这样的:
帮我写一个 Dart 类 Game2048,实现 4x4 的 2048 游戏逻辑,包括 startNewGame 初始化、move 方法支持上下左右四个方向移动、合并规则是相邻相同数字合并成两倍并加分、每次移动后在空白格子随机生成 2 或 4,还要有 isGameOver 和 hasWon 的判定。
AI 很快生成了一份可编译的代码。但这里要强调一个经验:AI 生成的代码能跑,不代表逻辑完全正确。我检查后发现它的合并逻辑没有处理“一次只能合并一次”的约束,[2,2,2,2]被合并成了[8,0,0,0]。这就是算法核心约束需要自己把好关的原因。
发现问题后,我在 Trae 里选中那段合并代码,补充了一句:
合并时同一个元素只能合并一次,比如 [2,2,2,2] 应该合并成 [4,4,0,0],请修正 _mergeRow 方法。
AI 很快就给出了带跳跃索引的修正版本。这种“人提需求、AI 写码、人审核心逻辑、AI 修改”的循环,是我实践中效率最高的 AI 辅助编程方式。
4.2 游戏逻辑层完整代码
经过修正和整理,最终的游戏逻辑层game_logic.dart如下:
import 'dart:math'; class Game2048 { static const int size = 4; List> grid = []; int score = 0; bool _hasWon = false; bool _isGameOver = false; final Random _random = Random(); bool get hasWon => _hasWon; bool get isGameOver => _isGameOver; Game2048() { startNewGame(); } void startNewGame() { grid = List.generate(size, (_) => List.filled(size, 0)); score = 0; _hasWon = false; _isGameOver = false; _addRandomTile(); _addRandomTile(); } void _addRandomTile() { List<(int, int)> emptyCells = []; for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (grid[r][c] == 0) { emptyCells.add((r, c)); } } } if (emptyCells.isEmpty) return; final cell = emptyCells[_random.nextInt(emptyCells.length)]; grid[cell.$1][cell.$2] = (_random.nextInt(10) < 9) ? 2 : 4; } // 方向: 0=左, 1=右, 2=上, 3=下 bool move(int direction) { final original = grid.map((row) => List.from(row)).toList(); int gained = switch (direction) { 0 => _moveLeft(), 1 => _moveRight(), 2 => _moveUp(), 3 => _moveDown(), _ => 0, }; final moved = !_isSame(original, grid); if (moved) { score += gained; _addRandomTile(); _checkGameStatus(); } return moved; } int _moveLeft() { int gained = 0; for (int i = 0; i < size; i++) { final (newRow, rowGain) = _mergeRow(grid[i]); grid[i] = newRow; gained += rowGain; } return gained; } int _moveRight() { int gained = 0; for (int i = 0; i < size; i++) { final reversed = grid[i].reversed.toList(); final (newRow, rowGain) = _mergeRow(reversed); grid[i] = newRow.reversed.toList(); gained += rowGain; } return gained; } int _moveUp() { transpose(); final gained = _moveLeft(); transpose(); return gained; } int _moveDown() { transpose(); final gained = _moveRight(); transpose(); return gained; } void transpose() { final newGrid = List.generate( size, (r) => List.generate(size, (c) => grid[c][r]), ); grid = newGrid; } (List, int) _mergeRow(List row) { final tiles = row.where((t) => t != 0).toList(); final result = List.filled(size, 0); int idx = 0; int gained = 0; for (int i = 0; i < tiles.length; i++) { if (i + 1 < tiles.length && tiles[i] == tiles[i + 1]) { final merged = tiles[i] * 2; result[idx++] = merged; gained += merged; if (merged == 2048) _hasWon = true; i++; } else { result[idx++] = tiles[i]; } if (idx >= size) break; } return (result, gained); } void _checkGameStatus() { _isGameOver = grid.every((row) => row.every((t) => t != 0)); if (!_isGameOver) return; for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (r + 1 < size && grid[r][c] == grid[r + 1][c]) { _isGameOver = false; return; } if (c + 1 < size && grid[r][c] == grid[r][c + 1]) { _isGameOver = false; return; } } } } bool _isSame(List> a, List> b) { for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (a[r][c] != b[r][c]) return false; } } return true; } }这段代码里有几个细节需要特别说明。_mergeRow返回的是一个 record 类型(List, int),第一个元素是合并后的行,第二个元素是这一行新增的分数。游戏整体得分不是边合并边加到score上,而是先临时累积,等确认这次移动真的改变了棋盘状态后,才统一累加。这样处理避免了“重复滑动但棋盘没变化时分数却增加”的逻辑漏洞。
另一个细节是transpose实现。Dart 的列表生成语法配合List.generate可以让转置逻辑一行完成,这个函数在上下方向的移动中每次调用两次,性能开销非常小,完全不用担心。
4.3 界面渲染与交互完整代码
UI 部分我用一个StatefulWidget承载整个游戏页面。棋盘通过GridView.builder渲染,每个格子是一个独立的_Tile组件,根据数值显示不同的背景色和字号。整个 UI 代码放在main.dart中:
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'game_logic.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '2048', debugShowCheckedModeBanner: false, theme: ThemeData( scaffoldBackgroundColor: const Color(0xFFFAF8EF), useMaterial3: true, ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); @override State createState() => _GamePageState(); } class _GamePageState extends State { final Game2048 _game = Game2048(); bool _wonShown = false; void _move(int direction) { setState(() { _game.move(direction); }); if (!_wonShown && _game.hasWon) { _wonShown = true; _showEndDialog(win: true); } else if (_game.isGameOver) { _showEndDialog(win: false); } } void _restart() { setState(() { _game.startNewGame(); _wonShown = false; }); } void _showEndDialog({required bool win}) { showDialog( context: context, builder: (context) => AlertDialog( title: Text(win ? '你赢了!' : '游戏结束'), content: Text(win ? '成功合成 2048' : '棋盘已满,无法继续移动'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); _restart(); }, child: const Text('再来一局'), ), ], ), ); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 24), _buildBoard(), ], ), ), ), ), ); } Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 56, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '得分', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: Text( '${_game.score}', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), const SizedBox(height: 8), ElevatedButton( onPressed: _restart, child: const Text('重新开始'), ), ], ), ], ); } Widget _buildBoard() { return KeyboardListener( autofocus: true, focusNode: FocusNode(), onKeyEvent: (KeyEvent event) { if (event is KeyDownEvent) { final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowLeft) _move(0); if (key == LogicalKeyboardKey.arrowRight) _move(1); if (key == LogicalKeyboardKey.arrowUp) _move(2); if (key == LogicalKeyboardKey.arrowDown) _move(3); } }, child: GestureDetector( onPanEnd: (details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { _move(dx > 0 ? 1 : 0); } else { _move(dy > 0 ? 3 : 2); } }, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(12), ), child: GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, crossAxisSpacing: 10, mainAxisSpacing: 10, ), itemCount: 16, itemBuilder: (context, index) { final value = _game.grid[index ~/ 4][index % 4]; return _Tile(value: value); }, ), ), ), ); } } class _Tile extends StatelessWidget { final int value; const _Tile({required this.value}); @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: _tileColor(value), borderRadius: BorderRadius.circular(8), ), child: value == 0 ? null : Text( '$value', style: TextStyle( fontSize: value >= 1024 ? 20 : 28, fontWeight: FontWeight.bold, color: value <= 4 ? const Color(0xFF776E65) : Colors.white, ), ), ); } Color _tileColor(int v) { switch (v) { case 0: return const Color(0xFFFFFFFF); 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); } } }4.4 键盘与手势输入的实现经验
在 Web 端处理输入比移动端稍微麻烦一点,因为浏览器本身的默认行为会跟你抢事件。上下左右方向键默认会滚动页面,如果不处理,页面就会出现上下跳动的现象。
我的做法是在棋盘外层套一个KeyboardListener,同时设置autofocus: true,让页面加载后焦点自动落在这个监听器上。这样方向键事件会优先被 Flutter 捕获。onKeyEvent里判断KeyDownEvent和对应的LogicalKeyboardKey,转换成移动方向的数字调用_move。
手势操作方面,通过GestureDetector的onPanEnd获取松手时的滑动速度向量,比较横向和纵向速度绝对值,就能判断用户意图是左右滑还是上下滑。用速度向量而不是位移向量做的原因是:用户快速滑动时,位移往往不大,但速度方向非常明确,判断会更准确。
需要注意的关键点是棋盘本身要禁用滚动,GridView的physics必须设为NeverScrollableScrollPhysics,否则手势操作时页面会跟着滚动。
5. 实战排查:这个项目我踩过的坑
5.1 Flutter Web 的 Service Worker 报错
运行flutter run -d chrome时有概率在控制台看到这样一行报错:
Error: Could not register Service Workers: InvalidStateError这个报错在 Chrome 的无痕模式、部分低版本浏览器、以及 HTTP 非安全环境下很常见。Flutter Web 默认会注册一个 Service Worker 用于缓存静态资源,但浏览器对 Service Worker 有严格的安全限制,不符合条件时就会注册失败。
开发阶段的处理方式很简单:这个报错不影响本地调试,可以忽略。发布到线上时,只要站点通过 HTTPS 提供访问,Service Worker 就能正常注册。如果内部测试环境只能用 HTTP,也可以在web/index.html中把 Service Worker 相关代码注释掉,代价是失去离线缓存能力。
5.2 合并算法里最常见的逻辑错误
我在用 AI 生成和人工审查代码时,发现 2048 合并逻辑的错误率非常高。最常见的错误就是一开始提到的连锁合并问题:[2,2,2,2]被错误地合并成[8,0,0,0]。这个问题出现的根本原因是遍历逻辑没有记录“当前元素是否已经参加过合并”。
第二个常见的坑是“合并后没有跳过被合并的元素”,导致同一个元素被反复使用。比如[4,4,8],正确结果是[8,8,0],但错写的话可能变成[16,0,0]。
第三个坑发生在右移和下移时。如果你对行做了反转后再处理,一定要记得再反转回来,而且反转操作必须作用在合并后的结果上,不是处理前的原数组上。我见过有同学在调试右移时发现数据乱了,最后定位到是反转的时机不对。
5.3 键盘滚动冲突与焦点丢失
方向键控制方块移动时,如果页面也跟着滚动,体验非常糟糕。除了上面提到的KeyboardListener,另一个需要注意的点是焦点问题:FocusNode如果被回收,键盘监听就失效了。
我遇到过这样的情况:用户点击了重新开始按钮,按钮获取了焦点,此时方向键事件全部被按钮消费掉了,棋盘不再响应。解决办法是在按钮点击后调用FocusScope.of(context).requestFocus(FocusNode())让焦点重新回到棋盘,或者在FocusNode上添加监听,切换焦点时自动抢回来。最简单粗暴的做法是把整个Scaffold包在Shortcuts和Actions里,用系统级的方式处理键盘事件,这个方案对焦点问题更免疫。
5.4 Windows 上遇到 Visual Studio toolchain 报错
如果你在 Windows 上开发 Flutter,并且曾经尝试构建过 Android 版本,可能会遇到类似Unable to find suitable Visual Studio toolchain的报错。这个报错其实跟 Flutter Web 无关,它发生在构建 Android 原生插件时,系统在查找 Visual Studio 的 C++ 开发工具链。
如果你这个项目只做 Web 目标,完全可以忽略这个报错。但如果你想在同一台机器上同时构建 Android 版本,解决方案是安装 Visual Studio 时勾选“使用 C++ 的桌面开发”工作负载,或者安装 Visual Studio Build Tools。这个跟 Flutter Web 本身没有关系,属于跨端开发的常见问题。
5.5 常见问题速查表
| 问题现象 | 出现场景 | 解决思路 |
|---|---|---|
| CanvasKit 加载黑屏 | 老版本浏览器 | 升级浏览器,或启用 WebGL |
| Service Worker 注册失败 | HTTP 环境、无痕模式 | 线上 HTTPS,本地可忽略 |
| 方向键滚动页面 | 键盘监听失效 | 使用 KeyboardListener 并处理焦点 |
| 棋盘动画卡顿 | 低端设备 | 减少 setState 频率、使用 RepaintBoundary |
| 移动端无法滑动 | 手势与滚动冲突 | GridView 禁用滚动 |
6. 优化与上线:让游戏真正跑给用户看
6.1 构建体积与内存调优
Flutter Web 一直被诟病的一点是包体积偏大。这是因为默认的 CanvasKit 渲染引擎会额外加载一份 WebAssembly 格式的引擎资源。对于一个 2048 这种小体量的游戏来说,这个体积其实可以接受,但如果你想进一步优化,有几个方向可以尝试。
第一,确保使用 release 模式构建。flutter build web --release会开启 tree shaking,移除未使用的代码,产物会比 debug 模式小很多。第二,不要随意引入第三方包,每加一个依赖都会增加最终产物的体积。我这个项目就是纯 Flutter,没有任何第三方依赖。第三,如果游戏逻辑中有大量 CPU 密集型计算,比如给 2048 写一个 AI 自动求解器,建议用Isolate或compute把计算任务放到后台执行,避免阻塞 UI 线程造成卡顿。不过对于普通玩家手动操作,2048 本身的计算量很小,完全不需要这个优化。
内存方面,Flutter Web 使用 CanvasKit 时会在浏览器里初始化一个 WebGL 上下文,这部分内存消耗相对固定。2048 的方块数量最多 16 个,每格只是一个带颜色的 Container,内存占用很低。真正需要注意的反而是不要把整个游戏页面塞进一个过度复杂的 Widget 树里,尽量用const构造函数减少不必要的重建。
6.2 部署到任意 Web 服务器
构建完成后,build/web目录下会生成完整的静态站点文件,包括index.html、main.dart.js以及assets目录。部署方式非常简单,把这个目录上传到任意 Nginx、Apache、GitHub Pages 或者对象存储的静态网站托管里就能访问。
以 Nginx 为例,站点根目录指向build/web后,默认配置就能跑。Flutter Web 默认使用 hash 路由,URL 都在#之后,所以不需要额外的 try_files 配置。如果你用的是自己定义的 history 路由,那就需要加上try_files $uri $uri/ /index.html;。
还有一个容易踩的细节是index.html里的。如果你把游戏部署在子目录下,比如https://example.com/games/2048/,就需要把 base href 改成/games/2048/,否则静态资源会加载失败。
6.3 基于这个项目的下一步扩展
2048 做出来了,如果想继续深入 Flutter,我强烈建议在这个基础上加几个功能。第一个是最高分持久化,使用 localStorage 或shared_preferences插件把历史最高分存下来,每次打开游戏都能看到之前的记录。第二个是移动动画,目前我的实现是瞬间更新棋盘,你可以使用AnimatedContainer或AnimationController让方块移动和合并过程有平滑的过渡效果。第三个是深色主题,2048 的经典配色在暗色背景下别有一番味道。
如果你对算法感兴趣,还可以给游戏写一个 AI 自动玩的功能。使用蒙特卡洛树搜索或简单的启发式策略,让 AI 替你冲高分,这个过程会涉及大量计算,正好可以用到 Flutter 的Isolate来避免 UI 卡顿。
最后分享一个小技巧:开发阶段固定端口启动非常有用。用flutter run -d chrome --web-port 8080启动后,同一局域网里的其他人可以直接通过http://你的IP:8080访问你的开发页面,手机上也能直接试玩,比每次构建部署再看快太多了。我在整个开发过程中一直在用这个方式投屏给朋友体验,反馈速度和调试效率都提升了不少。