diff --git a/index.html b/index.html index 07807ed..414b9bc 100644 --- a/index.html +++ b/index.html @@ -202,6 +202,9 @@

게임 오버

let isGameOver = false; let dropCooldownTimer = 0; let idCounter = 0; + let effects = []; + let screenShake = 0; + let audioContext = null; let currentFruitTier = randomDropTier(); let nextFruitTier = randomDropTier(); @@ -225,14 +228,101 @@

게임 오버

radius: def.radius, dangerTime: 0, settled: false, + spawnAge: 0, }; } + // 별도 음원 파일 없이 Web Audio API로 짧은 효과음을 만든다. + function getAudioContext() { + if (!audioContext) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return null; + audioContext = new AudioContext(); + } + if (audioContext.state === "suspended") audioContext.resume(); + return audioContext; + } + + function playTone({ frequency, endFrequency, duration, volume, type = "sine" }) { + const audio = getAudioContext(); + if (!audio) return; + + const now = audio.currentTime; + const oscillator = audio.createOscillator(); + const gain = audio.createGain(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(frequency, now); + oscillator.frequency.exponentialRampToValueAtTime(endFrequency, now + duration); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(volume, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + oscillator.connect(gain).connect(audio.destination); + oscillator.start(now); + oscillator.stop(now + duration + 0.02); + } + + function playDropSound(tier) { + playTone({ + frequency: 250 - tier * 14, + endFrequency: 105 - tier * 4, + duration: 0.13, + volume: 0.055, + type: "triangle", + }); + } + + function playMergeSound(tier) { + const base = 360 + tier * 55; + playTone({ + frequency: base, + endFrequency: base * 1.55, + duration: 0.18, + volume: 0.07, + type: "sine", + }); + } + + function createMergeEffect(x, y, tier) { + const color = FRUITS[tier].color; + effects.push({ type: "ring", x, y, age: 0, duration: 0.38, color }); + for (let i = 0; i < 12; i++) { + const angle = (Math.PI * 2 * i) / 12 + Math.random() * 0.25; + const speed = 65 + Math.random() * 95; + effects.push({ + type: "particle", + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + radius: 2 + Math.random() * 3, + age: 0, + duration: 0.35 + Math.random() * 0.18, + color, + }); + } + screenShake = Math.min(7, 2.5 + tier * 0.45); + } + + function updateEffects(dt) { + for (const effect of effects) { + effect.age += dt; + if (effect.type === "particle") { + effect.x += effect.vx * dt; + effect.y += effect.vy * dt; + effect.vy += 180 * dt; + } + } + effects = effects.filter((effect) => effect.age < effect.duration); + screenShake = Math.max(0, screenShake - 24 * dt); + } + function resetGame() { fruits = []; score = 0; isGameOver = false; dropCooldownTimer = 0; + effects = []; + screenShake = 0; currentFruitTier = randomDropTier(); nextFruitTier = randomDropTier(); gameOverEl.classList.add("hidden"); @@ -280,6 +370,7 @@

게임 오버

const clampedX = Math.max(radius, Math.min(W - radius, x)); fruits.push(createFruit(currentFruitTier, clampedX, LINE_Y - radius - 10)); + playDropSound(currentFruitTier); currentFruitTier = nextFruitTier; nextFruitTier = randomDropTier(); @@ -294,6 +385,7 @@

게임 오버

function stepPhysics(dt) { for (const f of fruits) { + f.spawnAge += dt; f.vy += GRAVITY * dt; f.x += f.vx * dt; f.y += f.vy * dt; @@ -367,6 +459,9 @@

게임 오버

toRemove.add(b.id); toAdd.push(createFruit(nextTier, nx, ny)); + createMergeEffect(nx, ny, nextTier); + playMergeSound(nextTier); + score += FRUITS[nextTier].score; } } @@ -462,8 +557,16 @@

게임 오버

function drawFruit(f) { const def = FRUITS[f.tier]; + // 새 과일은 살짝 커졌다 제자리로 돌아오며 나타난다. + const progress = Math.min(1, f.spawnAge / 0.22); + const scale = progress < 1 + ? 1 + Math.sin(progress * Math.PI) * 0.18 - (1 - progress) * 0.35 + : 1; + ctx.save(); + ctx.translate(f.x, f.y); + ctx.scale(scale, scale); ctx.beginPath(); - ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2); + ctx.arc(0, 0, f.radius, 0, Math.PI * 2); ctx.fillStyle = def.color; ctx.fill(); ctx.lineWidth = 2; @@ -473,7 +576,29 @@

게임 오버

ctx.font = `${f.radius}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText(def.emoji, f.x, f.y); + ctx.fillText(def.emoji, 0, 0); + ctx.restore(); + } + + function drawEffects() { + for (const effect of effects) { + const progress = effect.age / effect.duration; + ctx.save(); + ctx.globalAlpha = 1 - progress; + if (effect.type === "ring") { + ctx.beginPath(); + ctx.arc(effect.x, effect.y, 10 + progress * 55, 0, Math.PI * 2); + ctx.strokeStyle = effect.color; + ctx.lineWidth = 6 * (1 - progress) + 1; + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(effect.x, effect.y, effect.radius * (1 - progress * 0.4), 0, Math.PI * 2); + ctx.fillStyle = effect.color; + ctx.fill(); + } + ctx.restore(); + } } function drawCurrentFruit() { @@ -508,9 +633,18 @@

게임 오버

function render() { ctx.clearRect(0, 0, W, H); + ctx.save(); + if (screenShake > 0) { + ctx.translate( + (Math.random() - 0.5) * screenShake * 2, + (Math.random() - 0.5) * screenShake * 2 + ); + } drawLine(); for (const f of fruits) drawFruit(f); + drawEffects(); drawCurrentFruit(); + ctx.restore(); } // ---- 메인 루프 ---- @@ -523,6 +657,7 @@

게임 오버

let frameDt = (timestamp - lastTime) / 1000; lastTime = timestamp; frameDt = Math.min(frameDt, 0.05); // 탭 전환 등으로 인한 큰 점프 방지 + updateEffects(frameDt); if (dropCooldownTimer > 0) dropCooldownTimer -= frameDt;