Add petting interaction and achievements system
- Add petting interaction (♡ button) that makes voidlings happy - Add achievements system with 10 unlockable achievements - Add user stats tracking (pets, feedings, sessions) - Add achievements modal with progress counter - Add achievement unlock notifications - Track individual voidling pet counts
This commit is contained in:
13
WORKLOG.md
13
WORKLOG.md
@ -34,14 +34,17 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
||||
- Added voidling behaviors: spark moves faster, sleepy slows down, happy gets energetic
|
||||
- Added mood-specific animations (bounce, pulse, ping)
|
||||
- Added feeding interaction
|
||||
- Added petting interaction (♡ button) - makes voidlings happy
|
||||
- Added achievements system with 10 unlockable achievements
|
||||
- Added user stats tracking (total pets, feedings, sessions)
|
||||
|
||||
### Next Steps
|
||||
1. Add sound effects
|
||||
2. Add more voidling interactions (playing, petting)
|
||||
3. Add achievements or collection system
|
||||
4. Add voidling shop/trading
|
||||
5. Add night/day cycle
|
||||
2. Add voidling shop/trading
|
||||
3. Add night/day cycle
|
||||
4. Add more voidling types
|
||||
5. Add voidling minigames
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Session 3 complete - Added particle effects, voidling types, and mood animations*
|
||||
*Last updated: Session 4 complete - Added petting interaction and achievements system*
|
||||
|
||||
@ -11,6 +11,7 @@ interface Voidling {
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
pets: number
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
@ -23,6 +24,21 @@ interface Particle {
|
||||
color: string
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition: (voidlings: Voidling[], stats: UserStats) => boolean
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
totalPets: number
|
||||
totalFeedings: number
|
||||
totalVoidlings: number
|
||||
sessions: number
|
||||
}
|
||||
|
||||
const VOIDLING_TYPES = [
|
||||
{ type: 'wisp', color: 'bg-purple-400', glow: 'shadow-purple-500/50', shape: 'rounded-full', description: 'Ethereal and floaty' },
|
||||
{ type: 'blob', color: 'bg-cyan-400', glow: 'shadow-cyan-500/50', shape: 'rounded-[40%]', description: 'Bouncy and playful' },
|
||||
@ -30,6 +46,19 @@ const VOIDLING_TYPES = [
|
||||
{ type: 'ember', color: 'bg-rose-400', glow: 'shadow-rose-500/50', shape: 'rounded-full', description: 'Warm and sleepy' },
|
||||
]
|
||||
|
||||
const ACHIEVEMENTS: Achievement[] = [
|
||||
{ id: 'first_pet', name: 'Gentle Touch', description: 'Pet your first voidling', icon: '✋', condition: (v, s) => s.totalPets >= 1 },
|
||||
{ id: 'ten_pets', name: 'Frienemy', description: 'Pet voidlings 10 times', icon: '🤝', condition: (v, s) => s.totalPets >= 10 },
|
||||
{ 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: '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 },
|
||||
]
|
||||
|
||||
const MOOD_EMOJIS = {
|
||||
idle: '◉',
|
||||
happy: '✧',
|
||||
@ -76,24 +105,71 @@ export default function Chamber() {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const [feedingId, setFeedingId] = useState<string | null>(null)
|
||||
const [pettingId, setPettingId] = useState<string | null>(null)
|
||||
const [showAchievements, setShowAchievements] = useState(false)
|
||||
const [unlockedAchievements, setUnlockedAchievements] = useState<string[]>([])
|
||||
const [newAchievement, setNewAchievement] = useState<Achievement | null>(null)
|
||||
const [userStats, setUserStats] = useState<UserStats>({
|
||||
totalPets: 0,
|
||||
totalFeedings: 0,
|
||||
totalVoidlings: 0,
|
||||
sessions: 1,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('voidlings')
|
||||
if (saved) {
|
||||
const savedVoidlings = localStorage.getItem('voidlings')
|
||||
if (savedVoidlings) {
|
||||
try {
|
||||
setVoidlings(JSON.parse(saved))
|
||||
setVoidlings(JSON.parse(savedVoidlings))
|
||||
} catch {
|
||||
createStarterVoidlings()
|
||||
}
|
||||
} else {
|
||||
createStarterVoidlings()
|
||||
}
|
||||
|
||||
const savedAchievements = localStorage.getItem('voidling_achievements')
|
||||
if (savedAchievements) {
|
||||
try {
|
||||
setUnlockedAchievements(JSON.parse(savedAchievements))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const savedStats = localStorage.getItem('voidling_stats')
|
||||
if (savedStats) {
|
||||
try {
|
||||
const stats = JSON.parse(savedStats)
|
||||
stats.sessions = (stats.sessions || 0) + 1
|
||||
setUserStats(stats)
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('voidlings', JSON.stringify(voidlings))
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, totalVoidlings: voidlings.length }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
}, [voidlings])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('voidling_achievements', JSON.stringify(unlockedAchievements))
|
||||
}, [unlockedAchievements])
|
||||
|
||||
useEffect(() => {
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
if (!unlockedAchievements.includes(achievement.id)) {
|
||||
if (achievement.condition(voidlings, userStats)) {
|
||||
setUnlockedAchievements(prev => [...prev, achievement.id])
|
||||
setNewAchievement(achievement)
|
||||
setTimeout(() => setNewAchievement(null), 3000)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [voidlings, userStats])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setParticles(prev => {
|
||||
@ -159,9 +235,9 @@ export default function Chamber() {
|
||||
|
||||
function createStarterVoidlings() {
|
||||
const starters: Voidling[] = [
|
||||
{ id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', type: 'wisp', ...getRandomPosition() },
|
||||
{ id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', type: 'blob', ...getRandomPosition() },
|
||||
{ id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', type: 'spark', ...getRandomPosition() },
|
||||
{ id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', type: 'wisp', ...getRandomPosition(), pets: 0 },
|
||||
{ id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', type: 'blob', ...getRandomPosition(), pets: 0 },
|
||||
{ id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', type: 'spark', ...getRandomPosition(), pets: 0 },
|
||||
]
|
||||
setVoidlings(starters)
|
||||
}
|
||||
@ -180,6 +256,7 @@ export default function Chamber() {
|
||||
y: pos.y,
|
||||
vx: pos.vx,
|
||||
vy: pos.vy,
|
||||
pets: 0,
|
||||
}
|
||||
setVoidlings([...voidlings, newVoidling])
|
||||
setNamingVoidling(newVoidling.id)
|
||||
@ -222,9 +299,30 @@ export default function Chamber() {
|
||||
}
|
||||
return v
|
||||
}))
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, totalFeedings: prev.totalFeedings + 1 }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
setTimeout(() => setFeedingId(null), 500)
|
||||
}
|
||||
|
||||
function petVoidling(id: string) {
|
||||
setPettingId(id)
|
||||
setVoidlings(voidlings.map(v => {
|
||||
if (v.id === id) {
|
||||
return { ...v, mood: 'happy', pets: (v.pets || 0) + 1 }
|
||||
}
|
||||
return v
|
||||
}))
|
||||
setUserStats(prev => {
|
||||
const newStats = { ...prev, totalPets: prev.totalPets + 1 }
|
||||
localStorage.setItem('voidling_stats', JSON.stringify(newStats))
|
||||
return newStats
|
||||
})
|
||||
setTimeout(() => setPettingId(null), 300)
|
||||
}
|
||||
|
||||
function releaseVoidling(id: string) {
|
||||
setVoidlings(voidlings.filter(v => v.id !== id))
|
||||
}
|
||||
@ -270,6 +368,14 @@ export default function Chamber() {
|
||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400">
|
||||
The Void Chamber
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAchievements(!showAchievements)}
|
||||
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 flex items-center gap-2"
|
||||
>
|
||||
🏆 <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={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"
|
||||
@ -277,6 +383,7 @@ export default function Chamber() {
|
||||
+ Summon Voidling
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-[70vh] bg-zinc-900/30 rounded-3xl border border-zinc-800 overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
@ -350,6 +457,16 @@ export default function Chamber() {
|
||||
</div>
|
||||
|
||||
<div className="absolute -top-2 -right-2 flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
petVoidling(voidling.id)
|
||||
}}
|
||||
className={`w-6 h-6 bg-pink-900/80 hover:bg-pink-700 rounded-full text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center ${pettingId === voidling.id ? 'scale-125' : ''}`}
|
||||
title="Pet"
|
||||
>
|
||||
♡
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@ -376,7 +493,7 @@ export default function Chamber() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-zinc-500 text-sm">
|
||||
<p>Click: change mood • Double-click: rename • ◕: feed • ×: release</p>
|
||||
<p>Click: change mood • Double-click: rename • ♡: pet • ◕: feed • ×: release</p>
|
||||
<p className="mt-2">Your voidlings: {voidlings.length}</p>
|
||||
<div className="mt-3 flex justify-center gap-4 text-xs text-zinc-600">
|
||||
{VOIDLING_TYPES.map(t => (
|
||||
@ -388,6 +505,50 @@ export default function Chamber() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAchievements && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={() => setShowAchievements(false)}>
|
||||
<div className="bg-zinc-900 rounded-2xl p-6 max-w-md 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-purple-400 via-pink-400 to-cyan-400">
|
||||
Achievements
|
||||
</h2>
|
||||
<button onClick={() => setShowAchievements(false)} className="text-zinc-500 hover:text-zinc-300">×</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-h-80 overflow-y-auto">
|
||||
{ACHIEVEMENTS.map(achievement => {
|
||||
const unlocked = unlockedAchievements.includes(achievement.id)
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-3 rounded-xl border ${unlocked ? 'bg-zinc-800 border-purple-500/50' : 'bg-zinc-800/50 border-zinc-700 opacity-50'}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{achievement.icon}</div>
|
||||
<div className="font-medium text-sm">{achievement.name}</div>
|
||||
<div className="text-xs text-zinc-500">{achievement.description}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-zinc-700 text-xs text-zinc-500">
|
||||
<p>Stats: {userStats.totalPets} pets • {userStats.totalFeedings} feedings • {userStats.sessions} sessions</p>
|
||||
</div>
|
||||
</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">
|
||||
<span className="text-3xl">{newAchievement.icon}</span>
|
||||
<div>
|
||||
<div className="font-bold text-white">Achievement Unlocked!</div>
|
||||
<div className="text-white/80 text-sm">{newAchievement.name}</div>
|
||||
<div className="text-white/60 text-xs">{newAchievement.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user