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({