Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 286572777b | |||
| 7fd4b7ab69 | |||
| c9525a0233 | |||
| 0b9cd5e1cb | |||
| a2600d1caa | |||
| 0722d900a2 | |||
| 918814371c |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]';
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user