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:
20
WORKLOG.md
20
WORKLOG.md
@ -13,6 +13,9 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
||||
- [x] Main Chamber view created with floating voidlings
|
||||
- [x] Ability to name and rename 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
|
||||
(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 localStorage persistence to remember voidlings between sessions
|
||||
- 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
|
||||
1. Add more visual effects (particles, glows)
|
||||
2. Add voidling interactions (feeding, playing)
|
||||
3. Add sound effects
|
||||
4. Add voidling types/classes with different behaviors
|
||||
5. Add animations for different moods
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
*Last updated: Session 2 complete - Chamber view with interactive voidlings added*
|
||||
*Last updated: Session 3 complete - Added particle effects, voidling types, and mood animations*
|
||||
|
||||
@ -6,17 +6,28 @@ interface Voidling {
|
||||
name: string
|
||||
color: string
|
||||
mood: 'idle' | 'happy' | 'curious' | 'sleepy'
|
||||
type: 'wisp' | 'blob' | 'spark' | 'ember'
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const VOIDLING_COLORS = [
|
||||
'bg-purple-400',
|
||||
'bg-cyan-400',
|
||||
'bg-pink-400',
|
||||
'bg-emerald-400',
|
||||
'bg-amber-400',
|
||||
'bg-violet-400',
|
||||
interface Particle {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
speed: number
|
||||
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 = {
|
||||
@ -26,14 +37,23 @@ const MOOD_EMOJIS = {
|
||||
sleepy: '💤',
|
||||
}
|
||||
|
||||
function getRandomColor() {
|
||||
return VOIDLING_COLORS[Math.floor(Math.random() * VOIDLING_COLORS.length)]
|
||||
const PARTICLE_COLORS = ['bg-purple-400/30', 'bg-cyan-400/30', 'bg-pink-400/30', 'bg-amber-400/30']
|
||||
|
||||
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() {
|
||||
return {
|
||||
x: Math.random() * 80 + 10,
|
||||
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)
|
||||
}
|
||||
|
||||
function getTypeInfo(type: Voidling['type']) {
|
||||
return VOIDLING_TYPES.find(t => t.type === type) || VOIDLING_TYPES[0]
|
||||
}
|
||||
|
||||
export default function Chamber() {
|
||||
const [voidlings, setVoidlings] = useState<Voidling[]>([])
|
||||
const [namingVoidling, setNamingVoidling] = useState<string | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const [feedingId, setFeedingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('voidlings')
|
||||
@ -68,24 +94,92 @@ export default function Chamber() {
|
||||
localStorage.setItem('voidlings', JSON.stringify(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() {
|
||||
const starters: Voidling[] = [
|
||||
{ id: generateId(), name: 'Glip', color: 'bg-purple-400', mood: 'idle', ...getRandomPosition() },
|
||||
{ id: generateId(), name: 'Floop', color: 'bg-cyan-400', mood: 'happy', ...getRandomPosition() },
|
||||
{ id: generateId(), name: 'Norf', color: 'bg-pink-400', mood: 'curious', ...getRandomPosition() },
|
||||
{ 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() },
|
||||
]
|
||||
setVoidlings(starters)
|
||||
}
|
||||
|
||||
function spawnVoidling() {
|
||||
const pos = getRandomPosition()
|
||||
const type = getRandomType()
|
||||
const typeStyles = getTypeStyle(type)
|
||||
const newVoidling: Voidling = {
|
||||
id: generateId(),
|
||||
name: '???',
|
||||
color: getRandomColor(),
|
||||
color: typeStyles.color,
|
||||
mood: getRandomMood(),
|
||||
type: type,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
vx: pos.vx,
|
||||
vy: pos.vy,
|
||||
}
|
||||
setVoidlings([...voidlings, newVoidling])
|
||||
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) {
|
||||
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 (
|
||||
<div className="min-h-screen p-8 relative overflow-hidden">
|
||||
<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" />
|
||||
<div className="absolute top-40 right-20 w-3 h-3 bg-cyan-400/30 rounded-full animate-pulse" />
|
||||
<div className="absolute bottom-20 left-1/4 w-2 h-2 bg-pink-400/30 rounded-full animate-ping delay-700" />
|
||||
{particles.map(p => (
|
||||
<div
|
||||
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 className="max-w-6xl mx-auto relative z-10">
|
||||
@ -152,79 +279,113 @@ export default function Chamber() {
|
||||
</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">
|
||||
<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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-500">
|
||||
<p>The chamber feels empty... Summon a voidling to begin.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voidlings.map((voidling) => (
|
||||
<div
|
||||
key={voidling.id}
|
||||
className="absolute group cursor-pointer transition-all duration-500"
|
||||
style={{
|
||||
left: `${voidling.x}%`,
|
||||
top: `${voidling.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onClick={() => changeMood(voidling.id)}
|
||||
onDoubleClick={() => startNaming(voidling.id)}
|
||||
>
|
||||
{voidlings.map((voidling) => {
|
||||
const typeInfo = getTypeInfo(voidling.type)
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${voidling.color}
|
||||
w-20 h-20 rounded-full
|
||||
flex items-center justify-center
|
||||
text-zinc-900 font-bold text-lg
|
||||
transition-all duration-300
|
||||
hover:scale-110 hover:shadow-xl
|
||||
animate-float
|
||||
border-4 border-zinc-900/20
|
||||
`}
|
||||
key={voidling.id}
|
||||
className="absolute group cursor-pointer transition-all duration-300"
|
||||
style={{
|
||||
animationDuration: `${2 + Math.random()}s`,
|
||||
left: `${voidling.x}%`,
|
||||
top: `${voidling.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onClick={() => changeMood(voidling.id)}
|
||||
onDoubleClick={(e) => { e.stopPropagation(); startNaming(voidling.id) }}
|
||||
>
|
||||
<span className="text-2xl">{MOOD_EMOJIS[voidling.mood]}</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
{namingVoidling === voidling.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={() => finishNaming(voidling.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') finishNaming(voidling.id)
|
||||
}}
|
||||
className="bg-zinc-800 border border-zinc-600 rounded-lg px-2 py-1 text-xs text-center w-24 focus:outline-none focus:border-purple-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400 bg-zinc-900/80 px-2 py-1 rounded-lg">
|
||||
{voidling.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
${voidling.color}
|
||||
${typeInfo.shape}
|
||||
w-20 h-20
|
||||
flex items-center justify-center
|
||||
text-zinc-900 font-bold text-lg
|
||||
transition-all duration-300
|
||||
hover:scale-110
|
||||
shadow-lg ${typeInfo.glow}
|
||||
${getMoodAnimation(voidling.mood)}
|
||||
${feedingId === voidling.id ? 'scale-125' : ''}
|
||||
border-4 border-zinc-900/20
|
||||
`}
|
||||
>
|
||||
<span className="text-2xl filter drop-shadow-md">{MOOD_EMOJIS[voidling.mood]}</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap flex flex-col items-center gap-1">
|
||||
{namingVoidling === voidling.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={() => finishNaming(voidling.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') finishNaming(voidling.id)
|
||||
}}
|
||||
className="bg-zinc-800 border border-zinc-600 rounded-lg px-2 py-1 text-xs text-center w-24 focus:outline-none focus:border-purple-400"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-zinc-400 bg-zinc-900/80 px-2 py-1 rounded-lg">
|
||||
{voidling.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-600 capitalize">
|
||||
{voidling.type}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
releaseVoidling(voidling.id)
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user