@my-games/sudoku-react 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/README.md +34 -4
- package/dist/GameSudoku.d.ts +30 -1
- package/dist/GameSudoku.d.ts.map +1 -1
- package/dist/index.css +366 -8
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +373 -8
- package/dist/index.js.map +1 -1
- package/dist/useGame.d.ts +22 -0
- package/dist/useGame.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/GameSudoku.tsx +341 -5
- package/src/index.ts +11 -3
- package/src/useGame.ts +62 -0
package/src/GameSudoku.tsx
CHANGED
|
@@ -1,15 +1,351 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type Ref,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
useRef,
|
|
10
|
+
useState
|
|
11
|
+
} from 'react';
|
|
3
12
|
import '@my-games/sudoku-core/style.css';
|
|
13
|
+
import type { Difficulty, Digit, GameState, SaveStorage } from '@my-games/sudoku-core';
|
|
14
|
+
import { useGame } from './useGame';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GameSudoku —— React 18/19 组件,行为与 Vue 版对齐。
|
|
18
|
+
* 键盘:数字 1-9 填数 / N 切笔记 / Backspace·Delete 擦除 / 方向键移动选中;
|
|
19
|
+
* 严格判 `document.activeElement === board`,多实例并存只活动那个响应。
|
|
20
|
+
*/
|
|
4
21
|
|
|
5
22
|
export interface GameSudokuProps {
|
|
23
|
+
difficulty?: Difficulty;
|
|
24
|
+
storage?: SaveStorage | null;
|
|
25
|
+
saveKey?: string;
|
|
26
|
+
bestKey?: string;
|
|
27
|
+
freshStart?: boolean;
|
|
28
|
+
autofocus?: boolean;
|
|
29
|
+
showPad?: boolean;
|
|
6
30
|
title?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
showHint?: boolean;
|
|
33
|
+
onDigit?: (digit: Digit, changed: boolean) => void;
|
|
34
|
+
onSolved?: (payload: { difficulty: Difficulty; seconds: number }) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GameSudokuHandle {
|
|
38
|
+
select(row: number, col: number): void;
|
|
39
|
+
input(digit: Digit): boolean;
|
|
40
|
+
erase(): boolean;
|
|
41
|
+
toggleNote(digit: Digit): boolean;
|
|
42
|
+
hint(): boolean;
|
|
43
|
+
newGame(difficulty?: Difficulty): void;
|
|
44
|
+
undo(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PAD_DIGITS: Digit[] = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
48
|
+
const DIFFS: Array<{ key: Difficulty; label: string }> = [
|
|
49
|
+
{ key: 'easy', label: '简单' },
|
|
50
|
+
{ key: 'medium', label: '中等' },
|
|
51
|
+
{ key: 'hard', label: '困难' }
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** 9 宫 × 9 格的全局坐标映射。 */
|
|
55
|
+
const BOXES: Array<Array<{ row: number; col: number }>> = (() => {
|
|
56
|
+
const out: Array<Array<{ row: number; col: number }>> = [];
|
|
57
|
+
for (let bi = 0; bi < 9; bi++) {
|
|
58
|
+
const cells: Array<{ row: number; col: number }> = [];
|
|
59
|
+
for (let ci = 0; ci < 9; ci++) {
|
|
60
|
+
cells.push({
|
|
61
|
+
row: Math.floor(bi / 3) * 3 + Math.floor(ci / 3),
|
|
62
|
+
col: (bi % 3) * 3 + (ci % 3)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
out.push(cells);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
function formatTime(totalSeconds: number): string {
|
|
71
|
+
const m = Math.floor(totalSeconds / 60);
|
|
72
|
+
const s = totalSeconds % 60;
|
|
73
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cellValueOf(state: GameState, row: number, col: number): number {
|
|
77
|
+
return state.grid[row]?.[col] ?? 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cellClass(state: GameState, row: number, col: number): string {
|
|
81
|
+
const v = cellValueOf(state, row, col);
|
|
82
|
+
const given = state.givenMask[row]?.[col] ?? false;
|
|
83
|
+
const sel = state.selected;
|
|
84
|
+
const cls = ['game-sudoku-cell'];
|
|
85
|
+
if (given) cls.push('is-given');
|
|
86
|
+
else if (v !== 0) cls.push('is-user');
|
|
87
|
+
if (state.conflicts[row]?.[col]) cls.push('is-conflict');
|
|
88
|
+
if (sel && sel.row === row && sel.col === col) {
|
|
89
|
+
cls.push('is-selected');
|
|
90
|
+
} else if (sel) {
|
|
91
|
+
const sameBox =
|
|
92
|
+
Math.floor(sel.row / 3) === Math.floor(row / 3) &&
|
|
93
|
+
Math.floor(sel.col / 3) === Math.floor(col / 3);
|
|
94
|
+
if (sel.row === row || sel.col === col || sameBox) cls.push('is-peer');
|
|
95
|
+
const selVal = cellValueOf(state, sel.row, sel.col);
|
|
96
|
+
if (v !== 0 && v === selVal) cls.push('is-same');
|
|
97
|
+
}
|
|
98
|
+
return cls.join(' ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function cellNotesOf(state: GameState, row: number, col: number): Digit[] {
|
|
102
|
+
return cellValueOf(state, row, col) === 0 ? (state.notes[row]?.[col] ?? []) : [];
|
|
7
103
|
}
|
|
8
104
|
|
|
9
|
-
|
|
105
|
+
function GameSudokuInner(props: GameSudokuProps, ref: Ref<GameSudokuHandle>) {
|
|
106
|
+
const {
|
|
107
|
+
difficulty = 'easy',
|
|
108
|
+
storage,
|
|
109
|
+
saveKey,
|
|
110
|
+
bestKey,
|
|
111
|
+
freshStart = false,
|
|
112
|
+
autofocus = true,
|
|
113
|
+
showPad = true,
|
|
114
|
+
title = '数独',
|
|
115
|
+
className,
|
|
116
|
+
showHint = false,
|
|
117
|
+
onDigit,
|
|
118
|
+
onSolved
|
|
119
|
+
} = props;
|
|
120
|
+
|
|
121
|
+
const { state, game, select, input, erase, toggleNote, hint, newGame, undo } = useGame({
|
|
122
|
+
difficulty,
|
|
123
|
+
storage,
|
|
124
|
+
saveKey,
|
|
125
|
+
bestKey,
|
|
126
|
+
freshStart
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const boardRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const [noteMode, setNoteMode] = useState(false);
|
|
131
|
+
// 用时每秒刷新:core 只在操作时推快照,tick 触发重绘后由 game.getState() 读实时值。
|
|
132
|
+
const [, setTick] = useState(0);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (state.status !== 'playing') return;
|
|
135
|
+
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
|
|
136
|
+
return () => window.clearInterval(id);
|
|
137
|
+
}, [state.status]);
|
|
138
|
+
|
|
139
|
+
const applyDigit = useCallback(
|
|
140
|
+
(digit: Digit) => {
|
|
141
|
+
const changed = noteMode ? toggleNote(digit) : input(digit);
|
|
142
|
+
if (!noteMode) onDigit?.(digit, changed);
|
|
143
|
+
},
|
|
144
|
+
[noteMode, toggleNote, input, onDigit]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const handleKey = useCallback(
|
|
148
|
+
(ev: KeyboardEvent) => {
|
|
149
|
+
if (document.activeElement !== boardRef.current) return;
|
|
150
|
+
if (/^[1-9]$/.test(ev.key)) {
|
|
151
|
+
applyDigit(Number(ev.key) as Digit);
|
|
152
|
+
ev.preventDefault();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (ev.key === 'Backspace' || ev.key === 'Delete') {
|
|
156
|
+
erase();
|
|
157
|
+
ev.preventDefault();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (ev.key === 'n' || ev.key === 'N') {
|
|
161
|
+
setNoteMode((v) => !v);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const sel = state.selected;
|
|
165
|
+
if (!sel) return;
|
|
166
|
+
let { row, col } = sel;
|
|
167
|
+
switch (ev.key) {
|
|
168
|
+
case 'ArrowUp':
|
|
169
|
+
row = (row + 8) % 9;
|
|
170
|
+
break;
|
|
171
|
+
case 'ArrowDown':
|
|
172
|
+
row = (row + 1) % 9;
|
|
173
|
+
break;
|
|
174
|
+
case 'ArrowLeft':
|
|
175
|
+
col = (col + 8) % 9;
|
|
176
|
+
break;
|
|
177
|
+
case 'ArrowRight':
|
|
178
|
+
col = (col + 1) % 9;
|
|
179
|
+
break;
|
|
180
|
+
default:
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
ev.preventDefault();
|
|
184
|
+
select(row, col);
|
|
185
|
+
},
|
|
186
|
+
[applyDigit, erase, select, state.selected]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
window.addEventListener('keydown', handleKey, { passive: false });
|
|
191
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
192
|
+
}, [handleKey]);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (autofocus) boardRef.current?.focus({ preventScroll: true });
|
|
196
|
+
}, [autofocus]);
|
|
197
|
+
|
|
198
|
+
const lastStatus = useRef(state.status);
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (state.status === 'won' && lastStatus.current !== 'won') {
|
|
201
|
+
onSolved?.({ difficulty: state.difficulty, seconds: state.elapsedSeconds });
|
|
202
|
+
}
|
|
203
|
+
lastStatus.current = state.status;
|
|
204
|
+
}, [state.status, state.difficulty, state.elapsedSeconds, onSolved]);
|
|
205
|
+
|
|
206
|
+
useImperativeHandle(
|
|
207
|
+
ref,
|
|
208
|
+
() => ({ select, input, erase, toggleNote, hint, newGame, undo }),
|
|
209
|
+
[select, input, erase, toggleNote, hint, newGame, undo]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const won = state.status === 'won';
|
|
213
|
+
const timeText = formatTime(won ? state.elapsedSeconds : game.getState().elapsedSeconds);
|
|
214
|
+
const bestText = state.bestSeconds === null ? '—' : formatTime(state.bestSeconds);
|
|
215
|
+
|
|
216
|
+
const renderCell = (row: number, col: number): ReactNode => {
|
|
217
|
+
const value = cellValueOf(state, row, col);
|
|
218
|
+
const notes = cellNotesOf(state, row, col);
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
key={`${row}-${col}`}
|
|
222
|
+
className={cellClass(state, row, col)}
|
|
223
|
+
data-row={row}
|
|
224
|
+
data-col={col}
|
|
225
|
+
onClick={() => select(row, col)}
|
|
226
|
+
>
|
|
227
|
+
{value !== 0 ? (
|
|
228
|
+
value
|
|
229
|
+
) : (
|
|
230
|
+
<div className="cell-notes">
|
|
231
|
+
{PAD_DIGITS.map((d) => (
|
|
232
|
+
<span key={d}>{notes.includes(d) ? d : ''}</span>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
10
240
|
return (
|
|
11
|
-
<div className=
|
|
12
|
-
<
|
|
241
|
+
<div className={['game-sudoku', className].filter(Boolean).join(' ')}>
|
|
242
|
+
<header className="game-sudoku-header">
|
|
243
|
+
<h1 className="game-sudoku-title">
|
|
244
|
+
{title}
|
|
245
|
+
<span className="subtitle">@my-games/sudoku-react</span>
|
|
246
|
+
</h1>
|
|
247
|
+
<div className="game-sudoku-stats">
|
|
248
|
+
<div className="game-sudoku-stat">
|
|
249
|
+
<span className="label">用时</span>
|
|
250
|
+
<span className="value">{timeText}</span>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="game-sudoku-stat">
|
|
253
|
+
<span className="label">最佳</span>
|
|
254
|
+
<span className="value">{bestText}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div className={`game-sudoku-stat${state.mistakeCount > 0 ? ' is-danger' : ''}`}>
|
|
257
|
+
<span className="label">错误</span>
|
|
258
|
+
<span className="value">{state.mistakeCount}</span>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</header>
|
|
262
|
+
|
|
263
|
+
<div className="game-sudoku-toolbar">
|
|
264
|
+
<div className="game-sudoku-difficulty" role="group" aria-label="难度">
|
|
265
|
+
{DIFFS.map((d) => (
|
|
266
|
+
<button
|
|
267
|
+
key={d.key}
|
|
268
|
+
className={`game-sudoku-chip${state.difficulty === d.key ? ' is-active' : ''}`}
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={() => newGame(d.key)}
|
|
271
|
+
>
|
|
272
|
+
{d.label}
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
<div className="game-sudoku-actions">
|
|
277
|
+
<button
|
|
278
|
+
className={`game-sudoku-button${noteMode ? ' is-toggled' : ''}`}
|
|
279
|
+
type="button"
|
|
280
|
+
disabled={won}
|
|
281
|
+
onClick={() => setNoteMode((v) => !v)}
|
|
282
|
+
>
|
|
283
|
+
笔记 {noteMode ? '开' : '关'}
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
className="game-sudoku-button"
|
|
287
|
+
type="button"
|
|
288
|
+
disabled={won || state.historySize === 0}
|
|
289
|
+
onClick={() => undo()}
|
|
290
|
+
>
|
|
291
|
+
撤销
|
|
292
|
+
</button>
|
|
293
|
+
{showHint ? (
|
|
294
|
+
<button
|
|
295
|
+
className="game-sudoku-button"
|
|
296
|
+
type="button"
|
|
297
|
+
disabled={won}
|
|
298
|
+
onClick={() => hint()}
|
|
299
|
+
>
|
|
300
|
+
提示
|
|
301
|
+
</button>
|
|
302
|
+
) : null}
|
|
303
|
+
<button className="game-sudoku-button is-primary" type="button" onClick={() => newGame()}>
|
|
304
|
+
新游戏
|
|
305
|
+
</button>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div
|
|
310
|
+
ref={boardRef}
|
|
311
|
+
className="game-sudoku-board"
|
|
312
|
+
tabIndex={0}
|
|
313
|
+
role="application"
|
|
314
|
+
aria-label={`数独棋盘 ${state.status}`}
|
|
315
|
+
style={{ '--cols': 9 } as CSSProperties}
|
|
316
|
+
>
|
|
317
|
+
{BOXES.map((cells, bi) => (
|
|
318
|
+
<div key={bi} className="game-sudoku-box">
|
|
319
|
+
{cells.map(({ row, col }) => renderCell(row, col))}
|
|
320
|
+
</div>
|
|
321
|
+
))}
|
|
322
|
+
|
|
323
|
+
<div className={`game-sudoku-overlay${won ? ' is-visible' : ''}`}>
|
|
324
|
+
<h2>解开了!</h2>
|
|
325
|
+
<p className="meta">
|
|
326
|
+
难度 {state.difficulty} · 用时 {timeText} · 提示 {state.hintCount} 次 · 错误{' '}
|
|
327
|
+
{state.mistakeCount} 次
|
|
328
|
+
</p>
|
|
329
|
+
<div className="row">
|
|
330
|
+
<button className="game-sudoku-button is-primary" type="button" onClick={() => newGame()}>
|
|
331
|
+
再来一局
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{showPad ? (
|
|
338
|
+
<div className="game-sudoku-pad" role="group" aria-label="数字键盘">
|
|
339
|
+
{PAD_DIGITS.map((d) => (
|
|
340
|
+
<button key={d} type="button" disabled={won} onClick={() => applyDigit(d)}>
|
|
341
|
+
{d}
|
|
342
|
+
</button>
|
|
343
|
+
))}
|
|
344
|
+
</div>
|
|
345
|
+
) : null}
|
|
13
346
|
</div>
|
|
14
347
|
);
|
|
15
348
|
}
|
|
349
|
+
|
|
350
|
+
export const GameSudoku = forwardRef(GameSudokuInner);
|
|
351
|
+
GameSudoku.displayName = 'GameSudoku';
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export { GameSudoku, type GameSudokuProps } from './GameSudoku';
|
|
1
|
+
export { GameSudoku, type GameSudokuProps, type GameSudokuHandle } from './GameSudoku';
|
|
2
|
+
export { useGame, type UseGameReturn } from './useGame';
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
|
|
4
|
+
export type {
|
|
5
|
+
Cell,
|
|
6
|
+
Difficulty,
|
|
7
|
+
Digit,
|
|
8
|
+
GameState,
|
|
9
|
+
GameStatus,
|
|
10
|
+
Grid,
|
|
11
|
+
SaveStorage
|
|
12
|
+
} from '@my-games/sudoku-core';
|
package/src/useGame.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Sudoku,
|
|
4
|
+
type Difficulty,
|
|
5
|
+
type Digit,
|
|
6
|
+
type GameOptions,
|
|
7
|
+
type GameState
|
|
8
|
+
} from '@my-games/sudoku-core';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `useGame` —— React Hook 版引擎桥接(与 Vue 的 useGame 语义对齐)。
|
|
12
|
+
*
|
|
13
|
+
* - 引擎实例用 useMemo 按「难度 + 键名」缓存;这些 prop 变化 = 重开一局,视为预期语义。
|
|
14
|
+
* - useState 订阅引擎快照,useEffect 挂/卸订阅防泄漏。
|
|
15
|
+
* - useRef 保最新 game 实例给命令式 handler,避免闭包过期。
|
|
16
|
+
*/
|
|
17
|
+
export interface UseGameReturn {
|
|
18
|
+
state: GameState;
|
|
19
|
+
game: Sudoku;
|
|
20
|
+
select: (row: number, col: number) => void;
|
|
21
|
+
input: (digit: Digit) => boolean;
|
|
22
|
+
erase: () => boolean;
|
|
23
|
+
toggleNote: (digit: Digit) => boolean;
|
|
24
|
+
hint: () => boolean;
|
|
25
|
+
newGame: (difficulty?: Difficulty) => void;
|
|
26
|
+
undo: () => boolean;
|
|
27
|
+
canUndo: () => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useGame(options: GameOptions = {}): UseGameReturn {
|
|
31
|
+
const { difficulty, storage, saveKey, bestKey, freshStart, puzzle } = options;
|
|
32
|
+
|
|
33
|
+
const game = useMemo(
|
|
34
|
+
() => new Sudoku({ difficulty, storage, saveKey, bestKey, freshStart, puzzle }),
|
|
35
|
+
// 故意只依赖这些稳定原始值;storage / puzzle / rng / clock 引用不稳定,重开一次即可。
|
|
36
|
+
[difficulty, saveKey, bestKey]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const gameRef = useRef(game);
|
|
40
|
+
gameRef.current = game;
|
|
41
|
+
|
|
42
|
+
const [state, setState] = useState<GameState>(() => game.getState());
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
setState(game.getState());
|
|
46
|
+
const unsub = game.subscribe(setState);
|
|
47
|
+
return unsub;
|
|
48
|
+
}, [game]);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
state,
|
|
52
|
+
game,
|
|
53
|
+
select: (row, col) => gameRef.current.select(row, col),
|
|
54
|
+
input: (digit) => gameRef.current.input(digit),
|
|
55
|
+
erase: () => gameRef.current.erase(),
|
|
56
|
+
toggleNote: (digit) => gameRef.current.toggleNote(digit),
|
|
57
|
+
hint: () => gameRef.current.hint(),
|
|
58
|
+
newGame: (diff) => gameRef.current.newGame(diff),
|
|
59
|
+
undo: () => gameRef.current.undo(),
|
|
60
|
+
canUndo: () => gameRef.current.canUndo()
|
|
61
|
+
};
|
|
62
|
+
}
|