@my-games/2048-vue 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,79 @@
1
- # Temporary Holding Version
1
+ # @my-games/2048-vue
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 游戏的 Vue 3 组件适配器(Composition API + SFC)。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pnpm add @my-games/2048-vue vue
9
+ ```
10
+
11
+ ## 用法
12
+
13
+ ```vue
14
+ <script setup lang="ts">
15
+ import { Game2048 } from '@my-games/2048-vue';
16
+
17
+ function onMove(dir, gained) {
18
+ console.log(dir, gained);
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <Game2048
24
+ :size="4"
25
+ save-key="my-app:2048:save"
26
+ best-key="my-app:2048:best"
27
+ :enable-ai="false"
28
+ @move="onMove"
29
+ />
30
+ </template>
31
+ ```
32
+
33
+ 组件样式已经内嵌(`import '@my-games/2048-core/styles.css'`),不用额外引 CSS。
34
+
35
+ ## Props
36
+
37
+ | Prop | 类型 | 默认 | 说明 |
38
+ | --- | --- | --- | --- |
39
+ | `size` | `3 \| 4 \| 5 \| 6` | `4` | 棋盘边长 |
40
+ | `storage` | `SaveStorage \| null` | `localStorage` | 传 `null` 关闭持久化(SSR) |
41
+ | `saveKey` | `string` | `game-2048:save` | 存档键,多实例分开 |
42
+ | `bestKey` | `string` | `game-2048:best` | 最高分独立键 |
43
+ | `freshStart` | `boolean` | `false` | 忽略已有存档开新局 |
44
+ | `autofocus` | `boolean` | `true` | mount 后自动 focus 让键盘立刻可用 |
45
+ | `enableAI` | `boolean` | `false` | 是否渲染 AI 按钮(API 一直可用) |
46
+ | `aiDepth` | `number` | `3` | expectimax 搜索深度 |
47
+ | `title` | `string` | `'2048'` | 传空串隐藏 header |
48
+
49
+ ## 事件
50
+
51
+ `move(dir, gained)` / `won()` / `lost()`
52
+
53
+ ## Ref 命令式方法
54
+
55
+ `<script setup>` 里 `const g = ref<Game2048Instance>();` 后:
56
+
57
+ - `g.value?.move(dir)`
58
+ - `g.value?.newGame()` / `undo()` / `continuePlaying()`
59
+ - `g.value?.suggestMove(): Direction | null` — 只查询建议
60
+ - `g.value?.aiMove(): Direction | null` — 让 AI 走一步
61
+
62
+ ## Composable(想自己画 UI)
63
+
64
+ ```ts
65
+ import { useGame } from '@my-games/2048-vue';
66
+
67
+ const { state, move, suggestMove } = useGame({ size: 4 });
68
+ ```
69
+
70
+ `state` 是 `ShallowRef<GameState>`,模板里 auto-unwrap 直接用。
71
+
72
+ ---
73
+
74
+ 完整玩法、引擎 API、设计取舍见仓库根 README:
75
+ <https://github.com/my-gam/2048#readme>
76
+
77
+ ## License
78
+
79
+ MIT.
@@ -0,0 +1,131 @@
1
+ import { BoardSize } from '@my-games/2048-core';
2
+ import { ComponentOptionsMixin } from 'vue';
3
+ import { ComponentProvideOptions } from 'vue';
4
+ import { DefineComponent } from 'vue';
5
+ import { Direction } from '@my-games/2048-core';
6
+ import { Game2048 as Game2048_2 } from '@my-games/2048-core';
7
+ import { GameOptions } from '@my-games/2048-core';
8
+ import { GameState } from '@my-games/2048-core';
9
+ import { GameStatus } from '@my-games/2048-core';
10
+ import { PublicProps } from 'vue';
11
+ import { SaveStorage } from '@my-games/2048-core';
12
+ import { ShallowRef } from 'vue';
13
+ import { Tile } from '@my-games/2048-core';
14
+
15
+ export { BoardSize }
16
+
17
+ export { Direction }
18
+
19
+ export declare const Game2048: DefineComponent<Props, {
20
+ suggestMove: () => Direction | null;
21
+ aiMove: () => Direction | null;
22
+ newGame: () => void;
23
+ undo: () => boolean;
24
+ move: (dir: Direction) => boolean;
25
+ continuePlaying: () => void;
26
+ }, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, {} & {
27
+ move: (dir: Direction, gained: number) => any;
28
+ won: () => any;
29
+ lost: () => any;
30
+ }, string, PublicProps, Readonly<Props> & Readonly<{
31
+ onMove?: ((dir: Direction, gained: number) => any) | undefined;
32
+ onWon?: (() => any) | undefined;
33
+ onLost?: (() => any) | undefined;
34
+ }>, {
35
+ aiDepth: number;
36
+ size: 3 | 4 | 5 | 6;
37
+ storage: SaveStorage | null;
38
+ saveKey: string;
39
+ bestKey: string;
40
+ freshStart: boolean;
41
+ autofocus: boolean;
42
+ enableAI: boolean;
43
+ title: string;
44
+ }, {}, {}, {}, string, ComponentProvideOptions, false, {
45
+ boardEl: HTMLDivElement;
46
+ }, HTMLDivElement>;
47
+
48
+ /**
49
+ * 组件 `ref` 上暴露的方法(`defineExpose` 的对应类型)。
50
+ * 供父组件用 `const g = ref<Game2048Instance>()` 精确声明类型。
51
+ */
52
+ export declare interface Game2048Instance {
53
+ suggestMove(): Direction | null;
54
+ aiMove(): Direction | null;
55
+ newGame(): void;
56
+ undo(): boolean;
57
+ move(dir: Direction): boolean;
58
+ continuePlaying(): void;
59
+ }
60
+
61
+ export { GameState }
62
+
63
+ export { GameStatus }
64
+
65
+ /**
66
+ * Game2048 —— Vue 3 官方组件。
67
+ *
68
+ * 输入模型:
69
+ * - 键盘:window 监听 keydown,但只有 board 元素拥有 DOM 焦点时才处理。
70
+ * 多实例并存时用户点击 / 触摸哪个,哪个成为「活动」实例;`autofocus=true`
71
+ * (默认)让第一个实例在 mount 时自动 focus,符合单实例场景直觉。
72
+ * - 触摸:board 上的单指 swipe,主导轴决定方向;阈值 24px。
73
+ *
74
+ * 动画:
75
+ * - 新块:scale-in
76
+ * - 合并:pop 弹跳
77
+ * - 滑动:transform transition
78
+ *
79
+ * AI:`enableAI=true` 打开「提示」/「AI 帮走」按钮;未打开时依然可以通过
80
+ * template ref 拿到 `suggestMove` / `aiMove` 方法(`defineExpose`)。
81
+ */
82
+ declare interface Props {
83
+ size?: 3 | 4 | 5 | 6;
84
+ storage?: SaveStorage | null;
85
+ saveKey?: string;
86
+ bestKey?: string;
87
+ freshStart?: boolean;
88
+ autofocus?: boolean;
89
+ enableAI?: boolean;
90
+ aiDepth?: number;
91
+ title?: string;
92
+ }
93
+
94
+ export { SaveStorage }
95
+
96
+ export { Tile }
97
+
98
+ export declare function useGame(options?: UseGameOptions): UseGameReturn;
99
+
100
+ /**
101
+ * `useGame` —— Vue 侧连接 Game2048 引擎的 composable。
102
+ *
103
+ * 用法:
104
+ * ```ts
105
+ * const { state, move, newGame, undo } = useGame({ size: 4 });
106
+ * ```
107
+ *
108
+ * 生命周期:内部订阅引擎;`onBeforeUnmount` 时自动退订,防止内存泄漏。
109
+ * state 用 `shallowRef` 存 —— 引擎每次都推新对象,深度 reactive 无收益。
110
+ */
111
+ export declare interface UseGameOptions extends GameOptions {
112
+ /** AI 搜索深度;仅 `aiMove` 用到。 */
113
+ aiDepth?: number;
114
+ }
115
+
116
+ export declare interface UseGameReturn {
117
+ /** 引擎的完整状态快照;用 ShallowRef 声明让 <script setup> 的模板能自动 unwrap。 */
118
+ state: ShallowRef<GameState>;
119
+ raw: Game2048_2;
120
+ move: (dir: Direction) => boolean;
121
+ newGame: () => void;
122
+ undo: () => boolean;
123
+ continuePlaying: () => void;
124
+ canUndo: () => boolean;
125
+ /** 让 AI 走一步(内部仍走 game.move 触发订阅)。 */
126
+ aiMove: () => Direction | null;
127
+ /** 只查询、不走子。 */
128
+ suggestMove: () => Direction | null;
129
+ }
130
+
131
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,251 @@
1
+ import { shallowRef as ae, onBeforeUnmount as O, defineComponent as le, ref as h, onMounted as ie, watch as N, computed as m, openBlock as l, createElementBlock as i, createElementVNode as a, toDisplayString as f, unref as u, createCommentVNode as c, createTextVNode as R, withModifiers as ue, normalizeStyle as U, Fragment as W, renderList as Y, mergeProps as re, normalizeClass as ce } from "vue";
2
+ import { Game2048 as de, bestMove as F } from "@my-games/2048-core";
3
+ function ve(d = {}) {
4
+ const { aiDepth: w = 3, ..._ } = d, n = new de(_), v = ae(n.getState()), s = n.subscribe((r) => {
5
+ v.value = r;
6
+ });
7
+ return O(s), {
8
+ state: v,
9
+ raw: n,
10
+ move: (r) => n.move(r),
11
+ newGame: () => n.newGame(),
12
+ undo: () => n.undo(),
13
+ continuePlaying: () => n.continuePlaying(),
14
+ canUndo: () => n.canUndo(),
15
+ suggestMove: () => F(n.getGridValues(), { depth: w }),
16
+ aiMove: () => {
17
+ const r = F(n.getGridValues(), { depth: w });
18
+ return r && n.move(r), r;
19
+ }
20
+ };
21
+ }
22
+ const me = { class: "game-2048" }, fe = {
23
+ key: 0,
24
+ class: "game-2048-header"
25
+ }, be = { class: "game-2048-title" }, ge = { class: "game-2048-scores" }, ye = { class: "game-2048-score-box" }, pe = { class: "value" }, he = { class: "game-2048-score-box" }, we = { class: "value" }, ke = { class: "game-2048-toolbar" }, _e = { class: "game-2048-actions" }, Se = ["disabled"], Ae = ["disabled"], xe = ["disabled"], De = ["aria-label"], Te = { class: "game-2048-tile-layer" }, ze = ["data-value"], Ce = {
26
+ key: 0,
27
+ class: "game-2048-ai-indicator",
28
+ style: { position: "absolute", top: "8px", left: "8px", "z-index": "3" }
29
+ }, Me = { key: 0 }, Ee = { key: 1 }, Ge = { class: "row" }, Ke = /* @__PURE__ */ le({
30
+ __name: "Game2048",
31
+ props: {
32
+ size: { default: 4 },
33
+ storage: { default: void 0 },
34
+ saveKey: { default: void 0 },
35
+ bestKey: { default: void 0 },
36
+ freshStart: { type: Boolean, default: !1 },
37
+ autofocus: { type: Boolean, default: !0 },
38
+ enableAI: { type: Boolean, default: !1 },
39
+ aiDepth: { default: 3 },
40
+ title: { default: "2048" }
41
+ },
42
+ emits: ["move", "won", "lost"],
43
+ setup(d, { expose: w, emit: _ }) {
44
+ const n = d, v = _, { state: s, move: r, newGame: S, undo: A, continuePlaying: T, suggestMove: z, aiMove: C } = ve({
45
+ size: n.size,
46
+ storage: n.storage,
47
+ saveKey: n.saveKey,
48
+ bestKey: n.bestKey,
49
+ freshStart: n.freshStart,
50
+ aiDepth: n.aiDepth
51
+ }), k = h(null), b = h(null), g = h(null), M = h(s.value.score), E = h(s.value.status);
52
+ let y;
53
+ const X = {
54
+ ArrowUp: "up",
55
+ ArrowDown: "down",
56
+ ArrowLeft: "left",
57
+ ArrowRight: "right",
58
+ w: "up",
59
+ a: "left",
60
+ s: "down",
61
+ d: "right",
62
+ W: "up",
63
+ A: "left",
64
+ S: "down",
65
+ D: "right"
66
+ };
67
+ function H() {
68
+ return typeof document > "u" ? !1 : document.activeElement === k.value;
69
+ }
70
+ function G(t) {
71
+ const e = X[t.key];
72
+ e && H() && (t.preventDefault(), I(e));
73
+ }
74
+ function I(t) {
75
+ const e = s.value.score;
76
+ r(t) && (v("move", t, s.value.score - e), n.enableAI && (g.value = null));
77
+ }
78
+ let x = null;
79
+ function j(t) {
80
+ var o;
81
+ if (t.touches.length !== 1) return;
82
+ const e = t.touches[0];
83
+ e && (x = { x: e.clientX, y: e.clientY }, (o = k.value) == null || o.focus({ preventScroll: !0 }));
84
+ }
85
+ function q(t) {
86
+ const e = x;
87
+ if (x = null, !e) return;
88
+ const o = t.changedTouches[0];
89
+ if (!o) return;
90
+ const V = o.clientX - e.x, $ = o.clientY - e.y, P = Math.abs(V), L = Math.abs($);
91
+ if (Math.max(P, L) < 24) return;
92
+ const ne = P > L ? V > 0 ? "right" : "left" : $ > 0 ? "down" : "up";
93
+ I(ne);
94
+ }
95
+ ie(() => {
96
+ var t;
97
+ n.autofocus && ((t = k.value) == null || t.focus({ preventScroll: !0 })), window.addEventListener("keydown", G, { passive: !1 });
98
+ }), O(() => {
99
+ window.removeEventListener("keydown", G), y && window.clearTimeout(y);
100
+ }), N(
101
+ () => s.value.score,
102
+ (t, e) => {
103
+ const o = t - (e ?? M.value);
104
+ o > 0 && (b.value = { amount: o, key: Date.now() + Math.random() }, y && window.clearTimeout(y), y = window.setTimeout(() => b.value = null, 700)), M.value = t;
105
+ }
106
+ ), N(
107
+ () => s.value.status,
108
+ (t) => {
109
+ t !== E.value && (t === "won" ? v("won") : t === "lost" && v("lost"), E.value = t);
110
+ }
111
+ );
112
+ const J = m(() => ({
113
+ gridTemplateColumns: `repeat(${s.value.size}, 1fr)`,
114
+ gridTemplateRows: `repeat(${s.value.size}, 1fr)`
115
+ })), Q = m(() => ({ "--cols": String(s.value.size) }));
116
+ function Z(t, e) {
117
+ return { "--row": String(t), "--col": String(e) };
118
+ }
119
+ function ee(t) {
120
+ return {
121
+ "is-new": t.isNew,
122
+ "is-merged": t.merged,
123
+ "is-super": t.value > 2048
124
+ };
125
+ }
126
+ function te(t) {
127
+ return t.value > 2048 ? { "data-super": "1" } : {};
128
+ }
129
+ const B = m(() => s.value.status !== "playing"), D = m(() => s.value.status === "won"), p = m(() => s.value.status === "lost"), K = m(() => s.value.historySize === 0);
130
+ function oe() {
131
+ g.value = z();
132
+ }
133
+ function se() {
134
+ const t = s.value.score, e = C();
135
+ e && (v("move", e, s.value.score - t), g.value = null);
136
+ }
137
+ return w({ suggestMove: z, aiMove: C, newGame: S, undo: A, move: r, continuePlaying: T }), (t, e) => (l(), i("div", me, [
138
+ d.title ? (l(), i("header", fe, [
139
+ a("h1", be, f(d.title), 1),
140
+ a("div", ge, [
141
+ a("div", ye, [
142
+ e[5] || (e[5] = a("span", { class: "label" }, "Score", -1)),
143
+ a("span", pe, f(u(s).score), 1),
144
+ b.value ? (l(), i("span", {
145
+ key: b.value.key,
146
+ class: "delta"
147
+ }, "+" + f(b.value.amount), 1)) : c("", !0)
148
+ ]),
149
+ a("div", he, [
150
+ e[6] || (e[6] = a("span", { class: "label" }, "Best", -1)),
151
+ a("span", we, f(u(s).bestScore), 1)
152
+ ])
153
+ ])
154
+ ])) : c("", !0),
155
+ a("div", ke, [
156
+ e[7] || (e[7] = a("p", { class: "game-2048-desc" }, [
157
+ R(" 合并相同数字,冲过 "),
158
+ a("b", null, "2048"),
159
+ R("。方向键 / WASD / 触屏滑动。 ")
160
+ ], -1)),
161
+ a("div", _e, [
162
+ d.enableAI ? (l(), i("button", {
163
+ key: 0,
164
+ class: "game-2048-button",
165
+ type: "button",
166
+ disabled: p.value,
167
+ onClick: oe
168
+ }, " 提示 ", 8, Se)) : c("", !0),
169
+ d.enableAI ? (l(), i("button", {
170
+ key: 1,
171
+ class: "game-2048-button",
172
+ type: "button",
173
+ disabled: p.value,
174
+ onClick: se
175
+ }, " AI 帮走 ", 8, Ae)) : c("", !0),
176
+ a("button", {
177
+ class: "game-2048-button",
178
+ type: "button",
179
+ disabled: K.value,
180
+ onClick: e[0] || (e[0] = (o) => u(A)())
181
+ }, " 撤销 ", 8, xe),
182
+ a("button", {
183
+ class: "game-2048-button is-primary",
184
+ type: "button",
185
+ onClick: e[1] || (e[1] = (o) => u(S)())
186
+ }, " 新游戏 ")
187
+ ])
188
+ ]),
189
+ a("div", {
190
+ ref_key: "boardEl",
191
+ ref: k,
192
+ class: "game-2048-board",
193
+ style: U(Q.value),
194
+ tabindex: "0",
195
+ role: "application",
196
+ "aria-label": `2048 游戏板 ${u(s).status}`,
197
+ onTouchstartPassive: j,
198
+ onTouchend: ue(q, ["prevent"])
199
+ }, [
200
+ a("div", {
201
+ class: "game-2048-grid",
202
+ style: U(J.value)
203
+ }, [
204
+ (l(!0), i(W, null, Y(u(s).size * u(s).size, (o) => (l(), i("div", {
205
+ key: o,
206
+ class: "game-2048-cell"
207
+ }))), 128))
208
+ ], 4),
209
+ a("div", Te, [
210
+ (l(!0), i(W, null, Y(u(s).tiles, (o) => (l(), i("div", re({
211
+ key: o.id,
212
+ class: ["game-2048-tile", ee(o)],
213
+ style: Z(o.row, o.col),
214
+ "data-value": o.value
215
+ }, { ref_for: !0 }, te(o)), f(o.value), 17, ze))), 128))
216
+ ]),
217
+ d.enableAI && g.value ? (l(), i("div", Ce, " 推荐:" + f(g.value), 1)) : c("", !0),
218
+ a("div", {
219
+ class: ce(["game-2048-overlay", { "is-visible": B.value, "is-won": D.value, "is-lost": p.value }])
220
+ }, [
221
+ D.value ? (l(), i("h2", Me, "你赢了!")) : p.value ? (l(), i("h2", Ee, "游戏结束")) : c("", !0),
222
+ a("div", Ge, [
223
+ D.value ? (l(), i("button", {
224
+ key: 0,
225
+ class: "game-2048-button",
226
+ type: "button",
227
+ onClick: e[2] || (e[2] = (o) => u(T)())
228
+ }, " 继续游戏 ")) : c("", !0),
229
+ p.value && !K.value ? (l(), i("button", {
230
+ key: 1,
231
+ class: "game-2048-button",
232
+ type: "button",
233
+ onClick: e[3] || (e[3] = (o) => u(A)())
234
+ }, " 悔一步 ")) : c("", !0),
235
+ B.value ? (l(), i("button", {
236
+ key: 2,
237
+ class: "game-2048-button is-primary",
238
+ type: "button",
239
+ onClick: e[4] || (e[4] = (o) => u(S)())
240
+ }, " 再来一局 ")) : c("", !0)
241
+ ])
242
+ ], 2)
243
+ ], 44, De)
244
+ ]));
245
+ }
246
+ });
247
+ export {
248
+ Ke as Game2048,
249
+ ve as useGame
250
+ };
251
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/useGame.ts","../src/Game2048.vue"],"sourcesContent":["import { onBeforeUnmount, shallowRef, type ShallowRef } from 'vue';\nimport { bestMove as aiBestMove, Game2048, type Direction, type GameOptions, type GameState } from '@my-games/2048-core';\n\n/**\n * `useGame` —— Vue 侧连接 Game2048 引擎的 composable。\n *\n * 用法:\n * ```ts\n * const { state, move, newGame, undo } = useGame({ size: 4 });\n * ```\n *\n * 生命周期:内部订阅引擎;`onBeforeUnmount` 时自动退订,防止内存泄漏。\n * state 用 `shallowRef` 存 —— 引擎每次都推新对象,深度 reactive 无收益。\n */\nexport interface UseGameOptions extends GameOptions {\n /** AI 搜索深度;仅 `aiMove` 用到。 */\n aiDepth?: number;\n}\n\nexport interface UseGameReturn {\n /** 引擎的完整状态快照;用 ShallowRef 声明让 <script setup> 的模板能自动 unwrap。 */\n state: ShallowRef<GameState>;\n raw: Game2048;\n move: (dir: Direction) => boolean;\n newGame: () => void;\n undo: () => boolean;\n continuePlaying: () => void;\n canUndo: () => boolean;\n /** 让 AI 走一步(内部仍走 game.move 触发订阅)。 */\n aiMove: () => Direction | null;\n /** 只查询、不走子。 */\n suggestMove: () => Direction | null;\n}\n\nexport function useGame(options: UseGameOptions = {}): UseGameReturn {\n const { aiDepth = 3, ...engineOptions } = options;\n const game = new Game2048(engineOptions);\n const state = shallowRef<GameState>(game.getState());\n const unsubscribe = game.subscribe((s) => {\n state.value = s;\n });\n onBeforeUnmount(unsubscribe);\n\n return {\n state,\n raw: game,\n move: (dir) => game.move(dir),\n newGame: () => game.newGame(),\n undo: () => game.undo(),\n continuePlaying: () => game.continuePlaying(),\n canUndo: () => game.canUndo(),\n suggestMove: () => aiBestMove(game.getGridValues(), { depth: aiDepth }),\n aiMove: () => {\n const dir = aiBestMove(game.getGridValues(), { depth: aiDepth });\n if (dir) game.move(dir);\n return dir;\n }\n };\n}\n","<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';\nimport '@my-games/2048-core/styles.css';\nimport { useGame } from './useGame';\nimport type { Direction } from '@my-games/2048-core';\n\n/**\n * Game2048 —— Vue 3 官方组件。\n *\n * 输入模型:\n * - 键盘:window 监听 keydown,但只有 board 元素拥有 DOM 焦点时才处理。\n * 多实例并存时用户点击 / 触摸哪个,哪个成为「活动」实例;`autofocus=true`\n * (默认)让第一个实例在 mount 时自动 focus,符合单实例场景直觉。\n * - 触摸:board 上的单指 swipe,主导轴决定方向;阈值 24px。\n *\n * 动画:\n * - 新块:scale-in\n * - 合并:pop 弹跳\n * - 滑动:transform transition\n *\n * AI:`enableAI=true` 打开「提示」/「AI 帮走」按钮;未打开时依然可以通过\n * template ref 拿到 `suggestMove` / `aiMove` 方法(`defineExpose`)。\n */\n\ninterface Props {\n size?: 3 | 4 | 5 | 6;\n storage?: import('@my-games/2048-core').SaveStorage | null;\n saveKey?: string;\n bestKey?: string;\n freshStart?: boolean;\n autofocus?: boolean;\n enableAI?: boolean;\n aiDepth?: number;\n title?: string;\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n size: 4,\n storage: undefined,\n saveKey: undefined,\n bestKey: undefined,\n freshStart: false,\n autofocus: true,\n enableAI: false,\n aiDepth: 3,\n title: '2048'\n});\n\nconst emit = defineEmits<{\n (e: 'move', dir: Direction, gained: number): void;\n (e: 'won'): void;\n (e: 'lost'): void;\n}>();\n\nconst { state, move, newGame, undo, continuePlaying, suggestMove, aiMove } = useGame({\n size: props.size,\n storage: props.storage,\n saveKey: props.saveKey,\n bestKey: props.bestKey,\n freshStart: props.freshStart,\n aiDepth: props.aiDepth\n});\n\nconst boardEl = ref<HTMLDivElement | null>(null);\nconst scoreDelta = ref<{ amount: number; key: number } | null>(null);\nconst aiHint = ref<Direction | null>(null);\nconst lastScore = ref(state.value.score);\nconst lastStatus = ref(state.value.status);\nlet deltaTimer: number | undefined;\n\n// 手动 watch:Vue 的 watch 需要在 setup 里 import;这里用普通 getter 触发\n// 每次订阅推来的新 state 时,state.value 会变,computed 重算 → DOM 更新。\n// 分数变化与 status 变化通过在 update 回调里对比 last 值实现(见下方 subscribe 二次挂载)。\n// 简化:把状态变化处理放在 useGame 的订阅里;这里只保留必要的 reactive。\n\n// --- 键盘 ---\nconst KEY_TO_DIR: Record<string, Direction> = {\n ArrowUp: 'up',\n ArrowDown: 'down',\n ArrowLeft: 'left',\n ArrowRight: 'right',\n w: 'up',\n a: 'left',\n s: 'down',\n d: 'right',\n W: 'up',\n A: 'left',\n S: 'down',\n D: 'right'\n};\n\nfunction isBoardFocused(): boolean {\n if (typeof document === 'undefined') return false;\n return document.activeElement === boardEl.value;\n}\n\nfunction onKey(ev: KeyboardEvent) {\n const dir = KEY_TO_DIR[ev.key];\n if (!dir) return;\n if (!isBoardFocused()) return;\n ev.preventDefault();\n performMove(dir);\n}\n\nfunction performMove(dir: Direction) {\n const before = state.value.score;\n const ok = move(dir);\n if (ok) {\n emit('move', dir, state.value.score - before);\n if (props.enableAI) aiHint.value = null;\n }\n}\n\n// --- 触摸 ---\nlet touchStartPoint: { x: number; y: number } | null = null;\nfunction onTouchStart(ev: TouchEvent) {\n if (ev.touches.length !== 1) return;\n const t = ev.touches[0];\n if (!t) return;\n touchStartPoint = { x: t.clientX, y: t.clientY };\n // 触摸时把焦点转到本 board,让后续键盘操作也生效\n boardEl.value?.focus({ preventScroll: true });\n}\nfunction onTouchEnd(ev: TouchEvent) {\n const start = touchStartPoint;\n touchStartPoint = null;\n if (!start) return;\n const t = ev.changedTouches[0];\n if (!t) return;\n const dx = t.clientX - start.x;\n const dy = t.clientY - start.y;\n const adx = Math.abs(dx);\n const ady = Math.abs(dy);\n if (Math.max(adx, ady) < 24) return;\n const dir: Direction = adx > ady ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';\n performMove(dir);\n}\n\nonMounted(() => {\n if (props.autofocus) boardEl.value?.focus({ preventScroll: true });\n window.addEventListener('keydown', onKey, { passive: false });\n});\n\nonBeforeUnmount(() => {\n window.removeEventListener('keydown', onKey);\n if (deltaTimer) window.clearTimeout(deltaTimer);\n});\n\n// --- 分数 & 状态 side-effect ---\nwatch(\n () => state.value.score,\n (now, prev) => {\n const gained = now - (prev ?? lastScore.value);\n if (gained > 0) {\n scoreDelta.value = { amount: gained, key: Date.now() + Math.random() };\n if (deltaTimer) window.clearTimeout(deltaTimer);\n deltaTimer = window.setTimeout(() => (scoreDelta.value = null), 700);\n }\n lastScore.value = now;\n }\n);\nwatch(\n () => state.value.status,\n (s) => {\n if (s === lastStatus.value) return;\n if (s === 'won') emit('won');\n else if (s === 'lost') emit('lost');\n lastStatus.value = s;\n }\n);\n\n// --- 渲染辅助 ---\nconst gridStyle = computed(() => ({\n gridTemplateColumns: `repeat(${state.value.size}, 1fr)`,\n gridTemplateRows: `repeat(${state.value.size}, 1fr)`\n}));\n\nconst boardVars = computed(() => ({ '--cols': String(state.value.size) }));\n\nfunction tileVars(row: number, col: number) {\n return { '--row': String(row), '--col': String(col) } as Record<string, string>;\n}\n\nfunction tileClass(t: { value: number; isNew: boolean; merged: boolean }) {\n return {\n 'is-new': t.isNew,\n 'is-merged': t.merged,\n 'is-super': t.value > 2048\n };\n}\n\nfunction tileAttrs(t: { value: number }) {\n return t.value > 2048\n ? { 'data-super': '1' }\n : {};\n}\n\nconst overlayVisible = computed(() => state.value.status !== 'playing');\nconst isWon = computed(() => state.value.status === 'won');\nconst isLost = computed(() => state.value.status === 'lost');\nconst undoDisabled = computed(() => state.value.historySize === 0);\n\n// --- AI ---\nfunction onAskAI() {\n aiHint.value = suggestMove();\n}\nfunction onDoAI() {\n const before = state.value.score;\n const dir = aiMove();\n if (dir) {\n emit('move', dir, state.value.score - before);\n aiHint.value = null;\n }\n}\n\ndefineExpose({ suggestMove, aiMove, newGame, undo, move, continuePlaying });\n</script>\n\n<template>\n <div class=\"game-2048\">\n <header v-if=\"title\" class=\"game-2048-header\">\n <h1 class=\"game-2048-title\">{{ title }}</h1>\n <div class=\"game-2048-scores\">\n <div class=\"game-2048-score-box\">\n <span class=\"label\">Score</span>\n <span class=\"value\">{{ state.score }}</span>\n <span\n v-if=\"scoreDelta\"\n :key=\"scoreDelta.key\"\n class=\"delta\"\n >+{{ scoreDelta.amount }}</span\n >\n </div>\n <div class=\"game-2048-score-box\">\n <span class=\"label\">Best</span>\n <span class=\"value\">{{ state.bestScore }}</span>\n </div>\n </div>\n </header>\n\n <div class=\"game-2048-toolbar\">\n <p class=\"game-2048-desc\">\n 合并相同数字,冲过 <b>2048</b>。方向键 / WASD / 触屏滑动。\n </p>\n <div class=\"game-2048-actions\">\n <button\n v-if=\"enableAI\"\n class=\"game-2048-button\"\n type=\"button\"\n :disabled=\"isLost\"\n @click=\"onAskAI\"\n >\n 提示\n </button>\n <button\n v-if=\"enableAI\"\n class=\"game-2048-button\"\n type=\"button\"\n :disabled=\"isLost\"\n @click=\"onDoAI\"\n >\n AI 帮走\n </button>\n <button class=\"game-2048-button\" type=\"button\" :disabled=\"undoDisabled\" @click=\"undo()\">\n 撤销\n </button>\n <button class=\"game-2048-button is-primary\" type=\"button\" @click=\"newGame()\">\n 新游戏\n </button>\n </div>\n </div>\n\n <div\n ref=\"boardEl\"\n class=\"game-2048-board\"\n :style=\"boardVars\"\n tabindex=\"0\"\n role=\"application\"\n :aria-label=\"`2048 游戏板 ${state.status}`\"\n @touchstart.passive=\"onTouchStart\"\n @touchend.prevent=\"onTouchEnd\"\n >\n <div class=\"game-2048-grid\" :style=\"gridStyle\">\n <div v-for=\"i in state.size * state.size\" :key=\"i\" class=\"game-2048-cell\" />\n </div>\n <div class=\"game-2048-tile-layer\">\n <div\n v-for=\"t in state.tiles\"\n :key=\"t.id\"\n class=\"game-2048-tile\"\n :class=\"tileClass(t)\"\n :style=\"tileVars(t.row, t.col)\"\n :data-value=\"t.value\"\n v-bind=\"tileAttrs(t)\"\n >\n {{ t.value }}\n </div>\n </div>\n\n <div\n v-if=\"enableAI && aiHint\"\n class=\"game-2048-ai-indicator\"\n style=\"position: absolute; top: 8px; left: 8px; z-index: 3\"\n >\n 推荐:{{ aiHint }}\n </div>\n\n <div\n class=\"game-2048-overlay\"\n :class=\"{ 'is-visible': overlayVisible, 'is-won': isWon, 'is-lost': isLost }\"\n >\n <h2 v-if=\"isWon\">你赢了!</h2>\n <h2 v-else-if=\"isLost\">游戏结束</h2>\n <div class=\"row\">\n <button v-if=\"isWon\" class=\"game-2048-button\" type=\"button\" @click=\"continuePlaying()\">\n 继续游戏\n </button>\n <button\n v-if=\"isLost && !undoDisabled\"\n class=\"game-2048-button\"\n type=\"button\"\n @click=\"undo()\"\n >\n 悔一步\n </button>\n <button\n v-if=\"overlayVisible\"\n class=\"game-2048-button is-primary\"\n type=\"button\"\n @click=\"newGame()\"\n >\n 再来一局\n </button>\n </div>\n </div>\n </div>\n </div>\n</template>\n"],"names":["useGame","options","aiDepth","engineOptions","game","Game2048","state","shallowRef","unsubscribe","s","onBeforeUnmount","dir","aiBestMove","props","__props","emit","__emit","move","newGame","undo","continuePlaying","suggestMove","aiMove","boardEl","ref","scoreDelta","aiHint","lastScore","lastStatus","deltaTimer","KEY_TO_DIR","isBoardFocused","onKey","ev","performMove","before","touchStartPoint","onTouchStart","t","_a","onTouchEnd","start","dx","dy","adx","ady","onMounted","watch","now","prev","gained","gridStyle","computed","boardVars","tileVars","row","col","tileClass","tileAttrs","overlayVisible","isWon","isLost","undoDisabled","onAskAI","onDoAI","__expose","_openBlock","_createElementBlock","_hoisted_1","_hoisted_2","_createElementVNode","_hoisted_3","_toDisplayString","_hoisted_4","_hoisted_5","_cache","_hoisted_6","_unref","_hoisted_7","_hoisted_8","_hoisted_9","_hoisted_10","_hoisted_11","_hoisted_12","_hoisted_13","_Fragment","_renderList","i","_hoisted_15","_mergeProps","_hoisted_16","_hoisted_17","_hoisted_20"],"mappings":"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"}
package/dist/style.css ADDED
@@ -0,0 +1 @@
1
+ .game-2048{--board-size: min(90vw, 520px);--gap: clamp(6px, 2.5vw, 12px);--radius: clamp(4px, 1.2vw, 8px);--bg-board: #bbada0;--bg-empty: rgba(238, 228, 218, .35);--text-dark: #776e65;--text-light: #f9f6f2;--accent: #8f7a66;display:inline-flex;flex-direction:column;gap:var(--gap);font-family:Helvetica Neue,Segoe UI,system-ui,-apple-system,sans-serif;color:var(--text-dark);-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}.game-2048-header{display:flex;align-items:center;justify-content:space-between;gap:var(--gap);width:var(--board-size);max-width:90vw}.game-2048-title{margin:0;font-size:clamp(28px,8vw,44px);font-weight:800;letter-spacing:-1px;color:var(--text-dark)}.game-2048-scores{display:flex;gap:6px}.game-2048-score-box{position:relative;min-width:74px;padding:6px 12px 8px;border-radius:var(--radius);background:var(--bg-board);color:var(--text-light);text-align:center;font-variant-numeric:tabular-nums}.game-2048-score-box .label{display:block;font-size:11px;letter-spacing:1px;color:#eee4da;text-transform:uppercase}.game-2048-score-box .value{display:block;font-size:22px;font-weight:700;line-height:1.2}.game-2048-score-box .delta{position:absolute;top:4px;right:8px;color:#776e65;font-weight:700;font-size:14px;animation:game-2048-score-pop .7s ease-out forwards;pointer-events:none}@keyframes game-2048-score-pop{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-24px)}}.game-2048-toolbar{display:flex;align-items:center;justify-content:space-between;gap:var(--gap);width:var(--board-size);max-width:90vw}.game-2048-desc{margin:0;font-size:13px;line-height:1.5;color:var(--text-dark);opacity:.9}.game-2048-actions{display:flex;gap:6px;flex-shrink:0}.game-2048-button{padding:8px 14px;border:0;border-radius:var(--radius);background:var(--accent);color:var(--text-light);font:inherit;font-weight:700;font-size:14px;cursor:pointer;transition:background .12s,transform 80ms}.game-2048-button:hover:not(:disabled){background:#a68472}.game-2048-button:active:not(:disabled){transform:translateY(1px)}.game-2048-button:disabled{opacity:.4;cursor:not-allowed}.game-2048-button.is-primary{background:#f2b179;color:#776e65}.game-2048-button.is-primary:hover:not(:disabled){background:#f59563}.game-2048-board{position:relative;width:var(--board-size);max-width:90vw;aspect-ratio:1;padding:var(--gap);border-radius:var(--radius);background:var(--bg-board);touch-action:none;outline:none;box-sizing:content-box}.game-2048-board:focus-visible{box-shadow:0 0 0 3px #8f7a6680}.game-2048-grid{display:grid;width:100%;height:100%;gap:var(--gap)}.game-2048-cell{border-radius:var(--radius);background:var(--bg-empty)}.game-2048-tile-layer{position:absolute;inset:var(--gap);pointer-events:none}.game-2048-tile{--pitch-x: calc(100% + var(--gap));--pitch-y: calc(100% + var(--gap));position:absolute;top:0;left:0;display:flex;align-items:center;justify-content:center;border-radius:var(--radius);background:#eee4da;color:var(--text-dark);font-weight:800;font-size:clamp(24px,8vw,44px);line-height:1;width:calc((100% - var(--gap) * (var(--cols) - 1)) / var(--cols));height:calc((100% - var(--gap) * (var(--cols) - 1)) / var(--cols));transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y)));transition:transform .12s ease-in-out;will-change:transform}.game-2048-tile.is-new{animation:game-2048-tile-spawn .18s ease-out}.game-2048-tile.is-merged{animation:game-2048-tile-pop .18s ease-out;z-index:2}@keyframes game-2048-tile-spawn{0%{opacity:0;transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y))) scale(.3)}to{opacity:1;transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y))) scale(1)}}@keyframes game-2048-tile-pop{0%{transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y))) scale(1)}50%{transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y))) scale(1.18)}to{transform:translate(calc(var(--col) * var(--pitch-x)),calc(var(--row) * var(--pitch-y))) scale(1)}}.game-2048-tile[data-value="2"]{background:#eee4da;color:var(--text-dark)}.game-2048-tile[data-value="4"]{background:#ede0c8;color:var(--text-dark)}.game-2048-tile[data-value="8"]{background:#f2b179;color:var(--text-light)}.game-2048-tile[data-value="16"]{background:#f59563;color:var(--text-light)}.game-2048-tile[data-value="32"]{background:#f67c5f;color:var(--text-light)}.game-2048-tile[data-value="64"]{background:#f65e3b;color:var(--text-light)}.game-2048-tile[data-value="128"]{background:#edcf72;color:var(--text-light);font-size:clamp(20px,6.5vw,38px)}.game-2048-tile[data-value="256"]{background:#edcc61;color:var(--text-light);font-size:clamp(20px,6.5vw,38px)}.game-2048-tile[data-value="512"]{background:#edc850;color:var(--text-light);font-size:clamp(20px,6.5vw,38px)}.game-2048-tile[data-value="1024"]{background:#edc53f;color:var(--text-light);font-size:clamp(16px,5.5vw,32px)}.game-2048-tile[data-value="2048"]{background:#edc22e;color:var(--text-light);font-size:clamp(16px,5.5vw,32px);box-shadow:0 0 24px #f3d77499}.game-2048-tile[data-super="1"]{background:#3c3a33;color:var(--text-light);font-size:clamp(14px,5vw,28px)}.game-2048-overlay{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;border-radius:var(--radius);background:#eee4daba;color:var(--text-dark);text-align:center;opacity:0;pointer-events:none;transition:opacity .22s ease-in;z-index:10}.game-2048-overlay.is-visible{opacity:1;pointer-events:auto}.game-2048-overlay.is-lost{background:#eee4da80}.game-2048-overlay h2{margin:0;font-size:clamp(32px,9vw,52px);font-weight:800;color:var(--text-dark)}.game-2048-overlay.is-won h2{color:#f9f6f2;text-shadow:0 0 12px rgba(237,194,46,.6)}.game-2048-overlay .row{display:flex;gap:8px}.game-2048-ai-indicator{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#8f7a6624;font-size:12px;color:var(--text-dark)}@media (prefers-reduced-motion: reduce){.game-2048-tile,.game-2048-overlay{transition:none}.game-2048-tile.is-new,.game-2048-tile.is-merged{animation:none}}
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@my-games/2048-vue",
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 游戏的 Vue 3 组件适配器(Composition API)。",
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
+ "vue": "^3.4.0"
24
+ },
25
+ "dependencies": {
26
+ "@my-games/2048-core": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@vitejs/plugin-vue": "^5.1.4",
30
+ "typescript": "^5.9.3",
31
+ "vite": "^5.4.10",
32
+ "vite-plugin-dts": "^4.3.0",
33
+ "vue": "^3.5.12",
34
+ "vue-tsc": "^2.2.12"
35
+ },
36
+ "keywords": [
37
+ "2048",
38
+ "vue",
39
+ "vue3",
40
+ "game",
41
+ "puzzle",
42
+ "component"
43
+ ],
44
+ "license": "MIT",
45
+ "author": {
46
+ "name": "xyj2156"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/my-gam/2048.git",
51
+ "directory": "packages/vue"
52
+ },
53
+ "homepage": "https://github.com/my-gam/2048/tree/main/packages/vue#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/my-gam/2048/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "build": "vite build --mode lib",
62
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
63
+ }
6
64
  }
@@ -0,0 +1,338 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
3
+ import '@my-games/2048-core/styles.css';
4
+ import { useGame } from './useGame';
5
+ import type { Direction } from '@my-games/2048-core';
6
+
7
+ /**
8
+ * Game2048 —— Vue 3 官方组件。
9
+ *
10
+ * 输入模型:
11
+ * - 键盘:window 监听 keydown,但只有 board 元素拥有 DOM 焦点时才处理。
12
+ * 多实例并存时用户点击 / 触摸哪个,哪个成为「活动」实例;`autofocus=true`
13
+ * (默认)让第一个实例在 mount 时自动 focus,符合单实例场景直觉。
14
+ * - 触摸:board 上的单指 swipe,主导轴决定方向;阈值 24px。
15
+ *
16
+ * 动画:
17
+ * - 新块:scale-in
18
+ * - 合并:pop 弹跳
19
+ * - 滑动:transform transition
20
+ *
21
+ * AI:`enableAI=true` 打开「提示」/「AI 帮走」按钮;未打开时依然可以通过
22
+ * template ref 拿到 `suggestMove` / `aiMove` 方法(`defineExpose`)。
23
+ */
24
+
25
+ interface Props {
26
+ size?: 3 | 4 | 5 | 6;
27
+ storage?: import('@my-games/2048-core').SaveStorage | null;
28
+ saveKey?: string;
29
+ bestKey?: string;
30
+ freshStart?: boolean;
31
+ autofocus?: boolean;
32
+ enableAI?: boolean;
33
+ aiDepth?: number;
34
+ title?: string;
35
+ }
36
+
37
+ const props = withDefaults(defineProps<Props>(), {
38
+ size: 4,
39
+ storage: undefined,
40
+ saveKey: undefined,
41
+ bestKey: undefined,
42
+ freshStart: false,
43
+ autofocus: true,
44
+ enableAI: false,
45
+ aiDepth: 3,
46
+ title: '2048'
47
+ });
48
+
49
+ const emit = defineEmits<{
50
+ (e: 'move', dir: Direction, gained: number): void;
51
+ (e: 'won'): void;
52
+ (e: 'lost'): void;
53
+ }>();
54
+
55
+ const { state, move, newGame, undo, continuePlaying, suggestMove, aiMove } = useGame({
56
+ size: props.size,
57
+ storage: props.storage,
58
+ saveKey: props.saveKey,
59
+ bestKey: props.bestKey,
60
+ freshStart: props.freshStart,
61
+ aiDepth: props.aiDepth
62
+ });
63
+
64
+ const boardEl = ref<HTMLDivElement | null>(null);
65
+ const scoreDelta = ref<{ amount: number; key: number } | null>(null);
66
+ const aiHint = ref<Direction | null>(null);
67
+ const lastScore = ref(state.value.score);
68
+ const lastStatus = ref(state.value.status);
69
+ let deltaTimer: number | undefined;
70
+
71
+ // 手动 watch:Vue 的 watch 需要在 setup 里 import;这里用普通 getter 触发
72
+ // 每次订阅推来的新 state 时,state.value 会变,computed 重算 → DOM 更新。
73
+ // 分数变化与 status 变化通过在 update 回调里对比 last 值实现(见下方 subscribe 二次挂载)。
74
+ // 简化:把状态变化处理放在 useGame 的订阅里;这里只保留必要的 reactive。
75
+
76
+ // --- 键盘 ---
77
+ const KEY_TO_DIR: Record<string, Direction> = {
78
+ ArrowUp: 'up',
79
+ ArrowDown: 'down',
80
+ ArrowLeft: 'left',
81
+ ArrowRight: 'right',
82
+ w: 'up',
83
+ a: 'left',
84
+ s: 'down',
85
+ d: 'right',
86
+ W: 'up',
87
+ A: 'left',
88
+ S: 'down',
89
+ D: 'right'
90
+ };
91
+
92
+ function isBoardFocused(): boolean {
93
+ if (typeof document === 'undefined') return false;
94
+ return document.activeElement === boardEl.value;
95
+ }
96
+
97
+ function onKey(ev: KeyboardEvent) {
98
+ const dir = KEY_TO_DIR[ev.key];
99
+ if (!dir) return;
100
+ if (!isBoardFocused()) return;
101
+ ev.preventDefault();
102
+ performMove(dir);
103
+ }
104
+
105
+ function performMove(dir: Direction) {
106
+ const before = state.value.score;
107
+ const ok = move(dir);
108
+ if (ok) {
109
+ emit('move', dir, state.value.score - before);
110
+ if (props.enableAI) aiHint.value = null;
111
+ }
112
+ }
113
+
114
+ // --- 触摸 ---
115
+ let touchStartPoint: { x: number; y: number } | null = null;
116
+ function onTouchStart(ev: TouchEvent) {
117
+ if (ev.touches.length !== 1) return;
118
+ const t = ev.touches[0];
119
+ if (!t) return;
120
+ touchStartPoint = { x: t.clientX, y: t.clientY };
121
+ // 触摸时把焦点转到本 board,让后续键盘操作也生效
122
+ boardEl.value?.focus({ preventScroll: true });
123
+ }
124
+ function onTouchEnd(ev: TouchEvent) {
125
+ const start = touchStartPoint;
126
+ touchStartPoint = null;
127
+ if (!start) return;
128
+ const t = ev.changedTouches[0];
129
+ if (!t) return;
130
+ const dx = t.clientX - start.x;
131
+ const dy = t.clientY - start.y;
132
+ const adx = Math.abs(dx);
133
+ const ady = Math.abs(dy);
134
+ if (Math.max(adx, ady) < 24) return;
135
+ const dir: Direction = adx > ady ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up';
136
+ performMove(dir);
137
+ }
138
+
139
+ onMounted(() => {
140
+ if (props.autofocus) boardEl.value?.focus({ preventScroll: true });
141
+ window.addEventListener('keydown', onKey, { passive: false });
142
+ });
143
+
144
+ onBeforeUnmount(() => {
145
+ window.removeEventListener('keydown', onKey);
146
+ if (deltaTimer) window.clearTimeout(deltaTimer);
147
+ });
148
+
149
+ // --- 分数 & 状态 side-effect ---
150
+ watch(
151
+ () => state.value.score,
152
+ (now, prev) => {
153
+ const gained = now - (prev ?? lastScore.value);
154
+ if (gained > 0) {
155
+ scoreDelta.value = { amount: gained, key: Date.now() + Math.random() };
156
+ if (deltaTimer) window.clearTimeout(deltaTimer);
157
+ deltaTimer = window.setTimeout(() => (scoreDelta.value = null), 700);
158
+ }
159
+ lastScore.value = now;
160
+ }
161
+ );
162
+ watch(
163
+ () => state.value.status,
164
+ (s) => {
165
+ if (s === lastStatus.value) return;
166
+ if (s === 'won') emit('won');
167
+ else if (s === 'lost') emit('lost');
168
+ lastStatus.value = s;
169
+ }
170
+ );
171
+
172
+ // --- 渲染辅助 ---
173
+ const gridStyle = computed(() => ({
174
+ gridTemplateColumns: `repeat(${state.value.size}, 1fr)`,
175
+ gridTemplateRows: `repeat(${state.value.size}, 1fr)`
176
+ }));
177
+
178
+ const boardVars = computed(() => ({ '--cols': String(state.value.size) }));
179
+
180
+ function tileVars(row: number, col: number) {
181
+ return { '--row': String(row), '--col': String(col) } as Record<string, string>;
182
+ }
183
+
184
+ function tileClass(t: { value: number; isNew: boolean; merged: boolean }) {
185
+ return {
186
+ 'is-new': t.isNew,
187
+ 'is-merged': t.merged,
188
+ 'is-super': t.value > 2048
189
+ };
190
+ }
191
+
192
+ function tileAttrs(t: { value: number }) {
193
+ return t.value > 2048
194
+ ? { 'data-super': '1' }
195
+ : {};
196
+ }
197
+
198
+ const overlayVisible = computed(() => state.value.status !== 'playing');
199
+ const isWon = computed(() => state.value.status === 'won');
200
+ const isLost = computed(() => state.value.status === 'lost');
201
+ const undoDisabled = computed(() => state.value.historySize === 0);
202
+
203
+ // --- AI ---
204
+ function onAskAI() {
205
+ aiHint.value = suggestMove();
206
+ }
207
+ function onDoAI() {
208
+ const before = state.value.score;
209
+ const dir = aiMove();
210
+ if (dir) {
211
+ emit('move', dir, state.value.score - before);
212
+ aiHint.value = null;
213
+ }
214
+ }
215
+
216
+ defineExpose({ suggestMove, aiMove, newGame, undo, move, continuePlaying });
217
+ </script>
218
+
219
+ <template>
220
+ <div class="game-2048">
221
+ <header v-if="title" class="game-2048-header">
222
+ <h1 class="game-2048-title">{{ title }}</h1>
223
+ <div class="game-2048-scores">
224
+ <div class="game-2048-score-box">
225
+ <span class="label">Score</span>
226
+ <span class="value">{{ state.score }}</span>
227
+ <span
228
+ v-if="scoreDelta"
229
+ :key="scoreDelta.key"
230
+ class="delta"
231
+ >+{{ scoreDelta.amount }}</span
232
+ >
233
+ </div>
234
+ <div class="game-2048-score-box">
235
+ <span class="label">Best</span>
236
+ <span class="value">{{ state.bestScore }}</span>
237
+ </div>
238
+ </div>
239
+ </header>
240
+
241
+ <div class="game-2048-toolbar">
242
+ <p class="game-2048-desc">
243
+ 合并相同数字,冲过 <b>2048</b>。方向键 / WASD / 触屏滑动。
244
+ </p>
245
+ <div class="game-2048-actions">
246
+ <button
247
+ v-if="enableAI"
248
+ class="game-2048-button"
249
+ type="button"
250
+ :disabled="isLost"
251
+ @click="onAskAI"
252
+ >
253
+ 提示
254
+ </button>
255
+ <button
256
+ v-if="enableAI"
257
+ class="game-2048-button"
258
+ type="button"
259
+ :disabled="isLost"
260
+ @click="onDoAI"
261
+ >
262
+ AI 帮走
263
+ </button>
264
+ <button class="game-2048-button" type="button" :disabled="undoDisabled" @click="undo()">
265
+ 撤销
266
+ </button>
267
+ <button class="game-2048-button is-primary" type="button" @click="newGame()">
268
+ 新游戏
269
+ </button>
270
+ </div>
271
+ </div>
272
+
273
+ <div
274
+ ref="boardEl"
275
+ class="game-2048-board"
276
+ :style="boardVars"
277
+ tabindex="0"
278
+ role="application"
279
+ :aria-label="`2048 游戏板 ${state.status}`"
280
+ @touchstart.passive="onTouchStart"
281
+ @touchend.prevent="onTouchEnd"
282
+ >
283
+ <div class="game-2048-grid" :style="gridStyle">
284
+ <div v-for="i in state.size * state.size" :key="i" class="game-2048-cell" />
285
+ </div>
286
+ <div class="game-2048-tile-layer">
287
+ <div
288
+ v-for="t in state.tiles"
289
+ :key="t.id"
290
+ class="game-2048-tile"
291
+ :class="tileClass(t)"
292
+ :style="tileVars(t.row, t.col)"
293
+ :data-value="t.value"
294
+ v-bind="tileAttrs(t)"
295
+ >
296
+ {{ t.value }}
297
+ </div>
298
+ </div>
299
+
300
+ <div
301
+ v-if="enableAI && aiHint"
302
+ class="game-2048-ai-indicator"
303
+ style="position: absolute; top: 8px; left: 8px; z-index: 3"
304
+ >
305
+ 推荐:{{ aiHint }}
306
+ </div>
307
+
308
+ <div
309
+ class="game-2048-overlay"
310
+ :class="{ 'is-visible': overlayVisible, 'is-won': isWon, 'is-lost': isLost }"
311
+ >
312
+ <h2 v-if="isWon">你赢了!</h2>
313
+ <h2 v-else-if="isLost">游戏结束</h2>
314
+ <div class="row">
315
+ <button v-if="isWon" class="game-2048-button" type="button" @click="continuePlaying()">
316
+ 继续游戏
317
+ </button>
318
+ <button
319
+ v-if="isLost && !undoDisabled"
320
+ class="game-2048-button"
321
+ type="button"
322
+ @click="undo()"
323
+ >
324
+ 悔一步
325
+ </button>
326
+ <button
327
+ v-if="overlayVisible"
328
+ class="game-2048-button is-primary"
329
+ type="button"
330
+ @click="newGame()"
331
+ >
332
+ 再来一局
333
+ </button>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </template>
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { default as Game2048 } from './Game2048.vue';
2
+ export { useGame, type UseGameOptions, type UseGameReturn } from './useGame';
3
+ export type { Game2048Instance } from './instance';
4
+
5
+ // 顺手把 core 的关键类型透出来,宿主项目不需要额外装 core 也能拿到方向等类型。
6
+ export type {
7
+ BoardSize,
8
+ Direction,
9
+ GameState,
10
+ GameStatus,
11
+ SaveStorage,
12
+ Tile
13
+ } from '@my-games/2048-core';
@@ -0,0 +1,14 @@
1
+ import type { Direction } from '@my-games/2048-core';
2
+
3
+ /**
4
+ * 组件 `ref` 上暴露的方法(`defineExpose` 的对应类型)。
5
+ * 供父组件用 `const g = ref<Game2048Instance>()` 精确声明类型。
6
+ */
7
+ export interface Game2048Instance {
8
+ suggestMove(): Direction | null;
9
+ aiMove(): Direction | null;
10
+ newGame(): void;
11
+ undo(): boolean;
12
+ move(dir: Direction): boolean;
13
+ continuePlaying(): void;
14
+ }
package/src/useGame.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { onBeforeUnmount, shallowRef, type ShallowRef } from 'vue';
2
+ import { bestMove as aiBestMove, Game2048, type Direction, type GameOptions, type GameState } from '@my-games/2048-core';
3
+
4
+ /**
5
+ * `useGame` —— Vue 侧连接 Game2048 引擎的 composable。
6
+ *
7
+ * 用法:
8
+ * ```ts
9
+ * const { state, move, newGame, undo } = useGame({ size: 4 });
10
+ * ```
11
+ *
12
+ * 生命周期:内部订阅引擎;`onBeforeUnmount` 时自动退订,防止内存泄漏。
13
+ * state 用 `shallowRef` 存 —— 引擎每次都推新对象,深度 reactive 无收益。
14
+ */
15
+ export interface UseGameOptions extends GameOptions {
16
+ /** AI 搜索深度;仅 `aiMove` 用到。 */
17
+ aiDepth?: number;
18
+ }
19
+
20
+ export interface UseGameReturn {
21
+ /** 引擎的完整状态快照;用 ShallowRef 声明让 <script setup> 的模板能自动 unwrap。 */
22
+ state: ShallowRef<GameState>;
23
+ raw: Game2048;
24
+ move: (dir: Direction) => boolean;
25
+ newGame: () => void;
26
+ undo: () => boolean;
27
+ continuePlaying: () => void;
28
+ canUndo: () => boolean;
29
+ /** 让 AI 走一步(内部仍走 game.move 触发订阅)。 */
30
+ aiMove: () => Direction | null;
31
+ /** 只查询、不走子。 */
32
+ suggestMove: () => Direction | null;
33
+ }
34
+
35
+ export function useGame(options: UseGameOptions = {}): UseGameReturn {
36
+ const { aiDepth = 3, ...engineOptions } = options;
37
+ const game = new Game2048(engineOptions);
38
+ const state = shallowRef<GameState>(game.getState());
39
+ const unsubscribe = game.subscribe((s) => {
40
+ state.value = s;
41
+ });
42
+ onBeforeUnmount(unsubscribe);
43
+
44
+ return {
45
+ state,
46
+ raw: game,
47
+ move: (dir) => game.move(dir),
48
+ newGame: () => game.newGame(),
49
+ undo: () => game.undo(),
50
+ continuePlaying: () => game.continuePlaying(),
51
+ canUndo: () => game.canUndo(),
52
+ suggestMove: () => aiBestMove(game.getGridValues(), { depth: aiDepth }),
53
+ aiMove: () => {
54
+ const dir = aiBestMove(game.getGridValues(), { depth: aiDepth });
55
+ if (dir) game.move(dir);
56
+ return dir;
57
+ }
58
+ };
59
+ }