Files
ernie-poc/src/pages/Chamber.tsx
Ernie Cook 7d419a0f7b 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
2026-03-02 10:06:50 -05:00

394 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
interface Voidling {
id: string
name: string
color: string
mood: 'idle' | 'happy' | 'curious' | 'sleepy'
type: 'wisp' | 'blob' | 'spark' | 'ember'
x: number
y: number
vx: number
vy: number
}
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 = {
idle: '◉',
happy: '✧',
curious: '❓',
sleepy: '💤',
}
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,
}
}
function getRandomMood(): Voidling['mood'] {
const moods: Voidling['mood'][] = ['idle', 'happy', 'curious', 'sleepy']
return moods[Math.floor(Math.random() * moods.length)]
}
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')
if (saved) {
try {
setVoidlings(JSON.parse(saved))
} catch {
createStarterVoidlings()
}
} else {
createStarterVoidlings()
}
}, [])
useEffect(() => {
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', 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: typeStyles.color,
mood: getRandomMood(),
type: type,
x: pos.x,
y: pos.y,
vx: pos.vx,
vy: pos.vy,
}
setVoidlings([...voidlings, newVoidling])
setNamingVoidling(newVoidling.id)
setNewName('')
}
function startNaming(id: string) {
setNamingVoidling(id)
const v = voidlings.find(v => v.id === id)
setNewName(v?.name === '???' ? '' : v?.name || '')
}
function finishNaming(id: string) {
if (newName.trim()) {
setVoidlings(voidlings.map(v =>
v.id === id ? { ...v, name: newName.trim() } : v
))
}
setNamingVoidling(null)
setNewName('')
}
function changeMood(id: string) {
const moods: Voidling['mood'][] = ['idle', 'happy', 'curious', 'sleepy']
setVoidlings(voidlings.map(v => {
if (v.id === id) {
const currentIndex = moods.indexOf(v.mood)
const nextMood = moods[(currentIndex + 1) % moods.length]
return { ...v, mood: nextMood }
}
return v
}))
}
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">
{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">
<div className="flex items-center justify-between mb-8">
<Link
to="/"
className="text-zinc-500 hover:text-zinc-300 transition-colors text-sm"
>
Back to Portal
</Link>
<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>
<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"
>
+ Summon Voidling
</button>
</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) => {
const typeInfo = getTypeInfo(voidling.type)
return (
<div
key={voidling.id}
className="absolute group cursor-pointer transition-all duration-300"
style={{
left: `${voidling.x}%`,
top: `${voidling.y}%`,
transform: 'translate(-50%, -50%)',
}}
onClick={() => changeMood(voidling.id)}
onDoubleClick={(e) => { e.stopPropagation(); startNaming(voidling.id) }}
>
<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>
<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: 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>
)
}