用 Trae 与 Flutter Web 快速开发 2048:从算法到跨端实践
2026/9/15 3:48:13 网站建设 项目流程

1. 为什么我决定用 Trae 写一个 2048

1.1 这个项目到底在做什么

2048 这个游戏大家都不陌生,一个 4x4 的棋盘,通过上下左右滑动让相同数字合并,目标是凑出 2048。它看起来简单,但真正要自己从零写一遍,牵扯到的核心问题并不少:棋盘的存储结构、滑动合并的算法、新方块的随机生成、胜负判断、动画反馈、跨端运行。这些凑在一起,正好是一个非常适合练手的前端项目。

我这次选择的技术组合是Flutter Web + Trae。Flutter 大家知道,是跨端 UI 框架,平时更多用来做移动 App,其实它从很早就支持 Web 端编译,用一套 Dart 代码可以同时跑在 iOS、Android、浏览器里。Trae 则是最近很火的 AI 辅助 IDE,它内置了智能对话和自动改写代码的能力,特别适合从 0 到 1 快速搭一个项目原型。

这两个东西放在一起,我实际体验下来有个很直观的感受:以前写这种小游戏,从建项目到写逻辑到调 UI,至少得大半天;这次我用 Trae 来辅助生成和调整代码,整体只花了两三个小时,而且遇到问题直接在编辑器里问 AI 就能快速定位,不需要反复切到浏览器搜索。这篇博文我会把整个流程完整记录下来,包括算法思路、代码实现、踩过的坑,希望能给想用 Flutter Web 练手、或者刚接触 Trae 的朋友一些参考。

1.2 Trae 在其中的定位

先解释一下 Trae 到底是什么,因为有些朋友可能还没用过。Trae 是一个基于 VSCode 生态的 AI 驱动 IDE,它最大的特点是把 AI 能力直接集成在编辑器里,不是简单的代码补全,而是可以让你用自然语言描述需求,它直接帮你生成、修改、解释整个文件甚至整个项目。

它在工作流里有两种典型用法。一种是 Chat 模式,相当于在侧边栏开一个 AI 对话窗口,你可以选中代码然后问“这段逻辑是什么意思”“帮我加一个功能”;另一种是 Builder 模式,你给它一个任务描述,它会自己规划步骤,创建文件、修改文件、运行命令,像一个小型开发代理。

我这次大部分核心逻辑就是用 Builder 模式生成的。比如我先让 Trae 创建 Flutter Web 项目,然后告诉它“给我生成一个 2048 游戏的核心模型类,包含上下左右移动和合并逻辑”,它会直接把整个 Dart 类写出来,我再结合自己的理解去调整。这个过程不是完全扔给它不管,而是“人审 AI 的代码,AI 听人的指令改”,效率很高。

1.3 这个内容适合谁来参考

如果你是下面这几类人,这篇文章应该比较有价值:

  • 用过 Flutter 但主要写移动端,想试试 Flutter Web 的,2048 是个很好的练习题材。
  • 刚接触 Trae 这类 AI IDE,想知道它到底能不能高效辅助完整项目开发的。
  • 对 2048 算法感兴趣,想搞清楚滑动合并逻辑到底怎么设计的,但不想去啃复杂源码的。
  • 想找一个能快速出成果、又能完整跑在浏览器里的练手项目的。

即使你对 Flutter 完全没基础,只要会一点编程,跟着文章里的思路走,也能把一个能玩的 2048 跑起来。Flutter 的语法其实非常直观,尤其适合从 JavaScript 或 Java 转过来的开发者。

2. 动手前先想清楚:2048 的玩法拆解和技术选型

2.1 游戏的核心逻辑到底是什么

很多人一听说要做 2048,第一反应是“这有什么难的,4x4 数组挪一挪嘛”。但真把需求拆细,你会发现它包含好几个独立环节:

  1. 棋盘状态管理:4x4 格子,每个格子是 0 或 2 的幂,0 代表空。
  2. 滑动合并:玩家按左、右、上、下后,所有方块朝对应方向移动,相同数字碰撞时合并成一个两倍数字的方块。这一步是核心中的核心。
  3. 随机生成:每次有效滑动后,棋盘空位随机出现一个新方块,90% 概率是 2,10% 概率是 4。
  4. 胜负判断:出现 2048 方块算赢;棋盘满了且没有可合并的相邻方块算输。
  5. 计分系统:每次合并产生的分数累加,通常还会记录最高分。
  6. 交互与动画:响应键盘或手势输入,方块移动和合并要有平滑动画。

仔细看就会发现,真正有算法深度的是第二点“滑动合并”,其他部分都是常规的状态更新。所以做这个项目,不需要什么高深的图形学知识,考验的是你能不能写出一套干净、无 bug 的二维数组操作逻辑。

2.2 为什么用 Flutter Web 而不是原生 JS 或 Canvas

有人可能会问,2048 这种小游戏,用纯 JavaScript + HTML + CSS 也能写,或者用 Canvas 画,为什么选 Flutter Web?

我的理由是分场景的。如果只是想要一个能跑的 2048 网页,原生 JavaScript 确实更轻量,甚至几百行代码就能搞定。但 Flutter Web 的优势在于:

  • 单一代码库:游戏逻辑、UI、状态管理全用 Dart 写,以后想打包成 Android App、Windows 桌面版,几乎不用改代码。我现在写的这个项目,改一行命令就能跑成移动 App。
  • UI 一致性:Flutter 自绘渲染引擎,在不同浏览器、不同分辨率下的表现非常一致,不像 CSS 那样容易出现兼容性问题。
  • 动画系统成熟:Flutter 内置了强大的 AnimationController 和隐式动画组件,做方块的移动和缩放效果非常省事。
  • 适合学习 Flutter:如果你是 Flutter 新手,用游戏项目来练手比写一万遍“计数器 Demo”有效得多。

当然也有代价:Flutter Web 的首次加载体积比较大(大约 1-2MB),如果你要做极端轻量的 H5 页面,它不是最优选择。但作为个人项目、学习项目,这个代价完全值得。

2.3 对 2048 算法方案的整体设计

我在设计代码结构时,刻意把游戏逻辑和 UI 分离了。这样做的原因很简单:2048 的核心是状态机,不是界面。如果我把移动合并逻辑直接写在 Widget 里,代码会又臭又长,而且不好测试。

我的设计分三层:

  • 数据层:一个Game2048类,保存 4x4 的棋盘数据List<List<int>>、当前分数、最高分,提供move(Direction)isGameOver()hasWon()等方法。
  • 控制层:Flutter 的StatefulWidget负责接收用户输入,调用Game2048类的方法,然后调用setState刷新界面。
  • 展示层:用 Flutter Widget 绘制棋盘、方块、分数栏。

这种分层模式在真实项目中也经常用,游戏逻辑放到纯 Dart 类里意味着以后可以单独写单元测试,不用启动界面就能验证算法是否正确。

3. Trae 环境准备:从安装到跑起第一个 Flutter Web 项目

3.1 需要安装哪些东西

在开始写代码之前,先把环境搭好。我假设你的电脑是 Windows 或 macOS,需要安装:

工具用途安装方式
Flutter SDKFlutter 开发的核心工具链官网下载后解压,配置环境变量
Chrome 浏览器Flutter Web 运行和调试目标日常安装即可
TraeAI 辅助 IDE官网下载安装包,双击安装
Git项目版本管理(可选)官网下载或通过包管理器安装
VS Code 扩展(可选)Dart 语法高亮等Trae 内置支持,不需要额外装

Flutter SDK 的安装有几个注意点:一是要把flutter/bin目录加入系统 PATH;二是安装后先运行flutter doctor检查环境是否有缺失,它会提示你缺少 Android SDK、Xcode 或者其他组件,但因为我们目标是 Web,即便缺 Android 工具链也不影响。如果flutter doctor显示 Chrome 已安装,那就说明 Web 支持没问题。

提示:如果你网络环境不太好,Flutter 官方仓库可能会下载缓慢,这时候可以考虑配置国内镜像源。具体操作是在系统环境变量里添加PUB_HOSTED_URLFLUTTER_STORAGE_BASE_URL,指向可用的镜像地址。这个我建议在安装前就配好,否则后面flutter pub get拉取依赖容易卡住。

3.2 用 Trae 创建 Flutter Web 项目

环境装好后,打开 Trae。它的界面和 VSCode 非常像,如果你用过 VSCode,上手没有任何障碍。第一步,打开终端面板,先确认 Flutter 命令可用:

flutter --version

如果能正常输出版本号,就可以创建项目了。我们可以用命令行创建,也可以直接用 Trae 的 AI 对话来生成。我这次是先用命令行创建了项目骨架,再让 AI 往里面填逻辑:

flutter create game2048_web cd game2048_web

flutter create默认会生成所有平台的目录,包括 android、ios、web、windows、macos 等。如果你只想保留 Web,可以在创建时加参数:

flutter create --platforms web game2048_web

这样项目更干净,不会有一堆暂时用不到的目录。创建完成后,用 Trae 打开这个文件夹,在终端里运行:

flutter run -d chrome

看到 Chrome 自动打开并显示默认的 Flutter 示例页面,说明 Flutter Web 环境已经通了。这时候默认页面是一个计数器应用,下一步就是把它替换成我们的 2048。

3.3 让 Trae 自动生成基础骨架

我的习惯是先用 AI 生成一个能跑的基础版本,再手动优化细节。在 Trae 的 AI 对话窗口输入类似这样的描述:

我要写一个 Flutter Web 的 2048 小游戏,请帮我创建一个完整的 main.dart,包含游戏逻辑和 UI,使用 4x4 棋盘,支持方向键和滑动操作。

Trae 会直接生成一个完整文件。这里要提醒一点:AI 生成的代码质量参差不齐,尤其在这种“一次生成整个文件”的场景下,经常会有小 bug。但不用怕,这正是 Trae 好用的地方——你把报错信息粘贴给它,它会自己检查并修复。

如果你用的是 Builder 模式,它会自动创建或覆盖文件、运行flutter analyze检查语法,整个流程比纯 Chat 模式更自动化。我第一次用 Builder 生成完整 2048 代码时,它一次跑通,但分数记录逻辑有 bug,后来我让它“把最高分保存到本地”,它马上加了shared_preferences依赖并改好了相关代码。

4. 核心代码实现:2048 的算法和 Flutter 界面

4.1 棋盘的数据模型设计

2048 的棋盘本质是一个 4x4 的二维数组。在 Dart 里我这样定义:

class Game2048 { static const int gridSize = 4; List<List<int>> board; int score = 0; int bestScore = 0; bool win = false; bool gameOver = false; Game2048() : board = List.generate( gridSize, (_) => List.filled(gridSize, 0), ); }

初始时所有格子都是 0。我们需要在开局时随机生成两个方块,通常在两个不同的空位上。这个逻辑在_addRandomTile()方法中实现。

为了后面方便判断某个方块是否移动过,我在实际实现中用一个List<List<int>>存数字,用List<List<bool>>Set记录本次滑动中已经合并过的方块位置。这里要注意,合并规则是“每个方块在一次滑动中只能合并一次”,比如一行是[2, 2, 2, 2],左移后应该是[4, 4, 0, 0],而不是[8, 0, 0, 0]

4.2 一行合并的算法怎么写

合并一行的逻辑是整个游戏的核心。我的做法是先把一行中所有非零数字取出来,然后从左到右进行合并。

List<int> mergeRow(List<int> row) { final nonZero = row.where((v) => v != 0).toList(); final result = <int>[]; var i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { result.add(nonZero[i] * 2); i += 2; } else { result.add(nonZero[i]); i++; } } while (result.length < 4) { result.add(0); } return result; }

这个算法的核心思想是这样的:把一行中所有的非零数字压缩成一个数组,然后相邻相同的数字合并。举例说明:

  • [0, 2, 2, 4]-> 非零数组[2, 2, 4]-> 合并[4, 4, 0, 0]
  • [2, 2, 2, 2]-> 非零数组[2, 2, 2, 2]-> 合并[4, 4, 0, 0](这里需要注意,第二对 2 不会和第一对合并后的 4 再合并)
  • [4, 4, 8, 0]-> 非零数组[4, 4, 8]-> 合并[8, 8, 0, 0]

这一行逻辑写对后,其他的方向就都好办了。

4.3 四个方向的滑动怎么统一处理

左移是最基本的方向,右移、上移、下移都可以通过转换来复用mergeRow

  • 右移:将每行反转 -> 左移合并 -> 再反转回来。
  • 上移:按列提取数据 -> 左移合并 -> 放回列。
  • 下移:将每列反转 -> 左移合并 -> 再反转放回。

这样只需要写一个左移的逻辑,其他方向都是数学变换,代码大大减少。

void moveLeft() { for (var i = 0; i < 4; i++) { board[i] = mergeRow(board[i]); } } void moveRight() { for (var i = 0; i < 4; i++) { board[i] = mergeRow(board[i].reversed.toList()).reversed.toList(); } } void moveUp() { for (var j = 0; j < 4; j++) { final column = [for (var i = 0; i < 4; i++) board[i][j]]; final merged = mergeRow(column); for (var i = 0; i < 4; i++) { board[i][j] = merged[i]; } } } void moveDown() { for (var j = 0; j < 4; j++) { final column = [for (var i = 0; i < 4; i++) board[i][j]]; final merged = mergeRow(column.reversed.toList()).reversed.toList(); for (var i = 0; i < 4; i++) { board[i][j] = merged[i]; } } }

这里要留意一点:反转之后合并,再反转回来,结果就是右对齐的效果。我一开始写的时候漏了一层反转,导致右移结果完全不对,后来用 Trae 帮我检查逻辑,一眼就发现了问题。

4.4 随机生成新方块和胜负判断

每次有效移动后,需要在空位随机生成一个新方块。实现思路是收集所有值为 0 的坐标,然后从中随机取一个:

void addRandomTile() { final emptyCells = <(int, int)>[]; for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) { emptyCells.add((i, j)); } } } if (emptyCells.isEmpty) return; final cell = emptyCells[Random().nextInt(emptyCells.length)]; board[cell.$1][cell.$2] = Random().nextInt(10) < 9 ? 2 : 4; }

胜负判断分两部分。胜利很简单:遍历棋盘,如果出现 2048,则游戏胜利。失败则要判断是否“没有空位且没有相邻相等的方块”。相邻相等意味着还有合并的可能,所以不能算输。

bool isGameOver() { for (var i = 0; i < 4; i++) { for (var j = 0; j < 4; j++) { if (board[i][j] == 0) return false; if (i + 1 < 4 && board[i][j] == board[i + 1][j]) return false; if (j + 1 < 4 && board[i][j] == board[i][j + 1]) return false; } } return true; }

4.5 Flutter UI 实现

UI 部分我用 Flutter 的纯 Widget 绘制。整体布局是一个 Column:顶部显示“2048”标题和分数,中间是棋盘,棋盘下面放“重新开始”按钮。

棋盘本身我用了GridView.builder,但更精确的做法是使用AspectRatio+Stack+AnimatedPositioned,因为后者能实现方块移动动画。不过单纯为了先跑通,用GridView是最快的。

方块的颜色根据数字大小变化。我定义了一个Color getTileColor(int value)函数:

Color getTileColor(int value) { switch (value) { case 0: return Color(0xFFCDC1B4); case 2: return Color(0xFFEEE4DA); case 4: return Color(0xFFEDE0C8); case 8: return Color(0xFFF2B179); case 16: return Color(0xFFF59563); case 32: return Color(0xFFF67C5F); case 64: return Color(0xFFF65E3B); case 128: return Color(0xFFEDCF72); case 256: return Color(0xFFEDCC61); case 512: return Color(0xFFEDC850); case 1024: return Color(0xFFEDC53F); case 2048: return Color(0xFFEDC22E); default: return Color(0xFF3C3A32); } }

颜色值我是参考了网页版 2048 的经典配色,深色数字配浅色背景,或者浅色数字配深色背景。数字颜色也同理,2 和 4 用深棕色,其他用白色。

每个格子的 UI 是Container,用BoxDecoration设置圆角和背景色,中间一个Text显示数字。Text 的字体大小根据数字位数调整:一位数用大字,四位数用小字,避免溢出。

4.6 手势与键盘事件

Flutter Web 支持键盘事件和手势。桌面上我监听方向键:

@override void initState() { super.initState(); _focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } @override Widget build(BuildContext context) { return Focus( focusNode: _focusNode, onKeyEvent: (event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _gameController.move(Direction.left); return KeyEventResult.handled; // 其他方向类似 } } return KeyEventResult.ignored; }, child: // 主界面 ); }

移动端和浏览器触屏场景,我用GestureDetectoronPanUpdate监听滑动方向。判断滑动方向的方法是计算滑动的 dx 和 dy,哪个方向绝对值更大就往哪个方向移动:

onPanEnd: (details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { if (dx > 0) _move(Direction.right); else _move(Direction.left); } else { if (dy > 0) _move(Direction.down); else _move(Direction.up); } }

实际的翻页实现中要注意:使用onPanEnd获取速度可能不稳定,初次点击不滑动也会有微小的方向。更稳妥的方式是用onPanUpdate累计位移,或者记录onPanStart的起始位置,在onPanEnd时用终点和起点做差判断。

5. 完整代码与细节优化

5.1 完整 main.dart 代码结构

我把项目核心代码放在一个main.dart里,方便读者直接复制运行。完整代码分为几个部分:工具类Game2048、游戏页面Game2048Page、子 WidgetTileView

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, scaffoldBackgroundColor: const Color(0xFFFAF8EF), ), home: const Game2048Page(), ); } } enum Direction { left, right, up, down } class Game2048 { static const int gridSize = 4; final List<List<int>> board; int score = 0; int bestScore = 0; bool gameOver = false; bool win = false; Game2048() : board = List.generate( gridSize, (_) => List.filled(gridSize, 0), ); // 合并一行 List<int> mergeRow(List<int> row) { final nonZero = row.where((v) => v != 0).toList(); final result = <int>[]; var i = 0; while (i < nonZero.length) { if (i + 1 < nonZero.length && nonZero[i] == nonZero[i + 1]) { result.add(nonZero[i] * 2); score += nonZero[i] * 2; i += 2; } else { result.add(nonZero[i]); i++; } } while (result.length < gridSize) { result.add(0); } return result; } // 四个方向移动 void move(Direction dir) { final original = board.map((row) => row.toList()).toList(); switch (dir) { case Direction.left: for (var i = 0; i < gridSize; i++) { board[i] = mergeRow(board[i]); } break; case Direction.right: for (var i = 0; i < gridSize; i++) { board[i] = mergeRow(board[i].reversed.toList()).reversed.toList(); } break; case Direction.up: for (var j = 0; j < gridSize; j++) { final col = [for (var i = 0; i < gridSize; i++) board[i][j]]; final merged = mergeRow(col); for (var i = 0; i < gridSize; i++) { board[i][j] = merged[i]; } } break; case Direction.down: for (var j = 0; j < gridSize; j++) { final col = [for (var i = 0; i < gridSize; i++) board[i][j]]; final merged = mergeRow(col.reversed.toList()).reversed.toList(); for (var i = 0; i < gridSize; i++) { board[i][j] = merged[i]; } } break; } if (boardChanged(original, board)) { addRandomTile(); if (isGameOver()) gameOver = true; if (checkWin()) win = true; } if (score > bestScore) bestScore = score; } bool boardChanged(List<List<int>> a, List<List<int>> b) { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (a[i][j] != b[i][j]) return true; } } return false; } void addRandomTile() { final emptyCells = <(int, int)>[]; for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] == 0) emptyCells.add((i, j)); } } if (emptyCells.isEmpty) return; final cell = emptyCells[Random().nextInt(emptyCells.length)]; board[cell.$1][cell.$2] = Random().nextInt(10) < 9 ? 2 : 4; } bool isGameOver() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] == 0) return false; if (i + 1 < gridSize && board[i][j] == board[i + 1][j]) return false; if (j + 1 < gridSize && board[i][j] == board[i][j + 1]) return false; } } return true; } bool checkWin() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { if (board[i][j] >= 2048) return true; } } return false; } void reset() { for (var i = 0; i < gridSize; i++) { for (var j = 0; j < gridSize; j++) { board[i][j] = 0; } } score = 0; gameOver = false; win = false; addRandomTile(); addRandomTile(); } }

这个类干净地封装了所有游戏逻辑。UI 层只需要调用movereset等方法,完全不关心内部实现。

5.2 UI 层代码实现

UI 层我用一个StatefulWidget持有Game2048实例,每次按钮或手势触发后调用setState刷新。

class Game2048Page extends StatefulWidget { const Game2048Page({super.key}); @override State<Game2048Page> createState() => _Game2048PageState(); } class _Game2048PageState extends State<Game2048Page> { final Game2048 _game = Game2048(); final FocusNode _focusNode = FocusNode(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } void _handleMove(Direction dir) { setState(() { _game.move(dir); }); } void _reset() { setState(() { _game.reset(); }); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500), child: Padding( padding: const EdgeInsets.all(16), child: Focus( focusNode: _focusNode, onKeyEvent: (event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: _handleMove(Direction.left); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: _handleMove(Direction.right); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: _handleMove(Direction.up); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: _handleMove(Direction.down); return KeyEventResult.handled; } } return KeyEventResult.ignored; }, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanEnd: (details) { final dx = details.velocity.pixelsPerSecond.dx; final dy = details.velocity.pixelsPerSecond.dy; if (dx.abs() > dy.abs()) { _handleMove(dx > 0 ? Direction.right : Direction.left); } else { _handleMove(dy > 0 ? Direction.down : Direction.up); } }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(), const SizedBox(height: 16), _buildBoard(), const SizedBox(height: 16), _buildFooter(), ], ), ), ), ), ), ), ); } }

这里需要说明GestureDetector的手势识别:如果你直接使用onPanEnd,它会吞掉页面内的所有滑动事件。在 2048 这种全屏游戏场景里没问题,但如果页面上还有其他可滚动组件,就会冲突。更优雅的方案是用Listener监听原始指针事件,或者用onPanUpdate累计位移判断方向。我实际调试中发现,直接把GestureDetector包在棋盘区域、并且记录onPanDown的起始坐标再在onPanEnd做差值,误触概率小很多。

5.3 方块与棋盘 UI 细节

棋盘 UI 我用了GridView.count,简单清晰:

Widget _buildBoard() { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.count( crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, physics: const NeverScrollableScrollPhysics(), children: [ for (var i = 0; i < 4; i++) for (var j = 0; j < 4; j++) _buildTile(_game.board[i][j]), ], ), ), ); } Widget _buildTile(int value) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: getTileColor(value), borderRadius: BorderRadius.circular(6), ), child: value == 0 ? const SizedBox() : Text( '$value', style: TextStyle( fontSize: value >= 1024 ? 22 : value >= 128 ? 28 : 34, fontWeight: FontWeight.bold, color: value <= 4 ? const Color(0xFF776E65) : Colors.white, ), ), ); }

这里我特意给每个Container设置了固定圆角,和背景色Color(0xFFBBADA0)一致,让棋盘更精致。方块文字的大小根据数字位数动态调整,避免 2048 这种四位数撑满格子后溢出。

计分板放在棋盘上方:

Widget _buildHeader() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '2048', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _scoreChip('得分', _game.score), const SizedBox(height: 8), _scoreChip('最高分', _game.bestScore), ], ), ], ); } Widget _scoreChip(String label, int value) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(4), ), child: Column( children: [ Text( label, style: const TextStyle(fontSize: 12, color: Color(0xFFEEE4DA)), ), Text( '$value', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold), ), ], ), ); }

效果就是经典 2048 风格:左上角大标题,右上角两个分数标签。

5.4 游戏结束和胜利弹窗

当游戏结束或者玩家达到 2048 时,我弹出一个简单的对话框:

void _checkGameStatus() { if (_game.gameOver) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('游戏结束'), content: Text('最终得分:${_game.score}'), actions: [ TextButton( onPressed: () { Navigator.pop(context); _reset(); }, child: const Text('再来一局'), ), ], ), ); } else if (_game.win) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('你赢了!'), content: const Text('你已经合成 2048,是否继续挑战更高分数?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('继续'), ), TextButton( onPressed: () { Navigator.pop(context); _reset(); }, child: const Text('重新开始'), ), ], ), ); } }

这里有个小细节:showDialog之后如果玩家选择“继续”,弹窗会关闭,但_game.win仍然是 true,每次移动都会弹窗。所以我处理时,当玩家选择继续后,把_game.win置为 false,只保留gameOver的判断。这个小问题如果不处理,会严重影响体验。

5.5 运行效果与构建部署

在浏览器中运行flutter run -d chrome后,游戏就能玩了。想生成静态部署文件,运行:

flutter build web

生成的文件在build/web目录下,把里面的文件上传到任意静态网站托管服务,或者用python -m http.server 8080本地预览,就能得到一个在线可玩的 2048。

构建 Web 时有个优化点:默认 Flutter Web 会有比较重的加载体积,如果你追求加载速度,可以用--web-renderer html--web-renderer canvaskit参数选择渲染器。默认现在是 canvaskit,效果更好,但体积略大。对于小游戏项目,默认就好,不必刻意优化。

6. 踩坑记录:Trae + Flutter Web 的常见问题与排查

6.1 Trae 生成代码后运行报错怎么办

AI 生成代码最大的问题是“看起来像对的,实际上不一定能跑”。比如我第一次让 Trae 生成 2048 代码时,它用了Math.max这种 JavaScript 风格的写法,在 Dart 里根本不存在。这时候把报错信息直接粘贴给 Trae,它会立刻修正。

我的排查经验是:让 AI 生成代码后,先运行flutter analyze做静态检查,它会列出所有语法错误和类型问题。把输出贴给 Trae 说“修复这些错误”,比你自己一行行改要高效得多。

还有一个高频问题是 Trae 会生成重复的方法定义,或者用了未导入的库。比如它可能在main.dart里用了dart:math但没加import 'dart:math';。这类问题flutter analyze都会提示,按提示修复即可。

6.2 Flutter Web 键盘事件的焦点问题

Flutter Web 上监听键盘有一个常见的坑:KeyboardListenerFocus组件需要有焦点才能收到键盘事件。页面刚加载时,焦点并不在游戏区域,玩家按方向键没反应。

解决办法就是在initState里延迟请求焦点:

WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); });

另外,点击棋盘区域时焦点可能会被抢走,所以我在GestureDetectoronTap里也加了_focusNode.requestFocus(),确保点击过后方向键依然可用。

6.3 手势滑动方向误判

我最初用onPanEndvelocity判断滑动方向,但实测有个问题:如果玩家滑动速度偏慢,pixelsPerSecond的值可能不稳定,甚至为 0,导致方向判断失败。

更稳的方式是记录起始位置:

Offset _startPos = Offset.zero; onPanDown: (details) { _startPos = details.globalPosition; }, onPanEnd: (details) { final dx = details.globalPosition.dx - _startPos.dx; final dy = details.globalPosition.dy - _startPos.dy; if (dx.abs() > dy.abs()) { _handleMove(dx > 0 ? Direction.right : Direction.left); } else { _handleMove(dy > 0 ? Direction.down : Direction.up); } },

这样方向判断完全基于实际位移,不再依赖速度估算,准确率明显提高。同时我还加了最小滑动距离判断,比如位移绝对值小于 10 就不触发,避免误触。

6.4 页面溢出和响应式适配

Flutter Web 和移动端最大的区别是浏览器窗口可以随意缩放。如果把标题、分数栏、棋盘垂直排列,窗口高度不够时会出现RenderFlex overflowed报错。

我的解决办法是用SingleChildScrollView包住整个 Column,同时用ConstrainedBox限制最大宽度 500,让内容在宽度较大的屏幕上居中显示,在高度有限的屏幕上可以滚动。这个适配方案在桌面浏览器和移动浏览器上表现都不错。

6.5 最高分持久化

默认情况下,刷新页面后最高分会清零。为了让最高分能保存下来,需要用shared_preferences插件。在pubspec.yaml里添加依赖:

dependencies: flutter: sdk: flutter shared_preferences: ^2.2.2

然后在Game2048类里把bestScore的读写改成异步方法:

Future<void> loadBestScore() async { final prefs = await SharedPreferences.getInstance(); bestScore = prefs.getInt('best_score') ?? 0; } Future<void> saveBestScore() async { final prefs = await SharedPreferences.getInstance(); await prefs.setInt('best_score', bestScore); }

这里要注意shared_preferences在 Web 端底层用的是 localStorage,刷新后依然有效。如果是首次加载走网络,可能会有短暂延迟,但不影响正常使用。有了这个功能,每次得分刷新后调用saveBestScore(),就能跨刷新保持最高分。

6.6 动画缺失:为什么方块合并时没有过渡效果

如果直接用GridView刷新棋盘,方块的变化是“瞬移”的,没有平滑移动和合并动画。虽然能玩,但少了很多乐趣。

要加入动画,比较基础的做法是把棋盘换成Stack,每个方块用AnimatedPositioned控制位置。这样当你移动方向时,方块会平滑地滑到新位置。

AnimatedPositioned( duration: const Duration(milliseconds: 120), top: top, left: left, child: _buildTile(value), )

计算topleft的位置,用行索引乘以格子大小。格子大小可以用LayoutBuilder动态获取。这个方案实现不算复杂,但它需要你把二维数组遍历后,用一个列表维护每个方块的位置和值,而不是直接依赖GridView的自动布局。

我实现的过程中还发现一个细节:合并的两个方块,一个要消失、一个要变大,单靠AnimatedPositioned还不够。更完整的效果需要“合并时新方块有一个缩放动画”。我用了ScaleTransition,在检测到某个格子刚产生合并值时,播放一个从 1.0 到 1.2 再到 1.0 的缩放。这个效果让游戏手感提升了一个档次。

6.7 打包体积太大

如果你是第一次用 Flutter Web 打包,可能会被build/web目录里几个 MB 的 JS 文件吓到。这是正常的,Flutter Web 的默认产物是 CanvasKit 渲染器,自带渲染引擎,所以体积大。如果你想压缩,可以试试:

flutter build web --web-renderer html --release

HTML 渲染器体积更小,但渲染某些复杂效果时会有区别。对于 2048 这种矩形方块游戏,HTML 渲染器完全够用,加载速度还更快。

另外还有一个优化思路:使用flutter build web --pwa-strategy none可以去掉 Service Worker 相关的代码。这对静态部署非常有用,否则在部分静态托管环境下,Service Worker 可能会缓存旧版本导致更新不及时。

7. Trae 使用心得:AI IDE 写项目要注意什么

7.1 明确的需求描述是高效生成的前提

我这次体验下来,最重要的一个体会是:问 AI 要问得具体。如果你只说“帮我写一个 2048 游戏”,它可能会给你一个能用但不一定符合你预期的版本;但如果你说“用 Flutter 写 2048,4x4 棋盘,方向键和滑动手势控制,数字方块用经典配色,分数和最高分显示在右上角,支持本地持久化”,它生成的效果会精准很多。

Trae 的对话窗口支持多轮对话,你可以先让它生成一版,然后提出修改意见,比如“把背景色改成浅黄色”“给方块加圆角”“最高分存本地”。这种渐进式打磨的效果远比一次性要一个终极版可靠。

7.2 人和 AI 的分工:哪些可以交给 AI,哪些要自己把握

AI 在哪些环节比较强?首先是生成样板代码,比如项目骨架、重复性高的 UI 结构;其次是解释报错信息,让它帮你分析日志可以省去你到处搜索的时间;最后是“翻译”代码,比如把一个 JavaScript 的算法改成 Dart,AI 做得很好。

但有些东西我建议你自己掌握。一是算法正确性,AI 生成的合并逻辑偶尔会有边界 case 错误,比如我遇到过一次“同一行可以连续合并”的错误,就是[2,2,2,2]被算成了[8,0,0,0],这就是典型的 bug,需要人懂算法才能发现并用flutter analyze和测试用例揪出来。二是用户体验细节,AI 不会主动帮你考虑“弹窗后继续游戏会重复出现”“手势误触”“窗口缩放溢出”这些问题,这些需要你以玩家的视角去测试,然后反馈给 AI 修改。

7.3 用 Trae 调代码的实战技巧

如果你也在用 Trae 写项目,这里分享几个我实际用着很顺手的操作习惯:

  • 遇到报错时,不要只贴错误码,把相关代码段一起复制进对话,AI 的回复质量会明显提升。
  • 让 AI 给你“解释代码”时,指定它的解释对象,比如“给我解释这段代码,假设我是刚学 Flutter 的新手”,它会输出更适合你水平的答案。
  • 改动需求时,尽量描述“期望效果”而不是“实现方式”。比如说“我希望方块合并时有个弹跳的感觉”,比“帮我加一个 ScaleTransition 包着一个 AnimatedScale”更能让 AI 发挥。
  • 定期对你自己的代码做一次手动 review。AI 生成的代码有时会有冗余或潜在问题,比如它可能每次按键都重新创建状态、迂回地处理简单逻辑。你完全可以手动精简,这也是学习的过程。

7.4 Trae 积分消耗快,怎么用更省

Trae 的 AI 功能是有积分体系的,简单说就是 AI 生成和修改代码会消耗积分。我在几天使用中总结了一些省积分的方法:

  • 先本地把需求想清楚,一口气告诉它,避免多次反复对话。
  • 能用 Chat 模式解决的小问题,比如查个语法、问个 API 用法,不要频繁开 Builder 模式让它大改代码。
  • 对于已经生成好的文件,尽量减少“全量重写”的指令,用“只修改第 XX 行”这种精确描述。
  • 如果只是要 AI 解释一段代码,它默认也会消耗积分,所以我通常先看报错,尽量自己定位,再让 AI 验证。

顺带一提,Trae 有时会不定期有兑换码活动,遇到的话可以兑换积分。这个信息你自己留意官方渠道就行,我不展开讲。

7.5 工具再好用,基础能力不能丢

最后说点真心话。Trae 这一类 AI IDE 确实让开发效率上了一个台阶,尤其是对 Flutter Web 这种本就适合快速迭代的技术栈,AI 能帮你省掉大量模板代码的编写时间。但这并不意味着基础能力可以完全交给工具。2048 这种小项目,你如果不懂二维数组操作、不懂合并算法的边界条件,AI 生成的代码即使能跑,你也不知道它哪里埋了雷。

我的建议是:未来写项目可以默认开启 AI 辅助,但每生成一段核心代码,你都应该读懂它、跑通它、用几个边界 case 验证它。这样 AI 是你的杠杆,而不是你的外脑替代品。

这个 2048 项目本身就很小,非常适合拿来练这种“人和 AI 协作”的节奏。你完全可以把它扩展成 6x6 的进阶版、加入撤销功能、接入排行榜后端,每一个扩展都是对 Flutter Web 能力的一次深入探索。按照我上面的代码基础,扩展这些功能并不会花太多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询