@my-games/2048-react 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @my-games/2048-react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 2048 游戏的 React 18 组件适配器(函数组件 + Hooks + forwardRef)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add @my-games/2048-react react react-dom
9
+ ```
10
+
11
+ ## 用法
12
+
13
+ ```tsx
14
+ import { Game2048 } from '@my-games/2048-react';
15
+
16
+ export function Demo() {
17
+ return (
18
+ <Game2048
19
+ size={4}
20
+ saveKey="my-app:2048:save"
21
+ bestKey="my-app:2048:best"
22
+ enableAI={false}
23
+ onMove={(dir, gained) => console.log(dir, gained)}
24
+ onWon={() => console.log('won')}
25
+ />
26
+ );
27
+ }
28
+ ```
29
+
30
+ 组件样式已经内嵌(`import '@my-games/2048-core/styles.css'`),不用额外引 CSS。
31
+
32
+ ## Props
33
+
34
+ | Prop | 类型 | 默认 | 说明 |
35
+ | --- | --- | --- | --- |
36
+ | `size` | `3 \| 4 \| 5 \| 6` | `4` | 棋盘边长 |
37
+ | `storage` | `SaveStorage \| null` | `localStorage` | 传 `null` 关闭持久化(SSR) |
38
+ | `saveKey` | `string` | `game-2048:save` | 存档键,多实例分开 |
39
+ | `bestKey` | `string` | `game-2048:best` | 最高分独立键 |
40
+ | `freshStart` | `boolean` | `false` | 忽略已有存档开新局 |
41
+ | `autofocus` | `boolean` | `true` | mount 后自动 focus 让键盘立刻可用 |
42
+ | `enableAI` | `boolean` | `false` | 是否渲染 AI 按钮(API 一直可用) |
43
+ | `aiDepth` | `number` | `3` | expectimax 搜索深度 |
44
+ | `title` | `string` | `'2048'` | 传空串隐藏 header |
45
+ | `onMove` | `(dir, gained) => void` | — | 一次成功的滑动后触发 |
46
+ | `onWon` | `() => void` | — | 首次凑出目标值 |
47
+ | `onLost` | `() => void` | — | 无合法方向 |
48
+ | `className` | `string` | — | 追加到最外层 `<div className="game-2048">` |
49
+
50
+ ## Ref 命令式方法
51
+
52
+ ```tsx
53
+ import { useRef } from 'react';
54
+ import { Game2048, type Game2048Handle } from '@my-games/2048-react';
55
+
56
+ const g = useRef<Game2048Handle>(null);
57
+ // g.current?.move('left');
58
+ // g.current?.suggestMove();
59
+ // g.current?.aiMove();
60
+ ```
61
+
62
+ ## Hook(想自己画 UI)
63
+
64
+ ```tsx
65
+ import { useGame } from '@my-games/2048-react';
66
+
67
+ function MyBoard() {
68
+ const { state, move, suggestMove } = useGame({ size: 4 });
69
+ return <div>{state.score}</div>;
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ 完整玩法、引擎 API、设计取舍见仓库根 README:
76
+ <https://github.com/my-gam/2048#readme>
77
+
78
+ ## License
79
+
80
+ MIT.
@@ -0,0 +1,35 @@
1
+ import '@my-games/2048-core/styles.css';
2
+ import type { Direction, SaveStorage } from '@my-games/2048-core';
3
+ /**
4
+ * Game2048 —— React 18 组件。与 Vue 版行为对齐:
5
+ * - 键盘:window keydown + 严格 focus 判定(多实例并存只活动那个响应);
6
+ * 方向键 / WASD。
7
+ * - 触摸:单指 swipe 主导轴 ≥24px;touchstart 时把焦点抢过来。
8
+ * - 动画:新块 / 合并 / 位移全走 core 的 styles.css。
9
+ * - AI:`enableAI` prop 控制按钮渲染;通过 ref 一直可以命令式访问。
10
+ */
11
+ export interface Game2048Props {
12
+ size?: 3 | 4 | 5 | 6;
13
+ storage?: SaveStorage | null;
14
+ saveKey?: string;
15
+ bestKey?: string;
16
+ freshStart?: boolean;
17
+ autofocus?: boolean;
18
+ enableAI?: boolean;
19
+ aiDepth?: number;
20
+ title?: string;
21
+ onMove?: (dir: Direction, gained: number) => void;
22
+ onWon?: () => void;
23
+ onLost?: () => void;
24
+ className?: string;
25
+ }
26
+ export interface Game2048Handle {
27
+ suggestMove(): Direction | null;
28
+ aiMove(): Direction | null;
29
+ newGame(): void;
30
+ undo(): boolean;
31
+ move(dir: Direction): boolean;
32
+ continuePlaying(): void;
33
+ }
34
+ export declare const Game2048: import("react").ForwardRefExoticComponent<Game2048Props & import("react").RefAttributes<Game2048Handle>>;
35
+ //# sourceMappingURL=Game2048.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Game2048.d.ts","sourceRoot":"","sources":["../src/Game2048.tsx"],"names":[],"mappings":"AAYA,OAAO,gCAAgC,CAAC;AACxC,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGlE;;;;;;;GAOG;AAEH,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACrB,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,IAAI,SAAS,GAAG,IAAI,CAAC;IAChC,MAAM,IAAI,SAAS,GAAG,IAAI,CAAC;IAC3B,OAAO,IAAI,IAAI,CAAC;IAChB,IAAI,IAAI,OAAO,CAAC;IAChB,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC;IAC9B,eAAe,IAAI,IAAI,CAAC;CACzB;AA0UD,eAAO,MAAM,QAAQ,0GAA4B,CAAC"}
@@ -0,0 +1,164 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
3
+ import '@my-games/2048-core/styles.css';
4
+ import { useGame } from './useGame';
5
+ const KEY_TO_DIR = {
6
+ ArrowUp: 'up',
7
+ ArrowDown: 'down',
8
+ ArrowLeft: 'left',
9
+ ArrowRight: 'right',
10
+ w: 'up',
11
+ a: 'left',
12
+ s: 'down',
13
+ d: 'right',
14
+ W: 'up',
15
+ A: 'left',
16
+ S: 'down',
17
+ D: 'right'
18
+ };
19
+ function cssVars(vars) {
20
+ return vars;
21
+ }
22
+ function Game2048Inner(props, ref) {
23
+ const { size = 4, storage, saveKey, bestKey, freshStart = false, autofocus = true, enableAI = false, aiDepth = 3, title = '2048', onMove, onWon, onLost, className } = props;
24
+ const { state, move, newGame, undo, continuePlaying, suggestMove, aiMove } = useGame({
25
+ size,
26
+ storage,
27
+ saveKey,
28
+ bestKey,
29
+ freshStart,
30
+ aiDepth
31
+ });
32
+ const boardRef = useRef(null);
33
+ const touchStartPoint = useRef(null);
34
+ const lastScoreRef = useRef(state.score);
35
+ const lastStatusRef = useRef(state.status);
36
+ const [scoreDelta, setScoreDelta] = useState(null);
37
+ const [aiHint, setAiHint] = useState(null);
38
+ const deltaTimer = useRef(undefined);
39
+ // --- 键盘 ---
40
+ const performMove = useCallback((dir) => {
41
+ const before = lastScoreRef.current;
42
+ const ok = move(dir);
43
+ if (ok) {
44
+ onMove?.(dir, state.score - before);
45
+ if (enableAI)
46
+ setAiHint(null);
47
+ }
48
+ }, [move, onMove, state.score, enableAI]);
49
+ useEffect(() => {
50
+ const handler = (ev) => {
51
+ const dir = KEY_TO_DIR[ev.key];
52
+ if (!dir)
53
+ return;
54
+ // 只有本实例 board 有 DOM 焦点时才响应
55
+ if (document.activeElement !== boardRef.current)
56
+ return;
57
+ ev.preventDefault();
58
+ performMove(dir);
59
+ };
60
+ window.addEventListener('keydown', handler, { passive: false });
61
+ return () => window.removeEventListener('keydown', handler);
62
+ }, [performMove]);
63
+ useEffect(() => {
64
+ if (autofocus)
65
+ boardRef.current?.focus({ preventScroll: true });
66
+ }, [autofocus]);
67
+ // --- 触摸 ---
68
+ const onTouchStart = (ev) => {
69
+ if (ev.touches.length !== 1)
70
+ return;
71
+ const t = ev.touches[0];
72
+ if (!t)
73
+ return;
74
+ touchStartPoint.current = { x: t.clientX, y: t.clientY };
75
+ boardRef.current?.focus({ preventScroll: true });
76
+ };
77
+ const onTouchEnd = (ev) => {
78
+ const start = touchStartPoint.current;
79
+ touchStartPoint.current = null;
80
+ if (!start)
81
+ return;
82
+ const t = ev.changedTouches[0];
83
+ if (!t)
84
+ return;
85
+ const dx = t.clientX - start.x;
86
+ const dy = t.clientY - start.y;
87
+ const adx = Math.abs(dx);
88
+ const ady = Math.abs(dy);
89
+ if (Math.max(adx, ady) < 24)
90
+ return;
91
+ const dir = adx > ady ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';
92
+ performMove(dir);
93
+ };
94
+ // --- 分数 delta 与状态副作用 ---
95
+ useEffect(() => {
96
+ const gained = state.score - lastScoreRef.current;
97
+ if (gained > 0) {
98
+ setScoreDelta({ amount: gained, key: Date.now() + Math.random() });
99
+ if (deltaTimer.current)
100
+ window.clearTimeout(deltaTimer.current);
101
+ deltaTimer.current = window.setTimeout(() => setScoreDelta(null), 700);
102
+ }
103
+ lastScoreRef.current = state.score;
104
+ }, [state.score]);
105
+ useEffect(() => {
106
+ if (state.status === lastStatusRef.current)
107
+ return;
108
+ if (state.status === 'won')
109
+ onWon?.();
110
+ else if (state.status === 'lost')
111
+ onLost?.();
112
+ lastStatusRef.current = state.status;
113
+ }, [state.status, onWon, onLost]);
114
+ useEffect(() => () => {
115
+ if (deltaTimer.current)
116
+ window.clearTimeout(deltaTimer.current);
117
+ }, []);
118
+ // --- 渲染派生 ---
119
+ const gridStyle = useMemo(() => cssVars({
120
+ gridTemplateColumns: `repeat(${state.size}, 1fr)`,
121
+ gridTemplateRows: `repeat(${state.size}, 1fr)`
122
+ }), [state.size]);
123
+ const boardVars = useMemo(() => cssVars({ '--cols': state.size }), [state.size]);
124
+ const undoDisabled = state.historySize === 0;
125
+ const overlayVisible = state.status !== 'playing';
126
+ const isWon = state.status === 'won';
127
+ const isLost = state.status === 'lost';
128
+ useImperativeHandle(ref, () => ({
129
+ suggestMove,
130
+ aiMove,
131
+ newGame,
132
+ undo,
133
+ move,
134
+ continuePlaying
135
+ }), [suggestMove, aiMove, newGame, undo, move, continuePlaying]);
136
+ return (_jsxs("div", { className: ['game-2048', className].filter(Boolean).join(' '), children: [title ? (_jsxs("header", { className: "game-2048-header", children: [_jsx("h1", { className: "game-2048-title", children: title }), _jsxs("div", { className: "game-2048-scores", children: [_jsxs("div", { className: "game-2048-score-box", children: [_jsx("span", { className: "label", children: "Score" }), _jsx("span", { className: "value", children: state.score }), scoreDelta ? (_jsxs("span", { className: "delta", children: ["+", scoreDelta.amount] }, scoreDelta.key)) : null] }), _jsxs("div", { className: "game-2048-score-box", children: [_jsx("span", { className: "label", children: "Best" }), _jsx("span", { className: "value", children: state.bestScore })] })] })] })) : null, _jsxs("div", { className: "game-2048-toolbar", children: [_jsxs("p", { className: "game-2048-desc", children: ["\u5408\u5E76\u76F8\u540C\u6570\u5B57\uFF0C\u51B2\u8FC7 ", _jsx("b", { children: "2048" }), "\u3002\u65B9\u5411\u952E / WASD / \u89E6\u5C4F\u6ED1\u52A8\u3002"] }), _jsxs("div", { className: "game-2048-actions", children: [enableAI ? (_jsx("button", { className: "game-2048-button", type: "button", disabled: isLost, onClick: () => setAiHint(suggestMove()), children: "\u63D0\u793A" })) : null, enableAI ? (_jsx("button", { className: "game-2048-button", type: "button", disabled: isLost, onClick: () => {
137
+ const before = state.score;
138
+ const dir = aiMove();
139
+ if (dir) {
140
+ onMove?.(dir, state.score - before);
141
+ setAiHint(null);
142
+ }
143
+ }, children: "AI \u5E2E\u8D70" })) : null, _jsx("button", { className: "game-2048-button", type: "button", disabled: undoDisabled, onClick: () => undo(), children: "\u64A4\u9500" }), _jsx("button", { className: "game-2048-button is-primary", type: "button", onClick: () => newGame(), children: "\u65B0\u6E38\u620F" })] })] }), _jsxs("div", { ref: boardRef, className: "game-2048-board", style: boardVars, tabIndex: 0, role: "application", "aria-label": `2048 游戏板 ${state.status}`, onTouchStart: onTouchStart, onTouchEnd: onTouchEnd, children: [_jsx("div", { className: "game-2048-grid", style: gridStyle, children: Array.from({ length: state.size * state.size }).map((_, i) => (_jsx("div", { className: "game-2048-cell" }, i))) }), _jsx("div", { className: "game-2048-tile-layer", children: state.tiles.map((t) => {
144
+ const cls = [
145
+ 'game-2048-tile',
146
+ t.isNew ? 'is-new' : '',
147
+ t.merged ? 'is-merged' : '',
148
+ t.value > 2048 ? 'is-super' : ''
149
+ ]
150
+ .filter(Boolean)
151
+ .join(' ');
152
+ return (_jsx("div", { className: cls, style: cssVars({ '--row': t.row, '--col': t.col }), "data-value": t.value, ...(t.value > 2048 ? { 'data-super': '1' } : {}), children: t.value }, t.id));
153
+ }) }), enableAI && aiHint ? (_jsxs("div", { className: "game-2048-ai-indicator", style: cssVars({ position: 'absolute', top: '8px', left: '8px', zIndex: 3 }), children: ["\u63A8\u8350\uFF1A", aiHint] })) : null, _jsxs("div", { className: [
154
+ 'game-2048-overlay',
155
+ overlayVisible ? 'is-visible' : '',
156
+ isWon ? 'is-won' : '',
157
+ isLost ? 'is-lost' : ''
158
+ ]
159
+ .filter(Boolean)
160
+ .join(' '), children: [isWon ? _jsx("h2", { children: "\u4F60\u8D62\u4E86\uFF01" }) : null, isLost ? _jsx("h2", { children: "\u6E38\u620F\u7ED3\u675F" }) : null, _jsxs("div", { className: "row", children: [isWon ? (_jsx("button", { className: "game-2048-button", type: "button", onClick: () => continuePlaying(), children: "\u7EE7\u7EED\u6E38\u620F" })) : null, isLost && !undoDisabled ? (_jsx("button", { className: "game-2048-button", type: "button", onClick: () => undo(), children: "\u6094\u4E00\u6B65" })) : null, overlayVisible ? (_jsx("button", { className: "game-2048-button is-primary", type: "button", onClick: () => newGame(), children: "\u518D\u6765\u4E00\u5C40" })) : null] })] })] })] }));
161
+ }
162
+ export const Game2048 = forwardRef(Game2048Inner);
163
+ Game2048.displayName = 'Game2048';
164
+ //# sourceMappingURL=Game2048.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Game2048.js","sourceRoot":"","sources":["../src/Game2048.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export { Game2048, type Game2048Props, type Game2048Handle } from './Game2048';
2
+ export { useGame, type UseGameOptions, type UseGameReturn } from './useGame';
3
+ export type { BoardSize, Direction, GameState, GameStatus, SaveStorage, Tile } from '@my-games/2048-core';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE7E,YAAY,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,UAAU,EACV,WAAW,EACX,IAAI,EACL,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Game2048 } from './Game2048';
2
+ export { useGame } from './useGame';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAA2C,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,OAAO,EAA2C,MAAM,WAAW,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { Game2048, type Direction, type GameOptions, type GameState } from '@my-games/2048-core';
2
+ /**
3
+ * `useGame` —— React Hook 版引擎桥接。
4
+ *
5
+ * - 引擎实例在 `useMemo` 里按 `size`/`saveKey` 等关键 prop 缓存,避免每次 render
6
+ * 重新构造;这些 prop 变化 = 重开一局,视为预期语义。
7
+ * - `useState` 订阅引擎快照;`useEffect` 里挂 unsubscribe 防泄漏。
8
+ * - 用 `useRef` 保留最新 game 实例给 imperative handler(move / undo)用,
9
+ * 防止闭包过期。
10
+ */
11
+ export interface UseGameOptions extends GameOptions {
12
+ aiDepth?: number;
13
+ }
14
+ export interface UseGameReturn {
15
+ state: GameState;
16
+ game: Game2048;
17
+ move: (dir: Direction) => boolean;
18
+ newGame: () => void;
19
+ undo: () => boolean;
20
+ continuePlaying: () => void;
21
+ suggestMove: () => Direction | null;
22
+ aiMove: () => Direction | null;
23
+ }
24
+ export declare function useGame(options?: UseGameOptions): UseGameReturn;
25
+ //# sourceMappingURL=useGame.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGame.d.ts","sourceRoot":"","sources":["../src/useGame.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,WAAW,EAChB,KAAK,SAAS,EACf,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,SAAS,CAAC;IACjB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,OAAO,CAAC;IACpB,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B,WAAW,EAAE,MAAM,SAAS,GAAG,IAAI,CAAC;IACpC,MAAM,EAAE,MAAM,SAAS,GAAG,IAAI,CAAC;CAChC;AAED,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,aAAa,CAoCnE"}
@@ -0,0 +1,34 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { bestMove as aiBestMove, Game2048 } from '@my-games/2048-core';
3
+ export function useGame(options = {}) {
4
+ const { aiDepth = 3, size, storage, saveKey, bestKey, freshStart, target, undoLimit, rng } = options;
5
+ // useMemo 依赖里不放 rng / storage(函数/对象引用不稳定;重开一次即可)
6
+ const game = useMemo(() => new Game2048({ size, storage, saveKey, bestKey, freshStart, target, undoLimit, rng }),
7
+ // eslint-disable-next-line react-hooks/exhaustive-deps
8
+ [size, saveKey, bestKey, target, undoLimit]);
9
+ const gameRef = useRef(game);
10
+ gameRef.current = game;
11
+ const [state, setState] = useState(() => game.getState());
12
+ useEffect(() => {
13
+ // 引擎替换时重新订阅;先把 state 拉齐到最新,防止首帧错位
14
+ setState(game.getState());
15
+ const unsub = game.subscribe((s) => setState(s));
16
+ return unsub;
17
+ }, [game]);
18
+ return {
19
+ state,
20
+ game,
21
+ move: (dir) => gameRef.current.move(dir),
22
+ newGame: () => gameRef.current.newGame(),
23
+ undo: () => gameRef.current.undo(),
24
+ continuePlaying: () => gameRef.current.continuePlaying(),
25
+ suggestMove: () => aiBestMove(gameRef.current.getGridValues(), { depth: aiDepth }),
26
+ aiMove: () => {
27
+ const dir = aiBestMove(gameRef.current.getGridValues(), { depth: aiDepth });
28
+ if (dir)
29
+ gameRef.current.move(dir);
30
+ return dir;
31
+ }
32
+ };
33
+ }
34
+ //# sourceMappingURL=useGame.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useGame.js","sourceRoot":"","sources":["../src/useGame.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC7D,OAAO,EACL,QAAQ,IAAI,UAAU,EACtB,QAAQ,EAIT,MAAM,qBAAqB,CAAC;AA0B7B,MAAM,UAAU,OAAO,CAAC,UAA0B,EAAE;IAClD,MAAM,EAAE,OAAO,GAAG,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC;IAErG,iDAAiD;IACjD,MAAM,IAAI,GAAG,OAAO,CAClB,GAAG,EAAE,CAAC,IAAI,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;IAC3F,uDAAuD;IACvD,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAC5C,CAAC;IAEF,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAC7B,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAEvB,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAY,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAErE,SAAS,CAAC,GAAG,EAAE;QACb,kCAAkC;QAClC,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QACjD,OAAO,KAAK,CAAC;IACf,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IAEX,OAAO;QACL,KAAK;QACL,IAAI;QACJ,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC;QACxC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE;QACxC,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE;QAClC,eAAe,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,eAAe,EAAE;QACxD,WAAW,EAAE,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;QAClF,MAAM,EAAE,GAAG,EAAE;YACX,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;YAC5E,IAAI,GAAG;gBAAE,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACnC,OAAO,GAAG,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@my-games/2048-react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "2048 游戏的 React 18 组件适配器(函数组件 + Hooks)。",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./style.css": "@my-games/2048-core/styles.css"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "peerDependencies": {
23
+ "react": "^18.2.0",
24
+ "react-dom": "^18.2.0"
25
+ },
26
+ "dependencies": {
27
+ "@my-games/2048-core": "0.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^18.3.11",
31
+ "@types/react-dom": "^18.3.1",
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "keywords": [
37
+ "2048",
38
+ "react",
39
+ "react18",
40
+ "game",
41
+ "puzzle",
42
+ "component",
43
+ "hooks"
44
+ ],
45
+ "license": "MIT",
46
+ "author": {
47
+ "name": "xyj2156"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/my-gam/2048.git",
52
+ "directory": "packages/react"
53
+ },
54
+ "homepage": "https://github.com/my-gam/2048/tree/main/packages/react#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/my-gam/2048/issues"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.build.json",
63
+ "typecheck": "tsc --noEmit -p tsconfig.json"
64
+ }
6
65
  }
@@ -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
+ }