From e904856bee36e0de3708b33618efe6e2881a4b45 Mon Sep 17 00:00:00 2001 From: Ernie Cook Date: Tue, 3 Mar 2026 01:06:43 -0500 Subject: [PATCH] Add sound effects and Void Hunt minigame - Add Web Audio API sound engine with procedural sounds - Add sounds for: pet, feed, spawn, achievement, purchase, release - Add mute toggle button - Add Void Hunt minigame (30s click target game) - Award 5 Void Essence per target caught - Persist mute state to localStorage --- WORKLOG.md | 8 +- src/pages/Chamber.tsx | 174 ++++++++++++++++++++++++++++++++++++++ src/utils/sound.ts | 188 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 src/utils/sound.ts diff --git a/WORKLOG.md b/WORKLOG.md index 96cf131..c000f81 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -48,10 +48,14 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a - Added 2 new voidling types: Glitch (rare green) and Crystal (rare blue) - Added "Void Tycoon" achievement for accumulating 500 Void Essence - Updated biodiversity achievement to require all 6 voidling types +- Added sound effects system using Web Audio API (pet, feed, spawn, achievement, purchase, release) +- Added mute toggle button to control sounds +- Added Void Hunt minigame - click targets for 30 seconds to earn Void Essence ### Next Steps -1. Add sound effects -2. Add voidling minigames +1. Add more minigames +2. Add voidling evolution/combat system +3. Add decorations for the chamber --- diff --git a/src/pages/Chamber.tsx b/src/pages/Chamber.tsx index 3c0af6c..5133be1 100644 --- a/src/pages/Chamber.tsx +++ b/src/pages/Chamber.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' +import { soundEngine } from '../utils/sound' interface Voidling { id: string @@ -215,6 +216,54 @@ export default function Chamber() { const [isAutoCycle, setIsAutoCycle] = useState(true) const [cycleProgress, setCycleProgress] = useState(0) const [showShop, setShowShop] = useState(false) + const [isMuted, setIsMuted] = useState(() => { + const saved = localStorage.getItem('voidling_muted') + return saved === 'true' + }) + const [showGames, setShowGames] = useState(false) + const [gameActive, setGameActive] = useState(false) + const [gameScore, setGameScore] = useState(0) + const [gameTimeLeft, setGameTimeLeft] = useState(30) + const [gameTargets, setGameTargets] = useState<{id: number, x: number, y: number}[]>([]) + + useEffect(() => { + soundEngine.setMuted(isMuted) + localStorage.setItem('voidling_muted', String(isMuted)) + }, [isMuted]) + + useEffect(() => { + if (!gameActive) return + + const timer = setInterval(() => { + setGameTimeLeft(prev => { + if (prev <= 1) { + endGame() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [gameActive]) + + useEffect(() => { + if (!gameActive) return + + const spawnTarget = () => { + const newTarget = { + id: Date.now(), + x: Math.random() * 80 + 10, + y: Math.random() * 60 + 20, + } + setGameTargets(prev => [...prev, newTarget]) + } + + spawnTarget() + const spawnInterval = setInterval(spawnTarget, 1500) + + return () => clearInterval(spawnInterval) + }, [gameActive]) useEffect(() => { const savedVoidlings = localStorage.getItem('voidlings') @@ -264,6 +313,7 @@ export default function Chamber() { if (achievement.condition(voidlings, userStats)) { setUnlockedAchievements(prev => [...prev, achievement.id]) setNewAchievement(achievement) + soundEngine.playAchievement() setTimeout(() => setNewAchievement(null), 3000) } } @@ -398,6 +448,7 @@ export default function Chamber() { vy: pos.vy, pets: 0, } + soundEngine.playSpawn() setVoidlings([...voidlings, newVoidling]) setNamingVoidling(newVoidling.id) setNewName('') @@ -433,6 +484,7 @@ export default function Chamber() { function feedVoidling(id: string) { setFeedingId(id) + soundEngine.playFeed() setVoidlings(voidlings.map(v => { if (v.id === id) { return { ...v, mood: 'happy', vy: v.vy - 0.5 } @@ -449,6 +501,7 @@ export default function Chamber() { function petVoidling(id: string) { setPettingId(id) + soundEngine.playPet() setVoidlings(voidlings.map(v => { if (v.id === id) { return { ...v, mood: 'happy', pets: (v.pets || 0) + 1 } @@ -464,12 +517,41 @@ export default function Chamber() { } function releaseVoidling(id: string) { + soundEngine.playRelease() setVoidlings(voidlings.filter(v => v.id !== id)) } + function startGame() { + setGameActive(true) + setGameScore(0) + setGameTimeLeft(30) + setGameTargets([]) + } + + function endGame() { + setGameActive(false) + const reward = gameScore * 5 + if (reward > 0) { + setUserStats(prev => { + const newStats = { ...prev, voidEssence: prev.voidEssence + reward } + localStorage.setItem('voidling_stats', JSON.stringify(newStats)) + return newStats + }) + } + setGameTargets([]) + } + + function hitTarget(id: number) { + soundEngine.playPet() + setGameScore(prev => prev + 1) + setGameTargets(prev => prev.filter(t => t.id !== id)) + } + function purchaseItem(item: ShopItem) { if (userStats.voidEssence < item.cost) return + soundEngine.playPurchase() + setUserStats(prev => { const newStats = { ...prev, voidEssence: prev.voidEssence - item.cost } localStorage.setItem('voidling_stats', JSON.stringify(newStats)) @@ -603,6 +685,19 @@ export default function Chamber() { > + Summon Voidling + + @@ -797,6 +892,85 @@ export default function Chamber() { })} + + )} + + {showGames && ( +
setShowGames(false)}> +
e.stopPropagation()}> +
+

+ Void Games +

+ +
+ +
+
+
+ 🎯 +
+
Void Hunt
+
Click the targets!
+
+
+

Catch as many targets as you can in 30 seconds. Each catch awards 5 Void Essence!

+ +
+
+
+
+ )} + + {gameActive && ( +
+
+
+ ⏱️ {gameTimeLeft}s +
+
+ 🎯 {gameScore} +
+
+ + {gameTargets.map(target => ( + + ))} +
+ )} + + {!gameActive && gameScore > 0 && ( +
+
+
🎉
+

+ Game Over! +

+

+ You caught {gameScore} targets! +

+

+ +{gameScore * 5} Void Essence +

+ +
)} diff --git a/src/utils/sound.ts b/src/utils/sound.ts new file mode 100644 index 0000000..9932cdb --- /dev/null +++ b/src/utils/sound.ts @@ -0,0 +1,188 @@ +class SoundEngine { + private audioContext: AudioContext | null = null + private masterGain: GainNode | null = null + private isMuted: boolean = false + + private getContext(): AudioContext { + if (!this.audioContext) { + this.audioContext = new AudioContext() + this.masterGain = this.audioContext.createGain() + this.masterGain.connect(this.audioContext.destination) + this.masterGain.gain.value = 0.3 + } + return this.audioContext + } + + setMuted(muted: boolean) { + this.isMuted = muted + if (this.masterGain) { + this.masterGain.gain.value = muted ? 0 : 0.3 + } + } + + getMuted(): boolean { + return this.isMuted + } + + playPet() { + if (this.isMuted) return + const ctx = this.getContext() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.frequency.setValueAtTime(880, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.1) + + gain.gain.setValueAtTime(0.3, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15) + + osc.start(ctx.currentTime) + osc.stop(ctx.currentTime + 0.15) + } + + playFeed() { + if (this.isMuted) return + const ctx = this.getContext() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'sine' + osc.frequency.setValueAtTime(440, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(660, ctx.currentTime + 0.1) + osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.2) + + gain.gain.setValueAtTime(0.25, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3) + + osc.start(ctx.currentTime) + osc.stop(ctx.currentTime + 0.3) + } + + playSpawn() { + if (this.isMuted) return + const ctx = this.getContext() + + for (let i = 0; i < 3; i++) { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'sine' + const baseFreq = 220 * (i + 1) + osc.frequency.setValueAtTime(baseFreq, ctx.currentTime + i * 0.1) + osc.frequency.exponentialRampToValueAtTime(baseFreq * 1.5, ctx.currentTime + i * 0.1 + 0.2) + + gain.gain.setValueAtTime(0.2, ctx.currentTime + i * 0.1) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + i * 0.1 + 0.25) + + osc.start(ctx.currentTime + i * 0.1) + osc.stop(ctx.currentTime + i * 0.1 + 0.25) + } + } + + playAchievement() { + if (this.isMuted) return + const ctx = this.getContext() + + const notes = [523.25, 659.25, 783.99, 1046.50] + + notes.forEach((freq, i) => { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'sine' + osc.frequency.setValueAtTime(freq, ctx.currentTime + i * 0.12) + + gain.gain.setValueAtTime(0.25, ctx.currentTime + i * 0.12) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + i * 0.12 + 0.3) + + osc.start(ctx.currentTime + i * 0.12) + osc.stop(ctx.currentTime + i * 0.12 + 0.3) + }) + } + + playPurchase() { + if (this.isMuted) return + const ctx = this.getContext() + + const notes = [392, 523.25, 659.25] + + notes.forEach((freq, i) => { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'triangle' + osc.frequency.setValueAtTime(freq, ctx.currentTime + i * 0.08) + + gain.gain.setValueAtTime(0.2, ctx.currentTime + i * 0.08) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + i * 0.08 + 0.15) + + osc.start(ctx.currentTime + i * 0.08) + osc.stop(ctx.currentTime + i * 0.08 + 0.15) + }) + } + + playRelease() { + if (this.isMuted) return + const ctx = this.getContext() + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'sine' + osc.frequency.setValueAtTime(440, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.4) + + gain.gain.setValueAtTime(0.2, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4) + + osc.start(ctx.currentTime) + osc.stop(ctx.currentTime + 0.4) + } + + playTimeChange() { + if (this.isMuted) return + const ctx = this.getContext() + + const baseFreq = timeOfDayToFreq(ctx.currentTime % 4) + + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.connect(gain) + gain.connect(this.masterGain!) + + osc.type = 'sine' + osc.frequency.setValueAtTime(baseFreq * 0.5, ctx.currentTime) + osc.frequency.exponentialRampToValueAtTime(baseFreq, ctx.currentTime + 0.5) + + gain.gain.setValueAtTime(0.15, ctx.currentTime) + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.6) + + osc.start(ctx.currentTime) + osc.stop(ctx.currentTime + 0.6) + } +} + +function timeOfDayToFreq(phase: number): number { + const freqs = [261.63, 329.63, 392, 196] + return freqs[Math.floor(phase) % freqs.length] +} + +export const soundEngine = new SoundEngine()