index.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  6. <title>2048</title>
  7. <style>
  8. * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  9. html, body {
  10. height: 100%;
  11. font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif;
  12. background: #faf8ef;
  13. color: #776e65;
  14. display: flex;
  15. justify-content: center;
  16. align-items: center;
  17. overflow: hidden;
  18. }
  19. .container {
  20. width: min(92vw, 480px);
  21. position: relative;
  22. }
  23. /* 顶部栏:标题 + 分数 */
  24. .header {
  25. display: flex;
  26. justify-content: space-between;
  27. align-items: center;
  28. margin-bottom: 14px;
  29. flex-wrap: wrap;
  30. gap: 10px;
  31. }
  32. .title {
  33. font-size: clamp(40px, 11vw, 64px);
  34. font-weight: 800;
  35. color: #776e65;
  36. line-height: 1;
  37. }
  38. .scores { display: flex; gap: 8px; }
  39. .score-box {
  40. background: #bbada0;
  41. border-radius: 6px;
  42. padding: 8px 16px;
  43. text-align: center;
  44. min-width: 78px;
  45. }
  46. .score-label {
  47. color: #eee4da;
  48. font-size: 11px;
  49. font-weight: 700;
  50. text-transform: uppercase;
  51. letter-spacing: 1px;
  52. }
  53. .score-value { color: #fff; font-size: 22px; font-weight: 800; }
  54. .score-delta {
  55. position: absolute;
  56. color: rgba(119,110,101,.9);
  57. font-size: 20px;
  58. font-weight: 800;
  59. animation: deltaUp .6s ease forwards;
  60. pointer-events: none;
  61. }
  62. @keyframes deltaUp { 0%{opacity:.9;transform:translateY(0)} 100%{opacity:0;transform:translateY(-32px)} }
  63. /* 说明 + 新游戏按钮 */
  64. .bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; gap: 12px; }
  65. .intro { font-size: 13px; line-height: 1.5; flex: 1; }
  66. .btn {
  67. background: #8f7a66;
  68. color: #f9f6f2;
  69. border: none;
  70. border-radius: 6px;
  71. padding: 10px 18px;
  72. font-size: 14px;
  73. font-weight: 700;
  74. cursor: pointer;
  75. transition: background .15s;
  76. white-space: nowrap;
  77. }
  78. .btn:hover { background: #7c6655; }
  79. /* 棋盘 */
  80. .board-wrap { position: relative; width: 100%; }
  81. .grid {
  82. background: #bbada0;
  83. border-radius: 8px;
  84. padding: 10px;
  85. display: grid;
  86. grid-template-columns: repeat(4, 1fr);
  87. grid-template-rows: repeat(4, 1fr);
  88. gap: 10px;
  89. aspect-ratio: 1 / 1;
  90. position: relative;
  91. touch-action: none;
  92. }
  93. .cell-bg {
  94. background: rgba(238, 228, 218, 0.35);
  95. border-radius: 6px;
  96. }
  97. /* 方块层(绝对定位覆盖在网格上) */
  98. .tile-layer {
  99. position: absolute;
  100. inset: 10px;
  101. pointer-events: none;
  102. }
  103. .tile {
  104. position: absolute;
  105. width: calc((100% - 30px) / 4);
  106. height: calc((100% - 30px) / 4);
  107. border-radius: 6px;
  108. display: flex;
  109. align-items: center;
  110. justify-content: center;
  111. font-weight: 800;
  112. transition: transform .12s ease;
  113. line-height: 1;
  114. }
  115. .tile-new { animation: pop .18s ease; }
  116. .tile-merged { animation: merge .18s ease; z-index: 2; }
  117. @keyframes pop { 0%{transform:var(--pos) scale(0)} 60%{transform:var(--pos) scale(1.1)} 100%{transform:var(--pos) scale(1)} }
  118. @keyframes merge { 0%{transform:var(--pos) scale(1)} 50%{transform:var(--pos) scale(1.18)} 100%{transform:var(--pos) scale(1)} }
  119. /* 方块配色 */
  120. .t2{background:#eee4da;color:#776e65;font-size:clamp(24px,7vw,40px)}
  121. .t4{background:#ede0c8;color:#776e65;font-size:clamp(24px,7vw,40px)}
  122. .t8{background:#f2b179;color:#f9f6f2;font-size:clamp(24px,7vw,40px)}
  123. .t16{background:#f59563;color:#f9f6f2;font-size:clamp(22px,6.5vw,38px)}
  124. .t32{background:#f67c5f;color:#f9f6f2;font-size:clamp(22px,6.5vw,38px)}
  125. .t64{background:#f65e3b;color:#f9f6f2;font-size:clamp(22px,6.5vw,38px)}
  126. .t128{background:#edcf72;color:#f9f6f2;font-size:clamp(20px,5.5vw,34px);box-shadow:0 0 12px rgba(243,215,116,.6)}
  127. .t256{background:#edcc61;color:#f9f6f2;font-size:clamp(20px,5.5vw,34px);box-shadow:0 0 14px rgba(243,215,116,.7)}
  128. .t512{background:#edc850;color:#f9f6f2;font-size:clamp(20px,5.5vw,34px);box-shadow:0 0 16px rgba(243,215,116,.8)}
  129. .t1024{background:#edc53f;color:#f9f6f2;font-size:clamp(18px,4.5vw,30px);box-shadow:0 0 18px rgba(243,215,116,.9)}
  130. .t2048{background:#edc22e;color:#f9f6f2;font-size:clamp(18px,4.5vw,30px);box-shadow:0 0 22px rgba(243,215,116,1)}
  131. .t-super{background:#3c3a32;color:#f9f6f2;font-size:clamp(16px,4vw,28px)}
  132. /* 遮罩(胜利/失败) */
  133. .overlay {
  134. position: absolute;
  135. inset: 0;
  136. background: rgba(238,228,218,.75);
  137. border-radius: 8px;
  138. display: none;
  139. flex-direction: column;
  140. align-items: center;
  141. justify-content: center;
  142. z-index: 10;
  143. backdrop-filter: blur(2px);
  144. }
  145. .overlay.show { display: flex; animation: fadeIn .4s ease; }
  146. @keyframes fadeIn { from{opacity:0} to{opacity:1} }
  147. .overlay h2 { font-size: 40px; font-weight: 800; margin-bottom: 20px; }
  148. .overlay.win h2 { color: #edc22e; }
  149. .overlay.lose h2 { color: #776e65; }
  150. .overlay .btn { font-size: 16px; padding: 12px 24px; }
  151. .footer { text-align: center; margin-top: 14px; font-size: 12px; color: #bbada0; }
  152. </style>
  153. </head>
  154. <body>
  155. <div class="container">
  156. <div class="header">
  157. <div class="title">2048</div>
  158. <div class="scores">
  159. <div class="score-box"><div class="score-label">分数</div><div class="score-value" id="score">0</div></div>
  160. <div class="score-box"><div class="score-label">最高</div><div class="score-value" id="best">0</div></div>
  161. </div>
  162. </div>
  163. <div class="bar">
  164. <div class="intro"><b>玩法:</b>合并相同数字方块,拼出 2048!<br>键盘方向键/WASD 或手指滑动操作。</div>
  165. <button class="btn" id="newGame">新游戏</button>
  166. </div>
  167. <div class="board-wrap">
  168. <div class="grid" id="grid">
  169. <!-- 16 个背景格 -->
  170. <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div>
  171. <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div>
  172. <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div>
  173. <div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div><div class="cell-bg"></div>
  174. </div>
  175. <div class="tile-layer" id="tileLayer"></div>
  176. <div class="overlay" id="overlay">
  177. <h2 id="overlayText"></h2>
  178. <button class="btn" id="overlayBtn">再来一局</button>
  179. </div>
  180. </div>
  181. <div class="footer">用方向键 / WASD / 滑动来游玩</div>
  182. </div>
  183. <script>
  184. (function () {
  185. 'use strict';
  186. const SIZE = 4;
  187. const WIN_VAL = 2048;
  188. let grid, score, best, hasWon, movedThisTurn, animating;
  189. const $ = (id) => document.getElementById(id);
  190. const tileLayer = $('tileLayer');
  191. const overlay = $('overlay');
  192. const overlayText = $('overlayText');
  193. const scoreEl = $('score');
  194. const bestEl = $('best');
  195. /* ---------- 核心数据 ---------- */
  196. function emptyGrid() { return Array.from({ length: SIZE }, () => Array(SIZE).fill(0)); }
  197. function clone(g) { return g.map(r => r.slice()); }
  198. // 在空格随机放一个 2(90%) 或 4(10%)
  199. function addRandom(g) {
  200. const empty = [];
  201. for (let r = 0; r < SIZE; r++)
  202. for (let c = 0; c < SIZE; c++)
  203. if (g[r][c] === 0) empty.push([r, c]);
  204. if (empty.length === 0) return false;
  205. const [r, c] = empty[Math.floor(Math.random() * empty.length)];
  206. g[r][c] = Math.random() < 0.9 ? 2 : 4;
  207. return true;
  208. }
  209. /* ---------- 移动 / 合并 ---------- */
  210. // 向左移动一行,返回 {row, gained, moved}
  211. function slideLeftRow(row) {
  212. const filtered = row.filter(v => v !== 0);
  213. const merged = [];
  214. let gained = 0;
  215. for (let i = 0; i < filtered.length; i++) {
  216. if (i + 1 < filtered.length && filtered[i] === filtered[i + 1]) {
  217. const val = filtered[i] * 2;
  218. merged.push(val);
  219. gained += val;
  220. i++; // 跳过下一个
  221. } else {
  222. merged.push(filtered[i]);
  223. }
  224. }
  225. while (merged.length < SIZE) merged.push(0);
  226. const moved = row.some((v, i) => v !== merged[i]);
  227. return { row: merged, gained, moved };
  228. }
  229. function transpose(g) {
  230. return g[0].map((_, c) => g.map(r => r[c]));
  231. }
  232. function reverseRows(g) { return g.map(r => r.slice().reverse()); }
  233. // direction: 'left' | 'right' | 'up' | 'down'
  234. function move(g, direction) {
  235. let work = clone(g);
  236. if (direction === 'up') work = transpose(work);
  237. if (direction === 'down') work = reverseRows(transpose(work));
  238. if (direction === 'right') work = reverseRows(work);
  239. let gained = 0, moved = false;
  240. const result = work.map(row => {
  241. const res = slideLeftRow(row);
  242. gained += res.gained;
  243. if (res.moved) moved = true;
  244. return res.row;
  245. });
  246. let final = result;
  247. if (direction === 'up') final = transpose(result);
  248. if (direction === 'down') final = transpose(reverseRows(result));
  249. if (direction === 'right') final = reverseRows(result);
  250. return { grid: final, gained, moved };
  251. }
  252. /* ---------- 状态判断 ---------- */
  253. function canMove(g) {
  254. for (let r = 0; r < SIZE; r++)
  255. for (let c = 0; c < SIZE; c++) {
  256. if (g[r][c] === 0) return true;
  257. if (c + 1 < SIZE && g[r][c] === g[r][c + 1]) return true;
  258. if (r + 1 < SIZE && g[r][c] === g[r + 1][c]) return true;
  259. }
  260. return false;
  261. }
  262. function hasValue(g, v) {
  263. return g.some(row => row.some(cell => cell >= v));
  264. }
  265. /* ---------- 渲染 ---------- */
  266. function tileClass(v) {
  267. if (v <= 2048) return 't' + v;
  268. return 't-super';
  269. }
  270. // 计算方块在 tile-layer 内的坐标位置(百分比)
  271. function pos(r, c) {
  272. const pct = (idx) => `calc(${idx} * (100% + 10px) / 4)`; // gap=10px
  273. return { left: pct(c), top: pct(r) };
  274. }
  275. function render(opts) {
  276. opts = opts || {};
  277. const newCells = opts.newCells || [];
  278. const mergedCells = opts.mergedCells || [];
  279. tileLayer.innerHTML = '';
  280. for (let r = 0; r < SIZE; r++) {
  281. for (let c = 0; c < SIZE; c++) {
  282. const v = grid[r][c];
  283. if (v === 0) continue;
  284. const t = document.createElement('div');
  285. const isNew = newCells.some(p => p[0] === r && p[1] === c);
  286. const isMerged = mergedCells.some(p => p[0] === r && p[1] === c);
  287. t.className = 'tile ' + tileClass(v);
  288. if (isNew) t.classList.add('tile-new');
  289. if (isMerged) t.classList.add('tile-merged');
  290. const p = pos(r, c);
  291. // 用 CSS 变量统一控制位置,配合 scale 动画
  292. const tr = `translate(${p.left}, ${p.top})`;
  293. t.style.setProperty('--pos', tr);
  294. t.style.left = '0';
  295. t.style.top = '0';
  296. t.style.transform = tr;
  297. t.textContent = v;
  298. tileLayer.appendChild(t);
  299. }
  300. }
  301. }
  302. function showDelta(d) {
  303. if (d <= 0) return;
  304. const el = document.createElement('div');
  305. el.className = 'score-delta';
  306. el.textContent = '+' + d;
  307. const box = $('score').parentElement;
  308. el.style.left = '50%';
  309. el.style.top = '0';
  310. el.style.transform = 'translateX(-50%)';
  311. box.style.position = 'relative';
  312. box.appendChild(el);
  313. setTimeout(() => el.remove(), 600);
  314. }
  315. function updateScore() {
  316. scoreEl.textContent = score;
  317. if (score > best) {
  318. best = score;
  319. bestEl.textContent = best;
  320. localStorage.setItem('best2048', best);
  321. }
  322. }
  323. function showOverlay(type) {
  324. overlay.className = 'overlay show ' + type;
  325. overlayText.textContent = type === 'win' ? '🎉 你赢了!' : '游戏结束';
  326. $('overlayBtn').textContent = type === 'win' ? '继续挑战' : '再来一局';
  327. }
  328. function hideOverlay() { overlay.className = 'overlay'; }
  329. /* ---------- 跟踪合并位置(用于动画高亮) ---------- */
  330. function trackMerges(prevGrid, newGrid) {
  331. // 近似:值翻倍且非新生成的格子视为合并目标
  332. const merged = [];
  333. for (let r = 0; r < SIZE; r++)
  334. for (let c = 0; c < SIZE; c++) {
  335. const pv = prevGrid[r][c];
  336. const nv = newGrid[r][c];
  337. if (nv !== 0 && pv !== nv && nv % 2 === 0 && (pv === 0 || nv === pv * 2)) {
  338. // 粗略合并点标记,用于动画
  339. merged.push([r, c]);
  340. }
  341. }
  342. return merged;
  343. }
  344. /* ---------- 一次操作流程 ---------- */
  345. function handleMove(direction) {
  346. if (animating || overlay.classList.contains('show')) return;
  347. const prev = clone(grid);
  348. const res = move(grid, direction);
  349. if (!res.moved) return;
  350. animating = true;
  351. grid = res.grid;
  352. if (res.gained > 0) {
  353. score += res.gained;
  354. showDelta(res.gained);
  355. }
  356. updateScore();
  357. const mergedCells = trackMerges(prev, grid);
  358. render({ mergedCells });
  359. setTimeout(() => {
  360. // 记录加方块前
  361. const before = clone(grid);
  362. addRandom(grid);
  363. // 新方块 = before 为0但 grid 非0 且值<=4
  364. const newCells = [];
  365. for (let r = 0; r < SIZE; r++)
  366. for (let c = 0; c < SIZE; c++)
  367. if (before[r][c] === 0 && grid[r][c] !== 0) newCells.push([r, c]);
  368. render({ newCells, mergedCells });
  369. animating = false;
  370. if (!hasWon && hasValue(grid, WIN_VAL)) {
  371. hasWon = true;
  372. showOverlay('win');
  373. } else if (!canMove(grid)) {
  374. showOverlay('lose');
  375. }
  376. }, 120);
  377. }
  378. /* ---------- 新游戏 ---------- */
  379. function newGame() {
  380. grid = emptyGrid();
  381. score = 0;
  382. hasWon = false;
  383. animating = false;
  384. hideOverlay();
  385. addRandom(grid);
  386. addRandom(grid);
  387. updateScore();
  388. // 标记初始两个方块为新方块动画
  389. const newCells = [];
  390. for (let r = 0; r < SIZE; r++)
  391. for (let c = 0; c < SIZE; c++)
  392. if (grid[r][c] !== 0) newCells.push([r, c]);
  393. render({ newCells });
  394. }
  395. /* ---------- 输入 ---------- */
  396. const keyMap = {
  397. ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down',
  398. a: 'left', d: 'right', w: 'up', s: 'down',
  399. A: 'left', D: 'right', W: 'up', S: 'down',
  400. };
  401. document.addEventListener('keydown', (e) => {
  402. const dir = keyMap[e.key];
  403. if (dir) { e.preventDefault(); handleMove(dir); }
  404. });
  405. // 触摸滑动
  406. const gridEl = $('grid');
  407. let tsx = 0, tsy = 0;
  408. gridEl.addEventListener('touchstart', (e) => {
  409. const t = e.touches[0];
  410. tsx = t.clientX; tsy = t.clientY;
  411. }, { passive: true });
  412. gridEl.addEventListener('touchend', (e) => {
  413. const t = e.changedTouches[0];
  414. const dx = t.clientX - tsx, dy = t.clientY - tsy;
  415. const ax = Math.abs(dx), ay = Math.abs(dy);
  416. if (Math.max(ax, ay) < 24) return;
  417. if (ax > ay) handleMove(dx > 0 ? 'right' : 'left');
  418. else handleMove(dy > 0 ? 'down' : 'up');
  419. }, { passive: true });
  420. // 按钮
  421. $('newGame').addEventListener('click', newGame);
  422. $('overlayBtn').addEventListener('click', () => {
  423. if (overlay.classList.contains('win')) {
  424. // 继续:仅关闭遮罩
  425. hideOverlay();
  426. } else {
  427. newGame();
  428. }
  429. });
  430. /* ---------- 启动 ---------- */
  431. best = parseInt(localStorage.getItem('best2048') || '0', 10);
  432. bestEl.textContent = best;
  433. newGame();
  434. })();
  435. </script>
  436. </body>
  437. </html>