diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx new file mode 100644 index 00000000..27561948 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -0,0 +1,137 @@ +// src/blobbi/rooms/components/BlobbiCareRoom.tsx + +/** + * BlobbiCareRoom — Hygiene, care, and medicine room. + * + * Side actions depend on the currently focused carousel item: + * - Hygiene focused: Towel (left) + Shower (right) + * - Medicine focused: Treat (left) + spacer (right) + * + * Both left and right slots always render the same fixed width + * so the bottom bar never shifts when switching item types. + */ + +import { useMemo, useState, useCallback } from 'react'; +import { ShowerHead, Candy } from 'lucide-react'; + +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; + +interface BlobbiCareRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { + const { + isUsingItem, + usingItemId, + handleUseItemFromTab, + isPublishing, + actionInProgress, + isActiveFloatingCompanion, + } = ctx; + + const hygieneItems = useMemo(() => + getLiveShopItems().filter(i => i.type === 'hygiene'), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + const towelItem = hygieneItems.find(i => i.id === 'hyg_towel'); + + // Carousel: hygiene (except towel) + medicine, each tagged with meta + const carouselEntries = useMemo(() => { + const hygiene = getLiveShopItems() + .filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name, meta: 'hygiene' })); + const medicine = getLiveShopItems() + .filter(i => i.type === 'medicine') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name, meta: 'medicine' })); + return [...hygiene, ...medicine]; + }, []); + + // Track the type of the currently focused carousel item + const [focusedMeta, setFocusedMeta] = useState( + carouselEntries[0]?.meta ?? 'hygiene', + ); + + const handleFocusChange = useCallback((entry: CarouselEntry) => { + setFocusedMeta(entry.meta ?? 'hygiene'); + }, []); + + const isHygieneFocused = focusedMeta === 'hygiene'; + + return ( +
+ + + {!isActiveFloatingCompanion && ( +
+
+ {/* Left slot — always same width (button or spacer) */} + {isHygieneFocused ? ( + towelItem ? ( + {towelItem.icon}} + label="Towel" + color="text-cyan-500" + glowHex="#06b6d4" + onClick={() => handleUseItemFromTab(towelItem.id)} + disabled={isDisabled} + loading={isUsingItem && usingItemId === towelItem.id} + /> + ) : ( +
+ ) + ) : ( + } + label="Treat" + color="text-pink-400" + glowHex="#f472b6" + onClick={() => { + // Comfort treat — use a small food item as a reward after medicine + const treat = getLiveShopItems().find(i => i.type === 'food'); + if (treat) handleUseItemFromTab(treat.id); + }} + disabled={isDisabled} + /> + )} + + {/* Center carousel */} +
+ +
+ + {/* Right slot — always same width (button or spacer) */} + {isHygieneFocused ? ( + } + label="Shower" + color="text-blue-500" + glowHex="#3b82f6" + onClick={() => { + const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo'); + if (shampoo) handleUseItemFromTab(shampoo.id); + }} + disabled={isDisabled} + /> + ) : ( +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiClosetRoom.tsx b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx new file mode 100644 index 00000000..869189a9 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx @@ -0,0 +1,37 @@ +// src/blobbi/rooms/components/BlobbiClosetRoom.tsx + +/** + * BlobbiClosetRoom — Placeholder room for wardrobe / accessories. + * + * Uses the same bottom bar structure as other rooms for visual consistency, + * with a centered placeholder message. + */ + +import { Shirt } from 'lucide-react'; + +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +interface BlobbiClosetRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) { + return ( +
+ + + {/* Bottom bar — same structure as other rooms */} +
+
+ +

+ Closet coming soon +

+
+
+
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx new file mode 100644 index 00000000..b0804574 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -0,0 +1,542 @@ +// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx + +/** + * BlobbiHatcheryRoom — Incubation / evolution / progression room. + * + * Layout: + * - BlobbiRoomHero (Blobbi visual + stats) + * - Bottom center: main start/stop hatching or evolution button + * - Bottom right: quests/tasks button + * - Bottom left: Blobbis list/selector button + * + * Reuses existing hatch/evolve/missions logic from BlobbiPage. + */ + +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { + Loader2, Sparkles, Egg, Target, Check, ListTodo, + Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic, + Pill, Utensils, Plus, Footprints, ExternalLink, Theater, +} from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { openUrl } from '@/lib/downloadFile'; +import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { isLocalhostDev } from '@/blobbi/dev'; +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; + +// ─── Helper: companionNeedsCare (reused from BlobbiPage) ────────────────────── + +const CARE_THRESHOLD = 40; + +function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): boolean { + const { stats } = companion; + return ( + (stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) || + (stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) || + (stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) || + (stats.health !== undefined && stats.health < CARE_THRESHOLD) + ); +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface BlobbiHatcheryRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) { + const { + companion, + companions, + selectedD, + profile, + isEgg, + isBaby, + isIncubating, + isEvolvingState, + canStartIncubation, + canStartEvolution, + isStartingIncubation, + isStartingEvolution, + isStoppingIncubation, + isStoppingEvolution, + isHatching, + isEvolving, + hatchTasks, + evolveTasks, + onStartIncubation, + onStartEvolution, + onStopIncubation, + onStopEvolution, + onEvolve, + setShowPostModal, + setShowHatchCeremony, + isActiveFloatingCompanion, + // Blobbi selector + onSelectBlobbi, + blobbiNaddr, + // Adoption + setShowAdoptionFlow, + // Daily missions + dailyMissions, + onClaimReward, + isClaimingReward, + // DEV + setShowDevEditor, + setShowEmotionPanel, + } = ctx; + + const navigate = useNavigate(); + + // Side panels + const [showQuestsPanel, setShowQuestsPanel] = useState(false); + const [showBlobbisPanel, setShowBlobbisPanel] = useState(false); + + const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby); + const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution; + + const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks; + const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted; + const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading; + + const completedCount = tasks.filter(t => t.completed).length; + const totalCount = tasks.length; + + const { missions } = dailyMissions; + + return ( +
+ {/* ── Hero ── */} + + + {/* ── Bottom Action Bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Left — Blobbis selector */} + } + label="Blobbis" + color="text-primary" + glowHex="var(--primary)" + onClick={() => setShowBlobbisPanel(true)} + badge={companions.length > 1 ? ( + + {companions.length} + + ) : undefined} + /> + + {/* Center — Main hatch/evolve action */} +
+ {/* Active process: Hatch/Evolve CTA or progress */} + {hasActiveProcess && allCompleted && !isTasksLoading && ( + + )} + + {hasActiveProcess && !allCompleted && !isTasksLoading && ( +
+
+ + {isIncubating ? 'Hatching' : 'Evolving'} + {completedCount}/{totalCount} +
+ {/* Progress bar */} +
+
0 ? (completedCount / totalCount) * 100 : 0}%`, + background: isIncubating + ? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)' + : 'linear-gradient(90deg, #8b5cf6, #ec4899)', + }} + /> +
+
+ )} + + {hasActiveProcess && isTasksLoading && ( + + )} + + {/* No active process — show start button */} + {!hasActiveProcess && (canStartIncubation || canStartEvolution) && ( + + )} + + {!hasActiveProcess && !canStartIncubation && !canStartEvolution && ( +

No journey available

+ )} + + {/* Stop process link */} + {hasActiveProcess && !isTasksLoading && ( + + )} +
+ + {/* Right — Quests/Tasks */} + } + label="Quests" + color="text-amber-500" + glowHex="#f59e0b" + onClick={() => setShowQuestsPanel(true)} + badge={hasActiveProcess && totalCount - completedCount > 0 ? ( + + {totalCount - completedCount} + + ) : undefined} + /> +
+
+ )} + + {/* ── Quests Sheet ── */} + + + + + + Quests + + + +
+ {/* Journey tasks */} + {hasActiveProcess && ( +
+

+ {isIncubating ? 'Hatching Journey' : 'Evolution Journey'} +

+ {isTasksLoading && ( +
+ +
+ )} + {!isTasksLoading && tasks.map(task => { + const handleAction = () => { + if (!task.action || !task.actionTarget) return; + switch (task.action) { + case 'navigate': navigate(task.actionTarget); setShowQuestsPanel(false); break; + case 'external_link': openUrl(task.actionTarget); break; + case 'open_modal': if (task.actionTarget === 'blobbi_post') { setShowPostModal(true); setShowQuestsPanel(false); } break; + } + }; + const isActionable = !task.completed && !!task.action && !!task.actionTarget; + return ( + + ); + })} +
+ )} + + {!hasActiveProcess && ( +
+ +

Start a journey to unlock tasks

+
+ )} + + {/* Daily Bounties */} +
+

+ Daily Bounties +

+ {dailyMissions.noMissionsAvailable && ( +
+ +

Hatch your Blobbi to unlock bounties

+
+ )} + {!dailyMissions.noMissionsAvailable && missions.map(mission => { + const canClaim = mission.completed && !mission.claimed; + return ( +
+ +
+

{mission.title}

+

{mission.description}

+
+ {!mission.claimed && ( + {mission.currentCount}/{mission.requiredCount} + )} + {canClaim && ( + + )} +
+ ); + })} + {/* Bonus row */} + {!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && ( +
+
+ +
+
+

Daily Champion

+

All missions complete!

+
+ +
+ )} +
+
+
+
+
+ + {/* ── Blobbis Sheet ── */} + + + + + + Your Blobbis + + + +
+ {/* Blobbi grid */} +
+ {companions.map((c) => { + const isSelected = c.d === selectedD; + const isCompanion = c.d === profile?.currentCompanion; + return ( + + ); + })} + + {/* Adopt + button */} + +
+ + {/* Quick actions row */} +
+ setShowBlobbisPanel(false)} + className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors" + > + + View + + {/* DEV tools */} + {isLocalhostDev() && ( + <> + {companion.stage !== 'adult' && ( + + )} + + + + )} +
+
+
+
+
+
+ ); +} + +// ─── Quest task icon (reused from BlobbiPage) ───────────────────────────────── + +function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) { + const iconClass = 'size-4'; + const icon = (() => { + switch (taskId) { + case 'create_themes': return ; + case 'color_moments': return ; + case 'create_posts': return ; + case 'interactions': return ; + case 'edit_profile': return ; + case 'maintain_stats': return ; + default: return ; + } + })(); + return ( +
+ {completed ? : icon} +
+ ); +} + +// ─── Daily mission icon (reused from BlobbiPage) ────────────────────────────── + +function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) { + const iconClass = 'size-4'; + const icon = (() => { + switch (action) { + case 'interact': return ; + case 'feed': return ; + case 'clean': return ; + case 'sleep': return ; + case 'take_photo': return ; + case 'sing': return ; + case 'play_music': return ; + case 'medicine': return ; + default: return ; + } + })(); + return ( +
+ {claimed ? : icon} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx new file mode 100644 index 00000000..c0db3e12 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx @@ -0,0 +1,162 @@ +// src/blobbi/rooms/components/BlobbiHomeRoom.tsx + +/** + * BlobbiHomeRoom — The main living / play room. + * + * Layout: + * - BlobbiRoomHero (stats crown, Blobbi visual, name) + * - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right) + * - Inline activity (music player, sing card) above the bottom bar + * + * Sleep/wake has been moved to BlobbiRestRoom. + */ + +import { useMemo } from 'react'; +import { Camera, Footprints, Music, Mic } from 'lucide-react'; + +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; +import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions'; +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; + +interface BlobbiHomeRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { + const { + isActiveFloatingCompanion, + setShowPhotoModal, + isCurrentCompanion, + canBeCompanion, + isUpdatingCompanion, + handleSetAsCompanion, + isUsingItem, + usingItemId, + handleUseItemFromTab, + handleDirectAction, + isDirectActionPending, + inlineActivity, + handleConfirmSing, + handleCloseInlineActivity, + handleMusicPlaybackStart, + handleMusicPlaybackStop, + handleSingRecordingStart, + handleSingRecordingStop, + handleChangeTrack, + isPublishing, + actionInProgress, + } = ctx; + + // Build carousel entries: toys + music + sing + const carouselItems = useMemo(() => { + const toys = getLiveShopItems() + .filter(i => i.type === 'toy') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })); + + const actions: CarouselEntry[] = [ + { + id: '__action_music', + icon:
, + label: 'Music', + }, + { + id: '__action_sing', + icon:
, + label: 'Sing', + }, + ]; + + return [...toys, ...actions]; + }, []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + const handleCarouselUse = (id: string) => { + if (id === '__action_music') { + handleDirectAction('play_music'); + } else if (id === '__action_sing') { + handleDirectAction('sing'); + } else { + handleUseItemFromTab(id); + } + }; + + return ( +
+ {/* ── Hero (Blobbi + stats) ── */} + + + {/* ── Inline Activity Area (music/sing) ── */} + {inlineActivity.type === 'music' && ( +
+ +
+ )} + {inlineActivity.type === 'sing' && ( +
+ +
+ )} + + {/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Photo */} + } + label="Photo" + color="text-pink-500" + glowHex="#ec4899" + onClick={() => setShowPhotoModal(true)} + /> + + {/* Center carousel */} +
+ +
+ + {/* Companion toggle */} + {canBeCompanion ? ( + } + label={isCurrentCompanion ? 'With you' : 'Take along'} + color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'} + glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'} + onClick={handleSetAsCompanion} + disabled={isUpdatingCompanion} + loading={isUpdatingCompanion} + /> + ) : ( +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx new file mode 100644 index 00000000..26016a17 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx @@ -0,0 +1,148 @@ +// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx + +/** + * BlobbiKitchenRoom — The feeding room. + * + * Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right) + * Poop appears at pre-computed safe positions in the lower corners. + */ + +import { useMemo, useState } from 'react'; +import { Refrigerator, Shovel } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; +import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal'; +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; + +interface BlobbiKitchenRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) { + const { + companion, + profile, + isUsingItem, + usingItemId, + handleUseItemFromTab, + isPublishing, + actionInProgress, + isActiveFloatingCompanion, + } = ctx; + + const [showFridge, setShowFridge] = useState(false); + + const foodEntries = useMemo(() => + getLiveShopItems() + .filter(i => i.type === 'food') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + const handleFridgeUseItem = (itemId: string) => { + if (isUsingItem) return; + ctx.onUseItem(itemId, 'feed').finally(() => { + setShowFridge(false); + }); + }; + + const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen'); + const anyPoopAnywhere = hasAnyPoop(poopState.poops); + + return ( +
+ {/* ── Hero + Poop layer ── */} +
+ + + {/* Poop at pre-computed safe positions */} + {kitchenPoops.map((poop) => ( + + ))} +
+ + {/* ── Bottom bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Left — Shovel (when poop exists) or spacer */} + {anyPoopAnywhere ? ( + } + label={poopState.shovelMode ? 'Done' : 'Shovel'} + color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'} + glowHex={poopState.shovelMode ? '#d97706' : '#78716c'} + onClick={() => poopState.setShovelMode(prev => !prev)} + className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''} + /> + ) : ( +
+ )} + + {/* Center: food carousel */} +
+ +
+ + {/* Right — Fridge */} + } + label="Fridge" + color="text-orange-500" + glowHex="#f97316" + onClick={() => setShowFridge(true)} + disabled={isDisabled} + /> +
+
+ )} + + {showFridge && ( + setShowFridge(false)} + isUsingItem={isUsingItem} + usingItemId={usingItemId} + /> + )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiRestRoom.tsx b/src/blobbi/rooms/components/BlobbiRestRoom.tsx new file mode 100644 index 00000000..f56cd11c --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiRestRoom.tsx @@ -0,0 +1,62 @@ +// src/blobbi/rooms/components/BlobbiRestRoom.tsx + +/** + * BlobbiRestRoom — The bedroom / rest room. + * + * Bottom bar: Sleep/Wake button centered. + */ + +import { Moon, Sun, Loader2 } from 'lucide-react'; + +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; + +interface BlobbiRestRoomProps { + ctx: BlobbiRoomContext; + poopState: RoomPoopState; +} + +export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) { + const { + isEgg, + isSleeping, + onRest, + actionInProgress, + isPublishing, + isUsingItem, + isActiveFloatingCompanion, + } = ctx; + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + return ( +
+ + + {!isActiveFloatingCompanion && ( +
+
+ {!isEgg && ( + + : isSleeping + ? + : + } + label={isSleeping ? 'Wake up' : 'Sleep'} + color={isSleeping ? 'text-amber-500' : 'text-violet-500'} + glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'} + onClick={onRest} + disabled={isDisabled} + /> + )} +
+
+ )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx new file mode 100644 index 00000000..5380f698 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -0,0 +1,284 @@ +// src/blobbi/rooms/components/BlobbiRoomHero.tsx + +/** + * BlobbiRoomHero — Shared Blobbi visual display used in every room. + * + * This component does NOT clip or constrain the visual — it simply fills + * available flex space and centers the Blobbi + stats within it. + * The room owns the full-height surface; this just provides content. + * + * Top padding accounts for the floating room header overlay (~40px). + */ + +import { useMemo } from 'react'; +import { + Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle, + Footprints, Loader2, +} from 'lucide-react'; + +import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual'; +import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay'; +import { cn } from '@/lib/utils'; +import type { BlobbiRoomContext } from '../lib/room-types'; + +// ─── Stat colour maps ───────────────────────────────────────────────────────── + +const STAT_COLOR_MAP: Record = { + hunger: 'orange', + happiness: 'yellow', + health: 'green', + hygiene: 'blue', + energy: 'violet', +}; + +const STAT_COLORS: Record = { + orange: 'text-orange-500', + yellow: 'text-yellow-500', + green: 'text-green-500', + blue: 'text-blue-500', + violet: 'text-violet-500', +}; + +const STAT_BG_COLORS: Record = { + orange: 'bg-orange-500/10', + yellow: 'bg-yellow-500/10', + green: 'bg-green-500/10', + blue: 'bg-blue-500/10', + violet: 'bg-violet-500/10', +}; + +const STAT_RING_HEX: Record = { + orange: '#f97316', + yellow: '#eab308', + green: '#22c55e', + blue: '#3b82f6', + violet: '#8b5cf6', +}; + +const STAT_ICON_MAP: Record> = { + hunger: Utensils, + happiness: Gamepad2, + health: Heart, + hygiene: Droplets, + energy: Zap, +}; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface BlobbiRoomHeroProps { + ctx: BlobbiRoomContext; + className?: string; + hideStats?: boolean; + hideName?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) { + const { + companion, + currentStats, + isSleeping, + isEgg, + statusRecipe, + statusRecipeLabel, + effectiveEmotion, + hasDevOverride, + blobbiReaction, + isActiveFloatingCompanion, + isUpdatingCompanion, + handleSetAsCompanion, + heroRef, + heroWidth, + } = ctx; + + if (isActiveFloatingCompanion) { + return ( +
+ +

+ {companion.name} is out exploring right now. +

+ +
+ ); + } + + return ( +
+
+ {/* Stats crown */} + {!hideStats && } + + {/* Blobbi visual */} +
+
+ +
+ + {/* Blobbi Name */} + {!hideName && !isEgg && ( +

+ {companion.name} +

+ )} +
+
+ ); +} + +// ─── Stats Crown ────────────────────────────────────────────────────────────── + +function StatsCrown({ + companion, + currentStats, + heroWidth, +}: { + companion: BlobbiRoomContext['companion']; + currentStats: BlobbiRoomContext['currentStats']; + heroWidth: number; +}) { + const allStats = useMemo(() => + getVisibleStats(companion.stage).map(stat => ({ + stat, + value: currentStats[stat] ?? 100, + status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100), + color: STAT_COLOR_MAP[stat], + })), + [companion.stage, currentStats]); + + if (allStats.length === 0) return null; + + const count = allStats.length; + const isSmall = heroWidth < 400; + + // Balanced arc: mobile is compact, desktop has moderate breathing room. + // These values produce a stable crown with no dramatic changes between + // 375px (mobile) and 640px+ (desktop) — a smooth interpolation. + const arcSpread = isSmall + ? (count <= 2 ? 80 : count <= 3 ? 110 : 140) + : (count <= 2 ? 90 : count <= 3 ? 130 : 160); + const arcHalf = arcSpread / 2; + const angles = count === 1 + ? [0] + : allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i); + + return ( +
+ {allStats.map((s, i) => { + const angleDeg = angles[i]; + const angleRad = (angleDeg * Math.PI) / 180; + // Smooth interpolation: 110px at 340px width → 200px at 640px+ width + const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110)); + const x = Math.sin(angleRad) * radius; + const y = Math.cos(angleRad) * radius - radius; + + return ( +
+ +
+ ); + })} +
+ ); +} + +// ─── Stat Indicator ─────────────────────────────────────────────────────────── + +interface StatIndicatorProps { + stat: string; + value: number | undefined; + color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet'; + status?: 'normal' | 'warning' | 'critical'; +} + +function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) { + const displayValue = value ?? 0; + const isLow = status === 'warning' || status === 'critical'; + const ringHex = STAT_RING_HEX[color]; + const IconComponent = STAT_ICON_MAP[stat]; + + return ( +
+ + + + +
+ {IconComponent && } + {isLow && ( + + )} +
+
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx new file mode 100644 index 00000000..47978fd1 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -0,0 +1,201 @@ +// src/blobbi/rooms/components/BlobbiRoomShell.tsx + +/** + * BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard. + * + * Manages: + * - Current room state + navigation + * - Sleep dark overlay (scoped to this shell only) + * - Ephemeral poop instances (local-only, no persistence) + */ + +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { toast } from '@/hooks/useToast'; +import { + type BlobbiRoomId, + ROOM_META, + DEFAULT_ROOM_ORDER, + DEFAULT_INITIAL_ROOM, + getNextRoom, + getPreviousRoom, + getRoomIndex, +} from '../lib/room-config'; +import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types'; +import { + generateInitialPoops, + removePoop, + type PoopInstance, +} from '../lib/poop-system'; + +import { BlobbiHomeRoom } from './BlobbiHomeRoom'; +import { BlobbiKitchenRoom } from './BlobbiKitchenRoom'; +import { BlobbiCareRoom } from './BlobbiCareRoom'; +import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom'; +import { BlobbiRestRoom } from './BlobbiRestRoom'; +import { BlobbiClosetRoom } from './BlobbiClosetRoom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BlobbiRoomShellProps { + ctx: BlobbiRoomContext; + roomOrder?: BlobbiRoomId[]; + initialRoom?: BlobbiRoomId; +} + +interface RoomNavState { + current: BlobbiRoomId; + direction: 'left' | 'right' | null; +} + +// ─── Room Component Map ─────────────────────────────────────────────────────── + +const ROOM_COMPONENTS: Record> = { + care: BlobbiCareRoom, + kitchen: BlobbiKitchenRoom, + home: BlobbiHomeRoom, + hatchery: BlobbiHatcheryRoom, + rest: BlobbiRestRoom, + closet: BlobbiClosetRoom, +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function BlobbiRoomShell({ + ctx, + roomOrder = DEFAULT_ROOM_ORDER, + initialRoom = DEFAULT_INITIAL_ROOM, +}: BlobbiRoomShellProps) { + const [nav, setNav] = useState({ + current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0], + direction: null, + }); + + const goRight = useCallback(() => { + setNav(prev => ({ + current: getNextRoom(prev.current, roomOrder), + direction: 'right', + })); + }, [roomOrder]); + + const goLeft = useCallback(() => { + setNav(prev => ({ + current: getPreviousRoom(prev.current, roomOrder), + direction: 'left', + })); + }, [roomOrder]); + + const meta = ROOM_META[nav.current]; + const roomIndex = getRoomIndex(nav.current, roomOrder); + const RoomComponent = ROOM_COMPONENTS[nav.current]; + + const dots = useMemo(() => roomOrder.map((id, i) => ({ + id, + active: i === roomIndex, + label: ROOM_META[id].label, + })), [roomOrder, roomIndex]); + + const isSleeping = ctx.isSleeping; + + // ─── Poop system (ephemeral, local-only) ─── + const [poops, setPoops] = useState([]); + const [shovelMode, setShovelMode] = useState(false); + + // Generate poop on mount + useEffect(() => { + const hunger = ctx.currentStats.hunger; + const lastFeed = ctx.lastFeedTimestamp ?? ctx.companion.lastInteraction * 1000; + setPoops(generateInitialPoops(hunger, lastFeed)); + // Only run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onRemovePoop = useCallback((poopId: string) => { + setPoops(prev => { + const { remaining, xpReward } = removePoop(prev, poopId); + if (xpReward > 0) { + toast({ title: `+${xpReward} XP`, description: 'Cleaned up!' }); + } + if (remaining.length === 0) { + setShovelMode(false); + } + return remaining; + }); + }, []); + + const poopState: RoomPoopState = useMemo(() => ({ + poops, + shovelMode, + setShovelMode, + onRemovePoop, + }), [poops, shovelMode, onRemovePoop]); + + return ( +
+ {/* ── Room Content — fills the entire shell ── */} +
+ +
+ + {/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */} + {isSleeping && ( +
+ )} + + {/* ── Floating Room Header ── */} +
+
+
+ {meta.icon} + {meta.label} +
+
+ {dots.map(dot => ( +
+ ))} +
+
+
+ + {/* ── Left / Right Navigation Arrows ── */} + + +
+ ); +} diff --git a/src/blobbi/rooms/components/ItemCarousel.tsx b/src/blobbi/rooms/components/ItemCarousel.tsx new file mode 100644 index 00000000..2cd84a2d --- /dev/null +++ b/src/blobbi/rooms/components/ItemCarousel.tsx @@ -0,0 +1,166 @@ +// src/blobbi/rooms/components/ItemCarousel.tsx + +/** + * ItemCarousel — Single-focus carousel for room items. + * + * Layout stability guarantees: + * - The entire carousel width is deterministic (arrows + previews + focus slot) + * - Focused item uses a fixed-size container with overflow-hidden + * - Label is clamped to a fixed max-width and single line + * - Switching items never causes reflow or arrow movement + * + * Mobile: focused item only + compact arrows (no prev/next previews) + * Desktop: focused item + translucent prev/next previews + arrows + */ + +import { useState, useCallback } from 'react'; +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CarouselEntry { + id: string; + /** Emoji string or ReactNode rendered at large size */ + icon: React.ReactNode; + label: string; + /** Optional metadata attached to the entry (e.g. item type) */ + meta?: string; +} + +interface ItemCarouselProps { + items: CarouselEntry[]; + /** Called when the user taps the focused item */ + onUse: (id: string) => void; + /** Item id currently being used (shows spinner) */ + activeItemId?: string | null; + /** Whether any action is in progress */ + disabled?: boolean; + /** Called when the focused item changes (for conditional side actions) */ + onFocusChange?: (entry: CarouselEntry) => void; + className?: string; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ItemCarousel({ + items, + onUse, + activeItemId, + disabled, + onFocusChange, + className, +}: ItemCarouselProps) { + const [index, setIndex] = useState(0); + + const count = items.length; + + const prev = useCallback(() => { + setIndex(i => { + const n = (i - 1 + count) % count; + onFocusChange?.(items[n]); + return n; + }); + }, [count, items, onFocusChange]); + + const next = useCallback(() => { + setIndex(i => { + const n = (i + 1) % count; + onFocusChange?.(items[n]); + return n; + }); + }, [count, items, onFocusChange]); + + if (count === 0) { + return ( + // Empty state matches the height of a populated carousel +
+

Nothing here yet

+
+ ); + } + + const current = items[index]; + const prevItem = items[(index - 1 + count) % count]; + const nextItem = items[(index + 1) % count]; + const isThisActive = activeItemId === current.id; + const showPreviews = count >= 3; + + return ( +
+ {/* Left arrow — fixed 28/32px */} + + + {/* Preview (prev) — desktop only, fixed 40x48px slot */} + {showPreviews && ( +
+
+ {prevItem.icon} +
+
+ )} + + {/* Focused item — FIXED 80x72 / 96x88 container, never resizes */} + + + {/* Preview (next) — desktop only, fixed 40x48px slot */} + {showPreviews && ( +
+
+ {nextItem.icon} +
+
+ )} + + {/* Right arrow — fixed 28/32px */} + +
+ ); +} diff --git a/src/blobbi/rooms/components/RoomActionButton.tsx b/src/blobbi/rooms/components/RoomActionButton.tsx new file mode 100644 index 00000000..a0209bb7 --- /dev/null +++ b/src/blobbi/rooms/components/RoomActionButton.tsx @@ -0,0 +1,79 @@ +// src/blobbi/rooms/components/RoomActionButton.tsx + +/** + * RoomActionButton — Unified circular action button for all rooms. + * + * Responsive sizing: + * - Mobile: size-14 circle, size-7 icons + * - Desktop (sm+): size-20 circle, size-9 icons + * + * Matches the soft radial glow of the original Photo / Companion buttons + * but at a smaller scale so the bottom bar feels proportional on mobile. + */ + +import { Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +interface RoomActionButtonProps { + /** Lucide icon or emoji element rendered inside the circle */ + icon: React.ReactNode; + /** Small text label below the circle */ + label: string; + /** CSS colour class applied to the icon (e.g. 'text-pink-500') */ + color: string; + /** Hex colour used for the radial glow background */ + glowHex: string; + onClick: () => void; + disabled?: boolean; + loading?: boolean; + /** Optional badge content rendered at top-right of the circle */ + badge?: React.ReactNode; + className?: string; +} + +export function RoomActionButton({ + icon, + label, + color, + glowHex, + onClick, + disabled, + loading, + badge, + className, +}: RoomActionButtonProps) { + return ( + + ); +} diff --git a/src/blobbi/rooms/index.ts b/src/blobbi/rooms/index.ts new file mode 100644 index 00000000..6f7de4c4 --- /dev/null +++ b/src/blobbi/rooms/index.ts @@ -0,0 +1,20 @@ +// src/blobbi/rooms/index.ts — barrel export + +export { + type BlobbiRoomId, + type BlobbiRoomMeta, + ROOM_META, + DEFAULT_ROOM_ORDER, + DEFAULT_INITIAL_ROOM, + getNextRoom, + getPreviousRoom, + getRoomIndex, +} from './lib/room-config'; + +export { BlobbiRoomShell } from './components/BlobbiRoomShell'; +export { BlobbiHomeRoom } from './components/BlobbiHomeRoom'; +export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom'; +export { BlobbiCareRoom } from './components/BlobbiCareRoom'; +export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom'; +export { BlobbiRestRoom } from './components/BlobbiRestRoom'; +export { BlobbiClosetRoom } from './components/BlobbiClosetRoom'; diff --git a/src/blobbi/rooms/lib/poop-system.ts b/src/blobbi/rooms/lib/poop-system.ts new file mode 100644 index 00000000..7476d8ba --- /dev/null +++ b/src/blobbi/rooms/lib/poop-system.ts @@ -0,0 +1,137 @@ +// src/blobbi/rooms/lib/poop-system.ts + +/** + * Temporary local-only poop system. + * + * Generates poop based on: + * A) Overfeeding: hunger >= 95 -> poop in kitchen + * B) Time elapsed: every 2 hours since last feed -> poop in a random room + * + * This is entirely ephemeral -- no persistence to Nostr or localStorage. + * The state is generated fresh on page load and managed in React state. + */ + +import type { BlobbiRoomId } from './room-config'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PoopInstance { + id: string; + room: BlobbiRoomId; + /** 'overfeed' poops are kitchen-only and disappear on room change */ + source: 'overfeed' | 'time'; + /** Timestamp when this poop was generated */ + createdAt: number; + /** + * Safe-zone position for this poop. + * Kept as % offsets so the layout stays responsive. + * Positions are in the lower-left and lower-right corners, + * avoiding the central Blobbi hero area. + */ + position: { bottom: number; left: number }; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const OVERFEED_THRESHOLD = 95; +const HOURS_PER_POOP = 2; +const XP_PER_POOP = 5; + +/** Rooms where time-based poop can appear (not closet) */ +const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest']; + +/** + * Pre-defined safe positions in the lower corners of the room. + * Values are percentages. These avoid the central hero area + * (roughly 30%–70% horizontal, above 35% vertical). + */ +const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [ + { bottom: 22, left: 8 }, // lower-left + { bottom: 18, left: 78 }, // lower-right + { bottom: 28, left: 14 }, // mid-left + { bottom: 25, left: 82 }, // mid-right + { bottom: 15, left: 20 }, // bottom-left-ish + { bottom: 20, left: 72 }, // bottom-right-ish +]; + +// ─── Generation ─────────────────────────────────────────────────────────────── + +let _idCounter = 0; +function nextPoopId(): string { + return `poop_${++_idCounter}_${Date.now()}`; +} + +function pickPosition(index: number): { bottom: number; left: number } { + return SAFE_POSITIONS[index % SAFE_POSITIONS.length]; +} + +/** + * Generate initial poop instances based on current companion state. + * Called once when the dashboard mounts. + */ +export function generateInitialPoops( + hunger: number, + lastFeedTimestamp: number | undefined, +): PoopInstance[] { + const poops: PoopInstance[] = []; + const now = Date.now(); + let posIndex = 0; + + // A) Overfeeding poop -- kitchen only + if (hunger >= OVERFEED_THRESHOLD) { + poops.push({ + id: nextPoopId(), + room: 'kitchen', + source: 'overfeed', + createdAt: now, + position: pickPosition(posIndex++), + }); + } + + // B) Time-based poop -- random room + if (lastFeedTimestamp) { + const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60); + const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP); + for (let i = 0; i < Math.min(poopCount, 3); i++) { + const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)]; + poops.push({ + id: nextPoopId(), + room, + source: 'time', + createdAt: now - i * 1000, + position: pickPosition(posIndex++), + }); + } + } + + return poops; +} + +/** + * Get poops visible in a specific room. + */ +export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] { + return poops.filter(p => p.room === room); +} + +/** + * Remove a poop by id and return the XP reward. + */ +export function removePoop( + poops: PoopInstance[], + poopId: string, +): { remaining: PoopInstance[]; xpReward: number } { + const remaining = poops.filter(p => p.id !== poopId); + const wasRemoved = remaining.length < poops.length; + return { + remaining, + xpReward: wasRemoved ? XP_PER_POOP : 0, + }; +} + +/** + * Check if any poop exists anywhere. + */ +export function hasAnyPoop(poops: PoopInstance[]): boolean { + return poops.length > 0; +} diff --git a/src/blobbi/rooms/lib/room-config.ts b/src/blobbi/rooms/lib/room-config.ts new file mode 100644 index 00000000..8b4d958c --- /dev/null +++ b/src/blobbi/rooms/lib/room-config.ts @@ -0,0 +1,144 @@ +// src/blobbi/rooms/lib/room-config.ts + +/** + * Blobbi Room System — Configuration & Navigation + * + * This module defines the room types, default ordering, and navigation helpers. + * The design supports future per-user customisation: the default order is data, + * not hardcoded control flow, so it can be replaced with a user-stored sequence. + */ + +// ─── Room IDs ───────────────────────────────────────────────────────────────── + +/** + * Unique identifier for each room in the Blobbi world. + * New rooms can be added here without breaking existing code. + */ +export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet'; + +// ─── Room Metadata ──────────────────────────────────────────────────────────── + +export interface BlobbiRoomMeta { + /** Unique room identifier */ + id: BlobbiRoomId; + /** Human-readable display label */ + label: string; + /** Short description (for tooltips / accessibility) */ + description: string; + /** Emoji icon representing the room */ + icon: string; +} + +/** + * Static metadata for every room. + * This is a lookup — order does NOT matter here. + */ +export const ROOM_META: Record = { + care: { + id: 'care', + label: 'Care Room', + description: 'Hygiene, care, and medicine', + icon: '🩹', + }, + kitchen: { + id: 'kitchen', + label: 'Kitchen', + description: 'Feed your Blobbi', + icon: '🍳', + }, + home: { + id: 'home', + label: 'Home', + description: 'Main living room', + icon: '🏠', + }, + hatchery: { + id: 'hatchery', + label: 'Hatchery', + description: 'Evolution and quests', + icon: '🥚', + }, + rest: { + id: 'rest', + label: 'Bedroom', + description: 'Rest and recharge', + icon: '🌙', + }, + closet: { + id: 'closet', + label: 'Closet', + description: 'Wardrobe and accessories', + icon: '👗', + }, +}; + +// ─── Default Room Order ─────────────────────────────────────────────────────── + +/** + * The default room sequence. + * + * IMPORTANT: This array is the ONLY place that defines order. + * To support per-user customisation later, replace this with + * a user-stored array of BlobbiRoomId values. + * + * Closet is excluded for now (not yet implemented). + * To re-enable, add 'closet' back to the array. + */ +export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [ + 'care', + 'kitchen', + 'home', + 'hatchery', + 'rest', + // 'closet', — re-enable when wardrobe feature is ready +]; + +/** + * The room that should be selected when the dashboard first loads. + */ +export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home'; + +// ─── Navigation Helpers ─────────────────────────────────────────────────────── + +/** + * Get the next room in a looping sequence. + * + * @param current - The currently active room + * @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER) + * @returns The next room id (wraps around) + */ +export function getNextRoom( + current: BlobbiRoomId, + order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER, +): BlobbiRoomId { + const idx = order.indexOf(current); + if (idx === -1) return order[0]; + return order[(idx + 1) % order.length]; +} + +/** + * Get the previous room in a looping sequence. + * + * @param current - The currently active room + * @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER) + * @returns The previous room id (wraps around) + */ +export function getPreviousRoom( + current: BlobbiRoomId, + order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER, +): BlobbiRoomId { + const idx = order.indexOf(current); + if (idx === -1) return order[order.length - 1]; + return order[(idx - 1 + order.length) % order.length]; +} + +/** + * Get the index of a room in the order array. + * Returns -1 if the room is not in the order. + */ +export function getRoomIndex( + room: BlobbiRoomId, + order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER, +): number { + return order.indexOf(room); +} diff --git a/src/blobbi/rooms/lib/room-layout.ts b/src/blobbi/rooms/lib/room-layout.ts new file mode 100644 index 00000000..75596940 --- /dev/null +++ b/src/blobbi/rooms/lib/room-layout.ts @@ -0,0 +1,15 @@ +// src/blobbi/rooms/lib/room-layout.ts + +/** + * Shared layout constants for Blobbi room components. + */ + +/** + * CSS class for the bottom action bar in every room. + * + * On mobile/tablet (max-sidebar), adds extra bottom padding so the + * room controls clear the app's fixed bottom navigation bar. + * On desktop (sidebar:), uses normal padding since there's no bottom nav. + */ +export const ROOM_BOTTOM_BAR_CLASS = + 'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]'; diff --git a/src/blobbi/rooms/lib/room-types.ts b/src/blobbi/rooms/lib/room-types.ts new file mode 100644 index 00000000..964a3671 --- /dev/null +++ b/src/blobbi/rooms/lib/room-types.ts @@ -0,0 +1,194 @@ +// src/blobbi/rooms/lib/room-types.ts + +/** + * Shared prop types for Blobbi room components. + * + * These types are the "contract" that the BlobbiDashboard passes down + * to each room. They mirror the existing BlobbiDashboard internal state + * so rooms can reuse all existing logic without duplication. + */ + +import type { NostrEvent } from '@nostrify/nostrify'; + +import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi'; +import type { + InventoryAction, + DirectAction, + InlineActivityState, + BlobbiReactionState, + SelectedTrack, + StartIncubationMode, +} from '@/blobbi/actions'; +import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions'; +import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types'; +import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe'; +import type { ShopItem } from '@/blobbi/shop/types/shop.types'; + +// ─── Shared Dashboard Context ───────────────────────────────────────────────── + +/** + * Everything a room needs from the dashboard. + * Passed down by BlobbiRoomShell so rooms don't import dashboard state directly. + */ +export interface BlobbiRoomContext { + // ── Core data ── + companion: BlobbiCompanion; + companions: BlobbiCompanion[]; + selectedD: string; + profile: BlobbonautProfile | null; + + // ── Projected / visual state ── + currentStats: { + hunger: number; + happiness: number; + health: number; + hygiene: number; + energy: number; + }; + isSleeping: boolean; + isEgg: boolean; + isBaby: boolean; + + // ── Visual recipe ── + statusRecipe: BlobbiVisualRecipe | undefined; + statusRecipeLabel: string | undefined; + effectiveEmotion: BlobbiEmotion; + hasDevOverride: boolean; + blobbiReaction: BlobbiReactionState; + + // ── Item use ── + onUseItem: (itemId: string, action: InventoryAction) => Promise; + handleUseItemFromTab: (itemId: string) => void; + isUsingItem: boolean; + usingItemId: string | null; + allShopItems: ShopItem[]; + + // ── Direct actions ── + onDirectAction: (action: DirectAction) => Promise; + handleDirectAction: (action: DirectAction) => void; + isDirectActionPending: boolean; + + // ── Inline activity (music/sing) ── + inlineActivity: InlineActivityState; + setInlineActivity: React.Dispatch>; + setBlobbiReaction: React.Dispatch>; + setActionOverrideEmotion: React.Dispatch>; + showTrackPickerModal: boolean; + setShowTrackPickerModal: React.Dispatch>; + handleTrackSelected: (selection: SelectedTrack) => Promise; + handleConfirmSing: () => Promise; + handleCloseInlineActivity: () => void; + handleMusicPlaybackStart: () => void; + handleMusicPlaybackStop: () => void; + handleSingRecordingStart: () => void; + handleSingRecordingStop: () => void; + handleChangeTrack: () => void; + + // ── Rest / sleep ── + onRest: () => void; + actionInProgress: string | null; + isPublishing: boolean; + + // ── Companion toggle ── + isCurrentCompanion: boolean; + canBeCompanion: boolean; + isUpdatingCompanion: boolean; + isActiveFloatingCompanion: boolean; + handleSetAsCompanion: () => Promise; + + // ── Photo ── + showPhotoModal: boolean; + setShowPhotoModal: React.Dispatch>; + + // ── Blobbi selector ── + onSelectBlobbi: (d: string) => void; + + // ── Incubation / Evolution / Tasks ── + isIncubating: boolean; + isEvolvingState: boolean; + canStartIncubation: boolean; + canStartEvolution: boolean; + isStartingIncubation: boolean; + isStartingEvolution: boolean; + isStoppingIncubation: boolean; + isStoppingEvolution: boolean; + isHatching: boolean; + isEvolving: boolean; + hatchTasks: ReturnType; + evolveTasks: ReturnType; + onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise; + onStartEvolution: () => Promise; + onStopIncubation: () => Promise; + onStopEvolution: () => Promise; + onHatch: () => Promise; + onEvolve: () => Promise; + showPostModal: boolean; + setShowPostModal: React.Dispatch>; + refetchCurrentTasks: () => void; + + // ── Daily missions ── + dailyMissions: ReturnType; + onClaimReward: (id: string) => void; + isClaimingReward: boolean; + availableStages: ('egg' | 'baby' | 'adult')[]; + + // ── Adoption ── + showAdoptionFlow: boolean; + setShowAdoptionFlow: React.Dispatch>; + + // ── Adoption + Profile update props ── + publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise; + updateProfileEvent: (event: NostrEvent) => void; + updateCompanionEvent: (event: NostrEvent) => void; + invalidateProfile: () => void; + invalidateCompanion: () => void; + setStoredSelectedD: (d: string) => void; + ensureCanonicalBeforeAction: () => Promise<{ + companion: BlobbiCompanion; + content: string; + allTags: string[][]; + wasMigrated: boolean; + profileAllTags: string[][]; + profileStorage: StorageItem[]; + } | null>; + + // ── Naddr link ── + blobbiNaddr: string; + + // ── Hero measurement ── + /** Callback ref for the hero container — re-attaches ResizeObserver on room switch */ + heroRef: React.RefCallback | React.RefObject; + heroWidth: number; + + // ── DEV ONLY ── + showDevEditor: boolean; + setShowDevEditor: (show: boolean) => void; + onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise; + isDevUpdating: boolean; + showEmotionPanel: boolean; + setShowEmotionPanel: React.Dispatch>; + showHatchCeremony: boolean; + setShowHatchCeremony: React.Dispatch>; + + // ── Inventory modal (still used in kitchen) ── + inventoryAction: InventoryAction | null; + setInventoryAction: React.Dispatch>; + + // ── Last feed timestamp (for poop system) ── + lastFeedTimestamp: number | undefined; +} + +// ─── Poop State (passed from shell to rooms) ────────────────────────────────── + +import type { PoopInstance } from './poop-system'; + +export interface RoomPoopState { + /** All poop instances across rooms */ + poops: PoopInstance[]; + /** Whether shovel mode is currently active */ + shovelMode: boolean; + /** Toggle shovel mode on/off */ + setShovelMode: React.Dispatch>; + /** Remove a poop (returns XP reward via callback) */ + onRemovePoop: (poopId: string) => void; +} diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index fdce58b5..8e6c6694 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1,13 +1,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Link, useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap, Clock } from 'lucide-react'; +import { Egg, RefreshCw, Footprints, Plus } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState'; -import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay'; import { useAppContext } from '@/hooks/useAppContext'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useBlobbonautProfileNormalization } from '@/hooks/useBlobbonautProfileNormalization'; @@ -22,16 +20,11 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { SubHeaderBar } from '@/components/SubHeaderBar'; -import { TabButton } from '@/components/TabButton'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual'; import { BlobbiHatchingCeremony } from '@/blobbi/onboarding/components/BlobbiHatchingCeremony'; import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal'; import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData'; import { useLayoutOptions } from '@/contexts/LayoutContext'; -import { useIsMobile } from '@/hooks/useIsMobile'; -import { openUrl } from '@/lib/downloadFile'; import { cn } from '@/lib/utils'; import { @@ -46,14 +39,10 @@ import { import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay'; -import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; -import type { ShopItem } from '@/blobbi/shop/types/shop.types'; + import { - BlobbiActionInventoryModal, PlayMusicModal, - InlineMusicPlayer, - InlineSingCard, BlobbiPostModal, useBlobbiUseInventoryItem, useBlobbiHatch, @@ -84,15 +73,16 @@ import { type BlobbiReactionState, type StartIncubationMode, } from '@/blobbi/actions'; -// DailyMissionsPanel no longer used — daily missions rendered inline in MissionsTabContent +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; import { BlobbiOnboardingFlow } from '@/blobbi/onboarding'; import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction'; -import { useItemCooldown } from '@/blobbi/actions/hooks/useItemCooldown'; import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev'; import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; +import { BlobbiRoomShell } from '@/blobbi/rooms/components/BlobbiRoomShell'; +import type { BlobbiRoomContext } from '@/blobbi/rooms/lib/room-types'; @@ -107,31 +97,7 @@ function getSelectedBlobbiKey(pubkey: string): string { /** Enable debug logging in development only */ const DEBUG_BLOBBI = import.meta.env.DEV; -/** Stat threshold below which a Blobbi is considered to need care */ -const CARE_THRESHOLD = 40; -/** - * Check if a companion needs care based on stat thresholds. - * A Blobbi needs care if any stat is below CARE_THRESHOLD. - */ -function companionNeedsCare(companion: BlobbiCompanion): boolean { - const { stats } = companion; - return ( - (stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) || - (stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) || - (stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) || - (stats.health !== undefined && stats.health < CARE_THRESHOLD) - ); -} - -/** Map stat keys to indicator colors */ -const STAT_COLOR_MAP: Record = { - hunger: 'orange', - happiness: 'yellow', - health: 'green', - hygiene: 'blue', - energy: 'violet', -}; // ─── Page Component ─────────────────────────────────────────────────────────── @@ -814,11 +780,6 @@ function DashboardShell({ children }: DashboardShellProps) { ); } -// ─── Dashboard Drawer Type ──────────────────────────────────────────────────── - -/** Which drawer is open; 'none' = all closed */ -type DashboardDrawer = 'none' | 'care' | 'items' | 'missions' | 'more'; - // ─── Main Blobbi Dashboard ──────────────────────────────────────────────────── interface BlobbiDashboardProps { @@ -895,15 +856,6 @@ function BlobbiDashboard({ const isSleeping = companion.state === 'sleeping'; const isEgg = companion.stage === 'egg'; - // ─── Active Drawer ─── - const isMobile = useIsMobile(); - const [activeDrawer, setActiveDrawer] = useState(isMobile ? 'none' : 'care'); - - // Toggle drawer: tapping same tab closes it, tapping another opens that one - const toggleDrawer = useCallback((drawer: DashboardDrawer) => { - setActiveDrawer(prev => prev === drawer ? 'none' : drawer); - }, []); - // Build naddr for linking to the Blobbi's detail page const blobbiNaddr = useMemo(() => nip19.naddrEncode({ kind: KIND_BLOBBI_STATE, @@ -929,15 +881,28 @@ function BlobbiDashboard({ const projectedState = useProjectedBlobbiState(companion); // Measure hero container width for responsive stat arc radius + // heroWidth measurement: uses a callback ref so the ResizeObserver + // automatically re-attaches when the hero element unmounts/remounts + // (which happens on every room switch since each room renders its own hero). const heroRef = useRef(null); const [heroWidth, setHeroWidth] = useState(375); - useEffect(() => { - const el = heroRef.current; - if (!el) return; - const ro = new ResizeObserver(([entry]) => setHeroWidth(entry.contentRect.width)); - ro.observe(el); - setHeroWidth(el.clientWidth); - return () => ro.disconnect(); + const roRef = useRef(null); + + const heroCallbackRef = useCallback((node: HTMLDivElement | null) => { + // Disconnect previous observer + if (roRef.current) { + roRef.current.disconnect(); + roRef.current = null; + } + // Update the ref object so ctx.heroRef still works + (heroRef as React.MutableRefObject).current = node; + + if (node) { + setHeroWidth(node.clientWidth); + const ro = new ResizeObserver(([entry]) => setHeroWidth(entry.contentRect.width)); + ro.observe(node); + roRef.current = ro; + } }, []); // Modal states (only for things that genuinely need modals) @@ -1338,37 +1303,14 @@ function BlobbiDashboard({ setShowTrackPickerModal(true); }; - // Handle using an item (always uses once) - const handleUseItem = async (itemId: string) => { - if (!inventoryAction || isUsingItem) return; - setUsingItemId(itemId); - // Set action emotion override while item is being used - setActionOverrideEmotion(getActionEmotion(inventoryAction as ActionType)); - try { - await onUseItem(itemId, inventoryAction); - // Close the modal on success - setInventoryAction(null); - } finally { - setUsingItemId(null); - // Clear action emotion after a brief delay for visual feedback - setTimeout(() => setActionOverrideEmotion(null), 1500); - } - }; - - // Handle opening shop from empty state (switches to items drawer) - const handleOpenShopFromAction = () => { - setInventoryAction(null); - setActiveDrawer('items'); - }; - // ─── Daily Missions (for missions tab) ─── const dailyMissions = useDailyMissions({ availableStages }); const { mutate: claimReward, isPending: isClaimingReward } = useClaimMissionReward( profile, updateProfileEvent, ); - // Handle using an item from the items tab - const handleUseItemFromTab = (itemId: string) => { + // Handle using an item from the items tab / room carousel + const handleUseItemFromTab = useCallback((itemId: string) => { const action = getActionForItem(itemId); if (!action || isUsingItem) return; setUsingItemId(itemId); @@ -1377,7 +1319,165 @@ function BlobbiDashboard({ setUsingItemId(null); setTimeout(() => setActionOverrideEmotion(null), 1500); }); - }; + }, [isUsingItem, onUseItem]); + + // ─── Build Room Context ─── + // This is the single object that flows into BlobbiRoomShell → individual rooms. + // It mirrors everything the old tab components consumed but centralised. + const roomCtx = useMemo(() => ({ + // Core data + companion, + companions, + selectedD, + profile, + + // Projected / visual state + currentStats, + isSleeping, + isEgg, + isBaby, + + // Visual recipe + statusRecipe, + statusRecipeLabel, + effectiveEmotion, + hasDevOverride, + blobbiReaction, + + // Item use + onUseItem, + handleUseItemFromTab, + isUsingItem, + usingItemId, + allShopItems: getLiveShopItems(), + + // Direct actions + onDirectAction, + handleDirectAction, + isDirectActionPending, + + // Inline activity + inlineActivity, + setInlineActivity, + setBlobbiReaction, + setActionOverrideEmotion, + showTrackPickerModal, + setShowTrackPickerModal, + handleTrackSelected, + handleConfirmSing, + handleCloseInlineActivity, + handleMusicPlaybackStart, + handleMusicPlaybackStop, + handleSingRecordingStart, + handleSingRecordingStop, + handleChangeTrack, + + // Rest / sleep + onRest, + actionInProgress, + isPublishing, + + // Companion toggle + isCurrentCompanion, + canBeCompanion, + isUpdatingCompanion, + isActiveFloatingCompanion, + handleSetAsCompanion, + + // Photo + showPhotoModal, + setShowPhotoModal, + + // Blobbi selector + onSelectBlobbi, + + // Incubation / Evolution / Tasks + isIncubating, + isEvolvingState, + canStartIncubation, + canStartEvolution, + isStartingIncubation, + isStartingEvolution, + isStoppingIncubation, + isStoppingEvolution, + isHatching, + isEvolving, + hatchTasks, + evolveTasks, + onStartIncubation: handleStartIncubation, + onStartEvolution: handleStartEvolution, + onStopIncubation: handleStopIncubation, + onStopEvolution: handleStopEvolution, + onHatch: async () => setShowHatchCeremony(true), + onEvolve, + showPostModal, + setShowPostModal, + refetchCurrentTasks, + + // Daily missions + dailyMissions, + onClaimReward: (id: string) => claimReward({ missionId: id }), + isClaimingReward, + availableStages, + + // Adoption + showAdoptionFlow, + setShowAdoptionFlow, + + // Adoption + Profile update props + publishEvent, + updateProfileEvent, + updateCompanionEvent, + invalidateProfile, + invalidateCompanion, + setStoredSelectedD, + ensureCanonicalBeforeAction, + + // Naddr link + blobbiNaddr, + + // Hero measurement + heroRef: heroCallbackRef, + heroWidth, + + // DEV + showDevEditor, + setShowDevEditor, + onDevEditorApply, + isDevUpdating, + showEmotionPanel, + setShowEmotionPanel, + showHatchCeremony, + setShowHatchCeremony, + + // Inventory modal + inventoryAction, + setInventoryAction, + + // Last feed timestamp (for poop system — use lastInteraction as proxy) + lastFeedTimestamp: companion.lastInteraction ? companion.lastInteraction * 1000 : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [ + // Only the primitive/memoized deps that actually change. + // Stable callbacks (useCallback) and setState refs are intentionally omitted. + companion, companions, selectedD, profile, + currentStats, isSleeping, isEgg, isBaby, + statusRecipe, statusRecipeLabel, effectiveEmotion, hasDevOverride, blobbiReaction, + isUsingItem, usingItemId, isDirectActionPending, + inlineActivity, showTrackPickerModal, + actionInProgress, isPublishing, + isCurrentCompanion, canBeCompanion, isUpdatingCompanion, isActiveFloatingCompanion, + showPhotoModal, + isIncubating, isEvolvingState, canStartIncubation, canStartEvolution, + isStartingIncubation, isStartingEvolution, isStoppingIncubation, isStoppingEvolution, + isHatching, isEvolving, hatchTasks, evolveTasks, + showPostModal, refetchCurrentTasks, + dailyMissions, isClaimingReward, availableStages, + showAdoptionFlow, + blobbiNaddr, heroWidth, + showDevEditor, isDevUpdating, showEmotionPanel, showHatchCeremony, + inventoryAction, + ]); return ( @@ -1389,340 +1489,11 @@ function BlobbiDashboard({

)} - - {/* Backdrop — tapping outside the drawer collapses it */} - {activeDrawer !== 'none' && ( -
setActiveDrawer('none')} - /> - )} - {/* ─── Drawer + Tab Bar — overlays the hero ─── */} -
- {/* Sliding drawer — in flow, pushes tabs down when open */} -
- -
- {activeDrawer === 'care' && ( - setActiveDrawer('items')} - onDirectAction={handleDirectAction} - onRest={onRest} - actionInProgress={actionInProgress} - isPublishing={isPublishing} - /> - )} - {activeDrawer === 'items' && ( - - )} - {activeDrawer === 'missions' && ( - setShowHatchCeremony(true)} - isHatching={isHatching || showHatchCeremony} - onEvolve={onEvolve} - isEvolving={isEvolving} - onStopIncubation={handleStopIncubation} - isStoppingIncubation={isStoppingIncubation} - onStopEvolution={handleStopEvolution} - isStoppingEvolution={isStoppingEvolution} - onOpenPostModal={() => setShowPostModal(true)} - dailyMissions={dailyMissions} - onClaimReward={(id) => claimReward({ missionId: id })} - isClaimingReward={isClaimingReward} - canStartIncubation={canStartIncubation} - canStartEvolution={canStartEvolution} - isStartingIncubation={isStartingIncubation} - isStartingEvolution={isStartingEvolution} - onStartIncubation={() => handleStartIncubation('start')} - onStartEvolution={handleStartEvolution} - /> - )} - {activeDrawer === 'more' && ( - setShowAdoptionFlow(true)} - onDevOpenEditor={() => setShowDevEditor(true)} - onDevOpenEmotionPanel={() => setShowEmotionPanel(true)} - onDevInstantTransition={isEgg ? () => setShowHatchCeremony(true) : isBaby ? onEvolve : undefined} - isHatching={isHatching} - isEvolving={isEvolving} - /> - )} -
-
-
+ {/* ─── Room-based Layout ─── */} + - {/* The arc tab bar — below the drawer */} - - toggleDrawer('care')}> - - - Care - - - toggleDrawer('items')}> - - - Items - - - toggleDrawer('missions')}> - - - Quests - - - toggleDrawer('more')}> - - - Blobbis - - - -
- - {/* ─── Hero Section (always visible below drawer) ─── */} -
- - - {/* Main Blobbi Visual with stats crown + action buttons */} - {isActiveFloatingCompanion ? ( -
- -

- {companion.name} is out exploring right now. -

- -
- ) : ( -
- {/* Stats crown — arced above the Blobbi */} - {(() => { - const allStats = getVisibleStats(companion.stage).map(stat => ({ - stat, - value: currentStats[stat] ?? 100, - status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100), - color: STAT_COLOR_MAP[stat], - })); - if (allStats.length === 0) return null; - - const count = allStats.length; - const isSmall = heroWidth < 400; - const arcSpread = isSmall - ? (count <= 2 ? 90 : count <= 3 ? 130 : 160) - : (count <= 2 ? 80 : count <= 3 ? 120 : 160); - const arcHalf = arcSpread / 2; - const angles = count === 1 - ? [0] - : allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i); - - return ( -
- {allStats.map((s, i) => { - const angleDeg = angles[i]; - const angleRad = (angleDeg * Math.PI) / 180; - // Scale radius based on container width: ~140 at 340px, ~210 at 640px+ - const radius = Math.min(210, Math.max(140, (heroWidth - 340) / (640 - 340) * (210 - 140) + 140)); - const x = Math.sin(angleRad) * radius; - // Inverted: center (cos=1) is highest, edges droop down - const y = Math.cos(angleRad) * radius - radius; - - return ( -
- -
- ); - })} -
- ); - })()} - - {/* Blobbi visual */} -
-
- -
- - {/* Blobbi Name — hidden for eggs */} - {!isEgg && ( -

- {companion.name} -

- )} -
- )} - - {/* ── Action circles — below the Blobbi ── */} - {!isActiveFloatingCompanion && ( -
- {/* Photo — lower left */} - - - {/* Companion — lower right */} - {canBeCompanion && ( - - )} -
- )} -
- - {/* ─── Inline Activity Area (music/sing — floats above tabs) ─── */} - {inlineActivity.type === 'music' && ( -
- -
- )} - {inlineActivity.type === 'sing' && ( -
- -
- )} - - {/* Tab content is now rendered in the drawer above */} - - {/* ─── Dialogs (only for things that genuinely need modals) ─── */} - - {/* Inventory Action Confirmation Modal (Feed/Play/Clean) */} - {inventoryAction && ( - !open && setInventoryAction(null)} - action={inventoryAction} - companion={companion} - profile={profile} - onUseItem={handleUseItem} - onOpenShop={handleOpenShopFromAction} - isUsingItem={isUsingItem} - usingItemId={usingItemId} - /> - )} + {/* ─── Global Dialogs (shared across all rooms) ─── */} {/* Track Picker Modal */} - - {/* Hatch Ceremony — portaled to document.body to escape center column stacking context */} + {/* Hatch Ceremony — portaled to document.body */} {showHatchCeremony && createPortal(
void; - onDirectAction: (action: DirectAction) => void; - onRest: () => void; - actionInProgress: string | null; - isPublishing: boolean; -} - -function CareTabContent({ - isEgg, - isSleeping, - onOpenItems, - onDirectAction, - onRest, - actionInProgress, - isPublishing, -}: CareTabContentProps) { - const isDisabled = isPublishing || actionInProgress !== null; - - return ( -
-
- } - statIcon={} - label="Items" - description="Feed, clean & heal" - color="text-sky-500" - onClick={onOpenItems} - disabled={isDisabled} - /> - - } - statIcon={} - label="Music" - description="Play a tune" - color="text-pink-500" - onClick={() => onDirectAction('play_music')} - disabled={isDisabled} - /> - - } - statIcon={} - label="Sing" - description="Sing together" - color="text-purple-500" - onClick={() => onDirectAction('sing')} - disabled={isDisabled} - /> - - {!isEgg && ( - - ) : isSleeping ? ( - - ) : ( - - ) - } - statIcon={} - label={isSleeping ? 'Wake' : 'Sleep'} - description={isSleeping ? 'Rise and shine' : 'Rest & recharge'} - color={isSleeping ? 'text-amber-500' : 'text-violet-500'} - onClick={onRest} - disabled={isDisabled} - /> - )} -
-
- ); -} - -// ─── Care Action Button ─────────────────────────────────────────────────────── - -function CareActionButton({ - icon, - statIcon, - label, - description, - color, - onClick, - disabled, -}: { - icon: React.ReactNode; - statIcon: React.ReactNode; - label: string; - description: string; - color: string; - onClick: () => void; - disabled?: boolean; -}) { - return ( - - ); -} - -// ─── Items Tab Content ──────────────────────────────────────────────────────── - -/** Lucide icon + color for each item category */ -function ItemTypeIndicator({ type }: { type: string }) { - switch (type) { - case 'food': - return ; - case 'toy': - return ; - case 'medicine': - return ; - case 'hygiene': - return ; - default: - return null; - } -} - -interface ItemsTabContentProps { - allShopItems: ShopItem[]; - onUseItem: (itemId: string) => void; - isUsingItem: boolean; - usingItemId: string | null; -} - -function ItemsTabContent({ - allShopItems, - onUseItem, - isUsingItem, - usingItemId, -}: ItemsTabContentProps) { - const { isOnCooldown } = useItemCooldown(); - - return ( -
- {allShopItems.filter(i => i.status !== 'disabled').map((item) => { - const isThisUsing = isUsingItem && usingItemId === item.id; - const isCoolingDown = isOnCooldown(item.id); - const isDisabled = isUsingItem || isCoolingDown; - return ( - - ); - })} -
- ); -} - -// ─── Missions Tab Content ───────────────────────────────────────────────────── - -interface MissionsTabContentProps { - isIncubating: boolean; - isEvolvingState: boolean; - isEgg: boolean; - isBaby: boolean; - hatchTasks: ReturnType; - evolveTasks: ReturnType; - onHatch: () => Promise; - isHatching: boolean; - onEvolve: () => Promise; - isEvolving: boolean; - onStopIncubation: () => Promise; - isStoppingIncubation: boolean; - onStopEvolution: () => Promise; - isStoppingEvolution: boolean; - onOpenPostModal: () => void; - dailyMissions: ReturnType; - onClaimReward: (id: string) => void; - isClaimingReward: boolean; - canStartIncubation: boolean; - canStartEvolution: boolean; - isStartingIncubation: boolean; - isStartingEvolution: boolean; - onStartIncubation: () => void; - onStartEvolution: () => void; -} - -type QuestPane = 'journey' | 'bounties'; - -function MissionsTabContent({ - isIncubating, - isEvolvingState, - isEgg, - isBaby, - hatchTasks, - evolveTasks, - onHatch, - isHatching, - onEvolve, - isEvolving, - onStopIncubation, - isStoppingIncubation, - onStopEvolution, - isStoppingEvolution, - onOpenPostModal, - dailyMissions, - onClaimReward, - isClaimingReward, - canStartIncubation, - canStartEvolution, - isStartingIncubation, - isStartingEvolution, - onStartIncubation, - onStartEvolution, -}: MissionsTabContentProps) { - const [pane, setPane] = useState('journey'); - const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby); - const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution; - const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks; - const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted; - const isLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading; - const navigate = useNavigate(); - - const completedCount = tasks.filter(t => t.completed).length; - const totalCount = tasks.length; - - const { missions } = dailyMissions; - const dailyCompleted = missions.filter(m => m.claimed).length; - const dailyTotal = missions.length; - - return ( -
- {/* ── Pill toggle ── */} -
-
- - -
-
- - {/* ── Content area ── */} -
- {pane === 'journey' && ( - <> - {/* Loading */} - {isLoading && ( -
- -
- )} - - {/* Active task rows */} - {hasActiveProcess && !isLoading && tasks.map(task => { - const handleAction = () => { - if (!task.action || !task.actionTarget) return; - switch (task.action) { - case 'navigate': navigate(task.actionTarget); break; - case 'external_link': openUrl(task.actionTarget); break; - case 'open_modal': if (task.actionTarget === 'blobbi_post') onOpenPostModal(); break; - } - }; - const isActionable = !task.completed && !!task.action && !!task.actionTarget; - return ( - - ); - })} - - {/* Hatch / Evolve CTA */} - {hasActiveProcess && allCompleted && !isLoading && ( - - )} - - {/* Stop process */} - {hasActiveProcess && !isLoading && ( - - )} - - {/* No active process */} - {!hasActiveProcess && !isLoading && ( -
- {(canStartIncubation || canStartEvolution) ? ( - - ) : ( -

No journey available right now

- )} -
- )} - - )} - - {pane === 'bounties' && ( - <> - {dailyMissions.noMissionsAvailable && ( -
- -

Hatch your Blobbi to unlock daily bounties

-
- )} - - {!dailyMissions.noMissionsAvailable && missions.map(mission => { - const canClaim = mission.completed && !mission.claimed; - return ( -
- -
-

{mission.title}

-

{mission.description}

-
- {!mission.claimed && ( - {mission.currentCount}/{mission.requiredCount} - )} - {canClaim && ( - - )} -
- ); - })} - - {/* Bonus row */} - {!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && ( -
-
- -
-
-

Daily Champion

-

All missions complete!

-
- -
- )} - - {!dailyMissions.noMissionsAvailable && dailyCompleted === dailyTotal && dailyTotal > 0 && dailyMissions.bonusClaimed && ( -
- -

All done for today — come back tomorrow!

-
- )} - - )} -
-
- ); -} - -// ─── Quest task icon ────────────────────────────────────────────────────────── - -function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) { - const iconClass = 'size-4'; - const icon = (() => { - switch (taskId) { - case 'create_themes': return ; - case 'color_moments': return ; - case 'create_posts': return ; - case 'interactions': return ; - case 'edit_profile': return ; - case 'maintain_stats': return ; - default: return ; - } - })(); - return ( -
- {completed ? : icon} -
- ); -} - -// ─── Daily mission icon ─────────────────────────────────────────────────────── - -function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) { - const iconClass = 'size-4'; - const icon = (() => { - switch (action) { - case 'interact': return ; - case 'feed': return ; - case 'clean': return ; - case 'sleep': return ; - case 'take_photo': return ; - case 'sing': return ; - case 'play_music': return ; - case 'medicine': return ; - default: return ; - } - })(); - return ( -
- {claimed ? : icon} -
- ); -} - -// ─── More Tab Content ───────────────────────────────────────────────────────── - -interface MoreTabContentProps { - companion: BlobbiCompanion; - companions: BlobbiCompanion[]; - selectedD: string; - profile: BlobbonautProfile | null; - blobbiNaddr: string; - onSelectBlobbi: (d: string) => void; - onAdopt: () => void; - onDevOpenEditor: () => void; - onDevOpenEmotionPanel: () => void; - onDevInstantTransition?: () => void; - isHatching: boolean; - isEvolving: boolean; -} - -function MoreTabContent({ - companion, - companions, - selectedD, - profile, - blobbiNaddr, - onSelectBlobbi, - onAdopt, - onDevOpenEditor, - onDevOpenEmotionPanel, - onDevInstantTransition, - isHatching, - isEvolving, -}: MoreTabContentProps) { - const isTransitioning = isHatching || isEvolving; - - return ( -
- {/* ── Blobbi grid ── */} -
- {companions.map((c) => { - const isSelected = c.d === selectedD; - const isCompanion = c.d === profile?.currentCompanion; - return ( - - ); - })} - - {/* Adopt + button */} - -
- - {/* ── Quick actions row ── */} -
- - - View - - {/* DEV tools */} - {isLocalhostDev() && ( - <> - {companion.stage !== 'adult' && onDevInstantTransition && ( - - )} - - - - )} -
-
- ); -} - -// ─── Stat Indicator ─────────────────────────────────────────────────────────── - -interface StatIndicatorProps { - stat: string; - value: number | undefined; - color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet'; - status?: 'normal' | 'warning' | 'critical'; -} - -const STAT_COLORS: Record = { - orange: 'text-orange-500', - yellow: 'text-yellow-500', - green: 'text-green-500', - blue: 'text-blue-500', - violet: 'text-violet-500', -}; - -const STAT_BG_COLORS: Record = { - orange: 'bg-orange-500/10', - yellow: 'bg-yellow-500/10', - green: 'bg-green-500/10', - blue: 'bg-blue-500/10', - violet: 'bg-violet-500/10', -}; - -const STAT_RING_HEX: Record = { - orange: '#f97316', - yellow: '#eab308', - green: '#22c55e', - blue: '#3b82f6', - violet: '#8b5cf6', -}; - -/** Lucide icon component for each stat */ -const STAT_ICON_MAP: Record> = { - hunger: Utensils, - happiness: Gamepad2, - health: Heart, - hygiene: Droplets, - energy: Zap, -}; - -function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) { - const displayValue = value ?? 0; - const isLow = status === 'warning' || status === 'critical'; - const ringHex = STAT_RING_HEX[color]; - const IconComponent = STAT_ICON_MAP[stat]; - - return ( -
- {/* Progress ring */} - - - - - {/* Icon with warning badge on its corner */} -
- {IconComponent && } - {isLow && ( - - )} -
-
- ); -} // ─── Blobbi Selector Page ─────────────────────────────────────────────────────