git » sparkle-arcade » master » tree

[master] / crystal-cloud-carousel / game.js

(() => {
  const lanesContainer = document.getElementById('lanes');
  const scoreEl = document.getElementById('score');
  const comboEl = document.getElementById('combo');
  const timeEl = document.getElementById('time');
  const newGameBtn = document.getElementById('new-game');
  const overlay = document.getElementById('overlay');
  const startBtn = document.getElementById('start');
  const hitButtons = document.getElementById('hit-buttons');

  const laneColors = ['#ff9adf', '#c7a6ff', '#8fcbff', '#ffaccd'];
  const laneEmojis = ['💗', '💜', '💙', '💖'];
  const lanes = Array.from(lanesContainer.querySelectorAll('.lane'));

  const NOTE_SPEED = 180; // px per second
  const SONG_DURATION = 45; // seconds
  const TARGET_PAD = 90;
  const BEAT_INTERVAL = 0.55; // seconds per beat
  const lanePattern = [0, 1, 2, 1, 3, 2, 0, 2, 1, 3, 0, 2, 1, 0, 3, 1];

  let notes = [];
  let score = 0;
  let combo = 0;
  let isActive = false;
  let remaining = SONG_DURATION;
  let timerInterval = null;
  let beatTimer = 0;
  let patternIndex = 0;
  let lastTimestamp = null;
  let laneHeight = 0;
  let targetStart = 0;
  let targetEnd = 0;
  let songPlayer = null;

  function measureStage() {
    const rect = lanes[0].getBoundingClientRect();
    laneHeight = rect.height;
    targetEnd = laneHeight - 70;
    targetStart = targetEnd - TARGET_PAD;
  }

  function spawnNote(laneIndex) {
    const lane = typeof laneIndex === 'number' ? laneIndex : Math.floor(Math.random() * lanes.length);
    const noteEl = document.createElement('div');
    noteEl.className = 'note';
    noteEl.textContent = laneEmojis[lane];
    noteEl.style.background = `linear-gradient(135deg, ${laneColors[lane]}, rgba(255,255,255,0.9))`;
    lanes[lane].appendChild(noteEl);
    notes.push({ lane, y: -30, el: noteEl });
  }

  function removeNote(note) {
    note.el.remove();
    notes = notes.filter((n) => n !== note);
  }

  function hitLane(lane) {
    if (!isActive) return;
    flashLane(lane);
    const targetNote = notes.find((note) => note.lane === lane && note.y >= targetStart && note.y <= targetEnd);
    if (targetNote) {
      const distance = Math.abs(targetNote.y - targetEnd + TARGET_PAD / 2);
      const bonus = distance < 20 ? 120 : 90;
      score += bonus + combo * 2;
      combo += 1;
      updateHUD();
      if (songPlayer) songPlayer.hitTone(lane);
      showPop(`+${bonus}`, targetNote.el);
      removeNote(targetNote);
    } else {
      combo = 0;
      updateHUD();
    }
  }

  function flashLane(lane) {
    const laneEl = lanes[lane];
    laneEl.classList.add('lane--active');
    setTimeout(() => laneEl.classList.remove('lane--active'), 120);
  }

  function showPop(text, anchor) {
    const pop = document.createElement('span');
    pop.textContent = `${text} ✨`;
    pop.style.position = 'absolute';
    pop.style.left = '50%';
    pop.style.transform = 'translate(-50%, -50%)';
    pop.style.top = `${anchor.offsetTop}px`;
    pop.style.color = '#ff5fb1';
    pop.style.fontWeight = '700';
    pop.style.pointerEvents = 'none';
    anchor.parentElement.appendChild(pop);
    pop.animate(
      [
        { opacity: 1, transform: 'translate(-50%, -20px)' },
        { opacity: 0, transform: 'translate(-50%, -60px)' }
      ],
      { duration: 600 }
    ).onfinish = () => pop.remove();
  }

  function updateHUD() {
    scoreEl.textContent = score;
    comboEl.textContent = combo;
  }

  function startSong() {
    measureStage();
    notes.forEach((note) => note.el.remove());
    notes = [];
    score = 0;
    combo = 0;
    remaining = SONG_DURATION;
    beatTimer = 0;
    patternIndex = 0;
    lastTimestamp = null;
    updateHUD();
    timeEl.textContent = remaining;
    if (timerInterval) clearInterval(timerInterval);
    timerInterval = setInterval(() => {
      remaining -= 1;
      if (remaining <= 0) {
        remaining = 0;
        endSong();
      }
      timeEl.textContent = remaining;
    }, 1000);
    isActive = true;
    overlay.classList.add('hidden');
    if (!songPlayer) songPlayer = createSongPlayer();
    songPlayer.start();
  }

  function endSong() {
    if (!isActive) return;
    isActive = false;
    clearInterval(timerInterval);
    timerInterval = null;
    overlay.querySelector('p').textContent = `Final score: ${score} ✨ Combo: ${combo}`;
    overlay.classList.remove('hidden');
    if (songPlayer) songPlayer.stop();
  }

  function update(timestamp) {
    if (!lastTimestamp) lastTimestamp = timestamp;
    const delta = (timestamp - lastTimestamp) / 1000;
    lastTimestamp = timestamp;
    if (isActive) {
      beatTimer += delta;
      while (beatTimer >= BEAT_INTERVAL) {
        const lane = lanePattern[patternIndex % lanePattern.length];
        patternIndex += 1;
        spawnNote(lane);
        beatTimer -= BEAT_INTERVAL;
      }
      notes.slice().forEach((note) => {
        note.y += NOTE_SPEED * delta;
        note.el.style.transform = `translateY(${note.y}px)`;
        if (note.y > laneHeight + 40) {
          removeNote(note);
          combo = 0;
          updateHUD();
        } else if (note.y > targetEnd + 40) {
          removeNote(note);
          combo = 0;
          updateHUD();
        }
      });
      if (remaining <= 0 && notes.length === 0) {
        endSong();
      }
    }
    requestAnimationFrame(update);
  }

  function handleKey(event) {
    if (event.repeat) return;
    const keyMap = ['1', '2', '3', '4'];
    const index = keyMap.indexOf(event.key);
    if (index !== -1) {
      hitLane(index);
    }
  }

  hitButtons.addEventListener('click', (event) => {
    const button = event.target.closest('button');
    if (!button) return;
    const lane = Number(button.dataset.index);
    hitLane(lane);
  });

  lanes.forEach((laneEl) => {
    laneEl.addEventListener('pointerdown', () => {
      const lane = Number(laneEl.dataset.index);
      hitLane(lane);
    });
  });

  document.addEventListener('keydown', handleKey);
  newGameBtn.addEventListener('click', startSong);
  startBtn.addEventListener('click', startSong);

  requestAnimationFrame(update);

  function createSongPlayer() {
    const pattern = [523.25, 659.25, 587.33, 659.25, 783.99, 659.25, 587.33, 523.25];
    const totalDuration = pattern.length * BEAT_INTERVAL;
    return {
      ctx: null,
      gain: null,
      nextTime: 0,
      timeoutId: null,
      isPlaying: false,
      ensureContext() {
        if (!this.ctx) {
          const AudioCtx = window.AudioContext || window.webkitAudioContext;
          this.ctx = new AudioCtx();
          this.gain = this.ctx.createGain();
          this.gain.gain.value = 0.16;
          this.gain.connect(this.ctx.destination);
        }
      },
      start() {
        this.ensureContext();
        if (this.isPlaying) return;
        this.ctx.resume();
        this.isPlaying = true;
        this.nextTime = this.ctx.currentTime + 0.05;
        this.loop();
      },
      loop() {
        if (!this.isPlaying) return;
        let time = this.nextTime;
        pattern.forEach((freq) => {
          this.scheduleNote(freq, time, BEAT_INTERVAL * 0.75);
          time += BEAT_INTERVAL;
        });
        this.nextTime = time;
        this.timeoutId = setTimeout(() => this.loop(), totalDuration * 1000);
      },
      scheduleNote(freq, start, duration) {
        const osc = this.ctx.createOscillator();
        const gainNode = this.ctx.createGain();
        osc.type = 'triangle';
        osc.frequency.value = freq;
        osc.connect(gainNode);
        gainNode.connect(this.gain);
        gainNode.gain.setValueAtTime(0.0001, start);
        gainNode.gain.linearRampToValueAtTime(0.25, start + 0.03);
        gainNode.gain.exponentialRampToValueAtTime(0.001, start + duration);
        osc.start(start);
        osc.stop(start + duration + 0.02);
      },
      hitTone(lane) {
        this.ensureContext();
        const freq = 440 + lane * 80;
        this.scheduleNote(freq, this.ctx.currentTime, 0.25);
      },
      stop() {
        if (!this.isPlaying) return;
        this.isPlaying = false;
        if (this.timeoutId) {
          clearTimeout(this.timeoutId);
          this.timeoutId = null;
        }
        if (this.ctx && this.ctx.state !== 'closed') {
          this.ctx.suspend();
        }
      },
    };
  }
})();