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

View File

@ -9,6 +9,10 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
- [x] Project initialized with Vite + React + TypeScript + TailwindCSS - [x] Project initialized with Vite + React + TypeScript + TailwindCSS
- [x] Basic project structure in place - [x] Basic project structure in place
- [x] First "Hello World" page running - [x] First "Hello World" page running
- [x] React Router added for navigation
- [x] Main Chamber view created with floating voidlings
- [x] Ability to name and rename voidlings
- [x] localStorage persistence for voidlings
### In Progress ### In Progress
(None yet) (None yet)
@ -17,14 +21,19 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
- Initialized Vite + React + TypeScript project - Initialized Vite + React + TypeScript project
- Added TailwindCSS configuration - Added TailwindCSS configuration
- Created "The Void Chamber" landing page with interactive voidlings - Created "The Void Chamber" landing page with interactive voidlings
- Added React Router for navigation between landing and Chamber pages
- Created Chamber page with floating animated voidlings
- 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
### Next Steps ### Next Steps
1. Add React Router for navigation between pages 1. Add more visual effects (particles, glows)
2. Create a main "Chamber" view where voidlings live 2. Add voidling interactions (feeding, playing)
3. Add ability to name and interact with voidlings 3. Add sound effects
4. Add persistence (localStorage) to remember your voidlings 4. Add voidling types/classes with different behaviors
5. Add animations and visual effects 5. Add animations for different moods
--- ---
*Last updated: Session 1 complete - Project initialized* *Last updated: Session 2 complete - Chamber view with interactive voidlings added*

56
package-lock.json generated
View File

@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@ -2107,6 +2108,18 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3230,6 +3243,42 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3297,6 +3346,11 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@ -1,7 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom'
import Chamber from './pages/Chamber'
function App() { function Landing() {
const [greeted, setGreeted] = useState(false) const [greeted, setGreeted] = useState(false)
const navigate = useNavigate()
const voidlings = [ const voidlings = [
{ name: 'Glip', color: 'bg-purple-400' }, { name: 'Glip', color: 'bg-purple-400' },
@ -11,23 +14,28 @@ function App() {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-8"> <div className="min-h-screen flex flex-col items-center justify-center p-8">
<h1 className="text-5xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-20 left-20 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl" />
</div>
<h1 className="text-5xl font-bold mb-2 text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 relative z-10">
The Void Chamber The Void Chamber
</h1> </h1>
<p className="text-zinc-400 mb-8 text-lg"> <p className="text-zinc-400 mb-8 text-lg relative z-10">
A pocket dimension for your digital companions A pocket dimension for your digital companions
</p> </p>
{!greeted ? ( {!greeted ? (
<button <button
onClick={() => setGreeted(true)} onClick={() => setGreeted(true)}
className="px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-2xl text-xl transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/20 border border-zinc-700" className="px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-2xl text-xl transition-all duration-300 hover:scale-105 hover:shadow-lg hover:shadow-purple-500/20 border border-zinc-700 relative z-10"
> >
Awaken the Void Awaken the Void
</button> </button>
) : ( ) : (
<div className="flex gap-6 mt-4"> <div className="flex gap-6 mt-4 relative z-10">
{voidlings.map((v) => ( {voidlings.map((v) => (
<div <div
key={v.name} key={v.name}
@ -39,11 +47,33 @@ function App() {
</div> </div>
)} )}
<p className="mt-12 text-zinc-500 text-sm"> <div className="mt-12 flex gap-4 relative z-10">
{greeted && (
<button
onClick={() => navigate('/chamber')}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-xl text-lg transition-all duration-300 hover:scale-105 shadow-lg shadow-purple-500/25"
>
Enter the Chamber
</button>
)}
</div>
<p className="mt-12 text-zinc-500 text-sm relative z-10">
{greeted ? 'Your voidlings have awakened!' : 'Click to summon your first companions'} {greeted ? 'Your voidlings have awakened!' : 'Click to summon your first companions'}
</p> </p>
</div> </div>
) )
} }
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/chamber" element={<Chamber />} />
</Routes>
</BrowserRouter>
)
}
export default App export default App

View File

@ -1,5 +1,18 @@
@import "tailwindcss"; @import "tailwindcss";
@keyframes float {
0%, 100% {
transform: translateY(0) translate(-50%, -50%);
}
50% {
transform: translateY(-20px) translate(-50%, -50%);
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
:root { :root {
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
} }

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>
)
}