鸿蒙原生开发——从零构建数独游戏
一、引言
数独(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 采用"选中-填入"两步操作模式:
- 选中:点击棋盘上的空白格 → 格子高亮为淡蓝色(
#E3F2FD) - 填入:点击下方数字键盘的 1-9 → 数字填入选中的格子
- 擦除:点击 ⌫ 按钮 → 清空选中的格子
- 切换:点击另一个格子 → 选区转移
这种模式与移动端数独 App 的主流交互一致——避免了"每次点击弹窗选数字"的繁琐,也避免了"直接输入数字"的误触风险。两步操作给玩家提供了"思考→确认"的时间窗口。
2.3 数据类型
三种格子的视觉和交互区分:
| 类型 | 视觉特征 | 交互行为 |
|---|---|---|
| 题目格(given) | 深色加粗数字,浅灰背景 #F0F0F5 |
不可选中、不可修改 |
| 玩家格(player) | 蓝色数字,白色背景 | 可选中、可填入、可擦除 |
| 冲突格(conflict) | 红色数字,红色背景 #FFEBEE |
标记为错误,等待修正 |
题目格在加载谜题时确定(givenCells[r][c] = puzzle[r][c] !== 0),在游戏过程中它们的值和状态永不改变。玩家格是空白的可填格,填入的数字显示为蓝色(与题目格的深色区分)。当填入的数字与正确答案不一致时(grid[r][c] !== solution[r][c]),格子变为红色冲突态。
2.4 交互流程
一局数独的完整交互流程:
- 加载谜题:根据当前难度加载预置的 9×9 题目和完整答案
- 观察棋盘:题目格(已知数)以深色展示,空白格等待填入
- 选中格子:点击空白格 → 高亮为淡蓝色
- 填入数字:点击数字键盘 1-9 → 数字填入选中格 → 自动冲突检测
- 擦除重试:点击 ⌫ → 清空选中格
- 逐步推理:重复步骤 3-5,利用行/列/宫约束逐步缩小候选数字
- 通关:所有格子与答案一致 → 显示绿色通关横幅
- 换难度 / 新游戏:切换难度加载新谜题,或刷新当前难度

三、数据结构
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;
}
点击格子时:
- 已通关 → 忽略(游戏已结束)
- 是题目格 → 忽略(题目格不可修改)
- 是玩家空格 → 选中,
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();
}
填入数字的完整流程:
- 守卫条件:未选中 / 已通关 / 选中格是题目格 → 直接返回
- 二维数组不可变更新:创建全新的 9×9 二维数组,仅修改选中位置为数字
n - 冲突检测:遍历所有 81 格,标记与
solution不一致的玩家填入格 - 通关检测:逐格比较
grid与solution
二维数组的不可变更新比一维数组更啰嗦——需要嵌套循环逐行复制,但原理相同:创建全新的引用,触发 @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 选择"与答案比较"的方式有三个原因:
- 答案已知(从预置题库加载),比较成本低
- 逻辑更简洁——一次数组比较 vs 三次方向遍历
- 不会产生误导——如果玩家填入的数字与答案不同但在行/列/宫中不重复(暂时矛盾尚未暴露),用重复检测法不会标红,但用答案比较法会立即标红
答案比较法唯一的代价是"必须预先知道答案",这对于预置题库的场景不构成问题。
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'; // 蓝色(玩家填入)
}
四种视觉状态的优先级:
- 选中态(淡蓝
#E3F2FD):最高优先级——如果格子被选中,无论是否冲突都显示选中色 - 冲突态(粉红
#FFEBEE+ 红字#FF4D4F):第二优先级——填入的数字与答案不符 - 题目格(浅灰
#F0F0F5+ 深字#1a1a2e加粗):静态标记——不可修改的已知数 - 普通格(白色 + 蓝字):默认状态——未被选中、无冲突、玩家已填入或尚为空白的格子
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()。这意味着玩家可以在填入数字的瞬间看到它是否正确:蓝色 = 正确,红色 = 错误。
这种即时反馈策略比"提交时统一检查"更友好——玩家不需要等到最后才知道自己填错了,而是可以一边填一边修正。同时,由于题目格是不可修改的,正确的数字一旦填入就不会被意外覆盖,降低了挫败感。
八、总结
本文从零构建了一个经典数独游戏。与井字棋(博弈)、记忆翻牌(配对)、华容道(滑动)、猜词(推理)和扫雷(展开)不同,数独的核心是约束满足与填入验证——每次填入一个数字,用答案比对进行即时冲突检测,逐步向唯一解收敛。从技术角度看,它是二维数组操作、两步交互模式和动态边框计算的完整示例。
核心要点回顾:
-
二维数组棋盘:
grid[9][9]+solution[9][9]+givenCells[9][9]三个二维数组协同工作。题目格标记为不可修改(givenCells[r][c] === true),玩家填入显示为蓝色,与答案比对检测冲突(红色标记)。 -
即时的答案比对:不计算行/列/宫中的数字重复,而是直接与预置答案比较。
updateConflicts()在每次填入和擦除后运行——grid[r][c] !== solution[r][c]即标记冲突。这种策略简洁高效,代价是需要预置完整答案,对于题库模式不构成问题。 -
两步交互(选中-填入):
selectCell()设置selectedIdx,enterNumber(n)修改grid并触发冲突/通关检测。两步之间由淡蓝色高亮连接的"当前选中格",形成清晰的操作上下文。这种模式将"位置选择"和"数字选择"解耦,适配移动端的触屏操作。 -
动态宫格边框:
getCellBorder(idx)根据row % 3和col % 3动态计算每格四边的边框粗细。宫格边界的格子(第 2、5、8 行/列)底部/右边框加粗至 2vp,其余边保持 0.5vp。9×9 的均匀网格中,通过格子的粗边框拼合自然浮现 3×3 宫格分隔线——无需额外绘制粗线元素。 -
三级难度:简单(38 已知数)、中等(30 已知数)、困难(24 已知数)通过预置题库切换。
loadPuzzle(diff)重置grid、givenCells、solution、conflictCells、selectedIdx和gameWon。已知数越少,推理链越长,挑战越大。 -
不可变二维数组更新:每次填入或擦除创建一个全新的 9×9 二维数组——嵌套
for循环逐行逐列复制,仅修改目标位置。虽然比一维数组的一行 swap 更啰嗦,但 81 元素 × 深拷贝的操作在毫秒级完成,可靠性远高于原地修改。
数独是一款"规则极简但推理极深"的经典游戏。这个 330 行的 ArkUI 实现抓住了它的核心乐趣:选题时的期待感、选中格高亮的专注感、填入正确数字后的推进感、以及冲突格变红时的即时纠错感。它是本系列第六篇游戏类文章,也是二维数组操作、两步交互和动态边框渲染的完整示例。
更多推荐

所有评论(0)