54 道题、一次遍历、一个 JSON:纯端侧鸿蒙数独的三个设计取舍
一、为什么做一个“什么都没有”的数独
最近在鸿蒙实践案例中心看到一个纯端侧的数独案例。它没有后端、没有账号、没有网络权限,连登录页都没有,只做三件事:
- 提供一套 54 道的分难度题库;
- 填数后实时做冲突校验;
- 把通关进度保存到本地。
功能看起来朴素,但它把 ArkUI 最基础、也最容易写乱的三块——声明式 UI、状态管理、端侧持久化——完整地串联了一遍。如果你已经能写 ArkUI 页面,但还在纠结“状态放哪、数据存哪、规则写哪”,这个案例的结构比堆功能的项目更值得读。
环境很轻:DevEco Studio 官网最新稳定版,compatibleSdkVersion 为 6.1.0(23),compileSdkVersion 与 targetSdkVersion 为 6.1.1(24),模拟器或真机都能跑。
二、题库:54 道题不是手写的
手写 54 道数独既不现实,也容易出错。案例的思路是:先准备一个合法终盘,再对它做不破坏合法性的变换,最后按难度挖空。
不破坏合法性的变换有三类,都是数独的经典性质:
- 数字重映射:把 1~9 做一个随机置换,整盘仍然合法;
- 转置:沿主对角线翻转,行列互换,仍然合法;
- 宫行 / 宫列内交换:同一组三行(或三列)内部交换,仍然合法。
先准备一个基准终盘:
const BASE_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,
8, 5, 9, 7, 6, 1, 4, 2, 3,
4, 2, 6, 8, 5, 3, 7, 9, 1,
7, 1, 3, 9, 2, 4, 8, 5, 6,
9, 6, 1, 5, 3, 7, 2, 8, 4,
2, 8, 7, 4, 1, 9, 6, 3, 5,
3, 4, 5, 2, 8, 6, 1, 7, 9
];
再做数字置换和转置:
function shuffledDigits(): number[] {
const d: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let i = d.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = d[i]; d[i] = d[j]; d[j] = t;
}
return d;
}
function transpose(board: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < 81; i++) {
out.push(0);
}
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
out[c * 9 + r] = board[r * 9 + c];
}
}
return out;
}
function applyDigitMap(board: number[], map: number[]): number[] {
const out: number[] = [];
for (const v of board) {
out.push(v === 0 ? 0 : map[v - 1]);
}
return out;
}
挖空决定难度:每个 3×3 宫保留的提示数越少,题目越难。
function digHoles(solved: number[], blanksPerBox: number): number[] {
const puzzle: number[] = solved.slice();
for (let b = 0; b < 9; b++) {
const br = Math.floor(b / 3) * 3;
const bc = (b % 3) * 3;
const cells: number[] = [];
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
cells.push((br + r) * 9 + bc + c);
}
}
// 洗牌后挖掉前 blanksPerBox 个
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = cells[i]; cells[i] = cells[j]; cells[j] = t;
}
for (let k = 0; k < blanksPerBox; k++) {
puzzle[cells[k]] = 0;
}
}
return puzzle;
}
这里有第一个值得说的取舍:挖空只保证“有解”,不保证“唯一解”。
要证明一道数独只有唯一解,通常需要反复求解、统计解的个数,端侧做这件事成本不低。案例干脆不做唯一性校验,并把通关判定设为「填满且无行列宫冲突」,不与内置答案比对。所以同一道题可能存在多套合法终盘,玩家填出任意一套都算通关。
这不是漏洞,而是一个被写进案例边界说明的设计决定。项目里能把“我故意没做什么、为什么不做”写清楚,比悄悄留下一个坑要好得多。
三、冲突校验:81 格一次扫完
最容易写歪的做法是:每
- 点赞
- 收藏
- 关注作者
评论(0)