@my-games/2048-core 0.1.0 → 0.3.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/dist/ai.d.ts +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/game.d.ts +1 -1
- package/dist/index.cjs +752 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.iife.js +755 -0
- package/dist/index.iife.js.map +1 -0
- package/dist/index.js +727 -11
- package/dist/index.js.map +1 -1
- package/dist/index.umd.js +756 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/storage.d.ts +1 -1
- package/package.json +22 -11
- package/dist/ai.js +0 -186
- package/dist/ai.js.map +0 -1
- package/dist/board.js +0 -163
- package/dist/board.js.map +0 -1
- package/dist/game.js +0 -358
- package/dist/game.js.map +0 -1
- package/dist/storage.js +0 -79
- package/dist/storage.js.map +0 -1
- package/dist/types.js +0 -10
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,729 @@
|
|
|
1
|
+
// src/board.ts
|
|
1
2
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
* 纯函数棋盘工具:只用 `Cell[][]`(0 = 空),不涉及 tile id。
|
|
4
|
+
*
|
|
5
|
+
* 设计原则:所有会「改变棋盘」的函数都返回新数组,不原地修改。
|
|
6
|
+
* Game2048 与 AI 都建立在这套底层原语上。
|
|
7
|
+
*/
|
|
8
|
+
function createEmptyGrid(size) {
|
|
9
|
+
return Array.from({ length: size }, () => Array(size).fill(0));
|
|
10
|
+
}
|
|
11
|
+
function cloneGrid(grid) {
|
|
12
|
+
return grid.map((row) => row.slice());
|
|
13
|
+
}
|
|
14
|
+
/** 生成所有空位坐标(row-major 顺序)。 */
|
|
15
|
+
function emptyCells(grid) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (let r = 0; r < grid.length; r++) {
|
|
18
|
+
const row = grid[r];
|
|
19
|
+
if (!row) continue;
|
|
20
|
+
for (let c = 0; c < row.length; c++) if (row[c] === 0) out.push([r, c]);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 把一行「向左压缩 + 相邻等值合并」。
|
|
26
|
+
* 每个 tile 只允许在一次 slide 中参与一次合并,符合 2048 官方规则。
|
|
27
|
+
* 返回:新 line(长度不变)+ 本行得分增量。
|
|
28
|
+
*/
|
|
29
|
+
function slideLineLeft(line) {
|
|
30
|
+
const source = line.filter((v) => v !== 0);
|
|
31
|
+
const result = [];
|
|
32
|
+
let gained = 0;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < source.length) {
|
|
35
|
+
const a = source[i];
|
|
36
|
+
const b = source[i + 1];
|
|
37
|
+
if (b !== void 0 && a === b) {
|
|
38
|
+
const merged = a * 2;
|
|
39
|
+
result.push(merged);
|
|
40
|
+
gained += merged;
|
|
41
|
+
i += 2;
|
|
42
|
+
} else {
|
|
43
|
+
result.push(a);
|
|
44
|
+
i += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
while (result.length < line.length) result.push(0);
|
|
48
|
+
return {
|
|
49
|
+
line: result,
|
|
50
|
+
gained
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function reverseLine(line) {
|
|
54
|
+
return line.slice().reverse();
|
|
55
|
+
}
|
|
56
|
+
/** 沿方向向量把整个棋盘压缩一次;返回新 grid 与得分。 */
|
|
57
|
+
function slideGrid(grid, dir) {
|
|
58
|
+
const size = grid.length;
|
|
59
|
+
const out = createEmptyGrid(size);
|
|
60
|
+
let gained = 0;
|
|
61
|
+
for (let k = 0; k < size; k++) {
|
|
62
|
+
const line = [];
|
|
63
|
+
for (let i = 0; i < size; i++) line.push(readCell(grid, dir, k, i));
|
|
64
|
+
const reversed = dir === "right" || dir === "down";
|
|
65
|
+
const { line: processed, gained: g } = slideLineLeft(reversed ? reverseLine(line) : line);
|
|
66
|
+
gained += g;
|
|
67
|
+
const output = reversed ? reverseLine(processed) : processed;
|
|
68
|
+
for (let i = 0; i < size; i++) writeCell(out, dir, k, i, output[i]);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
grid: out,
|
|
72
|
+
gained
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 将 (k=线序号, i=线内序号) 映射到具体 cell 坐标:
|
|
77
|
+
* - left : row=k, col=i (每行从左侧读)
|
|
78
|
+
* - right: row=k, col=i (读时按 i 反向由 slideGrid 处理)
|
|
79
|
+
* - up : row=i, col=k (每列从上读)
|
|
80
|
+
* - down : row=i, col=k
|
|
81
|
+
* 注意:这里 readCell 只按「物理方向」取格子;反向压缩由 slideGrid 外面 reverseLine 承担。
|
|
82
|
+
*/
|
|
83
|
+
function readCell(grid, dir, k, i) {
|
|
84
|
+
switch (dir) {
|
|
85
|
+
case "left": return grid[k]?.[i] ?? 0;
|
|
86
|
+
case "right": return grid[k]?.[i] ?? 0;
|
|
87
|
+
case "up": return grid[i]?.[k] ?? 0;
|
|
88
|
+
case "down": return grid[i]?.[k] ?? 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function writeCell(grid, dir, k, i, v) {
|
|
92
|
+
switch (dir) {
|
|
93
|
+
case "left":
|
|
94
|
+
case "right":
|
|
95
|
+
{
|
|
96
|
+
const row = grid[k];
|
|
97
|
+
if (row) row[i] = v;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case "up":
|
|
101
|
+
case "down": {
|
|
102
|
+
const row = grid[i];
|
|
103
|
+
if (row) row[k] = v;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function gridsEqual(a, b) {
|
|
108
|
+
if (a.length !== b.length) return false;
|
|
109
|
+
for (let r = 0; r < a.length; r++) {
|
|
110
|
+
const ra = a[r];
|
|
111
|
+
const rb = b[r];
|
|
112
|
+
if (!ra || !rb) return false;
|
|
113
|
+
for (let c = 0; c < ra.length; c++) if (ra[c] !== rb[c]) return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
/** 该方向是否有可移动的空间(含合并)。用于判定是否 game over。 */
|
|
118
|
+
function canMove(grid) {
|
|
119
|
+
for (const d of [
|
|
120
|
+
"up",
|
|
121
|
+
"down",
|
|
122
|
+
"left",
|
|
123
|
+
"right"
|
|
124
|
+
]) {
|
|
125
|
+
const { grid: next } = slideGrid(grid, d);
|
|
126
|
+
if (!gridsEqual(grid, next)) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
/** 是否达到目标值(默认 2048)。 */
|
|
131
|
+
function hasValue(grid, target) {
|
|
132
|
+
for (const row of grid) for (const v of row) if (v === target) return true;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/** 把值网格内的最大值取出来(调试用)。 */
|
|
136
|
+
function maxValue(grid) {
|
|
137
|
+
let m = 0;
|
|
138
|
+
for (const row of grid) for (const v of row) if (v > m) m = v;
|
|
139
|
+
return m;
|
|
140
|
+
}
|
|
141
|
+
// src/storage.ts
|
|
142
|
+
/**
|
|
143
|
+
* 存档字段与版本控制。
|
|
144
|
+
*
|
|
145
|
+
* 版本演进策略:读到的 version 与当前 SAVE_VERSION 不一致时,走 `migrate()`;
|
|
146
|
+
* 无法识别的直接丢弃并让 Game 开新局。目前只有 v1。
|
|
147
|
+
*/
|
|
148
|
+
var SAVE_VERSION = 1;
|
|
149
|
+
var DEFAULT_SAVE_KEY = "game-2048:save";
|
|
150
|
+
var DEFAULT_BEST_KEY = "game-2048:best";
|
|
151
|
+
function isSerializedSave(x) {
|
|
152
|
+
if (typeof x !== "object" || x === null) return false;
|
|
153
|
+
const o = x;
|
|
154
|
+
return o["version"] === 1 && typeof o["size"] === "number" && Array.isArray(o["tiles"]) && typeof o["score"] === "number";
|
|
155
|
+
}
|
|
156
|
+
function loadSave(storage, key) {
|
|
157
|
+
if (!storage) return null;
|
|
158
|
+
const raw = storage.getItem(key);
|
|
159
|
+
if (!raw) return null;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
return isSerializedSave(parsed) ? parsed : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function writeSave(storage, key, save) {
|
|
168
|
+
if (!storage) return;
|
|
169
|
+
try {
|
|
170
|
+
storage.setItem(key, JSON.stringify(save));
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
function clearSave(storage, key) {
|
|
174
|
+
if (!storage) return;
|
|
175
|
+
try {
|
|
176
|
+
storage.removeItem(key);
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
function loadBest(storage, key) {
|
|
180
|
+
if (!storage) return 0;
|
|
181
|
+
const raw = storage.getItem(key);
|
|
182
|
+
if (!raw) return 0;
|
|
183
|
+
const n = Number(raw);
|
|
184
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
185
|
+
}
|
|
186
|
+
function writeBest(storage, key, best) {
|
|
187
|
+
if (!storage) return;
|
|
188
|
+
try {
|
|
189
|
+
storage.setItem(key, String(Math.floor(best)));
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
/** 浏览器环境下的默认 Storage(找不到返回 null,让 core 在无 DOM 环境也能跑)。 */
|
|
193
|
+
function tryGetGlobalStorage() {
|
|
194
|
+
if (typeof globalThis === "undefined") return null;
|
|
195
|
+
return globalThis.localStorage ?? null;
|
|
196
|
+
}
|
|
197
|
+
// src/game.ts
|
|
198
|
+
var DEFAULT_SIZE = 4;
|
|
199
|
+
var DEFAULT_TARGET = 2048;
|
|
200
|
+
var DEFAULT_UNDO_LIMIT = 20;
|
|
201
|
+
var CURSORS = {
|
|
202
|
+
left: {
|
|
203
|
+
start: (k) => ({
|
|
204
|
+
r: k,
|
|
205
|
+
c: 0
|
|
206
|
+
}),
|
|
207
|
+
step: {
|
|
208
|
+
dr: 0,
|
|
209
|
+
dc: 1
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
right: {
|
|
213
|
+
start: (k, size) => ({
|
|
214
|
+
r: k,
|
|
215
|
+
c: size - 1
|
|
216
|
+
}),
|
|
217
|
+
step: {
|
|
218
|
+
dr: 0,
|
|
219
|
+
dc: -1
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
up: {
|
|
223
|
+
start: (k) => ({
|
|
224
|
+
r: 0,
|
|
225
|
+
c: k
|
|
226
|
+
}),
|
|
227
|
+
step: {
|
|
228
|
+
dr: 1,
|
|
229
|
+
dc: 0
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
down: {
|
|
233
|
+
start: (k, size) => ({
|
|
234
|
+
r: size - 1,
|
|
235
|
+
c: k
|
|
236
|
+
}),
|
|
237
|
+
step: {
|
|
238
|
+
dr: -1,
|
|
239
|
+
dc: 0
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
function makeGrid(size) {
|
|
244
|
+
return Array.from({ length: size }, () => Array(size).fill(null));
|
|
245
|
+
}
|
|
246
|
+
var Game2048 = class {
|
|
247
|
+
constructor(opts = {}) {
|
|
248
|
+
this.score = 0;
|
|
249
|
+
this.bestScore = 0;
|
|
250
|
+
this.status = "playing";
|
|
251
|
+
this.keepPlaying = false;
|
|
252
|
+
this.moveCount = 0;
|
|
253
|
+
this.nextTileId = 1;
|
|
254
|
+
this.history = [];
|
|
255
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
256
|
+
this.notifying = false;
|
|
257
|
+
this.size = opts.size ?? DEFAULT_SIZE;
|
|
258
|
+
this.target = opts.target ?? DEFAULT_TARGET;
|
|
259
|
+
this.undoLimit = opts.undoLimit ?? DEFAULT_UNDO_LIMIT;
|
|
260
|
+
this.rng = opts.rng ?? Math.random;
|
|
261
|
+
this.storage = opts.storage === void 0 ? tryGetGlobalStorage() : opts.storage;
|
|
262
|
+
this.saveKey = opts.saveKey ?? "game-2048:save";
|
|
263
|
+
this.bestKey = opts.bestKey ?? "game-2048:best";
|
|
264
|
+
this.bestScore = loadBest(this.storage, this.bestKey);
|
|
265
|
+
this.grid = makeGrid(this.size);
|
|
266
|
+
const saved = opts.freshStart ? null : loadSave(this.storage, this.saveKey);
|
|
267
|
+
if (saved && saved.size === this.size) {
|
|
268
|
+
this.loadFromSave(saved);
|
|
269
|
+
if (!canMove(this.toValueGrid())) this.status = "lost";
|
|
270
|
+
} else this.startNewGame();
|
|
271
|
+
}
|
|
272
|
+
/** 订阅状态变化。返回退订函数。注册后立刻收到一次当前快照。 */
|
|
273
|
+
subscribe(fn) {
|
|
274
|
+
this.listeners.add(fn);
|
|
275
|
+
try {
|
|
276
|
+
fn(this.getState());
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error("[game-2048] listener threw on subscribe:", err);
|
|
279
|
+
}
|
|
280
|
+
return () => {
|
|
281
|
+
this.listeners.delete(fn);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
getState() {
|
|
285
|
+
return {
|
|
286
|
+
size: this.size,
|
|
287
|
+
tiles: this.collectTiles().map((t) => ({ ...t })),
|
|
288
|
+
score: this.score,
|
|
289
|
+
bestScore: this.bestScore,
|
|
290
|
+
status: this.status,
|
|
291
|
+
keepPlaying: this.keepPlaying,
|
|
292
|
+
moveCount: this.moveCount,
|
|
293
|
+
historySize: this.history.length
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** 拿到当前值网格(AI / 单测用);不影响游戏内部。 */
|
|
297
|
+
getGridValues() {
|
|
298
|
+
return this.toValueGrid();
|
|
299
|
+
}
|
|
300
|
+
/** 是否还可以撤销。 */
|
|
301
|
+
canUndo() {
|
|
302
|
+
return this.history.length > 0;
|
|
303
|
+
}
|
|
304
|
+
/** 沿方向滑动一次。返回 true = 棋盘真的发生了变化;false = 该方向无解。 */
|
|
305
|
+
move(dir) {
|
|
306
|
+
if (this.status === "lost") return false;
|
|
307
|
+
if (this.status === "won" && !this.keepPlaying) return false;
|
|
308
|
+
const snap = this.takeSnapshot();
|
|
309
|
+
for (const row of this.grid) for (const t of row) if (t) {
|
|
310
|
+
t.isNew = false;
|
|
311
|
+
t.merged = false;
|
|
312
|
+
}
|
|
313
|
+
const { moved, gained } = this.executeSlide(dir);
|
|
314
|
+
if (!moved) return false;
|
|
315
|
+
this.score += gained;
|
|
316
|
+
this.pushHistory(snap);
|
|
317
|
+
this.moveCount += 1;
|
|
318
|
+
if (!this.keepPlaying && this.hasValueAtLeast(this.target)) this.status = "won";
|
|
319
|
+
this.spawnRandomTile();
|
|
320
|
+
if (!canMove(this.toValueGrid())) this.status = "lost";
|
|
321
|
+
this.commit();
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
/** 达到 2048 后用户点击「继续游戏」。 */
|
|
325
|
+
continuePlaying() {
|
|
326
|
+
if (this.status !== "won") return;
|
|
327
|
+
this.keepPlaying = true;
|
|
328
|
+
this.status = "playing";
|
|
329
|
+
this.commit();
|
|
330
|
+
}
|
|
331
|
+
/** 开启新局:清空 board + 重置 score;保留 bestScore。 */
|
|
332
|
+
newGame() {
|
|
333
|
+
this.startNewGame();
|
|
334
|
+
this.commit();
|
|
335
|
+
}
|
|
336
|
+
/** 撤销一步;返回是否成功(历史为空时 false)。 */
|
|
337
|
+
undo() {
|
|
338
|
+
const s = this.history.pop();
|
|
339
|
+
if (!s) return false;
|
|
340
|
+
this.restoreSnapshot(s);
|
|
341
|
+
this.commit();
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
/** 清除当前存档(不影响 bestScore)。 */
|
|
345
|
+
clearSaved() {
|
|
346
|
+
clearSave(this.storage, this.saveKey);
|
|
347
|
+
}
|
|
348
|
+
startNewGame() {
|
|
349
|
+
this.grid = makeGrid(this.size);
|
|
350
|
+
this.score = 0;
|
|
351
|
+
this.status = "playing";
|
|
352
|
+
this.keepPlaying = false;
|
|
353
|
+
this.moveCount = 0;
|
|
354
|
+
this.nextTileId = 1;
|
|
355
|
+
this.history = [];
|
|
356
|
+
this.spawnRandomTile();
|
|
357
|
+
this.spawnRandomTile();
|
|
358
|
+
}
|
|
359
|
+
spawnRandomTile() {
|
|
360
|
+
const empties = [];
|
|
361
|
+
for (let r = 0; r < this.size; r++) for (let c = 0; c < this.size; c++) if (!this.grid[r]?.[c]) empties.push([r, c]);
|
|
362
|
+
if (empties.length === 0) return;
|
|
363
|
+
const slot = empties[Math.min(empties.length - 1, Math.floor(this.rng() * empties.length))];
|
|
364
|
+
if (!slot) return;
|
|
365
|
+
const [r, c] = slot;
|
|
366
|
+
const value = this.rng() < .9 ? 2 : 4;
|
|
367
|
+
const tile = {
|
|
368
|
+
id: this.nextTileId++,
|
|
369
|
+
value,
|
|
370
|
+
row: r,
|
|
371
|
+
col: c,
|
|
372
|
+
isNew: true,
|
|
373
|
+
merged: false
|
|
374
|
+
};
|
|
375
|
+
const row = this.grid[r];
|
|
376
|
+
if (row) row[c] = tile;
|
|
377
|
+
}
|
|
378
|
+
executeSlide(dir) {
|
|
379
|
+
const cursors = CURSORS[dir];
|
|
380
|
+
let moved = false;
|
|
381
|
+
let gained = 0;
|
|
382
|
+
for (let k = 0; k < this.size; k++) {
|
|
383
|
+
const lineTiles = [];
|
|
384
|
+
const slotOfTile = /* @__PURE__ */ new Map();
|
|
385
|
+
for (let i = 0; i < this.size; i++) {
|
|
386
|
+
const { r, c } = this.cellAt(cursors, k, i);
|
|
387
|
+
const t = this.grid[r]?.[c];
|
|
388
|
+
if (t) {
|
|
389
|
+
lineTiles.push(t);
|
|
390
|
+
slotOfTile.set(t, i);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const result = [];
|
|
394
|
+
let idx = 0;
|
|
395
|
+
while (idx < lineTiles.length) {
|
|
396
|
+
const cur = lineTiles[idx];
|
|
397
|
+
const next = lineTiles[idx + 1];
|
|
398
|
+
if (!cur) break;
|
|
399
|
+
if (next && cur.value === next.value) {
|
|
400
|
+
cur.value *= 2;
|
|
401
|
+
cur.merged = true;
|
|
402
|
+
gained += cur.value;
|
|
403
|
+
result.push(cur);
|
|
404
|
+
idx += 2;
|
|
405
|
+
moved = true;
|
|
406
|
+
} else {
|
|
407
|
+
result.push(cur);
|
|
408
|
+
idx += 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (let i = 0; i < result.length; i++) {
|
|
412
|
+
const t = result[i];
|
|
413
|
+
if (!t) continue;
|
|
414
|
+
if (slotOfTile.get(t) !== i) moved = true;
|
|
415
|
+
}
|
|
416
|
+
for (let i = 0; i < this.size; i++) {
|
|
417
|
+
const { r, c } = this.cellAt(cursors, k, i);
|
|
418
|
+
const row = this.grid[r];
|
|
419
|
+
if (row) row[c] = null;
|
|
420
|
+
}
|
|
421
|
+
for (let i = 0; i < result.length; i++) {
|
|
422
|
+
const t = result[i];
|
|
423
|
+
if (!t) continue;
|
|
424
|
+
const { r, c } = this.cellAt(cursors, k, i);
|
|
425
|
+
const row = this.grid[r];
|
|
426
|
+
if (row) row[c] = t;
|
|
427
|
+
t.row = r;
|
|
428
|
+
t.col = c;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
moved,
|
|
433
|
+
gained
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
cellAt(cursors, k, i) {
|
|
437
|
+
const start = cursors.start(k, this.size);
|
|
438
|
+
return {
|
|
439
|
+
r: start.r + i * cursors.step.dr,
|
|
440
|
+
c: start.c + i * cursors.step.dc
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
collectTiles() {
|
|
444
|
+
const out = [];
|
|
445
|
+
for (const row of this.grid) for (const t of row) if (t) out.push(t);
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
toValueGrid() {
|
|
449
|
+
const g = Array.from({ length: this.size }, () => Array(this.size).fill(0));
|
|
450
|
+
for (const t of this.collectTiles()) g[t.row][t.col] = t.value;
|
|
451
|
+
return g;
|
|
452
|
+
}
|
|
453
|
+
hasValueAtLeast(v) {
|
|
454
|
+
for (const t of this.collectTiles()) if (t.value >= v) return true;
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
takeSnapshot() {
|
|
458
|
+
return {
|
|
459
|
+
tiles: this.collectTiles().map((t) => ({ ...t })),
|
|
460
|
+
score: this.score,
|
|
461
|
+
status: this.status,
|
|
462
|
+
keepPlaying: this.keepPlaying,
|
|
463
|
+
moveCount: this.moveCount
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
restoreSnapshot(s) {
|
|
467
|
+
this.grid = makeGrid(this.size);
|
|
468
|
+
let maxId = 0;
|
|
469
|
+
for (const t of s.tiles) {
|
|
470
|
+
const clone = { ...t };
|
|
471
|
+
const row = this.grid[clone.row];
|
|
472
|
+
if (row) row[clone.col] = clone;
|
|
473
|
+
if (clone.id > maxId) maxId = clone.id;
|
|
474
|
+
}
|
|
475
|
+
this.nextTileId = maxId + 1;
|
|
476
|
+
this.score = s.score;
|
|
477
|
+
this.status = s.status;
|
|
478
|
+
this.keepPlaying = s.keepPlaying;
|
|
479
|
+
this.moveCount = s.moveCount;
|
|
480
|
+
}
|
|
481
|
+
pushHistory(s) {
|
|
482
|
+
this.history.push(s);
|
|
483
|
+
if (this.history.length > this.undoLimit) this.history.shift();
|
|
484
|
+
}
|
|
485
|
+
loadFromSave(save) {
|
|
486
|
+
this.grid = makeGrid(this.size);
|
|
487
|
+
let maxId = 0;
|
|
488
|
+
for (const raw of save.tiles) {
|
|
489
|
+
const t = {
|
|
490
|
+
id: raw.id,
|
|
491
|
+
value: raw.value,
|
|
492
|
+
row: raw.row,
|
|
493
|
+
col: raw.col,
|
|
494
|
+
isNew: false,
|
|
495
|
+
merged: false
|
|
496
|
+
};
|
|
497
|
+
if (t.row >= 0 && t.row < this.size && t.col >= 0 && t.col < this.size) {
|
|
498
|
+
const row = this.grid[t.row];
|
|
499
|
+
if (row) row[t.col] = t;
|
|
500
|
+
}
|
|
501
|
+
if (t.id > maxId) maxId = t.id;
|
|
502
|
+
}
|
|
503
|
+
this.score = save.score;
|
|
504
|
+
this.status = save.status === "won" ? "won" : save.status === "lost" ? "lost" : "playing";
|
|
505
|
+
this.keepPlaying = save.keepPlaying;
|
|
506
|
+
this.moveCount = save.moveCount;
|
|
507
|
+
this.nextTileId = Math.max(maxId + 1, save.nextTileId ?? maxId + 1);
|
|
508
|
+
}
|
|
509
|
+
commit() {
|
|
510
|
+
if (this.score > this.bestScore) {
|
|
511
|
+
this.bestScore = this.score;
|
|
512
|
+
writeBest(this.storage, this.bestKey, this.bestScore);
|
|
513
|
+
}
|
|
514
|
+
writeSave(this.storage, this.saveKey, {
|
|
515
|
+
version: 1,
|
|
516
|
+
size: this.size,
|
|
517
|
+
tiles: this.collectTiles().map((t) => ({ ...t })),
|
|
518
|
+
score: this.score,
|
|
519
|
+
status: this.status,
|
|
520
|
+
keepPlaying: this.keepPlaying,
|
|
521
|
+
moveCount: this.moveCount,
|
|
522
|
+
nextTileId: this.nextTileId
|
|
523
|
+
});
|
|
524
|
+
this.notify();
|
|
525
|
+
}
|
|
526
|
+
notify() {
|
|
527
|
+
if (this.notifying) return;
|
|
528
|
+
this.notifying = true;
|
|
529
|
+
try {
|
|
530
|
+
const snapshot = this.getState();
|
|
531
|
+
for (const fn of this.listeners) try {
|
|
532
|
+
fn(snapshot);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.error("[game-2048] listener threw:", err);
|
|
535
|
+
}
|
|
536
|
+
} finally {
|
|
537
|
+
this.notifying = false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
// src/ai.ts
|
|
542
|
+
/**
|
|
543
|
+
* 2048 AI 求解器(expectimax + 蛇形权值评估)。
|
|
544
|
+
*
|
|
545
|
+
* 结构:
|
|
546
|
+
* - Player 节点:4 个方向里挑 best(跳过非法方向)。
|
|
547
|
+
* - Chance 节点:对每个空位分别按 0.9 / 0.1 概率注入 2 / 4,取期望值。
|
|
548
|
+
* - 叶子:静态评估函数(蛇形权值 + 空位数奖励)。
|
|
549
|
+
*
|
|
550
|
+
* 4×4 棋盘 depth=3 单步耗时 <10ms,能稳定打出 5 位数分数;depth=4 更聪明但
|
|
551
|
+
* 单次可能上百 ms,UI 层若要防卡帧可放到 Worker 里跑。
|
|
552
|
+
*/
|
|
553
|
+
var DIRS = [
|
|
554
|
+
"up",
|
|
555
|
+
"left",
|
|
556
|
+
"down",
|
|
557
|
+
"right"
|
|
558
|
+
];
|
|
559
|
+
/**
|
|
560
|
+
* 4×4 蛇形权重(top-left 为蛇头 = 最大):
|
|
561
|
+
* ```
|
|
562
|
+
* path (0,0)=15 (0,1)=14 (0,2)=13 (0,3)=12
|
|
563
|
+
* (1,0)= 8 (1,1)= 9 (1,2)=10 (1,3)=11
|
|
564
|
+
* (2,0)= 7 (2,1)= 6 (2,2)= 5 (2,3)= 4
|
|
565
|
+
* (3,0)= 0 (3,1)= 1 (3,2)= 2 (3,3)= 3
|
|
566
|
+
* ```
|
|
567
|
+
* 权重 = 2^path,越接近蛇头权越大。AI 由此把最大 tile 锁在左上角,
|
|
568
|
+
* 形成「left/up 收拢、right/down 保持单调」的稳定策略。
|
|
569
|
+
*/
|
|
570
|
+
var SNAKE_WEIGHTS_4 = [
|
|
571
|
+
[
|
|
572
|
+
32768,
|
|
573
|
+
16384,
|
|
574
|
+
8192,
|
|
575
|
+
4096
|
|
576
|
+
],
|
|
577
|
+
[
|
|
578
|
+
256,
|
|
579
|
+
512,
|
|
580
|
+
1024,
|
|
581
|
+
2048
|
|
582
|
+
],
|
|
583
|
+
[
|
|
584
|
+
128,
|
|
585
|
+
64,
|
|
586
|
+
32,
|
|
587
|
+
16
|
|
588
|
+
],
|
|
589
|
+
[
|
|
590
|
+
1,
|
|
591
|
+
2,
|
|
592
|
+
4,
|
|
593
|
+
8
|
|
594
|
+
]
|
|
595
|
+
];
|
|
596
|
+
var NEW_TILE_PROBS = [[2, .9], [4, .1]];
|
|
597
|
+
/** 通用蛇形权重(3/4/5/6 尺寸;沿 S 形折返,蛇头在 (0, 0))。 */
|
|
598
|
+
function buildWeights(size) {
|
|
599
|
+
if (size === 4) return SNAKE_WEIGHTS_4;
|
|
600
|
+
const cells = size * size;
|
|
601
|
+
const out = Array.from({ length: size }, () => Array(size).fill(0));
|
|
602
|
+
let path = cells - 1;
|
|
603
|
+
for (let r = 0; r < size; r++) {
|
|
604
|
+
const leftToRight = r % 2 === 0;
|
|
605
|
+
for (let i = 0; i < size; i++) {
|
|
606
|
+
const c = leftToRight ? i : size - 1 - i;
|
|
607
|
+
out[r][c] = path;
|
|
608
|
+
path--;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) out[r][c] = (out[r][c] ?? 0) / cells;
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
/** 静态评估:值越靠近「蛇头」越大越好;空位多给额外奖励。 */
|
|
615
|
+
function evaluate(grid, weights) {
|
|
616
|
+
let s = 0;
|
|
617
|
+
let empty = 0;
|
|
618
|
+
let max = 0;
|
|
619
|
+
for (let r = 0; r < grid.length; r++) {
|
|
620
|
+
const row = grid[r];
|
|
621
|
+
const w = weights[r];
|
|
622
|
+
if (!row || !w) continue;
|
|
623
|
+
for (let c = 0; c < row.length; c++) {
|
|
624
|
+
const v = row[c] ?? 0;
|
|
625
|
+
if (v === 0) empty++;
|
|
626
|
+
else {
|
|
627
|
+
s += Math.log2(v) * (w[c] ?? 0);
|
|
628
|
+
if (v > max) max = v;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return s * 1e3 + empty * 200 + Math.log2(max || 1);
|
|
633
|
+
}
|
|
634
|
+
/** 单步的期望值搜索(player 视角)。 */
|
|
635
|
+
function expectimax(grid, depth, myTurn, weights) {
|
|
636
|
+
if (!canMove(grid)) return -Infinity;
|
|
637
|
+
if (depth === 0) return evaluate(grid, weights);
|
|
638
|
+
if (myTurn) {
|
|
639
|
+
let best = -Infinity;
|
|
640
|
+
for (const dir of DIRS) {
|
|
641
|
+
const { grid: next } = slideGrid(grid, dir);
|
|
642
|
+
if (gridsEqual(grid, next)) continue;
|
|
643
|
+
const v = expectimax(next, depth - 1, false, weights);
|
|
644
|
+
if (v > best) best = v;
|
|
645
|
+
}
|
|
646
|
+
return best === -Infinity ? evaluate(grid, weights) : best;
|
|
647
|
+
}
|
|
648
|
+
const empties = emptyCells(grid);
|
|
649
|
+
if (empties.length === 0) return evaluate(grid, weights);
|
|
650
|
+
let sum = 0;
|
|
651
|
+
const perCell = 1 / empties.length;
|
|
652
|
+
for (const [r, c] of empties) for (const [v, p] of NEW_TILE_PROBS) {
|
|
653
|
+
const g2 = cloneGrid(grid);
|
|
654
|
+
g2[r][c] = v;
|
|
655
|
+
sum += perCell * p * expectimax(g2, depth - 1, true, weights);
|
|
656
|
+
}
|
|
657
|
+
return sum;
|
|
658
|
+
}
|
|
659
|
+
/** 返回当前局面下最优方向;无解时 null。 */
|
|
660
|
+
function bestMove(grid, opts = {}) {
|
|
661
|
+
const depth = opts.depth ?? 3;
|
|
662
|
+
if (!canMove(grid)) return null;
|
|
663
|
+
const weights = buildWeights(grid.length);
|
|
664
|
+
let best = null;
|
|
665
|
+
let bestScore = -Infinity;
|
|
666
|
+
for (const dir of DIRS) {
|
|
667
|
+
const { grid: next } = slideGrid(grid, dir);
|
|
668
|
+
if (gridsEqual(grid, next)) continue;
|
|
669
|
+
const v = expectimax(next, depth - 1, false, weights);
|
|
670
|
+
if (v > bestScore) {
|
|
671
|
+
bestScore = v;
|
|
672
|
+
best = dir;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return best;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* 让 AI 自动跑完整局,返回结束时的评估值。用于单测/基准。
|
|
679
|
+
*
|
|
680
|
+
* 起点若为完全空盘(如 `createEmptyGrid`),会先按真实开局规则补两枚 2/4,
|
|
681
|
+
* 否则空盘 canMove=false 会立刻退出。
|
|
682
|
+
*/
|
|
683
|
+
function simulateUntilEnd(startGrid, opts = {}) {
|
|
684
|
+
const grid = cloneGrid(startGrid);
|
|
685
|
+
const depth = opts.depth ?? 3;
|
|
686
|
+
const defaultSpawn = (empties) => {
|
|
687
|
+
if (empties.length === 0) return null;
|
|
688
|
+
const cell = empties[Math.floor(Math.random() * empties.length)];
|
|
689
|
+
if (!cell) return null;
|
|
690
|
+
const [r, c] = cell;
|
|
691
|
+
return {
|
|
692
|
+
r,
|
|
693
|
+
c,
|
|
694
|
+
v: Math.random() < .9 ? 2 : 4
|
|
695
|
+
};
|
|
696
|
+
};
|
|
697
|
+
const spawnFn = opts.spawn ?? defaultSpawn;
|
|
698
|
+
const placeInitial = () => {
|
|
699
|
+
const empties = emptyCells(grid);
|
|
700
|
+
const pick = spawnFn(empties);
|
|
701
|
+
if (pick) grid[pick.r][pick.c] = pick.v;
|
|
702
|
+
};
|
|
703
|
+
if (emptyCells(grid).length === grid.length * grid.length) {
|
|
704
|
+
placeInitial();
|
|
705
|
+
placeInitial();
|
|
706
|
+
}
|
|
707
|
+
let steps = 0;
|
|
708
|
+
const cap = opts.maxSteps ?? 5e3;
|
|
709
|
+
while (steps < cap) {
|
|
710
|
+
const dir = bestMove(grid, { depth });
|
|
711
|
+
if (!dir) break;
|
|
712
|
+
const { grid: after } = slideGrid(grid, dir);
|
|
713
|
+
const empties = emptyCells(after);
|
|
714
|
+
if (empties.length > 0) {
|
|
715
|
+
const pick = spawnFn(empties);
|
|
716
|
+
if (pick) after[pick.r][pick.c] = pick.v;
|
|
717
|
+
}
|
|
718
|
+
grid.splice(0, grid.length, ...after);
|
|
719
|
+
steps++;
|
|
720
|
+
if (!canMove(grid)) break;
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
grid,
|
|
724
|
+
steps
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
export { DEFAULT_BEST_KEY, DEFAULT_SAVE_KEY, Game2048, SAVE_VERSION, bestMove, canMove, clearSave, cloneGrid, createEmptyGrid, emptyCells, evaluate, gridsEqual, hasValue, isSerializedSave, loadBest, loadSave, maxValue, simulateUntilEnd, slideGrid, slideLineLeft, tryGetGlobalStorage, writeBest, writeSave };
|
|
728
|
+
|
|
13
729
|
//# sourceMappingURL=index.js.map
|