撤销功能是数独游戏的重要辅助功能。当玩家填错数字或想要回退操作时,可以使用撤销功能恢复到之前的状态。这个功能让玩家敢于尝试,不用担心犯错。今天我们来详细实现数独游戏的撤销功能。

在设计撤销功能之前,我们需要考虑几个关键问题。首先是操作记录的数据结构,需要记录每次操作的完整信息。其次是撤销的范围,是只撤销数字还是也包括笔记。最后是撤销的限制,是否限制撤销次数。

让我们从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();
}

GameMove记录一次操作的完整信息。row和col是操作的位置。previousValue和newValue分别是操作前后的数值。previousNotes和newNotes记录笔记的变化。timestamp记录操作时间。使用可空类型是因为有些操作只涉及数值或只涉及笔记。

在GameController中管理操作历史。

class GameController extends GetxController {
  List<GameMove> moveHistory = [];
  
  void generateNewGame(String diff) {
    // 其他初始化代码...
    moveHistory.clear();
  }

moveHistory是一个列表,按时间顺序存储所有操作。新游戏开始时清空历史。使用List而不是Stack是因为List提供了更多的操作方法,比如获取长度、遍历等。

记录填入数字的操作。

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]);
    
    moveHistory.add(GameMove(
      row: row,
      col: col,
      previousValue: previousValue,
      newValue: number,
      previousNotes: previousNotes,
      newNotes: {},
    ));
    
    board[row][col] = number;
    notes[row][col] = {};
    update();
    
    _checkCompletion();
  }
}

在修改棋盘之前,先保存当前状态到GameMove对象。previousValue是旧数字,newValue是新数字。同时保存旧笔记,因为填入数字会清空笔记。这样撤销时可以完整恢复之前的状态。

记录笔记操作。

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);
  }
  
  moveHistory.add(GameMove(
    row: row,
    col: col,
    previousNotes: previousNotes,
    newNotes: Set.from(currentNotes),
  ));
  
  update();
}

笔记操作只记录笔记的变化,不涉及数值。previousNotes是修改前的笔记集合,newNotes是修改后的。使用Set.from创建副本,避免直接引用导致的问题。

记录擦除操作。

void 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] = {};
  update();
}

擦除操作将数字设为0,同时清空笔记。记录旧数字和旧笔记,撤销时可以恢复。即使单元格本来就是空的,也记录这次操作,保持历史的完整性。

记录提示操作。

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++;
  update();
  
  _checkCompletion();
}

提示操作也记录到历史中,这样玩家可以撤销提示。虽然提示给出了正确答案,但玩家可能想要自己解决,所以允许撤销是合理的。

撤销操作的实现。

void undoMove() {
  if (moveHistory.isEmpty) return;
  
  GameMove lastMove = moveHistory.removeLast();
  
  if (lastMove.previousValue != null) {
    board[lastMove.row][lastMove.col] = lastMove.previousValue!;
  }
  if (lastMove.previousNotes != null) {
    notes[lastMove.row][lastMove.col] = lastMove.previousNotes!;
  }
  
  update();
}

undoMove从历史列表中取出最后一个操作,恢复该单元格的旧值和旧笔记。removeLast同时获取并移除最后一个元素。使用可空类型检查是因为有些操作只涉及数值或只涉及笔记。

撤销按钮的UI实现。

Widget _buildUndoButton(GameController controller) {
  bool canUndo = controller.moveHistory.isNotEmpty;
  int undoCount = controller.moveHistory.length;
  
  return GestureDetector(
    onTap: canUndo ? 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,
            ),
          ),
        ],
      ),
    ),
  );
}

撤销按钮显示可撤销的步数。当没有可撤销的操作时,按钮变灰且不可点击。徽章显示具体步数,超过99显示"99+"。这种设计让玩家清楚地知道自己还能撤销多少步。

多步撤销功能。

void undoMultiple(int count) {
  for (int i = 0; i < count && moveHistory.isNotEmpty; i++) {
    GameMove lastMove = moveHistory.removeLast();
    
    if (lastMove.previousValue != null) {
      board[lastMove.row][lastMove.col] = lastMove.previousValue!;
    }
    if (lastMove.previousNotes != null) {
      notes[lastMove.row][lastMove.col] = lastMove.previousNotes!;
    }
  }
  update();
}

undoMultiple可以一次撤销多步操作。这在玩家想要回退到某个特定状态时很有用。只调用一次update(),避免多次UI更新。

撤销到特定时间点。

void undoToTimestamp(DateTime timestamp) {
  while (moveHistory.isNotEmpty && 
         moveHistory.last.timestamp.isAfter(timestamp)) {
    GameMove lastMove = moveHistory.removeLast();
    
    if (lastMove.previousValue != null) {
      board[lastMove.row][lastMove.col] = lastMove.previousValue!;
    }
    if (lastMove.previousNotes != null) {
      notes[lastMove.row][lastMove.col] = lastMove.previousNotes!;
    }
  }
  update();
}

undoToTimestamp撤销所有在指定时间之后的操作。这可以用于实现"回退到5分钟前"这样的功能。timestamp字段在这里发挥作用。

重做功能的实现。

class GameController extends GetxController {
  List<GameMove> moveHistory = [];
  List<GameMove> redoHistory = [];
  
  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();
  }
  
  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();
  }
}

添加redoHistory存储被撤销的操作。撤销时将操作移到redoHistory,重做时移回moveHistory。新的操作会清空redoHistory,因为重做只对连续的撤销有意义。

撤销操作的动画效果。

class UndoAnimation extends StatefulWidget {
  final VoidCallback onUndo;
  
  const UndoAnimation({super.key, required this.onUndo});

  
  State<UndoAnimation> createState() => _UndoAnimationState();
}

class _UndoAnimationState extends State<UndoAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _rotationAnimation;
  
  
  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();
    });
  }
  
  
  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),
      ),
    );
  }
}

UndoAnimation在撤销时播放旋转动画。图标逆时针旋转半圈,视觉上表示"回退"的含义。动画完成后才执行实际的撤销操作,让用户感知到操作正在进行。

撤销确认对话框。

void _showUndoConfirmDialog(int steps) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('撤销操作'),
      content: Text('确定要撤销最近$steps步操作吗?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        ElevatedButton(
          onPressed: () {
            Navigator.pop(context);
            controller.undoMultiple(steps);
          },
          child: const Text('确定'),
        ),
      ],
    ),
  );
}

多步撤销前显示确认对话框,防止误操作。对话框显示将要撤销的步数,让用户确认。这种设计在撤销大量操作时特别有用。

撤销历史查看器。

Widget _buildUndoHistoryViewer() {
  return Container(
    height: 200.h,
    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),
          ),
        );
      },
    ),
  );
}

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';
}

撤销历史查看器显示所有操作的列表。最新的操作在最上面,每个操作显示描述和时间。点击撤销按钮可以直接撤销到该步骤。这种可视化让玩家可以精确选择要回退到哪一步。

撤销操作的声音反馈。

class UndoSoundService {
  static AudioPlayer? _player;
  
  static Future<void> playUndoSound() async {
    // 播放撤销音效
  }
  
  static Future<void> playRedoSound() async {
    // 播放重做音效
  }
  
  static Future<void> playEmptyUndoSound() async {
    // 播放无法撤销的提示音
  }
}

void undoMove() {
  if (moveHistory.isEmpty) {
    UndoSoundService.playEmptyUndoSound();
    return;
  }
  
  UndoSoundService.playUndoSound();
  
  GameMove lastMove = moveHistory.removeLast();
  // ... 恢复状态
}

UndoSoundService为撤销操作提供声音反馈。成功撤销、重做、无法撤销分别使用不同的音效。声音反馈让操作更有确认感,特别是在不看屏幕时也能知道操作结果。

撤销操作的手势支持。

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,
  });

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onHorizontalDragEnd: (details) {
        if (details.primaryVelocity != null) {
          if (details.primaryVelocity! > 300) {
            // 向右滑动 - 撤销
            onUndo();
          } else if (details.primaryVelocity! < -300) {
            // 向左滑动 - 重做
            onRedo();
          }
        }
      },
      child: child,
    );
  }
}

UndoGestureDetector支持滑动手势进行撤销和重做。向右滑动撤销,向左滑动重做。速度阈值300确保只有明确的滑动才会触发,避免误操作。这种手势操作让撤销更加便捷。

撤销限制设置。

class UndoSettings {
  int maxUndoSteps;
  bool confirmMultipleUndo;
  int confirmThreshold;
  
  UndoSettings({
    this.maxUndoSteps = 100,
    this.confirmMultipleUndo = true,
    this.confirmThreshold = 10,
  });
}

class GameController extends GetxController {
  UndoSettings undoSettings = UndoSettings();
  
  void addToHistory(GameMove move) {
    moveHistory.add(move);
    
    // 限制历史记录数量
    while (moveHistory.length > undoSettings.maxUndoSteps) {
      moveHistory.removeAt(0);
    }
  }
}

UndoSettings让用户可以配置撤销行为。maxUndoSteps限制最大撤销步数,防止内存占用过多。confirmMultipleUndo控制多步撤销是否需要确认。confirmThreshold设置需要确认的步数阈值。

撤销统计。

class UndoStats {
  int totalUndos = 0;
  int totalRedos = 0;
  Map<String, int> undosByType = {};
  
  void recordUndo(GameMove move) {
    totalUndos++;
    String type = move.isFill ? 'fill' : (move.isErase ? 'erase' : 'note');
    undosByType[type] = (undosByType[type] ?? 0) + 1;
  }
  
  void recordRedo() {
    totalRedos++;
  }
  
  double get undoRate {
    // 撤销率 = 撤销次数 / 总操作次数
    int totalOps = totalUndos + totalRedos;
    return totalOps > 0 ? totalUndos / totalOps : 0;
  }
}

UndoStats统计撤销相关的数据。totalUndos和totalRedos记录撤销和重做次数。undosByType按操作类型统计撤销。undoRate计算撤销率,可以反映玩家的谨慎程度或游戏难度。

总结一下撤销功能的关键设计要点。首先是完整记录,每次操作都记录前后状态。其次是支持所有操作类型,包括填数、笔记、擦除、提示。然后是无限撤销,不限制撤销次数(或可配置限制)。接着是重做功能,让玩家可以恢复撤销的操作。还有历史查看,让玩家可以看到所有操作记录。最后是多种交互方式,支持按钮、手势、快捷键等。

撤销功能是数独游戏的安全网,它让玩家敢于尝试各种可能性,不用担心犯错。良好的撤销功能设计可以显著提升游戏体验,让玩家更加自信地探索解题策略。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐