前阵子我给自己定了个小目标:完全用 Trae 从装 SDK 到上线一个能玩的网页小游戏,选来选去最后挑了 2048。为什么是它?因为 2048 虽然玩法简单,但该有的东西一样不少——4x4 棋盘状态、移动合并算法、键盘和触屏输入、动画反馈、计分与胜负判定,把这一套跑通,基本等于把 Flutter Web 的常规开发路径完整走了一遍。这篇文章就是这次实践的全程记录,包括环境搭建、核心算法、界面交互、构建部署和踩坑实录,最后附上可以直接复现的完整代码。
项目本身不复杂,但如果你是想拿 Flutter Web 练手、或者想看看 AI 编程工具到底能不能真正从 0 到 1 干完一个小项目,那这篇笔记应该能帮你少走不少弯路。我会把每个关键选择背后的原因也讲清楚,不只是“照着做”,而是知道为什么要这么做。
1. 项目选型与整体设计思路
1.1 为什么选 2048 作为练手项目
很多人一提到用新技术做项目,上来就搞 todo list(待办清单)、博客系统,老实说这类 CRUD 项目对 Flutter 这种带自绘渲染引擎的框架来说太浪费了。2048 是一个状态密集型游戏:整个画面只有 16 个格子,所有玩法都建立在“棋盘状态”的读和写之上,天然适合验证一个框架的响应式渲染能力。
从工程角度看,2048 涵盖了三个关键模块:核心逻辑层(棋盘数据、移动规则、胜负判断)、输入层(键盘、鼠标拖拽、触屏滑动)、表现层(格子颜色、分数滚动、弹窗)。这三层互相独立又能串成完整闭环,用来练手 Flutter 的 ChangeNotifier 状态管理和 Widget 树组织非常合适。你写完这个项目,对 Flutter 的“数据驱动 UI”这套机制会有很直观的体感。
另外一点,2048 的规则虽然简单,但移动合并算法里有个经典坑:连续合并问题。比如一行是 [2, 2, 2, 2],左移后应该是 [4, 4, 0, 0],而不是 [8, 0, 0, 0]。这个细节特别适合用来检验 AI 生成的代码是不是真的理解规则,我后文会详细拆。
1.2 为什么选 Flutter Web 而不是其他方案
Flutter 最初是移动端框架,但从 3.0 开始 Web 已经是完全一等公民。跨端能力当然是它的优势,我更看重的是它的渲染路径:Flutter Web 默认用 CanvasKit(Skia 的 WebAssembly 版本)在 Canvas 上绘制 UI,和传统 DOM 方案的体验完全不同。同一套代码放到 Web 上,动画流畅度、像素级还原度都能和移动端保持一致,这对游戏类项目很关键。
代价也很直接:首包体积偏大。2048 这种小项目编译出来 main.dart.js 加上 CanvasKit 也要 2~3MB,首次加载在弱网环境会有明显等待。所以真实场景里 Flutter Web 更适合工具类、可视化大屏、内部系统这类对首屏性能不极端的场景,但用来做小游戏完全够用。新版本的 Impeller 渲染引擎也正在逐步改善这部分体验,Web 端性能后续还有提升空间。
还有一个实际原因:Flutter Web 开发调试链路短。热重载(Hot Reload)在浏览器里几乎实时生效,写完一个 Widget 马上能看到变化,和写 React 的体验差不多。这种即时反馈对新手和 AI 协作开发都很友好。
1.3 为什么用 Trae,以及它的定位
Trae 是字节跳动推出的 AI IDE,国内有自己的版本,内置了对话式 AI 助手和 Build 模式。简单说,Chat 模式适合边写边问、生成片段、解释代码;Build 模式可以直接下指令让它帮你创建或重构多个文件,输出整块功能。它还能接入 MCP 这类外部工具生态,不过这个项目用不到。
我这次刻意把 Trae 当成“主力开发者”来用——不是辅助补全,而是让它从创建项目、写核心逻辑到修 bug 全程参与。实际干下来我的结论是:AI 适合干“脚手架”和“样板代码”的活儿,比如创建 Flutter 项目、搭页面布局、写颜色映射这类高度模式化的代码,效率非常高;但涉及核心算法和交互细节时,你必须有足够的能力去 review 它生成的东西,否则表面跑通了,规则细节上会翻车。这篇博文里我会把 AI 生成代码后被修正的几处地方全部摆出来,都是真实记录。
2. 环境搭建与 Web 工程初始化
2.1 Flutter SDK 安装与 FVM 版本管理
不管用不用 Trae,Flutter 环境本身得先装好。最简单的方式是去 Flutter 官网下载对应系统的 SDK 压缩包,解压后把bin目录加到系统 PATH 里。Windows 用户注意,下载 zip 包后不要放在需要管理员权限的目录,否则后续各种写操作会出权限问题;macOS 用户我习惯放在~/development/下面,干净。
但这里我强烈建议你直接上 FVM(Flutter Version Management)。为什么?Flutter 版本迭代太快,不同项目依赖的 SDK 版本可能不一样,系统全局只有一个版本的话,换项目就要改 PATH,特别痛苦。FVM 可以按项目锁定版本,还能随时切换。安装方式用包管理器一行命令:
# macOS / Linux 用 brew brew install fvm # 然后安装指定版本 fvm install 3.24.0 fvm use 3.24.0 # 初始化项目时用 fvm flutter 代替 flutter fvm flutter doctor初始化项目在 Trae 的终端里执行下面这串命令:
fvm flutter create --platforms web,android,windows --org com.example game2048--platforms是我建议加上的,只创建你需要的平台目录,避免生成一堆用不上的 iOS、macOS 目录让工程显得混乱。这里只开了 web、android、windows,意味着后续可以只在 Web 端跑,也可以顺手用 Flutter 的桌面和移动端能力扩展,这也是 Flutter 跨端的价值所在。
创建完项目后,进入目录执行fvm flutter doctor,确认 Web 工具链可用。正常情况下你会看到 Chrome 和 DevTools 都处于可用状态。如果你电脑没装 Chrome,开发 Web 项目会缺一个调试浏览器,提前装好。
2.2 Trae 安装与 IDE 配置
Trae 的下载和安装没什么特殊步骤,官网对应自己系统下载安装包,装完登录账号激活即可。首次进入后建议先做两件事:第一,在设置里确认 AI 模型是启用状态,Trae 新用户会送一些免费的 AI 对话积分,日常开发够用一阵子,用完之后可以参加官方活动获取积分兑换码续上。第二,把 Flutter SDK 路径配置到 IDE 的设置项里,这样 Trae 就能正确识别 Flutter 项目并调用 SDK 命令。
我用下来觉得 Trae 最好用的是 Build 模式和 Diff 预览。Build 模式下,你让它“把项目改成 XXX 结构”,它会直接改多个文件,而且每个改动都会以 diff 形式展示出来,你可以单行接受或拒绝。这种模式比纯对话生成代码再手动粘贴高效得多,也安全得多。你可以把 AI 当结对编程的搭档,它出草稿,你掌控合并权。
需要注意一个使用习惯:AI 的上下文窗口是有限的,对话过长会导致它“忘记”前面的代码上下文。我建议把大任务拆成小任务,每完成一个模块就让它先停下,你看完代码确认没问题再继续下一个模块。这样可以明显减少 AI 改错、改漏的概率。
2.3 创建 Flutter Web 工程后的文件规划
fvm flutter create生成的 lib 目录默认只有一个main.dart,里面有示例计数器程序。我直接把项目结构规划成了三个文件,避免把所有代码堆在一个文件里:
lib/ ├── main.dart # 入口、整体 UI、游戏面板 ├── game_controller.dart # 棋盘数据、移动合并算法、胜负判断 └── board_widget.dart # 4x4 棋盘 Widget 与格子样式这个分层思路很简单:数据逻辑和 UI 分离。GameController继承ChangeNotifier,持有棋盘数据,所有 UI 通过 ListenableBuilder 监听它刷新。这种模式在 Flutter 里非常常见,相当于前端里的状态管理库。我让 Trae 先按这个文件规划生成骨架,它几个命令就搞定了,效率确实高。
pubspec.yaml不需要加任何第三方依赖,Flutter 自带的基础库完全够用。这一点也是选 Flutter 做小游戏的优势,不像很多 Web 框架要引入状态管理和动画库。
3. 核心玩法:数据模型与移动合并算法
3.1 棋盘建模:一维数组比二维数组更省心
2048 本质就是一个 4x4 矩阵,大部分人第一反应是用List>表示。我实际写下来建议换成固定长度的一维数组:List,长度 16,索引 0 到 15,对应棋盘中从上到下、从左到右的格子,0 表示空格。
为什么一维更好?因为 2048 的移动本质是“把一行或一列的非零数字提取出来、合并、再补零”。如果把 16 个格子摊平成一行,那么每行索引是固定的区间,每列索引也可以通过索引 = 列号 * 4 + 行号计算出来,用一维数组做映射非常干净,二维数组反而要频繁访问board[r][c],代码更啰嗦。另外,一维数组复制快,判断“移动前后是否发生变化”只需要逐位比较,写起来直观得多。
数据模型部分我这样定义:
class GameController extends ChangeNotifier { static const int size = 4; static const int total = size * size; final List _board = List.filled(total, 0); int _score = 0; bool _won = false; bool _gameOver = false; bool _keepPlaying = false; }_won和_gameOver分别代表是否合成 2048、是否无法移动。_keepPlaying是用户合成 2048 后选择“继续游戏”的标记,因为 2048 的经典设计是合出 2048 后可以继续玩下去拿更高分。
3.2 移动合并算法:方向归一化是最好的解法
这是整个项目的核心难点,也是我第一次让 AI 写的时候就翻车的地方。很多人写 2048 会把上下左右四个方向分别写一套逻辑,结果代码巨大且容易出错。正确思路是:把四个方向的移动全部统一成“从起点到终点,把一维数组去零、合并、补零”这个操作。
以向左移动为例,每一行从左往右读。向右移动呢?把每一行从右往左读,相当于把数组反转后再执行同样的操作,最后再把结果反转写回。向上和向下也是同理,只不过提取的是列而不是行。这样四个方向共用同一个_processRow方法,逻辑只有一份,bug 只可能在一个地方。
核心算法分三步:第一步,把当前行/列的非零数字按移动方向提取出来;第二步,从左到右依次判断相邻两个数是否相同,相同则合并,且每个数字只参与一次合并;第三步,把处理后的数字按原位置写回,空位补 0。具体实现如下:
List _processRow(List row) { // 去掉所有 0 final nums = row.where((v) => v != 0).toList(); final merged = []; for (var i = 0; i < nums.length; i++) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { final newVal = nums[i] * 2; merged.add(newVal); _score += newVal; if (newVal == 2048) { _won = true; } i++; // 跳过下一个,确保每个数字只合并一次 } else { merged.add(nums[i]); } } while (merged.length < size) { merged.add(0); } return merged; }合并后补零,得到的是目标方向的最终序列。比如[2, 2, 2, 2]左移,经过_processRow得到[4, 4, 0, 0],不会出现连续合并成[8, 0, 0, 0]的错误,关键就在上面那个i++。
接下来是移动入口。对四个方向分别映射索引提取顺序,再按同样顺序写回:
enum Direction { up, down, left, right } bool move(Direction direction) { bool changed = false; for (var i = 0; i < size; i++) { // 提取一行 / 一列 List line = []; for (var j = 0; j < size; j++) { switch (direction) { case Direction.left: line.add(_board[i * size + j]); break; case Direction.right: line.add(_board[i * size + (size - 1 - j)]); break; case Direction.up: line.add(_board[j * size + i]); break; case Direction.down: line.add(_board[(size - 1 - j) * size + i]); break; } } final processed = _processRow(line); // 写回 for (var j = 0; j < size; j++) { final idx = switch (direction) { Direction.left => i * size + j, Direction.right => i * size + (size - 1 - j), Direction.up => j * size + i, Direction.down => (size - 1 - j) * size + i, }; if (_board[idx] != processed[j]) { _board[idx] = processed[j]; changed = true; } } } if (changed) { addRandomTile(); _gameOver = !_canMove(); notifyListeners(); } return changed; }写回时如果某个位置的值有变化,就说明棋盘真的动了,changed为 true。只有在棋盘发生变化时才生成新方块和做游戏结束判断,否则一次无效滑动不应该触发新方块生成,这条规则很多实现都会忽略。
随机生成方块用简单均匀随机即可,新方块 90% 概率是 2、10% 概率是 4,这是 2048 官方概率:
void addRandomTile() { final empty = []; for (var i = 0; i < total; i++) { if (_board[i] == 0) empty.add(i); } if (empty.isEmpty) return; final index = empty[_random.nextInt(empty.length)]; _board[index] = _random.nextDouble() < 0.9 ? 2 : 4; }_random用Random()初始化即可,它会以当前时间作为随机种子。我见过有 AI 生成的代码里写Random(42),固定种子,结果每次重新开局生成的方块位置一模一样,这个坑大家留意一下。
游戏结束判断:遍历所有格子,只要还有空格或者水平垂直方向存在相邻相同数字,就还能移动;否则游戏结束。
bool _canMove() { for (var i = 0; i < total; i++) { if (_board[i] == 0) return true; if (i % size != size - 1 && _board[i] == _board[i + 1]) return true; if (i + size < total && _board[i] == _board[i + size]) return true; } return false; }3.3 GameController 完整代码与 AI 生成过程的修正
上面这些逻辑拼起来就是完整的game_controller.dart。我让 Trae 生成的初版其实已经能跑,但有一处逻辑 bug 典型得不行:它把合并逻辑写成在二维数组上对每个方向分别做循环,_processRow被复制了四份,其中向上移动那套缩进错了,导致列数据读取错位。这属于 AI 在“重复代码 + 索引变换”场景下最容易犯的错。
后来我让它统一改成方向归一化的写法,用一维数组实现,AI 一次就改对了。这里透露我的协作经验:遇到 AI 写的代码有结构性问题,不要直接给它一个 bug 描述让它猜,而是把方向讲清楚,比如“把所有方向统一成取行/列 + 处理 + 写回的模式,用一维数组索引”,它就能稳定生成正确代码。
完整代码如下:
import 'dart:math'; import 'package:flutter/foundation.dart'; enum Direction { up, down, left, right } class GameController extends ChangeNotifier { static const int size = 4; static const int total = size * size; final List _board = List.filled(total, 0); final Random _random = Random(); int _score = 0; bool _won = false; bool _gameOver = false; bool _keepPlaying = false; List get board => List.unmodifiable(_board); int get score => _score; bool get won => _won && !_keepPlaying; bool get gameOver => _gameOver; void newGame() { for (var i = 0; i < total; i++) { _board[i] = 0; } _score = 0; _won = false; _gameOver = false; _keepPlaying = false; addRandomTile(); addRandomTile(); notifyListeners(); } void continueGame() { _keepPlaying = true; notifyListeners(); } void addRandomTile() { final empty = []; for (var i = 0; i < total; i++) { if (_board[i] == 0) empty.add(i); } if (empty.isEmpty) return; final index = empty[_random.nextInt(empty.length)]; _board[index] = _random.nextDouble() < 0.9 ? 2 : 4; } List _processRow(List row) { final nums = row.where((v) => v != 0).toList(); final merged = []; for (var i = 0; i < nums.length; i++) { if (i + 1 < nums.length && nums[i] == nums[i + 1]) { final newVal = nums[i] * 2; merged.add(newVal); _score += newVal; if (newVal == 2048) { _won = true; } i++; } else { merged.add(nums[i]); } } while (merged.length < size) { merged.add(0); } return merged; } bool move(Direction direction) { bool changed = false; for (var i = 0; i < size; i++) { List line = []; for (var j = 0; j < size; j++) { switch (direction) { case Direction.left: line.add(_board[i * size + j]); break; case Direction.right: line.add(_board[i * size + (size - 1 - j)]); break; case Direction.up: line.add(_board[j * size + i]); break; case Direction.down: line.add(_board[(size - 1 - j) * size + i]); break; } } final processed = _processRow(line); for (var j = 0; j < size; j++) { final idx = switch (direction) { Direction.left => i * size + j, Direction.right => i * size + (size - 1 - j), Direction.up => j * size + i, Direction.down => (size - 1 - j) * size + i, }; if (_board[idx] != processed[j]) { _board[idx] = processed[j]; changed = true; } } } if (changed) { addRandomTile(); _gameOver = !_canMove(); notifyListeners(); } return changed; } bool _canMove() { for (var i = 0; i < total; i++) { if (_board[i] == 0) return true; if (i % size != size - 1 && _board[i] == _board[i + 1]) return true; if (i + size < total && _board[i] == _board[i + size]) return true; } return false; } }这段代码里notifyListeners()是核心。Flutter 里所有 UI 只要监听这个 Controller,任何数据变化都会自动触发刷新,这就是响应式状态管理最朴素的用法,不需要引入 Provider 或 Riverpod 这些重型状态库。
4. 界面渲染与交互实现
4.1 棋盘 UI 与数字配色
2048 的界面不复杂,一个 4x4 方阵,但视觉上要求数字块和颜色有较高的辨识度。我在board_widget.dart里定义了数字到颜色的映射表,0 是空格底色,2 和 4 用浅色,8 以上逐渐加深。经典 2048 配色如下:
static const Map tileColors = { 0: Color(0xFFCDC1B4), 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), }; static const Map textColors = { 2: Color(0xFF776E65), 4: Color(0xFF776E65), 8: Color(0xFFF9F6F2), 16: Color(0xFFF9F6F2), 32: Color(0xFFF9F6F2), 64: Color(0xFFF9F6F2), 128: Color(0xFFF9F6F2), 256: Color(0xFFF9F6F2), 512: Color(0xFFF9F6F2), 1024: Color(0xFFF9F6F2), 2048: Color(0xFFF9F6F2), };实际开发中你完全可以直接把 Trae 生成的配色映射拿来用,这种模式化代码 AI 完成度很高,几乎不用改。棋盘用GridView.builder构建,每个格子是一个带圆角的AnimatedContainer,用controller.board[index]作为数据源。为了动画稍微平滑一点,我给每个格子加了 80ms 的AnimatedContainer过渡,这样数字出现、颜色变化不会显得太突兀。
GridView需要放在固定宽高比的容器里,否则 4x4 格子会被拉伸变形。外层用AspectRatio(aspectRatio: 1)强制棋盘为正方形,内层用Padding留出边框间距。棋盘整体背景是深色圆角矩形,和格子的浅色形成对比,视觉效果接近官方版本。
4.2 键盘、鼠标与触屏手势的统一处理
Web 游戏比纯移动端游戏多一个输入来源:键盘。2048 在这点上必须同时支持桌面和移动端才完整。我在 UI 外层包了两层事件处理。
键盘方面,用 Flutter 的Focus组件监听onKeyEvent。注意 Flutter Web 上键盘事件必须让页面先获得焦点,否则按方向键完全没反应。这里的经验是给Focus组件设置autofocus: true,并且在用户点击棋盘区域时重新requestFocus,保证无论页面焦点在哪,游戏都能拿到键盘事件。监听逻辑会把方向键和 WASD 都映射到四个方向,左撇子用户也能直接上手:
onKeyEvent: (node, event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { final key = event.logicalKey; Direction? dir; if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.keyW) { dir = Direction.up; } else if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.keyS) { dir = Direction.down; } else if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.keyA) { dir = Direction.left; } else if (key == LogicalKeyboardKey.arrowRight || key == LogicalKeyboardKey.keyD) { dir = Direction.right; } if (dir != null) { controller.move(dir); return KeyEventResult.handled; } } return KeyEventResult.ignored; },鼠标和触屏统一用GestureDetector处理。onPanStart记录起点坐标,onPanEnd计算终点坐标的偏移向量,取绝对值大的那个轴作为滑动方向,同时设置一个最小滑动手势阈值,比如 16 像素,避免误触。这套逻辑在桌面浏览器(鼠标拖拽)和手机浏览器(手指滑动)上完全通用,Flutter 封装好了底层的指针事件,不需要自己区分机器类型。
需要注意,onPanEnd拿到的details.velocity也可以算方向,但我实测下来用起点终点坐标更稳定,不容易因为手指的抖动误判。简单阈值判断足够应付这个小游戏。
4.3 动画反馈与游戏结束/胜利弹窗
2048 原版有一个非常流畅的方块移动动画,格子不是瞬间跳过去的。但如果要做逐格位移动画,棋盘就不能用GridView排版,得用Stack定位每个方块的位置,再计算滑动距离做插值,复杂度会高一截。考虑到这是入门练手项目,我在动效上做了取舍:用AnimatedContainer做颜色和数字切换的过渡,用AnimatedSwitcher做方块出现时的淡入效果,得分数字变化也加了个简单的缩放动画。
如果你后续想升级成平滑移动动画,方向很明确:把 16 个格子全部改成Positioned,每次移动时记录每个方块移动前的位置和移动后的位置,用AnimationController驱动插值。这个扩展对理解 Flutter 动画体系很有帮助,可以当作下一步练习。
游戏状态弹窗用的是 Flutter 自带showDialog。胜利时弹“合成 2048”,给两个按钮:继续游戏和重新开始;失败时弹“游戏结束”,只能重新开始。弹窗出现前要判断controller.won和controller.gameOver,并在按钮点击后调用对应的continueGame()或newGame()。这里有个细节:胜利弹窗第一次出现后,必须把_keepPlaying设为 true,否则每合成一次 2048 就反复弹窗,而continueGame的实现就是把_keepPlaying置为 true 再刷新页面。
4.4 main.dart 完整可运行代码
把前面所有模块串起来的main.dart如下。它包含应用入口、顶部计分区、棋盘区和底部控制按钮。核心结构是一个 StatefulWidget 持有GameController实例,初始化时调用newGame(),然后通过ListenableBuilder监听整个 Controller,任意状态变化自动重建界面。
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'game_controller.dart'; import 'board_widget.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( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFBBADA0)), useMaterial3: true, ), home: const GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); @override State createState() => _GamePageState(); } class _GamePageState extends State { final GameController _controller = GameController(); Offset _startPosition = Offset.zero; @override void initState() { super.initState(); _controller.newGame(); } @override void dispose() { _controller.dispose(); super.dispose(); } void _onPanStart(DragStartDetails details) { _startPosition = details.globalPosition; } void _onPanEnd(DragEndDetails details) { final dx = details.globalPosition.dx - _startPosition.dx; final dy = details.globalPosition.dy - _startPosition.dy; if (dx.abs() < 16 && dy.abs() < 16) return; if (dx.abs() > dy.abs()) { _controller.move(dx > 0 ? Direction.right : Direction.left); } else { _controller.move(dy > 0 ? Direction.down : Direction.up); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFAF8EF), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { final key = event.logicalKey; Direction? dir; if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.keyW) { dir = Direction.up; } else if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.keyS) { dir = Direction.down; } else if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.keyA) { dir = Direction.left; } else if (key == LogicalKeyboardKey.arrowRight || key == LogicalKeyboardKey.keyD) { dir = Direction.right; } if (dir != null) { _controller.move(dir); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: GestureDetector( onPanStart: _onPanStart, onPanEnd: _onPanEnd, child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 16), Expanded( child: Center( child: BoardWidget(controller: _controller), ), ), const SizedBox(height: 16), _buildActionButtons(), ], ), ), ), ), ), ), ); } Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 56, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: ListenableBuilder( listenable: _controller, builder: (context, _) { return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ const Text( 'SCORE', style: TextStyle( fontSize: 12, color: Color(0xFFEEE4DA), fontWeight: FontWeight.bold, ), ), Text( '${_controller.score}', style: const TextStyle( fontSize: 24, color: Colors.white, fontWeight: FontWeight.bold, ), ), ], ); }, ), ), ], ); } Widget _buildActionButtons() { return ListenableBuilder( listenable: _controller, builder: (context, _) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextButton.icon( onPressed: () { setState(() { _controller.newGame(); }); }, icon: const Icon(Icons.refresh), label: const Text('新游戏'), ), if (_controller.won) TextButton.icon( onPressed: () { _controller.continueGame(); }, icon: const Icon(Icons.play_arrow), label: const Text('继续挑战'), ), if (_controller.gameOver) const Text( '游戏结束', style: TextStyle(fontSize: 18, color: Color(0xFF776E65)), ), ], ); }, ); } }BoardWidget 的完整实现也在下面,和 main.dart 组合后就是一个可以完整运行的项目。注意AnimatedBuilder在这里可以换成ListenableBuilder,两者在 Flutter 3.x 中效果一致,我只是保留了监听刷新的标准写法。
import 'package:flutter/material.dart'; import 'game_controller.dart'; class BoardWidget extends StatelessWidget { const BoardWidget({super.key, required this.controller}); final GameController controller; static const Map tileColors = { 0: Color(0xFFCDC1B4), 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), }; static const Map textColors = { 2: Color(0xFF776E65), 4: Color(0xFF776E65), 8: Color(0xFFF9F6F2), 16: Color(0xFFF9F6F2), 32: Color(0xFFF9F6F2), 64: Color(0xFFF9F6F2), 128: Color(0xFFF9F6F2), 256: Color(0xFFF9F6F2), 512: Color(0xFFF9F6F2), 1024: Color(0xFFF9F6F2), 2048: Color(0xFFF9F6F2), }; @override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: AnimatedBuilder( animation: controller, builder: (context, _) { return GridView.builder( physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: GameController.size, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: GameController.total, itemBuilder: (context, index) { final value = controller.board[index]; return AnimatedContainer( duration: const Duration(milliseconds: 80), decoration: BoxDecoration( color: tileColors[value] ?? tileColors[0]!, borderRadius: BorderRadius.circular(4), ), alignment: Alignment.center, child: value == 0 ? null : Text( '$value', style: TextStyle( fontSize: value >= 1024 ? 18 : 26, fontWeight: FontWeight.bold, color: textColors[value] ?? Colors.white, ), ), ); }, ); }, ), ), ); } }5. 运行调试与 Web 部署
5.1 本地运行与热重载调试
写完代码第一件事是本地跑起来验证。在 Trae 的终端执行:
fvm flutter run -d chromeFlutter 会启动 Chrome 并自动打开页面,同时进入调试模式。这期间修改代码并保存,热重载会自动刷新浏览器页面,不需要手动刷新。像修改颜色映射、调整间距这种轻微改动,热重载秒级生效;加了新文件或改了原生依赖,可能需要热重启,也就是在终端按大写 R。
调试时我特别推荐观察浏览器开发者工具的 Console 面板。Flutter Web 的运行时错误、HTTP 请求失败都会打印到浏览器 Console,很多问题在 Dart 层看不出端倪,但 Console 里会直接暴露根源。另外 Flutter DevTools 也要会用,打开它是通过执行命令后在浏览器里进入调试面板,可以看 Widget 树层级。我遇到过界面跟预想不一致的情况,用 DevTools 的 Widget Inspector 点几下就定位到了。
5.2 Web 构建产物说明
开发验证通过后就可以构建生产版本:
fvm flutter build web --release输出目录是build/web。这里的核心文件有:index.html是入口页面;main.dart.js(可能拆成多个 chunk)是编译后的 JavaScript,即业务逻辑本体;flutter_bootstrap.js负责加载入口;assets目录放字体和静态资源;CanvasKit 相关模块在 Flutter 3.x 中被打包到canvaskit子目录,负责浏览器里的渲染引擎。整个目录结构是一个纯静态站点,不需要任何后端服务,扔到任意静态文件服务器就能跑。
有一点会让新手迷惑:flutter build web默认生成的是 Hash 模式路由,即 URL 形如/index.html#/。这个模式的好处是刷新页面不会 404,因为路径变化不经过服务器。如果你的应用后续用了命令式路由想改成 Path 模式,那就必须在服务器上配 try_files 规则,否则刷新二级路径会报 404。本项目单页面没有路由跳转,保持默认即可。
5.3 Nginx 部署与多项目共存
部署到 Nginx 是常规操作。假设你有一台云服务器,把build/web里的文件拷贝到服务器目录,然后在 Nginx 配置里加一个 server 块:
server { listen 80; server_name your.domain.com; root /var/www/game2048; index index.html; location / { try_files $uri $uri/ /index.html; } }try_files是 Nginx 的万能兜底,确保单页应用刷新任意路由都不会 404。如果是本地服务器只有 80 端口,想和其他 Web 项目共存,放在子路径下部署更常见。比如想通过http://your.domain.com/game2048/访问,Nginx 配置要改成这样:
location /game2048/ { alias /var/www/game2048/; index index.html; try_files $uri $uri/ /game2048/index.html; }但这里有个 Flutter Web 特有的坑:构建时如果不指定--base-href,生成的 index.html 里资源路径是绝对路径/main.dart.js,放到子路径下就会去请求域名根目录的资源,导致白屏。解决方法是构建时指定子路径:
fvm flutter build web --release --base-href=/game2048/构建后用编辑工具打开build/web/index.html确认资源路径前缀是/game2048/,部署就稳了。这个细节是我踩过最多次的坑,每次都靠这个命令解决。
5.4 Service Worker 缓存与版本更新
Flutter Web 构建会自动生成flutter_service_worker.js,默认注册 Service Worker 做资源缓存。好处是二次加载快,坏处是发布新版本后用户浏览器还在用旧缓存,经常出现“改了代码但线上没变化”的情况,严重时会有旧缓存资源和当前 index.html 版本不匹配导致的控制台报错,报错信息类似error: could not register service worker: invalidstatee。
这个问题最省心的解法是在 Nginx 层面对 Service Worker 和 index.html 禁用缓存:
location = /flutter_service_worker.js { add_header Cache-Control "no-cache"; } location = /index.html { add_header Cache-Control "no-cache"; }同时你可以在 Flutter 里通过--no-web-resources-cache参数构建,去掉资源文件的强缓存头。实际发布时我建议两步都做:一是构建时不要用默认缓存策略,二是 Nginx 对入口文件和 SW 脚本禁用缓存。这样用户刷新页面就能拿到最新版本。
6. 开发中遇到的典型问题与排查实录
6.1 Trae 生成代码的典型坑
AI 辅助开发不是银弹,我这次遇到最多的问题是上下文丢失和局部过度自信。典型场景:我在 Chat 里让它改动了GameController的某个方法,然后接着问它“那现在如何判断游戏结束?”它会基于自己脑子里的旧版本回答,而不是我实时的文件内容。这是 AI 编程工具的固有短板,解决方案是善用 Build 模式和 @文件引用功能,明确把对应文件加入上下文,或者直接让它“先看一下 game_controller.dart 再回答”。
另一个典型坑是 AI 生成代码时对 2048 规则的“想当然”。我第一次让它实现移动合并时,它把每个方向的逻辑分别写了一遍,导致四个方向行为不一致;还有一次它写的随机数是固定种子,重开游戏总是同样的布局。这些都是只有实际运行才能暴露的问题。所以我的经验是:核心算法类代码,AI 生成后必须做两件事——一是把特殊边界输入丢进去测试,比如全 2 一排、连续两个相同数字在不同方向移动;二是读一遍索引变换逻辑,确认提取和写回的方向映射对称。这两步做完,AI 的代码基本可以放心用。
6.2 Flutter Web 运行时检查清单
如果你按上面的代码写完,运行时遇到异常,按这个清单排查最快:
第一,白屏且 Console 没有任何报错,优先怀疑资源路径问题,特别是子路径部署时--base-href没设置。第二,键盘方向键没反应,确认你是用 Chrome 打开并点击过页面,焦点不在棋盘区域时键盘事件不会生效;如果用的是 Server 端渲染或 iframe 嵌入,还需要额外处理焦点。第三,浏览器显示网格布局错位,检查是不是外层被ConstrainedBox限制了宽度,棋盘GridView在过窄的环境下格子会被压缩,建议在窄屏上把间距调小或隐藏得分按钮。第四,动画卡顿,Flutter Web 在低配机器上 CanvasKit 渲染压力较大,如果格子多、动画频繁,可以把动画时长去掉或者调低刷新率。
另外提醒一个 Dart 语法兼容问题:如果你用的 Flutter 版本太老(3.3 之前),代码里的 switch 表达式和onKeyEvent参数可能不兼容,建议直接把 Flutter 升到 3.10 以上,或者干脆用 FVM 锁到最新稳定版。
6.3 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 刷新页面后还是旧版本 | Service Worker 缓存 | 构建并设置 SW 不缓存,或部署后让用户强制刷新 |
| 键盘方向键无效 | Focus 焦点不在游戏区域 | 给 Focus 设置 autofocus,点击棋盘区域重新请求焦点 |
| 子路径部署白屏 | base-href 未设置 | 构建时加--base-href=/你的子路径/ |
| [4,4,8,0] 向右移动结果错误 | 合并顺序处理不对 | 检查是否所有方向共用同一个_processRow,且每个数字只合并一次 |
| 新方块总是出现在同一个格子 | Random 被固定种子初始化 | 用无参Random()或时间戳作为种子 |
| 鼠标滑动无反应 | Pan 阈值或坐标算错 | 用globalPosition而不是局部坐标,阈值至少 16px |
| 棋盘被拉伸变形 | 外层缺少宽高比约束 | 用AspectRatio(aspectRatio: 1)包裹棋盘 |
这个表是我实际开发中整理出来的高频问题,以后你再碰到类似情况直接对照排查。
最后再分享一个小技巧。AI 辅助开发 2048 这类小项目时,最合理的分工是:让 Trae 帮你搭脚手架、写页面布局、做颜色映射和事件绑定这些“体力活”,但移动合并算法和游戏状态迁移这种核心逻辑,你要么自己写,要么生成后拿一堆边界用例去验。这种交叉验证的流程,比单纯“AI 生成什么就用什么”靠谱得多。项目本身还可以往下扩展,比如加平滑移动动画、本地最高分存储、更多尺寸的棋盘、撤销一步操作等,都能把 Flutter Web 的能力吃得更透。