@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.
@@ -1,15 +1,351 @@
1
- // 0.0.0 空骨架占位组件:仅 import core 样式以让 vite lib build 抽出非空
2
- // dist/index.css(verify:dist 守卫要求)。真实棋盘/输入/提示 UI 随引擎补入。
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
- export function GameSudoku({ title = 'Sudoku' }: GameSudokuProps) {
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="game-sudoku">
12
- <p>{title} · React 占位组件(0.0.0)。真实棋盘随引擎后续提交补入。</p>
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 const SUDOKU_PACKAGE = 'sudoku-react' as const;
4
- export const SUDOKU_STAGE = '0.0.0' as const;
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
+ }