@my-games/sudoku-core 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { candidates, cellAt, cloneGrid, hasConflict, setCell } from './board';
2
+ import type { Digit, Grid } from './types';
3
+
4
+ /**
5
+ * 数独回溯求解器(MRV 启发式)+ 解个数统计。
6
+ *
7
+ * - `solve`:给定(可能不完整的)合法棋盘,返回一个完整解的副本;无解返回 null。
8
+ * - `countSolutions(grid, limit)`:统计该棋盘有多少个不同完整解,最多数到 `limit`
9
+ * 就早停。生成器用 `limit = 2` 判定「唯一解」(返回 1 → 唯一;≥2 → 多解)。
10
+ *
11
+ * MRV(Minimum Remaining Values):每步优先填「候选数最少」的空格,
12
+ * 一旦某空格候选为 0 立即回溯。标准 9×9 题在毫秒级完成,纯同步、无副作用。
13
+ *
14
+ * 内部对「工作副本」做原地 try/untry(backtracking 需要),但对外一律
15
+ * 先 `cloneGrid`,绝不改动调用者传入的棋盘。
16
+ */
17
+
18
+ /** 在 grid 上找候选最少的空格;若填满返回 null;若发现死格(0 候选)返回 { dead:true }。 */
19
+ function pickCell(grid: Grid): { row: number; col: number; cands: Digit[] } | null {
20
+ let best: { row: number; col: number; cands: Digit[] } | null = null;
21
+ for (let r = 0; r < grid.length; r++) {
22
+ for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
23
+ if (cellAt(grid, r, c) !== 0) continue;
24
+ const cands = candidates(grid, r, c);
25
+ if (cands.length === 0) return null; // 死格 → 由调用方据 filled 判定失败
26
+ if (!best || cands.length < best.cands.length) {
27
+ best = { row: r, col: c, cands };
28
+ if (cands.length === 1) return best; // 已是最优,提前收敛
29
+ }
30
+ }
31
+ }
32
+ return best;
33
+ }
34
+
35
+ /** 是否已无空格。 */
36
+ function isComplete(grid: Grid): boolean {
37
+ for (const row of grid) {
38
+ for (const v of row) if (v === 0) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /** 原地回溯填充工作副本;成功返回 true(grid 被填满),失败返回 false 并复原。 */
44
+ function fillInPlace(grid: Grid): boolean {
45
+ if (isComplete(grid)) return true;
46
+ const cell = pickCell(grid);
47
+ if (!cell) return false; // 有死格
48
+ for (const d of cell.cands) {
49
+ setCell(grid, cell.row, cell.col, d);
50
+ if (fillInPlace(grid)) return true;
51
+ setCell(grid, cell.row, cell.col, 0);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * 求解:返回完整解的新棋盘;若初始棋盘非法(已冲突)/无解返回 null。
58
+ * 不修改入参。
59
+ */
60
+ export function solve(grid: Grid): Grid | null {
61
+ if (hasConflict(grid)) return null; // 题面自身已矛盾 → 无合法解
62
+ const work = cloneGrid(grid);
63
+ return fillInPlace(work) ? work : null;
64
+ }
65
+
66
+ /**
67
+ * 统计解个数,最多数到 `limit` 即早停(默认 2)。
68
+ * 用于唯一性判定:`countSolutions(p) === 1` 表示唯一解。
69
+ * 不修改入参。`limit < 1` 视为 1;题面已冲突则返回 0。
70
+ */
71
+ export function countSolutions(grid: Grid, limit = 2): number {
72
+ if (hasConflict(grid)) return 0;
73
+ const cap = Math.max(1, limit);
74
+ const work = cloneGrid(grid);
75
+ let solutions = 0;
76
+
77
+ const rec = (): void => {
78
+ if (solutions >= cap) return;
79
+ if (isComplete(work)) {
80
+ solutions++;
81
+ return;
82
+ }
83
+ const cell = pickCell(work);
84
+ if (!cell) return; // 死格,此分支无解
85
+ for (const d of cell.cands) {
86
+ setCell(work, cell.row, cell.col, d);
87
+ rec();
88
+ setCell(work, cell.row, cell.col, 0);
89
+ if (solutions >= cap) return;
90
+ }
91
+ };
92
+
93
+ rec();
94
+ return solutions;
95
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { BestTimes, Difficulty, SaveStorage, SerializedSave } from './types';
2
+
3
+ /**
4
+ * 存档与最佳用时持久化。
5
+ *
6
+ * 版本策略:读到的 version ≠ SAVE_VERSION 时,`isSerializedSave` 守卫失败 →
7
+ * 上层丢弃并开新局。目前仅 v1。
8
+ *
9
+ * best 存的是 `BestTimes`(每个难度历史最佳用时秒数,null = 未通关),
10
+ * 以 JSON 落在单个 bestKey 下,读回时补全缺失难度为 null。
11
+ */
12
+ export const SAVE_VERSION = 1 as const;
13
+ export const DEFAULT_SAVE_KEY = 'game-sudoku:save';
14
+ export const DEFAULT_BEST_KEY = 'game-sudoku:best';
15
+
16
+ const DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard'];
17
+
18
+ function isDifficulty(x: unknown): x is Difficulty {
19
+ return typeof x === 'string' && (DIFFICULTIES as readonly string[]).includes(x);
20
+ }
21
+
22
+ /** 9×9 的二维数组守卫(浅校验行数与每行长度)。 */
23
+ function isGridLike(x: unknown): boolean {
24
+ return (
25
+ Array.isArray(x) &&
26
+ x.length === 9 &&
27
+ x.every((row) => Array.isArray(row) && row.length === 9)
28
+ );
29
+ }
30
+
31
+ /** 粗粒度结构守卫:只保证关键字段形状,深层值由 Sudoku 载入时再防御。 */
32
+ export function isSerializedSave(x: unknown): x is SerializedSave {
33
+ if (typeof x !== 'object' || x === null) return false;
34
+ const o = x as Record<string, unknown>;
35
+ return (
36
+ o['version'] === SAVE_VERSION &&
37
+ isDifficulty(o['difficulty']) &&
38
+ isGridLike(o['puzzle']) &&
39
+ isGridLike(o['solution']) &&
40
+ isGridLike(o['grid']) &&
41
+ (o['status'] === 'playing' || o['status'] === 'won')
42
+ );
43
+ }
44
+
45
+ export function loadSave(storage: SaveStorage | null, key: string): SerializedSave | null {
46
+ if (!storage) return null;
47
+ const raw = storage.getItem(key);
48
+ if (!raw) return null;
49
+ try {
50
+ const parsed: unknown = JSON.parse(raw);
51
+ return isSerializedSave(parsed) ? parsed : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function writeSave(storage: SaveStorage | null, key: string, save: SerializedSave): void {
58
+ if (!storage) return;
59
+ try {
60
+ storage.setItem(key, JSON.stringify(save));
61
+ } catch {
62
+ /* quota / 隐私模式静默忽略;不阻断游戏 */
63
+ }
64
+ }
65
+
66
+ export function clearSave(storage: SaveStorage | null, key: string): void {
67
+ if (!storage) return;
68
+ try {
69
+ storage.removeItem(key);
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ }
74
+
75
+ /** 空的 BestTimes(全 null)。 */
76
+ export function emptyBestTimes(): BestTimes {
77
+ return { easy: null, medium: null, hard: null };
78
+ }
79
+
80
+ /** 读回 BestTimes;损坏/缺失字段回落为 null。 */
81
+ export function loadBest(storage: SaveStorage | null, key: string): BestTimes {
82
+ const result = emptyBestTimes();
83
+ if (!storage) return result;
84
+ const raw = storage.getItem(key);
85
+ if (!raw) return result;
86
+ try {
87
+ const parsed: unknown = JSON.parse(raw);
88
+ if (typeof parsed === 'object' && parsed !== null) {
89
+ const o = parsed as Record<string, unknown>;
90
+ for (const d of DIFFICULTIES) {
91
+ const v = o[d];
92
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) result[d] = Math.floor(v);
93
+ }
94
+ }
95
+ } catch {
96
+ /* 回落全 null */
97
+ }
98
+ return result;
99
+ }
100
+
101
+ export function writeBest(storage: SaveStorage | null, key: string, best: BestTimes): void {
102
+ if (!storage) return;
103
+ try {
104
+ storage.setItem(key, JSON.stringify(best));
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+
110
+ /** 浏览器默认 Storage;无 DOM 环境返回 null,让 core 在 Node 也能跑。 */
111
+ export function tryGetGlobalStorage(): SaveStorage | null {
112
+ if (typeof globalThis === 'undefined') return null;
113
+ const s = (globalThis as { localStorage?: SaveStorage }).localStorage;
114
+ return s ?? null;
115
+ }
package/src/style.css CHANGED
@@ -1,17 +1,375 @@
1
- /*
2
- * @my-games/sudoku-core —— 0.0.0 占位样式表(单数命名 ./style.css)。
1
+ /**
2
+ * game-sudoku 通用样式表(单数命名:`./style.css`)。
3
3
  *
4
- * 空骨架阶段只放最小基类,保证三包各自构建能产出非空 dist/index.css
5
- * (verify:dist 守卫要求 vue/react 的 index.css 存在且非 0 字节)。
6
- * 真实棋盘样式(3×3 宫格布局、题面/用户/冲突配色、铅笔笔记迷你格)随引擎补入。
4
+ * 由 `@my-games/sudoku-core/style.css` 暴露,Vue / React 适配器都直接 import 这一份,
5
+ * 保持视觉一致(避免两包各自漂移)。
6
+ *
7
+ * 关键布局:棋盘是 3×3 的「宫」网格(.game-sudoku-box 各含 3×3 共 9 格)。
8
+ * 宫之间的粗缝(box 背景透出 strong line 色)天然构成九宫分隔线,宫之间用 box-gap、
9
+ * 宫内用 cell-gap 两级间距即可,无需 nth-child 取模去判边界。
10
+ *
11
+ * 约定:
12
+ * - 数字配色区分「题面给定」(深色加粗) 与「玩家填入」(蓝色);冲突格红底。
13
+ * - 选中格高亮 + 同数/同行列宫淡色提示;空格用 .cell-notes 3×3 迷你格画铅笔标记。
14
+ * - 尺寸自适应:容器宽度决定一切,clamp() 缩放,320px~520px 视觉合理。
15
+ * - prefers-reduced-motion 下关过渡/动画。
7
16
  */
8
17
 
9
18
  .game-sudoku {
19
+ --board-size: min(92vw, 460px);
20
+ --box-gap: 3px;
21
+ --cell-gap: 1px;
22
+ --radius: clamp(4px, 1.2vw, 10px);
23
+ --line-strong: #3b4a5a;
24
+ --line-soft: #93a3b3;
25
+ --cell-bg: #ffffff;
26
+ --text-given: #1f2d3d;
27
+ --text-user: #1c63b5;
28
+ --text-muted: #8a99a8;
29
+ --hl-selected: #cfe3ff;
30
+ --hl-peer: #eef4fb;
31
+ --hl-same: #d7e9ff;
32
+ --bad: #ffd7d7;
33
+ --bad-text: #b4232a;
34
+ --accent: #1c63b5;
35
+ --accent-dark: #144d8f;
36
+
10
37
  display: inline-flex;
11
38
  flex-direction: column;
12
- gap: 8px;
13
- font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
14
- color: #1f2d3d;
39
+ gap: 10px;
40
+ font-family: 'Helvetica Neue', 'Segoe UI', system-ui, -apple-system, sans-serif;
41
+ color: var(--text-given);
15
42
  user-select: none;
16
43
  -webkit-tap-highlight-color: transparent;
17
44
  }
45
+
46
+ .game-sudoku-header {
47
+ display: flex;
48
+ align-items: flex-start;
49
+ justify-content: space-between;
50
+ gap: 10px;
51
+ width: var(--board-size);
52
+ max-width: 92vw;
53
+ }
54
+
55
+ .game-sudoku-title {
56
+ margin: 0;
57
+ font-size: clamp(24px, 7vw, 38px);
58
+ font-weight: 800;
59
+ letter-spacing: -0.5px;
60
+ }
61
+
62
+ .game-sudoku-title .subtitle {
63
+ display: block;
64
+ font-size: 12px;
65
+ font-weight: 600;
66
+ letter-spacing: 0;
67
+ color: var(--text-muted);
68
+ }
69
+
70
+ .game-sudoku-stats {
71
+ display: flex;
72
+ gap: 6px;
73
+ }
74
+
75
+ .game-sudoku-stat {
76
+ position: relative;
77
+ min-width: 66px;
78
+ padding: 5px 10px 7px;
79
+ border-radius: var(--radius);
80
+ background: var(--line-strong);
81
+ color: #fff;
82
+ text-align: center;
83
+ font-variant-numeric: tabular-nums;
84
+ }
85
+
86
+ .game-sudoku-stat .label {
87
+ display: block;
88
+ font-size: 10px;
89
+ letter-spacing: 1px;
90
+ text-transform: uppercase;
91
+ opacity: 0.85;
92
+ }
93
+
94
+ .game-sudoku-stat .value {
95
+ display: block;
96
+ font-size: 18px;
97
+ font-weight: 700;
98
+ line-height: 1.2;
99
+ }
100
+
101
+ .game-sudoku-stat.is-danger .value {
102
+ color: #ffb3b3;
103
+ }
104
+
105
+ .game-sudoku-toolbar {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: space-between;
109
+ gap: 8px;
110
+ width: var(--board-size);
111
+ max-width: 92vw;
112
+ flex-wrap: wrap;
113
+ }
114
+
115
+ .game-sudoku-difficulty {
116
+ display: inline-flex;
117
+ gap: 4px;
118
+ }
119
+
120
+ .game-sudoku-chip {
121
+ padding: 6px 12px;
122
+ border: 1px solid var(--line-soft);
123
+ border-radius: 999px;
124
+ background: #fff;
125
+ color: var(--text-given);
126
+ font: inherit;
127
+ font-size: 13px;
128
+ font-weight: 600;
129
+ cursor: pointer;
130
+ }
131
+
132
+ .game-sudoku-chip.is-active {
133
+ background: var(--accent);
134
+ border-color: var(--accent);
135
+ color: #fff;
136
+ }
137
+
138
+ .game-sudoku-chip:disabled {
139
+ opacity: 0.5;
140
+ cursor: default;
141
+ }
142
+
143
+ .game-sudoku-actions {
144
+ display: flex;
145
+ gap: 6px;
146
+ flex-wrap: wrap;
147
+ }
148
+
149
+ .game-sudoku-button {
150
+ padding: 8px 12px;
151
+ border: 0;
152
+ border-radius: var(--radius);
153
+ background: #e7edf3;
154
+ color: var(--text-given);
155
+ font: inherit;
156
+ font-weight: 700;
157
+ font-size: 13px;
158
+ cursor: pointer;
159
+ transition: background 120ms, transform 80ms;
160
+ }
161
+
162
+ .game-sudoku-button:hover:not(:disabled) {
163
+ background: #d7e2ec;
164
+ }
165
+
166
+ .game-sudoku-button:active:not(:disabled) {
167
+ transform: translateY(1px);
168
+ }
169
+
170
+ .game-sudoku-button:disabled {
171
+ opacity: 0.4;
172
+ cursor: not-allowed;
173
+ }
174
+
175
+ .game-sudoku-button.is-primary {
176
+ background: var(--accent);
177
+ color: #fff;
178
+ }
179
+
180
+ .game-sudoku-button.is-primary:hover:not(:disabled) {
181
+ background: var(--accent-dark);
182
+ }
183
+
184
+ .game-sudoku-button.is-toggled {
185
+ background: var(--accent-dark);
186
+ color: #fff;
187
+ }
188
+
189
+ .game-sudoku-board {
190
+ position: relative;
191
+ width: var(--board-size);
192
+ max-width: 92vw;
193
+ padding: var(--box-gap);
194
+ border-radius: var(--radius);
195
+ background: var(--line-strong);
196
+ outline: none;
197
+ box-sizing: content-box;
198
+ display: grid;
199
+ grid-template-columns: repeat(3, 1fr);
200
+ grid-template-rows: repeat(3, 1fr);
201
+ gap: var(--box-gap);
202
+ aspect-ratio: 1;
203
+ }
204
+
205
+ .game-sudoku-board:focus-visible {
206
+ box-shadow: 0 0 0 3px rgba(28, 99, 181, 0.4);
207
+ }
208
+
209
+ .game-sudoku-box {
210
+ display: grid;
211
+ grid-template-columns: repeat(3, 1fr);
212
+ grid-template-rows: repeat(3, 1fr);
213
+ gap: var(--cell-gap);
214
+ background: var(--line-soft);
215
+ border-radius: 3px;
216
+ overflow: hidden;
217
+ }
218
+
219
+ .game-sudoku-cell {
220
+ position: relative;
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ background: var(--cell-bg);
225
+ font-weight: 500;
226
+ font-size: clamp(15px, 4.6vw, 26px);
227
+ line-height: 1;
228
+ cursor: pointer;
229
+ aspect-ratio: 1;
230
+ }
231
+
232
+ .game-sudoku-cell.is-given {
233
+ color: var(--text-given);
234
+ font-weight: 800;
235
+ }
236
+
237
+ .game-sudoku-cell.is-user {
238
+ color: var(--text-user);
239
+ }
240
+
241
+ .game-sudoku-cell.is-peer {
242
+ background: var(--hl-peer);
243
+ }
244
+
245
+ .game-sudoku-cell.is-same {
246
+ background: var(--hl-same);
247
+ }
248
+
249
+ .game-sudoku-cell.is-selected {
250
+ background: var(--hl-selected);
251
+ }
252
+
253
+ .game-sudoku-cell.is-conflict {
254
+ background: var(--bad);
255
+ color: var(--bad-text);
256
+ }
257
+
258
+ .game-sudoku-cell.is-hint {
259
+ color: var(--accent-dark);
260
+ }
261
+
262
+ .cell-notes {
263
+ position: absolute;
264
+ inset: 2px;
265
+ display: grid;
266
+ grid-template-columns: repeat(3, 1fr);
267
+ grid-template-rows: repeat(3, 1fr);
268
+ align-items: center;
269
+ justify-items: center;
270
+ font-size: clamp(7px, 1.8vw, 11px);
271
+ font-weight: 600;
272
+ color: var(--text-muted);
273
+ pointer-events: none;
274
+ }
275
+
276
+ .game-sudoku-pad {
277
+ display: grid;
278
+ grid-template-columns: repeat(9, 1fr);
279
+ gap: 6px;
280
+ width: var(--board-size);
281
+ max-width: 92vw;
282
+ }
283
+
284
+ .game-sudoku-pad button {
285
+ padding: 10px 0;
286
+ border: 0;
287
+ border-radius: var(--radius);
288
+ background: #eef3f8;
289
+ color: var(--text-user);
290
+ font: inherit;
291
+ font-weight: 700;
292
+ font-size: clamp(16px, 4vw, 22px);
293
+ cursor: pointer;
294
+ }
295
+
296
+ .game-sudoku-pad button:hover:not(:disabled) {
297
+ background: #dfeaf5;
298
+ }
299
+
300
+ .game-sudoku-pad button:disabled {
301
+ opacity: 0.35;
302
+ cursor: not-allowed;
303
+ }
304
+
305
+ .game-sudoku-pad .is-active-mode {
306
+ background: var(--accent);
307
+ color: #fff;
308
+ }
309
+
310
+ .game-sudoku-overlay {
311
+ position: absolute;
312
+ inset: 0;
313
+ display: flex;
314
+ flex-direction: column;
315
+ align-items: center;
316
+ justify-content: center;
317
+ gap: 12px;
318
+ border-radius: var(--radius);
319
+ background: rgba(255, 255, 255, 0.86);
320
+ color: var(--text-given);
321
+ text-align: center;
322
+ opacity: 0;
323
+ pointer-events: none;
324
+ transition: opacity 220ms ease-in;
325
+ z-index: 10;
326
+ }
327
+
328
+ .game-sudoku-overlay.is-visible {
329
+ opacity: 1;
330
+ pointer-events: auto;
331
+ }
332
+
333
+ .game-sudoku-overlay h2 {
334
+ margin: 0;
335
+ font-size: clamp(26px, 7vw, 40px);
336
+ font-weight: 800;
337
+ color: var(--accent-dark);
338
+ }
339
+
340
+ .game-sudoku-overlay .meta {
341
+ font-size: 14px;
342
+ color: var(--text-muted);
343
+ }
344
+
345
+ .game-sudoku-overlay .row {
346
+ display: flex;
347
+ gap: 8px;
348
+ }
349
+
350
+ .game-sudoku-desc {
351
+ margin: 0;
352
+ font-size: 13px;
353
+ line-height: 1.5;
354
+ color: var(--text-muted);
355
+ }
356
+
357
+ @keyframes game-sudoku-pop {
358
+ 0% { transform: scale(1); }
359
+ 50% { transform: scale(1.18); }
360
+ 100% { transform: scale(1); }
361
+ }
362
+
363
+ .game-sudoku-cell.is-pop {
364
+ animation: game-sudoku-pop 200ms ease-out;
365
+ }
366
+
367
+ @media (prefers-reduced-motion: reduce) {
368
+ .game-sudoku-button,
369
+ .game-sudoku-overlay {
370
+ transition: none;
371
+ }
372
+ .game-sudoku-cell.is-pop {
373
+ animation: none;
374
+ }
375
+ }