笔记功能是数独游戏的重要辅助工具。当玩家不确定某个单元格应该填什么数字时,可以先记录几个候选数字作为笔记。这个功能对于解决困难级别的数独谜题尤为重要。今天我们来详细实现数独游戏的笔记功能。

笔记数据结构

首先在GameController中定义笔记的数据结构。

class GameController extends GetxController {
  List<List<Set<int>>> notes = [];
  bool notesMode = false;
  
  void generateNewGame(String diff) {
    // 其他初始化代码...
    notes = List.generate(9, (_) => List.generate(9, (_) => <int>{}));
    notesMode = false;
  }

notes是一个9x9的二维数组,每个元素是一个Set,存储该单元格的候选数字。使用Set可以自动处理重复添加的情况,并且查找效率高。notesMode标记当前是否处于笔记输入模式。

切换笔记模式

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

toggleNotesMode简单地切换notesMode的值并更新UI。当notesMode为true时,点击数字键盘会添加笔记而不是填入数字。UI上的笔记按钮也会根据这个状态显示不同的样式。

添加笔记方法

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

addNote首先进行多项检查:是否有选中单元格、是否是固定数字、单元格是否已有数字。笔记只能添加到空单元格。然后获取当前笔记集合,保存副本用于撤销。

切换笔记数字

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

笔记操作也记录到历史中,这样撤销功能可以恢复笔记的变化。使用Set.from创建副本,避免直接引用导致的问题。update()触发UI更新。

显示笔记的Widget

Widget _buildNotes(Set<int> notes) {
  if (notes.isEmpty) return const SizedBox();
  
  return GridView.count(
    crossAxisCount: 3,
    physics: const NeverScrollableScrollPhysics(),
    padding: EdgeInsets.all(2.w),

_buildNotes构建笔记显示组件。如果没有笔记返回空组件。GridView.count创建3x3的网格,physics禁用滚动因为笔记区域很小。

笔记数字显示

    children: List.generate(9, (index) {
      int num = index + 1;
      return Center(
        child: Text(
          notes.contains(num) ? num.toString() : '',
          style: TextStyle(fontSize: 8.sp, color: Colors.grey),
        ),
      );
    }),
  );
}

生成9个子组件对应数字1-9。如果玩家标记了某个数字,对应位置就显示这个数字,否则显示空。笔记使用较小的字体和灰色,与正式填入的数字形成视觉区分。

在单元格中使用笔记

Widget _buildCell(GameController controller, int row, int col) {
  int value = controller.board[row][col];
  Set<int> cellNotes = controller.notes[row][col];
  
  return Container(
    decoration: BoxDecoration(
      color: _getCellColor(controller, row, col),
    ),

构建单元格时获取该位置的数字和笔记。Container设置背景色,根据选中、高亮等状态决定颜色。

单元格内容显示

    child: Center(
      child: value != 0
          ? Text(
              value.toString(),
              style: TextStyle(
                fontSize: 20.sp,
                fontWeight: controller.isFixed[row][col] ? FontWeight.bold : FontWeight.normal,
                color: _getTextColor(controller, row, col),
              ),
            )
          : _buildNotes(cellNotes),
    ),
  );
}

如果单元格有数字则显示数字,否则显示笔记。这种条件渲染确保数字和笔记不会同时显示。固定数字使用粗体,玩家填入的数字使用普通字重。

填入数字时清空笔记

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 {

enterNumber根据当前模式决定是添加笔记还是填入数字。notesMode为true时调用addNote,否则执行填入数字的逻辑。

记录填入操作

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

保存旧值和旧笔记用于撤销。GameMove记录完整的状态变化,包括数字和笔记。newNotes设为空集合,因为填入数字后笔记会被清空。

执行填入并清空笔记

    board[row][col] = number;
    notes[row][col] = {};
    update();
    
    _checkCompletion();
  }
}

填入正式数字时,同时清空该单元格的笔记。这是合理的行为,因为一旦确定了数字,候选数字就没有意义了。_checkCompletion检查游戏是否完成。

智能笔记功能

void autoFillNotes() {
  for (int i = 0; i < 9; i++) {
    for (int j = 0; j < 9; j++) {
      if (board[i][j] == 0 && !isFixed[i][j]) {
        notes[i][j] = _getPossibleNumbers(i, j);
      }
    }
  }
  update();
}

autoFillNotes自动为所有空单元格填充可能的候选数字。遍历棋盘,对每个空的非固定单元格调用_getPossibleNumbers计算可能的数字。

计算可能的数字

Set<int> _getPossibleNumbers(int row, int col) {
  Set<int> possible = {1, 2, 3, 4, 5, 6, 7, 8, 9};
  
  // 排除同行的数字
  for (int i = 0; i < 9; i++) {
    possible.remove(board[row][i]);
  }

_getPossibleNumbers计算某个位置可以填入的数字。从1-9开始,逐步排除不可能的数字。首先排除同一行已有的数字。

排除列和宫格数字

  // 排除同列的数字
  for (int i = 0; i < 9; i++) {
    possible.remove(board[i][col]);
  }
  // 排除同宫格的数字
  int boxRow = (row ~/ 3) * 3;
  int boxCol = (col ~/ 3) * 3;
  for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
      possible.remove(board[boxRow + i][boxCol + j]);
    }
  }
  
  return possible;
}

继续排除同列和同宫格已有的数字。boxRow和boxCol计算宫格左上角坐标。返回的集合就是该位置所有可能的候选数字。

清除所有笔记

void clearAllNotes() {
  for (int i = 0; i < 9; i++) {
    for (int j = 0; j < 9; j++) {
      notes[i][j] = {};
    }
  }
  update();
}

clearAllNotes清空所有单元格的笔记。这个功能在玩家想要重新开始记笔记时很有用。遍历整个棋盘,将每个笔记集合设为空。

笔记高亮显示

Widget _buildNotes(Set<int> notes, int? highlightNumber) {
  if (notes.isEmpty) return const SizedBox();
  
  return GridView.count(
    crossAxisCount: 3,
    physics: const NeverScrollableScrollPhysics(),
    padding: EdgeInsets.all(2.w),

增强版的_buildNotes接收highlightNumber参数,用于高亮显示特定数字。当玩家选中某个有数字的单元格时,其他单元格笔记中的相同数字可以高亮。

高亮笔记数字

    children: List.generate(9, (index) {
      int num = index + 1;
      bool isHighlighted = highlightNumber != null && num == highlightNumber;
      return Center(
        child: Text(
          notes.contains(num) ? num.toString() : '',
          style: TextStyle(
            fontSize: 8.sp,
            color: isHighlighted ? Colors.blue : Colors.grey,
            fontWeight: isHighlighted ? FontWeight.bold : FontWeight.normal,
          ),
        ),
      );
    }),
  );
}

如果数字与highlightNumber匹配,使用蓝色和粗体显示。这帮助玩家追踪某个数字在笔记中的分布情况,是解题的重要辅助。

笔记按钮UI

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

笔记按钮在激活时有明显的视觉变化。notesMode为true时使用蓝色背景和边框,否则使用灰色背景。

按钮内容

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

激活时使用实心图标和"笔记开"文字,未激活时使用空心图标和"笔记"文字。这些变化让玩家清楚地知道当前处于笔记模式。

笔记冲突分析

class NoteAnalyzer {
  static Set<int> getConflictingNotes(
    List<List<int>> board,
    List<List<Set<int>>> notes,
    int row,
    int col,
  ) {
    Set<int> conflicts = {};
    Set<int> cellNotes = notes[row][col];

NoteAnalyzer分析笔记中的冲突。getConflictingNotes检查笔记中的数字是否与已填入的数字冲突。

检查行列冲突

    for (int note in cellNotes) {
      // 检查同行是否有这个数字
      for (int i = 0; i < 9; i++) {
        if (board[row][i] == note) {
          conflicts.add(note);
          break;
        }
      }
      // 检查同列是否有这个数字
      for (int i = 0; i < 9; i++) {
        if (board[i][col] == note) {
          conflicts.add(note);
          break;
        }
      }

遍历笔记中的每个数字,检查同行和同列是否已有这个数字。如果有,说明这个笔记是无效的,添加到冲突集合。

检查宫格冲突

      // 检查同宫格是否有这个数字
      int boxRow = (row ~/ 3) * 3;
      int boxCol = (col ~/ 3) * 3;
      for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
          if (board[boxRow + i][boxCol + j] == note) {
            conflicts.add(note);
            break;
          }
        }
      }
    }
    
    return conflicts;
  }
}

继续检查同宫格是否有冲突。返回的集合包含所有无效的笔记数字。这个功能帮助玩家发现错误的笔记。

显示冲突笔记

Widget _buildNotesWithConflicts(Set<int> notes, Set<int> conflicts, int? highlightNumber) {
  if (notes.isEmpty) return const SizedBox();
  
  return GridView.count(
    crossAxisCount: 3,
    physics: const NeverScrollableScrollPhysics(),
    padding: EdgeInsets.all(2.w),

_buildNotesWithConflicts增加了冲突显示功能。接收conflicts参数标记哪些笔记是无效的。

冲突笔记样式

    children: List.generate(9, (index) {
      int num = index + 1;
      bool hasNote = notes.contains(num);
      bool isConflict = conflicts.contains(num);
      bool isHighlighted = highlightNumber != null && num == highlightNumber;
      
      Color textColor = Colors.grey;
      if (isConflict) {
        textColor = Colors.red;
      } else if (isHighlighted) {
        textColor = Colors.blue;
      }

根据状态决定文字颜色。冲突的笔记用红色显示,高亮的用蓝色,普通的用灰色。

冲突笔记删除线

      return Center(
        child: Text(
          hasNote ? num.toString() : '',
          style: TextStyle(
            fontSize: 8.sp,
            color: textColor,
            fontWeight: isHighlighted ? FontWeight.bold : FontWeight.normal,
            decoration: isConflict ? TextDecoration.lineThrough : null,
          ),
        ),
      );
    }),
  );
}

冲突的笔记除了红色还加上删除线,让玩家一眼就能看出哪些笔记是无效的。这种视觉区分帮助玩家快速识别问题。

自动清理无效笔记

void cleanInvalidNotes() {
  bool hasChanges = false;
  
  for (int row = 0; row < 9; row++) {
    for (int col = 0; col < 9; col++) {
      if (notes[row][col].isEmpty) continue;
      
      Set<int> validNotes = {};
      for (int note in notes[row][col]) {
        if (_isNoteValid(row, col, note)) {
          validNotes.add(note);
        }
      }

cleanInvalidNotes遍历所有单元格,移除与已填数字冲突的笔记。对每个笔记调用_isNoteValid检查是否有效。

更新笔记

      if (validNotes.length != notes[row][col].length) {
        notes[row][col] = validNotes;
        hasChanges = true;
      }
    }
  }
  
  if (hasChanges) {
    update();
  }
}

如果有效笔记数量与原笔记不同,说明有无效笔记被移除。只在有变化时调用update(),避免不必要的UI更新。

检查笔记有效性

bool _isNoteValid(int row, int col, int note) {
  // 检查同行
  for (int i = 0; i < 9; i++) {
    if (board[row][i] == note) return false;
  }
  // 检查同列
  for (int i = 0; i < 9; i++) {
    if (board[i][col] == note) return false;
  }

_isNoteValid检查单个笔记是否有效。如果同行或同列已有这个数字,返回false。

检查宫格

  // 检查同宫格
  int boxRow = (row ~/ 3) * 3;
  int boxCol = (col ~/ 3) * 3;
  for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
      if (board[boxRow + i][boxCol + j] == note) return false;
    }
  }
  return true;
}

继续检查同宫格。如果三项检查都通过,返回true表示笔记有效。这个方法可以在玩家填入数字后自动调用。

填入数字时更新相关笔记

void _removeNoteFromRelatedCells(int row, int col, int number) {
  // 清理同行的笔记
  for (int i = 0; i < 9; i++) {
    if (i != col) {
      notes[row][i].remove(number);
    }
  }

当玩家填入一个数字时,自动从同行其他单元格的笔记中移除这个数字。这大大减少了玩家手动维护笔记的工作量。

清理列和宫格笔记

  // 清理同列的笔记
  for (int i = 0; i < 9; i++) {
    if (i != row) {
      notes[i][col].remove(number);
    }
  }
  
  // 清理同宫格的笔记
  int boxRow = (row ~/ 3) * 3;
  int boxCol = (col ~/ 3) * 3;
  for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
      int r = boxRow + i;
      int c = boxCol + j;
      if (r != row || c != col) {
        notes[r][c].remove(number);
      }
    }
  }
}

同样清理同列和同宫格的笔记。排除当前单元格本身。这个功能让笔记始终保持最新状态。

为单个单元格填充笔记

void fillNotesForCell(int row, int col) {
  if (board[row][col] != 0) return;
  if (isFixed[row][col]) return;
  
  Set<int> possible = _getPossibleNumbers(row, col);
  
  moveHistory.add(GameMove(
    row: row,
    col: col,
    previousNotes: Set.from(notes[row][col]),
    newNotes: Set.from(possible),
  ));
  
  notes[row][col] = possible;
  update();
}

fillNotesForCell为单个单元格填充所有可能的候选数字。先检查单元格是否为空且非固定。记录到历史支持撤销,然后设置笔记并更新UI。

清空单个单元格笔记

void clearNotesForCell(int row, int col) {
  if (notes[row][col].isEmpty) return;
  
  moveHistory.add(GameMove(
    row: row,
    col: col,
    previousNotes: Set.from(notes[row][col]),
    newNotes: {},
  ));
  
  notes[row][col] = {};
  update();
}

clearNotesForCell清空单个单元格的笔记。如果已经为空则直接返回。记录到历史支持撤销操作。

长按显示笔记选项

Widget _buildCellWithGestures(GameController controller, int row, int col) {
  return GestureDetector(
    onTap: () => controller.selectCell(row, col),
    onLongPress: () => _showNoteOptions(controller, row, col),
    child: _buildCell(controller, row, col),
  );
}

为单元格添加长按手势。onTap选中单元格,onLongPress显示笔记操作菜单。这种交互方式让高级功能不会干扰基本操作。

笔记选项菜单

void _showNoteOptions(GameController controller, int row, int col) {
  if (controller.board[row][col] != 0) return;
  if (controller.isFixed[row][col]) return;
  
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: EdgeInsets.all(16.w),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [

_showNoteOptions显示底部弹出菜单。先检查单元格是否可以添加笔记。mainAxisSize.min让菜单高度自适应内容。

菜单选项

          ListTile(
            leading: const Icon(Icons.auto_fix_high),
            title: const Text('自动填充笔记'),
            onTap: () {
              controller.fillNotesForCell(row, col);
              Navigator.pop(context);
            },
          ),
          ListTile(
            leading: const Icon(Icons.clear),
            title: const Text('清空笔记'),
            onTap: () {
              controller.clearNotesForCell(row, col);
              Navigator.pop(context);
            },
          ),
        ],
      ),
    ),
  );
}

提供两个选项:自动填充笔记和清空笔记。点击后执行对应操作并关闭菜单。ListTile提供统一的菜单项样式。

笔记统计

class NoteStatistics {
  final int totalNotes;
  final int cellsWithNotes;
  final Map<int, int> noteDistribution;
  
  NoteStatistics({
    required this.totalNotes,
    required this.cellsWithNotes,
    required this.noteDistribution,
  });

NoteStatistics统计笔记的分布情况。totalNotes是笔记总数,cellsWithNotes是有笔记的单元格数,noteDistribution是每个数字在笔记中出现的次数。

计算统计数据

  static NoteStatistics calculate(List<List<Set<int>>> notes) {
    int totalNotes = 0;
    int cellsWithNotes = 0;
    Map<int, int> distribution = {};
    
    for (int i = 1; i <= 9; i++) {
      distribution[i] = 0;
    }

calculate静态方法计算统计数据。初始化计数器和分布Map,为1-9每个数字设置初始计数为0。

遍历统计

    for (int row = 0; row < 9; row++) {
      for (int col = 0; col < 9; col++) {
        Set<int> cellNotes = notes[row][col];
        if (cellNotes.isNotEmpty) {
          cellsWithNotes++;
          totalNotes += cellNotes.length;
          for (int note in cellNotes) {
            distribution[note] = (distribution[note] ?? 0) + 1;
          }
        }
      }
    }
    
    return NoteStatistics(
      totalNotes: totalNotes,
      cellsWithNotes: cellsWithNotes,
      noteDistribution: distribution,
    );
  }
}

遍历所有单元格,统计有笔记的单元格数、笔记总数和每个数字的出现次数。这些统计可以帮助玩家了解解题进度。

显示笔记统计

Widget _buildNoteStats(GameController controller) {
  NoteStatistics stats = NoteStatistics.calculate(controller.notes);
  
  return Container(
    padding: EdgeInsets.all(12.w),
    decoration: BoxDecoration(
      color: Colors.grey.shade100,
      borderRadius: BorderRadius.circular(8.r),
    ),

_buildNoteStats构建笔记统计面板。调用calculate获取统计数据,使用Container设置背景和圆角。

统计内容

    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('笔记统计', style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.bold)),
        SizedBox(height: 8.h),
        Text('总笔记数:${stats.totalNotes}'),
        Text('有笔记的单元格:${stats.cellsWithNotes}'),
        SizedBox(height: 8.h),

显示标题和基本统计数据。使用Column垂直排列,crossAxisAlignment.start让内容左对齐。

数字分布

        Wrap(
          spacing: 8.w,
          runSpacing: 4.h,
          children: List.generate(9, (index) {
            int num = index + 1;
            int count = stats.noteDistribution[num] ?? 0;
            return Chip(
              label: Text('$num: $count'),
              backgroundColor: count > 0 ? Colors.blue.shade50 : Colors.grey.shade200,
            );
          }),
        ),
      ],
    ),
  );
}

使用Wrap和Chip展示每个数字的分布。有笔记的数字用蓝色背景突出显示,没有的用灰色。这种可视化帮助玩家快速了解笔记情况。

总结

笔记功能的关键设计要点:数据结构使用Set存储每个单元格的候选数字;模式切换通过notesMode控制输入行为;视觉呈现使用3x3网格显示笔记;智能辅助提供自动填充和自动清理功能;冲突检测帮助玩家发现无效笔记。

笔记功能是解决困难数独的必备工具。通过记录候选数字,玩家可以系统地排除不可能的选项,逐步缩小答案范围。良好的笔记功能设计可以显著提升游戏体验。

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐