(() => {
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();
}
},
};
}
})();