Add night/day cycle with 4 time phases
- Added time cycle system with dawn, day, dusk, and night phases - Each phase has unique gradient backgrounds and ambient orb colors - Voidlings move faster during day/dawn/dusk, slower at night - Voidlings naturally become sleepy when night falls - Added auto-cycle that progresses every ~15 seconds per phase - Added manual time control toggle for user preference - Added progress bar showing time until next phase - Time controls display current time icon and name
This commit is contained in:
11
WORKLOG.md
11
WORKLOG.md
@ -37,13 +37,18 @@ A whimsical pocket dimension where digital companions called "Voidlings" float a
|
|||||||
- Added petting interaction (♡ button) - makes voidlings happy
|
- Added petting interaction (♡ button) - makes voidlings happy
|
||||||
- Added achievements system with 10 unlockable achievements
|
- Added achievements system with 10 unlockable achievements
|
||||||
- Added user stats tracking (total pets, feedings, sessions)
|
- Added user stats tracking (total pets, feedings, sessions)
|
||||||
|
- Added night/day cycle with 4 time phases (dawn, day, dusk, night)
|
||||||
|
- Added time-based visual themes with different gradients and ambient colors
|
||||||
|
- Added automatic time progression (1 minute per full cycle)
|
||||||
|
- Added manual time control toggle
|
||||||
|
- Added voidling speed multipliers based on time of day (slower at night)
|
||||||
|
- Voidlings naturally become sleepy at night
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
1. Add sound effects
|
1. Add sound effects
|
||||||
2. Add voidling shop/trading
|
2. Add voidling shop/trading
|
||||||
3. Add night/day cycle
|
3. Add more voidling types
|
||||||
4. Add more voidling types
|
4. Add voidling minigames
|
||||||
5. Add voidling minigames
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,74 @@ interface Voidling {
|
|||||||
pets: number
|
pets: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeOfDay = 'dawn' | 'day' | 'dusk' | 'night'
|
||||||
|
|
||||||
|
interface TimeCycle {
|
||||||
|
name: TimeOfDay
|
||||||
|
gradient: string
|
||||||
|
chamberBg: string
|
||||||
|
chamberBorder: string
|
||||||
|
orb1: string
|
||||||
|
orb2: string
|
||||||
|
orb3: string
|
||||||
|
voidlingMultiplier: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_CYCLES: TimeCycle[] = [
|
||||||
|
{
|
||||||
|
name: 'dawn',
|
||||||
|
gradient: 'from-orange-300 via-pink-300 to-purple-400',
|
||||||
|
chamberBg: 'bg-orange-900/20',
|
||||||
|
chamberBorder: 'border-orange-700/30',
|
||||||
|
orb1: 'bg-orange-400/20',
|
||||||
|
orb2: 'bg-pink-400/20',
|
||||||
|
orb3: 'bg-purple-400/20',
|
||||||
|
voidlingMultiplier: 1.2,
|
||||||
|
description: 'The void stirs...'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'day',
|
||||||
|
gradient: 'from-cyan-300 via-blue-300 to-purple-300',
|
||||||
|
chamberBg: 'bg-cyan-900/20',
|
||||||
|
chamberBorder: 'border-cyan-700/30',
|
||||||
|
orb1: 'bg-cyan-400/20',
|
||||||
|
orb2: 'bg-blue-400/20',
|
||||||
|
orb3: 'bg-purple-400/20',
|
||||||
|
voidlingMultiplier: 1.5,
|
||||||
|
description: 'The void is bright'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dusk',
|
||||||
|
gradient: 'from-purple-400 via-pink-400 to-orange-400',
|
||||||
|
chamberBg: 'bg-purple-900/20',
|
||||||
|
chamberBorder: 'border-purple-700/30',
|
||||||
|
orb1: 'bg-purple-400/20',
|
||||||
|
orb2: 'bg-pink-400/20',
|
||||||
|
orb3: 'bg-orange-400/20',
|
||||||
|
voidlingMultiplier: 1.2,
|
||||||
|
description: 'Shadows lengthen...'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'night',
|
||||||
|
gradient: 'from-indigo-900 via-purple-900 to-black',
|
||||||
|
chamberBg: 'bg-indigo-950/40',
|
||||||
|
chamberBorder: 'border-indigo-800/40',
|
||||||
|
orb1: 'bg-indigo-500/10',
|
||||||
|
orb2: 'bg-purple-500/10',
|
||||||
|
orb3: 'bg-pink-500/10',
|
||||||
|
voidlingMultiplier: 0.5,
|
||||||
|
description: 'The void sleeps'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIME_ICONS: Record<TimeOfDay, string> = {
|
||||||
|
dawn: '🌅',
|
||||||
|
day: '☀️',
|
||||||
|
dusk: '🌆',
|
||||||
|
night: '🌙',
|
||||||
|
}
|
||||||
|
|
||||||
interface Particle {
|
interface Particle {
|
||||||
id: number
|
id: number
|
||||||
x: number
|
x: number
|
||||||
@ -47,13 +115,13 @@ const VOIDLING_TYPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const ACHIEVEMENTS: Achievement[] = [
|
const ACHIEVEMENTS: Achievement[] = [
|
||||||
{ id: 'first_pet', name: 'Gentle Touch', description: 'Pet your first voidling', icon: '✋', condition: (v, s) => s.totalPets >= 1 },
|
{ id: 'first_pet', name: 'Gentle Touch', description: 'Pet your first voidling', icon: '✋', condition: (_v, s) => s.totalPets >= 1 },
|
||||||
{ id: 'ten_pets', name: 'Frienemy', description: 'Pet voidlings 10 times', icon: '🤝', condition: (v, s) => s.totalPets >= 10 },
|
{ id: 'ten_pets', name: 'Frienemy', description: 'Pet voidlings 10 times', icon: '🤝', condition: (_v, s) => s.totalPets >= 10 },
|
||||||
{ id: 'fifty_pets', name: 'Void Whisperer', description: 'Pet voidlings 50 times', icon: '🔮', condition: (v, s) => s.totalPets >= 50 },
|
{ id: 'fifty_pets', name: 'Void Whisperer', description: 'Pet voidlings 50 times', icon: '🔮', condition: (_v, s) => s.totalPets >= 50 },
|
||||||
{ id: 'first_feeding', name: 'Nourisher', description: 'Feed your first voidling', icon: '◕', condition: (v, s) => s.totalFeedings >= 1 },
|
{ id: 'first_feeding', name: 'Nourisher', description: 'Feed your first voidling', icon: '◕', condition: (_v, s) => s.totalFeedings >= 1 },
|
||||||
{ id: 'collector', name: 'Collector', description: 'Have 5 different voidlings', icon: '📦', condition: (v, s) => v.length >= 5 },
|
{ id: 'collector', name: 'Collector', description: 'Have 5 different voidlings', icon: '📦', condition: (v, _s) => v.length >= 5 },
|
||||||
{ id: 'variety', name: 'Biodiversity', description: 'Have all 4 types of voidlings', icon: '🌈', condition: (v) => { const types = new Set(v.map(x => x.type)); return types.size >= 4; } },
|
{ id: 'variety', name: 'Biodiversity', description: 'Have all 4 types of voidlings', icon: '🌈', condition: (v) => { const types = new Set(v.map(x => x.type)); return types.size >= 4; } },
|
||||||
{ id: 'parent', name: 'Protective Parent', description: 'Have 10 voidlings', icon: '👨👩👧👦', condition: (v, s) => v.length >= 10 },
|
{ id: 'parent', name: 'Protective Parent', description: 'Have 10 voidlings', icon: '👨👩👧👦', condition: (v, _s) => v.length >= 10 },
|
||||||
{ id: 'curious', name: 'Curious Mind', description: 'Have a curious voidling', icon: '❓', condition: (v) => v.some(x => x.mood === 'curious') },
|
{ id: 'curious', name: 'Curious Mind', description: 'Have a curious voidling', icon: '❓', condition: (v) => v.some(x => x.mood === 'curious') },
|
||||||
{ id: 'sleepy_boys', name: 'Sleepy Boys', description: 'Have a sleepy voidling', icon: '😴', condition: (v) => v.some(x => x.mood === 'sleepy') },
|
{ id: 'sleepy_boys', name: 'Sleepy Boys', description: 'Have a sleepy voidling', icon: '😴', condition: (v) => v.some(x => x.mood === 'sleepy') },
|
||||||
{ id: 'happy_home', name: 'Happy Home', description: 'Have 3 happy voidlings', icon: '☺️', condition: (v) => v.filter(x => x.mood === 'happy').length >= 3 },
|
{ id: 'happy_home', name: 'Happy Home', description: 'Have 3 happy voidlings', icon: '☺️', condition: (v) => v.filter(x => x.mood === 'happy').length >= 3 },
|
||||||
@ -115,6 +183,9 @@ export default function Chamber() {
|
|||||||
totalVoidlings: 0,
|
totalVoidlings: 0,
|
||||||
sessions: 1,
|
sessions: 1,
|
||||||
})
|
})
|
||||||
|
const [timeOfDay, setTimeOfDay] = useState<TimeOfDay>('night')
|
||||||
|
const [isAutoCycle, setIsAutoCycle] = useState(true)
|
||||||
|
const [cycleProgress, setCycleProgress] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedVoidlings = localStorage.getItem('voidlings')
|
const savedVoidlings = localStorage.getItem('voidlings')
|
||||||
@ -170,6 +241,43 @@ export default function Chamber() {
|
|||||||
})
|
})
|
||||||
}, [voidlings, userStats])
|
}, [voidlings, userStats])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAutoCycle) return
|
||||||
|
|
||||||
|
const cycleDuration = 60000
|
||||||
|
const updateInterval = 500
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCycleProgress(prev => {
|
||||||
|
const newProgress = prev + (updateInterval / cycleDuration) * TIME_CYCLES.length
|
||||||
|
if (newProgress >= TIME_CYCLES.length) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return newProgress
|
||||||
|
})
|
||||||
|
}, updateInterval)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isAutoCycle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentIndex = Math.floor(cycleProgress)
|
||||||
|
if (TIME_CYCLES[currentIndex]) {
|
||||||
|
setTimeOfDay(TIME_CYCLES[currentIndex].name)
|
||||||
|
}
|
||||||
|
}, [cycleProgress])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeOfDay === 'night') {
|
||||||
|
setVoidlings(prev => prev.map(v => {
|
||||||
|
if (v.mood !== 'sleepy' && Math.random() > 0.7) {
|
||||||
|
return { ...v, mood: 'sleepy' as const }
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [timeOfDay])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setParticles(prev => {
|
setParticles(prev => {
|
||||||
@ -199,6 +307,9 @@ export default function Chamber() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentTimeCycle = TIME_CYCLES.find(t => t.name === timeOfDay) || TIME_CYCLES[0]
|
||||||
|
const multiplier = currentTimeCycle.voidlingMultiplier
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setVoidlings(prev => prev.map(v => {
|
setVoidlings(prev => prev.map(v => {
|
||||||
let { x, y, vx, vy } = v
|
let { x, y, vx, vy } = v
|
||||||
@ -207,11 +318,11 @@ export default function Chamber() {
|
|||||||
vx *= 0.95
|
vx *= 0.95
|
||||||
vy *= 0.95
|
vy *= 0.95
|
||||||
} else if (v.mood === 'happy') {
|
} else if (v.mood === 'happy') {
|
||||||
vx += (Math.random() - 0.5) * 0.2
|
vx += (Math.random() - 0.5) * 0.2 * multiplier
|
||||||
vy += (Math.random() - 0.5) * 0.2
|
vy += (Math.random() - 0.5) * 0.2 * multiplier
|
||||||
} else if (v.mood === 'curious') {
|
} else if (v.mood === 'curious') {
|
||||||
vx += (Math.random() - 0.5) * 0.1
|
vx += (Math.random() - 0.5) * 0.1 * multiplier
|
||||||
vy += (Math.random() - 0.5) * 0.1
|
vy += (Math.random() - 0.5) * 0.1 * multiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
if (v.type === 'spark') {
|
if (v.type === 'spark') {
|
||||||
@ -221,8 +332,8 @@ export default function Chamber() {
|
|||||||
if (Math.abs(vy) > 1) vy = vy > 0 ? 1 : -1
|
if (Math.abs(vy) > 1) vy = vy > 0 ? 1 : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
x += vx
|
x += vx * multiplier
|
||||||
y += vy
|
y += vy * multiplier
|
||||||
|
|
||||||
if (x < 5 || x > 95) { vx *= -1; x = Math.max(5, Math.min(95, x)) }
|
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)) }
|
if (y < 10 || y > 90) { vy *= -1; y = Math.max(10, Math.min(90, y)) }
|
||||||
@ -231,7 +342,7 @@ export default function Chamber() {
|
|||||||
}))
|
}))
|
||||||
}, 100)
|
}, 100)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [timeOfDay])
|
||||||
|
|
||||||
function createStarterVoidlings() {
|
function createStarterVoidlings() {
|
||||||
const starters: Voidling[] = [
|
const starters: Voidling[] = [
|
||||||
@ -336,8 +447,11 @@ export default function Chamber() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentTimeCycle = TIME_CYCLES.find(t => t.name === timeOfDay) || TIME_CYCLES[0]
|
||||||
|
const progressInPhase = cycleProgress % 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-8 relative overflow-hidden">
|
<div className={`min-h-screen p-8 relative overflow-hidden transition-colors duration-1000 bg-gradient-to-br ${currentTimeCycle.gradient}`}>
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{particles.map(p => (
|
{particles.map(p => (
|
||||||
<div
|
<div
|
||||||
@ -369,6 +483,38 @@ export default function Chamber() {
|
|||||||
The Void Chamber
|
The Void Chamber
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-800/80 rounded-xl border border-zinc-700">
|
||||||
|
<span className="text-lg" title={currentTimeCycle.description}>{TIME_ICONS[timeOfDay]}</span>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs capitalize text-zinc-300">{timeOfDay}</span>
|
||||||
|
<div className="w-16 h-1 bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-purple-400 transition-all duration-300"
|
||||||
|
style={{ width: `${progressInPhase * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAutoCycle(!isAutoCycle)}
|
||||||
|
className={`text-xs px-2 py-1 rounded ${isAutoCycle ? 'bg-purple-600' : 'bg-zinc-600'} hover:bg-purple-500 transition-colors`}
|
||||||
|
title={isAutoCycle ? 'Auto cycle on' : 'Auto cycle off'}
|
||||||
|
>
|
||||||
|
{isAutoCycle ? 'AUTO' : 'MANUAL'}
|
||||||
|
</button>
|
||||||
|
{!isAutoCycle && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TIME_CYCLES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.name}
|
||||||
|
onClick={() => setTimeOfDay(t.name)}
|
||||||
|
className={`text-xs px-2 py-1 rounded transition-colors ${timeOfDay === t.name ? 'bg-purple-600' : 'bg-zinc-600 hover:bg-zinc-500'}`}
|
||||||
|
>
|
||||||
|
{TIME_ICONS[t.name]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAchievements(!showAchievements)}
|
onClick={() => setShowAchievements(!showAchievements)}
|
||||||
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 flex items-center gap-2"
|
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 flex items-center gap-2"
|
||||||
@ -385,11 +531,11 @@ export default function Chamber() {
|
|||||||
</div>
|
</div>
|
||||||
</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] rounded-3xl border overflow-hidden transition-colors duration-1000 ${currentTimeCycle.chamberBg} ${currentTimeCycle.chamberBorder}`}>
|
||||||
<div className="absolute inset-0">
|
<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 top-10 left-10 w-32 h-32 ${currentTimeCycle.orb1} 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 bottom-20 right-20 w-40 h-40 ${currentTimeCycle.orb2} 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 className={`absolute top-1/2 left-1/3 w-24 h-24 ${currentTimeCycle.orb3} rounded-full blur-2xl animate-pulse delay-1000`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{voidlings.length === 0 && (
|
{voidlings.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user