Add Chamber page with interactive voidlings and localStorage persistence

- Set up React Router for navigation between landing and Chamber pages
- Create Chamber page with floating animated voidlings
- Add ability to summon, name, rename, and release voidlings
- Implement mood system (idle, happy, curious, sleepy)
- Add localStorage persistence to remember voidlings between sessions
- Add float animation and visual effects
This commit is contained in:
Ernie Cook
2026-03-02 09:45:58 -05:00
parent 9baa655f9c
commit af5e31269c
6 changed files with 353 additions and 14 deletions

232
src/pages/Chamber.tsx Normal file
View File

@ -0,0 +1,232 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
interface Voidling {
id: string
name: string
color: string
mood: 'idle' | 'happy' | 'curious' | 'sleepy'
x: number
y: number
}
const VOIDLING_COLORS = [
'bg-purple-400',
'bg-cyan-400',
'bg-pink-400',
'bg-emerald-400',
'bg-amber-400',
'bg-violet-400',
]
const MOOD_EMOJIS = {
idle: '◉',
happy: '✧',
curious: '❓',
sleepy: '💤',
}
function getRandomColor() {
return VOIDLING_COLORS[Math.floor(Math.random() * VOIDLING_COLORS.length)]
}
function getRandomPosition() {
return {
x: Math.random() * 80 + 10,
y: Math.random() * 60 + 20,
}
}
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)
}
export default function Chamber() {
const [voidlings, setVoidlings] = useState<Voidling[]>([])
const [namingVoidling, setNamingVoidling] = useState<string | null>(null)
const [newName, setNewName] = useState('')
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])
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() },
]
setVoidlings(starters)
}
function spawnVoidling() {
const pos = getRandomPosition()
const newVoidling: Voidling = {
id: generateId(),
name: '???',
color: getRandomColor(),
mood: getRandomMood(),
x: pos.x,
y: pos.y,
}
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 releaseVoidling(id: string) {
setVoidlings(voidlings.filter(v => v.id !== id))
}
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" />
</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">
{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)}
>
<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
`}
style={{
animationDuration: `${2 + Math.random()}s`,
}}
>
<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>
<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>
<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 className="mt-2">Your voidlings: {voidlings.length}</p>
</div>
</div>
</div>
)
}