index.html 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  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, maximum-scale=1.0, user-scalable=no">
  6. <title>俄罗斯方块</title>
  7. <style>
  8. * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
  9. html, body { height: 100%; }
  10. body {
  11. background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
  12. color: #e8e8f0;
  13. font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  14. display: flex;
  15. align-items: center;
  16. justify-content: center;
  17. min-height: 100vh;
  18. overflow: auto;
  19. user-select: none;
  20. -webkit-user-select: none;
  21. touch-action: manipulation;
  22. }
  23. .app {
  24. display: flex;
  25. gap: 24px;
  26. padding: 20px;
  27. align-items: flex-start;
  28. flex-wrap: wrap;
  29. justify-content: center;
  30. }
  31. .board-wrap {
  32. position: relative;
  33. background: rgba(0,0,0,0.35);
  34. border: 2px solid rgba(255,255,255,0.12);
  35. border-radius: 12px;
  36. padding: 12px;
  37. box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 0 30px rgba(0,0,0,0.4);
  38. }
  39. canvas#board {
  40. display: block;
  41. background: #0a0a18;
  42. border-radius: 6px;
  43. image-rendering: pixelated;
  44. }
  45. .side {
  46. display: flex;
  47. flex-direction: column;
  48. gap: 16px;
  49. min-width: 180px;
  50. }
  51. .panel {
  52. background: rgba(0,0,0,0.35);
  53. border: 2px solid rgba(255,255,255,0.12);
  54. border-radius: 12px;
  55. padding: 14px 16px;
  56. box-shadow: 0 10px 30px rgba(0,0,0,0.35);
  57. }
  58. .panel h3 {
  59. font-size: 12px;
  60. letter-spacing: 2px;
  61. color: #8a8ab0;
  62. text-transform: uppercase;
  63. margin-bottom: 10px;
  64. }
  65. .score {
  66. font-size: 28px;
  67. font-weight: 700;
  68. color: #ffd86b;
  69. text-shadow: 0 0 12px rgba(255,216,107,0.4);
  70. font-variant-numeric: tabular-nums;
  71. }
  72. .stats-row {
  73. display: flex;
  74. justify-content: space-between;
  75. font-size: 14px;
  76. margin-top: 8px;
  77. }
  78. .stats-row span:last-child {
  79. color: #7ee8ff;
  80. font-weight: 600;
  81. font-variant-numeric: tabular-nums;
  82. }
  83. .next-canvas-wrap {
  84. display: flex;
  85. justify-content: center;
  86. align-items: center;
  87. background: #0a0a18;
  88. border-radius: 6px;
  89. height: 110px;
  90. }
  91. canvas#next {
  92. background: #0a0a18;
  93. border-radius: 6px;
  94. }
  95. .keys {
  96. font-size: 12px;
  97. line-height: 1.9;
  98. }
  99. .keys kbd {
  100. display: inline-block;
  101. min-width: 22px;
  102. text-align: center;
  103. background: rgba(255,255,255,0.08);
  104. border: 1px solid rgba(255,255,255,0.18);
  105. border-bottom-width: 2px;
  106. border-radius: 4px;
  107. padding: 1px 6px;
  108. margin-right: 6px;
  109. font-family: 'Consolas', 'Monaco', monospace;
  110. font-size: 11px;
  111. color: #fff;
  112. }
  113. .keys .row { display: flex; align-items: center; }
  114. .overlay {
  115. position: absolute;
  116. inset: 12px;
  117. background: rgba(10, 10, 24, 0.85);
  118. backdrop-filter: blur(4px);
  119. border-radius: 6px;
  120. display: flex;
  121. flex-direction: column;
  122. align-items: center;
  123. justify-content: center;
  124. text-align: center;
  125. padding: 20px;
  126. z-index: 10;
  127. transition: opacity 0.2s;
  128. }
  129. .overlay.hidden { display: none; }
  130. .overlay h2 {
  131. font-size: 32px;
  132. margin-bottom: 14px;
  133. letter-spacing: 4px;
  134. }
  135. .overlay p {
  136. color: #b0b0c8;
  137. margin-bottom: 8px;
  138. font-size: 14px;
  139. }
  140. .overlay .final-score {
  141. font-size: 40px;
  142. color: #ffd86b;
  143. font-weight: 700;
  144. margin: 10px 0;
  145. text-shadow: 0 0 20px rgba(255,216,107,0.5);
  146. }
  147. .btn {
  148. background: linear-gradient(135deg, #7ee8ff, #5b8cff);
  149. color: #0a0a18;
  150. border: none;
  151. padding: 10px 24px;
  152. font-size: 14px;
  153. font-weight: 700;
  154. border-radius: 6px;
  155. cursor: pointer;
  156. margin-top: 14px;
  157. letter-spacing: 1px;
  158. box-shadow: 0 4px 14px rgba(91,140,255,0.4);
  159. }
  160. .btn:hover { transform: translateY(-1px); }
  161. .btn:active { transform: translateY(1px); }
  162. .touch {
  163. display: none;
  164. margin-top: 16px;
  165. grid-template-columns: repeat(5, 1fr);
  166. gap: 8px;
  167. width: 100%;
  168. max-width: 420px;
  169. }
  170. .touch button {
  171. background: rgba(255,255,255,0.08);
  172. border: 1px solid rgba(255,255,255,0.18);
  173. color: #fff;
  174. padding: 14px 0;
  175. border-radius: 8px;
  176. font-size: 18px;
  177. font-weight: 700;
  178. cursor: pointer;
  179. min-height: 48px;
  180. }
  181. .touch button:active { background: rgba(126,232,255,0.25); }
  182. .touch .wide { grid-column: span 2; }
  183. .touch .full { grid-column: 1 / -1; }
  184. .combo {
  185. position: absolute;
  186. pointer-events: none;
  187. font-size: 22px;
  188. font-weight: 700;
  189. color: #ffd86b;
  190. text-shadow: 0 0 10px rgba(255,216,107,0.6);
  191. animation: float 1s ease-out forwards;
  192. }
  193. @keyframes float {
  194. from { opacity: 1; transform: translateY(0); }
  195. to { opacity: 0; transform: translateY(-40px); }
  196. }
  197. .title {
  198. position: absolute;
  199. top: -2px;
  200. left: 50%;
  201. transform: translate(-50%, -50%);
  202. background: linear-gradient(135deg, #7ee8ff, #ff7eb3);
  203. -webkit-background-clip: text;
  204. background-clip: text;
  205. color: transparent;
  206. font-weight: 800;
  207. font-size: 18px;
  208. letter-spacing: 6px;
  209. padding: 0 14px;
  210. }
  211. .flash {
  212. animation: flash 0.4s ease;
  213. }
  214. @keyframes flash {
  215. 0%, 100% { background: #0a0a18; }
  216. 50% { background: #2a2a4a; }
  217. }
  218. @media (max-width: 720px) {
  219. .touch { display: grid; }
  220. .side { min-width: 140px; }
  221. .keys { display: none; }
  222. }
  223. @media (hover: none) and (pointer: coarse) {
  224. .touch { display: grid; }
  225. .keys { display: none; }
  226. }
  227. </style>
  228. </head>
  229. <body>
  230. <div class="app">
  231. <div class="board-wrap">
  232. <div class="title">TETRIS</div>
  233. <canvas id="board" width="300" height="600"></canvas>
  234. <div class="overlay hidden" id="overlay">
  235. <h2 id="overlay-title">已暂停</h2>
  236. <p id="overlay-text">按 空格 继续游戏</p>
  237. <button class="btn" id="overlay-btn">继续</button>
  238. </div>
  239. </div>
  240. <div class="side">
  241. <div class="panel">
  242. <h3>分数</h3>
  243. <div class="score" id="score">0</div>
  244. <div class="stats-row"><span>等级</span><span id="level">1</span></div>
  245. <div class="stats-row"><span>行数</span><span id="lines">0</span></div>
  246. </div>
  247. <div class="panel">
  248. <h3>下一个</h3>
  249. <div class="next-canvas-wrap">
  250. <canvas id="next" width="100" height="100"></canvas>
  251. </div>
  252. </div>
  253. <div class="panel keys">
  254. <h3>操作</h3>
  255. <div class="row"><kbd>←</kbd><kbd>→</kbd>移动</div>
  256. <div class="row"><kbd>↓</kbd>快速下落</div>
  257. <div class="row"><kbd>Enter</kbd>直接落地</div>
  258. <div class="row"><kbd>Z</kbd><kbd>X</kbd>旋转</div>
  259. <div class="row"><kbd>Space</kbd>暂停</div>
  260. <div class="row"><kbd>Q</kbd>重开</div>
  261. </div>
  262. </div>
  263. </div>
  264. <div class="touch" id="touch">
  265. <button data-act="left">◀</button>
  266. <button data-act="rotateL">Z</button>
  267. <button data-act="down">▼</button>
  268. <button data-act="rotateR">X</button>
  269. <button data-act="right">▶</button>
  270. <button class="wide" data-act="drop">落地</button>
  271. <button class="wide" data-act="pause">暂停</button>
  272. <button class="full" data-act="restart">重新开始 (Q)</button>
  273. </div>
  274. <script>
  275. (() => {
  276. // ====== Constants ======
  277. const COLS = 10, ROWS = 20, CELL = 30;
  278. const board = document.getElementById('board');
  279. const ctx = board.getContext('2d');
  280. const next = document.getElementById('next');
  281. const nctx = next.getContext('2d');
  282. // Piece definitions: each is a 4x4 matrix with rotation states
  283. // Using a simpler representation: list of 4 rotation states, each is list of [x,y] cells
  284. const SHAPES = {
  285. I: {
  286. color: '#00f0f0',
  287. rotations: [
  288. [[0,1],[1,1],[2,1],[3,1]],
  289. [[2,0],[2,1],[2,2],[2,3]],
  290. [[0,2],[1,2],[2,2],[3,2]],
  291. [[1,0],[1,1],[1,2],[1,3]],
  292. ]
  293. },
  294. O: {
  295. color: '#f0f000',
  296. rotations: [
  297. [[1,0],[2,0],[1,1],[2,1]],
  298. [[1,0],[2,0],[1,1],[2,1]],
  299. [[1,0],[2,0],[1,1],[2,1]],
  300. [[1,0],[2,0],[1,1],[2,1]],
  301. ]
  302. },
  303. T: {
  304. color: '#a000f0',
  305. rotations: [
  306. [[1,0],[0,1],[1,1],[2,1]],
  307. [[1,0],[1,1],[2,1],[1,2]],
  308. [[0,1],[1,1],[2,1],[1,2]],
  309. [[1,0],[0,1],[1,1],[1,2]],
  310. ]
  311. },
  312. S: {
  313. color: '#00f000',
  314. rotations: [
  315. [[1,0],[2,0],[0,1],[1,1]],
  316. [[1,0],[1,1],[2,1],[2,2]],
  317. [[1,1],[2,1],[0,2],[1,2]],
  318. [[0,0],[0,1],[1,1],[1,2]],
  319. ]
  320. },
  321. Z: {
  322. color: '#f00000',
  323. rotations: [
  324. [[0,0],[1,0],[1,1],[2,1]],
  325. [[2,0],[1,1],[2,1],[1,2]],
  326. [[0,1],[1,1],[1,2],[2,2]],
  327. [[1,0],[0,1],[1,1],[0,2]],
  328. ]
  329. },
  330. J: {
  331. color: '#0000f0',
  332. rotations: [
  333. [[0,0],[0,1],[1,1],[2,1]],
  334. [[1,0],[2,0],[1,1],[1,2]],
  335. [[0,1],[1,1],[2,1],[2,2]],
  336. [[1,0],[1,1],[0,2],[1,2]],
  337. ]
  338. },
  339. L: {
  340. color: '#f0a000',
  341. rotations: [
  342. [[2,0],[0,1],[1,1],[2,1]],
  343. [[1,0],[1,1],[1,2],[2,2]],
  344. [[0,1],[1,1],[2,1],[0,2]],
  345. [[0,0],[1,0],[1,1],[1,2]],
  346. ]
  347. },
  348. };
  349. const TYPES = Object.keys(SHAPES);
  350. // SRS-style wall kick data
  351. const KICKS_JLSTZ = {
  352. '0->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
  353. '1->0': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
  354. '1->2': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
  355. '2->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
  356. '2->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
  357. '3->2': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
  358. '3->0': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
  359. '0->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
  360. };
  361. const KICKS_I = {
  362. '0->1': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
  363. '1->0': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
  364. '1->2': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
  365. '2->1': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
  366. '2->3': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
  367. '3->2': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
  368. '3->0': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
  369. '0->3': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
  370. };
  371. // ====== State ======
  372. let grid, current, nextType, score, level, lines, dropInterval, dropCounter, lastTime, gameOver, paused;
  373. const overlay = document.getElementById('overlay');
  374. const overlayTitle = document.getElementById('overlay-title');
  375. const overlayText = document.getElementById('overlay-text');
  376. const overlayBtn = document.getElementById('overlay-btn');
  377. const scoreEl = document.getElementById('score');
  378. const levelEl = document.getElementById('level');
  379. const linesEl = document.getElementById('lines');
  380. function init() {
  381. grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
  382. score = 0; level = 1; lines = 0;
  383. dropInterval = 1000; dropCounter = 0; lastTime = 0;
  384. gameOver = false; paused = false;
  385. nextType = randomType();
  386. spawn();
  387. hideOverlay();
  388. updateUI();
  389. draw();
  390. drawNext();
  391. }
  392. function randomType() {
  393. return TYPES[Math.floor(Math.random() * TYPES.length)];
  394. }
  395. function spawn() {
  396. const type = nextType;
  397. nextType = randomType();
  398. current = {
  399. type,
  400. rotation: 0,
  401. x: 3,
  402. y: -1,
  403. };
  404. drawNext();
  405. if (collides(current, 0, 0)) {
  406. // game over
  407. gameOver = true;
  408. showOverlay('游戏结束', `最终得分: ${score}`, '重新开始');
  409. }
  410. }
  411. function collides(piece, dx, dy, rot = piece.rotation) {
  412. const cells = SHAPES[piece.type].rotations[rot];
  413. for (const [cx, cy] of cells) {
  414. const x = piece.x + cx + dx;
  415. const y = piece.y + cy + dy;
  416. if (x < 0 || x >= COLS || y >= ROWS) return true;
  417. if (y >= 0 && grid[y][x]) return true;
  418. }
  419. return false;
  420. }
  421. function tryMove(dx, dy) {
  422. if (collides(current, dx, dy)) return false;
  423. current.x += dx;
  424. current.y += dy;
  425. return true;
  426. }
  427. function rotate(dir) {
  428. if (current.type === 'O') return;
  429. const from = current.rotation;
  430. const to = (from + (dir > 0 ? 1 : 3)) % 4;
  431. const kickTable = (current.type === 'I') ? KICKS_I : KICKS_JLSTZ;
  432. const key = `${from}->${to}`;
  433. const kicks = kickTable[key] || [[0,0]];
  434. for (const [kx, ky] of kicks) {
  435. // For I piece, kicks are applied as offsets; for others, also
  436. if (!collides(current, kx, -ky, to)) {
  437. current.x += kx;
  438. current.y += -ky;
  439. current.rotation = to;
  440. return;
  441. }
  442. }
  443. }
  444. function softDrop() {
  445. if (tryMove(0, 1)) {
  446. score += 1;
  447. updateUI();
  448. } else {
  449. lock();
  450. }
  451. }
  452. function hardDrop() {
  453. let dist = 0;
  454. while (tryMove(0, 1)) dist++;
  455. score += dist * 2;
  456. lock();
  457. }
  458. function lock() {
  459. const cells = SHAPES[current.type].rotations[current.rotation];
  460. for (const [cx, cy] of cells) {
  461. const x = current.x + cx;
  462. const y = current.y + cy;
  463. if (y >= 0 && y < ROWS && x >= 0 && x < COLS) {
  464. grid[y][x] = SHAPES[current.type].color;
  465. }
  466. }
  467. const cleared = clearLines();
  468. if (cleared > 0) {
  469. const base = [0, 100, 300, 500, 800][cleared];
  470. const gained = base * level;
  471. score += gained;
  472. lines += cleared;
  473. showCombo(cleared, gained);
  474. // Level up every 10 lines
  475. const newLevel = Math.floor(lines / 10) + 1;
  476. if (newLevel > level) {
  477. level = newLevel;
  478. dropInterval = Math.max(80, 1000 - (level - 1) * 80);
  479. }
  480. updateUI();
  481. // Flash effect
  482. board.classList.add('flash');
  483. setTimeout(() => board.classList.remove('flash'), 400);
  484. }
  485. spawn();
  486. }
  487. function clearLines() {
  488. let count = 0;
  489. for (let y = ROWS - 1; y >= 0; y--) {
  490. if (grid[y].every(c => c !== null)) {
  491. grid.splice(y, 1);
  492. grid.unshift(Array(COLS).fill(null));
  493. count++;
  494. y++; // recheck this row
  495. }
  496. }
  497. return count;
  498. }
  499. function showCombo(n, gained) {
  500. const labels = {1: '单消!', 2: '双消!', 3: '三消!', 4: 'TETRIS!'};
  501. const el = document.createElement('div');
  502. el.className = 'combo';
  503. el.textContent = `${labels[n] || ''} +${gained}`;
  504. el.style.left = (board.width / 2 - 60) + 'px';
  505. el.style.top = (board.height / 2) + 'px';
  506. board.parentElement.appendChild(el);
  507. setTimeout(() => el.remove(), 1000);
  508. }
  509. function updateUI() {
  510. scoreEl.textContent = score;
  511. levelEl.textContent = level;
  512. linesEl.textContent = lines;
  513. }
  514. // ====== Drawing ======
  515. function drawCell(ctx, x, y, color, size = CELL) {
  516. const px = x * size, py = y * size;
  517. // Glow
  518. ctx.fillStyle = color;
  519. ctx.shadowColor = color;
  520. ctx.shadowBlur = 8;
  521. ctx.fillRect(px + 1, py + 1, size - 2, size - 2);
  522. ctx.shadowBlur = 0;
  523. // Highlight
  524. ctx.fillStyle = 'rgba(255,255,255,0.25)';
  525. ctx.fillRect(px + 2, py + 2, size - 4, 3);
  526. ctx.fillRect(px + 2, py + 2, 3, size - 4);
  527. // Shadow
  528. ctx.fillStyle = 'rgba(0,0,0,0.3)';
  529. ctx.fillRect(px + 2, py + size - 5, size - 4, 3);
  530. ctx.fillRect(px + size - 5, py + 2, 3, size - 4);
  531. }
  532. function drawGrid() {
  533. ctx.strokeStyle = 'rgba(255,255,255,0.04)';
  534. ctx.lineWidth = 1;
  535. for (let x = 1; x < COLS; x++) {
  536. ctx.beginPath();
  537. ctx.moveTo(x * CELL + 0.5, 0);
  538. ctx.lineTo(x * CELL + 0.5, ROWS * CELL);
  539. ctx.stroke();
  540. }
  541. for (let y = 1; y < ROWS; y++) {
  542. ctx.beginPath();
  543. ctx.moveTo(0, y * CELL + 0.5);
  544. ctx.lineTo(COLS * CELL, y * CELL + 0.5);
  545. ctx.stroke();
  546. }
  547. }
  548. function drawGhost() {
  549. if (!current || gameOver) return;
  550. const cells = SHAPES[current.type].rotations[current.rotation];
  551. let gy = current.y;
  552. while (!collides({...current, y: gy + 1}, 0, 0)) gy++;
  553. ctx.fillStyle = 'rgba(255,255,255,0.12)';
  554. for (const [cx, cy] of cells) {
  555. const x = current.x + cx;
  556. const y = gy + cy;
  557. if (y >= 0) {
  558. ctx.fillRect(x * CELL + 2, y * CELL + 2, CELL - 4, CELL - 4);
  559. }
  560. }
  561. }
  562. function draw() {
  563. ctx.fillStyle = '#0a0a18';
  564. ctx.fillRect(0, 0, board.width, board.height);
  565. drawGrid();
  566. for (let y = 0; y < ROWS; y++) {
  567. for (let x = 0; x < COLS; x++) {
  568. if (grid[y][x]) drawCell(ctx, x, y, grid[y][x]);
  569. }
  570. }
  571. if (current && !gameOver) {
  572. drawGhost();
  573. const cells = SHAPES[current.type].rotations[current.rotation];
  574. for (const [cx, cy] of cells) {
  575. const x = current.x + cx;
  576. const y = current.y + cy;
  577. if (y >= 0) drawCell(ctx, x, y, SHAPES[current.type].color);
  578. }
  579. }
  580. }
  581. function drawNext() {
  582. nctx.fillStyle = '#0a0a18';
  583. nctx.fillRect(0, 0, next.width, next.height);
  584. const cells = SHAPES[nextType].rotations[0];
  585. const cs = 20;
  586. // Find bounds to center
  587. let minX = 4, maxX = 0, minY = 4, maxY = 0;
  588. for (const [x, y] of cells) {
  589. if (x < minX) minX = x;
  590. if (x > maxX) maxX = x;
  591. if (y < minY) minY = y;
  592. if (y > maxY) maxY = y;
  593. }
  594. const w = (maxX - minX + 1) * cs;
  595. const h = (maxY - minY + 1) * cs;
  596. const ox = (next.width - w) / 2 - minX * cs;
  597. const oy = (next.height - h) / 2 - minY * cs;
  598. for (const [x, y] of cells) {
  599. nctx.shadowColor = SHAPES[nextType].color;
  600. nctx.shadowBlur = 8;
  601. nctx.fillStyle = SHAPES[nextType].color;
  602. nctx.fillRect(ox + x * cs + 1, oy + y * cs + 1, cs - 2, cs - 2);
  603. }
  604. nctx.shadowBlur = 0;
  605. }
  606. // ====== Overlay ======
  607. function showOverlay(title, text, btn) {
  608. overlayTitle.textContent = title;
  609. overlayText.textContent = text;
  610. overlayBtn.textContent = btn;
  611. overlay.classList.remove('hidden');
  612. }
  613. function hideOverlay() {
  614. overlay.classList.add('hidden');
  615. }
  616. overlayBtn.addEventListener('click', () => {
  617. if (gameOver) {
  618. init();
  619. } else {
  620. paused = false;
  621. hideOverlay();
  622. lastTime = performance.now();
  623. }
  624. });
  625. // ====== Input ======
  626. function togglePause() {
  627. if (gameOver) return;
  628. paused = !paused;
  629. if (paused) showOverlay('已暂停', '按 空格 继续', '继续');
  630. else { hideOverlay(); lastTime = performance.now(); }
  631. }
  632. function restart() {
  633. init();
  634. }
  635. document.addEventListener('keydown', (e) => {
  636. if (gameOver && e.key.toLowerCase() !== 'q') return;
  637. switch (e.key) {
  638. case 'ArrowLeft':
  639. if (!paused) tryMove(-1, 0);
  640. e.preventDefault(); break;
  641. case 'ArrowRight':
  642. if (!paused) tryMove(1, 0);
  643. e.preventDefault(); break;
  644. case 'ArrowDown':
  645. if (!paused) softDrop();
  646. e.preventDefault(); break;
  647. case 'Enter':
  648. if (!paused) hardDrop();
  649. e.preventDefault(); break;
  650. case 'z': case 'Z':
  651. if (!paused) rotate(-1);
  652. e.preventDefault(); break;
  653. case 'x': case 'X':
  654. if (!paused) rotate(1);
  655. e.preventDefault(); break;
  656. case ' ':
  657. togglePause();
  658. e.preventDefault(); break;
  659. case 'q': case 'Q':
  660. restart();
  661. e.preventDefault(); break;
  662. }
  663. });
  664. // Touch controls
  665. const touchHold = { ArrowLeft: false, ArrowRight: false, ArrowDown: false };
  666. let holdTimer = null;
  667. function pressHold(key, start) {
  668. if (gameOver || paused) return;
  669. if (start) {
  670. touchHold[key] = true;
  671. if (holdTimer) clearInterval(holdTimer);
  672. holdTimer = setInterval(() => {
  673. if (touchHold.ArrowLeft) tryMove(-1, 0);
  674. if (touchHold.ArrowRight) tryMove(1, 0);
  675. if (touchHold.ArrowDown) softDrop();
  676. }, 80);
  677. } else {
  678. touchHold[key] = false;
  679. if (!touchHold.ArrowLeft && !touchHold.ArrowRight && !touchHold.ArrowDown) {
  680. clearInterval(holdTimer); holdTimer = null;
  681. }
  682. }
  683. }
  684. document.querySelectorAll('#touch button').forEach(btn => {
  685. const act = btn.dataset.act;
  686. const fire = (e) => {
  687. e.preventDefault();
  688. if (gameOver && act !== 'restart') return;
  689. switch (act) {
  690. case 'left': if (!paused) tryMove(-1, 0); pressHold('ArrowLeft', true); break;
  691. case 'right': if (!paused) tryMove(1, 0); pressHold('ArrowRight', true); break;
  692. case 'down': if (!paused) softDrop(); pressHold('ArrowDown', true); break;
  693. case 'rotateL': if (!paused) rotate(-1); break;
  694. case 'rotateR': if (!paused) rotate(1); break;
  695. case 'drop': if (!paused) hardDrop(); break;
  696. case 'pause': togglePause(); break;
  697. case 'restart': restart(); break;
  698. }
  699. };
  700. const release = (e) => {
  701. e.preventDefault();
  702. const act = btn.dataset.act;
  703. if (act === 'left') pressHold('ArrowLeft', false);
  704. if (act === 'right') pressHold('ArrowRight', false);
  705. if (act === 'down') pressHold('ArrowDown', false);
  706. };
  707. btn.addEventListener('touchstart', fire, {passive: false});
  708. btn.addEventListener('touchend', release, {passive: false});
  709. btn.addEventListener('touchcancel', release, {passive: false});
  710. btn.addEventListener('mousedown', fire);
  711. btn.addEventListener('mouseup', release);
  712. btn.addEventListener('mouseleave', release);
  713. });
  714. // ====== Game loop ======
  715. function loop(time = 0) {
  716. if (!gameOver && !paused) {
  717. const delta = time - lastTime;
  718. dropCounter += delta;
  719. if (dropCounter > dropInterval) {
  720. if (!tryMove(0, 1)) lock();
  721. dropCounter = 0;
  722. }
  723. lastTime = time;
  724. draw();
  725. }
  726. requestAnimationFrame(loop);
  727. }
  728. init();
  729. requestAnimationFrame(loop);
  730. })();
  731. </script>
  732. </body>
  733. </html>