Skip to content
Open

. #3

Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 137 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ <h2>게임 오버</h2>
let isGameOver = false;
let dropCooldownTimer = 0;
let idCounter = 0;
let effects = [];
let screenShake = 0;
let audioContext = null;

let currentFruitTier = randomDropTier();
let nextFruitTier = randomDropTier();
Expand All @@ -225,14 +228,101 @@ <h2>게임 오버</h2>
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");
Expand Down Expand Up @@ -280,6 +370,7 @@ <h2>게임 오버</h2>
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();
Expand All @@ -294,6 +385,7 @@ <h2>게임 오버</h2>

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;
Expand Down Expand Up @@ -367,6 +459,9 @@ <h2>게임 오버</h2>
toRemove.add(b.id);
toAdd.push(createFruit(nextTier, nx, ny));

createMergeEffect(nx, ny, nextTier);
playMergeSound(nextTier);

score += FRUITS[nextTier].score;
}
}
Expand Down Expand Up @@ -462,8 +557,16 @@ <h2>게임 오버</h2>

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;
Expand All @@ -473,7 +576,29 @@ <h2>게임 오버</h2>
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() {
Expand Down Expand Up @@ -508,9 +633,18 @@ <h2>게임 오버</h2>

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

// ---- 메인 루프 ----
Expand All @@ -523,6 +657,7 @@ <h2>게임 오버</h2>
let frameDt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
frameDt = Math.min(frameDt, 0.05); // 탭 전환 등으로 인한 큰 점프 방지
updateEffects(frameDt);

if (dropCooldownTimer > 0) dropCooldownTimer -= frameDt;

Expand Down