1. 项目概述
数独游戏作为经典的逻辑解谜游戏,撤销功能是其核心交互体验的重要组成部分。在Flutter for OpenHarmony平台上实现这一功能,需要考虑跨平台兼容性、状态管理效率以及用户体验的流畅性。撤销功能不仅仅是简单的"回退一步",它需要完整记录每一步操作的前后状态,支持多种操作类型(填数、笔记、擦除等),并提供直观的交互反馈。
从实际开发经验来看,一个完善的撤销系统应该包含以下几个关键要素:操作历史记录的数据结构设计、撤销/重做逻辑的实现、用户界面反馈机制,以及性能优化策略。这些要素共同决定了撤销功能的可靠性和用户体验。
2. 核心数据结构设计
2.1 GameMove数据模型
实现撤销功能的基础是设计一个能够完整记录操作信息的数据结构。GameMove类是这个功能的核心:
class GameMove { final int row; final int col; final int? previousValue; final int? newValue; final Set<int>? previousNotes; final Set<int>? 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 => previousValue != null && newValue == 0; bool get isNotesChange => (previousNotes != null || newNotes != null) && !(isFill || isErase); }这个设计有几个关键考虑点:
- 使用可空类型(?)区分不同类型的操作(填数、笔记修改等)
- 同时记录操作前后的状态,确保可以完全恢复
- 添加时间戳用于实现按时间点撤销
- 计算属性(isFill/isErase/isNotesChange)方便后续逻辑处理
2.2 操作历史管理
GameController负责管理操作历史和游戏状态:
class GameController extends GetxController { List<GameMove> moveHistory = []; List<GameMove> redoHistory = []; UndoSettings undoSettings = UndoSettings(); UndoStats undoStats = UndoStats(); void addToHistory(GameMove move) { // 限制历史记录数量 while (moveHistory.length >= undoSettings.maxUndoSteps) { moveHistory.removeAt(0); } moveHistory.add(move); redoHistory.clear(); // 新操作会清空重做历史 update(); } // 其他游戏逻辑... }这里有几个值得注意的实现细节:
- 使用两个列表分别存储撤销历史和重做历史
- 可配置的最大撤销步数限制,防止内存无限增长
- 新操作会清空重做历史,这是大多数编辑软件的通用做法
- 使用GetX的update()方法通知UI更新
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]; Set<int> previousNotes = Set.from(notes[row][col]); addToHistory(GameMove( row: row, col: col, previousValue: previousValue, newValue: number, previousNotes: previousNotes, newNotes: {}, )); board[row][col] = number; notes[row][col] = {}; _checkCompletion(); } }关键点:
- 在修改棋盘状态前先记录当前状态
- 填数操作会清空该单元格的所有笔记
- 使用Set.from()创建集合的副本,避免引用问题
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; Set<int> currentNotes = notes[row][col]; Set<int> previousNotes = Set.from(currentNotes); if (currentNotes.contains(number)) { currentNotes.remove(number); } else { currentNotes.add(number); } addToHistory(GameMove( row: row, col: col, previousNotes: previousNotes, newNotes: Set.from(currentNotes), )); }注意事项:
- 笔记只能在空白单元格(值为0)上操作
- 笔记是切换(toggle)模式,已有则删除,无则添加
- 同样需要注意集合的深拷贝问题
3.3 擦除操作记录
擦除操作需要处理数值和笔记的清除:
void eraseCell() { if (selectedRow < 0 || selectedCol < 0) return; if (isFixed[selectedRow][selectedCol]) return; int row = selectedRow; int col = selectedCol; addToHistory(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] = {}; }实现要点:
- 擦除操作将数值设为0,笔记清空
- 即使单元格本来就是空的也记录操作,保持历史完整
- 固定单元格(isFixed)不能被擦除
4. 撤销与重做实现
4.1 基本撤销功能
void undoMove() { if (moveHistory.isEmpty) { UndoSoundService.playEmptyUndoSound(); 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!; } UndoSoundService.playUndoSound(); undoStats.recordUndo(lastMove); update(); }关键细节:
- 检查历史是否为空,避免异常
- 从moveHistory移除操作并添加到redoHistory
- 分别恢复数值和笔记状态
- 提供声音反馈和统计记录
4.2 多步撤销实现
void undoMultiple(int count) { if (moveHistory.isEmpty) return; count = min(count, moveHistory.length); if (undoSettings.confirmMultipleUndo && count >= undoSettings.confirmThreshold) { _showUndoConfirmDialog(count); return; } for (int i = 0; i < count; i++) { GameMove move = moveHistory.removeLast(); redoHistory.add(move); if (move.previousValue != null) { board[move.row][move.col] = move.previousValue!; } if (move.previousNotes != null) { notes[move.row][move.col] = move.previousNotes!; } undoStats.recordUndo(move); } UndoSoundService.playUndoSound(); update(); }实现考虑:
- 处理撤销步数超过历史记录的情况
- 大数量撤销前显示确认对话框
- 批量操作后只调用一次update()提高性能
4.3 重做功能实现
void redoMove() { if (redoHistory.isEmpty) { UndoSoundService.playEmptyUndoSound(); 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!; } UndoSoundService.playRedoSound(); undoStats.recordRedo(); update(); }注意事项:
- 重做是撤销的逆操作,逻辑对称
- 同样需要考虑状态恢复的完整性
- 提供与撤销类似的声音反馈
5. 用户界面实现
5.1 撤销按钮组件
Widget _buildUndoButton(GameController controller) { bool canUndo = controller.moveHistory.isNotEmpty; int undoCount = controller.moveHistory.length; return GestureDetector( onTap: canUndo ? () { if (controller.moveHistory.length > 5) { _showUndoConfirmDialog(1); } else { controller.undoMove(); } } : null, child: Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), decoration: BoxDecoration( color: canUndo ? Colors.grey.shade100 : Colors.grey.shade50, borderRadius: BorderRadius.circular(8.r), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Stack( children: [ Icon( Icons.undo, size: 24.sp, color: canUndo ? Colors.grey.shade700 : Colors.grey.shade400, ), if (canUndo && undoCount > 0) Positioned( right: -4, top: -4, child: Container( padding: EdgeInsets.all(4.w), decoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), 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 ? Colors.grey.shade700 : Colors.grey.shade400, ), ), ], ), ), ); }UI设计要点:
- 根据可撤销状态改变按钮外观
- 显示可撤销步数徽章
- 步数过多时显示"99+"避免布局问题
- 大量操作时弹出确认对话框
5.2 撤销动画效果
class UndoAnimation extends StatefulWidget { final VoidCallback onUndo; const UndoAnimation({super.key, required this.onUndo}); @override State<UndoAnimation> createState() => _UndoAnimationState(); } class _UndoAnimationState extends State<UndoAnimation> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _rotationAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _rotationAnimation = Tween<double>(begin: 0, end: -0.5).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } void _onTap() { _controller.forward(from: 0).then((_) { widget.onUndo(); _controller.reverse(); }); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, child: AnimatedBuilder( animation: _rotationAnimation, builder: (context, child) => Transform.rotate( angle: _rotationAnimation.value * 3.14159, child: child, ), child: Icon(Icons.undo, size: 24.sp), ), ); } }动画实现细节:
- 使用AnimationController控制动画过程
- 图标逆时针旋转半圈表示回退
- 动画完成后执行实际撤销操作
- 操作完成后反向播放动画恢复原状
5.3 手势支持实现
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! > 300) { // 向右滑动 - 撤销 HapticFeedback.lightImpact(); onUndo(); } else if (details.primaryVelocity! < -300) { // 向左滑动 - 重做 HapticFeedback.lightImpact(); onRedo(); } } }, child: child, ); } }手势交互要点:
- 向右滑动触发撤销,向左滑动触发重做
- 速度阈值300避免误操作
- 添加触觉反馈提升操作确认感
- HitTestBehavior.opaque确保手势检测区域完整
6. 高级功能实现
6.1 按时间点撤销
void undoToTimestamp(DateTime timestamp) { bool changed = false; 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!; } undoStats.recordUndo(lastMove); changed = true; } if (changed) { UndoSoundService.playUndoSound(); update(); } }实现细节:
- 撤销指定时间点之后的所有操作
- 检查是否有实际变化再触发更新
- 记录到重做历史以便恢复
- 适用于"回到5分钟前"这类场景
6.2 撤销历史查看器
Widget _buildUndoHistoryViewer() { return Container( height: 200.h, decoration: BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(8.r), ), child: Column( children: [ Padding( padding: EdgeInsets.all(8.w), child: Text( '操作历史 (${controller.moveHistory.length})', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.bold, ), ), ), Expanded( child: ListView.builder( itemCount: controller.moveHistory.length, itemBuilder: (context, index) { int reverseIndex = controller.moveHistory.length - 1 - index; GameMove move = controller.moveHistory[reverseIndex]; return ListTile( leading: CircleAvatar( backgroundColor: Colors.grey.shade200, child: Text('${reverseIndex + 1}'), ), title: Text(_getMoveDescription(move)), subtitle: Text(_formatTimestamp(move.timestamp)), trailing: IconButton( icon: const Icon(Icons.undo), onPressed: () => _undoToIndex(reverseIndex), ), onTap: () => _undoToIndex(reverseIndex), ); }, ), ), ], ), ); } 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'; } String _formatTimestamp(DateTime timestamp) { return DateFormat('HH:mm:ss').format(timestamp); } void _undoToIndex(int index) { int steps = controller.moveHistory.length - index; controller.undoMultiple(steps); }功能特点:
- 倒序显示操作历史,最新操作在最上面
- 每个操作显示描述、位置和时间
- 点击项目或撤销按钮可回退到指定步骤
- 支持操作类型分类显示
6.3 撤销统计功能
class UndoStats { int totalUndos = 0; int totalRedos = 0; Map<String, int> undosByType = { 'fill': 0, 'erase': 0, 'note': 0, }; void recordUndo(GameMove move) { totalUndos++; if (move.isFill) { undosByType['fill'] = undosByType['fill']! + 1; } else if (move.isErase) { undosByType['erase'] = undosByType['erase']! + 1; } else if (move.isNotesChange) { undosByType['note'] = undosByType['note']! + 1; } } void recordRedo() { totalRedos++; } double get undoRate { int totalOps = totalUndos + totalRedos; return totalOps > 0 ? totalUndos / totalOps : 0; } Map<String, dynamic> toJson() { return { 'totalUndos': totalUndos, 'totalRedos': totalRedos, 'undosByType': undosByType, 'undoRate': undoRate, }; } }统计功能用途:
- 分析玩家行为模式
- 评估游戏难度设计
- 发现可能的UI/UX问题
- 为游戏平衡性调整提供数据支持
7. 性能优化与调试
7.1 内存管理策略
class UndoSettings { int maxUndoSteps = 100; bool compressHistory = true; static const int _compressThreshold = 50; List<GameMove> compressHistory(List<GameMove> history) { if (!compressHistory || history.length < _compressThreshold) { return history; } // 简单压缩策略:保留最近的30步,之前的每5步保留1步 List<GameMove> compressed = []; compressed.addAll(history.sublist(history.length - 30)); for (int i = 0; i < history.length - 30; i += 5) { compressed.add(history[i]); } return compressed; } }优化策略:
- 限制最大撤销步数
- 历史记录压缩策略
- 定期清理过旧的操作记录
- 针对移动设备的内存优化
7.2 状态序列化方案
class GameMove { // ...其他代码 Map<String, dynamic> toJson() { return { 'row': row, 'col': col, 'previousValue': previousValue, 'newValue': newValue, 'previousNotes': previousNotes?.toList(), 'newNotes': newNotes?.toList(), 'timestamp': timestamp.toIso8601String(), }; } factory GameMove.fromJson(Map<String, dynamic> json) { return GameMove( row: json['row'], col: json['col'], previousValue: json['previousValue'], newValue: json['newValue'], previousNotes: json['previousNotes'] != null ? Set<int>.from(json['previousNotes']) : null, newNotes: json['newNotes'] != null ? Set<int>.from(json['newNotes']) : null, timestamp: DateTime.parse(json['timestamp']), ); } }序列化考虑:
- 支持游戏状态保存/恢复
- 处理Set类型的序列化转换
- 时间戳的ISO格式存储
- 可空字段的序列化处理
7.3 常见问题排查
撤销后状态不一致
- 检查是否所有操作类型都被正确记录
- 验证GameMove是否包含恢复所需的全部信息
- 确保撤销逻辑正确处理可空字段
内存占用过高
- 检查maxUndoSteps设置是否合理
- 考虑实现历史记录压缩
- 分析GameMove对象的内存占用
重做功能异常
- 确认新操作是否清空了redoHistory
- 检查重做逻辑是否与撤销逻辑对称
- 验证状态恢复是否完整
跨平台兼容性问题
- 测试不同设备上的手势识别灵敏度
- 验证序列化在不同平台的兼容性
- 检查声音反馈在各平台的可用性
8. 项目总结与扩展思考
在Flutter for OpenHarmony平台上实现数独游戏的撤销功能,需要考虑跨平台特性与性能优化的平衡。通过本项目,我们实现了一个完整的撤销系统,具有以下特点:
- 完整的状态记录:能够捕获所有类型的游戏操作
- 灵活的撤销策略:支持单步、多步、按时间点撤销
- 直观的用户反馈:包含视觉动画、触觉和声音反馈
- 可扩展的设计:方便添加新的操作类型和撤销策略
对于类似的项目,可以考虑以下扩展方向:
- 操作合并:将连续的相同操作合并为一步撤销
- 分支历史:支持创建保存点并分支发展
- 云同步:将操作历史同步到云端实现跨设备继续
- AI分析:基于撤销数据提供游戏难度自适应调整
撤销功能作为游戏交互的重要组成部分,其实现质量直接影响用户体验。一个设计良好的撤销系统能够让玩家更自信地探索游戏内容,提升整体游戏体验。