Add Void Shop and Void Essence currency system

- Add Void Essence currency earned from petting (+2) and feeding (+3)
- Add Void Shop modal with purchasable items:
  - Voidling eggs (Wisp, Blob, Spark, Ember, Glitch, Crystal)
  - Void Treats to make all voidlings happy
  - Star Fragments for bonus essence
- Add 2 new voidling types: Glitch (rare green) and Crystal (rare blue)
- Add 'Void Tycoon' achievement for 500 Void Essence
- Update biodiversity achievement to require all 6 voidling types
- Add Void Essence display in header with shop button
This commit is contained in:
Ernie Cook
2026-03-03 00:07:59 -05:00
parent 3a7335ea7c
commit 08ec4fb19a
2 changed files with 127 additions and 5 deletions

View File

@ -43,6 +43,11 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
- Added manual time control toggle - Added manual time control toggle
- Added voidling speed multipliers based on time of day (slower at night) - Added voidling speed multipliers based on time of day (slower at night)
- Voidlings naturally become sleepy 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
### Next Steps ### Next Steps
1. Add sound effects 1. Add sound effects
@ -52,4 +57,4 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
--- ---
*Last updated: Session 4 complete - Added petting interaction and achievements system* *Last updated: Session 5 complete - Added voidling shop with Void Essence currency*

View File

@ -6,7 +6,7 @@ interface Voidling {
name: string name: string
color: string color: string
mood: 'idle' | 'happy' | 'curious' | 'sleepy' mood: 'idle' | 'happy' | 'curious' | 'sleepy'
type: 'wisp' | 'blob' | 'spark' | 'ember' type: 'wisp' | 'blob' | 'spark' | 'ember' | 'glitch' | 'crystal'
x: number x: number
y: number y: number
vx: number vx: number
@ -105,6 +105,19 @@ interface UserStats {
totalFeedings: number totalFeedings: number
totalVoidlings: number totalVoidlings: number
sessions: number sessions: number
voidEssence: number
}
interface ShopItem {
id: string
name: string
type: Voidling['type']
color: string
glow: string
shape: string
cost: number
description: string
icon: string
} }
const VOIDLING_TYPES = [ const VOIDLING_TYPES = [
@ -112,6 +125,19 @@ const VOIDLING_TYPES = [
{ type: 'blob', color: 'bg-cyan-400', glow: 'shadow-cyan-500/50', shape: 'rounded-[40%]', description: 'Bouncy and playful' }, { 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: '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: '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', 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', 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', 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', 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', 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', 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', 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', color: 'bg-white', glow: 'shadow-white/50', shape: 'rounded-full', cost: 200, description: 'Rare: +100 Void Essence!', icon: '⭐' },
] ]
const ACHIEVEMENTS: Achievement[] = [ const ACHIEVEMENTS: Achievement[] = [
@ -120,11 +146,12 @@ const ACHIEVEMENTS: Achievement[] = [
{ id: 'fifty_pets', name: 'Void Whisperer', description: 'Pet voidlings 50 times', icon: '🔮', condition: (_v, s) => s.totalPets >= 50 }, { 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: '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: '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: '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: '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: '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: '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 = { const MOOD_EMOJIS = {
@ -182,10 +209,12 @@ export default function Chamber() {
totalFeedings: 0, totalFeedings: 0,
totalVoidlings: 0, totalVoidlings: 0,
sessions: 1, sessions: 1,
voidEssence: 0,
}) })
const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>('night') const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>('night')
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)
useEffect(() => { useEffect(() => {
const savedVoidlings = localStorage.getItem('voidlings') const savedVoidlings = localStorage.getItem('voidlings')
@ -411,7 +440,7 @@ export default function Chamber() {
return v return v
})) }))
setUserStats(prev => { 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)) localStorage.setItem('voidling_stats', JSON.stringify(newStats))
return newStats return newStats
}) })
@ -427,7 +456,7 @@ export default function Chamber() {
return v return v
})) }))
setUserStats(prev => { 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)) localStorage.setItem('voidling_stats', JSON.stringify(newStats))
return newStats return newStats
}) })
@ -438,6 +467,43 @@ export default function Chamber() {
setVoidlings(voidlings.filter(v => v.id !== id)) setVoidlings(voidlings.filter(v => v.id !== id))
} }
function purchaseItem(item: ShopItem) {
if (userStats.voidEssence < item.cost) return
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 {
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']) => { const getMoodAnimation = (mood: Voidling['mood']) => {
switch (mood) { switch (mood) {
case 'happy': return 'animate-bounce' case 'happy': return 'animate-bounce'
@ -522,6 +588,15 @@ export default function Chamber() {
🏆 <span className="hidden sm:inline">Achievements</span> 🏆 <span className="hidden sm:inline">Achievements</span>
<span className="bg-purple-600 text-xs px-1.5 rounded-full">{unlockedAchievements.length}/{ACHIEVEMENTS.length}</span> <span className="bg-purple-600 text-xs px-1.5 rounded-full">{unlockedAchievements.length}/{ACHIEVEMENTS.length}</span>
</button> </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 <button
onClick={spawnVoidling} 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" 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"
@ -683,6 +758,48 @@ export default function Chamber() {
</div> </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>
)}
{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">