diff --git a/src/blobbi/companion/components/BlobbiCompanion.tsx b/src/blobbi/companion/components/BlobbiCompanion.tsx index ce588532..6568d189 100644 --- a/src/blobbi/companion/components/BlobbiCompanion.tsx +++ b/src/blobbi/companion/components/BlobbiCompanion.tsx @@ -193,8 +193,9 @@ export function BlobbiCompanion({ } // Calculate floating animation offset (gentle sway/float) - // Skip during entry animation, dragging, or debug mode - const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode) + // Skip during entry animation, dragging, debug mode, or sleeping + const isSleeping = companion.state === 'sleeping'; + const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode && !isSleeping) ? calculateFloatAnimation(animationTime, state === 'walking') : { x: 0, y: 0, rotation: 0 }; @@ -228,12 +229,15 @@ export function BlobbiCompanion({ : undefined; // Drag handlers with click detection + // Uses pointer events only (handles mouse, touch, and pen natively) const handlePointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); e.stopPropagation(); - // Capture pointer for tracking outside element - (e.target as HTMLElement).setPointerCapture(e.pointerId); + // Capture pointer on the container (not e.target which may be a child) + // for reliable tracking across element boundaries during drag + if (containerRef.current) { + containerRef.current.setPointerCapture(e.pointerId); + } // Start click detection tracking clickDetection.handlePointerDown({ x: e.clientX, y: e.clientY }); @@ -254,7 +258,9 @@ export function BlobbiCompanion({ }, [clickDetection, motion.isDragging, config.size, onUpdateDrag]); const handlePointerUp = useCallback((e: React.PointerEvent) => { - (e.target as HTMLElement).releasePointerCapture(e.pointerId); + if (containerRef.current) { + containerRef.current.releasePointerCapture(e.pointerId); + } // Finalize click detection - will call onClick if it was a click clickDetection.handlePointerUp(); @@ -265,42 +271,6 @@ export function BlobbiCompanion({ } }, [clickDetection, motion.isDragging, onEndDrag]); - // Touch handlers for mobile (with click detection) - const handleTouchStart = useCallback((e: React.TouchEvent) => { - e.preventDefault(); - if (e.touches.length === 0) return; - - const touch = e.touches[0]; - clickDetection.handlePointerDown({ x: touch.clientX, y: touch.clientY }); - }, [clickDetection]); - - const handleTouchMove = useCallback((e: React.TouchEvent) => { - if (e.touches.length === 0) return; - - const touch = e.touches[0]; - const position = { x: touch.clientX, y: touch.clientY }; - - // Check if movement exceeds click threshold (starts drag) - const isDrag = clickDetection.handlePointerMove(position); - - // If dragging, update position - if (motion.isDragging || isDrag) { - const newX = touch.clientX - config.size / 2; - const newY = touch.clientY - config.size / 2; - onUpdateDrag({ x: newX, y: newY }); - } - }, [clickDetection, motion.isDragging, config.size, onUpdateDrag]); - - const handleTouchEnd = useCallback(() => { - // Finalize click detection - clickDetection.handlePointerUp(); - - // Always end drag state - if (motion.isDragging) { - onEndDrag(); - } - }, [clickDetection, motion.isDragging, onEndDrag]); - return (
{ + if (!toggleSleep) { + if (import.meta.env.DEV) { + console.warn('[CompanionLayer] toggleSleep not registered'); + } + return; + } + closeMenu(); + try { + await toggleSleep(); + } catch (error) { + if (import.meta.env.DEV) { + console.error('[CompanionLayer] Sleep toggle failed:', error); + } + } + }, [toggleSleep, closeMenu]); + + /** Intercept action selection: sleep is a direct action, others go through item flow. */ + const handleActionClick = useCallback((action: Parameters[0]) => { + if (action === 'sleep') { + handleSleepAction(); + } else { + selectAction(action); + } + }, [handleSleepAction, selectAction]); + const handleCompanionClick = useCallback(() => { if (isEntering) return; toggleMenu(); @@ -262,8 +291,9 @@ export function BlobbiCompanionLayer() { companionSize={config.size} actions={availableActions} selectedAction={menuState.selectedAction} - onActionClick={selectAction} + onActionClick={handleActionClick} onClickOutside={handleClickOutside} + isSleeping={isSleeping} /> ('idle'); const [direction, setDirection] = useState('right'); @@ -137,7 +140,7 @@ export function useBlobbiCompanionState({ // Make a decision about what to do next const makeDecision = useCallback(() => { - if (!isActive || motionRef.current.isDragging) { + if (!isActive || isSleeping || motionRef.current.isDragging) { return; } @@ -173,7 +176,7 @@ export function useBlobbiCompanionState({ // Schedule next decision const duration = transition.duration ?? randomDuration(config.idleTime); timerRef.current = window.setTimeout(makeDecision, duration); - }, [isActive, bounds, state, config, startObservation]); + }, [isActive, isSleeping, bounds, state, config, startObservation]); // Handle reaching target const onReachedTarget = useCallback(() => { @@ -208,9 +211,22 @@ export function useBlobbiCompanionState({ } }, [makeDecision, observationTarget, config.observation.lookDuration]); - // Start decision loop when active + // Force idle when sleeping - stop all movement/decisions immediately useEffect(() => { - if (isActive && !motionRef.current.isDragging) { + if (isSleeping) { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setState('idle'); + setTargetX(null); + setObservationTarget(null); + } + }, [isSleeping]); + + // Start decision loop when active (and not sleeping) + useEffect(() => { + if (isActive && !isSleeping && !motionRef.current.isDragging) { // Clear any existing timer if (timerRef.current) { clearTimeout(timerRef.current); @@ -239,7 +255,7 @@ export function useBlobbiCompanionState({ clearTimeout(timerRef.current); } }; - }, [isActive, forceInitialWalk, startInitialWalk, makeDecision]); + }, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]); // Pause decisions while dragging // We poll isDragging via interval since motionRef changes don't trigger re-renders diff --git a/src/blobbi/companion/interaction/BlobbiActionsContext.tsx b/src/blobbi/companion/interaction/BlobbiActionsContext.tsx index 54cbbad1..aa2f789d 100644 --- a/src/blobbi/companion/interaction/BlobbiActionsContext.tsx +++ b/src/blobbi/companion/interaction/BlobbiActionsContext.tsx @@ -18,6 +18,7 @@ import { BlobbiActionsProvider, type UseItemFunction, type UseItemResult, + type ToggleSleepFunction, type BlobbiActionsContextValue, type BlobbiActionsContextInternal, } from './BlobbiActionsProvider'; @@ -28,6 +29,7 @@ export { BlobbiActionsProvider, type UseItemFunction, type UseItemResult, + type ToggleSleepFunction, type BlobbiActionsContextValue, type BlobbiActionsContextInternal, }; @@ -99,6 +101,9 @@ export function useBlobbiActions(): BlobbiActionsContextValue { const registeredIsUsing = context?.isUsingItemRegisteredRef.current ?? false; const isUsingItem = registeredIsUsing || fallbackItemUse.isUsingItem; + // Expose toggleSleep from registered ref (or null if not registered) + const toggleSleep = context?.toggleSleepRef.current ?? null; + // Return stable object return useMemo(() => ({ useItem, @@ -106,7 +111,8 @@ export function useBlobbiActions(): BlobbiActionsContextValue { canUseItems, isItemOnCooldown: fallbackItemUse.isItemOnCooldown, clearItemCooldown: fallbackItemUse.clearItemCooldown, - }), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown]); + toggleSleep, + }), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown, toggleSleep]); } // ─── Registration Hook ──────────────────────────────────────────────────────── @@ -124,7 +130,8 @@ export function useBlobbiActions(): BlobbiActionsContextValue { */ export function useBlobbiActionsRegistration( useItemFn: UseItemFunction | null, - isUsingItem: boolean + isUsingItem: boolean, + toggleSleepFn?: ToggleSleepFunction | null, ): void { const context = useContext(BlobbiActionsContext); @@ -135,6 +142,10 @@ export function useBlobbiActionsRegistration( const useItemRef = useRef(useItemFn); useItemRef.current = useItemFn; + // Keep toggleSleepFn in a ref to avoid stale closures + const toggleSleepInnerRef = useRef(toggleSleepFn); + toggleSleepInnerRef.current = toggleSleepFn; + // Create a stable wrapper that delegates to the ref const stableUseItem = useCallback(async (itemId, action, quantity = 1) => { if (!useItemRef.current) { @@ -146,6 +157,13 @@ export function useBlobbiActionsRegistration( return useItemRef.current(itemId, action, quantity); }, []); + // Create a stable wrapper for toggleSleep + const stableToggleSleep = useCallback(async () => { + if (toggleSleepInnerRef.current) { + return toggleSleepInnerRef.current(); + } + }, []); + // Update refs and notify only when canUseItems actually changes useEffect(() => { if (!context) { @@ -161,6 +179,7 @@ export function useBlobbiActionsRegistration( context.registerRef.current = canUseItems ? stableUseItem : null; context.canUseItemsRegisteredRef.current = canUseItems; context.isUsingItemRegisteredRef.current = isUsingItem; + context.toggleSleepRef.current = toggleSleepFn ? stableToggleSleep : null; // Only notify consumers if canUseItems changed (major state change) if (prevCanUseRef.current !== canUseItems) { @@ -171,7 +190,7 @@ export function useBlobbiActionsRegistration( console.log('[BlobbiActions] Registration changed:', { canUseItems, isUsingItem }); } } - }, [context, useItemFn, stableUseItem, isUsingItem]); + }, [context, useItemFn, stableUseItem, isUsingItem, toggleSleepFn, stableToggleSleep]); // Cleanup on unmount useEffect(() => { @@ -180,6 +199,7 @@ export function useBlobbiActionsRegistration( context.registerRef.current = null; context.canUseItemsRegisteredRef.current = false; context.isUsingItemRegisteredRef.current = false; + context.toggleSleepRef.current = null; context.notifyUpdate(); if (import.meta.env.DEV) { diff --git a/src/blobbi/companion/interaction/BlobbiActionsProvider.tsx b/src/blobbi/companion/interaction/BlobbiActionsProvider.tsx index 703aa6c0..edead346 100644 --- a/src/blobbi/companion/interaction/BlobbiActionsProvider.tsx +++ b/src/blobbi/companion/interaction/BlobbiActionsProvider.tsx @@ -37,6 +37,12 @@ export type UseItemFunction = ( quantity?: number ) => Promise; +/** + * Function signature for toggling sleep/wake state. + * Returns a promise that resolves when the state change is published. + */ +export type ToggleSleepFunction = () => Promise; + /** * Context value for Blobbi actions (consumer side). */ @@ -58,6 +64,12 @@ export interface BlobbiActionsContextValue { /** Clear cooldown for an item */ clearItemCooldown: (itemId: string) => void; + + /** + * Toggle sleep/wake state on the current companion. + * Only available when BlobbiPage has registered its handler. + */ + toggleSleep: ToggleSleepFunction | null; } /** @@ -70,6 +82,8 @@ export interface BlobbiActionsContextInternal { canUseItemsRegisteredRef: React.MutableRefObject; /** Whether an item is currently being used (via registration) */ isUsingItemRegisteredRef: React.MutableRefObject; + /** Registered sleep/wake toggle function (from BlobbiPage) */ + toggleSleepRef: React.MutableRefObject; /** Force update consumers (called sparingly) */ notifyUpdate: () => void; /** Subscribe to updates */ @@ -100,6 +114,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps) const registerRef = useRef(null); const canUseItemsRegisteredRef = useRef(false); const isUsingItemRegisteredRef = useRef(false); + const toggleSleepRef = useRef(null); // Subscribers for manual notification const subscribersRef = useRef void>>(new Set()); @@ -120,6 +135,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps) registerRef, canUseItemsRegisteredRef, isUsingItemRegisteredRef, + toggleSleepRef, notifyUpdate, subscribe, }), [notifyUpdate, subscribe]); diff --git a/src/blobbi/companion/interaction/CompanionActionMenu.tsx b/src/blobbi/companion/interaction/CompanionActionMenu.tsx index a34f4693..d0800e2f 100644 --- a/src/blobbi/companion/interaction/CompanionActionMenu.tsx +++ b/src/blobbi/companion/interaction/CompanionActionMenu.tsx @@ -35,6 +35,8 @@ interface CompanionActionMenuProps { onActionClick: (action: CompanionMenuAction) => void; /** Callback for clicking outside the menu */ onClickOutside?: () => void; + /** Whether Blobbi is currently sleeping (affects sleep button label) */ + isSleeping?: boolean; } // Layout configuration @@ -90,6 +92,7 @@ export function CompanionActionMenu({ selectedAction, onActionClick, onClickOutside, + isSleeping = false, }: CompanionActionMenuProps) { if (!isOpen) return null; @@ -122,6 +125,11 @@ export function CompanionActionMenu({ const isSelected = selectedAction === action.id; const delay = index * MENU_CONFIG.staggerDelay; + // Sleep action toggles label/emoji based on sleeping state + const isSleepAction = action.id === 'sleep'; + const displayEmoji = isSleepAction && isSleeping ? '\u2600\uFE0F' : action.emoji; + const displayLabel = isSleepAction && isSleeping ? 'Wake up' : action.label; + return ( ); diff --git a/src/blobbi/companion/interaction/index.ts b/src/blobbi/companion/interaction/index.ts index b5692bf2..6bfb6326 100644 --- a/src/blobbi/companion/interaction/index.ts +++ b/src/blobbi/companion/interaction/index.ts @@ -58,6 +58,7 @@ export { export type { UseItemResult as ContextUseItemResult, UseItemFunction, + ToggleSleepFunction, BlobbiActionsContextValue, } from './BlobbiActionsContext'; diff --git a/src/blobbi/ui/lib/adapters.ts b/src/blobbi/ui/lib/adapters.ts index fc485a12..5b96a678 100644 --- a/src/blobbi/ui/lib/adapters.ts +++ b/src/blobbi/ui/lib/adapters.ts @@ -9,7 +9,7 @@ * - BlobbiCompanionVisual.tsx (toBlobiForVisual - note typo) */ -import type { Blobbi } from '@/blobbi/core/types/blobbi'; +import type { Blobbi, BlobbiState } from '@/blobbi/core/types/blobbi'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; import type { CompanionData } from '@/blobbi/companion/types/companion.types'; @@ -61,12 +61,13 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi { * @returns Blobbi type for visual components */ export function companionDataToBlobbi(companion: CompanionData): Blobbi { + const isSleeping = companion.state === 'sleeping'; return { id: companion.d, name: companion.name, lifeStage: companion.stage, - state: 'active', - isSleeping: false, + state: (companion.state as BlobbiState) ?? 'active', + isSleeping, stats: { hunger: 100, happiness: 100, diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index b307099e..39ebc310 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -426,8 +426,8 @@ function BlobbiContent() { }; }, [executeUseItem, companion, profile]); - // Register with the global BlobbiActionsContext - useBlobbiActionsRegistration(useItemForContext, isUsingItem); + // Register with the global BlobbiActionsContext (includes sleep toggle for companion layer) + useBlobbiActionsRegistration(useItemForContext, isUsingItem, handleRest); // ─── Stage Transition Hooks ─── const { mutateAsync: executeHatch, isPending: isHatching } = useBlobbiHatch({