Compare commits

...

7 Commits

Author SHA1 Message Date
filemon 286572777b Fix stat crown instability on room switch; hide Closet from navigation
Stat crown fix — root cause and solution:
The heroWidth measurement used a useRef + useEffect([], ...) pattern
that created a ResizeObserver once on mount. When switching rooms,
BlobbiRoomHero unmounts and remounts inside the new room component,
creating a new DOM element. But the ResizeObserver was still watching
the old detached element, so heroWidth went stale (often falling to
the default 375px or reading 0 from the detached node).

Fix: replaced useRef + useEffect with a callback ref (heroCallbackRef)
that disconnects the old ResizeObserver and creates a new one every
time the ref is assigned to a new element. This ensures heroWidth is
always measured from the currently-mounted hero container.

Additionally, the arc spread and radius values were too extreme on
desktop (120/190/230 degrees, 300px radius) which made the stats
fly far apart. Now using balanced moderate values:
- Mobile: 80/110/140 degrees
- Desktop: 90/130/160 degrees
- Radius: smooth 110px → 200px interpolation based on container width

This produces a stable, moderate crown that looks the same on initial
render and after room switches, with desktop having slightly more
breathing room than mobile without being exaggerated.

Closet hidden:
Removed 'closet' from DEFAULT_ROOM_ORDER with a comment explaining
how to re-enable it. The BlobbiClosetRoom component, its type in
BlobbiRoomId, its ROOM_META entry, and its ROOM_COMPONENTS mapping
all remain intact — only the navigation sequence excludes it.
2026-04-06 02:34:57 -03:00
filemon 7fd4b7ab69 Polish carousel stability, desktop stat spacing, poop UX, and visual harmony
Carousel stability:
- Focused item container is now overflow-hidden with explicit dimensions
  (w-20 h-[4.5rem] / sm:w-24 sm:h-[5.5rem]) so no content can push
  the layout wider/taller
- Label uses a fixed max-width (w-16 sm:w-20) with text-center truncate
  so changing items never shifts the arrows
- Preview slots also have overflow-hidden
- Empty state matches the populated carousel height

Desktop stat spacing (mobile unchanged):
- Arc spread widened to 120/190/230 degrees (was 100/160/200)
- Radius scaled up to 300px max (was 260px)
- This gives stat indicators much more breathing room on desktop
  without affecting the mobile layout at all

Poop placement refinement:
- Pre-computed safe-zone positions in lower-left and lower-right
  corners, avoiding the central Blobbi hero area
- Positions stored on each PoopInstance so they're stable
- Shovel mode: active state shows ring indicator around the button
  and label changes to 'Done'; poop gets drop-shadow-lg when
  hoverable; hover:scale-150 for clearer interaction feedback

Care room fix:
- Treat button no longer calls invalid 'interact' DirectAction;
  uses a small food item from shop instead
- Both left/right slots always render the same fixed width
  (RoomActionButton or w-14/w-20 spacer) so switching between
  hygiene/medicine items never causes layout shift

Visual harmony:
- Closet room bottom bar now uses the same flex items-center
  justify-center horizontal layout instead of a different
  flex-col structure, matching the rhythm of all other rooms
2026-04-06 02:28:53 -03:00
filemon c9525a0233 Add carousel stability, sleep overlay, care room conditional actions, poop system
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
2026-04-06 02:05:19 -03:00
filemon 0b9cd5e1cb 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.
2026-04-06 01:32:43 -03:00
filemon a2600d1caa Fix room layout proportions, responsive sizing, and visual composition
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.
2026-04-06 01:15:11 -03:00
filemon 0722d900a2 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.
2026-04-06 01:02:14 -03:00
filemon 918814371c 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
2026-04-06 00:46:04 -03:00
16 changed files with 2518 additions and 1157 deletions
@@ -0,0 +1,137 @@
// src/blobbi/rooms/components/BlobbiCareRoom.tsx
/**
* BlobbiCareRoom — Hygiene, care, and medicine room.
*
* Side actions depend on the currently focused carousel item:
* - Hygiene focused: Towel (left) + Shower (right)
* - Medicine focused: Treat (left) + spacer (right)
*
* Both left and right slots always render the same fixed width
* so the bottom bar never shifts when switching item types.
*/
import { useMemo, useState, useCallback } from 'react';
import { ShowerHead, Candy } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiCareRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
const {
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const hygieneItems = useMemo(() =>
getLiveShopItems().filter(i => i.type === 'hygiene'),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
// Carousel: hygiene (except towel) + medicine, each tagged with meta
const carouselEntries = useMemo<CarouselEntry[]>(() => {
const hygiene = getLiveShopItems()
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'hygiene' }));
const medicine = getLiveShopItems()
.filter(i => i.type === 'medicine')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'medicine' }));
return [...hygiene, ...medicine];
}, []);
// Track the type of the currently focused carousel item
const [focusedMeta, setFocusedMeta] = useState<string>(
carouselEntries[0]?.meta ?? 'hygiene',
);
const handleFocusChange = useCallback((entry: CarouselEntry) => {
setFocusedMeta(entry.meta ?? 'hygiene');
}, []);
const isHygieneFocused = focusedMeta === 'hygiene';
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left slot — always same width (button or spacer) */}
{isHygieneFocused ? (
towelItem ? (
<RoomActionButton
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
onClick={() => handleUseItemFromTab(towelItem.id)}
disabled={isDisabled}
loading={isUsingItem && usingItemId === towelItem.id}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)
) : (
<RoomActionButton
icon={<Candy className="size-7 sm:size-9" />}
label="Treat"
color="text-pink-400"
glowHex="#f472b6"
onClick={() => {
// Comfort treat — use a small food item as a reward after medicine
const treat = getLiveShopItems().find(i => i.type === 'food');
if (treat) handleUseItemFromTab(treat.id);
}}
disabled={isDisabled}
/>
)}
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
onFocusChange={handleFocusChange}
/>
</div>
{/* Right slot — always same width (button or spacer) */}
{isHygieneFocused ? (
<RoomActionButton
icon={<ShowerHead className="size-7 sm:size-9" />}
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}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
// src/blobbi/rooms/components/BlobbiClosetRoom.tsx
/**
* BlobbiClosetRoom — Placeholder room for wardrobe / accessories.
*
* Uses the same bottom bar structure as other rooms for visual consistency,
* with a centered placeholder message.
*/
import { Shirt } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
interface BlobbiClosetRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Bottom bar — same structure as other rooms */}
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center gap-2 py-1">
<Shirt className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground/40 font-medium">
Closet coming soon
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,542 @@
// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx
/**
* BlobbiHatcheryRoom — Incubation / evolution / progression room.
*
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Bottom center: main start/stop hatching or evolution button
* - Bottom right: quests/tasks button
* - Bottom left: Blobbis list/selector button
*
* Reuses existing hatch/evolve/missions logic from BlobbiPage.
*/
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Loader2, Sparkles, Egg, Target, Check, ListTodo,
Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic,
Pill, Utensils, Plus, Footprints, ExternalLink, Theater,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { openUrl } from '@/lib/downloadFile';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { isLocalhostDev } from '@/blobbi/dev';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
// ─── Helper: companionNeedsCare (reused from BlobbiPage) ──────────────────────
const CARE_THRESHOLD = 40;
function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): boolean {
const { stats } = companion;
return (
(stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) ||
(stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) ||
(stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) ||
(stats.health !== undefined && stats.health < CARE_THRESHOLD)
);
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiHatcheryRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
const {
companion,
companions,
selectedD,
profile,
isEgg,
isBaby,
isIncubating,
isEvolvingState,
canStartIncubation,
canStartEvolution,
isStartingIncubation,
isStartingEvolution,
isStoppingIncubation,
isStoppingEvolution,
isHatching,
isEvolving,
hatchTasks,
evolveTasks,
onStartIncubation,
onStartEvolution,
onStopIncubation,
onStopEvolution,
onEvolve,
setShowPostModal,
setShowHatchCeremony,
isActiveFloatingCompanion,
// Blobbi selector
onSelectBlobbi,
blobbiNaddr,
// Adoption
setShowAdoptionFlow,
// Daily missions
dailyMissions,
onClaimReward,
isClaimingReward,
// DEV
setShowDevEditor,
setShowEmotionPanel,
} = ctx;
const navigate = useNavigate();
// Side panels
const [showQuestsPanel, setShowQuestsPanel] = useState(false);
const [showBlobbisPanel, setShowBlobbisPanel] = useState(false);
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks;
const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted;
const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading;
const completedCount = tasks.filter(t => t.completed).length;
const totalCount = tasks.length;
const { missions } = dailyMissions;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Blobbis selector */}
<RoomActionButton
icon={<Egg className="size-7 sm:size-9" />}
label="Blobbis"
color="text-primary"
glowHex="var(--primary)"
onClick={() => setShowBlobbisPanel(true)}
badge={companions.length > 1 ? (
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
{companions.length}
</span>
) : undefined}
/>
{/* Center — Main hatch/evolve action */}
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
{/* Active process: Hatch/Evolve CTA or progress */}
{hasActiveProcess && allCompleted && !isTasksLoading && (
<button
onClick={isIncubating ? () => setShowHatchCeremony(true) : onEvolve}
disabled={isProcessBusy}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isProcessBusy && 'opacity-50 pointer-events-none',
)}
style={{
background: isIncubating
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isHatching || isEvolving) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<span className="text-lg">{isIncubating ? '\uD83D\uDC23' : '\u2728'}</span>
)}
<span>{(isHatching || isEvolving) ? (isIncubating ? 'Hatching...' : 'Evolving...') : (isIncubating ? 'Hatch!' : 'Evolve!')}</span>
</button>
)}
{hasActiveProcess && !allCompleted && !isTasksLoading && (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="size-4 text-primary" />
<span className="font-medium">{isIncubating ? 'Hatching' : 'Evolving'}</span>
<span className="text-xs tabular-nums">{completedCount}/{totalCount}</span>
</div>
{/* Progress bar */}
<div className="w-40 h-1.5 rounded-full bg-muted/30 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
background: isIncubating
? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(90deg, #8b5cf6, #ec4899)',
}}
/>
</div>
</div>
)}
{hasActiveProcess && isTasksLoading && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
{/* No active process — show start button */}
{!hasActiveProcess && (canStartIncubation || canStartEvolution) && (
<button
onClick={() => canStartIncubation ? onStartIncubation('start') : onStartEvolution()}
disabled={isStartingIncubation || isStartingEvolution}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
(isStartingIncubation || isStartingEvolution) && 'opacity-50 pointer-events-none',
)}
style={{
background: canStartIncubation
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isStartingIncubation || isStartingEvolution) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Sparkles className="size-5" />
)}
<span>{canStartIncubation ? 'Begin Hatching' : 'Begin Evolution'}</span>
</button>
)}
{!hasActiveProcess && !canStartIncubation && !canStartEvolution && (
<p className="text-xs text-muted-foreground/50">No journey available</p>
)}
{/* Stop process link */}
{hasActiveProcess && !isTasksLoading && (
<button
onClick={isIncubating ? onStopIncubation : onStopEvolution}
disabled={isProcessBusy}
className="text-[11px] text-muted-foreground/40 hover:text-destructive/60 transition-colors"
>
{(isStoppingIncubation || isStoppingEvolution) ? 'Stopping...' : `Stop ${isIncubating ? 'incubation' : 'evolution'}`}
</button>
)}
</div>
{/* Right — Quests/Tasks */}
<RoomActionButton
icon={<ListTodo className="size-7 sm:size-9" />}
label="Quests"
color="text-amber-500"
glowHex="#f59e0b"
onClick={() => setShowQuestsPanel(true)}
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
{totalCount - completedCount}
</span>
) : undefined}
/>
</div>
</div>
)}
{/* ── Quests Sheet ── */}
<Sheet open={showQuestsPanel} onOpenChange={setShowQuestsPanel}>
<SheetContent side="right" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Target className="size-4" />
Quests
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4 space-y-4">
{/* Journey tasks */}
{hasActiveProcess && (
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{isIncubating ? 'Hatching Journey' : 'Evolution Journey'}
</h3>
{isTasksLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!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 (
<button
key={task.id}
onClick={isActionable ? handleAction : undefined}
disabled={!isActionable}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all text-left',
isActionable && 'hover:bg-accent/50 active:scale-[0.98] cursor-pointer',
!isActionable && 'cursor-default',
)}
>
<QuestTaskIcon taskId={task.id} completed={task.completed} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', task.completed && 'text-muted-foreground line-through')}>{task.name}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-1">{task.description}</p>
</div>
{task.required > 1 && !task.completed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{task.current}/{task.required}</span>
)}
</button>
);
})}
</div>
)}
{!hasActiveProcess && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-6 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Start a journey to unlock tasks</p>
</div>
)}
{/* Daily Bounties */}
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Daily Bounties
</h3>
{dailyMissions.noMissionsAvailable && (
<div className="flex flex-col items-center gap-2 py-4 text-center">
<Egg className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Hatch your Blobbi to unlock bounties</p>
</div>
)}
{!dailyMissions.noMissionsAvailable && missions.map(mission => {
const canClaim = mission.completed && !mission.claimed;
return (
<div
key={mission.id}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all',
canClaim && 'bg-amber-500/[0.06]',
)}
>
<DailyMissionIcon action={mission.action} claimed={mission.claimed} canClaim={canClaim} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', mission.claimed && 'text-muted-foreground line-through')}>{mission.title}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{mission.description}</p>
</div>
{!mission.claimed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{mission.currentCount}/{mission.requiredCount}</span>
)}
{canClaim && (
<button
onClick={() => onClaimReward(mission.id)}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
)}
</div>
);
})}
{/* Bonus row */}
{!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && (
<div className="w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl bg-amber-500/[0.06]">
<div className="size-8 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Sparkles className="size-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">Daily Champion</p>
<p className="text-[10px] text-muted-foreground">All missions complete!</p>
</div>
<button
onClick={() => onClaimReward('bonus_daily_complete')}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
</div>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
{/* ── Blobbis Sheet ── */}
<Sheet open={showBlobbisPanel} onOpenChange={setShowBlobbisPanel}>
<SheetContent side="left" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Egg className="size-4" />
Your Blobbis
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4">
{/* Blobbi grid */}
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 py-3">
{companions.map((c) => {
const isSelected = c.d === selectedD;
const isCompanion = c.d === profile?.currentCompanion;
return (
<button
key={c.d}
onClick={() => { onSelectBlobbi(c.d); setShowBlobbisPanel(false); }}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-200',
'hover:-translate-y-1 hover:scale-105 active:scale-95',
)}
>
<div className="relative">
<div className={cn(
'rounded-full p-1 transition-all',
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
)}>
<BlobbiStageVisual companion={c} size="sm" />
</div>
{isCompanion && (
<div className="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
<Footprints className="size-3 text-emerald-500" />
</div>
)}
{companionNeedsCare(c) && !isCompanion && (
<div className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-amber-500 flex items-center justify-center">
<span className="text-[8px] text-white font-bold">!</span>
</div>
)}
</div>
{c.stage !== 'egg' && (
<span className={cn(
'text-[11px] font-medium max-w-[4.5rem] truncate',
isSelected ? 'text-foreground' : 'text-muted-foreground',
)}>
{c.name}
</span>
)}
</button>
);
})}
{/* Adopt + button */}
<button
onClick={() => { setShowBlobbisPanel(false); setShowAdoptionFlow(true); }}
className="flex flex-col items-center gap-1 transition-all duration-200 hover:-translate-y-1 hover:scale-105 active:scale-95"
>
<div className="size-14 rounded-full flex items-center justify-center" style={{
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, currentColor 10%, transparent), color-mix(in srgb, currentColor 3%, transparent) 70%)',
}}>
<Plus className="size-6 text-muted-foreground/60" />
</div>
<span className="text-[11px] font-medium text-muted-foreground/60">Adopt</span>
</button>
</div>
{/* Quick actions row */}
<div className="flex items-center justify-center gap-6 pt-3 border-t mt-3">
<Link
to={`/${blobbiNaddr}`}
onClick={() => setShowBlobbisPanel(false)}
className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="size-5" />
<span className="text-[10px]">View</span>
</Link>
{/* DEV tools */}
{isLocalhostDev() && (
<>
{companion.stage !== 'adult' && (
<button
onClick={() => { setShowBlobbisPanel(false); if (isEgg) { setShowHatchCeremony(true); } else { onEvolve(); } }}
disabled={isHatching || isEvolving}
className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors disabled:opacity-40"
>
<Sparkles className="size-5" />
<span className="text-[10px]">{companion.stage === 'egg' ? 'Hatch' : 'Evolve'}</span>
</button>
)}
<button onClick={() => { setShowBlobbisPanel(false); setShowDevEditor(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Wrench className="size-5" />
<span className="text-[10px]">Editor</span>
</button>
<button onClick={() => { setShowBlobbisPanel(false); setShowEmotionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Theater className="size-5" />
<span className="text-[10px]">Emote</span>
</button>
</>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}
// ─── 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 <Sparkles className={iconClass} />;
case 'color_moments': return <Droplets className={iconClass} />;
case 'create_posts': return <Target className={iconClass} />;
case 'interactions': return <Heart className={iconClass} />;
case 'edit_profile': return <Wrench className={iconClass} />;
case 'maintain_stats': return <Zap className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
completed ? 'bg-emerald-500/15 text-emerald-500' : 'bg-muted/60 text-muted-foreground',
)}>
{completed ? <Check className="size-4" /> : icon}
</div>
);
}
// ─── 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 <Heart className={iconClass} />;
case 'feed': return <Utensils className={iconClass} />;
case 'clean': return <Droplets className={iconClass} />;
case 'sleep': return <Moon className={iconClass} />;
case 'take_photo': return <Camera className={iconClass} />;
case 'sing': return <Mic className={iconClass} />;
case 'play_music': return <Music className={iconClass} />;
case 'medicine': return <Pill className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
claimed ? 'bg-emerald-500/15 text-emerald-500' : canClaim ? 'bg-amber-500/15 text-amber-500' : 'bg-muted/60 text-muted-foreground',
)}>
{claimed ? <Check className="size-4" /> : icon}
</div>
);
}
@@ -0,0 +1,162 @@
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
/**
* BlobbiHomeRoom — The main living / play room.
*
* Layout:
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
* - Inline activity (music player, sing card) above the bottom bar
*
* Sleep/wake has been moved to BlobbiRestRoom.
*/
import { useMemo } from 'react';
import { Camera, Footprints, Music, Mic } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiHomeRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
const {
isActiveFloatingCompanion,
setShowPhotoModal,
isCurrentCompanion,
canBeCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
isUsingItem,
usingItemId,
handleUseItemFromTab,
handleDirectAction,
isDirectActionPending,
inlineActivity,
handleConfirmSing,
handleCloseInlineActivity,
handleMusicPlaybackStart,
handleMusicPlaybackStop,
handleSingRecordingStart,
handleSingRecordingStop,
handleChangeTrack,
isPublishing,
actionInProgress,
} = ctx;
// Build carousel entries: toys + music + sing
const carouselItems = useMemo<CarouselEntry[]>(() => {
const toys = getLiveShopItems()
.filter(i => i.type === 'toy')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
const actions: CarouselEntry[] = [
{
id: '__action_music',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
label: 'Music',
},
{
id: '__action_sing',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
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 (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero (Blobbi + stats) ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Inline Activity Area (music/sing) ── */}
{inlineActivity.type === 'music' && (
<div className="px-4 sm:px-6 pb-2">
<InlineMusicPlayer
selection={inlineActivity.selection}
onChangeTrack={handleChangeTrack}
onClose={handleCloseInlineActivity}
onPlaybackStart={handleMusicPlaybackStart}
onPlaybackStop={handleMusicPlaybackStop}
isPublished={inlineActivity.isPublished}
isPublishing={isDirectActionPending}
/>
</div>
)}
{inlineActivity.type === 'sing' && (
<div className="px-4 sm:px-6 pb-2">
<InlineSingCard
onConfirm={handleConfirmSing}
onClose={handleCloseInlineActivity}
onRecordingStart={handleSingRecordingStart}
onRecordingStop={handleSingRecordingStop}
isPublishing={isDirectActionPending}
/>
</div>
)}
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Photo */}
<RoomActionButton
icon={<Camera className="size-7 sm:size-9" />}
label="Photo"
color="text-pink-500"
glowHex="#ec4899"
onClick={() => setShowPhotoModal(true)}
/>
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselItems}
onUse={handleCarouselUse}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Companion toggle */}
{canBeCompanion ? (
<RoomActionButton
icon={<Footprints className="size-7 sm:size-9" />}
label={isCurrentCompanion ? 'With you' : 'Take along'}
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
loading={isUpdatingCompanion}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx
/**
* BlobbiKitchenRoom — The feeding room.
*
* Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right)
* Poop appears at pre-computed safe positions in the lower corners.
*/
import { useMemo, useState } from 'react';
import { Refrigerator, Shovel } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiKitchenRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) {
const {
companion,
profile,
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const [showFridge, setShowFridge] = useState(false);
const foodEntries = useMemo<CarouselEntry[]>(() =>
getLiveShopItems()
.filter(i => i.type === 'food')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name })),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const handleFridgeUseItem = (itemId: string) => {
if (isUsingItem) return;
ctx.onUseItem(itemId, 'feed').finally(() => {
setShowFridge(false);
});
};
const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen');
const anyPoopAnywhere = hasAnyPoop(poopState.poops);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero + Poop layer ── */}
<div className="relative flex-1 min-h-0 flex flex-col">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Poop at pre-computed safe positions */}
{kitchenPoops.map((poop) => (
<button
key={poop.id}
onClick={() => poopState.shovelMode && poopState.onRemovePoop(poop.id)}
className={cn(
'absolute z-10 transition-all duration-300',
poopState.shovelMode
? 'cursor-pointer hover:scale-150 active:scale-75'
: 'pointer-events-none',
)}
style={{
bottom: `${poop.position.bottom}%`,
left: `${poop.position.left}%`,
}}
>
<span className={cn(
'text-2xl sm:text-3xl block',
poopState.shovelMode && 'drop-shadow-lg',
)}>
{'💩'}
</span>
</button>
))}
</div>
{/* ── Bottom bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Shovel (when poop exists) or spacer */}
{anyPoopAnywhere ? (
<RoomActionButton
icon={<Shovel className="size-7 sm:size-9" />}
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' : ''}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
{/* Center: food carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={foodEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Right — Fridge */}
<RoomActionButton
icon={<Refrigerator className="size-7 sm:size-9" />}
label="Fridge"
color="text-orange-500"
glowHex="#f97316"
onClick={() => setShowFridge(true)}
disabled={isDisabled}
/>
</div>
</div>
)}
{showFridge && (
<BlobbiActionInventoryModal
open={showFridge}
onOpenChange={setShowFridge}
action="feed"
companion={companion}
profile={profile}
onUseItem={handleFridgeUseItem}
onOpenShop={() => setShowFridge(false)}
isUsingItem={isUsingItem}
usingItemId={usingItemId}
/>
)}
</div>
);
}
@@ -0,0 +1,62 @@
// src/blobbi/rooms/components/BlobbiRestRoom.tsx
/**
* BlobbiRestRoom — The bedroom / rest room.
*
* Bottom bar: Sleep/Wake button centered.
*/
import { Moon, Sun, Loader2 } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
interface BlobbiRestRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
const {
isEgg,
isSleeping,
onRest,
actionInProgress,
isPublishing,
isUsingItem,
isActiveFloatingCompanion,
} = ctx;
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center">
{!isEgg && (
<RoomActionButton
icon={
actionInProgress === 'rest'
? <Loader2 className="size-7 sm:size-9 animate-spin" />
: isSleeping
? <Sun className="size-7 sm:size-9" />
: <Moon className="size-7 sm:size-9" />
}
label={isSleeping ? 'Wake up' : 'Sleep'}
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'}
onClick={onRest}
disabled={isDisabled}
/>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,284 @@
// src/blobbi/rooms/components/BlobbiRoomHero.tsx
/**
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
*
* This component does NOT clip or constrain the visual — it simply fills
* available flex space and centers the Blobbi + stats within it.
* The room owns the full-height surface; this just provides content.
*
* Top padding accounts for the floating room header overlay (~40px).
*/
import { useMemo } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { cn } from '@/lib/utils';
import type { BlobbiRoomContext } from '../lib/room-types';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
happiness: 'yellow',
health: 'green',
hygiene: 'blue',
energy: 'violet',
};
const STAT_COLORS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
violet: '#8b5cf6',
};
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils,
happiness: Gamepad2,
health: Heart,
hygiene: Droplets,
energy: Zap,
};
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiRoomHeroProps {
ctx: BlobbiRoomContext;
className?: string;
hideStats?: boolean;
hideName?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) {
const {
companion,
currentStats,
isSleeping,
isEgg,
statusRecipe,
statusRecipeLabel,
effectiveEmotion,
hasDevOverride,
blobbiReaction,
isActiveFloatingCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
heroRef,
heroWidth,
} = ctx;
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
<button
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Footprints className="size-4" />
)}
<span>Bring {companion.name} home</span>
</button>
</div>
);
}
return (
<div
ref={heroRef}
className={cn(
// No overflow-hidden — let the room own the visual surface.
// pt-10 creates clearance for the floating room header overlay.
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
className,
)}
>
<div className="relative flex flex-col items-center">
{/* Stats crown */}
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
{/* Blobbi visual */}
<div
className="relative transition-all duration-500"
style={!isSleeping ? {
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}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
</div>
{/* Blobbi Name */}
{!hideName && !isEgg && (
<h2
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h2>
)}
</div>
</div>
);
}
// ─── Stats Crown ──────────────────────────────────────────────────────────────
function StatsCrown({
companion,
currentStats,
heroWidth,
}: {
companion: BlobbiRoomContext['companion'];
currentStats: BlobbiRoomContext['currentStats'];
heroWidth: number;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
const count = allStats.length;
const isSmall = heroWidth < 400;
// Balanced arc: mobile is compact, desktop has moderate breathing room.
// These values produce a stable crown with no dramatic changes between
// 375px (mobile) and 640px+ (desktop) — a smooth interpolation.
const arcSpread = isSmall
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
? [0]
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
// Smooth interpolation: 110px at 340px width → 200px at 640px+ width
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
style={{
transform: `translate(-50%, 0)`,
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator
stat={s.stat}
value={s.value}
color={s.color}
status={s.status}
/>
</div>
);
})}
</div>
);
}
// ─── 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 (
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/rooms/components/BlobbiRoomShell.tsx
/**
* BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard.
*
* Manages:
* - Current room state + navigation
* - Sleep dark overlay (scoped to this shell only)
* - Ephemeral poop instances (local-only, no persistence)
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from '@/hooks/useToast';
import {
type BlobbiRoomId,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from '../lib/room-config';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import {
generateInitialPoops,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
import { BlobbiHomeRoom } from './BlobbiHomeRoom';
import { BlobbiKitchenRoom } from './BlobbiKitchenRoom';
import { BlobbiCareRoom } from './BlobbiCareRoom';
import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom';
import { BlobbiRestRoom } from './BlobbiRestRoom';
import { BlobbiClosetRoom } from './BlobbiClosetRoom';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiRoomShellProps {
ctx: BlobbiRoomContext;
roomOrder?: BlobbiRoomId[];
initialRoom?: BlobbiRoomId;
}
interface RoomNavState {
current: BlobbiRoomId;
direction: 'left' | 'right' | null;
}
// ─── Room Component Map ───────────────────────────────────────────────────────
const ROOM_COMPONENTS: Record<BlobbiRoomId, React.ComponentType<{ ctx: BlobbiRoomContext; poopState: RoomPoopState }>> = {
care: BlobbiCareRoom,
kitchen: BlobbiKitchenRoom,
home: BlobbiHomeRoom,
hatchery: BlobbiHatcheryRoom,
rest: BlobbiRestRoom,
closet: BlobbiClosetRoom,
};
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomShell({
ctx,
roomOrder = DEFAULT_ROOM_ORDER,
initialRoom = DEFAULT_INITIAL_ROOM,
}: BlobbiRoomShellProps) {
const [nav, setNav] = useState<RoomNavState>({
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
direction: null,
});
const goRight = useCallback(() => {
setNav(prev => ({
current: getNextRoom(prev.current, roomOrder),
direction: 'right',
}));
}, [roomOrder]);
const goLeft = useCallback(() => {
setNav(prev => ({
current: getPreviousRoom(prev.current, roomOrder),
direction: 'left',
}));
}, [roomOrder]);
const meta = ROOM_META[nav.current];
const roomIndex = getRoomIndex(nav.current, roomOrder);
const RoomComponent = ROOM_COMPONENTS[nav.current];
const dots = useMemo(() => roomOrder.map((id, i) => ({
id,
active: i === roomIndex,
label: ROOM_META[id].label,
})), [roomOrder, roomIndex]);
const isSleeping = ctx.isSleeping;
// ─── Poop system (ephemeral, local-only) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
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 (
<div className="flex flex-col flex-1 min-h-0 relative">
{/* ── Room Content — fills the entire shell ── */}
<div className="flex-1 min-h-0 flex flex-col relative">
<RoomComponent ctx={ctx} poopState={poopState} />
</div>
{/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */}
{isSleeping && (
<div
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
/>
)}
{/* ── Floating Room Header ── */}
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
<div className="flex flex-col items-center pt-2 pb-1">
<div className="flex items-center gap-1.5 pointer-events-auto">
<span className="text-sm">{meta.icon}</span>
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{dots.map(dot => (
<div
key={dot.id}
className={cn(
'rounded-full transition-all duration-300',
dot.active
? 'w-4 h-1 bg-primary'
: 'w-1 h-1 bg-muted-foreground/20',
)}
title={dot.label}
/>
))}
</div>
</div>
</div>
{/* ── Left / Right Navigation Arrows ── */}
<button
onClick={goLeft}
className={cn(
'absolute left-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getPreviousRoom(nav.current, roomOrder)].label}`}
>
<ChevronLeft className="size-5" />
</button>
<button
onClick={goRight}
className={cn(
'absolute right-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getNextRoom(nav.current, roomOrder)].label}`}
>
<ChevronRight className="size-5" />
</button>
</div>
);
}
@@ -0,0 +1,166 @@
// src/blobbi/rooms/components/ItemCarousel.tsx
/**
* ItemCarousel — Single-focus carousel for room items.
*
* Layout stability guarantees:
* - The entire carousel width is deterministic (arrows + previews + focus slot)
* - Focused item uses a fixed-size container with overflow-hidden
* - Label is clamped to a fixed max-width and single line
* - Switching items never causes reflow or arrow movement
*
* Mobile: focused item only + compact arrows (no prev/next previews)
* Desktop: focused item + translucent prev/next previews + arrows
*/
import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CarouselEntry {
id: string;
/** Emoji string or ReactNode rendered at large size */
icon: React.ReactNode;
label: string;
/** Optional metadata attached to the entry (e.g. item type) */
meta?: string;
}
interface ItemCarouselProps {
items: CarouselEntry[];
/** Called when the user taps the focused item */
onUse: (id: string) => void;
/** Item id currently being used (shows spinner) */
activeItemId?: string | null;
/** Whether any action is in progress */
disabled?: boolean;
/** Called when the focused item changes (for conditional side actions) */
onFocusChange?: (entry: CarouselEntry) => void;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ItemCarousel({
items,
onUse,
activeItemId,
disabled,
onFocusChange,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const count = items.length;
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
if (count === 0) {
return (
// Empty state matches the height of a populated carousel
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center', className)}>
{/* Left arrow — fixed 28/32px */}
<button
onClick={prev}
disabled={disabled}
className={cn(
'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',
)}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
{/* Preview (prev) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{prevItem.icon}</span>
</div>
</div>
)}
{/* Focused item — FIXED 80x72 / 96x88 container, never resizes */}
<button
onClick={() => onUse(current.id)}
disabled={disabled}
className={cn(
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
'transition-colors duration-200',
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-4xl sm:text-5xl leading-none">
{current.icon}
</span>
{/* Label: fixed max-width, single line, ellipsis */}
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
{current.label}
</span>
{isThisActive && (
<Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />
)}
</button>
{/* Preview (next) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{nextItem.icon}</span>
</div>
</div>
)}
{/* Right arrow — fixed 28/32px */}
<button
onClick={next}
disabled={disabled}
className={cn(
'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',
)}
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
@@ -0,0 +1,79 @@
// src/blobbi/rooms/components/RoomActionButton.tsx
/**
* RoomActionButton — Unified circular action button for all rooms.
*
* Responsive sizing:
* - Mobile: size-14 circle, size-7 icons
* - Desktop (sm+): size-20 circle, size-9 icons
*
* Matches the soft radial glow of the original Photo / Companion buttons
* but at a smaller scale so the bottom bar feels proportional on mobile.
*/
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RoomActionButtonProps {
/** Lucide icon or emoji element rendered inside the circle */
icon: React.ReactNode;
/** Small text label below the circle */
label: string;
/** CSS colour class applied to the icon (e.g. 'text-pink-500') */
color: string;
/** Hex colour used for the radial glow background */
glowHex: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
/** Optional badge content rendered at top-right of the circle */
badge?: React.ReactNode;
className?: string;
}
export function RoomActionButton({
icon,
label,
color,
glowHex,
onClick,
disabled,
loading,
badge,
className,
}: RoomActionButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'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,
)}
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? (
<Loader2 className="size-7 sm:size-9 animate-spin" />
) : (
icon
)}
</div>
{badge && (
<div className="absolute -top-0.5 -right-0.5">
{badge}
</div>
)}
</div>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
// src/blobbi/rooms/index.ts — barrel export
export {
type BlobbiRoomId,
type BlobbiRoomMeta,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from './lib/room-config';
export { BlobbiRoomShell } from './components/BlobbiRoomShell';
export { BlobbiHomeRoom } from './components/BlobbiHomeRoom';
export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom';
export { BlobbiCareRoom } from './components/BlobbiCareRoom';
export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom';
export { BlobbiRestRoom } from './components/BlobbiRestRoom';
export { BlobbiClosetRoom } from './components/BlobbiClosetRoom';
+137
View File
@@ -0,0 +1,137 @@
// src/blobbi/rooms/lib/poop-system.ts
/**
* Temporary local-only poop system.
*
* Generates poop based on:
* A) Overfeeding: hunger >= 95 -> poop in kitchen
* B) Time elapsed: every 2 hours since last feed -> poop in a random room
*
* This is entirely ephemeral -- no persistence to Nostr or localStorage.
* The state is generated fresh on page load and managed in React state.
*/
import type { BlobbiRoomId } from './room-config';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopInstance {
id: string;
room: BlobbiRoomId;
/** 'overfeed' poops are kitchen-only and disappear on room change */
source: 'overfeed' | 'time';
/** Timestamp when this poop was generated */
createdAt: number;
/**
* Safe-zone position for this poop.
* Kept as % offsets so the layout stays responsive.
* Positions are in the lower-left and lower-right corners,
* avoiding the central Blobbi hero area.
*/
position: { bottom: number; left: number };
}
// ─── Constants ────────────────────────────────────────────────────────────────
const OVERFEED_THRESHOLD = 95;
const HOURS_PER_POOP = 2;
const XP_PER_POOP = 5;
/** Rooms where time-based poop can appear (not closet) */
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest'];
/**
* Pre-defined safe positions in the lower corners of the room.
* Values are percentages. These avoid the central hero area
* (roughly 30%70% horizontal, above 35% vertical).
*/
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
{ bottom: 22, left: 8 }, // lower-left
{ bottom: 18, left: 78 }, // lower-right
{ bottom: 28, left: 14 }, // mid-left
{ bottom: 25, left: 82 }, // mid-right
{ bottom: 15, left: 20 }, // bottom-left-ish
{ bottom: 20, left: 72 }, // bottom-right-ish
];
// ─── Generation ───────────────────────────────────────────────────────────────
let _idCounter = 0;
function nextPoopId(): string {
return `poop_${++_idCounter}_${Date.now()}`;
}
function pickPosition(index: number): { bottom: number; left: number } {
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
}
/**
* Generate initial poop instances based on current companion state.
* Called once when the dashboard mounts.
*/
export function generateInitialPoops(
hunger: number,
lastFeedTimestamp: number | undefined,
): PoopInstance[] {
const poops: PoopInstance[] = [];
const now = Date.now();
let posIndex = 0;
// A) Overfeeding poop -- kitchen only
if (hunger >= OVERFEED_THRESHOLD) {
poops.push({
id: nextPoopId(),
room: 'kitchen',
source: 'overfeed',
createdAt: now,
position: pickPosition(posIndex++),
});
}
// B) Time-based poop -- random room
if (lastFeedTimestamp) {
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP);
for (let i = 0; i < Math.min(poopCount, 3); i++) {
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
poops.push({
id: nextPoopId(),
room,
source: 'time',
createdAt: now - i * 1000,
position: pickPosition(posIndex++),
});
}
}
return poops;
}
/**
* Get poops visible in a specific room.
*/
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
return poops.filter(p => p.room === room);
}
/**
* Remove a poop by id and return the XP reward.
*/
export function removePoop(
poops: PoopInstance[],
poopId: string,
): { remaining: PoopInstance[]; xpReward: number } {
const remaining = poops.filter(p => p.id !== poopId);
const wasRemoved = remaining.length < poops.length;
return {
remaining,
xpReward: wasRemoved ? XP_PER_POOP : 0,
};
}
/**
* Check if any poop exists anywhere.
*/
export function hasAnyPoop(poops: PoopInstance[]): boolean {
return poops.length > 0;
}
+144
View File
@@ -0,0 +1,144 @@
// src/blobbi/rooms/lib/room-config.ts
/**
* Blobbi Room System — Configuration & Navigation
*
* This module defines the room types, default ordering, and navigation helpers.
* The design supports future per-user customisation: the default order is data,
* not hardcoded control flow, so it can be replaced with a user-stored sequence.
*/
// ─── Room IDs ─────────────────────────────────────────────────────────────────
/**
* Unique identifier for each room in the Blobbi world.
* New rooms can be added here without breaking existing code.
*/
export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet';
// ─── Room Metadata ────────────────────────────────────────────────────────────
export interface BlobbiRoomMeta {
/** Unique room identifier */
id: BlobbiRoomId;
/** Human-readable display label */
label: string;
/** Short description (for tooltips / accessibility) */
description: string;
/** Emoji icon representing the room */
icon: string;
}
/**
* Static metadata for every room.
* This is a lookup — order does NOT matter here.
*/
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
care: {
id: 'care',
label: 'Care Room',
description: 'Hygiene, care, and medicine',
icon: '🩹',
},
kitchen: {
id: 'kitchen',
label: 'Kitchen',
description: 'Feed your Blobbi',
icon: '🍳',
},
home: {
id: 'home',
label: 'Home',
description: 'Main living room',
icon: '🏠',
},
hatchery: {
id: 'hatchery',
label: 'Hatchery',
description: 'Evolution and quests',
icon: '🥚',
},
rest: {
id: 'rest',
label: 'Bedroom',
description: 'Rest and recharge',
icon: '🌙',
},
closet: {
id: 'closet',
label: 'Closet',
description: 'Wardrobe and accessories',
icon: '👗',
},
};
// ─── Default Room Order ───────────────────────────────────────────────────────
/**
* The default room sequence.
*
* IMPORTANT: This array is the ONLY place that defines order.
* To support per-user customisation later, replace this with
* a user-stored array of BlobbiRoomId values.
*
* Closet is excluded for now (not yet implemented).
* To re-enable, add 'closet' back to the array.
*/
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
'care',
'kitchen',
'home',
'hatchery',
'rest',
// 'closet', — re-enable when wardrobe feature is ready
];
/**
* The room that should be selected when the dashboard first loads.
*/
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
// ─── Navigation Helpers ───────────────────────────────────────────────────────
/**
* Get the next room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The next room id (wraps around)
*/
export function getNextRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[0];
return order[(idx + 1) % order.length];
}
/**
* Get the previous room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The previous room id (wraps around)
*/
export function getPreviousRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[order.length - 1];
return order[(idx - 1 + order.length) % order.length];
}
/**
* Get the index of a room in the order array.
* Returns -1 if the room is not in the order.
*/
export function getRoomIndex(
room: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): number {
return order.indexOf(room);
}
+15
View File
@@ -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)]';
+194
View File
@@ -0,0 +1,194 @@
// src/blobbi/rooms/lib/room-types.ts
/**
* Shared prop types for Blobbi room components.
*
* These types are the "contract" that the BlobbiDashboard passes down
* to each room. They mirror the existing BlobbiDashboard internal state
* so rooms can reuse all existing logic without duplication.
*/
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import type {
InventoryAction,
DirectAction,
InlineActivityState,
BlobbiReactionState,
SelectedTrack,
StartIncubationMode,
} from '@/blobbi/actions';
import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { ShopItem } from '@/blobbi/shop/types/shop.types';
// ─── Shared Dashboard Context ─────────────────────────────────────────────────
/**
* Everything a room needs from the dashboard.
* Passed down by BlobbiRoomShell so rooms don't import dashboard state directly.
*/
export interface BlobbiRoomContext {
// ── Core data ──
companion: BlobbiCompanion;
companions: BlobbiCompanion[];
selectedD: string;
profile: BlobbonautProfile | null;
// ── Projected / visual state ──
currentStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
isSleeping: boolean;
isEgg: boolean;
isBaby: boolean;
// ── Visual recipe ──
statusRecipe: BlobbiVisualRecipe | undefined;
statusRecipeLabel: string | undefined;
effectiveEmotion: BlobbiEmotion;
hasDevOverride: boolean;
blobbiReaction: BlobbiReactionState;
// ── Item use ──
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
handleUseItemFromTab: (itemId: string) => void;
isUsingItem: boolean;
usingItemId: string | null;
allShopItems: ShopItem[];
// ── Direct actions ──
onDirectAction: (action: DirectAction) => Promise<void>;
handleDirectAction: (action: DirectAction) => void;
isDirectActionPending: boolean;
// ── Inline activity (music/sing) ──
inlineActivity: InlineActivityState;
setInlineActivity: React.Dispatch<React.SetStateAction<InlineActivityState>>;
setBlobbiReaction: React.Dispatch<React.SetStateAction<BlobbiReactionState>>;
setActionOverrideEmotion: React.Dispatch<React.SetStateAction<BlobbiEmotion | null>>;
showTrackPickerModal: boolean;
setShowTrackPickerModal: React.Dispatch<React.SetStateAction<boolean>>;
handleTrackSelected: (selection: SelectedTrack) => Promise<void>;
handleConfirmSing: () => Promise<void>;
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<void>;
// ── Photo ──
showPhotoModal: boolean;
setShowPhotoModal: React.Dispatch<React.SetStateAction<boolean>>;
// ── 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<typeof useHatchTasks>;
evolveTasks: ReturnType<typeof useEvolveTasks>;
onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise<void>;
onStartEvolution: () => Promise<void>;
onStopIncubation: () => Promise<void>;
onStopEvolution: () => Promise<void>;
onHatch: () => Promise<void>;
onEvolve: () => Promise<void>;
showPostModal: boolean;
setShowPostModal: React.Dispatch<React.SetStateAction<boolean>>;
refetchCurrentTasks: () => void;
// ── Daily missions ──
dailyMissions: ReturnType<typeof useDailyMissions>;
onClaimReward: (id: string) => void;
isClaimingReward: boolean;
availableStages: ('egg' | 'baby' | 'adult')[];
// ── Adoption ──
showAdoptionFlow: boolean;
setShowAdoptionFlow: React.Dispatch<React.SetStateAction<boolean>>;
// ── Adoption + Profile update props ──
publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise<NostrEvent>;
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: StorageItem[];
} | null>;
// ── Naddr link ──
blobbiNaddr: string;
// ── Hero measurement ──
/** Callback ref for the hero container — re-attaches ResizeObserver on room switch */
heroRef: React.RefCallback<HTMLDivElement> | React.RefObject<HTMLDivElement | null>;
heroWidth: number;
// ── DEV ONLY ──
showDevEditor: boolean;
setShowDevEditor: (show: boolean) => void;
onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise<void>;
isDevUpdating: boolean;
showEmotionPanel: boolean;
setShowEmotionPanel: React.Dispatch<React.SetStateAction<boolean>>;
showHatchCeremony: boolean;
setShowHatchCeremony: React.Dispatch<React.SetStateAction<boolean>>;
// ── Inventory modal (still used in kitchen) ──
inventoryAction: InventoryAction | null;
setInventoryAction: React.Dispatch<React.SetStateAction<InventoryAction | null>>;
// ── Last feed timestamp (for poop system) ──
lastFeedTimestamp: number | undefined;
}
// ─── Poop State (passed from shell to rooms) ──────────────────────────────────
import type { PoopInstance } from './poop-system';
export interface RoomPoopState {
/** All poop instances across rooms */
poops: PoopInstance[];
/** Whether shovel mode is currently active */
shovelMode: boolean;
/** Toggle shovel mode on/off */
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Remove a poop (returns XP reward via callback) */
onRemovePoop: (poopId: string) => void;
}
+190 -1157
View File
File diff suppressed because it is too large Load Diff