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