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:
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
</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>
|
||||
|
||||
@ -797,6 +892,85 @@ export default function Chamber() {
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
188
src/utils/sound.ts
Normal file
188
src/utils/sound.ts
Normal 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()
|
||||
Reference in New Issue
Block a user