脑力激荡:简易数独生成器 —— Flutter + OpenHarmony 鸿蒙风益智小游戏

个人主页:ujainu

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

文章目录

前言

在数字时代,碎片化娱乐充斥着我们的生活。而数独------这一源自18世纪的逻辑谜题,至今仍以其纯粹的规则与深度的思考魅力吸引着全球数亿玩家。它不仅能锻炼逻辑推理能力、提升专注力,还能有效缓解焦虑,成为现代人"数字排毒"的理想选择。

然而,传统 9×9 数独对新手门槛较高,容易劝退初学者。为此,我们基于 Flutter + OpenHarmony 平台,开发了一款面向大众的 简易数独生成器(Mini Sudoku Generator) ,支持 4×4 与 6×6 两种轻量级难度,配合实时校验、智能提示与鸿蒙美学设计,让每个人都能轻松上手、快乐动脑!

本文将完整解析该应用的实现过程,涵盖 谜题生成算法、UI 绘制、状态管理与交互反馈 四大核心模块。全文包含详细代码讲解与可运行示例,适合 Flutter 中级开发者学习复用。


一、为什么做"简易数独"?

1. 用户体验优先

  • 降低认知负荷:4×4(2×2 宫格)仅需填入 1~4;6×6(2×3 宫格)使用 1~6,规则一致但规模更小
  • 快速完成感:平均 1~3 分钟可解一局,契合移动端碎片时间场景
  • 教育友好:适合儿童逻辑启蒙或老年人脑力训练

2. 鸿蒙设计原则融入

  • 清晰反馈:输入错误时格子边框红色闪烁,符合 OpenHarmony "即时、明确" 的交互准则
  • 视觉层次
    • 已知数字 → 灰色(不可编辑)
    • 用户输入 → 主色蓝色(#6200EE
    • 宫格背景 → 浅灰区分(增强结构识别)
  • 圆角美学 :所有格子采用 borderRadius: 8,柔和不刺眼

核心功能清单

  • 自动生成合法 4×4 / 6×6 数独谜题
  • 点击格子弹出数字选择面板
  • 实时校验行/列/宫重复(标红提示)
  • "提示"按钮填充一个正确空格
  • "重置"清空用户输入,保留初始谜题

二、技术架构:从生成到验证

我们将系统划分为三个关键层:

模块 职责 技术点
生成层 创建完整数独 + 挖洞成谜题 回溯算法(Backtracking)
UI 层 渲染网格、颜色、动画 GridView / Container + BoxDecoration
逻辑层 管理状态、校验冲突、处理交互 二维 List + 函数式验证

⚠️ 注意:为控制复杂度,本文使用 简化回溯 生成完整盘面,再随机挖去部分格子形成谜题(非唯一解保证,但对简易数独影响较小)。


三、核心算法:简易数独生成器

1. 回溯法生成完整盘面

以 4×4 为例(宫格大小为 2×2),我们定义:

dart 复制代码
class SudokuGenerator {
  static const int size4 = 4;
  static const int boxSize4 = 2;
  static const int size6 = 6;
  static const int boxSize6 = 3; // 注意:6x6 的宫格是 2x3,但校验时按 2 行 3 列处理

  static List<List<int>> generate(int size) {
    final grid = List.generate(size, (_) => List.filled(size, 0));
    _fillGrid(grid, size);
    return grid;
  }

  static bool _fillGrid(List<List<int>> grid, int size) {
    final empty = _findEmpty(grid, size);
    if (empty == null) return true; // 已填满

    final row = empty[0], col = empty[1];
    final numbers = List<int>.generate(size, (i) => i + 1)..shuffle();

    for (final num in numbers) {
      if (_isValid(grid, row, col, num, size)) {
        grid[row][col] = num;
        if (_fillGrid(grid, size)) return true;
        grid[row][col] = 0; // 回溯
      }
    }
    return false;
  }

  static List<int>? _findEmpty(List<List<int>> grid, int size) {
    for (int i = 0; i < size; i++) {
      for (int j = 0; j < size; j++) {
        if (grid[i][j] == 0) return [i, j];
      }
    }
    return null;
  }

  static bool _isValid(List<List<int>> grid, int row, int col, int num, int size) {
    final boxSize = size == 4 ? 2 : (size == 6 ? 2 : 3); // 6x6 的行方向 box 大小为 2

    // 检查行
    for (int x = 0; x < size; x++) {
      if (grid[row][x] == num) return false;
    }

    // 检查列
    for (int y = 0; y < size; y++) {
      if (grid[y][col] == num) return false;
    }

    // 检查宫格(重点:6x6 是 2x3 宫格)
    int boxRowStart, boxColStart;
    if (size == 4) {
      boxRowStart = (row ~/ 2) * 2;
      boxColStart = (col ~/ 2) * 2;
    } else if (size == 6) {
      boxRowStart = (row ~/ 2) * 2;   // 每 2 行一个宫
      boxColStart = (col ~/ 3) * 3;   // 每 3 列一个宫
    } else {
      boxRowStart = (row ~/ 3) * 3;
      boxColStart = (col ~/ 3) * 3;
    }

    for (int i = boxRowStart; i < boxRowStart + (size == 6 ? 2 : boxSize); i++) {
      for (int j = boxColStart; j < boxColStart + (size == 6 ? 3 : boxSize); j++) {
        if (grid[i][j] == num) return false;
      }
    }

    return true;
  }
}

🔍 关键说明

  • 6×6 数独的宫格结构为 2 行 × 3 列,共 6 个宫
  • _isValid 中对 size == 6 特殊处理宫格边界
  • 使用 shuffle() 打乱数字顺序,增加谜题多样性

2. 从完整盘面生成谜题

dart 复制代码
static List<List<int>> createPuzzle(List<List<int>> fullGrid, int difficulty) {
  final size = fullGrid.length;
  final puzzle = List.generate(size, (i) => List.from(fullGrid[i]));
  
  // 难度:4x4 挖 8~10 个;6x6 挖 18~22 个
  int cellsToHide = size == 4 ? 8 + difficulty : 18 + difficulty * 2;
  
  final positions = List.generate(size * size, (i) => [i ~/ size, i % size])
    ..shuffle();
    
  for (int i = 0; i < cellsToHide && i < positions.length; i++) {
    final pos = positions[i];
    puzzle[pos[0]][pos[1]] = 0;
  }
  
  return puzzle;
}

四、UI 实现:鸿蒙风格网格渲染

1. 宫格背景区分

通过判断 (row ~/ boxRows) + (col ~/ boxCols) 的奇偶性,交替使用浅灰背景:

dart 复制代码
Color _getBoxColor(int row, int col, int size) {
  if (size == 4) {
    final isEven = ((row ~/ 2) + (col ~/ 2)) % 2 == 0;
    return isEven ? Colors.white : const Color(0xFFF0F0F5);
  } else if (size == 6) {
    final isEven = ((row ~/ 2) + (col ~/ 3)) % 2 == 0;
    return isEven ? Colors.white : const Color(0xFFF0F0F5);
  }
  return Colors.white;
}

2. 格子组件构建

dart 复制代码
Widget _buildCell(int row, int col, BuildContext context) {
  final value = _currentGrid[row][col];
  final original = _originalPuzzle[row][col] != 0;
  final hasError = _errorCells.contains([row, col]);

  return GestureDetector(
    onTap: original ? null : () => _showNumberPicker(row, col),
    child: Container(
      margin: const EdgeInsets.all(1),
      decoration: BoxDecoration(
        color: _getBoxColor(row, col, _size),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(
          color: hasError ? Colors.red : Colors.transparent,
          width: hasError ? 2 : 0,
        ),
      ),
      child: Center(
        child: Text(
          value == 0 ? '' : '$value',
          style: TextStyle(
            fontSize: 20,
            fontWeight: original ? FontWeight.w400 : FontWeight.bold,
            color: original ? Colors.grey[600] : const Color(0xFF6200EE),
          ),
        ),
      ),
    ),
  );
}

💡 交互细节

  • 原始数字不可点击(onTap: null
  • 错误格子显示红色边框(hasError 来自 _errorCells Set)
  • 用户输入文字加粗 + 主色蓝

五、实时校验与反馈机制

1. 冲突检测函数

dart 复制代码
Set<List<int>> _getErrorCells() {
  final errors = <List<int>>{};
  final size = _size;

  for (int i = 0; i < size; i++) {
    for (int j = 0; j < size; j++) {
      final val = _currentGrid[i][j];
      if (val == 0 || _originalPuzzle[i][j] != 0) continue;

      // 检查行
      for (int k = 0; k < size; k++) {
        if (k != j && _currentGrid[i][k] == val) {
          errors.add([i, j]);
          errors.add([i, k]);
        }
      }

      // 检查列
      for (int k = 0; k < size; k++) {
        if (k != i && _currentGrid[k][j] == val) {
          errors.add([i, j]);
          errors.add([k, j]);
        }
      }

      // 检查宫格
      int startRow, startCol, boxRows, boxCols;
      if (size == 4) {
        startRow = (i ~/ 2) * 2;
        startCol = (j ~/ 2) * 2;
        boxRows = boxCols = 2;
      } else {
        startRow = (i ~/ 2) * 2;
        startCol = (j ~/ 3) * 3;
        boxRows = 2;
        boxCols = 3;
      }

      for (int r = startRow; r < startRow + boxRows; r++) {
        for (int c = startCol; c < startCol + boxCols; c++) {
          if ((r != i || c != j) && _currentGrid[r][c] == val) {
            errors.add([i, j]);
            errors.add([r, c]);
          }
        }
      }
    }
  }

  return errors;
}

2. 状态更新与 UI 刷新

每次用户输入后调用:

dart 复制代码
void _updateGrid(int row, int col, int value) {
  setState(() {
    _currentGrid[row][col] = value;
    _errorCells = _getErrorCells();
  });
}

鸿蒙反馈原则体现

  • 错误即时高亮(无延迟)
  • 视觉提示明确(红色边框 > 文字变色)
  • 不打断操作流(用户可继续输入)

六、辅助功能:"提示"与"重置"

1. 提示功能

随机选择一个空格,填入正确答案:

dart 复制代码
void _giveHint() {
  final emptyCells = <List<int>>[];
  for (int i = 0; i < _size; i++) {
    for (int j = 0; j < _size; j++) {
      if (_currentGrid[i][j] == 0) {
        emptyCells.add([i, j]);
      }
    }
  }

  if (emptyCells.isNotEmpty) {
    final cell = emptyCells[Random().nextInt(emptyCells.length)];
    final correctValue = _solution[cell[0]][cell[1]];
    _updateGrid(cell[0], cell[1], correctValue);
  }
}

2. 重置功能

仅清空用户输入,保留原始谜题:

dart 复制代码
void _resetPuzzle() {
  setState(() {
    for (int i = 0; i < _size; i++) {
      for (int j = 0; j < _size; j++) {
        if (_originalPuzzle[i][j] == 0) {
          _currentGrid[i][j] = 0;
        }
      }
    }
    _errorCells.clear();
  });
}

七、完整可运行代码

以下为整合所有功能的完整实现,可直接在 Flutter + OpenHarmony 环境中运行:

dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';

const Color kPrimaryColor = Color(0xFF6200EE);
const Color kBackgroundColor = Color(0xFFF9F9FB);

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '简易数独',
      theme: ThemeData(
        primarySwatch: Colors.purple,
        primaryColor: kPrimaryColor,
        scaffoldBackgroundColor: kBackgroundColor,
        appBarTheme: const AppBarTheme(backgroundColor: kPrimaryColor),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(backgroundColor: kPrimaryColor),
        ),
      ),
      home: const SudokuGame(),
    );
  }
}

class SudokuGenerator {
  static const int size4 = 4;
  static const int size6 = 6;

  static List<List<int>> generate(int size) {
    final grid = List.generate(size, (_) => List.filled(size, 0));
    _fillGrid(grid, size);
    return grid;
  }

  static bool _fillGrid(List<List<int>> grid, int size) {
    final empty = _findEmpty(grid, size);
    if (empty == null) return true;

    final row = empty[0], col = empty[1];
    final numbers = List<int>.generate(size, (i) => i + 1)..shuffle();

    for (final num in numbers) {
      if (_isValid(grid, row, col, num, size)) {
        grid[row][col] = num;
        if (_fillGrid(grid, size)) return true;
        grid[row][col] = 0;
      }
    }
    return false;
  }

  static List<int>? _findEmpty(List<List<int>> grid, int size) {
    for (int i = 0; i < size; i++) {
      for (int j = 0; j < size; j++) {
        if (grid[i][j] == 0) return [i, j];
      }
    }
    return null;
  }

  static bool _isValid(List<List<int>> grid, int row, int col, int num, int size) {
    // Check row
    for (int x = 0; x < size; x++) {
      if (grid[row][x] == num) return false;
    }

    // Check column
    for (int y = 0; y < size; y++) {
      if (grid[y][col] == num) return false;
    }

    // Check box
    int boxRowStart, boxColStart, boxRows, boxCols;
    if (size == 4) {
      boxRowStart = (row ~/ 2) * 2;
      boxColStart = (col ~/ 2) * 2;
      boxRows = boxCols = 2;
    } else {
      boxRowStart = (row ~/ 2) * 2;
      boxColStart = (col ~/ 3) * 3;
      boxRows = 2;
      boxCols = 3;
    }

    for (int i = boxRowStart; i < boxRowStart + boxRows; i++) {
      for (int j = boxColStart; j < boxColStart + boxCols; j++) {
        if (grid[i][j] == num) return false;
      }
    }

    return true;
  }

  static List<List<int>> createPuzzle(List<List<int>> fullGrid, int hiddenCount) {
    final size = fullGrid.length;
    final puzzle = List.generate(size, (i) => List.from(fullGrid[i]));
    final positions = List.generate(size * size, (i) => [i ~/ size, i % size])
      ..shuffle();

    for (int i = 0; i < hiddenCount && i < positions.length; i++) {
      final pos = positions[i];
      puzzle[pos[0]][pos[1]] = 0;
    }

    return puzzle;
  }
}

class SudokuGame extends StatefulWidget {
  const SudokuGame({super.key});

  @override
  State<SudokuGame> createState() => _SudokuGameState();
}

class _SudokuGameState extends State<SudokuGame> {
  late List<List<int>> _solution;
  late List<List<int>> _originalPuzzle;
  late List<List<int>> _currentGrid;
  late Set<List<int>> _errorCells;
  int _size = 4;

  @override
  void initState() {
    super.initState();
    _newGame();
  }

  void _newGame() {
    final full = SudokuGenerator.generate(_size);
    final hidden = _size == 4 ? 8 : 18;
    final puzzle = SudokuGenerator.createPuzzle(full, hidden);

    setState(() {
      _solution = full;
      _originalPuzzle = puzzle;
      _currentGrid = List.generate(_size, (i) => List.from(puzzle[i]));
      _errorCells = {};
    });
  }

  Color _getBoxColor(int row, int col) {
    if (_size == 4) {
      return ((row ~/ 2) + (col ~/ 2)) % 2 == 0
          ? Colors.white
          : const Color(0xFFF0F0F5);
    } else {
      return ((row ~/ 2) + (col ~/ 3)) % 2 == 0
          ? Colors.white
          : const Color(0xFFF0F0F5);
    }
  }

  Set<List<int>> _getErrorCells() {
    final errors = <List<int>>{};
    final size = _size;

    for (int i = 0; i < size; i++) {
      for (int j = 0; j < size; j++) {
        final val = _currentGrid[i][j];
        if (val == 0 || _originalPuzzle[i][j] != 0) continue;

        // Check row
        for (int k = 0; k < size; k++) {
          if (k != j && _currentGrid[i][k] == val) {
            errors.add([i, j]);
            errors.add([i, k]);
          }
        }

        // Check column
        for (int k = 0; k < size; k++) {
          if (k != i && _currentGrid[k][j] == val) {
            errors.add([i, j]);
            errors.add([k, j]);
          }
        }

        // Check box
        int startRow, startCol, boxRows, boxCols;
        if (size == 4) {
          startRow = (i ~/ 2) * 2;
          startCol = (j ~/ 2) * 2;
          boxRows = boxCols = 2;
        } else {
          startRow = (i ~/ 2) * 2;
          startCol = (j ~/ 3) * 3;
          boxRows = 2;
          boxCols = 3;
        }

        for (int r = startRow; r < startRow + boxRows; r++) {
          for (int c = startCol; c < startCol + boxCols; c++) {
            if ((r != i || c != j) && _currentGrid[r][c] == val) {
              errors.add([i, j]);
              errors.add([r, c]);
            }
          }
        }
      }
    }

    return errors;
  }

  void _updateGrid(int row, int col, int value) {
    setState(() {
      _currentGrid[row][col] = value;
      _errorCells = _getErrorCells();
    });
  }

  void _showNumberPicker(int row, int col) {
    showModalBottomSheet(
      context: context,
      builder: (context) {
        return Wrap(
          spacing: 8,
          runSpacing: 8,
          children: List.generate(_size, (i) {
            final num = i + 1;
            return SizedBox(
              width: 60,
              height: 60,
              child: ElevatedButton(
                onPressed: () {
                  _updateGrid(row, col, num);
                  Navigator.pop(context);
                },
                style: ElevatedButton.styleFrom(
                  backgroundColor: kPrimaryColor.withOpacity(0.1),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                child: Text(
                  '$num',
                  style: const TextStyle(fontSize: 20, color: kPrimaryColor),
                ),
              ),
            );
          }),
        );
      },
    );
  }

  void _giveHint() {
    final emptyCells = <List<int>>[];
    for (int i = 0; i < _size; i++) {
      for (int j = 0; j < _size; j++) {
        if (_currentGrid[i][j] == 0) {
          emptyCells.add([i, j]);
        }
      }
    }

    if (emptyCells.isNotEmpty) {
      final cell = emptyCells[Random().nextInt(emptyCells.length)];
      final correctValue = _solution[cell[0]][cell[1]];
      _updateGrid(cell[0], cell[1], correctValue);
    }
  }

  void _resetPuzzle() {
    setState(() {
      for (int i = 0; i < _size; i++) {
        for (int j = 0; j < _size; j++) {
          if (_originalPuzzle[i][j] == 0) {
            _currentGrid[i][j] = 0;
          }
        }
      }
      _errorCells.clear();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('简易数独'),
        actions: [
          PopupMenuButton<int>(
            onSelected: (value) {
              setState(() {
                _size = value;
              });
              _newGame();
            },
            itemBuilder: (context) => [
              const PopupMenuItem(value: 4, child: Text('4×4')),
              const PopupMenuItem(value: 6, child: Text('6×6')),
            ],
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Expanded(
              child: GridView.builder(
                gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: _size,
                  crossAxisSpacing: 2,
                  mainAxisSpacing: 2,
                ),
                itemCount: _size * _size,
                itemBuilder: (context, index) {
                  final row = index ~/ _size;
                  final col = index % _size;
                  final value = _currentGrid[row][col];
                  final original = _originalPuzzle[row][col] != 0;
                  final hasError = _errorCells.any((e) => e[0] == row && e[1] == col);

                  return GestureDetector(
                    onTap: original ? null : () => _showNumberPicker(row, col),
                    child: Container(
                      decoration: BoxDecoration(
                        color: _getBoxColor(row, col),
                        borderRadius: BorderRadius.circular(8),
                        border: Border.all(
                          color: hasError ? Colors.red : Colors.transparent,
                          width: hasError ? 2 : 0,
                        ),
                      ),
                      child: Center(
                        child: Text(
                          value == 0 ? '' : '$value',
                          style: TextStyle(
                            fontSize: 20,
                            fontWeight: original ? FontWeight.w400 : FontWeight.bold,
                            color: original ? Colors.grey[600] : kPrimaryColor,
                          ),
                        ),
                      ),
                    ),
                  );
                },
              ),
            ),
            const SizedBox(height: 16),
            Row(
              children: [
                Expanded(
                  child: ElevatedButton.icon(
                    onPressed: _giveHint,
                    icon: const Icon(Icons.lightbulb_outlined),
                    label: const Text('提示'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: _resetPuzzle,
                    icon: const Icon(Icons.refresh),
                    label: const Text('重置'),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: _newGame,
                    icon: const Icon(Icons.casino),
                    label: const Text('新游戏'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

运行界面

结语

这款简易数独生成器,不仅是一次对经典益智游戏的现代化演绎,更是 Flutter 与 OpenHarmony 设计哲学 的完美结合。通过精巧的算法、清晰的反馈与优雅的界面,我们让"动脑"变得轻松而愉悦。

无论是通勤路上的几分钟,还是睡前放松的片刻,打开这个小工具,让逻辑之光照亮你的日常。正如鸿蒙所倡导的:"科技,应服务于人的专注与平静。"

相关推荐
微祎_10 小时前
Flutter for OpenHarmony:链迹 - 基于Flutter的会话级快速链接板极简实现方案
flutter
微祎_10 小时前
Flutter for OpenHarmony:魔方计时器开发实战 - 基于Flutter的专业番茄工作法应用实现与交互设计
flutter·交互
麟听科技15 小时前
HarmonyOS 6.0+ APP智能种植监测系统开发实战:农业传感器联动与AI种植指导落地
人工智能·分布式·学习·华为·harmonyos
前端不太难15 小时前
HarmonyOS PC 焦点系统重建
华为·状态模式·harmonyos
空白诗16 小时前
基础入门 Flutter for Harmony:Text 组件详解
javascript·flutter·harmonyos
lbb 小魔仙17 小时前
【HarmonyOS】React Native实战+Popover内容自适应
react native·华为·harmonyos
喝拿铁写前端17 小时前
接手老 Flutter 项目踩坑指南:从环境到调试的实际经验
前端·flutter
renke336417 小时前
Flutter for OpenHarmony:单词迷宫 - 基于路径探索与字母匹配的认知解谜系统
flutter
motosheep17 小时前
鸿蒙开发(四)播放 Lottie 动画实战(Canvas 渲染 + 资源加载踩坑总结)
华为·harmonyos
火柴就是我17 小时前
我们来尝试实现一个类似内阴影的效果
android·flutter