Flutter Web版2048游戲)
前陣子我一直想找個(gè)項(xiàng)目把 Trae 的完整工作流摸熟。對(duì)比了幾個(gè)方向之后我選了用 Trae 從 0 到 1 開發(fā)一個(gè) Flutter Web 小游戲 2048。這個(gè)項(xiàng)目體量不大但狀態(tài)管理、手勢(shì)交互、Web 構(gòu)建、動(dòng)畫反饋這些東西全都涉及非常適合練手。這篇文章既是我這次開發(fā)的完整復(fù)盤也會(huì)把最終整理好的可運(yùn)行代碼直接貼出來。想跟著做的人只要本地有 Flutter SDK就能把下面的代碼跑起來。如果沒有基礎(chǔ)也不慌我會(huì)先把 2048 本身的數(shù)據(jù)結(jié)構(gòu)講清楚再講怎么用 Trae 把代碼一步步生成出來最后再說 Web 端那些特別容易踩的坑。整個(gè)過程我盡量按真實(shí)開發(fā)的順序來寫而不是事后整理出的“標(biāo)準(zhǔn)答案”。1. 為什么做這個(gè)項(xiàng)目2048 用來練手比想象中劃算1.1 一個(gè)小游戲里藏著的完整前端基本功很多人練 Flutter 都是從待辦清單、計(jì)數(shù)器開始但那樣的項(xiàng)目只覆蓋了 UI 組件和 setState連鍵盤事件和頁面生命周期都碰不到。2048 不一樣它麻雀雖小五臟俱全。玩過 2048 的都知道規(guī)則4×4 的棋盤每次滑動(dòng)所有方塊朝同一個(gè)方向移動(dòng)相同數(shù)字碰撞后會(huì)合并成它們的和每次有效移動(dòng)后棋盤會(huì)隨機(jī)生成一個(gè)新方塊目標(biāo)是把數(shù)字堆到 2048。就這么幾條規(guī)則落到代碼層面你要處理的東西包括二維數(shù)組的狀態(tài)表達(dá)和更新四個(gè)方向移動(dòng)時(shí)“合并”算法的統(tǒng)一抽象隨機(jī)位置生成新方塊游戲結(jié)束和勝利條件判斷鍵盤方向鍵監(jiān)聽觸屏滑動(dòng)的手勢(shì)識(shí)別和閾值控制數(shù)字方塊的顏色映射、尺寸適配重新開始時(shí)的狀態(tài)重置Flutter Web 的構(gòu)建產(chǎn)物和部署。這些知識(shí)點(diǎn)分布在狀態(tài)管理、事件處理、UI 布局、工程構(gòu)建四個(gè)層面。做一遍 2048基本能把 Flutter 從界面到邏輯到打包的路線走通一遍比看十篇教程都管用。1.2 Trae 在項(xiàng)目里扮演的角色這次我特意全程用 Trae 來寫。Trae 這類 AI 編程工具很多人拿來問“這個(gè)報(bào)錯(cuò)怎么解決”“幫我寫個(gè)排序函數(shù)”但真讓它從空目錄開始搭一個(gè)完整的小項(xiàng)目時(shí)很多人反而不知道該怎么指揮。我自己總結(jié)出的用法是把 Trae 當(dāng)成一個(gè)“能馬上執(zhí)行代碼變更的結(jié)對(duì)程序員”而不是搜索引擎。你需要把需求拆成清晰的指令讓它生成初版代碼然后你自己審查、運(yùn)行、帶著報(bào)錯(cuò)回來追問。這個(gè)過程里最重要的是反復(fù)對(duì)話而不是一次性讓它給一個(gè)“完美文件”。2048 的代碼量不算大但邏輯分支很多。直接說“幫我寫一個(gè) 2048 游戲”Trae 大概率會(huì)返回一個(gè)能看的 Demo但未必能兼顧 Web 端的鍵盤事件、手勢(shì)判斷和狀態(tài)重置。所以我在動(dòng)手前先把數(shù)據(jù)建模想清楚了再去寫提示詞。2. 動(dòng)手前的數(shù)據(jù)建模棋盤、移動(dòng)、合并、判定2.1 棋盤用二維數(shù)組還是扁平列表2048 棋盤是 4×4最直覺的存儲(chǔ)方式是ListListint也就是二維數(shù)組。外層四個(gè)元素代表四行內(nèi)層四個(gè)元素代表每行的四個(gè)格子值為 0 表示空格值為 2、4、8 等表示數(shù)字方塊。有人可能會(huì)想既然移動(dòng)和合并只發(fā)生在行或列用一個(gè)長(zhǎng)度為 16 的扁平列表下標(biāo)從 0 到 15行操作和列操作用索引換算豈不是更省內(nèi)存省那幾字節(jié)對(duì)前端項(xiàng)目毫無意義。二維數(shù)組最大的好處是代碼可讀性高。board[1][2]一眼就知道是第 1 行第 2 列的內(nèi)容后續(xù)寫合并算法、隨機(jī)生成新方塊、判斷游戲結(jié)束都更不容易出錯(cuò)。在 AI 輔助編程的場(chǎng)景下可讀性更加重要因?yàn)槟阋汛a貼給 Trae 讓它做二次修改它讀起來也更快。2.2 四個(gè)方向的移動(dòng)怎么復(fù)用同一套邏輯2048 最核心的地方在于移動(dòng)合并。向左滑動(dòng)最簡(jiǎn)單對(duì)每一行從左到右去掉所有 0再把相鄰的相同數(shù)字合并最后補(bǔ) 0。舉個(gè)例子一行數(shù)據(jù)是[0, 2, 2, 4]去掉 0[2, 2, 4]從左開始合并2 和 2 相同合并成 4這一行變成[4, 4]補(bǔ) 0 到長(zhǎng)度 4[4, 4, 0, 0]這個(gè)例子里要注意一個(gè)細(xì)節(jié)一行中每個(gè)方塊在一次移動(dòng)里最多參與一次合并。比如[2, 2, 2, 2]左移之后應(yīng)該是[4, 4, 0, 0]而不是[8, 0, 0, 0]。很多人第一次寫合并算法會(huì)忽略這一點(diǎn)導(dǎo)致分?jǐn)?shù)和方塊數(shù)值都異常。向右移動(dòng)可以轉(zhuǎn)換成“把行反轉(zhuǎn)左移再反轉(zhuǎn)回來”。比如[0, 2, 2, 4]向右移動(dòng)先把行反轉(zhuǎn)成[4, 2, 2, 0]左移之后再反轉(zhuǎn)就得到了正確結(jié)果。向上和向下呢最省事的做法是轉(zhuǎn)置棋盤。把棋盤的行列互換之后“向上”就變成了“向左”。向下同理轉(zhuǎn)置后“向右”。這套抽象的意義在于你只需要寫好一個(gè)_slideAndMerge(Listint row)的純函數(shù)然后通過轉(zhuǎn)置和反轉(zhuǎn)的組合把四個(gè)方向全部映射到它身上。代碼量小正確性也容易驗(yàn)證。2.3 新方塊生成與終局判斷每次有效移動(dòng)后棋盤需要隨機(jī)生成一個(gè)新方塊。生成規(guī)則是先找到所有值為 0 的格子從中隨機(jī)選一個(gè)90% 概率生成 210% 概率生成 4。終局判斷分兩個(gè)方向勝利棋盤中出現(xiàn) 2048失敗棋盤滿格且任意相鄰格子上下左右都沒有相同數(shù)字。失敗判斷很多人寫錯(cuò)只檢查滿格是不夠的。因?yàn)闈M格時(shí)如果還存在可以合并的相鄰數(shù)字玩家仍能通過合并騰出空格游戲就不該結(jié)束。所以必須同時(shí)檢查空位和相鄰相等值。這些邏輯都不依賴 UI可以獨(dú)立成一個(gè)GameBoard類。這個(gè)類只負(fù)責(zé)修改棋盤數(shù)據(jù)不關(guān)心界面怎么渲染后續(xù)測(cè)試和復(fù)用都很方便。3. 提示詞才是關(guān)鍵用 Trae 生成第一版代碼3.1 我用的初始提示詞打開 Trae 新建一個(gè) Flutter 項(xiàng)目之后我在 Chat 模式里給的第一條指令是這樣的你是 Flutter 專家。請(qǐng)為我生成一個(gè)可直接運(yùn)行的 Flutter Web 2048 游戲代碼放在 /lib/main.dart。 要求 1. 只用一個(gè) main.dart 文件不依賴第三方庫 2. 4x4 棋盤使用 StatefulWidget setState 管理狀態(tài) 3. 支持方向鍵和 WASD 控制同時(shí)支持觸屏滑動(dòng)和鼠標(biāo)拖拽 4. 數(shù)字方塊顏色區(qū)分2、4、8、16 等不同數(shù)字用不同背景色 5. 有得分面板游戲結(jié)束和達(dá)到 2048 時(shí)顯示覆蓋層提示 6. 有重新開始按鈕 7. 生成方塊邏輯90% 生成 210% 生成 4 8. 每次移動(dòng)后判斷是否結(jié)束失敗條件包括滿格且無相鄰可合并項(xiàng)。這段提示詞的重點(diǎn)不是“幫我寫個(gè) 2048”而是把規(guī)則、文件路徑、技術(shù)選型、UI 要求全部說清楚。Trae 生成的初版代碼已經(jīng)能跑起來但我又在 Chat 里追加了幾輪修改包括“字體過大的問題”“重新開始時(shí)分?jǐn)?shù)沒有清零”“剛才這段鍵盤監(jiān)聽在 Web 上不生效”它都能針對(duì)性地改。我個(gè)人的感受是提示詞寫得越具體Trae 的產(chǎn)出越接近可用狀態(tài)。尤其是“不依賴第三方庫”和“用 setState”這兩個(gè)約束能避免它自作主張引入 provider 或者 flappy 之類的東西。3.2 Build 模式和 Chat 模式怎么配合Trae 有 Build 模式和 Chat 模式兩種用法。Chat 模式是純對(duì)話會(huì)給建議和代碼片段需要你自己手動(dòng)應(yīng)用。Build 模式會(huì)直接操作文件新建、覆蓋、重構(gòu)都能落地。我的習(xí)慣是剛起步時(shí)用 Chat先看它的方案和邏輯是否合理確認(rèn)沒問題再切到 Build 讓代碼落地。如果一開始就用 Build 模式它可能會(huì)一次性生成你并沒有完全理解的代碼出 bug 后反而不容易排查。到后期改動(dòng)很小的功能比如“把游戲結(jié)束彈窗改成頁面內(nèi)覆蓋層”“調(diào)整一下顏色”我直接切 Build 模式讓它改效率很高。因?yàn)樯舷挛拿鞔_改動(dòng)范圍可控它很少會(huì)碰壞別的地方。3.3 第一版代碼的問題與修正Trae 生成的第一版大體能玩但存在兩個(gè)明顯問題。第一個(gè)問題是分?jǐn)?shù)計(jì)算偏大。原因在于它寫的合并邏輯里一行[2, 2, 2, 2]左移后直接變成了[8, 0, 0, 0]還加了 8 分。這在 2048 規(guī)則里是不允許的一次移動(dòng)最多合并出兩個(gè)新方塊所以正確結(jié)果應(yīng)該是[4, 4, 0, 0]得分 8 不變。嚴(yán)格說[2,2,2,2]得分是 448結(jié)果一樣但它把兩個(gè)方塊合并成了一個(gè) 8棋盤狀態(tài)錯(cuò)了。第二個(gè)問題是鍵盤監(jiān)聽用的是RawKeyboard這在新的 Flutter 版本里已經(jīng)廢棄而且 Web 端經(jīng)常收不到事件必須換成HardwareKeyboard.instance.addHandler或者KeyboardListener。這兩類問題都不難改但很典型。AI 生成代碼拿過來直接跑細(xì)節(jié)上一定會(huì)有瑕疵你必須能看懂邏輯知道它哪里錯(cuò)才能讓它繼續(xù)改。這也是我一直堅(jiān)持“先自己建模再交給 AI”的原因。4. 完整實(shí)現(xiàn)可直接拼成一個(gè) main.dart 的核心代碼4.1 棋盤與游戲狀態(tài)下面是我最終整理出的核心代碼。模型層用了獨(dú)立的GameBoard類這樣界面重建時(shí)不會(huì)把游戲狀態(tài)搞亂。import dart:math; enum Direction { left, right, up, down } class GameBoard { static const int size 4; final Random _random Random(); ListListint grid; int score 0; bool won false; bool gameOver false; GameBoard() { grid List.generate(size, (_) List.filled(size, 0)); _spawnTile(); _spawnTile(); } void restart() { grid List.generate(size, (_) List.filled(size, 0)); score 0; won false; gameOver false; _spawnTile(); _spawnTile(); } bool move(Direction dir) { if (gameOver || won) return false; final before _copyGrid(grid); switch (dir) { case Direction.left: _moveLeft(); break; case Direction.right: _moveRight(); break; case Direction.up: _moveUp(); break; case Direction.down: _moveDown(); break; } if (_sameGrid(before, grid)) return false; _spawnTile(); if (_hasWon()) won true; if (_isGameOver()) gameOver true; return true; } Listint _slideAndMerge(Listint row) { final nums row.where((e) e ! 0).toList(); final merged int[]; for (var i 0; i nums.length; i) { if (i 1 nums.length nums[i] nums[i 1]) { merged.add(nums[i] * 2); score nums[i] * 2; i; } else { merged.add(nums[i]); } } while (merged.length size) { merged.add(0); } return merged; } void _moveLeft() { for (var r 0; r size; r) { grid[r] _slideAndMerge(grid[r]); } } void _moveRight() { for (var r 0; r size; r) { final reversed grid[r].reversed.toList(); grid[r] _slideAndMerge(reversed).reversed.toList(); } } ListListint _transposed() { return List.generate(size, (r) { return List.generate(size, (c) grid[c][r]); }); } void _storeTransposed(ListListint tGrid) { for (var r 0; r size; r) { for (var c 0; c size; c) { grid[c][r] tGrid[r][c]; } } } void _moveUp() { final tGrid _transposed(); for (var r 0; r size; r) { tGrid[r] _slideAndMerge(tGrid[r]); } _storeTransposed(tGrid); } void _moveDown() { final tGrid _transposed(); for (var r 0; r size; r) { final reversed tGrid[r].reversed.toList(); tGrid[r] _slideAndMerge(reversed).reversed.toList(); } _storeTransposed(tGrid); } void _spawnTile() { final empty Listint[]; for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 0) empty.add([r, c]); } } if (empty.isEmpty) return; final pos empty[_random.nextInt(empty.length)]; grid[pos[0]][pos[1]] _random.nextDouble() 0.9 ? 2 : 4; } bool _hasWon() { for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 2048) return true; } } return false; } bool _isGameOver() { for (var r 0; r size; r) { for (var c 0; c size; c) { if (grid[r][c] 0) return false; if (r 1 size grid[r][c] grid[r 1][c]) return false; if (c 1 size grid[r][c] grid[r][c 1]) return false; } } return true; } ListListint _copyGrid(ListListint source) { return List.generate(size, (r) Listint.from(source[r])); } bool _sameGrid(ListListint a, ListListint b) { for (var r 0; r size; r) { for (var c 0; c size; c) { if (a[r][c] ! b[r][c]) return false; } } return true; } }這段代碼要特別解釋幾個(gè)細(xì)節(jié)。_slideAndMerge里的循環(huán)用了i跳過一個(gè)已合并元素這是保證“一次移動(dòng)只能合并一次”的關(guān)鍵。score在合并時(shí)直接累加而不是留到 UI 層算省掉了遍歷計(jì)算分值的環(huán)節(jié)。移動(dòng)方法move里先復(fù)制了棋盤再執(zhí)行方向移動(dòng)。如果移動(dòng)前后一模一樣說明這次滑動(dòng)沒有改變?nèi)魏畏綁K位置就不允許生成新方塊。這一步很關(guān)鍵否則玩家往沒有空位的方向亂滑時(shí)棋盤會(huì)不斷生成新方塊很快把自己卡死。4.2 頁面交互鍵盤、鼠標(biāo)拖拽、觸屏接下來是 UI 層。我用HardwareKeyboard.instance.addHandler監(jiān)聽鍵盤因?yàn)樗灰蕾図撁胬锬硞€(gè)焦點(diǎn)組件在 Flutter Web 上更穩(wěn)。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 GamePage(), ); } } class GamePage extends StatefulWidget { const GamePage({super.key}); override StateGamePage createState() _GamePageState(); } class _GamePageState extends StateGamePage { final GameBoard _game GameBoard(); Offset? _dragStart; override void initState() { super.initState(); HardwareKeyboard.instance.addHandler(_handleKey); } override void dispose() { HardwareKeyboard.instance.removeHandler(_handleKey); super.dispose(); } bool _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return false; if (event is KeyRepeatEvent) return false; setState(() { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp: case LogicalKeyboardKey.keyW: _game.move(Direction.up); break; case LogicalKeyboardKey.arrowDown: case LogicalKeyboardKey.keyS: _game.move(Direction.down); break; case LogicalKeyboardKey.arrowLeft: case LogicalKeyboardKey.keyA: _game.move(Direction.left); break; case LogicalKeyboardKey.arrowRight: case LogicalKeyboardKey.keyD: _game.move(Direction.right); break; } }); return false; } void _handlePanStart(DragStartDetails details) { _dragStart details.localPosition; } void _handlePanEnd(DragEndDetails details) { final start _dragStart; if (start null) return; final delta details.localPosition - start; if (delta.distance 20) return; setState(() { if (delta.dx.abs() delta.dy.abs()) { _game.move(delta.dx 0 ? Direction.right : Direction.left); } else { _game.move(delta.dy 0 ? Direction.down : Direction.up); } }); _dragStart null; } 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: GestureDetector( onPanStart: _handlePanStart, onPanEnd: _handlePanEnd, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ const Text( 2048, style: TextStyle( fontSize: 40, fontWeight: FontWeight.bold, color: Color(0xFF776E65), ), ), const Spacer(), _ScoreBoard(score: _game.score), const SizedBox(width: 8), FilledButton( onPressed: () setState(_game.restart), child: const Text(重開), ), ], ), const SizedBox(height: 16), AspectRatio( aspectRatio: 1, child: _buildBoard(), ), const SizedBox(height: 12), const Text( 方向鍵 / WASD / 滑動(dòng)棋盤控制, style: TextStyle(color: Colors.black54), ), ], ), ), ), ), ), ), ); }這段 UI 里我把整塊棋盤包在GestureDetector里用onPanStart記錄起點(diǎn)onPanEnd計(jì)算終點(diǎn)偏移再根據(jù) x 和 y 的絕對(duì)值大小判斷方向。這樣一次手勢(shì)只觸發(fā)一次移動(dòng)不會(huì)像onPanUpdate那樣連續(xù)觸發(fā)。HardwareKeyboard的 handler 里我判斷了event is! KeyDownEvent和event is KeyRepeatEvent。這兩行是為了屏蔽長(zhǎng)按鍵盤時(shí)的重復(fù)觸發(fā)避免玩家按住方向鍵不放導(dǎo)致棋盤快速亂動(dòng)。4.3 界面渲染與顏色映射棋盤渲染我用了GridView.builder通過itemCount: 16固定生成 16 個(gè)格子。每個(gè)格子的顏色由數(shù)字決定數(shù)字越大顏色越深文字顏色則從深色過渡到白色。Widget _buildBoard() { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(8), ), child: GridView.builder( padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemCount: GameBoard.size * GameBoard.size, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: GameBoard.size, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemBuilder: (context, index) { final row index ~/ GameBoard.size; final col index % GameBoard.size; final value _game.grid[row][col]; return TweenAnimationBuilderdouble( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 80), builder: (context, t, child) { return Transform.scale( scale: 0.9 0.1 * t, child: Container( alignment: Alignment.center, decoration: BoxDecoration( color: _cellColor(value), borderRadius: BorderRadius.circular(6), ), child: Text( value 0 ? : $value, style: TextStyle( fontSize: _fontSize(value), fontWeight: FontWeight.bold, color: _textColor(value), ), ), ), ); }, ); }, ), ); } Color _cellColor(int value) { switch (value) { case 0: return const Color(0xFFCDC1B4); 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); } } Color _textColor(int value) { return (value 2 || value 4) ? const Color(0xFF776E65) : Colors.white; } double _fontSize(int value) { if (value 100) return 28; if (value 1000) return 22; return 16; } }這里我用TweenAnimationBuilder做了一次簡(jiǎn)單的新方塊出現(xiàn)動(dòng)畫。每次setState改變棋盤數(shù)據(jù)后格子的 scale 都會(huì)從 0.9 彈到 1.0視覺上能感到新方塊“冒”出來。這個(gè)動(dòng)畫很輕量不會(huì)影響 Web 性能。完整的代碼還需要補(bǔ)上分?jǐn)?shù)面板和結(jié)束覆蓋層。分?jǐn)?shù)面板我直接用Container加圓角覆蓋層放在Stack里壓在棋盤上方代碼如下class _ScoreBoard extends StatelessWidget { const _ScoreBoard({required this.score}); final int score; override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFBBADA0), borderRadius: BorderRadius.circular(6), ), child: Column( children: [ const Text( 分?jǐn)?shù), style: TextStyle(color: Colors.white, fontSize: 12), ), Text( $score, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ], ), ); } }至于結(jié)束覆蓋層做法是修改build里棋盤那一塊把棋盤包進(jìn)Stack當(dāng)_game.gameOver或_game.won為真時(shí)疊加一個(gè)半透明容器和文字提示。這里就不整段貼重復(fù)代碼了核心思路就是在棋盤上面蓋一層。5. 跑起來之后必須處理的 Web 端特有問題5.1 方向鍵沒反應(yīng)的真正原因我在本地用flutter run -d chrome測(cè)試時(shí)第一分鐘就發(fā)現(xiàn)了問題鼠標(biāo)點(diǎn)擊和按鈕都正常但方向鍵怎么按都沒反應(yīng)。排查詢問的路徑大概是這樣的先確認(rèn)鍵盤事件有沒有進(jìn)入到 Flutter。在_handleKey里加print(event.logicalKey)瀏覽器控制臺(tái)能打出按鍵值說明事件進(jìn)來了。確認(rèn)有沒有被前面的if (event is! KeyDownEvent)攔截。把回調(diào)參數(shù)改成event.runtimeType打印發(fā)現(xiàn)長(zhǎng)按時(shí)會(huì)有KeyRepeatEvent但按一次正常只有KeyDownEvent。繼續(xù)查發(fā)現(xiàn)是我的處理函數(shù)返回了false而HardwareKeyboard.instance.addHandler的規(guī)則是如果某個(gè) handler 返回 true事件就會(huì)被消費(fèi)返回 false事件會(huì)繼續(xù)傳給下一個(gè) handler。我的 handler 雖然執(zhí)行了移動(dòng)邏輯但返回 false 導(dǎo)致 Flutter 框架認(rèn)為“沒人處理這個(gè)事件”在 Web 端某些情況下會(huì)觸發(fā)默認(rèn)行為方向鍵就順帶滾動(dòng)了頁面。不過我這次遇到的問題其實(shí)是焦點(diǎn)。Flutter Web 應(yīng)用在瀏覽器里如果 canvas 沒有取得焦點(diǎn)鍵盤事件根本不會(huì)進(jìn)到 Flutter 的事件系統(tǒng)。解決辦法是在頁面加載后手動(dòng)請(qǐng)求焦點(diǎn)WidgetsBinding.instance.addPostFrameCallback((_) { FocusManager.instance.primaryFocus?.requestFocus(); });這個(gè)處理加在initState里頁面打開后會(huì)自動(dòng)把焦點(diǎn)放到 Flutter 視圖上。如果在 iframe 里嵌入游戲還需要先點(diǎn)擊一下頁面瀏覽器才肯把鍵盤事件交給 iframe。5.2 拖動(dòng)判斷閾值與誤觸Web 端最容易被忽略的是鼠標(biāo)拖動(dòng)和點(diǎn)擊按鈕的沖突。如果不加閾值玩家在棋盤上輕輕一晃鼠標(biāo)就觸發(fā)了一次移動(dòng)體驗(yàn)非常差。我設(shè)置的是 20 像素。具體在_handlePanEnd里如果起點(diǎn)和終點(diǎn)距離小于 20就什么都不做。這個(gè)數(shù)值不是拍腦袋定的而是根據(jù)棋盤在手機(jī)屏幕上約 350 像素寬度算出來的。手指按下去到抬起來稍有抖動(dòng)通常不超過 15 像素20 能避開誤觸如果棋盤更大比如平板上的 700 像素寬20 依然合理因?yàn)榕袛嗟氖墙^對(duì)距離不是比例。有人會(huì)用onPanUpdate的details.delta來判斷方向也就是位移增量。這種方法在每次回調(diào)里增量都很小比如一次滑動(dòng)會(huì)觸發(fā)幾十次回調(diào)每次只有 5、6 像素。如果要判斷最終方向就得自己累加非常麻煩。用onPanStart記錄起點(diǎn)、onPanEnd判斷終點(diǎn)的方案天然就拿到了整個(gè)手勢(shì)的總體位移。5.3 重新開始后棋盤狀態(tài)沒清干凈還有一個(gè)容易翻車的地方是重新開始按鈕。我讓_game.restart()把grid重建為全 0再生成兩個(gè)新方塊score、won、gameOver全部重置。按這個(gè)邏輯重開沒問題但如果你是在原GameBoard對(duì)象上直接修改而不是重建grid比如grid[r][c] 0這種原地賦值要注意把所有格子都清干凈。更隱蔽的是很多人只在 UI 層setState(() _game GameBoard())但鍵盤 handler 和手勢(shì)回調(diào)里持有的是舊_game對(duì)象點(diǎn)擊重開之后棋盤看起來重置了再一滑動(dòng)又操作了舊對(duì)象狀態(tài)就亂掉了。我這次刻意把_game聲明為final重開時(shí)調(diào)用它內(nèi)部的方法重置數(shù)據(jù)而不是換對(duì)象。這是個(gè)小細(xì)節(jié)但能避免一類非常詭異的 bug。5.4 動(dòng)畫閃爍和構(gòu)建體積Web 端還有一個(gè)性能相關(guān)的體驗(yàn)問題Flutter Web 首次加載時(shí)CanvasKit 渲染器體積不小如果不做優(yōu)化瀏覽器會(huì)白屏好幾秒。我在本地flutter build web之后看了下產(chǎn)物main.dart.js就有 2 MB 多加上 CanvasKit 的 wasm 文件首屏加載確實(shí)要等一會(huì)兒。如果只是開發(fā)自娛這個(gè)無所謂但如果要給別人玩建議做兩件事部署到支持 gzip 或 brotli 壓縮的靜態(tài)托管平臺(tái)能顯著減小傳輸體積在構(gòu)建參數(shù)里根據(jù) Flutter 版本選擇合適的渲染器新的版本可以嘗試flutter build web --wasm或--web-renderer canvaskit不同版本參數(shù)有差異先用flutter build web --help看一下當(dāng)前版本支持什么。至于動(dòng)畫閃爍往往是GridView子項(xiàng)被系統(tǒng)重建導(dǎo)致的。給每個(gè)格子加上穩(wěn)定的 key 能緩解比如ValueKey($row-$col)讓 Flutter 知道每個(gè)位置上是同一個(gè)組件而不是隨著數(shù)據(jù)變化銷毀重建。我用TweenAnimationBuilder的寫法天然避開了大部分重建問題如果自己手寫AnimatedContainer一定要留意 key。6. 構(gòu)建部署與后續(xù)擴(kuò)展從一個(gè) Demo 變成能給人玩的游戲6.1 flutter build web 構(gòu)建與部署當(dāng)代碼在flutter run -d chrome下跑通后執(zhí)行flutter build web --release構(gòu)建完成后產(chǎn)物在build/web目錄里。這個(gè)目錄就是一個(gè)純靜態(tài)站點(diǎn)包含index.html、main.dart.js、assets等文件。部署方式不限Nginx、GitHub Pages、對(duì)象存儲(chǔ) CDN 都可以。唯一要注意的是如果部署在子路徑下比如https://example.com/2048/構(gòu)建時(shí)要指定 base hrefflutter build web --release --base-href /2048/否則刷新頁面后資源路徑會(huì) 404。這個(gè)問題我在第一次部署時(shí)就踩過Flutter Web 默認(rèn)按根路徑生成資源引用放到子目錄必須顯式聲明。6.2 想讓 2048 更像樣可以從這幾個(gè)方向擴(kuò)展基礎(chǔ)版跑通之后我給自己列了幾個(gè)擴(kuò)展方向優(yōu)先級(jí)從高到低本地存檔用shared_preferences保存最高分和棋盤狀態(tài)刷新頁面不丟進(jìn)度。撤銷一步在每次移動(dòng)前把棋盤快照存進(jìn)歷史棧做一個(gè)Undo按鈕。棋盤大小可變把GameBoard.size改成可配置參數(shù)支持 3×3、5×5 等變體玩法。動(dòng)畫增強(qiáng)用AnimatedPositioned或自繪布局實(shí)現(xiàn)方塊滑動(dòng)動(dòng)畫而不是只做縮放過渡。排行榜如果部署到公網(wǎng)可以接一個(gè)后端或者云數(shù)據(jù)庫記錄全球玩家的最高分。這些功能里本地存檔和撤銷對(duì)代碼結(jié)構(gòu)的鍛煉價(jià)值最大。加本地存檔時(shí)你要處理序列化和反序列化加撤銷時(shí)你要想清楚撤銷的邊界條件比如已經(jīng) gameOver 之后撤銷是否允許、撤銷后是否要重新生成方塊等。6.3 關(guān)于 Trae 使用的一點(diǎn)點(diǎn)個(gè)人心得最后說回 Trae。這次項(xiàng)目做下來我用 AI 編程工具的心法又更新了一輪。第一AI 生成代碼的正確性要以“我能理解”為前提。如果一段代碼你完全看不懂那它出 bug 時(shí)你也沒法精準(zhǔn)地把它喂回給 AI 修復(fù)。所以我會(huì)先自己把數(shù)據(jù)結(jié)構(gòu)想明白再讓 AI 填充實(shí)現(xiàn)。第二報(bào)錯(cuò)信息是最佳的上下文。運(yùn)行時(shí)報(bào)錯(cuò)、控制臺(tái)輸出、lint 警告直接復(fù)制粘貼到 Trae 對(duì)話框比你自己描述半天的效果都好。它能精準(zhǔn)定位文件路徑和行號(hào)大部分情況一次就改對(duì)。第三不要讓它一口氣寫五百行。一次要一個(gè)模塊跑通再要下一個(gè)模塊。我用 Trae 從 0 到 1 開發(fā)這個(gè) Flutter Web 小游戲 2048 的過程中最順利的節(jié)奏是“給模型邏輯 → 生成核心算法 → 跑起來 → 讓它加 Web 事件 → 調(diào)樣式”。每一步之間都有運(yùn)行驗(yàn)證問題不會(huì)被拖到很后面才集中爆發(fā)?,F(xiàn)在這個(gè) 2048 游戲已經(jīng)部署上線我平時(shí)手機(jī)和電腦都會(huì)打開玩兩把?;仡櫿麄€(gè)過程最有價(jià)值的不是那 4×4 的棋盤而是我把一套“從規(guī)則到代碼再到部署”的流程完整走了一遍。如果你也想練手我建議別直接復(fù)制完整代碼跑完就結(jié)束而是自己從空白項(xiàng)目開始讓 Trae 輔助你把每一步都寫出來遇到問題再回頭對(duì)照這里的代碼排查收獲會(huì)大得多。