Flutter for OpenHarmony数独游戏App实战:撤销功能
接着是重做功能,让玩家可以恢复撤销的操作。新的操作会清空redoHistory,因为重做只对连续的撤销有意义。首先是操作记录的数据结构,需要记录每次操作的完整信息。其次是撤销的范围,是只撤销数字还是也包括笔记。最后是撤销的限制,是否限制撤销次数。当玩家填错数字或想要回退操作时,可以使用撤销功能恢复到之前的状态。动画完成后才执行实际的撤销操作,让用户感知到操作正在进行。撤销功能是数独游戏的安全网,它
撤销功能是数独游戏的重要辅助功能。当玩家填错数字或想要回退操作时,可以使用撤销功能恢复到之前的状态。这个功能让玩家敢于尝试,不用担心犯错。今天我们来详细实现数独游戏的撤销功能。
在设计撤销功能之前,我们需要考虑几个关键问题。首先是操作记录的数据结构,需要记录每次操作的完整信息。其次是撤销的范围,是只撤销数字还是也包括笔记。最后是撤销的限制,是否限制撤销次数。
让我们从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
更多推荐



所有评论(0)