一、引言

数独(Sudoku)是全球最受欢迎的逻辑推理游戏之一,每天有数千万人在报纸、手机 App 和网页上填写数独。它的规则简单得惊人:在 9×9 的方格中填入数字 1-9,使得每一行、每一列和每一个 3×3 粗线宫内的数字都不重复。1979 年由美国建筑师 Howard Garns 发明,1986 年在日本被命名为"数独"(数字的唯一),之后于 2004 年在英国报纸上爆发式流行,迅速席卷全球。

从技术角度看,数独是一个约束满足问题。与井字棋的博弈对抗、扫雷的邻域推理、华容道的空间滑动都不同,数独的核心操作是填入与验证——每填入一个数字,需要检查三组约束(行、列、宫)中是否出现重复。这个过程没有任何随机性:每个数独谜题有且仅有一个解,玩家通过逻辑推理逐步收敛到唯一答案。

本文用 ArkUI 从零构建一个经典数独游戏,包含三级难度(简单 38 已知数/中等 30 已知数/困难 24 已知数)、选中高亮、冲突检测和通关验证。9×9 网格通过 Flex 弹性布局自动排列为规整的矩阵,81 个格子带 3×3 宫格粗边框——通过 getCellBorder() 方法为每格动态计算边框粗细。

阅读完本文,你将能够:

  • 用二维数组(number[][])表示 9×9 数独棋盘,区分"题目格"和"玩家格"
  • 实现三约束(行、列、宫)的冲突检测逻辑
  • 用预置题库 + 完整答案实现"即时验证"模式
  • 用动态边框计算在 Flex 布局中渲染 3×3 宫格分隔线
  • 实现选中高亮、冲突红色反馈、通关检测三类状态管理

二、游戏设计

2.1 规则与目标

数独的核心规则只有一条:每行、每列、每个 3×3 宫中的数字 1-9 各出现一次

9×9 棋盘被划分为 9 个 3×3 的"宫"(box),粗线分隔:

┌───┬───┬───┐
│   │   │   │  ← 宫 0 (左上)
│   │   │   │
│   │   │   │
┝━━━┿━━━┿━━━┥
│   │   │   │  ← 宫 3 (左中)
│   │   │   │
│   │   │   │
┝━━━┿━━━┿━━━┥
│   │   │   │  ← 宫 6 (左下)
│   │   │   │
│   │   │   │
└───┴───┴───┘

游戏开始时,棋盘上已经有一些数字(称为"已知数"或 givens),玩家需要在空白格中填入正确的数字。已知数的数量决定了难度:

  • 简单:38 个已知数(占 47%),留 43 个空格
  • 中等:30 个已知数(占 37%),留 51 个空格
  • 困难:24 个已知数(占 30%),留 57 个空格

已知数越少,推理链越长,游戏越难。

2.2 操作模式

本 Demo 采用"选中-填入"两步操作模式:

  1. 选中:点击棋盘上的空白格 → 格子高亮为淡蓝色(#E3F2FD
  2. 填入:点击下方数字键盘的 1-9 → 数字填入选中的格子
  3. 擦除:点击 ⌫ 按钮 → 清空选中的格子
  4. 切换:点击另一个格子 → 选区转移

这种模式与移动端数独 App 的主流交互一致——避免了"每次点击弹窗选数字"的繁琐,也避免了"直接输入数字"的误触风险。两步操作给玩家提供了"思考→确认"的时间窗口。

2.3 数据类型

三种格子的视觉和交互区分:

类型 视觉特征 交互行为
题目格(given) 深色加粗数字,浅灰背景 #F0F0F5 不可选中、不可修改
玩家格(player) 蓝色数字,白色背景 可选中、可填入、可擦除
冲突格(conflict) 红色数字,红色背景 #FFEBEE 标记为错误,等待修正

题目格在加载谜题时确定(givenCells[r][c] = puzzle[r][c] !== 0),在游戏过程中它们的值和状态永不改变。玩家格是空白的可填格,填入的数字显示为蓝色(与题目格的深色区分)。当填入的数字与正确答案不一致时(grid[r][c] !== solution[r][c]),格子变为红色冲突态。

2.4 交互流程

一局数独的完整交互流程:

  1. 加载谜题:根据当前难度加载预置的 9×9 题目和完整答案
  2. 观察棋盘:题目格(已知数)以深色展示,空白格等待填入
  3. 选中格子:点击空白格 → 高亮为淡蓝色
  4. 填入数字:点击数字键盘 1-9 → 数字填入选中格 → 自动冲突检测
  5. 擦除重试:点击 ⌫ → 清空选中格
  6. 逐步推理:重复步骤 3-5,利用行/列/宫约束逐步缩小候选数字
  7. 通关:所有格子与答案一致 → 显示绿色通关横幅
  8. 换难度 / 新游戏:切换难度加载新谜题,或刷新当前难度
    在这里插入图片描述

三、数据结构

3.1 棋盘表示

棋盘使用三个 9×9 的二维数组表示:

@State grid: number[][] = [];        // 当前棋盘(0 = 空格)
@State solution: number[][] = [];    // 完整答案
@State givenCells: boolean[][] = []; // 哪些是题目格

grid 存储玩家当前看到的棋盘状态——包括题目已知数和玩家填入的数字,空格用 0 表示。solution 存储该谜题的完整答案,用于冲突检测和通关判断。givenCells 是一个布尔矩阵,标记哪些格子的值是题目预设的(不可修改)。

三个数组在 loadPuzzle() 中同步更新:

loadPuzzle(diff: string): void {
  // 选择谜题和答案
  let puzzle: number[][] = [];
  let sol: number[][] = [];
  if (diff === 'easy') { puzzle = EASY_PUZZLE; sol = EASY_SOLUTION; }
  // ...

  const g: number[][] = [];
  const gc: boolean[][] = [];
  for (let r = 0; r < 9; r++) {
    const row: number[] = [];
    const grow: boolean[] = [];
    for (let c = 0; c < 9; c++) {
      row.push(puzzle[r][c]);
      grow.push(puzzle[r][c] !== 0);
    }
    g.push(row);
    gc.push(grow);
  }
  this.grid = g;
  this.givenCells = gc;
  this.solution = sol;
}

3.2 预置题库

每个难度预置了一个谜题和对应的答案:

const EASY_PUZZLE: number[][] = [
  [5, 3, 0, 0, 7, 0, 0, 0, 0],
  [6, 0, 0, 1, 9, 5, 0, 0, 0],
  [0, 9, 8, 0, 0, 0, 0, 6, 0],
  // ...
];

const EASY_SOLUTION: number[][] = [
  [5, 3, 4, 6, 7, 8, 9, 1, 2],
  [6, 7, 2, 1, 9, 5, 3, 4, 8],
  [1, 9, 8, 3, 4, 2, 5, 6, 7],
  // ...
];

谜题是部分填充的 9×9 网格(0 代表空格),答案是完整的 9×9 网格。三个谜题选自经典数独题库,保证了唯一解性。

在真正的数独应用中,谜题生成是一个复杂的问题——需要保证唯一解、可控难度、对称性等。本文使用预置题库简化这一环节,将焦点放在交互逻辑上。

3.3 选中格与冲突标记

@State selectedIdx: number = -1;          // 选中的格子索引(-1 = 未选中)
@State conflictCells: boolean[] = [];     // 冲突格标记(81 个布尔值)

selectedIdx 存储当前选中的格子在一维索引空间中的位置(0-80)。-1 表示未选中任何格子。conflictCells 是 81 个布尔值的数组,每个元素对应一个格子是否为冲突状态。

使用一维索引而非二维坐标的好处是简化了 ForEach 中的参数传递——每个格子对应一个唯一的整数 ID,所有视图辅助方法都接受 idx: number 参数。
在这里插入图片描述

四、核心逻辑

4.1 选中格子

selectCell(idx: number): void {
  if (this.gameWon) return;
  const r = Math.floor(idx / 9);
  const c = idx % 9;
  if (this.givenCells[r][c]) return;  // 题目格不可选中
  this.selectedIdx = idx;
}

点击格子时:

  1. 已通关 → 忽略(游戏已结束)
  2. 是题目格 → 忽略(题目格不可修改)
  3. 是玩家空格 → 选中,selectedIdx 更新,UI 自动高亮

已选中的格子再次点击无变化(无"取消选中"操作),要改变选区只需点击另一个格子。选中的"持久性"设计让玩家可以先选格、再填入,两步之间有任意长的思考时间。

4.2 填入数字

enterNumber(n: number): void {
  if (this.selectedIdx === -1 || this.gameWon) return;
  const r = Math.floor(this.selectedIdx / 9);
  const c = this.selectedIdx % 9;
  if (this.givenCells[r][c]) return;

  const newGrid: number[][] = [];
  for (let i = 0; i < 9; i++) {
    const row: number[] = [];
    for (let j = 0; j < 9; j++) {
      if (i === r && j === c) row.push(n);
      else row.push(this.grid[i][j]);
    }
    newGrid.push(row);
  }
  this.grid = newGrid;
  this.updateConflicts();
  this.checkWin();
}

填入数字的完整流程:

  1. 守卫条件:未选中 / 已通关 / 选中格是题目格 → 直接返回
  2. 二维数组不可变更新:创建全新的 9×9 二维数组,仅修改选中位置为数字 n
  3. 冲突检测:遍历所有 81 格,标记与 solution 不一致的玩家填入格
  4. 通关检测:逐格比较 gridsolution

二维数组的不可变更新比一维数组更啰嗦——需要嵌套循环逐行复制,但原理相同:创建全新的引用,触发 @State 的 UI 重新渲染。

4.3 冲突检测

updateConflicts(): void {
  const conflicts: boolean[] = [];
  for (let i = 0; i < 81; i++) conflicts.push(false);

  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const v = this.grid[r][c];
      if (v === 0) continue;
      if (this.solution[r][c] !== v) {
        conflicts[r * 9 + c] = true;
      }
    }
  }
  this.conflictCells = conflicts;
}

冲突检测逻辑非常简单:与 solution 对比即可。只要玩家填入的数字与对应位置的答案不一致,该格即标记为冲突。

这里有一个设计决策:为什么用"与答案比较"而不是"检查行/列/宫重复"来做冲突检测?

传统的冲突检测算法是:对于填入的数字 n,遍历同行的 9 格、同列的 9 格、同宫的 9 格,如果发现重复数字则标记冲突。这种方法不依赖完整答案,但实现更复杂(20 次比较,需要跳过自身和空格)。

本 Demo 选择"与答案比较"的方式有三个原因:

  1. 答案已知(从预置题库加载),比较成本低
  2. 逻辑更简洁——一次数组比较 vs 三次方向遍历
  3. 不会产生误导——如果玩家填入的数字与答案不同但在行/列/宫中不重复(暂时矛盾尚未暴露),用重复检测法不会标红,但用答案比较法会立即标红

答案比较法唯一的代价是"必须预先知道答案",这对于预置题库的场景不构成问题。

4.4 通关检测

checkWin(): void {
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      if (this.grid[r][c] !== this.solution[r][c]) return;
    }
  }
  this.gameWon = true;
  this.selectedIdx = -1;
}

81 个格子逐一比对,全部匹配则通关。通关后清空选中状态(selectedIdx = -1),显示绿色横幅。

4.5 擦除数字

eraseCell(): void {
  if (this.selectedIdx === -1 || this.gameWon) return;
  const r = Math.floor(this.selectedIdx / 9);
  const c = this.selectedIdx % 9;
  if (this.givenCells[r][c]) return;

  // 不可变更新:将选中格改为 0
  const newGrid: number[][] = [];
  for (let i = 0; i < 9; i++) {
    const row: number[] = [];
    for (let j = 0; j < 9; j++) {
      if (i === r && j === c) row.push(0);
      else row.push(this.grid[i][j]);
    }
    newGrid.push(row);
  }
  this.grid = newGrid;
  this.updateConflicts();
}

擦除的逻辑与填入几乎对称——不同的是将选中格的值改为 0(空格)而非数字 n。擦除后重新执行冲突检测,因为擦除可能导致之前的冲突消失。

五、UI 设计

5.1 页面结构

┌─────────────────────────────────────┐
│ 🧩 数独(深色标题栏)                 │
├─────────────────────────────────────┤
│ 🟢 简单   🟠 中等   🔴 困难          │ ← 难度切换
├─────────────────────────────────────┤
│ [🎉 恭喜通关!](条件渲染)           │ ← 通关横幅
├─────────────────────────────────────┤
│ ┌──┬──┬──┰──┬──┬──┰──┬──┬──┐      │
│ │ 5│ 3│  ┃  │ 7│  ┃  │  │  │      │ ← 9×9 棋盘
│ │ 6│  │  ┃ 1│ 9│ 5┃  │  │  │      │    Flex 弹性布局
│ │  │ 9│ 8┃  │  │  ┃  │ 6│  │      │    粗线分隔 3×3 宫
│ ┝━━┿━━┿━━╋━━┿━━┿━━╋━━┿━━┿━━┥      │
│ │...│   │   │   │   │   │   │   │   │      │
│ └──┴──┴──┸──┴──┴──┸──┴──┴──┘      │
├─────────────────────────────────────┤
│  1  2  3  4  5  6  7  8  9         │ ← 数字键盘
│             ⌫ 擦除                  │ ← 擦除按钮
├─────────────────────────────────────┤
│          🔄 新游戏                   │
└─────────────────────────────────────┘

5.2 棋盘渲染

9×9 棋盘使用 Flex({ wrap: FlexWrap.Wrap }) 布局,81 个格子通过单层 ForEach 渲染:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  ForEach(this.cellIdxArr, (idx: number) => {
    Text(this.cellValue(idx))
      .fontSize(18)
      .fontColor(this.cellColor(idx))
      .fontWeight(this.isGiven(idx) ? FontWeight.Bold : FontWeight.Medium)
      .width(35)
      .height(35)
      .backgroundColor(this.cellBg(idx))
      .textAlign(TextAlign.Center)
      .border(this.getCellBorder(idx))
      .onClick(() => { this.selectCell(idx); })
  }, (idx: number) => '' + idx)
}
.width(322)
.padding(3)
.backgroundColor('#333344')

要点:

  • 宽度 322vp:9 格 × 35vp + 间距 = 315 + 7 = 322,确保每行恰好 9 格不换行
  • 背景 #333344:深灰色背景透过格子间隙显示出来,形成自然的网格线
  • padding(3):3vp 内边距,使边缘格子与边框之间有均匀的间隙
  • FlexAlign.Start:格子在行内左对齐,确保每行对齐

5.3 3×3 宫格边框

这是数独 UI 最有挑战性的部分——如何在均匀的 9×9 网格中标记出 3×3 宫格的粗边框。

解决方案:getCellBorder() 方法根据格子的行、列位置动态计算各边边框粗细:

getCellBorder(idx: number): BorderOptions {
  const r = Math.floor(idx / 9);
  const c = idx % 9;
  const rightW = (c % 3 === 2 && c < 8) ? 2 : 0.5;
  const bottomW = (r % 3 === 2 && r < 8) ? 2 : 0.5;
  return {
    width: { top: 0.5, right: rightW, bottom: bottomW, left: 0.5 },
    color: '#CCCCD8'
  };
}

逻辑分析:

  • 右边框:当 c % 3 === 2(即第 2、5、8 列,每个 3×3 宫的右边界)且 c < 8(不是棋盘最右列)时,右边框加粗为 2vp;否则为 0.5vp
  • 下边框:当 r % 3 === 2(即第 2、5、8 行,每个 3×3 宫的下边界)且 r < 8(不是棋盘最下行)时,下边框加粗为 2vp;否则为 0.5vp
  • 左上边框:统一 0.5vp,因为它们不是宫格分隔线
  • 最右列和最下行:不需要粗边框(棋盘外边界由容器的 padding 和背景色自然形成)

这样一来,每格的边框由 (row, col) 位置决定,四边各自独立计算。9×9 的均匀网格中自然浮现出 3×3 宫格的视觉分隔——无需显式绘制"宫格边框线",它们是格子的粗边框拼合而成的。

5.4 格子状态样式

cellBg(idx: number): string {
  if (this.selectedIdx === idx) return '#E3F2FD';  // 选中态
  if (this.conflictCells[idx]) return '#FFEBEE';    // 冲突态
  if (this.isGiven(idx)) return '#F0F0F5';          // 题目格
  return '#FFFFFF';                                  // 普通空格
}

cellColor(idx: number): string {
  if (this.conflictCells[idx]) return '#FF4D4F';    // 红色
  if (this.isGiven(idx)) return '#1a1a2e';           // 深色
  return '#1677FF';                                  // 蓝色(玩家填入)
}

四种视觉状态的优先级:

  1. 选中态(淡蓝 #E3F2FD):最高优先级——如果格子被选中,无论是否冲突都显示选中色
  2. 冲突态(粉红 #FFEBEE + 红字 #FF4D4F):第二优先级——填入的数字与答案不符
  3. 题目格(浅灰 #F0F0F5 + 深字 #1a1a2e 加粗):静态标记——不可修改的已知数
  4. 普通格(白色 + 蓝字):默认状态——未被选中、无冲突、玩家已填入或尚为空白的格子

5.5 数字键盘

Row() {
  ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (n: number) => {
    Text('' + n)
      .fontSize(18)
      .fontColor('#FFFFFF')
      .fontWeight(FontWeight.Bold)
      .width(32)
      .height(40)
      .backgroundColor('#667eea')
      .borderRadius(BorderRadius.SM)
      .textAlign(TextAlign.Center)
      .onClick(() => { this.enterNumber(n); })
  }, (n: number) => '' + n)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)

9 个数字按钮水平排列,FlexAlign.SpaceBetween 均分间距。每个数字按钮 32vp 宽 × 40vp 高(比正方形稍高,优化点击区域),紫色背景白色粗体数字。

擦除按钮独立放在键盘下方:

Text('⌫ 擦除')
  .width(100).height(36)
  .backgroundColor('#FF4D4F')
  .borderRadius(BorderRadius.FULL)
  .onClick(() => { this.eraseCell(); })

红色胶囊形按钮,BorderRadius.FULL 实现圆角。与数字键盘的视觉区分——紫色 = 输入,红色 = 删除——符合用户对"增/删"的直觉预期。

六、完整代码结构

SudokuPage (~330 行)
├── 预置题库
│   ├── EASY_PUZZLE / EASY_SOLUTION
│   ├── MEDIUM_PUZZLE / MEDIUM_SOLUTION
│   └── HARD_PUZZLE / HARD_SOLUTION
├── 状态变量
│   ├── @State grid[9][9] — 当前棋盘
│   ├── @State solution[9][9] — 完整答案
│   ├── @State givenCells[9][9] — 题目格标记
│   ├── @State selectedIdx — 选中格子索引
│   ├── @State conflictCells[81] — 冲突标记
│   └── @State gameWon / difficulty — 通关 + 难度
├── 游戏逻辑
│   ├── loadPuzzle() — 加载谜题 + 初始化
│   ├── selectCell() — 选中格子
│   ├── enterNumber() — 填入数字 + 冲突检测 + 通关检测
│   ├── eraseCell() — 擦除数字
│   ├── updateConflicts() — 对比答案检测冲突
│   └── checkWin() — 81 格全等判定
├── 视图辅助
│   ├── cellValue/cellBg/cellColor — 格子显示三要素
│   ├── isGiven() — 判断是否题目格
│   └── getCellBorder() — 动态计算宫格边框
├── 视图
│   ├── 标题栏 — 🧩 数独
│   ├── 难度选择 — @Builder diffBtn × 3
│   ├── 通关横幅(条件渲染)
│   ├── 9×9 棋盘 — Flex wrap + ForEach
│   ├── 数字键盘 1-9 — Row + ForEach
│   ├── 擦除按钮
│   └── 新游戏按钮
└── 生命周期
    └── aboutToAppear() — 初始化 cellIdxArr + 加载 easy 谜题

七、与前面 Demo 的对比

7.1 "选中-操作"两步交互

前面所有 Demo 的交互都是"点击即执行"的单步模式:

  • 井字棋:点击空格 → 立即落子
  • 华容道:点击滑块 → 立即滑动
  • 猜词游戏:点击字母 → 立即判断
  • 扫雷:点击格子 → 立即揭开

数独首次引入了"选中-操作"两步交互——点击格子选中它(第一步),然后点击数字键盘填入(第二步)。这个中间态(“选中态”)在 UI 中表现为格子的淡蓝色高亮。

两步交互的设计动机是:数独的每个操作有两个维度——“在哪儿填”(位置)和"填什么"(数字)。将两个维度分配到两个操作中(先选位、再选数),比单步操作(在每个格子上弹窗选数字)更流畅,也更符合移动端的操作习惯。

7.2 二维数组不可变更新

数独的棋盘是 3 个 9×9 二维数组,更新方式比一维数组更啰嗦:

// 二维数组不可变更新
const newGrid: number[][] = [];
for (let i = 0; i < 9; i++) {
  const row: number[] = [];
  for (let j = 0; j < 9; j++) {
    if (i === r && j === c) row.push(n);
    else row.push(this.grid[i][j]);
  }
  newGrid.push(row);
}
this.grid = newGrid;

嵌套循环逐行逐列复制,只修改目标位置的值。9×9 的规模(81 个元素)对于这种深拷贝来说微不足道——毫秒级别的开销,换来的是 ArkUI 状态更新的可靠性。

7.3 冲突即时反馈

数独的冲突检测是实时执行的——每次填入或擦除后立即执行 updateConflicts()。这意味着玩家可以在填入数字的瞬间看到它是否正确:蓝色 = 正确,红色 = 错误。

这种即时反馈策略比"提交时统一检查"更友好——玩家不需要等到最后才知道自己填错了,而是可以一边填一边修正。同时,由于题目格是不可修改的,正确的数字一旦填入就不会被意外覆盖,降低了挫败感。

八、总结

本文从零构建了一个经典数独游戏。与井字棋(博弈)、记忆翻牌(配对)、华容道(滑动)、猜词(推理)和扫雷(展开)不同,数独的核心是约束满足与填入验证——每次填入一个数字,用答案比对进行即时冲突检测,逐步向唯一解收敛。从技术角度看,它是二维数组操作、两步交互模式和动态边框计算的完整示例。

核心要点回顾:

  1. 二维数组棋盘grid[9][9] + solution[9][9] + givenCells[9][9] 三个二维数组协同工作。题目格标记为不可修改(givenCells[r][c] === true),玩家填入显示为蓝色,与答案比对检测冲突(红色标记)。

  2. 即时的答案比对:不计算行/列/宫中的数字重复,而是直接与预置答案比较。updateConflicts() 在每次填入和擦除后运行——grid[r][c] !== solution[r][c] 即标记冲突。这种策略简洁高效,代价是需要预置完整答案,对于题库模式不构成问题。

  3. 两步交互(选中-填入):selectCell() 设置 selectedIdxenterNumber(n) 修改 grid 并触发冲突/通关检测。两步之间由淡蓝色高亮连接的"当前选中格",形成清晰的操作上下文。这种模式将"位置选择"和"数字选择"解耦,适配移动端的触屏操作。

  4. 动态宫格边框getCellBorder(idx) 根据 row % 3col % 3 动态计算每格四边的边框粗细。宫格边界的格子(第 2、5、8 行/列)底部/右边框加粗至 2vp,其余边保持 0.5vp。9×9 的均匀网格中,通过格子的粗边框拼合自然浮现 3×3 宫格分隔线——无需额外绘制粗线元素。

  5. 三级难度:简单(38 已知数)、中等(30 已知数)、困难(24 已知数)通过预置题库切换。loadPuzzle(diff) 重置 gridgivenCellssolutionconflictCellsselectedIdxgameWon。已知数越少,推理链越长,挑战越大。

  6. 不可变二维数组更新:每次填入或擦除创建一个全新的 9×9 二维数组——嵌套 for 循环逐行逐列复制,仅修改目标位置。虽然比一维数组的一行 swap 更啰嗦,但 81 元素 × 深拷贝的操作在毫秒级完成,可靠性远高于原地修改。

数独是一款"规则极简但推理极深"的经典游戏。这个 330 行的 ArkUI 实现抓住了它的核心乐趣:选题时的期待感、选中格高亮的专注感、填入正确数字后的推进感、以及冲突格变红时的即时纠错感。它是本系列第六篇游戏类文章,也是二维数组操作、两步交互和动态边框渲染的完整示例。

Logo

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

更多推荐