Gxx 1 месяц назад
Сommit
16d2466c33
1 измененных файлов с 765 добавлено и 0 удалено
  1. 765 0
      index.html

+ 765 - 0
index.html

@@ -0,0 +1,765 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>俄罗斯方块</title>
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
+  html, body { height: 100%; }
+  body {
+    background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
+    color: #e8e8f0;
+    font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 100vh;
+    overflow: auto;
+    user-select: none;
+    -webkit-user-select: none;
+    touch-action: manipulation;
+  }
+  .app {
+    display: flex;
+    gap: 24px;
+    padding: 20px;
+    align-items: flex-start;
+    flex-wrap: wrap;
+    justify-content: center;
+  }
+  .board-wrap {
+    position: relative;
+    background: rgba(0,0,0,0.35);
+    border: 2px solid rgba(255,255,255,0.12);
+    border-radius: 12px;
+    padding: 12px;
+    box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 0 30px rgba(0,0,0,0.4);
+  }
+  canvas#board {
+    display: block;
+    background: #0a0a18;
+    border-radius: 6px;
+    image-rendering: pixelated;
+  }
+  .side {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    min-width: 180px;
+  }
+  .panel {
+    background: rgba(0,0,0,0.35);
+    border: 2px solid rgba(255,255,255,0.12);
+    border-radius: 12px;
+    padding: 14px 16px;
+    box-shadow: 0 10px 30px rgba(0,0,0,0.35);
+  }
+  .panel h3 {
+    font-size: 12px;
+    letter-spacing: 2px;
+    color: #8a8ab0;
+    text-transform: uppercase;
+    margin-bottom: 10px;
+  }
+  .score {
+    font-size: 28px;
+    font-weight: 700;
+    color: #ffd86b;
+    text-shadow: 0 0 12px rgba(255,216,107,0.4);
+    font-variant-numeric: tabular-nums;
+  }
+  .stats-row {
+    display: flex;
+    justify-content: space-between;
+    font-size: 14px;
+    margin-top: 8px;
+  }
+  .stats-row span:last-child {
+    color: #7ee8ff;
+    font-weight: 600;
+    font-variant-numeric: tabular-nums;
+  }
+  .next-canvas-wrap {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    background: #0a0a18;
+    border-radius: 6px;
+    height: 110px;
+  }
+  canvas#next {
+    background: #0a0a18;
+    border-radius: 6px;
+  }
+  .keys {
+    font-size: 12px;
+    line-height: 1.9;
+  }
+  .keys kbd {
+    display: inline-block;
+    min-width: 22px;
+    text-align: center;
+    background: rgba(255,255,255,0.08);
+    border: 1px solid rgba(255,255,255,0.18);
+    border-bottom-width: 2px;
+    border-radius: 4px;
+    padding: 1px 6px;
+    margin-right: 6px;
+    font-family: 'Consolas', 'Monaco', monospace;
+    font-size: 11px;
+    color: #fff;
+  }
+  .keys .row { display: flex; align-items: center; }
+  .overlay {
+    position: absolute;
+    inset: 12px;
+    background: rgba(10, 10, 24, 0.85);
+    backdrop-filter: blur(4px);
+    border-radius: 6px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    text-align: center;
+    padding: 20px;
+    z-index: 10;
+    transition: opacity 0.2s;
+  }
+  .overlay.hidden { display: none; }
+  .overlay h2 {
+    font-size: 32px;
+    margin-bottom: 14px;
+    letter-spacing: 4px;
+  }
+  .overlay p {
+    color: #b0b0c8;
+    margin-bottom: 8px;
+    font-size: 14px;
+  }
+  .overlay .final-score {
+    font-size: 40px;
+    color: #ffd86b;
+    font-weight: 700;
+    margin: 10px 0;
+    text-shadow: 0 0 20px rgba(255,216,107,0.5);
+  }
+  .btn {
+    background: linear-gradient(135deg, #7ee8ff, #5b8cff);
+    color: #0a0a18;
+    border: none;
+    padding: 10px 24px;
+    font-size: 14px;
+    font-weight: 700;
+    border-radius: 6px;
+    cursor: pointer;
+    margin-top: 14px;
+    letter-spacing: 1px;
+    box-shadow: 0 4px 14px rgba(91,140,255,0.4);
+  }
+  .btn:hover { transform: translateY(-1px); }
+  .btn:active { transform: translateY(1px); }
+  .touch {
+    display: none;
+    margin-top: 16px;
+    grid-template-columns: repeat(5, 1fr);
+    gap: 8px;
+    width: 100%;
+    max-width: 420px;
+  }
+  .touch button {
+    background: rgba(255,255,255,0.08);
+    border: 1px solid rgba(255,255,255,0.18);
+    color: #fff;
+    padding: 14px 0;
+    border-radius: 8px;
+    font-size: 18px;
+    font-weight: 700;
+    cursor: pointer;
+    min-height: 48px;
+  }
+  .touch button:active { background: rgba(126,232,255,0.25); }
+  .touch .wide { grid-column: span 2; }
+  .touch .full { grid-column: 1 / -1; }
+  .combo {
+    position: absolute;
+    pointer-events: none;
+    font-size: 22px;
+    font-weight: 700;
+    color: #ffd86b;
+    text-shadow: 0 0 10px rgba(255,216,107,0.6);
+    animation: float 1s ease-out forwards;
+  }
+  @keyframes float {
+    from { opacity: 1; transform: translateY(0); }
+    to   { opacity: 0; transform: translateY(-40px); }
+  }
+  .title {
+    position: absolute;
+    top: -2px;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background: linear-gradient(135deg, #7ee8ff, #ff7eb3);
+    -webkit-background-clip: text;
+    background-clip: text;
+    color: transparent;
+    font-weight: 800;
+    font-size: 18px;
+    letter-spacing: 6px;
+    padding: 0 14px;
+  }
+  .flash {
+    animation: flash 0.4s ease;
+  }
+  @keyframes flash {
+    0%, 100% { background: #0a0a18; }
+    50% { background: #2a2a4a; }
+  }
+  @media (max-width: 720px) {
+    .touch { display: grid; }
+    .side { min-width: 140px; }
+    .keys { display: none; }
+  }
+  @media (hover: none) and (pointer: coarse) {
+    .touch { display: grid; }
+    .keys { display: none; }
+  }
+</style>
+</head>
+<body>
+
+<div class="app">
+  <div class="board-wrap">
+    <div class="title">TETRIS</div>
+    <canvas id="board" width="300" height="600"></canvas>
+    <div class="overlay hidden" id="overlay">
+      <h2 id="overlay-title">已暂停</h2>
+      <p id="overlay-text">按 空格 继续游戏</p>
+      <button class="btn" id="overlay-btn">继续</button>
+    </div>
+  </div>
+
+  <div class="side">
+    <div class="panel">
+      <h3>分数</h3>
+      <div class="score" id="score">0</div>
+      <div class="stats-row"><span>等级</span><span id="level">1</span></div>
+      <div class="stats-row"><span>行数</span><span id="lines">0</span></div>
+    </div>
+    <div class="panel">
+      <h3>下一个</h3>
+      <div class="next-canvas-wrap">
+        <canvas id="next" width="100" height="100"></canvas>
+      </div>
+    </div>
+    <div class="panel keys">
+      <h3>操作</h3>
+      <div class="row"><kbd>←</kbd><kbd>→</kbd>移动</div>
+      <div class="row"><kbd>↓</kbd>快速下落</div>
+      <div class="row"><kbd>Enter</kbd>直接落地</div>
+      <div class="row"><kbd>Z</kbd><kbd>X</kbd>旋转</div>
+      <div class="row"><kbd>Space</kbd>暂停</div>
+      <div class="row"><kbd>Q</kbd>重开</div>
+    </div>
+  </div>
+</div>
+
+<div class="touch" id="touch">
+  <button data-act="left">◀</button>
+  <button data-act="rotateL">Z</button>
+  <button data-act="down">▼</button>
+  <button data-act="rotateR">X</button>
+  <button data-act="right">▶</button>
+  <button class="wide" data-act="drop">落地</button>
+  <button class="wide" data-act="pause">暂停</button>
+  <button class="full" data-act="restart">重新开始 (Q)</button>
+</div>
+
+<script>
+(() => {
+  // ====== Constants ======
+  const COLS = 10, ROWS = 20, CELL = 30;
+  const board = document.getElementById('board');
+  const ctx = board.getContext('2d');
+  const next = document.getElementById('next');
+  const nctx = next.getContext('2d');
+
+  // Piece definitions: each is a 4x4 matrix with rotation states
+  // Using a simpler representation: list of 4 rotation states, each is list of [x,y] cells
+  const SHAPES = {
+    I: {
+      color: '#00f0f0',
+      rotations: [
+        [[0,1],[1,1],[2,1],[3,1]],
+        [[2,0],[2,1],[2,2],[2,3]],
+        [[0,2],[1,2],[2,2],[3,2]],
+        [[1,0],[1,1],[1,2],[1,3]],
+      ]
+    },
+    O: {
+      color: '#f0f000',
+      rotations: [
+        [[1,0],[2,0],[1,1],[2,1]],
+        [[1,0],[2,0],[1,1],[2,1]],
+        [[1,0],[2,0],[1,1],[2,1]],
+        [[1,0],[2,0],[1,1],[2,1]],
+      ]
+    },
+    T: {
+      color: '#a000f0',
+      rotations: [
+        [[1,0],[0,1],[1,1],[2,1]],
+        [[1,0],[1,1],[2,1],[1,2]],
+        [[0,1],[1,1],[2,1],[1,2]],
+        [[1,0],[0,1],[1,1],[1,2]],
+      ]
+    },
+    S: {
+      color: '#00f000',
+      rotations: [
+        [[1,0],[2,0],[0,1],[1,1]],
+        [[1,0],[1,1],[2,1],[2,2]],
+        [[1,1],[2,1],[0,2],[1,2]],
+        [[0,0],[0,1],[1,1],[1,2]],
+      ]
+    },
+    Z: {
+      color: '#f00000',
+      rotations: [
+        [[0,0],[1,0],[1,1],[2,1]],
+        [[2,0],[1,1],[2,1],[1,2]],
+        [[0,1],[1,1],[1,2],[2,2]],
+        [[1,0],[0,1],[1,1],[0,2]],
+      ]
+    },
+    J: {
+      color: '#0000f0',
+      rotations: [
+        [[0,0],[0,1],[1,1],[2,1]],
+        [[1,0],[2,0],[1,1],[1,2]],
+        [[0,1],[1,1],[2,1],[2,2]],
+        [[1,0],[1,1],[0,2],[1,2]],
+      ]
+    },
+    L: {
+      color: '#f0a000',
+      rotations: [
+        [[2,0],[0,1],[1,1],[2,1]],
+        [[1,0],[1,1],[1,2],[2,2]],
+        [[0,1],[1,1],[2,1],[0,2]],
+        [[0,0],[1,0],[1,1],[1,2]],
+      ]
+    },
+  };
+  const TYPES = Object.keys(SHAPES);
+
+  // SRS-style wall kick data
+  const KICKS_JLSTZ = {
+    '0->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
+    '1->0': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
+    '1->2': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
+    '2->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
+    '2->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
+    '3->2': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
+    '3->0': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
+    '0->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
+  };
+  const KICKS_I = {
+    '0->1': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
+    '1->0': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
+    '1->2': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
+    '2->1': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
+    '2->3': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
+    '3->2': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
+    '3->0': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
+    '0->3': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
+  };
+
+  // ====== State ======
+  let grid, current, nextType, score, level, lines, dropInterval, dropCounter, lastTime, gameOver, paused;
+  const overlay = document.getElementById('overlay');
+  const overlayTitle = document.getElementById('overlay-title');
+  const overlayText = document.getElementById('overlay-text');
+  const overlayBtn = document.getElementById('overlay-btn');
+  const scoreEl = document.getElementById('score');
+  const levelEl = document.getElementById('level');
+  const linesEl = document.getElementById('lines');
+
+  function init() {
+    grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
+    score = 0; level = 1; lines = 0;
+    dropInterval = 1000; dropCounter = 0; lastTime = 0;
+    gameOver = false; paused = false;
+    nextType = randomType();
+    spawn();
+    hideOverlay();
+    updateUI();
+    draw();
+    drawNext();
+  }
+
+  function randomType() {
+    return TYPES[Math.floor(Math.random() * TYPES.length)];
+  }
+
+  function spawn() {
+    const type = nextType;
+    nextType = randomType();
+    current = {
+      type,
+      rotation: 0,
+      x: 3,
+      y: -1,
+    };
+    drawNext();
+    if (collides(current, 0, 0)) {
+      // game over
+      gameOver = true;
+      showOverlay('游戏结束', `最终得分: ${score}`, '重新开始');
+    }
+  }
+
+  function collides(piece, dx, dy, rot = piece.rotation) {
+    const cells = SHAPES[piece.type].rotations[rot];
+    for (const [cx, cy] of cells) {
+      const x = piece.x + cx + dx;
+      const y = piece.y + cy + dy;
+      if (x < 0 || x >= COLS || y >= ROWS) return true;
+      if (y >= 0 && grid[y][x]) return true;
+    }
+    return false;
+  }
+
+  function tryMove(dx, dy) {
+    if (collides(current, dx, dy)) return false;
+    current.x += dx;
+    current.y += dy;
+    return true;
+  }
+
+  function rotate(dir) {
+    if (current.type === 'O') return;
+    const from = current.rotation;
+    const to = (from + (dir > 0 ? 1 : 3)) % 4;
+    const kickTable = (current.type === 'I') ? KICKS_I : KICKS_JLSTZ;
+    const key = `${from}->${to}`;
+    const kicks = kickTable[key] || [[0,0]];
+    for (const [kx, ky] of kicks) {
+      // For I piece, kicks are applied as offsets; for others, also
+      if (!collides(current, kx, -ky, to)) {
+        current.x += kx;
+        current.y += -ky;
+        current.rotation = to;
+        return;
+      }
+    }
+  }
+
+  function softDrop() {
+    if (tryMove(0, 1)) {
+      score += 1;
+      updateUI();
+    } else {
+      lock();
+    }
+  }
+
+  function hardDrop() {
+    let dist = 0;
+    while (tryMove(0, 1)) dist++;
+    score += dist * 2;
+    lock();
+  }
+
+  function lock() {
+    const cells = SHAPES[current.type].rotations[current.rotation];
+    for (const [cx, cy] of cells) {
+      const x = current.x + cx;
+      const y = current.y + cy;
+      if (y >= 0 && y < ROWS && x >= 0 && x < COLS) {
+        grid[y][x] = SHAPES[current.type].color;
+      }
+    }
+    const cleared = clearLines();
+    if (cleared > 0) {
+      const base = [0, 100, 300, 500, 800][cleared];
+      const gained = base * level;
+      score += gained;
+      lines += cleared;
+      showCombo(cleared, gained);
+      // Level up every 10 lines
+      const newLevel = Math.floor(lines / 10) + 1;
+      if (newLevel > level) {
+        level = newLevel;
+        dropInterval = Math.max(80, 1000 - (level - 1) * 80);
+      }
+      updateUI();
+      // Flash effect
+      board.classList.add('flash');
+      setTimeout(() => board.classList.remove('flash'), 400);
+    }
+    spawn();
+  }
+
+  function clearLines() {
+    let count = 0;
+    for (let y = ROWS - 1; y >= 0; y--) {
+      if (grid[y].every(c => c !== null)) {
+        grid.splice(y, 1);
+        grid.unshift(Array(COLS).fill(null));
+        count++;
+        y++; // recheck this row
+      }
+    }
+    return count;
+  }
+
+  function showCombo(n, gained) {
+    const labels = {1: '单消!', 2: '双消!', 3: '三消!', 4: 'TETRIS!'};
+    const el = document.createElement('div');
+    el.className = 'combo';
+    el.textContent = `${labels[n] || ''} +${gained}`;
+    el.style.left = (board.width / 2 - 60) + 'px';
+    el.style.top = (board.height / 2) + 'px';
+    board.parentElement.appendChild(el);
+    setTimeout(() => el.remove(), 1000);
+  }
+
+  function updateUI() {
+    scoreEl.textContent = score;
+    levelEl.textContent = level;
+    linesEl.textContent = lines;
+  }
+
+  // ====== Drawing ======
+  function drawCell(ctx, x, y, color, size = CELL) {
+    const px = x * size, py = y * size;
+    // Glow
+    ctx.fillStyle = color;
+    ctx.shadowColor = color;
+    ctx.shadowBlur = 8;
+    ctx.fillRect(px + 1, py + 1, size - 2, size - 2);
+    ctx.shadowBlur = 0;
+    // Highlight
+    ctx.fillStyle = 'rgba(255,255,255,0.25)';
+    ctx.fillRect(px + 2, py + 2, size - 4, 3);
+    ctx.fillRect(px + 2, py + 2, 3, size - 4);
+    // Shadow
+    ctx.fillStyle = 'rgba(0,0,0,0.3)';
+    ctx.fillRect(px + 2, py + size - 5, size - 4, 3);
+    ctx.fillRect(px + size - 5, py + 2, 3, size - 4);
+  }
+
+  function drawGrid() {
+    ctx.strokeStyle = 'rgba(255,255,255,0.04)';
+    ctx.lineWidth = 1;
+    for (let x = 1; x < COLS; x++) {
+      ctx.beginPath();
+      ctx.moveTo(x * CELL + 0.5, 0);
+      ctx.lineTo(x * CELL + 0.5, ROWS * CELL);
+      ctx.stroke();
+    }
+    for (let y = 1; y < ROWS; y++) {
+      ctx.beginPath();
+      ctx.moveTo(0, y * CELL + 0.5);
+      ctx.lineTo(COLS * CELL, y * CELL + 0.5);
+      ctx.stroke();
+    }
+  }
+
+  function drawGhost() {
+    if (!current || gameOver) return;
+    const cells = SHAPES[current.type].rotations[current.rotation];
+    let gy = current.y;
+    while (!collides({...current, y: gy + 1}, 0, 0)) gy++;
+    ctx.fillStyle = 'rgba(255,255,255,0.12)';
+    for (const [cx, cy] of cells) {
+      const x = current.x + cx;
+      const y = gy + cy;
+      if (y >= 0) {
+        ctx.fillRect(x * CELL + 2, y * CELL + 2, CELL - 4, CELL - 4);
+      }
+    }
+  }
+
+  function draw() {
+    ctx.fillStyle = '#0a0a18';
+    ctx.fillRect(0, 0, board.width, board.height);
+    drawGrid();
+    for (let y = 0; y < ROWS; y++) {
+      for (let x = 0; x < COLS; x++) {
+        if (grid[y][x]) drawCell(ctx, x, y, grid[y][x]);
+      }
+    }
+    if (current && !gameOver) {
+      drawGhost();
+      const cells = SHAPES[current.type].rotations[current.rotation];
+      for (const [cx, cy] of cells) {
+        const x = current.x + cx;
+        const y = current.y + cy;
+        if (y >= 0) drawCell(ctx, x, y, SHAPES[current.type].color);
+      }
+    }
+  }
+
+  function drawNext() {
+    nctx.fillStyle = '#0a0a18';
+    nctx.fillRect(0, 0, next.width, next.height);
+    const cells = SHAPES[nextType].rotations[0];
+    const cs = 20;
+    // Find bounds to center
+    let minX = 4, maxX = 0, minY = 4, maxY = 0;
+    for (const [x, y] of cells) {
+      if (x < minX) minX = x;
+      if (x > maxX) maxX = x;
+      if (y < minY) minY = y;
+      if (y > maxY) maxY = y;
+    }
+    const w = (maxX - minX + 1) * cs;
+    const h = (maxY - minY + 1) * cs;
+    const ox = (next.width - w) / 2 - minX * cs;
+    const oy = (next.height - h) / 2 - minY * cs;
+    for (const [x, y] of cells) {
+      nctx.shadowColor = SHAPES[nextType].color;
+      nctx.shadowBlur = 8;
+      nctx.fillStyle = SHAPES[nextType].color;
+      nctx.fillRect(ox + x * cs + 1, oy + y * cs + 1, cs - 2, cs - 2);
+    }
+    nctx.shadowBlur = 0;
+  }
+
+  // ====== Overlay ======
+  function showOverlay(title, text, btn) {
+    overlayTitle.textContent = title;
+    overlayText.textContent = text;
+    overlayBtn.textContent = btn;
+    overlay.classList.remove('hidden');
+  }
+  function hideOverlay() {
+    overlay.classList.add('hidden');
+  }
+  overlayBtn.addEventListener('click', () => {
+    if (gameOver) {
+      init();
+    } else {
+      paused = false;
+      hideOverlay();
+      lastTime = performance.now();
+    }
+  });
+
+  // ====== Input ======
+  function togglePause() {
+    if (gameOver) return;
+    paused = !paused;
+    if (paused) showOverlay('已暂停', '按 空格 继续', '继续');
+    else { hideOverlay(); lastTime = performance.now(); }
+  }
+
+  function restart() {
+    init();
+  }
+
+  document.addEventListener('keydown', (e) => {
+    if (gameOver && e.key.toLowerCase() !== 'q') return;
+    switch (e.key) {
+      case 'ArrowLeft':
+        if (!paused) tryMove(-1, 0);
+        e.preventDefault(); break;
+      case 'ArrowRight':
+        if (!paused) tryMove(1, 0);
+        e.preventDefault(); break;
+      case 'ArrowDown':
+        if (!paused) softDrop();
+        e.preventDefault(); break;
+      case 'Enter':
+        if (!paused) hardDrop();
+        e.preventDefault(); break;
+      case 'z': case 'Z':
+        if (!paused) rotate(-1);
+        e.preventDefault(); break;
+      case 'x': case 'X':
+        if (!paused) rotate(1);
+        e.preventDefault(); break;
+      case ' ':
+        togglePause();
+        e.preventDefault(); break;
+      case 'q': case 'Q':
+        restart();
+        e.preventDefault(); break;
+    }
+  });
+
+  // Touch controls
+  const touchHold = { ArrowLeft: false, ArrowRight: false, ArrowDown: false };
+  let holdTimer = null;
+  function pressHold(key, start) {
+    if (gameOver || paused) return;
+    if (start) {
+      touchHold[key] = true;
+      if (holdTimer) clearInterval(holdTimer);
+      holdTimer = setInterval(() => {
+        if (touchHold.ArrowLeft) tryMove(-1, 0);
+        if (touchHold.ArrowRight) tryMove(1, 0);
+        if (touchHold.ArrowDown) softDrop();
+      }, 80);
+    } else {
+      touchHold[key] = false;
+      if (!touchHold.ArrowLeft && !touchHold.ArrowRight && !touchHold.ArrowDown) {
+        clearInterval(holdTimer); holdTimer = null;
+      }
+    }
+  }
+  document.querySelectorAll('#touch button').forEach(btn => {
+    const act = btn.dataset.act;
+    const fire = (e) => {
+      e.preventDefault();
+      if (gameOver && act !== 'restart') return;
+      switch (act) {
+        case 'left': if (!paused) tryMove(-1, 0); pressHold('ArrowLeft', true); break;
+        case 'right': if (!paused) tryMove(1, 0); pressHold('ArrowRight', true); break;
+        case 'down': if (!paused) softDrop(); pressHold('ArrowDown', true); break;
+        case 'rotateL': if (!paused) rotate(-1); break;
+        case 'rotateR': if (!paused) rotate(1); break;
+        case 'drop': if (!paused) hardDrop(); break;
+        case 'pause': togglePause(); break;
+        case 'restart': restart(); break;
+      }
+    };
+    const release = (e) => {
+      e.preventDefault();
+      const act = btn.dataset.act;
+      if (act === 'left') pressHold('ArrowLeft', false);
+      if (act === 'right') pressHold('ArrowRight', false);
+      if (act === 'down') pressHold('ArrowDown', false);
+    };
+    btn.addEventListener('touchstart', fire, {passive: false});
+    btn.addEventListener('touchend', release, {passive: false});
+    btn.addEventListener('touchcancel', release, {passive: false});
+    btn.addEventListener('mousedown', fire);
+    btn.addEventListener('mouseup', release);
+    btn.addEventListener('mouseleave', release);
+  });
+
+  // ====== Game loop ======
+  function loop(time = 0) {
+    if (!gameOver && !paused) {
+      const delta = time - lastTime;
+      dropCounter += delta;
+      if (dropCounter > dropInterval) {
+        if (!tryMove(0, 1)) lock();
+        dropCounter = 0;
+      }
+      lastTime = time;
+      draw();
+    }
+    requestAnimationFrame(loop);
+  }
+
+  init();
+  requestAnimationFrame(loop);
+})();
+</script>
+
+</body>
+</html>