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;