tetris.tpl 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  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" />
  6. <title>俄罗斯方块</title>
  7. <style>
  8. :root {
  9. --bg-a: #0f172a;
  10. --bg-b: #111827;
  11. --panel: rgba(17, 24, 39, 0.86);
  12. --line: #334155;
  13. --text: #e2e8f0;
  14. --muted: #94a3b8;
  15. --accent: #22d3ee;
  16. }
  17. * { box-sizing: border-box; }
  18. body {
  19. margin: 0;
  20. min-height: 100vh;
  21. font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
  22. color: var(--text);
  23. background: radial-gradient(circle at 20% 20%, #1e293b 0%, var(--bg-a) 45%, var(--bg-b) 100%);
  24. display: grid;
  25. place-items: center;
  26. padding: 20px;
  27. }
  28. .wrap {
  29. width: min(920px, 100%);
  30. display: grid;
  31. grid-template-columns: minmax(280px, 420px) minmax(240px, 1fr);
  32. gap: 20px;
  33. }
  34. .panel {
  35. background: var(--panel);
  36. border: 1px solid var(--line);
  37. border-radius: 16px;
  38. box-shadow: 0 12px 40px rgba(2, 6, 23, 0.4);
  39. overflow: hidden;
  40. }
  41. .board {
  42. padding: 16px;
  43. display: grid;
  44. place-items: center;
  45. }
  46. #game {
  47. width: 100%;
  48. max-width: 360px;
  49. aspect-ratio: 1 / 2;
  50. background: #020617;
  51. border: 1px solid #1e293b;
  52. border-radius: 12px;
  53. display: block;
  54. }
  55. .side {
  56. padding: 20px;
  57. display: grid;
  58. gap: 16px;
  59. align-content: start;
  60. }
  61. h1 {
  62. margin: 0;
  63. font-size: 26px;
  64. letter-spacing: 0.5px;
  65. }
  66. .sub {
  67. color: var(--muted);
  68. font-size: 14px;
  69. margin-top: 4px;
  70. }
  71. .stats {
  72. display: grid;
  73. grid-template-columns: repeat(2, minmax(120px, 1fr));
  74. gap: 10px;
  75. }
  76. .card {
  77. border: 1px solid var(--line);
  78. border-radius: 12px;
  79. padding: 12px;
  80. background: rgba(2, 6, 23, 0.35);
  81. }
  82. .label { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
  83. .value { font-size: 22px; font-weight: 700; color: #f8fafc; }
  84. .next-box {
  85. border: 1px solid var(--line);
  86. border-radius: 12px;
  87. padding: 12px;
  88. background: rgba(2, 6, 23, 0.35);
  89. }
  90. #next {
  91. width: 100%;
  92. max-width: 180px;
  93. aspect-ratio: 1 / 1;
  94. background: #020617;
  95. border-radius: 10px;
  96. border: 1px solid #1e293b;
  97. display: block;
  98. }
  99. .btns {
  100. display: flex;
  101. flex-wrap: wrap;
  102. gap: 10px;
  103. }
  104. button {
  105. cursor: pointer;
  106. border: 1px solid var(--line);
  107. background: #0b1220;
  108. color: var(--text);
  109. padding: 9px 14px;
  110. border-radius: 10px;
  111. font-weight: 600;
  112. }
  113. button.primary {
  114. background: linear-gradient(135deg, #0891b2, #06b6d4);
  115. color: #062026;
  116. border-color: #22d3ee;
  117. }
  118. .keys {
  119. border: 1px dashed var(--line);
  120. border-radius: 12px;
  121. padding: 12px;
  122. color: var(--muted);
  123. font-size: 13px;
  124. line-height: 1.7;
  125. background: rgba(2, 6, 23, 0.2);
  126. }
  127. .status {
  128. color: var(--accent);
  129. font-weight: 700;
  130. font-size: 14px;
  131. min-height: 20px;
  132. }
  133. @media (max-width: 760px) {
  134. .wrap { grid-template-columns: 1fr; }
  135. .board { order: 1; }
  136. .side { order: 2; }
  137. }
  138. </style>
  139. </head>
  140. <body>
  141. <main class="wrap">
  142. <section class="panel board">
  143. <canvas id="game" width="300" height="600"></canvas>
  144. </section>
  145. <aside class="panel side">
  146. <div>
  147. <h1>俄罗斯方块</h1>
  148. <div class="sub">路径: /admin/game/tetris</div>
  149. </div>
  150. <div class="stats">
  151. <div class="card">
  152. <div class="label">分数</div>
  153. <div class="value" id="score">0</div>
  154. </div>
  155. <div class="card">
  156. <div class="label">等级</div>
  157. <div class="value" id="level">1</div>
  158. </div>
  159. <div class="card">
  160. <div class="label">消除行数</div>
  161. <div class="value" id="lines">0</div>
  162. </div>
  163. <div class="card">
  164. <div class="label">最高分</div>
  165. <div class="value" id="best">0</div>
  166. </div>
  167. </div>
  168. <div class="next-box">
  169. <div class="label">下一个方块</div>
  170. <canvas id="next" width="160" height="160"></canvas>
  171. </div>
  172. <div class="btns">
  173. <button class="primary" id="startBtn">开始 / 重新开始</button>
  174. <button id="pauseBtn">暂停</button>
  175. </div>
  176. <div class="status" id="status">按“开始”后即可游戏</div>
  177. <div class="keys">
  178. ← / → : 左右移动<br>
  179. ↑ : 旋转<br>
  180. ↓ : 加速下落<br>
  181. 空格 : 直接落到底<br>
  182. P : 暂停 / 继续
  183. </div>
  184. </aside>
  185. </main>
  186. <script>
  187. (function () {
  188. const COLS = 10;
  189. const ROWS = 20;
  190. const BLOCK = 30;
  191. const SHAPES = {
  192. I: [[1, 1, 1, 1]],
  193. O: [[1, 1], [1, 1]],
  194. T: [[0, 1, 0], [1, 1, 1]],
  195. S: [[0, 1, 1], [1, 1, 0]],
  196. Z: [[1, 1, 0], [0, 1, 1]],
  197. J: [[1, 0, 0], [1, 1, 1]],
  198. L: [[0, 0, 1], [1, 1, 1]]
  199. };
  200. const COLORS = {
  201. I: '#38bdf8',
  202. O: '#facc15',
  203. T: '#a78bfa',
  204. S: '#4ade80',
  205. Z: '#fb7185',
  206. J: '#60a5fa',
  207. L: '#fb923c'
  208. };
  209. const gameCanvas = document.getElementById('game');
  210. const gameCtx = gameCanvas.getContext('2d');
  211. const nextCanvas = document.getElementById('next');
  212. const nextCtx = nextCanvas.getContext('2d');
  213. const scoreEl = document.getElementById('score');
  214. const levelEl = document.getElementById('level');
  215. const linesEl = document.getElementById('lines');
  216. const bestEl = document.getElementById('best');
  217. const statusEl = document.getElementById('status');
  218. const startBtn = document.getElementById('startBtn');
  219. const pauseBtn = document.getElementById('pauseBtn');
  220. const BEST_KEY = 'tetris_best_score_v1';
  221. let board = [];
  222. let current = null;
  223. let next = null;
  224. let dropCounter = 0;
  225. let lastTime = 0;
  226. let isRunning = false;
  227. let isPaused = false;
  228. let score = 0;
  229. let lines = 0;
  230. let level = 1;
  231. let best = Number(localStorage.getItem(BEST_KEY) || 0);
  232. bestEl.textContent = String(best);
  233. function createBoard() {
  234. return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
  235. }
  236. function pickType() {
  237. const types = Object.keys(SHAPES);
  238. return types[Math.floor(Math.random() * types.length)];
  239. }
  240. function createPiece(type) {
  241. return {
  242. type,
  243. shape: SHAPES[type].map(row => row.slice()),
  244. x: Math.floor(COLS / 2) - Math.ceil(SHAPES[type][0].length / 2),
  245. y: 0
  246. };
  247. }
  248. function rotate(shape) {
  249. const h = shape.length;
  250. const w = shape[0].length;
  251. const out = Array.from({ length: w }, () => Array(h).fill(0));
  252. for (let y = 0; y < h; y++) {
  253. for (let x = 0; x < w; x++) {
  254. out[x][h - y - 1] = shape[y][x];
  255. }
  256. }
  257. return out;
  258. }
  259. function collides(piece, dx = 0, dy = 0, shape = piece.shape) {
  260. for (let y = 0; y < shape.length; y++) {
  261. for (let x = 0; x < shape[y].length; x++) {
  262. if (!shape[y][x]) continue;
  263. const nx = piece.x + x + dx;
  264. const ny = piece.y + y + dy;
  265. if (nx < 0 || nx >= COLS || ny >= ROWS) return true;
  266. if (ny >= 0 && board[ny][nx]) return true;
  267. }
  268. }
  269. return false;
  270. }
  271. function merge(piece) {
  272. for (let y = 0; y < piece.shape.length; y++) {
  273. for (let x = 0; x < piece.shape[y].length; x++) {
  274. if (piece.shape[y][x]) {
  275. const by = piece.y + y;
  276. const bx = piece.x + x;
  277. if (by >= 0) board[by][bx] = piece.type;
  278. }
  279. }
  280. }
  281. }
  282. function clearLines() {
  283. let cleared = 0;
  284. for (let y = ROWS - 1; y >= 0; y--) {
  285. if (board[y].every(cell => cell !== null)) {
  286. board.splice(y, 1);
  287. board.unshift(Array(COLS).fill(null));
  288. cleared++;
  289. y++;
  290. }
  291. }
  292. if (cleared > 0) {
  293. lines += cleared;
  294. score += [0, 100, 300, 500, 800][cleared] * level;
  295. level = 1 + Math.floor(lines / 10);
  296. refreshStats();
  297. }
  298. }
  299. function refreshStats() {
  300. scoreEl.textContent = String(score);
  301. linesEl.textContent = String(lines);
  302. levelEl.textContent = String(level);
  303. }
  304. function spawn() {
  305. if (!next) next = createPiece(pickType());
  306. current = next;
  307. current.x = Math.floor(COLS / 2) - Math.ceil(current.shape[0].length / 2);
  308. current.y = 0;
  309. next = createPiece(pickType());
  310. drawNext();
  311. if (collides(current, 0, 0)) {
  312. isRunning = false;
  313. statusEl.textContent = '游戏结束,点击“开始 / 重新开始”再来一局';
  314. if (score > best) {
  315. best = score;
  316. localStorage.setItem(BEST_KEY, String(best));
  317. bestEl.textContent = String(best);
  318. }
  319. }
  320. }
  321. function move(dx) {
  322. if (!current || !isRunning || isPaused) return;
  323. if (!collides(current, dx, 0)) current.x += dx;
  324. }
  325. function softDrop() {
  326. if (!current || !isRunning || isPaused) return;
  327. if (!collides(current, 0, 1)) {
  328. current.y += 1;
  329. } else {
  330. lockAndContinue();
  331. }
  332. }
  333. function hardDrop() {
  334. if (!current || !isRunning || isPaused) return;
  335. while (!collides(current, 0, 1)) {
  336. current.y += 1;
  337. score += 2;
  338. }
  339. refreshStats();
  340. lockAndContinue();
  341. }
  342. function lockAndContinue() {
  343. merge(current);
  344. clearLines();
  345. spawn();
  346. }
  347. function rotateCurrent() {
  348. if (!current || !isRunning || isPaused) return;
  349. const rotated = rotate(current.shape);
  350. if (!collides(current, 0, 0, rotated)) {
  351. current.shape = rotated;
  352. return;
  353. }
  354. if (!collides(current, -1, 0, rotated)) {
  355. current.x -= 1;
  356. current.shape = rotated;
  357. return;
  358. }
  359. if (!collides(current, 1, 0, rotated)) {
  360. current.x += 1;
  361. current.shape = rotated;
  362. }
  363. }
  364. function drawCell(ctx, x, y, color, size) {
  365. ctx.fillStyle = color;
  366. ctx.fillRect(x * size, y * size, size, size);
  367. ctx.strokeStyle = 'rgba(15, 23, 42, 0.8)';
  368. ctx.lineWidth = 1;
  369. ctx.strokeRect(x * size + 0.5, y * size + 0.5, size - 1, size - 1);
  370. }
  371. function drawBoard() {
  372. gameCtx.fillStyle = '#020617';
  373. gameCtx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  374. for (let y = 0; y < ROWS; y++) {
  375. for (let x = 0; x < COLS; x++) {
  376. const t = board[y][x];
  377. if (t) drawCell(gameCtx, x, y, COLORS[t], BLOCK);
  378. }
  379. }
  380. if (current) {
  381. for (let y = 0; y < current.shape.length; y++) {
  382. for (let x = 0; x < current.shape[y].length; x++) {
  383. if (current.shape[y][x]) {
  384. drawCell(gameCtx, current.x + x, current.y + y, COLORS[current.type], BLOCK);
  385. }
  386. }
  387. }
  388. }
  389. }
  390. function drawNext() {
  391. nextCtx.fillStyle = '#020617';
  392. nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
  393. if (!next) return;
  394. const size = 32;
  395. const shape = next.shape;
  396. const offsetX = Math.floor((nextCanvas.width - shape[0].length * size) / 2 / size);
  397. const offsetY = Math.floor((nextCanvas.height - shape.length * size) / 2 / size);
  398. for (let y = 0; y < shape.length; y++) {
  399. for (let x = 0; x < shape[y].length; x++) {
  400. if (shape[y][x]) drawCell(nextCtx, offsetX + x, offsetY + y, COLORS[next.type], size);
  401. }
  402. }
  403. }
  404. function getDropInterval() {
  405. return Math.max(100, 800 - (level - 1) * 60);
  406. }
  407. function update(time = 0) {
  408. const delta = time - lastTime;
  409. lastTime = time;
  410. if (isRunning && !isPaused) {
  411. dropCounter += delta;
  412. if (dropCounter >= getDropInterval()) {
  413. softDrop();
  414. dropCounter = 0;
  415. }
  416. }
  417. drawBoard();
  418. requestAnimationFrame(update);
  419. }
  420. function startGame() {
  421. board = createBoard();
  422. score = 0;
  423. lines = 0;
  424. level = 1;
  425. isRunning = true;
  426. isPaused = false;
  427. refreshStats();
  428. statusEl.textContent = '游戏进行中';
  429. next = createPiece(pickType());
  430. spawn();
  431. }
  432. function togglePause() {
  433. if (!isRunning) return;
  434. isPaused = !isPaused;
  435. statusEl.textContent = isPaused ? '已暂停' : '游戏进行中';
  436. }
  437. document.addEventListener('keydown', (e) => {
  438. if (!isRunning) return;
  439. if (e.code === 'ArrowLeft') { e.preventDefault(); move(-1); }
  440. if (e.code === 'ArrowRight') { e.preventDefault(); move(1); }
  441. if (e.code === 'ArrowDown') { e.preventDefault(); softDrop(); }
  442. if (e.code === 'ArrowUp') { e.preventDefault(); rotateCurrent(); }
  443. if (e.code === 'Space') { e.preventDefault(); hardDrop(); }
  444. if (e.code === 'KeyP') { e.preventDefault(); togglePause(); }
  445. });
  446. startBtn.addEventListener('click', startGame);
  447. pauseBtn.addEventListener('click', togglePause);
  448. board = createBoard();
  449. drawBoard();
  450. drawNext();
  451. requestAnimationFrame(update);
  452. })();
  453. </script>
  454. </body>
  455. </html>