git » sparkle-arcade » master » tree

[master] / starlit-stacker / game.js

(() => {
  const rows = 20;
  const cols = 10;
  const boardEl = document.getElementById('board');
  const previewEl = document.getElementById('preview');
  const scoreEl = document.getElementById('score');
  const linesEl = document.getElementById('lines');
  const bestEl = document.getElementById('best');
  const newGameBtn = document.getElementById('new-game');
  const overlay = document.getElementById('overlay');
  const startBtn = document.getElementById('start');
  const touchControls = document.getElementById('touch-controls');

  const bestKey = 'starlitBestScore';

  const shapes = [
    [[1, 1, 1, 1]],
    [[1, 0, 0], [1, 1, 1]],
    [[0, 0, 1], [1, 1, 1]],
    [[1, 1], [1, 1]],
    [[0, 1, 1], [1, 1, 0]],
    [[0, 1, 0], [1, 1, 1]],
    [[1, 1, 0], [0, 1, 1]],
  ];

  const colors = ['#f9b5ff', '#9dd1ff', '#a3ffde', '#ffd4a8', '#ff9bd1', '#c4a5ff', '#a5ffe3'];

  let board = createBoard();
  let current = null;
  let nextPiece = null;
  let dropTimer = null;
  let dropInterval = 800;
  let score = 0;
  let lines = 0;
  let bestScore = Number(localStorage.getItem(bestKey)) || 0;
  let isActive = false;
  const audio = createAudio();

  const cells = [];

  function initGrid() {
    boardEl.innerHTML = '';
    for (let r = 0; r < rows; r += 1) {
      const rowCells = [];
      for (let c = 0; c < cols; c += 1) {
        const cell = document.createElement('div');
        cell.className = 'cell';
        boardEl.appendChild(cell);
        rowCells.push(cell);
      }
      cells.push(rowCells);
    }
  }

  function createBoard() {
    return Array.from({ length: rows }, () => Array(cols).fill(null));
  }

  function randomPiece() {
    const idx = Math.floor(Math.random() * shapes.length);
    return {
      shape: shapes[idx].map((row) => row.slice()),
      row: 0,
      col: Math.floor(cols / 2) - 2,
      color: colors[idx],
    };
  }

  function spawnPiece() {
    current = nextPiece || randomPiece();
    current.row = -1;
    current.col = Math.floor(cols / 2) - Math.ceil(current.shape[0].length / 2);
    nextPiece = randomPiece();
    renderPreview();
    if (!canMove(current.shape, current.row + 1, current.col)) {
      endGame();
      return;
    }
    current.row += 1;
  }

  function renderPreview() {
    previewEl.innerHTML = '';
    const grid = Array.from({ length: 4 }, () => Array(4).fill(false));
    nextPiece.shape.forEach((row, r) => {
      row.forEach((val, c) => {
        if (val && r < 4 && c < 4) grid[r][c] = true;
      });
    });
    grid.forEach((row) => {
      row.forEach((filled) => {
        const cell = document.createElement('div');
        cell.className = 'preview-cell';
        if (filled) {
          cell.style.background = nextPiece.color;
        }
        previewEl.appendChild(cell);
      });
    });
  }

  function canMove(shape, targetRow, targetCol) {
    for (let r = 0; r < shape.length; r += 1) {
      for (let c = 0; c < shape[r].length; c += 1) {
        if (!shape[r][c]) continue;
        const newRow = targetRow + r;
        const newCol = targetCol + c;
        if (newCol < 0 || newCol >= cols || newRow >= rows) return false;
        if (newRow >= 0 && board[newRow][newCol]) return false;
      }
    }
    return true;
  }

  function placePiece() {
    const piece = current;
    piece.shape.forEach((row, r) => {
      row.forEach((val, c) => {
        if (val) {
          const boardRow = piece.row + r;
          const boardCol = piece.col + c;
          if (boardRow >= 0) {
            board[boardRow][boardCol] = current.color;
          }
        }
      });
    });
    current = null;
    playSound('lock');
    const cleared = clearLines();
    if (cleared === 0) {
      spawnPiece();
    } else {
      playSound(cleared >= 4 ? 'tetris' : 'clear');
      setTimeout(spawnPiece, 60);
    }
  }

  function rotate(shape) {
    const size = shape.length;
    const width = shape[0].length;
    const newShape = Array.from({ length: width }, () => Array(size).fill(0));
    for (let r = 0; r < size; r += 1) {
      for (let c = 0; c < width; c += 1) {
        newShape[c][size - 1 - r] = shape[r][c];
      }
    }
    return newShape;
  }

  function tryRotate() {
    const nextShape = rotate(current.shape);
    if (canMove(nextShape, current.row, current.col)) {
      current.shape = nextShape;
    }
  }

  function clearLines() {
    let cleared = 0;
    for (let r = rows - 1; r >= 0; r -= 1) {
      if (board[r].every(Boolean)) {
        board.splice(r, 1);
        board.unshift(Array(cols).fill(null));
        cleared += 1;
        r += 1;
      }
    }
    if (cleared > 0) {
      score += cleared * 100;
      lines += cleared;
      dropInterval = Math.max(200, 800 - lines * 10);
      updateHUD();
      restartDropTimer();
    }
    return cleared;
  }

  function moveDown() {
    if (!current) return;
    if (canMove(current.shape, current.row + 1, current.col)) {
      current.row += 1;
    } else {
      placePiece();
    }
    render();
  }

  function hardDrop() {
    if (!current) return;
    while (canMove(current.shape, current.row + 1, current.col)) {
      current.row += 1;
    }
    placePiece();
    render();
    playSound('drop');
  }

  function render() {
    for (let r = 0; r < rows; r += 1) {
      for (let c = 0; c < cols; c += 1) {
        const cell = cells[r][c];
        const color = board[r][c];
        if (color) {
          cell.classList.add('filled');
          cell.style.background = color;
        } else {
          cell.classList.remove('filled');
          cell.style.background = 'var(--cell-empty)';
        }
      }
    }
    if (current) {
      current.shape.forEach((row, rIndex) => {
        row.forEach((val, cIndex) => {
          if (val) {
            const br = current.row + rIndex;
            const bc = current.col + cIndex;
            if (br >= 0 && br < rows && bc >= 0 && bc < cols) {
              const cell = cells[br][bc];
              cell.classList.add('filled');
              cell.style.background = current.color;
            }
          }
        });
      });
    }
  }

  function updateHUD() {
    scoreEl.textContent = score;
    linesEl.textContent = lines;
    if (score > bestScore) {
      bestScore = score;
      localStorage.setItem(bestKey, String(bestScore));
    }
    bestEl.textContent = bestScore;
  }

  function startGame() {
    if (audio) audio.resume();
    board = createBoard();
    score = 0;
    lines = 0;
    dropInterval = 800;
    updateHUD();
    if (dropTimer) clearInterval(dropTimer);
    current = null;
    nextPiece = randomPiece();
    spawnPiece();
    render();
    dropTimer = setInterval(moveDown, dropInterval);
    isActive = true;
    overlay.classList.add('hidden');
  }

  function endGame() {
    isActive = false;
    clearInterval(dropTimer);
    dropTimer = null;
    overlay.querySelector('p').textContent = `Final score: ${score} ⭐`;
    overlay.classList.remove('hidden');
  }

  function restartDropTimer() {
    if (dropTimer) {
      clearInterval(dropTimer);
    }
    dropTimer = setInterval(moveDown, dropInterval);
  }

  function move(direction) {
    if (!isActive) return;
    const offset = direction === 'left' ? -1 : 1;
    if (canMove(current.shape, current.row, current.col + offset)) {
      current.col += offset;
      render();
      playSound('move');
    }
  }

  function softDrop() {
    if (canMove(current.shape, current.row + 1, current.col)) {
      current.row += 1;
      score += 1;
      updateHUD();
      render();
      playSound('soft');
    }
  }

  document.addEventListener('keydown', (event) => {
    if (!isActive && event.code !== 'Space') return;
    switch (event.code) {
      case 'ArrowLeft':
        event.preventDefault();
        move('left');
        break;
      case 'ArrowRight':
        event.preventDefault();
        move('right');
        break;
      case 'ArrowUp':
        event.preventDefault();
        tryRotate();
        render();
        playSound('rotate');
        break;
      case 'ArrowDown':
        event.preventDefault();
        softDrop();
        break;
      case 'Space':
        event.preventDefault();
        hardDrop();
        break;
      default:
        break;
    }
  });

  touchControls.addEventListener('click', (event) => {
    const button = event.target.closest('button');
    if (!button) return;
    const action = button.dataset.action;
    if (!isActive && action !== 'drop') return;
    switch (action) {
      case 'left':
        move('left');
        break;
      case 'right':
        move('right');
        break;
      case 'rotate':
        tryRotate();
        render();
        break;
      case 'drop':
        softDrop();
        break;
      default:
        break;
    }
  });

  newGameBtn.addEventListener('click', startGame);
  startBtn.addEventListener('click', startGame);

  bestEl.textContent = bestScore;
  initGrid();
  render();

  function playSound(type) {
    if (audio) audio.play(type);
  }

  function createAudio() {
    const AudioCtx = window.AudioContext || window.webkitAudioContext;
    if (!AudioCtx) return null;
    const ctx = new AudioCtx();
    const master = ctx.createGain();
    master.gain.value = 0.18;
    master.connect(ctx.destination);
    ctx.suspend();
    const tones = {
      move: 760,
      rotate: 840,
      lock: 520,
      clear: 960,
      tetris: 0,
      drop: 620,
      soft: 700,
    };
    function schedule(freq, duration = 0.18, delay = 0) {
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.type = 'triangle';
      osc.frequency.value = freq;
      osc.connect(gain);
      gain.connect(master);
      const start = ctx.currentTime + delay;
      gain.gain.setValueAtTime(0.0001, start);
      gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
      gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
      osc.start(start);
      osc.stop(start + duration + 0.02);
    }
    return {
      resume() {
        if (ctx.state === 'suspended') ctx.resume();
      },
      play(type) {
        if (type === 'tetris') {
          [880, 980, 1100, 1300].forEach((freq, i) => schedule(freq, 0.2, i * 0.05));
          return;
        }
        const freq = tones[type] || 600;
        schedule(freq);
      },
    };
  }
})();