From ffa9efb800a3462d4a724ec62ad05a0a940108ac Mon Sep 17 00:00:00 2001 From: Ernie Cook Date: Mon, 2 Mar 2026 17:03:05 -0500 Subject: [PATCH] Add petting interaction and achievements system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- WORKLOG.md | 13 +-- src/pages/Chamber.tsx | 189 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 183 insertions(+), 19 deletions(-) diff --git a/WORKLOG.md b/WORKLOG.md index 43db075..4b7f512 100644 --- a/WORKLOG.md +++ b/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* diff --git a/src/pages/Chamber.tsx b/src/pages/Chamber.tsx index 0cd7c53..1d1bb8d 100644 --- a/src/pages/Chamber.tsx +++ b/src/pages/Chamber.tsx @@ -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([]) const [feedingId, setFeedingId] = useState(null) + const [pettingId, setPettingId] = useState(null) + const [showAchievements, setShowAchievements] = useState(false) + const [unlockedAchievements, setUnlockedAchievements] = useState([]) + const [newAchievement, setNewAchievement] = useState(null) + const [userStats, setUserStats] = useState({ + 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)) } @@ -259,7 +357,7 @@ export default function Chamber() {
-
+
The Void Chamber - +
+ + +
@@ -350,6 +457,16 @@ export default function Chamber() {
+
-

Click: change mood โ€ข Double-click: rename โ€ข โ—•: feed โ€ข ร—: release

+

Click: change mood โ€ข Double-click: rename โ€ข โ™ก: pet โ€ข โ—•: feed โ€ข ร—: release

Your voidlings: {voidlings.length}

{VOIDLING_TYPES.map(t => ( @@ -388,6 +505,50 @@ export default function Chamber() {
+ + {showAchievements && ( +
setShowAchievements(false)}> +
e.stopPropagation()}> +
+

+ Achievements +

+ +
+
+ {ACHIEVEMENTS.map(achievement => { + const unlocked = unlockedAchievements.includes(achievement.id) + return ( +
+
{achievement.icon}
+
{achievement.name}
+
{achievement.description}
+
+ ) + })} +
+
+

Stats: {userStats.totalPets} pets โ€ข {userStats.totalFeedings} feedings โ€ข {userStats.sessions} sessions

+
+
+
+ )} + + {newAchievement && ( +
+
+ {newAchievement.icon} +
+
Achievement Unlocked!
+
{newAchievement.name}
+
{newAchievement.description}
+
+
+
+ )}
) }