git » sparkle-arcade » master » tree

[master] / moonlit-garden-match / game.js

(() => {
  const size = 6;
  const scoreEl = document.getElementById('score');
  const movesEl = document.getElementById('moves');
  const boardEl = document.getElementById('board');
  const newGameBtn = document.getElementById('new-game');
  const overlay = document.getElementById('overlay');
  const startBtn = document.getElementById('start');

  const tiles = ['🌙', '🍄', '🦋', '🌸', '💫', '🌿'];

  let grid = [];
  let selected = null;
  let score = 0;
  let moves = 25;
  let busy = false;
  const audio = createAudio();

  function createBoard() {
    const newGrid = Array.from({ length: size }, () => Array(size).fill(null));
    for (let r = 0; r < size; r += 1) {
      for (let c = 0; c < size; c += 1) {
        let value;
        do {
          value = randomTile();
          newGrid[r][c] = value;
        } while (createsMatch(newGrid, r, c));
      }
    }
    return newGrid;
  }

  function randomTile() {
    return tiles[Math.floor(Math.random() * tiles.length)];
  }

  function createsMatch(gridRef, r, c) {
    const value = gridRef[r][c];
    if (!value) return false;
    const horiz = c >= 2 && gridRef[r][c - 1] === value && gridRef[r][c - 2] === value;
    const vert = r >= 2 && gridRef[r - 1][c] === value && gridRef[r - 2][c] === value;
    return horiz || vert;
  }

  function renderBoard() {
    boardEl.innerHTML = '';
    for (let r = 0; r < size; r += 1) {
      for (let c = 0; c < size; c += 1) {
        const tile = document.createElement('button');
        tile.type = 'button';
        tile.className = 'tile';
        tile.textContent = grid[r][c];
        tile.dataset.row = r;
        tile.dataset.col = c;
        if (selected && selected.row === r && selected.col === c) {
          tile.classList.add('selected');
        }
        tile.addEventListener('click', () => handleSelect(r, c));
        boardEl.appendChild(tile);
      }
    }
  }

  function handleSelect(row, col) {
    if (busy) return;
    if (!selected) {
      selected = { row, col };
      renderBoard();
      return;
    }
    if (selected.row === row && selected.col === col) {
      selected = null;
      renderBoard();
      return;
    }
    if (!areNeighbors(selected, { row, col })) {
      selected = { row, col };
      renderBoard();
      return;
    }
    attemptSwap(selected, { row, col });
  }

  function areNeighbors(a, b) {
    const dr = Math.abs(a.row - b.row);
    const dc = Math.abs(a.col - b.col);
    return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
  }

  function attemptSwap(a, b) {
    busy = true;
    swap(a, b);
    const matches = findMatches();
    moves = Math.max(0, moves - 1);
    updateHUD();
    selected = null;
    renderBoard();
    if (matches.length === 0) {
      busy = false;
      playSound('bump');
      if (moves === 0) endGame();
      return;
    }
    playSound('swap');
    resolveMatches(matches, 1);
  }

  function swap(a, b) {
    const temp = grid[a.row][a.col];
    grid[a.row][a.col] = grid[b.row][b.col];
    grid[b.row][b.col] = temp;
  }

  function findMatches() {
    const matches = [];
    // horizontal
    for (let r = 0; r < size; r += 1) {
      let run = 1;
      for (let c = 1; c < size; c += 1) {
        if (grid[r][c] && grid[r][c] === grid[r][c - 1]) {
          run += 1;
        } else {
          if (run >= 3) {
            matches.push(...rangeCells(r, c - run, run, 'row'));
          }
          run = 1;
        }
      }
      if (run >= 3) {
        matches.push(...rangeCells(r, size - run, run, 'row'));
      }
    }
    // vertical
    for (let c = 0; c < size; c += 1) {
      let run = 1;
      for (let r = 1; r < size; r += 1) {
        if (grid[r][c] && grid[r][c] === grid[r - 1][c]) {
          run += 1;
        } else {
          if (run >= 3) {
            matches.push(...rangeCells(r - run, c, run, 'col'));
          }
          run = 1;
        }
      }
      if (run >= 3) {
        matches.push(...rangeCells(size - run, c, run, 'col'));
      }
    }
    const seen = new Set();
    return matches.filter(({ row, col }) => {
      const key = `${row}-${col}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  function rangeCells(startRowOrCol, fixed, length, mode) {
    const cells = [];
    for (let i = 0; i < length; i += 1) {
      if (mode === 'row') {
        cells.push({ row: startRowOrCol, col: fixed + i });
      } else {
        cells.push({ row: startRowOrCol + i, col: fixed });
      }
    }
    return cells;
  }

  function resolveMatches(matchCells, chain) {
    if (matchCells.length === 0) {
      busy = false;
      if (moves === 0) endGame();
      return;
    }
    matchCells.forEach(({ row, col }) => {
      grid[row][col] = null;
    });
    score += matchCells.length * 60 * chain;
    updateHUD();
    animateMatches(matchCells);
    playSound(chain > 1 ? 'cascade' : 'match');
    setTimeout(() => {
      applyGravity();
      fillNew();
      renderBoard();
      const nextMatches = findMatches();
      if (nextMatches.length > 0) {
        resolveMatches(nextMatches, chain + 1);
      } else {
        busy = false;
        if (moves === 0) endGame();
      }
    }, 350);
  }

  function animateMatches(cells) {
    const map = new Map();
    Array.from(boardEl.children).forEach((tile) => {
      const r = Number(tile.dataset.row);
      const c = Number(tile.dataset.col);
      map.set(`${r}-${c}`, tile);
    });
    cells.forEach(({ row, col }) => {
      const tile = map.get(`${row}-${col}`);
      if (tile) tile.classList.add('matching');
    });
  }

  function applyGravity() {
    for (let c = 0; c < size; c += 1) {
      let writeRow = size - 1;
      for (let r = size - 1; r >= 0; r -= 1) {
        if (grid[r][c]) {
          grid[writeRow][c] = grid[r][c];
          if (writeRow !== r) {
            grid[r][c] = null;
          }
          writeRow -= 1;
        }
      }
      for (let r = writeRow; r >= 0; r -= 1) {
        grid[r][c] = null;
      }
    }
  }

  function fillNew() {
    for (let r = 0; r < size; r += 1) {
      for (let c = 0; c < size; c += 1) {
        if (!grid[r][c]) {
          grid[r][c] = randomTile();
        }
      }
    }
  }

  function updateHUD() {
    scoreEl.textContent = score;
    movesEl.textContent = moves;
  }

  function startGame() {
    audio.resume();
    grid = createBoard();
    score = 0;
    moves = 25;
    selected = null;
    busy = false;
    updateHUD();
    renderBoard();
    overlay.classList.add('hidden');
  }

  function endGame() {
    overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`;
    overlay.classList.remove('hidden');
    playSound('end');
  }

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

  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();
    function blip(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.001, 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(event) {
        switch (event) {
          case 'bump':
            blip(320);
            break;
          case 'swap':
            blip(640);
            break;
          case 'match':
            blip(900);
            break;
          case 'cascade':
            [900, 1080].forEach((freq, i) => blip(freq, 0.2, i * 0.05));
            break;
          case 'end':
            blip(280, 0.4);
            break;
          default:
            break;
        }
      },
    };
  }
})();