@my-games/2048-react 0.0.0-stage → 0.2.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.
@@ -0,0 +1,380 @@
1
+ import {
2
+ type CSSProperties,
3
+ type Ref,
4
+ type TouchEvent as ReactTouchEvent,
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useMemo,
10
+ useRef,
11
+ useState
12
+ } from 'react';
13
+ import '@my-games/2048-core/styles.css';
14
+ import type { Direction, SaveStorage } from '@my-games/2048-core';
15
+ import { useGame } from './useGame';
16
+
17
+ /**
18
+ * Game2048 —— React 18 组件。与 Vue 版行为对齐:
19
+ * - 键盘:window keydown + 严格 focus 判定(多实例并存只活动那个响应);
20
+ * 方向键 / WASD。
21
+ * - 触摸:单指 swipe 主导轴 ≥24px;touchstart 时把焦点抢过来。
22
+ * - 动画:新块 / 合并 / 位移全走 core 的 styles.css。
23
+ * - AI:`enableAI` prop 控制按钮渲染;通过 ref 一直可以命令式访问。
24
+ */
25
+
26
+ export interface Game2048Props {
27
+ size?: 3 | 4 | 5 | 6;
28
+ storage?: SaveStorage | null;
29
+ saveKey?: string;
30
+ bestKey?: string;
31
+ freshStart?: boolean;
32
+ autofocus?: boolean;
33
+ enableAI?: boolean;
34
+ aiDepth?: number;
35
+ title?: string;
36
+ onMove?: (dir: Direction, gained: number) => void;
37
+ onWon?: () => void;
38
+ onLost?: () => void;
39
+ className?: string;
40
+ }
41
+
42
+ export interface Game2048Handle {
43
+ suggestMove(): Direction | null;
44
+ aiMove(): Direction | null;
45
+ newGame(): void;
46
+ undo(): boolean;
47
+ move(dir: Direction): boolean;
48
+ continuePlaying(): void;
49
+ }
50
+
51
+ const KEY_TO_DIR: Record<string, Direction> = {
52
+ ArrowUp: 'up',
53
+ ArrowDown: 'down',
54
+ ArrowLeft: 'left',
55
+ ArrowRight: 'right',
56
+ w: 'up',
57
+ a: 'left',
58
+ s: 'down',
59
+ d: 'right',
60
+ W: 'up',
61
+ A: 'left',
62
+ S: 'down',
63
+ D: 'right'
64
+ };
65
+
66
+ function cssVars(vars: Record<string, string | number>): CSSProperties {
67
+ return vars as CSSProperties;
68
+ }
69
+
70
+ function Game2048Inner(props: Game2048Props, ref: Ref<Game2048Handle>) {
71
+ const {
72
+ size = 4,
73
+ storage,
74
+ saveKey,
75
+ bestKey,
76
+ freshStart = false,
77
+ autofocus = true,
78
+ enableAI = false,
79
+ aiDepth = 3,
80
+ title = '2048',
81
+ onMove,
82
+ onWon,
83
+ onLost,
84
+ className
85
+ } = props;
86
+
87
+ const { state, move, newGame, undo, continuePlaying, suggestMove, aiMove } = useGame({
88
+ size,
89
+ storage,
90
+ saveKey,
91
+ bestKey,
92
+ freshStart,
93
+ aiDepth
94
+ });
95
+
96
+ const boardRef = useRef<HTMLDivElement>(null);
97
+ const touchStartPoint = useRef<{ x: number; y: number } | null>(null);
98
+ const lastScoreRef = useRef(state.score);
99
+ const lastStatusRef = useRef(state.status);
100
+
101
+ const [scoreDelta, setScoreDelta] = useState<{ amount: number; key: number } | null>(null);
102
+ const [aiHint, setAiHint] = useState<Direction | null>(null);
103
+ const deltaTimer = useRef<number | undefined>(undefined);
104
+
105
+ // --- 键盘 ---
106
+ const performMove = useCallback(
107
+ (dir: Direction) => {
108
+ const before = lastScoreRef.current;
109
+ const ok = move(dir);
110
+ if (ok) {
111
+ onMove?.(dir, state.score - before);
112
+ if (enableAI) setAiHint(null);
113
+ }
114
+ },
115
+ [move, onMove, state.score, enableAI]
116
+ );
117
+
118
+ useEffect(() => {
119
+ const handler = (ev: KeyboardEvent) => {
120
+ const dir = KEY_TO_DIR[ev.key];
121
+ if (!dir) return;
122
+ // 只有本实例 board 有 DOM 焦点时才响应
123
+ if (document.activeElement !== boardRef.current) return;
124
+ ev.preventDefault();
125
+ performMove(dir);
126
+ };
127
+ window.addEventListener('keydown', handler, { passive: false });
128
+ return () => window.removeEventListener('keydown', handler);
129
+ }, [performMove]);
130
+
131
+ useEffect(() => {
132
+ if (autofocus) boardRef.current?.focus({ preventScroll: true });
133
+ }, [autofocus]);
134
+
135
+ // --- 触摸 ---
136
+ const onTouchStart = (ev: ReactTouchEvent<HTMLDivElement>) => {
137
+ if (ev.touches.length !== 1) return;
138
+ const t = ev.touches[0];
139
+ if (!t) return;
140
+ touchStartPoint.current = { x: t.clientX, y: t.clientY };
141
+ boardRef.current?.focus({ preventScroll: true });
142
+ };
143
+ const onTouchEnd = (ev: ReactTouchEvent<HTMLDivElement>) => {
144
+ const start = touchStartPoint.current;
145
+ touchStartPoint.current = null;
146
+ if (!start) return;
147
+ const t = ev.changedTouches[0];
148
+ if (!t) return;
149
+ const dx = t.clientX - start.x;
150
+ const dy = t.clientY - start.y;
151
+ const adx = Math.abs(dx);
152
+ const ady = Math.abs(dy);
153
+ if (Math.max(adx, ady) < 24) return;
154
+ const dir: Direction = adx > ady ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';
155
+ performMove(dir);
156
+ };
157
+
158
+ // --- 分数 delta 与状态副作用 ---
159
+ useEffect(() => {
160
+ const gained = state.score - lastScoreRef.current;
161
+ if (gained > 0) {
162
+ setScoreDelta({ amount: gained, key: Date.now() + Math.random() });
163
+ if (deltaTimer.current) window.clearTimeout(deltaTimer.current);
164
+ deltaTimer.current = window.setTimeout(() => setScoreDelta(null), 700);
165
+ }
166
+ lastScoreRef.current = state.score;
167
+ }, [state.score]);
168
+
169
+ useEffect(() => {
170
+ if (state.status === lastStatusRef.current) return;
171
+ if (state.status === 'won') onWon?.();
172
+ else if (state.status === 'lost') onLost?.();
173
+ lastStatusRef.current = state.status;
174
+ }, [state.status, onWon, onLost]);
175
+
176
+ useEffect(
177
+ () => () => {
178
+ if (deltaTimer.current) window.clearTimeout(deltaTimer.current);
179
+ },
180
+ []
181
+ );
182
+
183
+ // --- 渲染派生 ---
184
+ const gridStyle = useMemo(
185
+ () =>
186
+ cssVars({
187
+ gridTemplateColumns: `repeat(${state.size}, 1fr)`,
188
+ gridTemplateRows: `repeat(${state.size}, 1fr)`
189
+ }),
190
+ [state.size]
191
+ );
192
+ const boardVars = useMemo(() => cssVars({ '--cols': state.size }), [state.size]);
193
+ const undoDisabled = state.historySize === 0;
194
+ const overlayVisible = state.status !== 'playing';
195
+ const isWon = state.status === 'won';
196
+ const isLost = state.status === 'lost';
197
+
198
+ useImperativeHandle(
199
+ ref,
200
+ () => ({
201
+ suggestMove,
202
+ aiMove,
203
+ newGame,
204
+ undo,
205
+ move,
206
+ continuePlaying
207
+ }),
208
+ [suggestMove, aiMove, newGame, undo, move, continuePlaying]
209
+ );
210
+
211
+ return (
212
+ <div className={['game-2048', className].filter(Boolean).join(' ')}>
213
+ {title ? (
214
+ <header className="game-2048-header">
215
+ <h1 className="game-2048-title">{title}</h1>
216
+ <div className="game-2048-scores">
217
+ <div className="game-2048-score-box">
218
+ <span className="label">Score</span>
219
+ <span className="value">{state.score}</span>
220
+ {scoreDelta ? (
221
+ <span key={scoreDelta.key} className="delta">
222
+ +{scoreDelta.amount}
223
+ </span>
224
+ ) : null}
225
+ </div>
226
+ <div className="game-2048-score-box">
227
+ <span className="label">Best</span>
228
+ <span className="value">{state.bestScore}</span>
229
+ </div>
230
+ </div>
231
+ </header>
232
+ ) : null}
233
+
234
+ <div className="game-2048-toolbar">
235
+ <p className="game-2048-desc">
236
+ 合并相同数字,冲过 <b>2048</b>。方向键 / WASD / 触屏滑动。
237
+ </p>
238
+ <div className="game-2048-actions">
239
+ {enableAI ? (
240
+ <button
241
+ className="game-2048-button"
242
+ type="button"
243
+ disabled={isLost}
244
+ onClick={() => setAiHint(suggestMove())}
245
+ >
246
+ 提示
247
+ </button>
248
+ ) : null}
249
+ {enableAI ? (
250
+ <button
251
+ className="game-2048-button"
252
+ type="button"
253
+ disabled={isLost}
254
+ onClick={() => {
255
+ const before = state.score;
256
+ const dir = aiMove();
257
+ if (dir) {
258
+ onMove?.(dir, state.score - before);
259
+ setAiHint(null);
260
+ }
261
+ }}
262
+ >
263
+ AI 帮走
264
+ </button>
265
+ ) : null}
266
+ <button
267
+ className="game-2048-button"
268
+ type="button"
269
+ disabled={undoDisabled}
270
+ onClick={() => undo()}
271
+ >
272
+ 撤销
273
+ </button>
274
+ <button
275
+ className="game-2048-button is-primary"
276
+ type="button"
277
+ onClick={() => newGame()}
278
+ >
279
+ 新游戏
280
+ </button>
281
+ </div>
282
+ </div>
283
+
284
+ <div
285
+ ref={boardRef}
286
+ className="game-2048-board"
287
+ style={boardVars}
288
+ tabIndex={0}
289
+ role="application"
290
+ aria-label={`2048 游戏板 ${state.status}`}
291
+ onTouchStart={onTouchStart}
292
+ onTouchEnd={onTouchEnd}
293
+ >
294
+ <div className="game-2048-grid" style={gridStyle}>
295
+ {Array.from({ length: state.size * state.size }).map((_, i) => (
296
+ <div key={i} className="game-2048-cell" />
297
+ ))}
298
+ </div>
299
+ <div className="game-2048-tile-layer">
300
+ {state.tiles.map((t) => {
301
+ const cls = [
302
+ 'game-2048-tile',
303
+ t.isNew ? 'is-new' : '',
304
+ t.merged ? 'is-merged' : '',
305
+ t.value > 2048 ? 'is-super' : ''
306
+ ]
307
+ .filter(Boolean)
308
+ .join(' ');
309
+ return (
310
+ <div
311
+ key={t.id}
312
+ className={cls}
313
+ style={cssVars({ '--row': t.row, '--col': t.col })}
314
+ data-value={t.value}
315
+ {...(t.value > 2048 ? { 'data-super': '1' } : {})}
316
+ >
317
+ {t.value}
318
+ </div>
319
+ );
320
+ })}
321
+ </div>
322
+
323
+ {enableAI && aiHint ? (
324
+ <div
325
+ className="game-2048-ai-indicator"
326
+ style={cssVars({ position: 'absolute', top: '8px', left: '8px', zIndex: 3 })}
327
+ >
328
+ 推荐:{aiHint}
329
+ </div>
330
+ ) : null}
331
+
332
+ <div
333
+ className={[
334
+ 'game-2048-overlay',
335
+ overlayVisible ? 'is-visible' : '',
336
+ isWon ? 'is-won' : '',
337
+ isLost ? 'is-lost' : ''
338
+ ]
339
+ .filter(Boolean)
340
+ .join(' ')}
341
+ >
342
+ {isWon ? <h2>你赢了!</h2> : null}
343
+ {isLost ? <h2>游戏结束</h2> : null}
344
+ <div className="row">
345
+ {isWon ? (
346
+ <button
347
+ className="game-2048-button"
348
+ type="button"
349
+ onClick={() => continuePlaying()}
350
+ >
351
+ 继续游戏
352
+ </button>
353
+ ) : null}
354
+ {isLost && !undoDisabled ? (
355
+ <button
356
+ className="game-2048-button"
357
+ type="button"
358
+ onClick={() => undo()}
359
+ >
360
+ 悔一步
361
+ </button>
362
+ ) : null}
363
+ {overlayVisible ? (
364
+ <button
365
+ className="game-2048-button is-primary"
366
+ type="button"
367
+ onClick={() => newGame()}
368
+ >
369
+ 再来一局
370
+ </button>
371
+ ) : null}
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ export const Game2048 = forwardRef(Game2048Inner);
380
+ Game2048.displayName = 'Game2048';
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { Game2048, type Game2048Props, type Game2048Handle } from './Game2048';
2
+ export { useGame, type UseGameOptions, type UseGameReturn } from './useGame';
3
+
4
+ export type {
5
+ BoardSize,
6
+ Direction,
7
+ GameState,
8
+ GameStatus,
9
+ SaveStorage,
10
+ Tile
11
+ } from '@my-games/2048-core';
package/src/useGame.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ bestMove as aiBestMove,
4
+ Game2048,
5
+ type Direction,
6
+ type GameOptions,
7
+ type GameState
8
+ } from '@my-games/2048-core';
9
+
10
+ /**
11
+ * `useGame` —— React Hook 版引擎桥接。
12
+ *
13
+ * - 引擎实例在 `useMemo` 里按 `size`/`saveKey` 等关键 prop 缓存,避免每次 render
14
+ * 重新构造;这些 prop 变化 = 重开一局,视为预期语义。
15
+ * - `useState` 订阅引擎快照;`useEffect` 里挂 unsubscribe 防泄漏。
16
+ * - 用 `useRef` 保留最新 game 实例给 imperative handler(move / undo)用,
17
+ * 防止闭包过期。
18
+ */
19
+ export interface UseGameOptions extends GameOptions {
20
+ aiDepth?: number;
21
+ }
22
+
23
+ export interface UseGameReturn {
24
+ state: GameState;
25
+ game: Game2048;
26
+ move: (dir: Direction) => boolean;
27
+ newGame: () => void;
28
+ undo: () => boolean;
29
+ continuePlaying: () => void;
30
+ suggestMove: () => Direction | null;
31
+ aiMove: () => Direction | null;
32
+ }
33
+
34
+ export function useGame(options: UseGameOptions = {}): UseGameReturn {
35
+ const { aiDepth = 3, size, storage, saveKey, bestKey, freshStart, target, undoLimit, rng } = options;
36
+
37
+ // useMemo 依赖里不放 rng / storage(函数/对象引用不稳定;重开一次即可)
38
+ const game = useMemo(
39
+ () => new Game2048({ size, storage, saveKey, bestKey, freshStart, target, undoLimit, rng }),
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ [size, saveKey, bestKey, target, undoLimit]
42
+ );
43
+
44
+ const gameRef = useRef(game);
45
+ gameRef.current = game;
46
+
47
+ const [state, setState] = useState<GameState>(() => game.getState());
48
+
49
+ useEffect(() => {
50
+ // 引擎替换时重新订阅;先把 state 拉齐到最新,防止首帧错位
51
+ setState(game.getState());
52
+ const unsub = game.subscribe((s) => setState(s));
53
+ return unsub;
54
+ }, [game]);
55
+
56
+ return {
57
+ state,
58
+ game,
59
+ move: (dir) => gameRef.current.move(dir),
60
+ newGame: () => gameRef.current.newGame(),
61
+ undo: () => gameRef.current.undo(),
62
+ continuePlaying: () => gameRef.current.continuePlaying(),
63
+ suggestMove: () => aiBestMove(gameRef.current.getGridValues(), { depth: aiDepth }),
64
+ aiMove: () => {
65
+ const dir = aiBestMove(gameRef.current.getGridValues(), { depth: aiDepth });
66
+ if (dir) gameRef.current.move(dir);
67
+ return dir;
68
+ }
69
+ };
70
+ }