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
This commit is contained in:
Ernie Cook
2026-03-03 01:06:43 -05:00
parent 574c50c047
commit e904856bee
3 changed files with 368 additions and 2 deletions

View File

@ -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 2 new voidling types: Glitch (rare green) and Crystal (rare blue)
- Added "Void Tycoon" achievement for accumulating 500 Void Essence - Added "Void Tycoon" achievement for accumulating 500 Void Essence
- Updated biodiversity achievement to require all 6 voidling types - 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 ### Next Steps
1. Add sound effects 1. Add more minigames
2. Add voidling minigames 2. Add voidling evolution/combat system
3. Add decorations for the chamber
--- ---

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { soundEngine } from '../utils/sound'
interface Voidling { interface Voidling {
id: string id: string
@ -215,6 +216,54 @@ export default function Chamber() {
const [isAutoCycle, setIsAutoCycle] = useState(true) const [isAutoCycle, setIsAutoCycle] = useState(true)
const [cycleProgress, setCycleProgress] = useState(0) const [cycleProgress, setCycleProgress] = useState(0)
const [showShop, setShowShop] = useState(false) 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(() => { useEffect(() => {
const savedVoidlings = localStorage.getItem('voidlings') const savedVoidlings = localStorage.getItem('voidlings')
@ -264,6 +313,7 @@ export default function Chamber() {
if (achievement.condition(voidlings, userStats)) { if (achievement.condition(voidlings, userStats)) {
setUnlockedAchievements(prev => [...prev, achievement.id]) setUnlockedAchievements(prev => [...prev, achievement.id])
setNewAchievement(achievement) setNewAchievement(achievement)
soundEngine.playAchievement()
setTimeout(() => setNewAchievement(null), 3000) setTimeout(() => setNewAchievement(null), 3000)
} }
} }
@ -398,6 +448,7 @@ export default function Chamber() {
vy: pos.vy, vy: pos.vy,
pets: 0, pets: 0,
} }
soundEngine.playSpawn()
setVoidlings([...voidlings, newVoidling]) setVoidlings([...voidlings, newVoidling])
setNamingVoidling(newVoidling.id) setNamingVoidling(newVoidling.id)
setNewName('') setNewName('')
@ -433,6 +484,7 @@ export default function Chamber() {
function feedVoidling(id: string) { function feedVoidling(id: string) {
setFeedingId(id) setFeedingId(id)
soundEngine.playFeed()
setVoidlings(voidlings.map(v => { setVoidlings(voidlings.map(v => {
if (v.id === id) { if (v.id === id) {
return { ...v, mood: 'happy', vy: v.vy - 0.5 } return { ...v, mood: 'happy', vy: v.vy - 0.5 }
@ -449,6 +501,7 @@ export default function Chamber() {
function petVoidling(id: string) { function petVoidling(id: string) {
setPettingId(id) setPettingId(id)
soundEngine.playPet()
setVoidlings(voidlings.map(v => { setVoidlings(voidlings.map(v => {
if (v.id === id) { if (v.id === id) {
return { ...v, mood: 'happy', pets: (v.pets || 0) + 1 } return { ...v, mood: 'happy', pets: (v.pets || 0) + 1 }
@ -464,12 +517,41 @@ export default function Chamber() {
} }
function releaseVoidling(id: string) { function releaseVoidling(id: string) {
soundEngine.playRelease()
setVoidlings(voidlings.filter(v => v.id !== id)) 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) { function purchaseItem(item: ShopItem) {
if (userStats.voidEssence < item.cost) return if (userStats.voidEssence < item.cost) return
soundEngine.playPurchase()
setUserStats(prev => { setUserStats(prev => {
const newStats = { ...prev, voidEssence: prev.voidEssence - item.cost } const newStats = { ...prev, voidEssence: prev.voidEssence - item.cost }
localStorage.setItem('voidling_stats', JSON.stringify(newStats)) localStorage.setItem('voidling_stats', JSON.stringify(newStats))
@ -603,6 +685,19 @@ export default function Chamber() {
> >
+ Summon Voidling + Summon Voidling
</button> </button>
<button
onClick={() => setIsMuted(!isMuted)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-sm transition-all duration-200 hover:scale-105 border border-zinc-700"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? '🔇' : '🔊'}
</button>
<button
onClick={() => setShowGames(true)}
className="px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 rounded-xl text-sm transition-all duration-200 hover:scale-105 border border-zinc-700 flex items-center gap-2"
>
🎮 <span className="hidden sm:inline">Games</span>
</button>
</div> </div>
</div> </div>
@ -800,6 +895,85 @@ export default function Chamber() {
</div> </div>
)} )}
{showGames && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setShowGames(false)}>
<div className="bg-zinc-900 rounded-2xl p-6 max-w-sm w-full border border-zinc-700" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-400 to-purple-400">
Void Games
</h2>
<button onClick={() => setShowGames(false)} className="text-zinc-500 hover:text-zinc-300">×</button>
</div>
<div className="space-y-4">
<div className="p-4 bg-zinc-800/50 rounded-xl border border-zinc-700">
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl">🎯</span>
<div>
<div className="font-bold">Void Hunt</div>
<div className="text-xs text-zinc-500">Click the targets!</div>
</div>
</div>
<p className="text-xs text-zinc-400 mb-3">Catch as many targets as you can in 30 seconds. Each catch awards 5 Void Essence!</p>
<button
onClick={() => { setShowGames(false); startGame(); }}
className="w-full py-2 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 rounded-lg text-sm font-medium transition-all"
>
Start Game
</button>
</div>
</div>
</div>
</div>
)}
{gameActive && (
<div className="fixed inset-0 pointer-events-none z-40">
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex gap-8 text-2xl font-bold pointer-events-auto">
<div className="bg-zinc-900/80 px-6 py-2 rounded-xl border border-zinc-700">
{gameTimeLeft}s
</div>
<div className="bg-zinc-900/80 px-6 py-2 rounded-xl border border-zinc-700">
🎯 {gameScore}
</div>
</div>
{gameTargets.map(target => (
<button
key={target.id}
onClick={() => hitTarget(target.id)}
className="absolute w-16 h-16 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full animate-pulse hover:scale-110 transition-transform pointer-events-auto flex items-center justify-center text-2xl shadow-lg shadow-orange-500/50"
style={{ left: `${target.x}%`, top: `${target.y}%`, transform: 'translate(-50%, -50%)' }}
>
</button>
))}
</div>
)}
{!gameActive && gameScore > 0 && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-zinc-900 rounded-2xl p-6 max-w-sm w-full border border-zinc-700 text-center">
<div className="text-5xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400 mb-2">
Game Over!
</h2>
<p className="text-zinc-400 mb-4">
You caught <span className="text-yellow-400 font-bold">{gameScore}</span> targets!
</p>
<p className="text-lg text-yellow-400 font-bold mb-4">
+{gameScore * 5} Void Essence
</p>
<button
onClick={() => { setGameScore(0); startGame(); }}
className="w-full py-3 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 rounded-xl font-medium transition-all"
>
Play Again
</button>
</div>
</div>
)}
{newAchievement && ( {newAchievement && (
<div className="fixed top-8 right-8 bg-gradient-to-r from-purple-600 to-pink-600 p-4 rounded-xl shadow-lg animate-bounce z-50"> <div className="fixed top-8 right-8 bg-gradient-to-r from-purple-600 to-pink-600 p-4 rounded-xl shadow-lg animate-bounce z-50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

188
src/utils/sound.ts Normal file
View File

@ -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()