@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 +78 -2
- package/dist/index.d.ts +131 -0
- package/dist/index.js +251 -0
- package/dist/index.js.map +1 -0
- package/dist/style.css +1 -0
- package/package.json +61 -3
- package/src/Game2048.vue +338 -0
- package/src/index.ts +13 -0
- package/src/instance.ts +14 -0
- package/src/useGame.ts +59 -0
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @my-games/2048-vue
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|
package/src/Game2048.vue
ADDED
|
@@ -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';
|
package/src/instance.ts
ADDED
|
@@ -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
|
+
}
|