数独游戏除了数字输入,还需要一系列辅助功能来提升游戏体验。撤销、擦除、笔记、提示这四个功能是数独游戏的标配。今天我们来详细实现这些游戏控制按钮,让玩家能够更轻松地享受解题的乐趣。
请添加图片描述

在设计控制按钮之前,我们需要理解每个功能的使用场景。撤销功能让玩家可以回退错误的操作,这在填错数字时非常有用。擦除功能用于清空当前选中的单元格。笔记功能让玩家可以在单元格中记录候选数字,这是解决困难数独的必备技巧。提示功能在玩家卡住时给出正确答案,但通常会有使用次数的限制。

让我们从创建GameControls组件开始。

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../controllers/game_controller.dart';

class GameControls extends StatelessWidget {
  const GameControls({super.key});

这是控制按钮组件的基础结构。我们导入了Flutter的Material库、GetX状态管理库、ScreenUtil屏幕适配库,以及我们自定义的GameController。使用StatelessWidget是因为按钮本身不需要管理状态,所有状态都由GameController提供。

接下来实现build方法,构建控制按钮的整体布局。

  
  Widget build(BuildContext context) {
    return GetBuilder<GameController>(
      builder: (controller) => Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildControlButton(
            icon: Icons.undo,
            label: '撤销',
            onTap: controller.undoMove,
          ),

GetBuilder监听GameController的状态变化,当笔记模式或提示次数等状态改变时自动重建UI。Row组件水平排列四个控制按钮,mainAxisAlignment设置为spaceEvenly让按钮均匀分布。第一个按钮是撤销功能,使用undo图标,点击时调用controller的undoMove方法。

继续添加其他控制按钮。

          _buildControlButton(
            icon: Icons.backspace_outlined,
            label: '擦除',
            onTap: controller.eraseCell,
          ),
          _buildControlButton(
            icon: controller.notesMode ? Icons.edit : Icons.edit_outlined,
            label: '笔记',
            onTap: controller.toggleNotesMode,
            isActive: controller.notesMode,
          ),
          _buildControlButton(
            icon: Icons.lightbulb_outline,
            label: '提示(${controller.hintsUsed})',
            onTap: controller.useHint,
          ),
        ],
      ),
    );
  }

擦除按钮使用backspace图标,直观地表示删除操作。笔记按钮根据当前模式显示不同的图标,激活时使用实心图标,未激活时使用空心图标。提示按钮的标签中显示了已使用的提示次数,让玩家知道自己用了多少次提示。isActive参数用于标记笔记按钮的激活状态,会影响按钮的视觉样式。

现在来实现单个控制按钮的构建方法。

  Widget _buildControlButton({
    required IconData icon,
    required String label,
    required VoidCallback onTap,
    bool isActive = false,
  }) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
        decoration: BoxDecoration(
          color: isActive ? Colors.blue.shade100 : Colors.grey.shade100,
          borderRadius: BorderRadius.circular(8.r),
        ),

_buildControlButton是一个通用的按钮构建方法,接收图标、标签、点击回调和激活状态作为参数。GestureDetector处理点击事件,Container定义按钮的样式。内边距设置为水平16、垂直8,让按钮有足够的点击区域。激活状态使用蓝色背景,非激活状态使用灰色背景,通过颜色区分当前状态。

按钮内部的图标和文字布局。

        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 24.sp, color: isActive ? Colors.blue : Colors.grey.shade700),
            SizedBox(height: 4.h),
            Text(
              label,
              style: TextStyle(
                fontSize: 12.sp,
                color: isActive ? Colors.blue : Colors.grey.shade700,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Column垂直排列图标和文字,mainAxisSize设置为min让Column只占用必要的空间。图标大小为24,文字大小为12,两者之间有4像素的间距。激活状态下图标和文字都使用蓝色,非激活状态使用深灰色。这种一致的颜色方案让按钮状态一目了然。

现在让我们深入理解每个控制功能在GameController中的实现。首先是撤销功能。

  List<GameMove> moveHistory = [];

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

moveHistory是一个列表,记录了玩家的所有操作。每次输入数字或修改笔记时,都会向这个列表添加一个GameMove对象。undoMove方法从列表末尾取出最后一次操作,然后恢复该单元格的旧值和旧笔记。如果列表为空则直接返回,避免出错。这种设计让撤销功能可以无限次使用,玩家可以一直撤销到游戏开始的状态。

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记录了一次操作的完整信息:位置、旧值、新值、旧笔记、新笔记、时间戳。使用可空类型是因为有些操作只涉及数值变化,有些只涉及笔记变化。timestamp记录操作时间,可以用于统计分析或回放功能。这个数据模型的设计让撤销功能能够精确地恢复任何类型的操作。

擦除功能的实现。

  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,清空笔记。这样擦除操作也可以被撤销。注意我们使用Set.from创建笔记的副本,避免直接引用导致的问题。

笔记模式的切换。

  bool notesMode = false;

  void toggleNotesMode() {
    notesMode = !notesMode;
    update();
  }

笔记模式是一个简单的布尔值,toggleNotesMode方法切换这个值并更新UI。当笔记模式激活时,点击数字键盘会添加笔记而不是填入数字。这种模式切换的设计让玩家可以方便地在填数和记笔记之间切换。

提示功能的实现。

  int hintsUsed = 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];

提示功能首先进行多项检查:是否有选中单元格、是否是固定数字、当前值是否已经正确。如果当前值已经是正确答案,就不需要提示了。然后从solution数组中获取正确答案。这些检查确保提示功能只在真正需要时才生效。

继续完成提示功能的实现。

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

提示操作也会被记录到历史中,这样玩家可以撤销提示。填入正确答案后清空笔记,增加提示使用次数,然后检查游戏是否完成。hintsUsed的值会显示在提示按钮上,让玩家知道自己用了多少次提示。有些数独应用会限制提示次数,这里我们只是记录次数,不做限制。

让我们来优化控制按钮的视觉效果。添加按压动画可以提升交互体验。

class AnimatedControlButton extends StatefulWidget {
  final IconData icon;
  final String label;
  final VoidCallback onTap;
  final bool isActive;
  final bool isEnabled;
  
  const AnimatedControlButton({
    super.key,
    required this.icon,
    required this.label,
    required this.onTap,
    this.isActive = false,
    this.isEnabled = true,
  });

  
  State<AnimatedControlButton> createState() => _AnimatedControlButtonState();
}

将控制按钮提取为独立的StatefulWidget,可以为每个按钮添加独立的动画效果。新增了isEnabled参数,用于控制按钮是否可用,比如当没有操作可撤销时,撤销按钮应该显示为禁用状态。

按钮的动画状态管理。

class _AnimatedControlButtonState extends State<AnimatedControlButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  
  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 100),
      vsync: this,
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

AnimationController控制按压动画,时长100毫秒。缩放动画从1.0到0.95,比数字按钮的缩放幅度小一些,因为控制按钮本身就比较小。使用easeInOut曲线让动画更自然流畅。

处理按压事件和构建UI。

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: widget.isEnabled ? (_) => _controller.forward() : null,
      onTapUp: widget.isEnabled ? (_) {
        _controller.reverse();
        widget.onTap();
      } : null,
      onTapCancel: () => _controller.reverse(),
      child: AnimatedBuilder(
        animation: _scaleAnimation,
        builder: (context, child) => Transform.scale(
          scale: _scaleAnimation.value,
          child: Opacity(
            opacity: widget.isEnabled ? 1.0 : 0.5,
            child: child,
          ),
        ),
        child: _buildButtonContent(),
      ),
    );
  }

当按钮禁用时,不响应点击事件,并且透明度降低到0.5。AnimatedBuilder监听动画值变化,Transform.scale实现缩放效果,Opacity控制透明度。这种组合让按钮的状态变化更加直观。

按钮内容的构建。

  Widget _buildButtonContent() {
    Color color = widget.isActive 
        ? Colors.blue 
        : (widget.isEnabled ? Colors.grey.shade700 : Colors.grey.shade400);
    
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
      decoration: BoxDecoration(
        color: widget.isActive ? Colors.blue.shade100 : Colors.grey.shade100,
        borderRadius: BorderRadius.circular(8.r),
        border: widget.isActive 
            ? Border.all(color: Colors.blue, width: 1)
            : null,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(widget.icon, size: 24.sp, color: color),
          SizedBox(height: 4.h),
          Text(
            widget.label,
            style: TextStyle(fontSize: 12.sp, color: color),
          ),
        ],
      ),
    );
  }

颜色根据激活状态和启用状态动态计算。激活状态的按钮添加了蓝色边框,让它更加突出。禁用状态使用更浅的灰色,视觉上表示不可用。这种细致的状态区分让用户界面更加清晰易懂。

资源释放。

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

在组件销毁时释放AnimationController,这是Flutter动画开发的基本规范。忘记释放会导致内存泄漏,特别是在频繁创建销毁组件的场景下。

现在让我们考虑控制按钮的增强功能。撤销按钮可以显示可撤销的步数。

class EnhancedGameControls extends StatelessWidget {
  const EnhancedGameControls({super.key});

  
  Widget build(BuildContext context) {
    return GetBuilder<GameController>(
      builder: (controller) => Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildUndoButton(controller),
          _buildEraseButton(controller),
          _buildNotesButton(controller),
          _buildHintButton(controller),
        ],
      ),
    );
  }

将每个按钮的构建逻辑分离到独立的方法中,让代码更清晰。这种结构也方便后续添加更多的控制按钮,比如重新开始、暂停等。

撤销按钮的增强实现。

  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+",避免徽章过大。当没有可撤销的操作时,按钮变灰且不可点击。这种设计让玩家清楚地知道自己还能撤销多少步。

擦除按钮的增强实现。

  Widget _buildEraseButton(GameController controller) {
    bool canErase = controller.selectedRow >= 0 &&
                    controller.selectedCol >= 0 &&
                    !controller.isFixed[controller.selectedRow][controller.selectedCol] &&
                    (controller.board[controller.selectedRow][controller.selectedCol] != 0 ||
                     controller.notes[controller.selectedRow][controller.selectedCol].isNotEmpty);
    
    return GestureDetector(
      onTap: canErase ? controller.eraseCell : null,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
        decoration: BoxDecoration(
          color: canErase ? Colors.grey.shade100 : Colors.grey.shade50,
          borderRadius: BorderRadius.circular(8.r),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              Icons.backspace_outlined,
              size: 24.sp,
              color: canErase ? Colors.grey.shade700 : Colors.grey.shade400,
            ),
            SizedBox(height: 4.h),
            Text(
              '擦除',
              style: TextStyle(
                fontSize: 12.sp,
                color: canErase ? Colors.grey.shade700 : Colors.grey.shade400,
              ),
            ),
          ],
        ),
      ),
    );
  }

擦除按钮只有在选中了非固定单元格,且该单元格有内容时才可用。这个判断逻辑比较复杂,需要检查选中状态、固定状态、数值和笔记。当按钮不可用时显示为灰色,给玩家明确的反馈。

笔记按钮的增强实现。

  Widget _buildNotesButton(GameController controller) {
    return GestureDetector(
      onTap: controller.toggleNotesMode,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
        decoration: BoxDecoration(
          color: controller.notesMode ? Colors.blue.shade100 : Colors.grey.shade100,
          borderRadius: BorderRadius.circular(8.r),
          border: controller.notesMode 
              ? Border.all(color: Colors.blue, width: 2)
              : null,
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              controller.notesMode ? Icons.edit : Icons.edit_outlined,
              size: 24.sp,
              color: controller.notesMode ? Colors.blue : Colors.grey.shade700,
            ),
            SizedBox(height: 4.h),
            Text(
              controller.notesMode ? '笔记开' : '笔记',
              style: TextStyle(
                fontSize: 12.sp,
                color: controller.notesMode ? Colors.blue : Colors.grey.shade700,
              ),
            ),
          ],
        ),
      ),
    );
  }

笔记按钮在激活时有明显的视觉变化:蓝色背景、蓝色边框、实心图标、文字变为"笔记开"。这些变化让玩家清楚地知道当前处于笔记模式,避免误操作。边框宽度为2像素,比普通状态更粗,增强视觉区分度。

提示按钮的增强实现。

  Widget _buildHintButton(GameController controller) {
    bool canUseHint = controller.selectedRow >= 0 &&
                      controller.selectedCol >= 0 &&
                      !controller.isFixed[controller.selectedRow][controller.selectedCol] &&
                      controller.board[controller.selectedRow][controller.selectedCol] !=
                      controller.solution[controller.selectedRow][controller.selectedCol];
    
    return GestureDetector(
      onTap: canUseHint ? controller.useHint : null,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
        decoration: BoxDecoration(
          color: canUseHint ? Colors.amber.shade50 : Colors.grey.shade50,
          borderRadius: BorderRadius.circular(8.r),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              Icons.lightbulb_outline,
              size: 24.sp,
              color: canUseHint ? Colors.amber.shade700 : Colors.grey.shade400,
            ),
            SizedBox(height: 4.h),
            Text(
              '提示(${controller.hintsUsed})',
              style: TextStyle(
                fontSize: 12.sp,
                color: canUseHint ? Colors.amber.shade700 : Colors.grey.shade400,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

提示按钮使用琥珀色调,与其他按钮的灰色调形成对比,暗示这是一个特殊功能。只有当选中的单元格需要提示时按钮才可用。括号中显示已使用的提示次数,让玩家有节制地使用提示功能。

控制按钮的触觉反馈。

import 'package:flutter/services.dart';

GestureDetector(
  onTap: () {
    HapticFeedback.mediumImpact();
    controller.undoMove();
  },
  child: Container(
    // 按钮内容
  ),
)

为控制按钮添加触觉反馈,使用mediumImpact比数字按钮的lightImpact稍强,因为控制操作通常更重要。不同的操作可以使用不同强度的反馈,比如擦除可以用heavyImpact,提示可以用selectionClick。

控制按钮的无障碍支持。

Semantics(
  button: true,
  label: '撤销,可撤销${controller.moveHistory.length}步',
  enabled: controller.moveHistory.isNotEmpty,
  child: GestureDetector(
    onTap: controller.undoMove,
    child: Container(
      // 按钮内容
    ),
  ),
)

Semantics组件为屏幕阅读器提供按钮的描述信息。撤销按钮的描述包括可撤销的步数,让视障用户也能获得完整的信息。enabled属性告诉辅助技术按钮是否可用。

控制按钮的主题定制。

class GameControlsTheme {
  final Color activeBackgroundColor;
  final Color inactiveBackgroundColor;
  final Color activeIconColor;
  final Color inactiveIconColor;
  final Color activeTextColor;
  final Color inactiveTextColor;
  final Color hintButtonColor;
  final double iconSize;
  final double fontSize;
  final double borderRadius;
  
  const GameControlsTheme({
    this.activeBackgroundColor = const Color(0xFFE3F2FD),
    this.inactiveBackgroundColor = const Color(0xFFF5F5F5),
    this.activeIconColor = Colors.blue,
    this.inactiveIconColor = const Color(0xFF616161),
    this.activeTextColor = Colors.blue,
    this.inactiveTextColor = const Color(0xFF616161),
    this.hintButtonColor = const Color(0xFFFFA000),
    this.iconSize = 24,
    this.fontSize = 12,
    this.borderRadius = 8,
  });
}

通过主题类可以轻松定制控制按钮的外观。所有的颜色、尺寸都可以配置,让控制按钮能够适应不同的应用主题。提示按钮有单独的颜色配置,因为它通常需要特殊的视觉处理。

总结一下游戏控制按钮的关键设计要点。首先是清晰的功能划分,每个按钮负责一个明确的功能。其次是状态反馈,通过颜色、图标、文字的变化让玩家知道当前状态。然后是可用性判断,根据游戏状态动态启用或禁用按钮。最后是操作记录,所有操作都记录到历史中支持撤销。

这四个控制按钮虽然简单,但它们极大地提升了数独游戏的可玩性。撤销功能让玩家敢于尝试,擦除功能方便修改,笔记功能辅助思考,提示功能在卡住时提供帮助。这些功能的组合让数独游戏变得更加友好和有趣。

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

Logo

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

更多推荐