Compare commits
4 Commits
3a7335ea7c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b74acb272e | |||
| e904856bee | |||
| 574c50c047 | |||
| 08ec4fb19a |
21
WORKLOG.md
21
WORKLOG.md
@ -43,13 +43,24 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
||||
- Added manual time control toggle
|
||||
- Added voidling speed multipliers based on time of day (slower at night)
|
||||
- Voidlings naturally become sleepy at night
|
||||
- Added Void Essence currency - earned by petting (+2) and feeding (+3)
|
||||
- Added Void Shop with purchasable voidling eggs, treats, and items
|
||||
- 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
|
||||
- Added Star Catcher minigame - catch falling stars for higher rewards (10 Void Essence per star)
|
||||
- Added purchasable chamber decorations: Crystal Orb, Void Flame, Ancient Tome, Moon Stone
|
||||
- Decorations appear in the chamber with ambient pulsing animation
|
||||
|
||||
### Next Steps
|
||||
1. Add sound effects
|
||||
2. Add voidling shop/trading
|
||||
3. Add more voidling types
|
||||
4. Add voidling minigames
|
||||
1. Add more minigames (DONE - added Star Catcher)
|
||||
2. Add voidling evolution/combat system
|
||||
3. Add more chamber decorations/ways to customize chamber
|
||||
4. Add achievement for catching stars in Star Catcher
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Session 4 complete - Added petting interaction and achievements system*
|
||||
*Last updated: Session 6 complete - Added Star Catcher minigame and chamber decorations*
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { soundEngine } from '../utils/sound'
|
||||
|
||||
interface Voidling {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
mood: 'idle' | 'happy' | 'curious' | 'sleepy'
|
||||
type: 'wisp' | 'blob' | 'spark' | 'ember'
|
||||
type: 'wisp' | 'blob' | 'spark' | 'ember' | 'glitch' | 'crystal'
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
@ -105,6 +106,36 @@ interface UserStats {
|
||||
totalFeedings: number
|
||||
totalVoidlings: number
|
||||
sessions: number
|
||||
voidEssence: number
|
||||
}
|
||||
|
||||
interface ShopItem {
|
||||
id: string
|
||||
name: string
|
||||
type: Voidling['type']
|
||||
category: 'voidling' | 'consumable' | 'decoration'
|
||||
color: string
|
||||
glow: string
|
||||
shape: string
|
||||
cost: number
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface Decoration {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface FallingStar {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
speed: number
|
||||
points: number
|
||||
}
|
||||
|
||||
const VOIDLING_TYPES = [
|
||||
@ -112,6 +143,23 @@ const VOIDLING_TYPES = [
|
||||
{ type: 'blob', color: 'bg-cyan-400', glow: 'shadow-cyan-500/50', shape: 'rounded-[40%]', description: 'Bouncy and playful' },
|
||||
{ type: 'spark', color: 'bg-amber-400', glow: 'shadow-amber-500/50', shape: 'rounded-sm', description: 'Energetic and quick' },
|
||||
{ type: 'ember', color: 'bg-rose-400', glow: 'shadow-rose-500/50', shape: 'rounded-full', description: 'Warm and sleepy' },
|
||||
{ type: 'glitch', color: 'bg-green-400', glow: 'shadow-green-500/50', shape: 'rounded-lg', description: 'Unstable and mysterious' },
|
||||
{ type: 'crystal', color: 'bg-blue-400', glow: 'shadow-blue-500/50', shape: 'rounded-sm', description: 'Sharp and reflective' },
|
||||
]
|
||||
|
||||
const SHOP_ITEMS: ShopItem[] = [
|
||||
{ id: 'wisp_egg', name: 'Wisp Egg', type: 'wisp', category: 'voidling', color: 'bg-purple-400', glow: 'shadow-purple-500/50', shape: 'rounded-full', cost: 50, description: 'Hatches into a wisp', icon: '🥚' },
|
||||
{ id: 'blob_egg', name: 'Blob Egg', type: 'blob', category: 'voidling', color: 'bg-cyan-400', glow: 'shadow-cyan-500/50', shape: 'rounded-[40%]', cost: 50, description: 'Hatches into a blob', icon: '🥚' },
|
||||
{ id: 'spark_egg', name: 'Spark Egg', type: 'spark', category: 'voidling', color: 'bg-amber-400', glow: 'shadow-amber-500/50', shape: 'rounded-sm', cost: 75, description: 'Hatches into a spark', icon: '⚡' },
|
||||
{ id: 'ember_egg', name: 'Ember Egg', type: 'ember', category: 'voidling', color: 'bg-rose-400', glow: 'shadow-rose-500/50', shape: 'rounded-full', cost: 75, description: 'Hatches into an ember', icon: '🔥' },
|
||||
{ id: 'glitch_egg', name: 'Glitch Egg', type: 'glitch', category: 'voidling', color: 'bg-green-400', glow: 'shadow-green-500/50', shape: 'rounded-lg', cost: 150, description: 'Rare unstable voidling', icon: '👾' },
|
||||
{ id: 'crystal_egg', name: 'Crystal Egg', type: 'crystal', category: 'voidling', color: 'bg-blue-400', glow: 'shadow-blue-500/50', shape: 'rounded-sm', cost: 150, description: 'Rare sharp voidling', icon: '💎' },
|
||||
{ id: 'treat', name: 'Void Treat', type: 'wisp', category: 'consumable', color: 'bg-yellow-400', glow: 'shadow-yellow-500/50', shape: 'rounded-full', cost: 20, description: 'All voidlings become happy', icon: '🍪' },
|
||||
{ id: 'star_fragment', name: 'Star Fragment', type: 'wisp', category: 'consumable', color: 'bg-white', glow: 'shadow-white/50', shape: 'rounded-full', cost: 200, description: 'Rare: +100 Void Essence!', icon: '⭐' },
|
||||
{ id: 'crystal_orb', name: 'Crystal Orb', type: 'wisp', category: 'decoration', color: 'bg-blue-400', glow: 'shadow-blue-500/50', shape: 'rounded-full', cost: 100, description: 'A glowing orb for your chamber', icon: '🔮' },
|
||||
{ id: 'void_flame', name: 'Void Flame', type: 'wisp', category: 'decoration', color: 'bg-purple-400', glow: 'shadow-purple-500/50', shape: 'rounded-full', cost: 120, description: 'An eternal purple flame', icon: '🕯️' },
|
||||
{ id: 'ancient_book', name: 'Ancient Tome', type: 'wisp', category: 'decoration', color: 'bg-amber-600', glow: 'shadow-amber-500/50', shape: 'rounded-lg', cost: 150, description: 'Knowledge from the void', icon: '📕' },
|
||||
{ id: 'moon_stone', name: 'Moon Stone', type: 'wisp', category: 'decoration', color: 'bg-zinc-300', glow: 'shadow-zinc-400/50', shape: 'rounded-full', cost: 200, description: 'A piece of the moon', icon: '🌑' },
|
||||
]
|
||||
|
||||
const ACHIEVEMENTS: Achievement[] = [
|
||||
@ -120,11 +168,12 @@ const ACHIEVEMENTS: Achievement[] = [
|
||||
{ id: 'fifty_pets', name: 'Void Whisperer', description: 'Pet voidlings 50 times', icon: '🔮', condition: (_v, s) => s.totalPets >= 50 },
|
||||
{ id: 'first_feeding', name: 'Nourisher', description: 'Feed your first voidling', icon: '◕', condition: (_v, s) => s.totalFeedings >= 1 },
|
||||
{ id: 'collector', name: 'Collector', description: 'Have 5 different voidlings', icon: '📦', condition: (v, _s) => v.length >= 5 },
|
||||
{ id: 'variety', name: 'Biodiversity', description: 'Have all 4 types of voidlings', icon: '🌈', condition: (v) => { const types = new Set(v.map(x => x.type)); return types.size >= 4; } },
|
||||
{ id: 'variety', name: 'Biodiversity', description: 'Have all 6 types of voidlings', icon: '🌈', condition: (v) => { const types = new Set(v.map(x => x.type)); return types.size >= 6; } },
|
||||
{ id: 'parent', name: 'Protective Parent', description: 'Have 10 voidlings', icon: '👨👩👧👦', condition: (v, _s) => v.length >= 10 },
|
||||
{ id: 'curious', name: 'Curious Mind', description: 'Have a curious voidling', icon: '❓', condition: (v) => v.some(x => x.mood === 'curious') },
|
||||
{ id: 'sleepy_boys', name: 'Sleepy Boys', description: 'Have a sleepy voidling', icon: '😴', condition: (v) => v.some(x => x.mood === 'sleepy') },
|
||||
{ id: 'happy_home', name: 'Happy Home', description: 'Have 3 happy voidlings', icon: '☺️', condition: (v) => v.filter(x => x.mood === 'happy').length >= 3 },
|
||||
{ id: 'rich', name: 'Void Tycoon', description: 'Accumulate 500 Void Essence', icon: '💰', condition: (_v, s) => s.voidEssence >= 500 },
|
||||
]
|
||||
|
||||
const MOOD_EMOJIS = {
|
||||
@ -182,10 +231,97 @@ export default function Chamber() {
|
||||
totalFeedings: 0,
|
||||
totalVoidlings: 0,
|
||||
sessions: 1,
|
||||
voidEssence: 0,
|
||||
})
|
||||
const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>('night')
|
||||
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 [gameType, setGameType] = useState<'hunt' | 'stars' | null>(null)
|
||||
const [gameScore, setGameScore] = useState(0)
|
||||
const [gameTimeLeft, setGameTimeLeft] = useState(30)
|
||||
const [gameTargets, setGameTargets] = useState<{id: number, x: number, y: number}[]>([])
|
||||
const [fallingStars, setFallingStars] = useState<FallingStar[]>([])
|
||||
const [decorations, setDecorations] = useState<Decoration[]>([])
|
||||
|
||||
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 || gameType !== 'hunt') 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, gameType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameActive || gameType !== 'stars') return
|
||||
|
||||
const spawnStar = () => {
|
||||
const newStar: FallingStar = {
|
||||
id: Date.now() + Math.random(),
|
||||
x: Math.random() * 80 + 10,
|
||||
y: -5,
|
||||
speed: Math.random() * 0.8 + 0.5,
|
||||
points: Math.random() > 0.8 ? 3 : 1,
|
||||
}
|
||||
setFallingStars(prev => [...prev, newStar])
|
||||
}
|
||||
|
||||
spawnStar()
|
||||
const spawnInterval = setInterval(spawnStar, 800)
|
||||
|
||||
return () => clearInterval(spawnInterval)
|
||||
}, [gameActive, gameType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameActive || gameType !== 'stars') return
|
||||
|
||||
const moveStars = setInterval(() => {
|
||||
setFallingStars(prev => {
|
||||
return prev
|
||||
.map(s => ({ ...s, y: s.y + s.speed }))
|
||||
.filter(s => s.y < 110)
|
||||
})
|
||||
}, 50)
|
||||
|
||||
return () => clearInterval(moveStars)
|
||||
}, [gameActive, gameType])
|
||||
|
||||
useEffect(() => {
|
||||
const savedVoidlings = localStorage.getItem('voidlings')
|
||||
@ -214,6 +350,13 @@ export default function Chamber() {
|
||||
setUserStats(stats)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const savedDecorations = localStorage.getItem('voidling_decorations')
|
||||
if (savedDecorations) {
|
||||
try {
|
||||
setDecorations(JSON.parse(savedDecorations))
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -229,12 +372,17 @@ export default function Chamber() {
|
||||
localStorage.setItem('voidling_achievements', JSON.stringify(unlockedAchievements))
|
||||
}, [unlockedAchievements])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('voidling_decorations', JSON.stringify(decorations))
|
||||
}, [decorations])
|
||||
|
||||
useEffect(() => {
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
if (!unlockedAchievements.includes(achievement.id)) {
|
||||
if (achievement.condition(voidlings, userStats)) {
|
||||
setUnlockedAchievements(prev => [...prev, achievement.id])
|
||||
setNewAchievement(achievement)
|
||||
soundEngine.playAchievement()
|
||||
setTimeout(() => setNewAchievement(null), 3000)
|
||||
}
|
||||
}
|
||||
@ -369,6 +517,7 @@ export default function Chamber() {
|
||||
vy: pos.vy,
|
||||
pets: 0,
|
||||
}
|
||||
soundEngine.playSpawn()
|
||||
setVoidlings([...voidlings, newVoidling])
|
||||
setNamingVoidling(newVoidling.id)
|
||||
setNewName('')
|
||||
@ -404,6 +553,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 }
|
||||
@ -411,7 +561,7 @@ export default function Chamber() {
|
||||
return v
|
||||
}))
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, totalFeedings: prev.totalFeedings + 1 }
|
||||
const newStats = { ...prev, totalFeedings: prev.totalFeedings + 1, voidEssence: prev.voidEssence + 3 }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
@ -420,6 +570,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 }
|
||||
@ -427,7 +578,7 @@ export default function Chamber() {
|
||||
return v
|
||||
}))
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, totalPets: prev.totalPets + 1 }
|
||||
const newStats = { ...prev, totalPets: prev.totalPets + 1, voidEssence: prev.voidEssence + 2 }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
@ -435,9 +586,102 @@ export default function Chamber() {
|
||||
}
|
||||
|
||||
function releaseVoidling(id: string) {
|
||||
soundEngine.playRelease()
|
||||
setVoidlings(voidlings.filter(v => v.id !== id))
|
||||
}
|
||||
|
||||
function startGame(type: 'hunt' | 'stars') {
|
||||
setGameType(type)
|
||||
setGameActive(true)
|
||||
setGameScore(0)
|
||||
setGameTimeLeft(30)
|
||||
setGameTargets([])
|
||||
setFallingStars([])
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
setGameActive(false)
|
||||
let reward = 0
|
||||
if (gameType === 'hunt') {
|
||||
reward = gameScore * 5
|
||||
} else if (gameType === 'stars') {
|
||||
reward = gameScore * 10
|
||||
}
|
||||
if (reward > 0) {
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, voidEssence: prev.voidEssence + reward }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
}
|
||||
setGameTargets([])
|
||||
setFallingStars([])
|
||||
setGameType(null)
|
||||
}
|
||||
|
||||
function hitTarget(id: number) {
|
||||
soundEngine.playPet()
|
||||
setGameScore(prev => prev + 1)
|
||||
setGameTargets(prev => prev.filter(t => t.id !== id))
|
||||
}
|
||||
|
||||
function catchStar(id: number) {
|
||||
soundEngine.playPet()
|
||||
const star = fallingStars.find(s => s.id === id)
|
||||
if (star) {
|
||||
setGameScore(prev => prev + star.points)
|
||||
}
|
||||
setFallingStars(prev => prev.filter(s => s.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))
|
||||
return newStats
|
||||
})
|
||||
|
||||
if (item.id === 'treat') {
|
||||
setVoidlings(prev => prev.map(v => ({ ...v, mood: 'happy' as const })))
|
||||
} else if (item.id === 'star_fragment') {
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, voidEssence: prev.voidEssence + 100 }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
} else if (item.category === 'decoration') {
|
||||
const newDecoration: Decoration = {
|
||||
id: generateId(),
|
||||
name: item.name,
|
||||
icon: item.icon,
|
||||
x: Math.random() * 70 + 15,
|
||||
y: Math.random() * 50 + 30,
|
||||
}
|
||||
setDecorations(prev => [...prev, newDecoration])
|
||||
} else {
|
||||
const pos = getRandomPosition()
|
||||
const newVoidling: Voidling = {
|
||||
id: generateId(),
|
||||
name: '???',
|
||||
color: item.color,
|
||||
mood: getRandomMood(),
|
||||
type: item.type,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
vx: pos.vx,
|
||||
vy: pos.vy,
|
||||
pets: 0,
|
||||
}
|
||||
setVoidlings([...voidlings, newVoidling])
|
||||
setNamingVoidling(newVoidling.id)
|
||||
setNewName('')
|
||||
}
|
||||
}
|
||||
|
||||
const getMoodAnimation = (mood: Voidling['mood']) => {
|
||||
switch (mood) {
|
||||
case 'happy': return 'animate-bounce'
|
||||
@ -522,12 +766,34 @@ export default function Chamber() {
|
||||
🏆 <span className="hidden sm:inline">Achievements</span>
|
||||
<span className="bg-purple-600 text-xs px-1.5 rounded-full">{unlockedAchievements.length}/{ACHIEVEMENTS.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowShop(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-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">Shop</span>
|
||||
<span className="bg-zinc-800 text-yellow-400 text-xs px-1.5 rounded-full flex items-center gap-1">
|
||||
◇{userStats.voidEssence}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={spawnVoidling}
|
||||
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"
|
||||
>
|
||||
+ 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>
|
||||
|
||||
@ -636,6 +902,20 @@ export default function Chamber() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{decorations.map((decoration) => (
|
||||
<div
|
||||
key={decoration.id}
|
||||
className="absolute text-4xl pointer-events-none opacity-60 animate-pulse"
|
||||
style={{
|
||||
left: `${decoration.x}%`,
|
||||
top: `${decoration.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{decoration.icon}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-zinc-500 text-sm">
|
||||
@ -683,6 +963,163 @@ export default function Chamber() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showShop && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setShowShop(false)}>
|
||||
<div className="bg-zinc-900 rounded-2xl p-6 max-w-lg 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-yellow-400 via-orange-400 to-pink-400">
|
||||
Void Shop
|
||||
</h2>
|
||||
<button onClick={() => setShowShop(false)} className="text-zinc-500 hover:text-zinc-300">×</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4 p-3 bg-zinc-800/50 rounded-xl">
|
||||
<span className="text-2xl">◇</span>
|
||||
<span className="text-yellow-400 font-bold text-lg">Void Essence: {userStats.voidEssence}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mb-4">Pet and feed your voidlings to earn Void Essence!</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-h-80 overflow-y-auto">
|
||||
{SHOP_ITEMS.map(item => {
|
||||
const canAfford = userStats.voidEssence >= item.cost
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => purchaseItem(item)}
|
||||
disabled={!canAfford}
|
||||
className={`p-4 rounded-xl border text-left transition-all ${canAfford ? 'bg-zinc-800 border-purple-500/30 hover:border-purple-500 hover:scale-105' : 'bg-zinc-800/30 border-zinc-700 opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-3xl">{item.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-bold ${canAfford ? 'text-yellow-400' : 'text-zinc-600'}`}>
|
||||
◇{item.cost}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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('hunt'); }}
|
||||
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 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">Star Catcher</div>
|
||||
<div className="text-xs text-zinc-500">Catch falling stars!</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mb-3">Catch falling stars in 30 seconds. Golden stars are worth 3 points! Each point awards 10 Void Essence!</p>
|
||||
<button
|
||||
onClick={() => { setShowGames(false); startGame('stars'); }}
|
||||
className="w-full py-2 bg-gradient-to-r from-yellow-600 to-amber-600 hover:from-yellow-500 hover:to-amber-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">
|
||||
{gameType === 'hunt' ? '🎯' : '⭐'} {gameScore}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gameType === 'hunt' && 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>
|
||||
))}
|
||||
|
||||
{gameType === 'stars' && fallingStars.map(star => (
|
||||
<button
|
||||
key={star.id}
|
||||
onClick={() => catchStar(star.id)}
|
||||
className={`absolute w-12 h-12 rounded-full animate-pulse hover:scale-110 transition-transform pointer-events-auto flex items-center justify-center text-2xl shadow-lg ${
|
||||
star.points === 3
|
||||
? 'bg-gradient-to-r from-yellow-300 to-amber-500 shadow-amber-500/50'
|
||||
: 'bg-gradient-to-r from-white to-yellow-200 shadow-yellow-300/50'
|
||||
}`}
|
||||
style={{ left: `${star.x}%`, top: `${star.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{star.points === 3 ? '🌟' : '⭐'}
|
||||
</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">
|
||||
{gameType === 'hunt' ? (
|
||||
<>You caught <span className="text-yellow-400 font-bold">{gameScore}</span> targets!</>
|
||||
) : (
|
||||
<>You caught <span className="text-yellow-400 font-bold">{gameScore}</span> stars!</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-lg text-yellow-400 font-bold mb-4">
|
||||
+{(gameType === 'hunt' ? gameScore * 5 : gameScore * 10)} Void Essence
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setGameScore(0); startGame(gameType || 'hunt'); }}
|
||||
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 && (
|
||||
<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">
|
||||
|
||||
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