Add particle effects, voidling types, and mood animations

- Added ambient particle system with floating particles
- Added 4 voidling types: Wisp, Blob, Spark, Ember with unique behaviors
- Added mood-specific animations (bounce, pulse, ping)
- Added feeding interaction
- Added glow effects and atmospheric lighting
- Voidlings now move autonomously with type-specific behaviors
This commit is contained in:
Ernie Cook
2026-03-02 10:06:50 -05:00
parent af5e31269c
commit 7d419a0f7b
2 changed files with 249 additions and 80 deletions

View File

@ -13,6 +13,9 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
- [x] Main Chamber view created with floating voidlings - [x] Main Chamber view created with floating voidlings
- [x] Ability to name and rename voidlings - [x] Ability to name and rename voidlings
- [x] localStorage persistence for voidlings - [x] localStorage persistence for voidlings
- [x] Particle effects and ambient glows added
- [x] Voidling types/classes with different behaviors
- [x] Mood-specific animations
### In Progress ### In Progress
(None yet) (None yet)
@ -26,14 +29,19 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
- Added ability to summon, name, rename, and release voidlings - Added ability to summon, name, rename, and release voidlings
- Added localStorage persistence to remember voidlings between sessions - Added localStorage persistence to remember voidlings between sessions
- Added mood system (idle, happy, curious, sleepy) with click interaction - Added mood system (idle, happy, curious, sleepy) with click interaction
- Added particle effects and ambient glows for atmosphere
- Added 4 voidling types: Wisp (ethereal), Blob (bouncy), Spark (energetic), Ember (warm)
- Added voidling behaviors: spark moves faster, sleepy slows down, happy gets energetic
- Added mood-specific animations (bounce, pulse, ping)
- Added feeding interaction
### Next Steps ### Next Steps
1. Add more visual effects (particles, glows) 1. Add sound effects
2. Add voidling interactions (feeding, playing) 2. Add more voidling interactions (playing, petting)
3. Add sound effects 3. Add achievements or collection system
4. Add voidling types/classes with different behaviors 4. Add voidling shop/trading
5. Add animations for different moods 5. Add night/day cycle
--- ---
*Last updated: Session 2 complete - Chamber view with interactive voidlings added* *Last updated: Session 3 complete - Added particle effects, voidling types, and mood animations*

View File

@ -6,17 +6,28 @@ 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'
x: number x: number
y: number y: number
vx: number
vy: number
} }
const VOIDLING_COLORS = [ interface Particle {
'bg-purple-400', id: number
'bg-cyan-400', x: number
'bg-pink-400', y: number
'bg-emerald-400', size: number
'bg-amber-400', speed: number
'bg-violet-400', opacity: number
color: string
}
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' },
{ 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' },
] ]
const MOOD_EMOJIS = { const MOOD_EMOJIS = {
@ -26,14 +37,23 @@ const MOOD_EMOJIS = {
sleepy: '💤', sleepy: '💤',
} }
function getRandomColor() { const PARTICLE_COLORS = ['bg-purple-400/30', 'bg-cyan-400/30', 'bg-pink-400/30', 'bg-amber-400/30']
return VOIDLING_COLORS[Math.floor(Math.random() * VOIDLING_COLORS.length)]
function getRandomType(): Voidling['type'] {
const types: Voidling['type'][] = ['wisp', 'blob', 'spark', 'ember']
return types[Math.floor(Math.random() * types.length)]
}
function getTypeStyle(type: Voidling['type']) {
return VOIDLING_TYPES.find(t => t.type === type) || VOIDLING_TYPES[0]
} }
function getRandomPosition() { function getRandomPosition() {
return { return {
x: Math.random() * 80 + 10, x: Math.random() * 80 + 10,
y: Math.random() * 60 + 20, y: Math.random() * 60 + 20,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
} }
} }
@ -46,10 +66,16 @@ function generateId() {
return Math.random().toString(36).substring(2, 9) return Math.random().toString(36).substring(2, 9)
} }
function getTypeInfo(type: Voidling['type']) {
return VOIDLING_TYPES.find(t => t.type === type) || VOIDLING_TYPES[0]
}
export default function Chamber() { export default function Chamber() {
const [voidlings, setVoidlings] = useState<Voidling[]>([]) const [voidlings, setVoidlings] = useState<Voidling[]>([])
const [namingVoidling, setNamingVoidling] = useState<string | null>(null) const [namingVoidling, setNamingVoidling] = useState<string | null>(null)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [particles, setParticles] = useState<Particle[]>([])
const [feedingId, setFeedingId] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem('voidlings') const saved = localStorage.getItem('voidlings')
@ -68,24 +94,92 @@ export default function Chamber() {
localStorage.setItem('voidlings', JSON.stringify(voidlings)) localStorage.setItem('voidlings', JSON.stringify(voidlings))
}, [voidlings]) }, [voidlings])
useEffect(() => {
const interval = setInterval(() => {
setParticles(prev => {
const newParticles: Particle[] = prev
.map(p => ({
...p,
y: p.y - p.speed,
opacity: p.opacity - 0.005,
}))
.filter(p => p.opacity > 0)
if (newParticles.length < 30 && Math.random() > 0.7) {
newParticles.push({
id: Date.now() + Math.random(),
x: Math.random() * 100,
y: 100,
size: Math.random() * 4 + 2,
speed: Math.random() * 0.3 + 0.1,
opacity: Math.random() * 0.5 + 0.3,
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
})
}
return newParticles
})
}, 50)
return () => clearInterval(interval)
}, [])
useEffect(() => {
const interval = setInterval(() => {
setVoidlings(prev => prev.map(v => {
let { x, y, vx, vy } = v
if (v.mood === 'sleepy') {
vx *= 0.95
vy *= 0.95
} else if (v.mood === 'happy') {
vx += (Math.random() - 0.5) * 0.2
vy += (Math.random() - 0.5) * 0.2
} else if (v.mood === 'curious') {
vx += (Math.random() - 0.5) * 0.1
vy += (Math.random() - 0.5) * 0.1
}
if (v.type === 'spark') {
vx *= 1.02
vy *= 1.02
if (Math.abs(vx) > 1) vx = vx > 0 ? 1 : -1
if (Math.abs(vy) > 1) vy = vy > 0 ? 1 : -1
}
x += vx
y += vy
if (x < 5 || x > 95) { vx *= -1; x = Math.max(5, Math.min(95, x)) }
if (y < 10 || y > 90) { vy *= -1; y = Math.max(10, Math.min(90, y)) }
return { ...v, x, y, vx, vy }
}))
}, 100)
return () => clearInterval(interval)
}, [])
function createStarterVoidlings() { function createStarterVoidlings() {
const starters: Voidling[] = [ const starters: Voidling[] = [
{ id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', ...getRandomPosition() }, { id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', type: 'wisp', ...getRandomPosition() },
{ id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', ...getRandomPosition() }, { id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', type: 'blob', ...getRandomPosition() },
{ id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', ...getRandomPosition() }, { id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', type: 'spark', ...getRandomPosition() },
] ]
setVoidlings(starters) setVoidlings(starters)
} }
function spawnVoidling() { function spawnVoidling() {
const pos = getRandomPosition() const pos = getRandomPosition()
const type = getRandomType()
const typeStyles = getTypeStyle(type)
const newVoidling: Voidling = { const newVoidling: Voidling = {
id: generateId(), id: generateId(),
name: '???', name: '???',
color: getRandomColor(), color: typeStyles.color,
mood: getRandomMood(), mood: getRandomMood(),
type: type,
x: pos.x, x: pos.x,
y: pos.y, y: pos.y,
vx: pos.vx,
vy: pos.vy,
} }
setVoidlings([...voidlings, newVoidling]) setVoidlings([...voidlings, newVoidling])
setNamingVoidling(newVoidling.id) setNamingVoidling(newVoidling.id)
@ -120,16 +214,49 @@ export default function Chamber() {
})) }))
} }
function feedVoidling(id: string) {
setFeedingId(id)
setVoidlings(voidlings.map(v => {
if (v.id === id) {
return { ...v, mood: 'happy', vy: v.vy - 0.5 }
}
return v
}))
setTimeout(() => setFeedingId(null), 500)
}
function releaseVoidling(id: string) { function releaseVoidling(id: string) {
setVoidlings(voidlings.filter(v => v.id !== id)) setVoidlings(voidlings.filter(v => v.id !== id))
} }
const getMoodAnimation = (mood: Voidling['mood']) => {
switch (mood) {
case 'happy': return 'animate-bounce'
case 'sleepy': return 'animate-pulse'
case 'curious': return 'animate-ping'
default: return ''
}
}
return ( return (
<div className="min-h-screen p-8 relative overflow-hidden"> <div className="min-h-screen p-8 relative overflow-hidden">
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none">
<div className="absolute top-10 left-10 w-2 h-2 bg-purple-400/30 rounded-full animate-ping" /> {particles.map(p => (
<div className="absolute top-40 right-20 w-3 h-3 bg-cyan-400/30 rounded-full animate-pulse" /> <div
<div className="absolute bottom-20 left-1/4 w-2 h-2 bg-pink-400/30 rounded-full animate-ping delay-700" /> key={p.id}
className={`absolute rounded-full ${p.color}`}
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: p.size,
height: p.size,
opacity: p.opacity,
}}
/>
))}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-purple-500/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-cyan-500/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-gradient-radial from-purple-500/5 to-transparent rounded-full" />
</div> </div>
<div className="max-w-6xl mx-auto relative z-10"> <div className="max-w-6xl mx-auto relative z-10">
@ -152,43 +279,51 @@ export default function Chamber() {
</div> </div>
<div className="relative w-full h-[70vh] bg-zinc-900/30 rounded-3xl border border-zinc-800 overflow-hidden"> <div className="relative w-full h-[70vh] bg-zinc-900/30 rounded-3xl border border-zinc-800 overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-10 left-10 w-32 h-32 bg-purple-500/10 rounded-full blur-2xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-40 h-40 bg-cyan-500/10 rounded-full blur-2xl animate-pulse delay-500" />
<div className="absolute top-1/2 left-1/3 w-24 h-24 bg-pink-500/10 rounded-full blur-2xl animate-pulse delay-1000" />
</div>
{voidlings.length === 0 && ( {voidlings.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-zinc-500"> <div className="absolute inset-0 flex items-center justify-center text-zinc-500">
<p>The chamber feels empty... Summon a voidling to begin.</p> <p>The chamber feels empty... Summon a voidling to begin.</p>
</div> </div>
)} )}
{voidlings.map((voidling) => ( {voidlings.map((voidling) => {
const typeInfo = getTypeInfo(voidling.type)
return (
<div <div
key={voidling.id} key={voidling.id}
className="absolute group cursor-pointer transition-all duration-500" className="absolute group cursor-pointer transition-all duration-300"
style={{ style={{
left: `${voidling.x}%`, left: `${voidling.x}%`,
top: `${voidling.y}%`, top: `${voidling.y}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}} }}
onClick={() => changeMood(voidling.id)} onClick={() => changeMood(voidling.id)}
onDoubleClick={() => startNaming(voidling.id)} onDoubleClick={(e) => { e.stopPropagation(); startNaming(voidling.id) }}
> >
<div <div
className={` className={`
${voidling.color} ${voidling.color}
w-20 h-20 rounded-full ${typeInfo.shape}
w-20 h-20
flex items-center justify-center flex items-center justify-center
text-zinc-900 font-bold text-lg text-zinc-900 font-bold text-lg
transition-all duration-300 transition-all duration-300
hover:scale-110 hover:shadow-xl hover:scale-110
animate-float shadow-lg ${typeInfo.glow}
${getMoodAnimation(voidling.mood)}
${feedingId === voidling.id ? 'scale-125' : ''}
border-4 border-zinc-900/20 border-4 border-zinc-900/20
`} `}
style={{
animationDuration: `${2 + Math.random()}s`,
}}
> >
<span className="text-2xl">{MOOD_EMOJIS[voidling.mood]}</span> <span className="text-2xl filter drop-shadow-md">{MOOD_EMOJIS[voidling.mood]}</span>
</div> </div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap"> <div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap flex flex-col items-center gap-1">
{namingVoidling === voidling.id ? ( {namingVoidling === voidling.id ? (
<input <input
autoFocus autoFocus
@ -203,28 +338,54 @@ export default function Chamber() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<>
<span className="text-xs text-zinc-400 bg-zinc-900/80 px-2 py-1 rounded-lg"> <span className="text-xs text-zinc-400 bg-zinc-900/80 px-2 py-1 rounded-lg">
{voidling.name} {voidling.name}
</span> </span>
<span className="text-[10px] text-zinc-600 capitalize">
{voidling.type}
</span>
</>
)} )}
</div> </div>
<div className="absolute -top-2 -right-2 flex gap-1">
<button
onClick={(e) => {
e.stopPropagation()
feedVoidling(voidling.id)
}}
className="w-6 h-6 bg-amber-900/80 hover:bg-amber-700 rounded-full text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Feed"
>
</button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
releaseVoidling(voidling.id) releaseVoidling(voidling.id)
}} }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-900/80 hover:bg-red-700 rounded-full text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="w-6 h-6 bg-red-900/80 hover:bg-red-700 rounded-full text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
> >
× ×
</button> </button>
</div> </div>
))} </div>
)
})}
</div> </div>
<div className="mt-6 text-center text-zinc-500 text-sm"> <div className="mt-6 text-center text-zinc-500 text-sm">
<p>Click voidling to change mood Double-click to rename Click × to release</p> <p>Click: change mood Double-click: rename : feed ×: release</p>
<p className="mt-2">Your voidlings: {voidlings.length}</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 => (
<span key={t.type} className="flex items-center gap-1">
<span className={`w-2 h-2 rounded-full ${t.color}`} />
{t.type}
</span>
))}
</div>
</div> </div>
</div> </div>
</div> </div>