From 918814371c1bdffb3bee954fb2ab7e296acd7424 Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 6 Apr 2026 00:46:04 -0300 Subject: [PATCH 1/7] Refactor Blobbi dashboard into room-based navigation system Replace the tab/drawer layout with a room-based system where each room represents a specific area of Blobbi interaction: - Care (bathroom): hygiene tools, medicine, towel, shower - Kitchen: food carousel, fridge modal - Home: toys, music, sing, photo, companion, lamp/sleep - Hatchery: hatch/evolve progress, quests sheet, Blobbis sheet - Closet: placeholder for future wardrobe Architecture: - Room types, config, and navigation helpers in src/blobbi/rooms/lib/ - BlobbiRoomShell manages current room state and left/right navigation - BlobbiRoomHero shared component for the Blobbi visual + stats crown - Each room is a self-contained component receiving BlobbiRoomContext - Default room order is data-driven (DEFAULT_ROOM_ORDER array) to support future per-user customization - Navigation direction tracked for future animated transitions - All existing hooks, mutations, and flows preserved unchanged --- .../rooms/components/BlobbiCareRoom.tsx | 167 +++ .../rooms/components/BlobbiClosetRoom.tsx | 37 + .../rooms/components/BlobbiHatcheryRoom.tsx | 554 +++++++ .../rooms/components/BlobbiHomeRoom.tsx | 259 ++++ .../rooms/components/BlobbiKitchenRoom.tsx | 131 ++ .../rooms/components/BlobbiRoomHero.tsx | 278 ++++ .../rooms/components/BlobbiRoomShell.tsx | 179 +++ src/blobbi/rooms/index.ts | 19 + src/blobbi/rooms/lib/room-config.ts | 134 ++ src/blobbi/rooms/lib/room-types.ts | 175 +++ src/pages/BlobbiPage.tsx | 1317 +++-------------- 11 files changed, 2100 insertions(+), 1150 deletions(-) create mode 100644 src/blobbi/rooms/components/BlobbiCareRoom.tsx create mode 100644 src/blobbi/rooms/components/BlobbiClosetRoom.tsx create mode 100644 src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx create mode 100644 src/blobbi/rooms/components/BlobbiHomeRoom.tsx create mode 100644 src/blobbi/rooms/components/BlobbiKitchenRoom.tsx create mode 100644 src/blobbi/rooms/components/BlobbiRoomHero.tsx create mode 100644 src/blobbi/rooms/components/BlobbiRoomShell.tsx create mode 100644 src/blobbi/rooms/index.ts create mode 100644 src/blobbi/rooms/lib/room-config.ts create mode 100644 src/blobbi/rooms/lib/room-types.ts diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx new file mode 100644 index 00000000..6fb1abfd --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -0,0 +1,167 @@ +// src/blobbi/rooms/components/BlobbiCareRoom.tsx + +/** + * BlobbiCareRoom — The bathroom / hygiene room. + * + * Layout: + * - BlobbiRoomHero (Blobbi visual + stats) + * - Bottom center: bath interaction tools (soap, etc.) + * - Bottom right: shower (water action) + * - Bottom left: towel (only usable when wet — placeholder for now) + * + * Also shows medicine items in the center tools. + * + * Future: interactions could become drag-based. + */ + +import { useMemo } from 'react'; +import { Loader2, ShowerHead } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; +import type { BlobbiRoomContext } from '../lib/room-types'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +interface BlobbiCareRoomProps { + ctx: BlobbiRoomContext; +} + +export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { + const { + isUsingItem, + usingItemId, + handleUseItemFromTab, + isPublishing, + actionInProgress, + isActiveFloatingCompanion, + } = ctx; + + // Hygiene + medicine items from shop catalog + const hygieneItems = useMemo(() => + getLiveShopItems().filter(i => i.type === 'hygiene'), + []); + + const medicineItems = useMemo(() => + getLiveShopItems().filter(i => i.type === 'medicine'), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + // Find the towel item + const towelItem = hygieneItems.find(i => i.id === 'hyg_towel'); + // Other hygiene items (excluding towel — it's placed at bottom-left) + const centerHygieneItems = hygieneItems.filter(i => i.id !== 'hyg_towel'); + + return ( +
+ {/* ── Hero ── */} + + + {/* ── Bottom Action Bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Bottom left — Towel */} +
+ {towelItem && ( + + )} +
+ + {/* Center: hygiene tools + medicine */} +
+
+ {/* Hygiene items (soap, shampoo, bubble bath, etc.) */} + {centerHygieneItems.map(item => { + const isThisUsing = isUsingItem && usingItemId === item.id; + return ( + + ); + })} + + {/* Divider */} + {medicineItems.length > 0 && centerHygieneItems.length > 0 && ( +
+ )} + + {/* Medicine items */} + {medicineItems.map(item => { + const isThisUsing = isUsingItem && usingItemId === item.id; + return ( + + ); + })} +
+
+ + {/* Bottom right — Shower */} +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiClosetRoom.tsx b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx new file mode 100644 index 00000000..7f7a8cc1 --- /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. + * + * Not implemented yet — shows a clean empty state. + */ + +import { Shirt } from 'lucide-react'; + +import type { BlobbiRoomContext } from '../lib/room-types'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +interface BlobbiClosetRoomProps { + ctx: BlobbiRoomContext; +} + +export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) { + return ( +
+ {/* ── Hero ── */} + + + {/* ── Bottom Placeholder ── */} +
+
+
+ +
+

+ 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..e46b274f --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -0,0 +1,554 @@ +// 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 } from '../lib/room-types'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +// ─── 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; +} + +// ─── 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 && ( +
+
+ {/* Bottom left — Blobbis selector button */} +
+ +
+ + {/* 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 && ( + + )} +
+ + {/* Bottom right — Quests/Tasks button */} +
+ +
+
+
+ )} + + {/* ── 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..87b6702a --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx @@ -0,0 +1,259 @@ +// src/blobbi/rooms/components/BlobbiHomeRoom.tsx + +/** + * BlobbiHomeRoom — The main living / play room. + * + * Layout: + * - BlobbiRoomHero (stats crown, Blobbi visual, name) + * - Bottom center: horizontal carousel with toys + music + sing entries + * - Bottom right: lamp (sleep/wake toggle) + * - Bottom left: empty for now + * - Below hero: photo (left) + companion toggle (right) + * + * Inline activity (music player, sing card) renders between hero and bottom bar. + */ + +import { useMemo } from 'react'; +import { Camera, Footprints, Loader2, Sun, Lamp, Music, Mic } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; +import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions'; +import type { BlobbiRoomContext } from '../lib/room-types'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +interface BlobbiHomeRoomProps { + ctx: BlobbiRoomContext; +} + +export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { + const { + isEgg, + isSleeping, + isActiveFloatingCompanion, + // Photo + setShowPhotoModal, + // Companion + isCurrentCompanion, + canBeCompanion, + isUpdatingCompanion, + handleSetAsCompanion, + // Items + actions + isUsingItem, + usingItemId, + handleUseItemFromTab, + handleDirectAction, + isDirectActionPending, + // Inline activity + inlineActivity, + handleConfirmSing, + handleCloseInlineActivity, + handleMusicPlaybackStart, + handleMusicPlaybackStop, + handleSingRecordingStart, + handleSingRecordingStop, + handleChangeTrack, + // Rest + onRest, + actionInProgress, + isPublishing, + } = ctx; + + // Toys from shop catalog + const toyItems = useMemo(() => + getLiveShopItems().filter(i => i.type === 'toy'), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + return ( +
+ {/* ── Hero (Blobbi + stats) ── */} + + + {/* ── Action circles — Photo (left) + Companion (right) ── */} + {!isActiveFloatingCompanion && ( +
+ {/* Photo */} + + + {/* Companion toggle */} + {canBeCompanion && ( + + )} +
+ )} + + {/* ── Inline Activity Area (music/sing) ── */} + {inlineActivity.type === 'music' && ( +
+ +
+ )} + {inlineActivity.type === 'sing' && ( +
+ +
+ )} + + {/* ── Bottom Action Bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Bottom left — empty for now */} +
+ + {/* Center: horizontal carousel — toys + music + sing */} +
+
+ {/* Toy items */} + {toyItems.map(item => { + const isThisUsing = isUsingItem && usingItemId === item.id; + return ( + + ); + })} + + {/* Music action */} + + + {/* Sing action */} + +
+
+ + {/* Bottom right — Lamp (sleep/wake) */} + {!isEgg && ( +
+ +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx new file mode 100644 index 00000000..8a13d020 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx @@ -0,0 +1,131 @@ +// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx + +/** + * BlobbiKitchenRoom — The feeding room. + * + * Layout: + * - BlobbiRoomHero (Blobbi visual + stats) + * - Bottom center: horizontal food items carousel + * - Bottom right: fridge button (opens full items view for food) + * - Bottom left: empty for now + */ + +import { useMemo, useState } from 'react'; +import { Loader2, Refrigerator } 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 } from '../lib/room-types'; +import { BlobbiRoomHero } from './BlobbiRoomHero'; + +interface BlobbiKitchenRoomProps { + ctx: BlobbiRoomContext; +} + +export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) { + const { + companion, + profile, + isUsingItem, + usingItemId, + handleUseItemFromTab, + isPublishing, + actionInProgress, + isActiveFloatingCompanion, + } = ctx; + + // Open the fridge modal (shows all food items in the full inventory modal) + const [showFridge, setShowFridge] = useState(false); + + // Food items from shop catalog + const foodItems = useMemo(() => + getLiveShopItems().filter(i => i.type === 'food'), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + // Handler for using an item from the fridge modal + const handleFridgeUseItem = (itemId: string) => { + if (isUsingItem) return; + ctx.onUseItem(itemId, 'feed').finally(() => { + setShowFridge(false); + }); + }; + + return ( +
+ {/* ── Hero ── */} + + + {/* ── Bottom Action Bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Bottom left — empty for now */} +
+ + {/* Center: horizontal food carousel */} +
+
+ {foodItems.map(item => { + const isThisUsing = isUsingItem && usingItemId === item.id; + return ( + + ); + })} +
+
+ + {/* Bottom right — Fridge */} +
+ +
+
+
+ )} + + {/* ── Fridge Modal (reuses BlobbiActionInventoryModal for "feed" action) ── */} + {showFridge && ( + setShowFridge(false)} + isUsingItem={isUsingItem} + usingItemId={usingItemId} + /> + )} +
+ ); +} diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx new file mode 100644 index 00000000..9f303d22 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -0,0 +1,278 @@ +// src/blobbi/rooms/components/BlobbiRoomHero.tsx + +/** + * BlobbiRoomHero — Shared Blobbi visual display used in every room. + * + * Renders: + * - Stats crown (arced indicators above Blobbi) + * - BlobbiStageVisual (the main Blobbi) + * - Blobbi name (hidden for eggs) + * - Bob/sway animation when not sleeping + * + * This component extracts the hero section that was previously inlined + * in BlobbiDashboard so all rooms can share it without duplication. + */ + +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 (same as original) ────────────────────────────────────── + +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; + /** Optional extra className on the outer container */ + className?: string; + /** If true, hides the stats crown */ + hideStats?: boolean; + /** If true, hides the name */ + 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; + + // When the companion is out floating, show "out exploring" instead + if (isActiveFloatingCompanion) { + return ( +
+ +

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

+ +
+ ); + } + + return ( +
+
+ {/* Stats crown */} + {!hideStats && } + + {/* Blobbi visual */} +
+
+ +
+ + {/* Blobbi Name — hidden for eggs */} + {!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; + 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; + const radius = Math.min(210, Math.max(140, (heroWidth - 340) / (640 - 340) * (210 - 140) + 140)); + 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..cdab5a89 --- /dev/null +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -0,0 +1,179 @@ +// src/blobbi/rooms/components/BlobbiRoomShell.tsx + +/** + * BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard. + * + * Responsibilities: + * 1. Manages the current room state + * 2. Renders left/right navigation arrows + * 3. Shows the room label indicator + * 4. Delegates room content to the appropriate room component + * + * The shell does NOT own any Blobbi domain logic — it only owns + * which room is displayed and provides the navigation chrome. + * + * Future animation branch: + * - The `currentRoom` / `direction` state already provides enough + * information to drive enter/exit CSS transitions or framer-motion + * variants. The `direction` field ('left' | 'right' | null) can be + * used to pick the animation direction. + */ + +import { useState, useCallback, useMemo } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { + type BlobbiRoomId, + ROOM_META, + DEFAULT_ROOM_ORDER, + DEFAULT_INITIAL_ROOM, + getNextRoom, + getPreviousRoom, + getRoomIndex, +} from '../lib/room-config'; +import type { BlobbiRoomContext } from '../lib/room-types'; + +import { BlobbiHomeRoom } from './BlobbiHomeRoom'; +import { BlobbiKitchenRoom } from './BlobbiKitchenRoom'; +import { BlobbiCareRoom } from './BlobbiCareRoom'; +import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom'; +import { BlobbiClosetRoom } from './BlobbiClosetRoom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BlobbiRoomShellProps { + /** All room context passed through to the active room */ + ctx: BlobbiRoomContext; + /** + * Room sequence. Defaults to DEFAULT_ROOM_ORDER. + * Later this can come from user preferences. + */ + roomOrder?: BlobbiRoomId[]; + /** Which room to start on. Defaults to DEFAULT_INITIAL_ROOM ('home'). */ + initialRoom?: BlobbiRoomId; +} + +/** + * Internal navigation state. + * `direction` is kept for future animation: it tells which way the user navigated. + */ +interface RoomNavState { + current: BlobbiRoomId; + direction: 'left' | 'right' | null; +} + +// ─── Room Component Map ─────────────────────────────────────────────────────── + +const ROOM_COMPONENTS: Record> = { + care: BlobbiCareRoom, + kitchen: BlobbiKitchenRoom, + home: BlobbiHomeRoom, + hatchery: BlobbiHatcheryRoom, + 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); + + // Resolve the room component to render + const RoomComponent = ROOM_COMPONENTS[nav.current]; + + // Room indicator dots + const dots = useMemo(() => roomOrder.map((id, i) => ({ + id, + active: i === roomIndex, + label: ROOM_META[id].label, + })), [roomOrder, roomIndex]); + + return ( +
+ {/* ── Room Header — label + dots ── */} +
+ {/* Room label */} +
+ {meta.icon} + {meta.label} +
+
+ + {/* ── Room indicator dots ── */} +
+ {dots.map(dot => ( +
+ ))} +
+ + {/* ── Room Content Area ── */} + {/* + This is where future animation will happen. + The `direction` in nav state tells which way to animate. + For now, we simply render the active room instantly. + */} +
+ +
+ + {/* ── Left / Right Navigation Arrows ── */} + + +
+ ); +} diff --git a/src/blobbi/rooms/index.ts b/src/blobbi/rooms/index.ts new file mode 100644 index 00000000..e0827682 --- /dev/null +++ b/src/blobbi/rooms/index.ts @@ -0,0 +1,19 @@ +// 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 { BlobbiClosetRoom } from './components/BlobbiClosetRoom'; diff --git a/src/blobbi/rooms/lib/room-config.ts b/src/blobbi/rooms/lib/room-config.ts new file mode 100644 index 00000000..62da6b70 --- /dev/null +++ b/src/blobbi/rooms/lib/room-config.ts @@ -0,0 +1,134 @@ +// 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' | '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: 'Bathroom', + description: 'Hygiene and care', + 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: '🥚', + }, + 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. + */ +export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [ + 'care', + 'kitchen', + 'home', + 'hatchery', + 'closet', +]; + +/** + * 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-types.ts b/src/blobbi/rooms/lib/room-types.ts new file mode 100644 index 00000000..2e0015a6 --- /dev/null +++ b/src/blobbi/rooms/lib/room-types.ts @@ -0,0 +1,175 @@ +// 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 ── + heroRef: 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>; +} diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 2fd42964..6a3b2212 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 } 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,7 +73,7 @@ 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 { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev'; @@ -92,6 +81,8 @@ 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'; @@ -106,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 ─────────────────────────────────────────────────────────── @@ -823,11 +790,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 { @@ -904,15 +866,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, @@ -1347,37 +1300,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); @@ -1386,7 +1316,162 @@ 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, + heroWidth, + + // DEV + showDevEditor, + setShowDevEditor, + onDevEditorApply, + isDevUpdating, + showEmotionPanel, + setShowEmotionPanel, + showHatchCeremony, + setShowHatchCeremony, + + // Inventory modal + inventoryAction, + setInventoryAction, + // 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 ( @@ -1398,340 +1483,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) { - return ( -
- {allShopItems.filter(i => i.status !== 'disabled').map((item) => { - const isThisUsing = isUsingItem && usingItemId === item.id; - 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 ───────────────────────────────────────────────────── From 0722d900a2e35ae6f34baf47844500428edf5007 Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 6 Apr 2026 01:02:14 -0300 Subject: [PATCH 2/7] Polish room UI: single-focus carousel, unified action buttons, add Rest room Major improvements to room layout and visual consistency: - ItemCarousel: single-focus carousel showing one main item at center with translucent prev/next previews and left/right navigation arrows. Replaces the horizontal scroll rows in Kitchen, Care, and Home rooms. - RoomActionButton: unified circular button component matching the original Photo and Companion button visual language (radial glow background, consistent size, hover lift, label beneath). Used in all rooms for consistent visual weight. - BlobbiRestRoom: new dedicated bedroom for sleep/wake behavior, with a Moon/Sun toggle as a RoomActionButton on the bottom-right. Sleep/wake removed from Home room. - Updated default room order: care -> kitchen -> home -> hatchery -> rest -> closet (looped in both directions). - All room bottom bars now use consistent px-4 sm:px-8 pb-6 spacing with items-start justify-between layout for left/center/right zones. - HatcheryRoom left/right buttons (Blobbis, Quests) upgraded to RoomActionButton with badge support. --- .../rooms/components/BlobbiCareRoom.tsx | 154 ++++--------- .../rooms/components/BlobbiHatcheryRoom.tsx | 74 +++--- .../rooms/components/BlobbiHomeRoom.tsx | 217 +++++------------- .../rooms/components/BlobbiKitchenRoom.tsx | 84 +++---- .../rooms/components/BlobbiRestRoom.tsx | 74 ++++++ .../rooms/components/BlobbiRoomShell.tsx | 2 + src/blobbi/rooms/components/ItemCarousel.tsx | 139 +++++++++++ .../rooms/components/RoomActionButton.tsx | 79 +++++++ src/blobbi/rooms/index.ts | 1 + src/blobbi/rooms/lib/room-config.ts | 9 +- 10 files changed, 474 insertions(+), 359 deletions(-) create mode 100644 src/blobbi/rooms/components/BlobbiRestRoom.tsx create mode 100644 src/blobbi/rooms/components/ItemCarousel.tsx create mode 100644 src/blobbi/rooms/components/RoomActionButton.tsx diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx index 6fb1abfd..864be26f 100644 --- a/src/blobbi/rooms/components/BlobbiCareRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -5,22 +5,21 @@ * * Layout: * - BlobbiRoomHero (Blobbi visual + stats) - * - Bottom center: bath interaction tools (soap, etc.) - * - Bottom right: shower (water action) - * - Bottom left: towel (only usable when wet — placeholder for now) - * - * Also shows medicine items in the center tools. + * - Center: single-focus carousel with hygiene + medicine items + * - Bottom left: towel (RoomActionButton style) + * - Bottom right: shower (RoomActionButton style) * * Future: interactions could become drag-based. */ import { useMemo } from 'react'; -import { Loader2, ShowerHead } from 'lucide-react'; +import { ShowerHead } from 'lucide-react'; -import { cn } from '@/lib/utils'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; import type { BlobbiRoomContext } from '../lib/room-types'; import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; interface BlobbiCareRoomProps { ctx: BlobbiRoomContext; @@ -36,21 +35,27 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { isActiveFloatingCompanion, } = ctx; - // Hygiene + medicine items from shop catalog const hygieneItems = useMemo(() => getLiveShopItems().filter(i => i.type === 'hygiene'), []); - const medicineItems = useMemo(() => - getLiveShopItems().filter(i => i.type === 'medicine'), - []); - const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; - // Find the towel item + // Towel is shown as a dedicated button, not in the carousel const towelItem = hygieneItems.find(i => i.id === 'hyg_towel'); - // Other hygiene items (excluding towel — it's placed at bottom-left) - const centerHygieneItems = hygieneItems.filter(i => i.id !== 'hyg_towel'); + + // Carousel: hygiene (except towel) + medicine + 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 })); + + const medicine = getLiveShopItems() + .filter(i => i.type === 'medicine') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })); + + return [...hygiene, ...medicine]; + }, []); return (
@@ -59,106 +64,45 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
-
+
+
{/* Bottom left — Towel */} -
+
{towelItem && ( - + loading={isUsingItem && usingItemId === towelItem.id} + /> )}
- {/* Center: hygiene tools + medicine */} -
-
- {/* Hygiene items (soap, shampoo, bubble bath, etc.) */} - {centerHygieneItems.map(item => { - const isThisUsing = isUsingItem && usingItemId === item.id; - return ( - - ); - })} - - {/* Divider */} - {medicineItems.length > 0 && centerHygieneItems.length > 0 && ( -
- )} - - {/* Medicine items */} - {medicineItems.map(item => { - const isThisUsing = isUsingItem && usingItemId === item.id; - return ( - - ); - })} -
+ {/* Center: single-focus carousel */} +
+
{/* Bottom right — Shower */} -
- -
+ } + 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/BlobbiHatcheryRoom.tsx b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx index e46b274f..f9cca590 100644 --- a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -28,6 +28,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { isLocalhostDev } from '@/blobbi/dev'; import type { BlobbiRoomContext } from '../lib/room-types'; import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; // ─── Helper: companionNeedsCare (reused from BlobbiPage) ────────────────────── @@ -118,31 +119,24 @@ export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) { {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
-
- {/* Bottom left — Blobbis selector button */} -
- -
+
+
+ {/* Bottom 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 && ( -
+ {/* Bottom right — Quests/Tasks */} + } + label="Quests" + color="text-amber-500" + glowHex="#f59e0b" + onClick={() => setShowQuestsPanel(true)} + badge={hasActiveProcess && totalCount - completedCount > 0 ? ( + + {totalCount - completedCount} + + ) : undefined} + />
)} diff --git a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx index 87b6702a..13ecc067 100644 --- a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx @@ -5,22 +5,22 @@ * * Layout: * - BlobbiRoomHero (stats crown, Blobbi visual, name) - * - Bottom center: horizontal carousel with toys + music + sing entries - * - Bottom right: lamp (sleep/wake toggle) - * - Bottom left: empty for now - * - Below hero: photo (left) + companion toggle (right) + * - Photo button (left) + Companion toggle (right) — same style as original + * - Center: single-focus carousel with toys + music + sing + * - Inline activity (music player, sing card) between hero and bottom bar * - * Inline activity (music player, sing card) renders between hero and bottom bar. + * Sleep/wake has been moved to BlobbiRestRoom. */ import { useMemo } from 'react'; -import { Camera, Footprints, Loader2, Sun, Lamp, Music, Mic } from 'lucide-react'; +import { Camera, Footprints, Music, Mic } from 'lucide-react'; -import { cn } from '@/lib/utils'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions'; import type { BlobbiRoomContext } from '../lib/room-types'; import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; interface BlobbiHomeRoomProps { ctx: BlobbiRoomContext; @@ -28,8 +28,6 @@ interface BlobbiHomeRoomProps { export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { const { - isEgg, - isSleeping, isActiveFloatingCompanion, // Photo setShowPhotoModal, @@ -53,19 +51,45 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { handleSingRecordingStart, handleSingRecordingStop, handleChangeTrack, - // Rest - onRest, - actionInProgress, + // State isPublishing, + actionInProgress, } = ctx; - // Toys from shop catalog - const toyItems = useMemo(() => - getLiveShopItems().filter(i => i.type === 'toy'), - []); + // 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) ── */} @@ -75,53 +99,25 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { {!isActiveFloatingCompanion && (
{/* Photo */} - + /> {/* Companion toggle */} {canBeCompanion && ( - + loading={isUpdatingCompanion} + /> )}
)} @@ -152,106 +148,15 @@ export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
)} - {/* ── Bottom Action Bar ── */} + {/* ── Bottom: Single-focus carousel ── */} {!isActiveFloatingCompanion && ( -
-
- {/* Bottom left — empty for now */} -
- - {/* Center: horizontal carousel — toys + music + sing */} -
-
- {/* Toy items */} - {toyItems.map(item => { - const isThisUsing = isUsingItem && usingItemId === item.id; - return ( - - ); - })} - - {/* Music action */} - - - {/* Sing action */} - -
-
- - {/* Bottom right — Lamp (sleep/wake) */} - {!isEgg && ( -
- -
- )} -
+
+
)}
diff --git a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx index 8a13d020..fa0f4a11 100644 --- a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx @@ -5,19 +5,20 @@ * * Layout: * - BlobbiRoomHero (Blobbi visual + stats) - * - Bottom center: horizontal food items carousel - * - Bottom right: fridge button (opens full items view for food) + * - Center: single-focus food carousel + * - Bottom right: fridge button (opens full food list modal) * - Bottom left: empty for now */ import { useMemo, useState } from 'react'; -import { Loader2, Refrigerator } from 'lucide-react'; +import { Refrigerator } 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 } from '../lib/room-types'; import { BlobbiRoomHero } from './BlobbiRoomHero'; +import { RoomActionButton } from './RoomActionButton'; +import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; interface BlobbiKitchenRoomProps { ctx: BlobbiRoomContext; @@ -35,12 +36,13 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) { isActiveFloatingCompanion, } = ctx; - // Open the fridge modal (shows all food items in the full inventory modal) const [showFridge, setShowFridge] = useState(false); - // Food items from shop catalog - const foodItems = useMemo(() => - getLiveShopItems().filter(i => i.type === 'food'), + // Food carousel entries + 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; @@ -60,59 +62,35 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) { {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
-
- {/* Bottom left — empty for now */} -
+
+
+ {/* Bottom left — empty */} +
- {/* Center: horizontal food carousel */} -
-
- {foodItems.map(item => { - const isThisUsing = isUsingItem && usingItemId === item.id; - return ( - - ); - })} -
+ {/* Center: single-focus food carousel */} +
+
{/* Bottom right — Fridge */} -
- -
+ } + label="Fridge" + color="text-orange-500" + glowHex="#f97316" + onClick={() => setShowFridge(true)} + disabled={isDisabled} + />
)} - {/* ── Fridge Modal (reuses BlobbiActionInventoryModal for "feed" action) ── */} + {/* ── Fridge Modal ── */} {showFridge && ( + {/* ── Hero ── */} + + + {/* ── Bottom Action Bar ── */} + {!isActiveFloatingCompanion && ( +
+
+ {/* Bottom left — empty for now */} +
+ + {/* Center — minimal for now */} +
+ + {/* Bottom right — Sleep / Wake */} + {!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/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index cdab5a89..4b9fc9e0 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -38,6 +38,7 @@ 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 ──────────────────────────────────────────────────────────────────── @@ -70,6 +71,7 @@ const ROOM_COMPONENTS: Record void; + /** Item id currently being used (shows spinner) */ + activeItemId?: string | null; + /** Whether any action is in progress */ + disabled?: boolean; + className?: string; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ItemCarousel({ + items, + onUse, + activeItemId, + disabled, + className, +}: ItemCarouselProps) { + const [index, setIndex] = useState(0); + + const count = items.length; + + const prev = useCallback(() => { + setIndex(i => (i - 1 + count) % count); + }, [count]); + + const next = useCallback(() => { + setIndex(i => (i + 1) % count); + }, [count]); + + if (count === 0) { + return ( +
+

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; + + return ( +
+ {/* Left arrow */} + + + {/* Preview (prev) — only when 3+ items */} + {count >= 3 && ( +
+ {prevItem.icon} +
+ )} + + {/* Focused item */} + + + {/* Preview (next) — only when 3+ items */} + {count >= 3 && ( +
+ {nextItem.icon} +
+ )} + + {/* Right arrow */} + +
+ ); +} diff --git a/src/blobbi/rooms/components/RoomActionButton.tsx b/src/blobbi/rooms/components/RoomActionButton.tsx new file mode 100644 index 00000000..43330081 --- /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. + * + * Matches the visual language of the original Photo and Companion buttons: + * - Large rounded-full circle with soft radial glow background + * - Icon centred inside + * - Label beneath + * - Hover lift + scale, active scale-down + * - Consistent size across all rooms + */ + +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 index e0827682..6f7de4c4 100644 --- a/src/blobbi/rooms/index.ts +++ b/src/blobbi/rooms/index.ts @@ -16,4 +16,5 @@ 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/room-config.ts b/src/blobbi/rooms/lib/room-config.ts index 62da6b70..0357dc62 100644 --- a/src/blobbi/rooms/lib/room-config.ts +++ b/src/blobbi/rooms/lib/room-config.ts @@ -14,7 +14,7 @@ * 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' | 'closet'; +export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet'; // ─── Room Metadata ──────────────────────────────────────────────────────────── @@ -58,6 +58,12 @@ export const ROOM_META: Record = { description: 'Evolution and quests', icon: '🥚', }, + rest: { + id: 'rest', + label: 'Bedroom', + description: 'Rest and recharge', + icon: '🌙', + }, closet: { id: 'closet', label: 'Closet', @@ -80,6 +86,7 @@ export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [ 'kitchen', 'home', 'hatchery', + 'rest', 'closet', ]; From a2600d1caaa652e562eb5904a1434705dfaa8ffc Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 6 Apr 2026 01:15:11 -0300 Subject: [PATCH 3/7] Fix room layout proportions, responsive sizing, and visual composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses layout issues where the room felt cut off and controls were disproportionate, especially on mobile. Key changes: RoomActionButton — responsive sizing: Mobile: size-14 circle, size-7 icons Desktop: size-20 circle, size-9 icons Tighter gap-1 and text-[10px] labels on mobile ItemCarousel — responsive behavior: Mobile: focused item only + compact arrows, no prev/next previews Desktop: focused item + translucent side previews Smaller focused item (text-4xl mobile, text-5xl desktop) BlobbiRoomHero — reduced visual footprint: Blobbi visual: tighter responsive scale (size-48 -> size-60 -> size-80) Stats crown: reduced margin (mb-6 sm:mb-10 vs mb-14) Stat indicators: size-14 sm:size-[4.5rem] (was size-[4.5rem] sm:size-20) Glow blur reduced on mobile (-m-16 vs -m-24) overflow-hidden to prevent hero from clipping HomeRoom — unified bottom bar: Removed -mt-10 negative margin hack that caused visual clipping Photo, Carousel, and Companion now sit in one flex row with items-center alignment, forming a single cohesive bottom composition All rooms — standardized bottom bar pattern: Consistent px-3 sm:px-6 pb-4 sm:pb-6 pt-1 spacing flex items-center justify-between gap-1 sm:gap-3 layout Spacer divs match button widths (w-14 sm:w-20) for balance Room shell content area uses flex flex-col to ensure rooms fill the available vertical space correctly. --- .../rooms/components/BlobbiCareRoom.tsx | 53 +++++------- .../rooms/components/BlobbiHatcheryRoom.tsx | 18 ++-- .../rooms/components/BlobbiHomeRoom.tsx | 86 +++++++++---------- .../rooms/components/BlobbiKitchenRoom.tsx | 25 ++---- .../rooms/components/BlobbiRestRoom.tsx | 31 +++---- .../rooms/components/BlobbiRoomHero.tsx | 43 +++++----- .../rooms/components/BlobbiRoomShell.tsx | 2 +- src/blobbi/rooms/components/ItemCarousel.tsx | 34 ++++---- .../rooms/components/RoomActionButton.tsx | 20 ++--- 9 files changed, 140 insertions(+), 172 deletions(-) diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx index 864be26f..2aeff9c0 100644 --- a/src/blobbi/rooms/components/BlobbiCareRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -3,13 +3,7 @@ /** * BlobbiCareRoom — The bathroom / hygiene room. * - * Layout: - * - BlobbiRoomHero (Blobbi visual + stats) - * - Center: single-focus carousel with hygiene + medicine items - * - Bottom left: towel (RoomActionButton style) - * - Bottom right: shower (RoomActionButton style) - * - * Future: interactions could become drag-based. + * Bottom bar: Towel (left) | hygiene+medicine carousel (center) | Shower (right) */ import { useMemo } from 'react'; @@ -40,48 +34,41 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { []); const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; - - // Towel is shown as a dedicated button, not in the carousel const towelItem = hygieneItems.find(i => i.id === 'hyg_towel'); - // Carousel: hygiene (except towel) + medicine 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 })); - const medicine = getLiveShopItems() .filter(i => i.type === 'medicine') .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })); - return [...hygiene, ...medicine]; }, []); return (
- {/* ── Hero ── */} - {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
-
- {/* Bottom left — Towel */} -
- {towelItem && ( - {towelItem.icon}} - label="Towel" - color="text-cyan-500" - glowHex="#06b6d4" - onClick={() => handleUseItemFromTab(towelItem.id)} - disabled={isDisabled} - loading={isUsingItem && usingItemId === towelItem.id} - /> - )} -
+
+
+ {/* Left — Towel */} + {towelItem ? ( + {towelItem.icon}} + label="Towel" + color="text-cyan-500" + glowHex="#06b6d4" + onClick={() => handleUseItemFromTab(towelItem.id)} + disabled={isDisabled} + loading={isUsingItem && usingItemId === towelItem.id} + /> + ) : ( +
+ )} - {/* Center: single-focus carousel */} + {/* Center carousel */}
- {/* Bottom right — Shower */} + {/* Right — Shower */} } + icon={} label="Shower" color="text-blue-500" glowHex="#3b82f6" diff --git a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx index f9cca590..21dc041d 100644 --- a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -119,24 +119,24 @@ export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) { {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
-
- {/* Bottom left — Blobbis selector */} +
+
+ {/* Left — Blobbis selector */} } + icon={} 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 && ( @@ -128,7 +127,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo } return ( -
+
{/* Stats crown */} {!hideStats && } @@ -140,7 +139,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`, } : undefined} > -
+
@@ -159,7 +158,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo {/* Blobbi Name — hidden for eggs */} {!hideName && !isEgg && (

{companion.name} @@ -195,7 +194,7 @@ function StatsCrown({ const count = allStats.length; const isSmall = heroWidth < 400; const arcSpread = isSmall - ? (count <= 2 ? 90 : count <= 3 ? 130 : 160) + ? (count <= 2 ? 80 : count <= 3 ? 120 : 150) : (count <= 2 ? 80 : count <= 3 ? 120 : 160); const arcHalf = arcSpread / 2; const angles = count === 1 @@ -203,11 +202,11 @@ function StatsCrown({ : allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i); return ( -
+
{allStats.map((s, i) => { const angleDeg = angles[i]; const angleRad = (angleDeg * Math.PI) / 180; - const radius = Math.min(210, Math.max(140, (heroWidth - 340) / (640 - 340) * (210 - 140) + 140)); + const radius = Math.min(180, Math.max(100, (heroWidth - 340) / (640 - 340) * (180 - 100) + 100)); const x = Math.sin(angleRad) * radius; const y = Math.cos(angleRad) * radius - radius; @@ -251,7 +250,7 @@ function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorP return (
@@ -265,10 +264,10 @@ function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorP />
- {IconComponent && } + {IconComponent && } {isLow && ( )} diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index 4b9fc9e0..09a7cb09 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -147,7 +147,7 @@ export function BlobbiRoomShell({ The `direction` in nav state tells which way to animate. For now, we simply render the active room instantly. */} -
+
diff --git a/src/blobbi/rooms/components/ItemCarousel.tsx b/src/blobbi/rooms/components/ItemCarousel.tsx index facf1962..45d2491a 100644 --- a/src/blobbi/rooms/components/ItemCarousel.tsx +++ b/src/blobbi/rooms/components/ItemCarousel.tsx @@ -3,11 +3,10 @@ /** * ItemCarousel — Single-focus carousel for room items. * - * Shows one main item at centre with translucent prev/next previews. - * Left/right arrows cycle through items in a loop. + * Mobile: focused item only + compact arrows (no prev/next previews) + * Desktop: focused item + translucent prev/next previews + arrows * * Each "item" is an opaque entry with id, icon, label, and an onUse callback. - * The carousel doesn't know about shop items or actions — the parent maps them. */ import { useState, useCallback } from 'react'; @@ -58,7 +57,7 @@ export function ItemCarousel({ if (count === 0) { return ( -
+

Nothing here yet

); @@ -68,15 +67,16 @@ export function ItemCarousel({ 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 */} - {/* Preview (prev) — only when 3+ items */} - {count >= 3 && ( -
+ {/* Preview (prev) — desktop only, 3+ items */} + {showPreviews && ( +
{prevItem.icon}
)} @@ -98,24 +98,24 @@ export function ItemCarousel({ onClick={() => onUse(current.id)} disabled={disabled} className={cn( - 'relative flex flex-col items-center gap-1 py-2 px-4 rounded-2xl transition-all duration-200 shrink-0', + 'relative flex flex-col items-center gap-0.5 py-1 px-3 sm:px-4 rounded-2xl transition-all duration-200 shrink-0', 'hover:-translate-y-0.5 active:scale-95', isThisActive && 'bg-accent/40', disabled && !isThisActive && 'opacity-50 pointer-events-none', )} > - + {current.icon} - {current.label} + {current.label} {isThisActive && ( - + )} - {/* Preview (next) — only when 3+ items */} - {count >= 3 && ( -
+ {/* Preview (next) — desktop only, 3+ items */} + {showPreviews && ( +
{nextItem.icon}
)} @@ -125,7 +125,7 @@ export function ItemCarousel({ onClick={next} disabled={disabled} className={cn( - 'size-8 rounded-full flex items-center justify-center shrink-0', + 'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0', 'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40', 'transition-all duration-200 active:scale-90', disabled && 'opacity-30 pointer-events-none', diff --git a/src/blobbi/rooms/components/RoomActionButton.tsx b/src/blobbi/rooms/components/RoomActionButton.tsx index 43330081..a0209bb7 100644 --- a/src/blobbi/rooms/components/RoomActionButton.tsx +++ b/src/blobbi/rooms/components/RoomActionButton.tsx @@ -3,12 +3,12 @@ /** * RoomActionButton — Unified circular action button for all rooms. * - * Matches the visual language of the original Photo and Companion buttons: - * - Large rounded-full circle with soft radial glow background - * - Icon centred inside - * - Label beneath - * - Hover lift + scale, active scale-down - * - Consistent size across 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'; @@ -48,7 +48,7 @@ export function RoomActionButton({ onClick={onClick} disabled={disabled} className={cn( - 'flex flex-col items-center gap-1.5 transition-all duration-300 ease-out', + 'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0', 'hover:-translate-y-1 hover:scale-110 active:scale-95', disabled && 'opacity-50 pointer-events-none', className, @@ -56,13 +56,13 @@ export function RoomActionButton({ >
{loading ? ( - + ) : ( icon )} @@ -73,7 +73,7 @@ export function RoomActionButton({
)}
- {label} + {label} ); } From 0b9cd5e1cb88b3a4ad4c45a951013f864da6b205 Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 6 Apr 2026 01:32:43 -0300 Subject: [PATCH 4/7] Unify room surface, fix desktop proportions, clear mobile bottom nav Three structural layout fixes: 1. Remove 'background cut' feeling: - Room header (label + dots) is now absolutely positioned over the room content instead of being a separate flex block above it. This eliminates the stacked-panels look where each section appeared to have its own background surface. - Hero container no longer uses overflow-hidden; uses pt-10 for header clearance instead. The room reads as one continuous scene. - Room shell content area fills entirely; header and nav arrows float over it as overlays. 2. Desktop proportions tuned (mobile preserved): - Blobbi visual reduced on desktop: size-72/size-80/size-96 ladder (was size-80/size-[28rem]/size-[32rem]) - Stats crown arc spread widened on desktop: 90/140/180 degrees (was 80/120/160), with radius up to 220px (was 180px). This gives the stat indicators more breathing room on wide screens. - Stats crown margin: mb-4 sm:mb-8 (tighter on mobile, roomier on desktop) - Mobile stat sizing and spacing unchanged from previous pass. 3. Mobile/tablet bottom nav clearance: - New shared ROOM_BOTTOM_BAR_CLASS constant in room-layout.ts - On max-sidebar (below 900px), bottom bars add padding: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + 1rem) - This pushes room controls above the app's fixed bottom navigation (Feed/Search/Notifications/Profile) - On desktop (sidebar:), normal pb-6 applies since there's no bottom nav bar. - All 6 rooms now use the shared class for consistent clearance. --- .../rooms/components/BlobbiCareRoom.tsx | 3 +- .../rooms/components/BlobbiClosetRoom.tsx | 3 +- .../rooms/components/BlobbiHatcheryRoom.tsx | 3 +- .../rooms/components/BlobbiHomeRoom.tsx | 3 +- .../rooms/components/BlobbiKitchenRoom.tsx | 3 +- .../rooms/components/BlobbiRestRoom.tsx | 3 +- .../rooms/components/BlobbiRoomHero.tsx | 41 ++++--- .../rooms/components/BlobbiRoomShell.tsx | 107 +++++++----------- src/blobbi/rooms/lib/room-layout.ts | 15 +++ 9 files changed, 93 insertions(+), 88 deletions(-) create mode 100644 src/blobbi/rooms/lib/room-layout.ts diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx index 2aeff9c0..137d7ab8 100644 --- a/src/blobbi/rooms/components/BlobbiCareRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -11,6 +11,7 @@ import { ShowerHead } from 'lucide-react'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; import type { BlobbiRoomContext } 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'; @@ -51,7 +52,7 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { {!isActiveFloatingCompanion && ( -
+
{/* Left — Towel */} {towelItem ? ( diff --git a/src/blobbi/rooms/components/BlobbiClosetRoom.tsx b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx index 7f7a8cc1..77326e33 100644 --- a/src/blobbi/rooms/components/BlobbiClosetRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx @@ -9,6 +9,7 @@ import { Shirt } from 'lucide-react'; import type { BlobbiRoomContext } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; import { BlobbiRoomHero } from './BlobbiRoomHero'; interface BlobbiClosetRoomProps { @@ -22,7 +23,7 @@ export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) { {/* ── Bottom Placeholder ── */} -
+
diff --git a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx index 21dc041d..673d79a1 100644 --- a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -27,6 +27,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { isLocalhostDev } from '@/blobbi/dev'; import type { BlobbiRoomContext } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; import { BlobbiRoomHero } from './BlobbiRoomHero'; import { RoomActionButton } from './RoomActionButton'; @@ -119,7 +120,7 @@ export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) { {/* ── Bottom Action Bar ── */} {!isActiveFloatingCompanion && ( -
+
{/* Left — Blobbis selector */} +
{/* Photo */} {!isActiveFloatingCompanion && ( -
+
{/* Left — empty spacer matching button width */}
diff --git a/src/blobbi/rooms/components/BlobbiRestRoom.tsx b/src/blobbi/rooms/components/BlobbiRestRoom.tsx index 13abeacf..59b99acb 100644 --- a/src/blobbi/rooms/components/BlobbiRestRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiRestRoom.tsx @@ -9,6 +9,7 @@ import { Moon, Sun, Loader2 } from 'lucide-react'; import type { BlobbiRoomContext } from '../lib/room-types'; +import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout'; import { BlobbiRoomHero } from './BlobbiRoomHero'; import { RoomActionButton } from './RoomActionButton'; @@ -34,7 +35,7 @@ export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) { {!isActiveFloatingCompanion && ( -
+
{/* Left — empty */}
diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index c304ae8b..eb8925c9 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -3,16 +3,11 @@ /** * BlobbiRoomHero — Shared Blobbi visual display used in every room. * - * Renders: - * - Stats crown (arced indicators above Blobbi) - * - BlobbiStageVisual (the main Blobbi) - * - Blobbi name (hidden for eggs) - * - Bob/sway animation when not sleeping + * 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. * - * Sizing is tuned so the hero doesn't crowd the bottom action bar: - * - Stats indicators are smaller on mobile (size-14 vs size-20) - * - Blobbi visual uses a tighter responsive scale - * - Stats crown margin is reduced + * Top padding accounts for the floating room header overlay (~40px). */ import { useMemo } from 'react'; @@ -97,7 +92,6 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo heroWidth, } = ctx; - // When the companion is out floating, show "out exploring" instead if (isActiveFloatingCompanion) { return (
@@ -127,7 +121,15 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo } return ( -
+
{/* Stats crown */} {!hideStats && } @@ -139,7 +141,7 @@ export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRo animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`, } : undefined} > -
+
- {/* Blobbi Name — hidden for eggs */} + {/* Blobbi Name */} {!hideName && !isEgg && (

-arcHalf + (arcSpread / (count - 1)) * i); return ( -
+
{allStats.map((s, i) => { const angleDeg = angles[i]; const angleRad = (angleDeg * Math.PI) / 180; - const radius = Math.min(180, Math.max(100, (heroWidth - 340) / (640 - 340) * (180 - 100) + 100)); + // Mobile: tighter radius; Desktop: wider for more spread + const radius = Math.min(220, Math.max(100, (heroWidth - 340) / (640 - 340) * (220 - 100) + 100)); const x = Math.sin(angleRad) * radius; const y = Math.cos(angleRad) * radius - radius; diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index 09a7cb09..f90e13cf 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -3,20 +3,16 @@ /** * BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard. * - * Responsibilities: - * 1. Manages the current room state - * 2. Renders left/right navigation arrows - * 3. Shows the room label indicator - * 4. Delegates room content to the appropriate room component + * The shell renders the room as one continuous surface: + * - Room header (label + dots) floats absolutely over the room content + * - Room navigation arrows float absolutely on the sides + * - The room component fills the entire area * - * The shell does NOT own any Blobbi domain logic — it only owns - * which room is displayed and provides the navigation chrome. + * This avoids the "stacked panels" look where header, content, and footer + * appear as separate background blocks. * * Future animation branch: - * - The `currentRoom` / `direction` state already provides enough - * information to drive enter/exit CSS transitions or framer-motion - * variants. The `direction` field ('left' | 'right' | null) can be - * used to pick the animation direction. + * - `direction` in nav state tells which way the user navigated. */ import { useState, useCallback, useMemo } from 'react'; @@ -44,21 +40,11 @@ import { BlobbiClosetRoom } from './BlobbiClosetRoom'; // ─── Types ──────────────────────────────────────────────────────────────────── interface BlobbiRoomShellProps { - /** All room context passed through to the active room */ ctx: BlobbiRoomContext; - /** - * Room sequence. Defaults to DEFAULT_ROOM_ORDER. - * Later this can come from user preferences. - */ roomOrder?: BlobbiRoomId[]; - /** Which room to start on. Defaults to DEFAULT_INITIAL_ROOM ('home'). */ initialRoom?: BlobbiRoomId; } -/** - * Internal navigation state. - * `direction` is kept for future animation: it tells which way the user navigated. - */ interface RoomNavState { current: BlobbiRoomId; direction: 'left' | 'right' | null; @@ -103,11 +89,8 @@ export function BlobbiRoomShell({ const meta = ROOM_META[nav.current]; const roomIndex = getRoomIndex(nav.current, roomOrder); - - // Resolve the room component to render const RoomComponent = ROOM_COMPONENTS[nav.current]; - // Room indicator dots const dots = useMemo(() => roomOrder.map((id, i) => ({ id, active: i === roomIndex, @@ -116,65 +99,61 @@ export function BlobbiRoomShell({ return (
- {/* ── Room Header — label + dots ── */} -
- {/* Room label */} -
- {meta.icon} - {meta.label} -
-
- - {/* ── Room indicator dots ── */} -
- {dots.map(dot => ( -
- ))} -
- - {/* ── Room Content Area ── */} - {/* - This is where future animation will happen. - The `direction` in nav state tells which way to animate. - For now, we simply render the active room instantly. - */} + {/* ── Room Content — fills the entire shell ── */}
- {/* ── Left / Right Navigation Arrows ── */} + {/* ── Floating Room Header — absolutely positioned over content ── */} +
+
+ {/* Room label */} +
+ {meta.icon} + {meta.label} +
+ {/* Indicator dots */} +
+ {dots.map(dot => ( +
+ ))} +
+
+
+ + {/* ── Left / Right Navigation Arrows — absolutely positioned ── */}
); 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)]'; From c9525a0233bf471863a5e8abacc96f3ae0b810c9 Mon Sep 17 00:00:00 2001 From: filemon Date: Mon, 6 Apr 2026 02:05:19 -0300 Subject: [PATCH 5/7] Add carousel stability, sleep overlay, care room conditional actions, poop system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple polish and interaction improvements: 1. Carousel stability: Focused item area now uses fixed dimensions (w-20 h-16 / sm:w-24 sm:h-20) so the carousel never reflows when switching between items. Arrows stay perfectly stable. Preview slots also have fixed w-10 h-12 dimensions. 2. Desktop stat spacing: Arc spread widened further on desktop: 100/160/200 degrees (was 90/140/180) with radius up to 260px (was 220px). Stats now have much more breathing room on desktop while mobile stays unchanged. 3. Bedroom sleep button centered: Sleep/wake button is now in the center of the bottom bar instead of right-aligned, making the bedroom feel more focused. 4. Sleep dark overlay: When Blobbi is sleeping, a radial-gradient dark overlay covers the entire room shell (all rooms, not just bedroom). Uses z-20 with pointer-events-none so controls remain usable. Scoped to the room shell only — app header, bottom nav, and other app UI stay unaffected. 5. Renamed Bathroom → Care Room: Room label changed from 'Bathroom' to 'Care Room' with icon changed from bathtub to bandage emoji, since the room also contains medicine. 6. Care room conditional side actions: ItemCarousel now supports onFocusChange callback and meta field. When a hygiene item is focused: Towel (left) + Shower (right). When a medicine item is focused: Lollipop/Treat (left) + empty (right). Side buttons swap reactively as the user cycles through items. 7. Temporary local poop system: - poop-system.ts: ephemeral generation based on hunger >= 95 (overfeed, kitchen-only) and hours since last interaction (time-based, random room) - Generated once on mount, no persistence - Poop appears as floating emoji in affected rooms - Kitchen shows a Shovel button when any poop exists anywhere - Shovel mode: clicking poop removes it and awards 5 XP via toast - All rooms accept RoomPoopState prop for future expansion - BlobbiRoomContext extended with lastFeedTimestamp --- .../rooms/components/BlobbiCareRoom.tsx | 97 +++++++++++----- .../rooms/components/BlobbiClosetRoom.tsx | 3 +- .../rooms/components/BlobbiHatcheryRoom.tsx | 3 +- .../rooms/components/BlobbiHomeRoom.tsx | 3 +- .../rooms/components/BlobbiKitchenRoom.tsx | 55 +++++++-- .../rooms/components/BlobbiRestRoom.tsx | 18 +-- .../rooms/components/BlobbiRoomHero.tsx | 8 +- .../rooms/components/BlobbiRoomShell.tsx | 77 ++++++++++--- src/blobbi/rooms/components/ItemCarousel.tsx | 63 ++++++---- src/blobbi/rooms/lib/poop-system.ts | 109 ++++++++++++++++++ src/blobbi/rooms/lib/room-config.ts | 6 +- src/blobbi/rooms/lib/room-types.ts | 18 +++ src/pages/BlobbiPage.tsx | 3 + 13 files changed, 363 insertions(+), 100 deletions(-) create mode 100644 src/blobbi/rooms/lib/poop-system.ts diff --git a/src/blobbi/rooms/components/BlobbiCareRoom.tsx b/src/blobbi/rooms/components/BlobbiCareRoom.tsx index 137d7ab8..1da95b58 100644 --- a/src/blobbi/rooms/components/BlobbiCareRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiCareRoom.tsx @@ -1,16 +1,18 @@ // src/blobbi/rooms/components/BlobbiCareRoom.tsx /** - * BlobbiCareRoom — The bathroom / hygiene room. + * BlobbiCareRoom — Hygiene, care, and medicine room. * - * Bottom bar: Towel (left) | hygiene+medicine carousel (center) | Shower (right) + * Side actions are conditional on the currently focused carousel item: + * - Hygiene item focused: Towel (left) + Shower (right) + * - Medicine item focused: Lollipop (left) + empty (right) */ -import { useMemo } from 'react'; -import { ShowerHead } from 'lucide-react'; +import { useMemo, useState, useCallback } from 'react'; +import { ShowerHead, Candy } from 'lucide-react'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; -import type { BlobbiRoomContext } from '../lib/room-types'; +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'; @@ -18,6 +20,7 @@ import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; interface BlobbiCareRoomProps { ctx: BlobbiRoomContext; + poopState: RoomPoopState; } export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { @@ -37,16 +40,28 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { 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 })); + .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 })); + .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 (
@@ -54,19 +69,34 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { {!isActiveFloatingCompanion && (
- {/* Left — Towel */} - {towelItem ? ( - {towelItem.icon}} - label="Towel" - color="text-cyan-500" - glowHex="#06b6d4" - onClick={() => handleUseItemFromTab(towelItem.id)} - disabled={isDisabled} - loading={isUsingItem && usingItemId === towelItem.id} - /> + {/* Left — conditional: Towel (hygiene) or Lollipop (medicine) */} + {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={() => { + // Use lollipop as a comfort treat after medicine + // For now this triggers a small happiness boost via the direct action + handleUseItemFromTab(carouselEntries.find(e => e.meta === 'medicine')?.id ?? ''); + }} + disabled={isDisabled} + /> )} {/* Center carousel */} @@ -76,21 +106,26 @@ export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) { onUse={handleUseItemFromTab} activeItemId={isUsingItem ? usingItemId : null} disabled={isDisabled} + onFocusChange={handleFocusChange} />
- {/* Right — Shower */} - } - 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} - /> + {/* Right — conditional: Shower (hygiene) or empty (medicine) */} + {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 index 77326e33..51a928c0 100644 --- a/src/blobbi/rooms/components/BlobbiClosetRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiClosetRoom.tsx @@ -8,12 +8,13 @@ import { Shirt } from 'lucide-react'; -import type { BlobbiRoomContext } from '../lib/room-types'; +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) { diff --git a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx index 673d79a1..b0804574 100644 --- a/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx @@ -26,7 +26,7 @@ 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 } from '../lib/room-types'; +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'; @@ -49,6 +49,7 @@ function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: n interface BlobbiHatcheryRoomProps { ctx: BlobbiRoomContext; + poopState: RoomPoopState; } // ─── Component ──────────────────────────────────────────────────────────────── diff --git a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx index ce454850..c0db3e12 100644 --- a/src/blobbi/rooms/components/BlobbiHomeRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiHomeRoom.tsx @@ -16,7 +16,7 @@ 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 } from '../lib/room-types'; +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'; @@ -24,6 +24,7 @@ import { ItemCarousel, type CarouselEntry } from './ItemCarousel'; interface BlobbiHomeRoomProps { ctx: BlobbiRoomContext; + poopState: RoomPoopState; } export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) { diff --git a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx index 807130e5..ff2adc26 100644 --- a/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiKitchenRoom.tsx @@ -3,25 +3,29 @@ /** * BlobbiKitchenRoom — The feeding room. * - * Bottom bar: (empty left) | food carousel (center) | Fridge button (right) + * Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right) + * Poop appears as floating emoji in the room when present. */ import { useMemo, useState } from 'react'; -import { Refrigerator } from 'lucide-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 } from '../lib/room-types'; +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 }: BlobbiKitchenRoomProps) { +export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) { const { companion, profile, @@ -50,15 +54,52 @@ export function BlobbiKitchenRoom({ ctx }: BlobbiKitchenRoomProps) { }); }; + // Poop in this room + const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen'); + const anyPoopAnywhere = hasAnyPoop(poopState.poops); + return (
- + {/* ── Hero + Poop ── */} +
+ + + {/* Poop floating in the room */} + {kitchenPoops.map((poop, i) => ( + + ))} +
{!isActiveFloatingCompanion && (
- {/* Left — empty spacer matching button width */} -
+ {/* Left — Shovel (when poop exists anywhere) or empty */} + {anyPoopAnywhere ? ( + } + label={poopState.shovelMode ? 'Shoveling' : 'Shovel'} + color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'} + glowHex={poopState.shovelMode ? '#d97706' : '#78716c'} + onClick={() => poopState.setShovelMode(prev => !prev)} + /> + ) : ( +
+ )} {/* Center: food carousel */}
diff --git a/src/blobbi/rooms/components/BlobbiRestRoom.tsx b/src/blobbi/rooms/components/BlobbiRestRoom.tsx index 59b99acb..f56cd11c 100644 --- a/src/blobbi/rooms/components/BlobbiRestRoom.tsx +++ b/src/blobbi/rooms/components/BlobbiRestRoom.tsx @@ -3,18 +3,19 @@ /** * BlobbiRestRoom — The bedroom / rest room. * - * Bottom bar: (empty left) | (empty center) | Sleep/Wake (right) + * Bottom bar: Sleep/Wake button centered. */ import { Moon, Sun, Loader2 } from 'lucide-react'; -import type { BlobbiRoomContext } from '../lib/room-types'; +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) { @@ -36,15 +37,8 @@ export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) { {!isActiveFloatingCompanion && (
-
- {/* Left — empty */} -
- - {/* Center — empty */} -
- - {/* Right — Sleep / Wake */} - {!isEgg ? ( +
+ {!isEgg && ( - ) : ( -
)}
diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index eb8925c9..22e96202 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -196,10 +196,10 @@ function StatsCrown({ const count = allStats.length; const isSmall = heroWidth < 400; - // Mobile: tighter arc; Desktop: wider spread for breathing room + // Mobile: tighter arc; Desktop: much wider spread for breathing room const arcSpread = isSmall ? (count <= 2 ? 80 : count <= 3 ? 120 : 150) - : (count <= 2 ? 90 : count <= 3 ? 140 : 180); + : (count <= 2 ? 100 : count <= 3 ? 160 : 200); const arcHalf = arcSpread / 2; const angles = count === 1 ? [0] @@ -210,8 +210,8 @@ function StatsCrown({ {allStats.map((s, i) => { const angleDeg = angles[i]; const angleRad = (angleDeg * Math.PI) / 180; - // Mobile: tighter radius; Desktop: wider for more spread - const radius = Math.min(220, Math.max(100, (heroWidth - 340) / (640 - 340) * (220 - 100) + 100)); + // Mobile: tighter radius; Desktop: much wider for more spread + const radius = Math.min(260, Math.max(100, (heroWidth - 340) / (640 - 340) * (260 - 100) + 100)); const x = Math.sin(angleRad) * radius; const y = Math.cos(angleRad) * radius - radius; diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index f90e13cf..47978fd1 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -3,22 +3,17 @@ /** * BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard. * - * The shell renders the room as one continuous surface: - * - Room header (label + dots) floats absolutely over the room content - * - Room navigation arrows float absolutely on the sides - * - The room component fills the entire area - * - * This avoids the "stacked panels" look where header, content, and footer - * appear as separate background blocks. - * - * Future animation branch: - * - `direction` in nav state tells which way the user navigated. + * Manages: + * - Current room state + navigation + * - Sleep dark overlay (scoped to this shell only) + * - Ephemeral poop instances (local-only, no persistence) */ -import { useState, useCallback, useMemo } from 'react'; +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, @@ -28,7 +23,12 @@ import { getPreviousRoom, getRoomIndex, } from '../lib/room-config'; -import type { BlobbiRoomContext } from '../lib/room-types'; +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'; @@ -52,7 +52,7 @@ interface RoomNavState { // ─── Room Component Map ─────────────────────────────────────────────────────── -const ROOM_COMPONENTS: Record> = { +const ROOM_COMPONENTS: Record> = { care: BlobbiCareRoom, kitchen: BlobbiKitchenRoom, home: BlobbiHomeRoom, @@ -97,22 +97,63 @@ export function BlobbiRoomShell({ 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 ── */}
- +
- {/* ── Floating Room Header — absolutely positioned over content ── */} + {/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */} + {isSleeping && ( +
+ )} + + {/* ── Floating Room Header ── */}
- {/* Room label */}
{meta.icon} {meta.label}
- {/* Indicator dots */}
{dots.map(dot => (
- {/* ── Left / Right Navigation Arrows — absolutely positioned ── */} + {/* ── Left / Right Navigation Arrows ── */} - {/* Preview (prev) — desktop only, 3+ items */} + {/* Preview (prev) — desktop only, fixed slot */} {showPreviews && ( -
- {prevItem.icon} +
+
+ {prevItem.icon} +
)} - {/* Focused item */} + {/* Focused item — FIXED SIZE container so layout never shifts */} - {/* Preview (next) — desktop only, 3+ items */} + {/* Preview (next) — desktop only, fixed slot */} {showPreviews && ( -
- {nextItem.icon} +
+
+ {nextItem.icon} +
)} - {/* Right arrow */} + {/* Right arrow — fixed size */} ))}
+ {/* ── Bottom bar ── */} {!isActiveFloatingCompanion && (
- {/* Left — Shovel (when poop exists anywhere) or empty */} + {/* Left — Shovel (when poop exists) or spacer */} {anyPoopAnywhere ? ( } - label={poopState.shovelMode ? 'Shoveling' : 'Shovel'} + 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' : ''} /> ) : (
diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index 22e96202..bb46a142 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -196,10 +196,10 @@ function StatsCrown({ const count = allStats.length; const isSmall = heroWidth < 400; - // Mobile: tighter arc; Desktop: much wider spread for breathing room + // Mobile: tighter arc (good already); Desktop: very wide for generous spacing const arcSpread = isSmall ? (count <= 2 ? 80 : count <= 3 ? 120 : 150) - : (count <= 2 ? 100 : count <= 3 ? 160 : 200); + : (count <= 2 ? 120 : count <= 3 ? 190 : 230); const arcHalf = arcSpread / 2; const angles = count === 1 ? [0] @@ -210,8 +210,8 @@ function StatsCrown({ {allStats.map((s, i) => { const angleDeg = angles[i]; const angleRad = (angleDeg * Math.PI) / 180; - // Mobile: tighter radius; Desktop: much wider for more spread - const radius = Math.min(260, Math.max(100, (heroWidth - 340) / (640 - 340) * (260 - 100) + 100)); + // Mobile: compact radius; Desktop: very wide for generous indicator spacing + const radius = Math.min(300, Math.max(100, (heroWidth - 340) / (640 - 340) * (300 - 100) + 100)); const x = Math.sin(angleRad) * radius; const y = Math.cos(angleRad) * radius - radius; diff --git a/src/blobbi/rooms/components/ItemCarousel.tsx b/src/blobbi/rooms/components/ItemCarousel.tsx index 17d0a5b3..2cd84a2d 100644 --- a/src/blobbi/rooms/components/ItemCarousel.tsx +++ b/src/blobbi/rooms/components/ItemCarousel.tsx @@ -3,8 +3,11 @@ /** * ItemCarousel — Single-focus carousel for room items. * - * The focused item area has a fixed width/height so the arrows and - * side previews never shift when cycling between 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 @@ -55,23 +58,24 @@ export function ItemCarousel({ const prev = useCallback(() => { setIndex(i => { - const next = (i - 1 + count) % count; - onFocusChange?.(items[next]); - return next; + const n = (i - 1 + count) % count; + onFocusChange?.(items[n]); + return n; }); }, [count, items, onFocusChange]); const next = useCallback(() => { setIndex(i => { - const next = (i + 1) % count; - onFocusChange?.(items[next]); - return next; + 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

); @@ -85,7 +89,7 @@ export function ItemCarousel({ return (
- {/* Left arrow — fixed size */} + {/* Left arrow — fixed 28/32px */} - {/* Preview (prev) — desktop only, fixed slot */} + {/* Preview (prev) — desktop only, fixed 40x48px slot */} {showPreviews && ( -
-
- {prevItem.icon} +
+
+ {prevItem.icon}
)} - {/* Focused item — FIXED SIZE container so layout never shifts */} + {/* Focused item — FIXED 80x72 / 96x88 container, never resizes */} - {/* Preview (next) — desktop only, fixed slot */} + {/* Preview (next) — desktop only, fixed 40x48px slot */} {showPreviews && ( -
-
- {nextItem.icon} +
+
+ {nextItem.icon}
)} - {/* Right arrow — fixed size */} + {/* Right arrow — fixed 28/32px */}