ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter数独游戏撤销功能设计与实现

Flutter数独游戏撤销功能设计与实现 1. 数独游戏撤销功能的核心价值在数独游戏开发中撤销功能绝不是简单的回退一步那么简单。作为一个深度参与过多个数独App开发的工程师我可以明确地说撤销功能的质量直接决定了游戏体验的上限。想象一下当玩家在高级难度下苦思冥想半小时后不小心点错了格子如果没有完善的撤销机制这种挫败感足以让用户直接卸载应用。好的撤销系统应该像时光机一样精准可靠。它不仅需要记录每一步操作的内容还要保存操作时的完整上下文。在我们的Flutter for OpenHarmony实现中一个完整的撤销系统包含以下核心要素操作记录的完整性不只是记录数字变化还包括笔记标记的变更操作类型的全覆盖填数、擦除、提示、笔记修改等所有操作类型状态恢复的精确性能够完美还原操作前的游戏状态用户体验的流畅性支持多种交互方式按钮、手势、快捷键2. 数据结构设计与实现细节2.1 GameMove类的深度解析GameMove是我们撤销系统的基石它的设计直接决定了撤销功能的可靠性和扩展性。让我们拆解这个核心数据结构class GameMove { final int row; final int col; final int? previousValue; final int? newValue; final Setint? previousNotes; final Setint? newNotes; final DateTime timestamp; GameMove({ required this.row, required this.col, this.previousValue, this.newValue, this.previousNotes, this.newNotes, DateTime? timestamp, }) : timestamp timestamp ?? DateTime.now(); bool get isFill newValue ! null; bool get isErase newValue 0; bool get isNotesChange newNotes ! null; }几个关键设计决策值得特别说明可空类型的使用不是所有操作都同时涉及数字和笔记变更。使用可空类型可以节省内存同时保持类型安全。时间戳的自动填充默认使用当前时间但允许外部传入特定时间这在实现回退到特定时间点功能时非常有用。计算属性的添加isFill、isErase等属性让代码更易读避免到处写null检查。2.2 操作历史的存储策略在GameController中我们使用简单的List来存储操作历史class GameController extends GetxController { ListGameMove moveHistory []; ListGameMove redoHistory []; // 其他代码... }为什么不使用Stack实际开发中我们发现List提供了更丰富的API特别是当需要实现以下功能时查看历史记录条数moveHistory.length遍历历史记录for循环或map等操作实现多步撤销sublist操作限制历史记录大小removeAt等操作redoHistory的引入让重做功能成为可能。每次撤销时我们将操作从moveHistory移到redoHistory执行新操作时清空redoHistory。3. 各类操作的具体实现3.1 数字填入操作数字填入是最核心的操作其撤销实现也最为典型void enterNumber(int number) { if (selectedRow 0 || selectedCol 0) return; if (isFixed[selectedRow][selectedCol]) return; int row selectedRow; int col selectedCol; if (notesMode) { addNote(number); } else { // 记录操作前的状态 int previousValue board[row][col]; Setint previousNotes Set.from(notes[row][col]); // 添加到历史记录 moveHistory.add(GameMove( row: row, col: col, previousValue: previousValue, newValue: number, previousNotes: previousNotes, newNotes: {}, )); // 清空redo历史 redoHistory.clear(); // 执行实际修改 board[row][col] number; notes[row][col] {}; update(); _checkCompletion(); } }关键点在修改游戏状态前先记录当前状态填入数字会清空该格子的所有笔记任何新操作都会清空redo历史3.2 笔记操作的特殊处理笔记操作与数字填入有所不同它只影响笔记状态void addNote(int number) { if (selectedRow 0 || selectedCol 0) return; if (isFixed[selectedRow][selectedCol]) return; if (board[selectedRow][selectedCol] ! 0) return; int row selectedRow; int col selectedCol; Setint currentNotes notes[row][col]; Setint previousNotes Set.from(currentNotes); // 切换笔记状态 if (currentNotes.contains(number)) { currentNotes.remove(number); } else { currentNotes.add(number); } // 记录笔记变更 moveHistory.add(GameMove( row: row, col: col, previousNotes: previousNotes, newNotes: Set.from(currentNotes), )); redoHistory.clear(); update(); }笔记操作的特殊性只在空白格子值为0允许笔记操作笔记是切换toggle模式而不是覆盖使用Set.from创建副本避免引用问题3.3 擦除与提示操作擦除操作本质上是将数字设为0void eraseCell() { if (selectedRow 0 || selectedCol 0) return; if (isFixed[selectedRow][selectedCol]) return; int row selectedRow; int col selectedCol; moveHistory.add(GameMove( row: row, col: col, previousValue: board[row][col], newValue: 0, previousNotes: Set.from(notes[row][col]), newNotes: {}, )); board[row][col] 0; notes[row][col] {}; redoHistory.clear(); update(); }提示操作则更为复杂因为它需要访问解决方案void useHint() { if (selectedRow 0 || selectedCol 0) return; if (isFixed[selectedRow][selectedCol]) return; if (board[selectedRow][selectedCol] solution[selectedRow][selectedCol]) return; int row selectedRow; int col selectedCol; int correctValue solution[row][col]; moveHistory.add(GameMove( row: row, col: col, previousValue: board[row][col], newValue: correctValue, previousNotes: Set.from(notes[row][col]), newNotes: {}, )); board[row][col] correctValue; notes[row][col] {}; hintsUsed; redoHistory.clear(); update(); _checkCompletion(); }提示操作的特殊考量不允许对已正确的格子使用提示使用提示会减少可用提示数同样需要记录完整的状态变更4. 撤销与重做的核心逻辑4.1 基础撤销实现撤销操作的核心是从历史记录中恢复之前的状态void undoMove() { if (moveHistory.isEmpty) return; GameMove lastMove moveHistory.removeLast(); redoHistory.add(lastMove); // 恢复数字 if (lastMove.previousValue ! null) { board[lastMove.row][lastMove.col] lastMove.previousValue!; } // 恢复笔记 if (lastMove.previousNotes ! null) { notes[lastMove.row][lastMove.col] lastMove.previousNotes!; } update(); }几个关键细节将操作从moveHistory移到redoHistory分别检查并恢复数字和笔记状态使用!操作符断言非空因为我们在添加时已经确保了完整性4.2 重做操作的对称实现重做是撤销的逆过程void redoMove() { if (redoHistory.isEmpty) return; GameMove move redoHistory.removeLast(); moveHistory.add(move); // 应用数字变更 if (move.newValue ! null) { board[move.row][move.col] move.newValue!; } // 应用笔记变更 if (move.newNotes ! null) { notes[move.row][move.col] move.newNotes!; } update(); }重做的特殊注意事项只有存在redo历史时才允许重做新操作会清空redo历史在enterNumber等方法中重做后操作会回到moveHistory可以再次撤销4.3 多步撤销与时间点撤销对于高级玩家单步撤销可能不够高效我们实现了多步撤销void undoMultiple(int count) { for (int i 0; i count moveHistory.isNotEmpty; i) { GameMove lastMove moveHistory.removeLast(); redoHistory.add(lastMove); if (lastMove.previousValue ! null) { board[lastMove.row][lastMove.col] lastMove.previousValue!; } if (lastMove.previousNotes ! null) { notes[lastMove.row][lastMove.col] lastMove.previousNotes!; } } update(); }更强大的时间点撤销void undoToTimestamp(DateTime timestamp) { while (moveHistory.isNotEmpty moveHistory.last.timestamp.isAfter(timestamp)) { GameMove lastMove moveHistory.removeLast(); redoHistory.add(lastMove); if (lastMove.previousValue ! null) { board[lastMove.row][lastMove.col] lastMove.previousValue!; } if (lastMove.previousNotes ! null) { notes[lastMove.row][lastMove.col] lastMove.previousNotes!; } } update(); }时间点撤销的使用场景玩家想要回退到特定时间前的状态配合UI显示操作时间线实现撤销最近5分钟操作这样的功能5. 用户界面与交互设计5.1 撤销按钮的完整实现撤销按钮不仅要功能完整还要提供良好的视觉反馈Widget _buildUndoButton(GameController controller) { bool canUndo controller.moveHistory.isNotEmpty; int undoCount controller.moveHistory.length; return GestureDetector( onTap: canUndo ? () { HapticFeedback.lightImpact(); // 触觉反馈 controller.undoMove(); } : null, child: Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), decoration: BoxDecoration( color: canUndo ? Theme.of(context).primaryColor.withOpacity(0.1) : Colors.grey.shade200, borderRadius: BorderRadius.circular(8.r), border: Border.all( color: canUndo ? Theme.of(context).primaryColor : Colors.transparent, width: 1.w, ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Stack( clipBehavior: Clip.none, children: [ Icon( Icons.undo, size: 24.sp, color: canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, ), if (canUndo undoCount 0) Positioned( right: -4, top: -4, child: Container( padding: EdgeInsets.all(4.w), decoration: BoxDecoration( color: Theme.of(context).primaryColor, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black12, blurRadius: 2.r, offset: Offset(0, 1.h), ), ], ), child: Text( undoCount 99 ? 99 : undoCount.toString(), style: TextStyle( fontSize: 8.sp, color: Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ], ), SizedBox(height: 4.h), Text( 撤销, style: TextStyle( fontSize: 12.sp, color: canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, fontWeight: FontWeight.w500, ), ), ], ), ), ); }UI设计要点状态反馈可撤销与不可撤销状态有明显视觉区分操作计数显示可撤销步数超过99显示99触觉反馈操作时提供轻微的震动反馈主题适配使用主题色保持应用一致性5.2 手势操作的实现除了按钮我们还实现了滑动手势支持class UndoGestureDetector extends StatelessWidget { final Widget child; final VoidCallback onUndo; final VoidCallback onRedo; const UndoGestureDetector({ super.key, required this.child, required this.onUndo, required this.onRedo, }); override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragEnd: (details) { if (details.primaryVelocity ! null) { if (details.primaryVelocity! 800) { // 快速右滑 - 撤销 onUndo(); } else if (details.primaryVelocity! -800) { // 快速左滑 - 重做 onRedo(); } } }, child: child, ); } }手势实现细节设置较高的速度阈值800避免误操作使用HitTestBehavior.opaque确保手势检测区域只响应快速滑动慢速拖动不会触发右滑撤销左滑重做符合用户直觉5.3 撤销动画与音效为了提升操作体验我们添加了动画和音效class UndoAnimation extends StatefulWidget { final VoidCallback onUndo; final bool canUndo; const UndoAnimation({ super.key, required this.onUndo, required this.canUndo, }); override StateUndoAnimation createState() _UndoAnimationState(); } class _UndoAnimationState extends StateUndoAnimation with SingleTickerProviderStateMixin { late AnimationController _controller; late Animationdouble _rotationAnimation; override void initState() { super.initState(); _controller AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _rotationAnimation Tweendouble(begin: 0, end: -0.5).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } void _onTap() { if (!widget.canUndo) return; _controller.forward(from: 0).then((_) { _controller.reverse(); widget.onUndo(); }); } override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, child: AnimatedBuilder( animation: _rotationAnimation, builder: (context, child) Transform.rotate( angle: _rotationAnimation.value * pi, child: child, ), child: Icon( Icons.undo, size: 24.sp, color: widget.canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, ), ), ); } }音效服务的实现class UndoSoundService { static final AudioCache _audioCache AudioCache(); static AudioPlayer? _player; static Futurevoid playUndoSound() async { _player await _audioCache.play(sounds/undo.wav); } static Futurevoid playRedoSound() async { _player await _audioCache.play(sounds/redo.wav); } static Futurevoid playEmptyUndoSound() async { _player await _audioCache.play(sounds/error.wav); } static Futurevoid dispose() async { await _player?.dispose(); } }体验优化要点旋转动画图标逆时针旋转表示回退双向动画先向前再反向形成完整动作状态感知不可操作时不播放动画音效反馈不同操作有不同音效提示资源管理及时释放音频资源6. 高级功能与性能优化6.1 撤销历史查看器对于专业玩家我们提供了完整的历史查看界面class UndoHistoryViewer extends StatelessWidget { final GameController controller; const UndoHistoryViewer({super.key, required this.controller}); override Widget build(BuildContext context) { return Column( children: [ Padding( padding: EdgeInsets.all(16.w), child: Text( 操作历史 (${controller.moveHistory.length}), style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.bold, ), ), ), Expanded( child: ListView.builder( itemCount: controller.moveHistory.length, reverse: true, // 最新操作显示在最上面 itemBuilder: (context, index) { GameMove move controller.moveHistory[index]; return ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).primaryColor.withOpacity(0.2), child: Text(${controller.moveHistory.length - index}), ), title: Text( _getMoveDescription(move), style: TextStyle(fontSize: 14.sp), ), subtitle: Text( DateFormat(HH:mm:ss).format(move.timestamp), style: TextStyle(fontSize: 12.sp), ), trailing: IconButton( icon: Icon(Icons.undo, size: 20.sp), onPressed: () _undoToIndex(context, index), ), onTap: () _undoToIndex(context, index), ); }, ), ), ], ); } String _getMoveDescription(GameMove move) { String position (${move.row 1}, ${move.col 1}); if (move.isFill) { return 在$position填入${move.newValue}; } else if (move.isErase) { return 清除$position的数字; } else if (move.isNotesChange) { return 修改$position的笔记; } return 未知操作$position; } void _undoToIndex(BuildContext context, int index) { int steps controller.moveHistory.length - index; if (steps 0) return; if (steps 5) { showDialog( context: context, builder: (context) AlertDialog( title: const Text(确认撤销), content: Text(确定要撤销最近$steps步操作吗), actions: [ TextButton( onPressed: () Navigator.pop(context), child: const Text(取消), ), TextButton( onPressed: () { Navigator.pop(context); controller.undoMultiple(steps); }, child: const Text(确定), ), ], ), ); } else { controller.undoMultiple(steps); } } }历史查看器的关键功能倒序显示最新操作在最上面操作描述清晰说明每个操作的内容时间显示记录每个操作的具体时间批量撤销支持直接回退到特定步骤确认提示大量撤销前要求确认6.2 性能优化策略随着游戏进行操作历史可能变得很大我们需要考虑性能优化历史记录限制class GameController extends GetxController { static const int maxHistoryLength 200; void addToHistory(GameMove move) { moveHistory.add(move); redoHistory.clear(); // 限制历史记录大小 if (moveHistory.length maxHistoryLength) { moveHistory.removeRange(0, moveHistory.length - maxHistoryLength); } } }内存优化class GameMove { // 使用更紧凑的数据表示 static const int _noteMask 0x1FF; // 9位表示1-9的笔记 int? get compressedPreviousNotes previousNotes ! null ? _compressNotes(previousNotes!) : null; int? get compressedNewNotes newNotes ! null ? _compressNotes(newNotes!) : null; int _compressNotes(Setint notes) { int result 0; for (int note in notes) { if (note 1 note 9) { result | 1 (note - 1); } } return result _noteMask; } }延迟加载对于非常长的历史记录可以考虑只在需要时加载部分记录class LazyGameHistory { final ListGameMove _loadedMoves []; final int totalCount; final FutureListGameMove Function(int, int) loader; Futurevoid ensureLoaded(int index) async { if (index _loadedMoves.length) { int start _loadedMoves.length; int end min(start 50, totalCount); var newMoves await loader(start, end); _loadedMoves.addAll(newMoves); } } }6.3 撤销统计与用户行为分析收集撤销相关数据可以帮助我们改进游戏设计class UndoAnalytics { final GameController controller; final MapString, int _undoCountsByType {}; int _totalUndos 0; int _totalRedos 0; UndoAnalytics(this.controller) { controller.addListener(_recordUndos); } void _recordUndos() { if (controller.canUndo) { _totalUndos; String type controller.lastMoveType; _undoCountsByType[type] (_undoCountsByType[type] ?? 0) 1; } if (controller.canRedo) { _totalRedos; } } double get undoRate { int totalMoves controller.moveHistory.length; return totalMoves 0 ? _totalUndos / totalMoves : 0; } MapString, dynamic toJson() { return { total_undos: _totalUndos, total_redos: _totalRedos, undo_rate: undoRate, undos_by_type: _undoCountsByType, }; } }数据分析的应用场景识别玩家容易出错的操作类型评估游戏难度是否合理发现可能的UI/UX问题为不同玩家提供个性化提示7. 跨平台适配与OpenHarmony优化7.1 Flutter for OpenHarmony的特殊考量在OpenHarmony平台上我们需要特别注意以下几点性能特性OpenHarmony的UI渲染管线与Android/iOS有所不同动画和手势处理可能需要特别优化内存管理策略需要调整平台API差异系统音效API可能不同触觉反馈的实现方式不同后台任务处理有特殊限制适配方案class OpenHarmonyUndoButton extends StatelessWidget { override Widget build(BuildContext context) { if (Platform.isOpenHarmony) { return _buildOpenHarmonySpecificButton(); } else { return _buildDefaultButton(); } } Widget _buildOpenHarmonySpecificButton() { // OpenHarmony特有的按钮实现 return Container( // 使用OHOS设计规范 ); } }7.2 平台特定优化针对OpenHarmony的优化措施渲染优化class OptimizedUndoAnimation extends StatefulWidget { override _OptimizedUndoAnimationState createState() _OptimizedUndoAnimationState(); } class _OptimizedUndoAnimationState extends StateOptimizedUndoAnimation with SingleTickerProviderStateMixin { override void initState() { super.initState(); if (Platform.isOpenHarmony) { // 使用更适合OHOS的动画参数 _controller AnimationController( duration: const Duration(milliseconds: 250), vsync: this, ); } else { _controller AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); } } }手势识别优化class OpenHarmonyGestureDetector extends StatelessWidget { override Widget build(BuildContext context) { return Listener( onPointerMove: (event) { if (Platform.isOpenHarmony) { // OHOS特有的手势处理逻辑 } else { // 标准处理逻辑 } }, child: child, ); } }内存管理class OpenHarmonyGameMove implements GameMove { override void dispose() { // OHOS特有的资源释放逻辑 } }7.3 多平台兼容性测试确保撤销功能在所有平台表现一致void testUndoFunctionality() { testWidgets(undo should restore previous state, (tester) async { // 初始化游戏 await tester.pumpWidget(MaterialApp(home: SudokuGame())); // 执行操作 await tester.tap(find.text(1)); await tester.pump(); // 验证状态 expect(find.text(1), findsOneWidget); // 执行撤销 await tester.tap(find.byIcon(Icons.undo)); await tester.pump(); // 验证状态恢复 expect(find.text(1), findsNothing); // 平台特定断言 if (Platform.isOpenHarmony) { // OHOS特有的验证 } else if (Platform.isAndroid) { // Android特有的验证 } }); }8. 实际开发中的经验与教训在实现撤销功能的过程中我们积累了一些宝贵的经验状态管理的陷阱必须深拷贝所有可变状态特别是笔记集合操作记录应该保存原始值而不是引用时间戳应该在创建GameMove时立即记录性能问题的发现最初实现时没有限制历史记录大小导致内存暴涨频繁的UI更新造成了卡顿复杂的动画在低端设备上掉帧严重解决方案的演进引入maxHistoryLength限制批量更新时合并UI刷新为动画添加复杂度检测和降级机制测试中的发现边界条件测试连续撤销所有步骤后再执行新操作压力测试快速连续执行大量操作和撤销平台差异测试不同设备上的表现一致性一个典型的性能优化案例// 优化前的实现 - 每次操作都立即更新UI void enterNumber(int number) { // ...记录操作... board[row][col] number; update(); // 立即更新 } // 优化后的实现 - 批量更新 void enterNumbers(Listint numbers) { bool shouldUpdate false; for (var number in numbers) { // ...记录操作... board[row][col] number; shouldUpdate true; } if (shouldUpdate) { update(); // 批量更新 } }另一个重要的教训是关于重做历史的处理// 错误实现 - 没有正确处理重做历史 void enterNumber(int number) { moveHistory.add(move); board[row][col] number; } // 正确实现 - 清空重做历史 void enterNumber(int number) { moveHistory.add(move); redoHistory.clear(); // 关键行 board[row][col] number; }
返回列表