feat(companion): implement proper sleep/wake state, fix mobile tap interaction

- Fix sleep visuals on floating companion: companionDataToBlobbi adapter
  now passes through actual state and isSleeping instead of hardcoding
  'active'/false, so sleeping Blobbi renders closed eyes and Zzz

- Refactor companion sleep button as direct action: sleep/wake toggle
  is routed through BlobbiActionsProvider (toggleSleep registration)
  instead of the item-flow system. Companion menu button shows Wake up
  (sun emoji) when sleeping, Sleep (moon emoji) when awake

- Freeze companion movement during sleep: state machine respects
  isSleeping flag, clears all timers/targets, forces idle state.
  Float animation and sway CSS animation also disabled while sleeping.
  Blobbi stays parked exactly where sleep was triggered

- Fix mobile tap on companion: remove duplicate touch event handlers
  (touchstart/touchmove/touchend) that conflicted with pointer events.
  Pointer events handle mouse+touch+pen natively. Use containerRef for
  setPointerCapture instead of e.target for reliable cross-platform
  tracking. Remove preventDefault from pointerdown to avoid blocking
  browser touch-to-pointer synthesis
This commit is contained in:
filemon
2026-03-31 12:32:48 -03:00
parent 0e4ce974f0
commit ef88ca4235
11 changed files with 128 additions and 63 deletions
@@ -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 (
<div
ref={containerRef}
@@ -321,9 +291,6 @@ export function BlobbiCompanion({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<BlobbiCompanionVisual
companion={companion}
@@ -125,6 +125,7 @@ export function BlobbiCompanionLayer() {
useItem: contextUseItem,
canUseItems,
isItemOnCooldown,
toggleSleep,
} = useBlobbiActions();
// ── Item use with emotion override ─────────────────────────────────────────
@@ -181,6 +182,34 @@ export function BlobbiCompanionLayer() {
// ── Companion click ────────────────────────────────────────────────────────
// ── Sleep action (direct, not item-based) ───────────────────────────────────
const handleSleepAction = useCallback(async () => {
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<typeof selectAction>[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}
/>
<HangingItems
@@ -164,7 +164,9 @@ export function BlobbiCompanionVisual({
}, [floatOffset]);
// Reaction state for CSS animations on the OUTER wrapper
const reaction = isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
// When sleeping, always idle — no swaying/happy animation
const isSleeping = companion.state === 'sleeping';
const reaction = isSleeping ? 'idle' : isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
// ── Shadow ─────────────────────────────────────────────────────────────────
const SHADOW_FADE_DISTANCE = 30;
@@ -210,6 +210,9 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
}, config.attention.postRouteDelay);
}, [findMainContentPosition, triggerAttention, config.attention.postRouteDuration, config.attention.postRouteDelay]);
// Determine if companion is sleeping
const companionSleeping = companion?.state === 'sleeping';
// State management
// Pass the shared motionRef so state can read live motion values
const {
@@ -224,6 +227,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
motionRef,
bounds,
attentionTarget: currentAttention,
isSleeping: companionSleeping,
});
// Motion management
@@ -33,6 +33,8 @@ interface UseBlobbiCompanionStateOptions {
forceInitialWalk?: boolean;
/** Current attention target (from UI attention system) */
attentionTarget?: AttentionTarget | null;
/** Whether the companion is sleeping (freezes all decisions/movement) */
isSleeping?: boolean;
}
interface UseBlobbiCompanionStateResult {
@@ -59,6 +61,7 @@ export function useBlobbiCompanionState({
bounds,
forceInitialWalk = true,
attentionTarget,
isSleeping = false,
}: UseBlobbiCompanionStateOptions): UseBlobbiCompanionStateResult {
const [state, setState] = useState<CompanionState>('idle');
const [direction, setDirection] = useState<CompanionDirection>('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
@@ -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<UseItemFunction>(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<ToggleSleepFunction>(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) {
@@ -37,6 +37,12 @@ export type UseItemFunction = (
quantity?: number
) => Promise<UseItemResult>;
/**
* Function signature for toggling sleep/wake state.
* Returns a promise that resolves when the state change is published.
*/
export type ToggleSleepFunction = () => Promise<void>;
/**
* 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<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Registered sleep/wake toggle function (from BlobbiPage) */
toggleSleepRef: React.MutableRefObject<ToggleSleepFunction | null>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
@@ -100,6 +114,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
const registerRef = useRef<UseItemFunction | null>(null);
const canUseItemsRegisteredRef = useRef<boolean>(false);
const isUsingItemRegisteredRef = useRef<boolean>(false);
const toggleSleepRef = useRef<ToggleSleepFunction | null>(null);
// Subscribers for manual notification
const subscribersRef = useRef<Set<() => void>>(new Set());
@@ -120,6 +135,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
registerRef,
canUseItemsRegisteredRef,
isUsingItemRegisteredRef,
toggleSleepRef,
notifyUpdate,
subscribe,
}), [notifyUpdate, subscribe]);
@@ -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 (
<button
key={action.id}
@@ -155,15 +163,15 @@ export function CompanionActionMenu({
e.stopPropagation();
onActionClick(action.id);
}}
title={action.label}
aria-label={action.label}
title={displayLabel}
aria-label={displayLabel}
>
<span
className="text-xl select-none"
role="img"
aria-hidden="true"
>
{action.emoji}
{displayEmoji}
</span>
</button>
);
@@ -58,6 +58,7 @@ export {
export type {
UseItemResult as ContextUseItemResult,
UseItemFunction,
ToggleSleepFunction,
BlobbiActionsContextValue,
} from './BlobbiActionsContext';
+4 -3
View File
@@ -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,
+2 -2
View File
@@ -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({