尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter Web实战:用Trae从零开发2048小游戏(含核心算法)

Flutter Web实战:用Trae从零开发2048小游戏(含核心算法) 最近我用 Trae 把一个经典的 2048 复刻到了 Flutter Web 上从创建工程到核心算法、手势交互、打包部署整个流程走下来非常顺。这篇文章就把我的完整实现思路和可运行代码整理出来包含核心合并算法、界面渲染、键盘与滑动手势以及 Flutter Web 开发里常见的几个坑。适合正在学 Flutter、想用 AI 编程工具做点实物的同学参考代码逻辑也很适合作为入门小游戏的练手项目。1. 项目定位2048 小游戏为什么适合用 Flutter Web 做1.1 选 Trae、Flutter、Web 三方组合的理由先聊选型。2048 这个游戏规则简单但内部藏着数组操作、状态管理、手势识别、渲染刷新这些基本功是练习 Flutter Web 非常合适的项目。相比写一个商城页面或者后台管理系统2048 不用处理网络请求、不用设计复杂的数据模型可以专心把交互和状态玩明白。Trae 在这个项目里的角色是“AI 编程伙伴”。我用它来生成初始代码、补全重复性界面代码、分析报错信息。实际体验下来它能省掉很多查文档的时间特别是在生成网格布局、动画容器这类样板代码时效率提升很明显。但要注意AI 生成的代码不等于可靠代码核心算法必须自己 review这也是我把 2048 当作练手项目的原因之一你可以放心大胆地让 Trae 写代码然后自己再去做逻辑验证这个过程本身就很能锻炼工程判断力。Flutter Web 的优势在于一套代码可以同时跑在浏览器、桌面和移动端开发时用热重载改样式特别舒服。虽然首屏加载体积比纯 HTML/JS 大但这种轻量小游戏根本感觉不到作为学习项目完全够用。1.2 把 2048 的游戏规则拆成可编码的模块2048 的核心规则可以分成四个部分一个 4x4 网格初始时随机出现两个数字方块通常是 2 或 4。玩家通过滑动或方向键让所有方块朝一个方向移动相同数字相遇时合并成它们的和每个数字每一轮只能参与一次合并。每次有效移动后会在空格子里随机生成一个新方块。当任意方块达到 2048 时玩家获胜当网格被填满且上下左右都没有相邻相同数字时游戏结束。这四句话看起来简单翻译成代码时却是两个完全不同的模块。一个是“游戏状态”负责存储矩阵数据、分数、游戏是否结束另一个是“交互表现”负责接收手势或键盘事件、渲染 4x4 网格、播放简单动画。我从一开始就把这两个模块分开。游戏状态部分独立成一个不依赖 Flutter 的 Dart 类这样不仅可以在 Dart 侧做单元测试以后想换 UI、接 AI 自动演示、或者改成命令行版都特别方便。很多新手容易把数据逻辑直接写进 Widget 里结果改一个功能要翻半天代码这个教训我踩过。1.3 整体数据流与状态管理方式整个应用的数据流其实是一条直线用户滑动屏幕手势回调通知 Game 对象执行移动操作Game 内部更新二维数组和分数最后通知 Flutter 界面重新渲染。状态管理我选了最简单的 ChangeNotifier 思路Game 对象持有一份 4x4 数据界面通过 setState 触发重建。这个项目规模不大用不到 Riverpod 或 Bloc 这种重型方案否则光学习状态管理框架的成本就超过了游戏本身。等你想给项目加“撤销上一步”或“自动演示”功能时再引入更系统的状态管理也不迟。2. 环境准备Trae 与 Flutter Web 开发环境搭建2.1 安装 Flutter 并启用 Web 支持环境搭建这个环节看似基础但第一次做 Flutter Web 的人经常会卡在这里。我列一下我的操作步骤。先下载 Flutter SDK解压到指定目录后把 bin 目录加入系统 PATH。然后打开终端执行flutter doctor它会帮你检查 Flutter 依赖的环境是否齐全。如果你附近的网络下载官方 SDK 比较慢可以配置国内的镜像源主要是两个环境变量PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL指向可用的镜像地址即可。接着执行flutter config --enable-web flutter doctor看到 Chrome 那一项正常就可以继续了。注意flutter doctor里经常会提示 Visual Studio 找不到或者 Android toolchain 缺失这不是 Web 开发必需的尤其是“unable to find suitable visual studio toolc”这种提示只有在你要构建 Windows 桌面端的时候才需要处理做 Web 版可以直接忽略。版本方面建议直接用稳定版。Flutter 的版本迭代很快有些 API 在不同版本间有差异我后面讲代码时也会提到几个典型的版本坑。2.2 配置 Trae安装 Flutter 插件与调试器Trae 本身是一个 AI IDE内置了代码补全和对话功能但 Flutter 相关的扩展还是需要装一下。进入插件市场搜索“Flutter”和“Dart”安装后 Trae 会自动识别本机的 Flutter SDK。这里有一个关键配置要确保 Trae 右下角显示的 Flutter SDK 路径正确。如果之前机器上装过多个 Flutter 版本路径指错了会出现莫名其妙的问题。另外建议在 Trae 的设置里把默认终端切换成系统终端这样能直接使用你配置好的 PATH 环境变量避免终端里找不到flutter命令。Web 调试目标也要提前选好我一般直接用 Chrome。运行项目时用内置终端执行flutter run -d chrome第一次运行会做 Web SDK 的编译需要等一会儿之后再热重载就快很多了。2.3 创建项目与代码目录规划用命令创建工程时我特意指定了平台避免生成一堆用不到的原生工程目录flutter create --platformsweb game_2048生成的 lib 目录里我会按这样的结构组织代码main.dart应用入口和主界面布局。game_logic.dart2048 核心逻辑包括数据结构、移动合并、随机生成和胜负判断不依赖 Flutter。board_widget.dart棋盘网格渲染组件。这个分层的好处是游戏逻辑可以脱离 UI 单独测试我在改 UI 配色的时候完全不用担心搞坏算法。3. 核心算法与 UI 实现细节3.1 4x4 网格的数据结构与运动规则我直接用ListListint表示整个棋盘外层是行内层是列空位用 0 表示。这样一个二维数组就能描述全部游戏状态。真正的难点在于“滑动”这个操作。看似四个方向本质只有一种操作把一排非零数字按顺序压缩到一侧并且相邻且相同的数字合并一次。比如一行[0, 2, 2, 4]向左移动后应该是[4, 4, 0, 0]向右移动后是[0, 0, 4, 4]。四个方向可以分别写四个函数但那样代码冗余还容易在某个方向写错逻辑。更优雅的做法是利用矩阵旋转把矩阵旋转到某个角度后四次方向都统一成“左移”执行完再旋转回去。比如上移操作就是把棋盘顺时针旋转 90 度执行左移再逆时针旋转 90 度恢复。右移则可以先把每一行反转左移再反转回来。这样核心算法只需要写一个左移和一个旋转函数既好理解也方便测试。3.2 合并逻辑与常见陷阱合并逻辑是这个小游戏里最容易写错的地方。先从一维行处理开始Listint _mergeLine(Listint line) { final nums line.where((e) e ! 0).toList(); final result int[]; var i 0; while (i nums.length) { if (i 1 nums.length nums[i] nums[i 1]) { result.add(nums[i] * 2); score nums[i] * 2; i 2; } else { result.add(nums[i]); i 1; } } while (result.length 4) { result.add(0); } return result; }这段代码的注意力集中在第 6 行的条件判断。当两个相邻数字相等时合并成它们的和然后下标跳过两个数。这样做避免了经典的“连消”错误比如[2, 2, 2, 2]向左移动正确结果是[4, 4, 0, 0]而不是[8, 0, 0, 0]。如果不用标记数组也不跳下标就会出现一个方块被反复合并的问题。原始的数字 2048 规则里每个方块每轮只能合并一次所以这种“从左到右两两配对”的算法是最稳妥的。做完一维合并再借助旋转处理二维棋盘。矩阵旋转 90 度的公式不复杂顺时针旋转后原位置(i, j)的元素会落到(j, n-1-i)ListListint _rotateClockwise(ListListint grid) { final n grid.length; final rotated List.generate(n, (_) Listint.filled(n, 0)); for (var i 0; i n; i) { for (var j 0; j n; j) { rotated[j][n - 1 - i] grid[i][j]; } } return rotated; } ListListint _rotateCounterClockwise(ListListint grid) { final n grid.length; final rotated List.generate(n, (_) Listint.filled(n, 0)); for (var i 0; i n; i) { for (var j 0; j n; j) { rotated[n - 1 - j][i] grid[i][j]; } } return rotated; }然后移动处理就能统一成bool move(String direction) { final before board.map((row) Listint.from(row)).toList(); if (direction left) { board board.map(_mergeLine).toList(); } else if (direction right) { board board .map((row) row.reversed.toList()) .map(_mergeLine) .map((row) row.reversed.toList()) .toList(); } else if (direction up) { var rotated _rotateClockwise(board); rotated rotated.map(_mergeLine).toList(); board _rotateCounterClockwise(rotated); } else if (direction down) { var rotated _rotateCounterClockwise(board); rotated rotated.map(_mergeLine).toList(); board _rotateClockwise(rotated); } final changed !_sameBoard(before, board); if (changed) { addRandomTile(); checkWonAndGameOver(); } return changed; }这里有个很重要的细节每次移动前要把原始棋盘存一份快照移动后比较是否发生了变化。如果玩家往一个完全没有空隙的方向滑动本质上是无效操作此时不应该生成新方块也不应该刷新界面。这个判断很多初学者会漏掉导致棋盘越玩越多方块。3.3 新方块生成、得分与胜负判定有效移动之后需要生成新方块。最简单的做法是先收集所有空位随机挑一个填上数字void addRandomTile() { final empty (int, int)[]; for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (board[i][j] 0) empty.add((i, j)); } } if (empty.isEmpty) return; final cell empty[Random().nextInt(empty.length)]; board[cell.$1][cell.$2] Random().nextDouble() 0.9 ? 2 : 4; }生成 2 的概率是 90%生成 4 的概率是 10%这个比例是 2048 比较经典的设计。注意这里用到了 Dart 的 record 语法(int, int)如果你的 Flutter 版本比较老不支持这种写法可以改成一个简单的坐标类或者直接用两个变量保存下标。得分规则比较简单每当两个方块合并成一个就把合并后的数值累加到总分里而不是移动一次加固定分数。这样做更符合游戏直觉分值也更能反映玩家的操作质量。胜负判断分两步胜利条件棋盘里有任一格子达到 2048。失败条件棋盘没有空格且任意相邻格子都不相等。void checkWonAndGameOver() { for (var row in board) { if (row.contains(2048)) { won true; return; } } for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (board[i][j] 0) return; if (j 1 4 board[i][j] board[i][j 1]) return; if (i 1 4 board[i][j] board[i 1][j]) return; } } gameOver true; }注意胜利和失败并不是互斥的玩家完全有可能生成了 2048 之后继续玩到棋盘填满。所以判断胜利后不要立刻锁住棋盘而是把控制权留给玩家选择是否继续。3.4 Flutter Web 界面渲染与手势识别界面部分最核心的是 4x4 网格渲染。我用GridView.builder来画棋盘每个格子根据数值显示不同的背景色和文字。Flutter 的网格布局相当方便指定crossAxisCount: 4就能完成棋盘骨架。为了给方块一点交互反馈我给每个格子套了AnimatedContainer数值变化时颜色和大小会有 150 毫秒的过渡动画。这个动画量级不会干扰游戏节奏也不会让代码变得复杂。手势识别用GestureDetector的onPanStart和onPanEnd就行void _handlePanStart(DragStartDetails details) { _startX details.globalPosition.dx; _startY details.globalPosition.dy; } void _handlePanEnd(DragEndDetails details) { final dx details.globalPosition.dx - _startX; final dy details.globalPosition.dy - _startY; if (dx.abs() 20 dy.abs() 20) return; setState(() { if (dx.abs() dy.abs()) { game.move(dx 0 ? right : left); } else { game.move(dy 0 ? down : up); } }); }这个实现的核心思路是记录手势起点和终点计算横向和纵向位移哪边位移大就判定为哪个方向。20 像素的阈值是为了防止误触。Web 端还应该支持键盘操作方向键上下左右控制移动这样才能在电脑上玩得顺手。Flutter 里可以用KeyboardListener监听按键事件配合Focus组件让页面自动获得焦点。4. 完整实操记录从空白工程到可玩 Web 小游戏4.1 用 Trae 自然语言生成核心代码新建工程之后我第一步并不是直接写代码而是先打开 Trae 的对话框输入需求“请用 Dart 写一个 2048 核心逻辑类包含 move 方法、随机生成方块、胜负判断不要依赖 Flutter UI。”Trae 很快生成了一个类整体结构是可用的但它的初始版本里有两个典型问题move方法没有判断棋盘是否真的发生了变化无效滑动也会生成新方块。合并逻辑没有处理“一个方块一轮只能合并一次”的限制[2, 2, 2, 2]这种情况会被一次吞成 8。这两个问题恰好是 2048 的经典算法考点。我把问题反馈给 Trae让它重新生成同时自己也手动改了一遍_mergeLine。这里想强调一下 AI 辅助开发的边界让 AI 写重复样板和初步骨架完全没问题但核心算法必须自己懂原理否则连它哪里写错了都看不出来。4.2 核心代码文件game_logic.dart我把game_logic.dart整理成下面这个完整版本它不依赖 Flutter只负责游戏状态管理import dart:math; class Grid2048 { ListListint board; int score 0; bool gameOver false; bool won false; Grid2048({ListListint? initialBoard, this.score 0}) : board initialBoard ?? List.generate(4, (_) List.filled(4, 0)) { if (initialBoard null) { addRandomTile(); addRandomTile(); } else { checkWonAndGameOver(); } } void reset() { board List.generate(4, (_) List.filled(4, 0)); score 0; gameOver false; won false; addRandomTile(); addRandomTile(); } Listint _mergeLine(Listint line) { final nums line.where((e) e ! 0).toList(); final result int[]; var i 0; while (i nums.length) { if (i 1 nums.length nums[i] nums[i 1]) { result.add(nums[i] * 2); score nums[i] * 2; i 2; } else { result.add(nums[i]); i 1; } } while (result.length 4) { result.add(0); } return result; } ListListint _rotateClockwise(ListListint grid) { final n grid.length; final rotated List.generate(n, (_) Listint.filled(n, 0)); for (var i 0; i n; i) { for (var j 0; j n; j) { rotated[j][n - 1 - i] grid[i][j]; } } return rotated; } ListListint _rotateCounterClockwise(ListListint grid) { final n grid.length; final rotated List.generate(n, (_) Listint.filled(n, 0)); for (var i 0; i n; i) { for (var j 0; j n; j) { rotated[n - 1 - j][i] grid[i][j]; } } return rotated; } bool move(String direction) { final before board.map((row) Listint.from(row)).toList(); if (direction left) { board board.map(_mergeLine).toList(); } else if (direction right) { board board .map((row) row.reversed.toList()) .map(_mergeLine) .map((row) row.reversed.toList()) .toList(); } else if (direction up) { var rotated _rotateClockwise(board); rotated rotated.map(_mergeLine).toList(); board _rotateCounterClockwise(rotated); } else if (direction down) { var rotated _rotateCounterClockwise(board); rotated rotated.map(_mergeLine).toList(); board _rotateClockwise(rotated); } final changed !_sameBoard(before, board); if (changed) { addRandomTile(); checkWonAndGameOver(); } return changed; } bool _sameBoard(ListListint a, ListListint b) { for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (a[i][j] ! b[i][j]) return false; } } return true; } void addRandomTile() { final empty (int, int)[]; for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (board[i][j] 0) empty.add((i, j)); } } if (empty.isEmpty) return; final cell empty[Random().nextInt(empty.length)]; board[cell.$1][cell.$2] Random().nextDouble() 0.9 ? 2 : 4; } void checkWonAndGameOver() { for (var row in board) { if (row.contains(2048)) { won true; return; } } for (var i 0; i 4; i) { for (var j 0; j 4; j) { if (board[i][j] 0) return; if (j 1 4 board[i][j] board[i][j 1]) return; if (i 1 4 board[i][j] board[i 1][j]) return; } } gameOver true; } }这个文件可以直接用dart test做单元测试也可以被任意 UI 包装。4.3 核心代码文件main.dart接着是主界面。我把整个界面写在一个文件里方便阅读。项目结构简单的时候不用过早纠结拆分。import package:flutter/material.dart; import package:flutter/services.dart; import game_logic.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(colorSchemeSeed: Colors.brown, useMaterial3: true), home: const HomePage(), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); override StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage { final Grid2048 game Grid2048(); double _startX 0; double _startY 0; void _handlePanStart(DragStartDetails details) { _startX details.globalPosition.dx; _startY details.globalPosition.dy; } void _handlePanEnd(DragEndDetails details) { final dx details.globalPosition.dx - _startX; final dy details.globalPosition.dy - _startY; if (dx.abs() 20 dy.abs() 20) return; setState(() { if (dx.abs() dy.abs()) { game.move(dx 0 ? right : left); } else { game.move(dy 0 ? down : up); } }); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is KeyDownEvent || event is KeyRepeatEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowLeft: setState(() game.move(left)); return KeyEventResult.handled; case LogicalKeyboardKey.arrowRight: setState(() game.move(right)); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: setState(() game.move(up)); return KeyEventResult.handled; case LogicalKeyboardKey.arrowDown: setState(() game.move(down)); return KeyEventResult.handled; default: break; } } return KeyEventResult.ignored; } 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: Focus( autofocus: true, onKeyEvent: _handleKeyEvent, child: GestureDetector( onPanStart: _handlePanStart, onPanEnd: _handlePanEnd, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 2048, style: Theme.of(context).textTheme.headlineMedium, ), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.brown.shade200, borderRadius: BorderRadius.circular(8), ), child: Text(分数 ${game.score}), ), const SizedBox(width: 8), TextButton( onPressed: () { setState(() game.reset()); }, child: const Text(重开), ), ], ), ], ), const SizedBox(height: 16), Expanded(child: BoardView(game: game)), const SizedBox(height: 16), const Text(滑动屏幕或使用方向键操作), ], ), ), ), ), ), ), ), ); } } class BoardView extends StatelessWidget { final Grid2048 game; const BoardView({super.key, required this.game}); Color _colorFor(int value) { const colors { 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), }; return colors[value] ?? const Color(0xFF3C3A32); } override Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.brown.shade400, borderRadius: BorderRadius.circular(12), ), child: GridView.builder( physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: 16, itemBuilder: (context, index) { final row index ~/ 4; final col index % 4; final value game.board[row][col]; return AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: _colorFor(value), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( value 0 ? : $value, style: TextStyle( fontSize: value 1024 ? 20 : 32, fontWeight: FontWeight.bold, color: value 4 ? Colors.black54 : Colors.white, ), ), ); }, ), ), ); } }关于AnimatedContainer有个说明它只对颜色、尺寸这类属性做过渡真正实现“方块滑动位移”需要更复杂的布局计算。我这个版本追求的是逻辑清晰和代码可读所以没有做完整的滑动位移动画但核心玩法完全不受影响。想加动画的话后续可以在map阶段记录每个格子的旧位置和新位置再用Transform或AnimationController实现移动这是一个很好的扩展练习。4.4 编译运行与打包部署开发模式下直接运行flutter run -d chrome看到棋盘初始生成两个方块用方向键和鼠标拖动都能操作说明基本功能已经完成。发布版本的构建命令是flutter build web --release构建产物在build/web目录下里面的文件可以直接扔到任意静态服务器上。我在本地用 nginx 做过托管测试只需要把location /指向build/web即可。由于这个项目没有用路由刷新页面不会出现 404 问题。如果你想部署到 GitHub Pages 之类的地方也可以直接把build/web的内容推到仓库里。需要注意 Flutter Web 的产物包含service worker相关逻辑它是用来做资源缓存的。如果你更新版本后浏览器仍然显示旧页面可以试试强制刷新或者在部署时清理浏览器的站点数据这个缓存机制在开发小游戏时偶尔会给你“明明改了代码却看不到变化”的错觉。5. 常见问题与排查技巧实录5.1 滑动方向不准与键盘事件失效滑动方向不准通常有两个原因一个是阈值设得太小手指轻微抖动就被判定为一次移动另一个是页面里的滚动组件抢占了手势。我的方案是把阈值设为 20 像素并计算水平和纵向位移的绝对值差取较大者为移动方向。键盘事件失效的场景多半是页面没有获得焦点。我在根布局包了一层Focus并设置autofocus: true这样打开页面时键盘事件就能被捕获。如果按了方向键没反应先点一下页面任意位置再按方向键。5.2 合并逻辑错误与数组越界合并逻辑最常见的问题是“连消”。比如某一行变成[4, 4, 8, 0]时你发现它被一次合并成[16, 0, 0, 0]说明你的合并函数让当前方块参与了多次合并。解决办法就是我上面_mergeLine里写的合并成功时下标i 2否则i 1保证每个数字一轮最多被合并一次。数组越界多半发生在尝试修改二维数组时忘记处理边界。旋转函数里很容易出现下标反了的情况我的建议是先用一个 2x2 的小矩阵在纸上手动验算一遍把(0,1)放到旋转后的哪个位置然后对着代码检查。5.3 Trae 生成代码的版本适配问题Trae 在生成 Dart 代码时经常默认使用最新语法。比如我在第 3 章用到的 record(int, int)在较老的 Flutter 版本里是编译不过的。遇到这种情况不要慌直接把报错信息复制到 Trae 对话框它会帮你改写。另一个典型问题是onKeyEvent这个 API 在 Flutter 3.x 不同版本里的参数类型有差异。如果你用的是旧版本可以改成onKey或参考当前版本的 Flutter API 文档。这里想强调AI 工具生成代码的版本敏感度完全依赖你的环境用之前先确认自己 Flutter SDK 版本或者让 Trae 限定“兼容 Flutter 3.10 版本”再生成。5.4 排错速查表我把这一路踩过的坑总结成表方便你对照排查现象可能原因解决办法方向键无反应页面没有焦点用 Focus autofocus 包裹棋盘滑动一次触发多次移动阈值太小位移差绝对值小于 20 时忽略方块被连续合并合并后未跳下标合并时让下标 i 2无效滑动也会生成新方块没有判断棋盘前后变化移动前后比较棋盘快照编译报 record 语法错误Flutter 版本过旧改用坐标类或升级 Flutter浏览器显示旧版本Service Worker 缓存强制刷新或清理站点数据构建缓慢或卡住首次下载 Web SDK检查网络配置镜像源后重试一点后续扩展思路做完基础功能后我给这个项目又加了几个升级方向你可以按兴趣选一个继续练手。第一个是操作历史与撤销功能。只需要在每次移动前把棋盘和分数放入一个栈就能实现 CtrlZ 式的撤销这对游戏体验提升非常大实现成本也不高。第二个是自动演示模式。写一个简单的 AI 策略让它在浏览器里自己玩 2048规则很简单优先向左移动不能移动时再尝试其他方向高分区域保持在角落。然后结合 5.2 的动画改造就能做出一个“看 AI 玩 2048”的演示页面这个项目用来练习 Dart 异步和动画控制非常合适。第三个是本地记录。把最高分存到 localStorageFlutter Web 里可以用shared_preferences插件或者直接通过dart:js_interop调用 localStorage这个需求能帮你理解 Web 端与浏览器 API 的交互方式。我个人更推荐从撤销开始因为它的逻辑清晰又能加深对状态管理的理解。总之这个标题虽然叫“开发 2048”但真正有价值的是过程中关于数据结构、状态管理、手势交互、AI 辅助开发的整套实践这些经验换个项目照样能用。
返回列表