From c659eaeeadbb88933cdc252545ecbf80bfd29c54 Mon Sep 17 00:00:00 2001 From: filemon Date: Tue, 24 Mar 2026 20:07:16 -0300 Subject: [PATCH] Replace item bubbles with hanging items, fix action menu tracking Action Menu Fixes: - Remove useMemo for position calculations to avoid stale values - Calculate button positions directly each render - Menu now follows Blobbi continuously during all states (idle, walking, floating, dragging, settling) Hanging Items System: - New HangingItems component replaces CompanionItemBubbles - Items appear as circles hanging from vertical lines at top of screen - Wider horizontal spacing (80px between items) - Playful, spatial presentation instead of modal-like container Click-to-Fall Animation: - Clicking an item releases it from the hanger - Line and quantity badge disappear instantly - Item falls with rotation animation (800ms) - Structured for future extension (drag, attraction, reactions) Pointer Events: - Container uses pointer-events-none - Individual items use pointer-events-auto - All items are now properly clickable Removed: - CompanionItemBubbles component (deleted) - Modal-like container presentation - Close button (no longer needed) --- .../components/BlobbiCompanionLayer.tsx | 10 +- src/blobbi/companion/index.ts | 2 +- .../interaction/CompanionActionMenu.tsx | 79 +++-- .../interaction/CompanionItemBubbles.tsx | 163 ---------- .../companion/interaction/HangingItems.tsx | 278 ++++++++++++++++++ src/blobbi/companion/interaction/index.ts | 8 +- 6 files changed, 324 insertions(+), 216 deletions(-) delete mode 100644 src/blobbi/companion/interaction/CompanionItemBubbles.tsx create mode 100644 src/blobbi/companion/interaction/HangingItems.tsx diff --git a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx index 4b31bcf9..1617a7b5 100644 --- a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx +++ b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx @@ -23,7 +23,7 @@ import { calculateGroundY } from '../utils/movement'; import { useCompanionActionMenu, CompanionActionMenu, - CompanionItemBubbles, + HangingItems, } from '../interaction'; import type { Position } from '../types/companion.types'; @@ -77,7 +77,6 @@ export function BlobbiCompanionLayer() { toggleMenu, closeMenu, selectAction, - clearAction, handleItemClick, } = useCompanionActionMenu({ isActive: isVisible, @@ -232,13 +231,12 @@ export function BlobbiCompanionLayer() { onClickOutside={handleClickOutside} /> - {/* Item Bubbles - floating items for selected action */} - ); diff --git a/src/blobbi/companion/index.ts b/src/blobbi/companion/index.ts index ee2ad149..2bbdaf2c 100644 --- a/src/blobbi/companion/index.ts +++ b/src/blobbi/companion/index.ts @@ -115,7 +115,7 @@ export { useCompanionActionMenu, useClickDetection, CompanionActionMenu, - CompanionItemBubbles, + HangingItems, MENU_ACTIONS, INITIAL_MENU_STATE, DEFAULT_CLICK_CONFIG, diff --git a/src/blobbi/companion/interaction/CompanionActionMenu.tsx b/src/blobbi/companion/interaction/CompanionActionMenu.tsx index f1ae117a..62b03fd5 100644 --- a/src/blobbi/companion/interaction/CompanionActionMenu.tsx +++ b/src/blobbi/companion/interaction/CompanionActionMenu.tsx @@ -4,15 +4,18 @@ * Floating radial action menu that appears around Blobbi when clicked. * Actions are arranged in a curved arc above the companion. * + * IMPORTANT: This component calculates positions directly from props + * on every render to ensure the menu stays perfectly attached to Blobbi + * during all animations (idle, walking, floating, dragging, etc.). + * * Features: * - Radial/arc layout centered above Blobbi + * - Real-time position tracking (no lag) * - Smooth open/close animations * - Hover and active states * - Selected action highlight */ -import { useMemo } from 'react'; - import { cn } from '@/lib/utils'; import type { Position } from '../types/companion.types'; import type { CompanionMenuAction, MenuActionConfig } from './types'; @@ -20,7 +23,7 @@ import type { CompanionMenuAction, MenuActionConfig } from './types'; interface CompanionActionMenuProps { /** Whether the menu is visible */ isOpen: boolean; - /** Position of Blobbi (top-left of the companion) */ + /** Position of Blobbi (top-left of the companion) - updates every frame */ companionPosition: Position; /** Size of the companion */ companionSize: number; @@ -49,38 +52,34 @@ const MENU_CONFIG = { }; /** - * Calculate button positions in an arc above the companion. + * Calculate button position for a single action in the arc. + * This is called per-button directly in render to avoid stale position values. */ -function calculateArcPositions( +function calculateButtonPosition( centerX: number, centerY: number, + index: number, count: number, config: typeof MENU_CONFIG -): Position[] { - if (count === 0) return []; +): Position { if (count === 1) { // Single button goes directly above const angleRad = (config.arcCenter * Math.PI) / 180; - return [{ + return { x: centerX + Math.cos(angleRad) * config.radius, y: centerY + Math.sin(angleRad) * config.radius, - }]; + }; } - const positions: Position[] = []; const startAngle = config.arcCenter - config.arcSpread / 2; const angleStep = config.arcSpread / (count - 1); + const angleDeg = startAngle + angleStep * index; + const angleRad = (angleDeg * Math.PI) / 180; - for (let i = 0; i < count; i++) { - const angleDeg = startAngle + angleStep * i; - const angleRad = (angleDeg * Math.PI) / 180; - positions.push({ - x: centerX + Math.cos(angleRad) * config.radius, - y: centerY + Math.sin(angleRad) * config.radius, - }); - } - - return positions; + return { + x: centerX + Math.cos(angleRad) * config.radius, + y: centerY + Math.sin(angleRad) * config.radius, + }; } export function CompanionActionMenu({ @@ -92,25 +91,12 @@ export function CompanionActionMenu({ onActionClick, onClickOutside, }: CompanionActionMenuProps) { - // Calculate companion center - const companionCenter = useMemo(() => ({ - x: companionPosition.x + companionSize / 2, - y: companionPosition.y + companionSize / 2, - }), [companionPosition, companionSize]); - - // Calculate button positions - const buttonPositions = useMemo(() => - calculateArcPositions( - companionCenter.x, - companionCenter.y, - actions.length, - MENU_CONFIG - ), - [companionCenter, actions.length] - ); - if (!isOpen) return null; + // Calculate companion center directly (no memoization to avoid stale values) + const companionCenterX = companionPosition.x + companionSize / 2; + const companionCenterY = companionPosition.y + companionSize / 2; + return ( <> {/* Invisible backdrop for click outside detection */} @@ -122,10 +108,16 @@ export function CompanionActionMenu({ aria-hidden="true" /> - {/* Action buttons */} + {/* Action buttons - positions calculated directly each render */} {actions.map((action, index) => { - const position = buttonPositions[index]; - if (!position) return null; + // Calculate position directly per button (no memoization = no lag) + const position = calculateButtonPosition( + companionCenterX, + companionCenterY, + index, + actions.length, + MENU_CONFIG + ); const isSelected = selectedAction === action.id; const delay = index * MENU_CONFIG.staggerDelay; @@ -136,18 +128,19 @@ export function CompanionActionMenu({ className={cn( // Base styles - pointer-events-auto needed because parent has pointer-events-none "fixed flex items-center justify-center rounded-full pointer-events-auto", - "shadow-lg transition-all duration-200", + "shadow-lg transition-colors duration-200", "focus:outline-none focus:ring-2 focus:ring-primary/50", // Background isSelected ? "bg-primary text-primary-foreground" : "bg-background/95 hover:bg-accent", - // Hover effects + // Hover effects (only scale, not position-affecting) "hover:scale-110 active:scale-95", // Animation "animate-in fade-in zoom-in-75" )} style={{ + // Position directly from calculation (updates every render) left: position.x - MENU_CONFIG.buttonSize / 2, top: position.y - MENU_CONFIG.buttonSize / 2, width: MENU_CONFIG.buttonSize, @@ -155,6 +148,8 @@ export function CompanionActionMenu({ zIndex: 10002, animationDelay: `${delay}ms`, animationFillMode: 'backwards', + // Use transform for smooth visual feedback without affecting position calculation + transition: 'transform 200ms, background-color 200ms, color 200ms', }} onClick={(e) => { e.stopPropagation(); diff --git a/src/blobbi/companion/interaction/CompanionItemBubbles.tsx b/src/blobbi/companion/interaction/CompanionItemBubbles.tsx deleted file mode 100644 index bbbf15b6..00000000 --- a/src/blobbi/companion/interaction/CompanionItemBubbles.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * CompanionItemBubbles - * - * Floating item bubbles that appear near the top of the screen - * when an action is selected from the companion menu. - * - * Features: - * - Horizontal row of item bubbles - * - Shows emoji and quantity badge - * - Smooth appearance animation - * - Clickable (for future item use) - * - * Future extensions: - * - Falling animation toward Blobbi - * - Drag to drop on Blobbi - * - Blobbi reaction when item is near - */ - -import { cn } from '@/lib/utils'; -import type { CompanionItem, CompanionMenuAction } from './types'; -import { getMenuActionConfig } from './types'; - -interface CompanionItemBubblesProps { - /** Whether the bubbles are visible */ - isVisible: boolean; - /** Selected action (used for header/context) */ - selectedAction: CompanionMenuAction | null; - /** Items to display */ - items: CompanionItem[]; - /** Callback when an item bubble is clicked */ - onItemClick?: (item: CompanionItem) => void; - /** Callback to close the bubbles */ - onClose?: () => void; -} - -// Layout configuration -const BUBBLES_CONFIG = { - /** Bubble size */ - bubbleSize: 56, - /** Gap between bubbles */ - gap: 12, - /** Top margin from viewport */ - topMargin: 80, - /** Animation stagger delay (ms) */ - staggerDelay: 40, -}; - -export function CompanionItemBubbles({ - isVisible, - selectedAction, - items, - onItemClick, - onClose, -}: CompanionItemBubblesProps) { - if (!isVisible || !selectedAction || items.length === 0) { - // Show empty state if action selected but no items - if (isVisible && selectedAction && items.length === 0) { - const actionConfig = getMenuActionConfig(selectedAction); - return ( -
-
-

- No {actionConfig?.label.toLowerCase()} items in your inventory -

-
-
- ); - } - return null; - } - - const actionConfig = getMenuActionConfig(selectedAction); - - return ( -
- {/* Container with subtle background */} -
- {/* Header */} -
- - {actionConfig?.emoji} {actionConfig?.label} - - -
- - {/* Item bubbles row */} -
- {items.map((item, index) => ( - - ))} -
-
-
- ); -} diff --git a/src/blobbi/companion/interaction/HangingItems.tsx b/src/blobbi/companion/interaction/HangingItems.tsx new file mode 100644 index 00000000..b1fd06e8 --- /dev/null +++ b/src/blobbi/companion/interaction/HangingItems.tsx @@ -0,0 +1,278 @@ +/** + * HangingItems + * + * Displays inventory items as hanging elements from the top of the screen. + * Each item appears as a circle connected to the top by a thin vertical line, + * creating a playful, spatial feel. + * + * Features: + * - Thin vertical lines from the top of screen + * - Circular item containers with emoji icons + * - Quantity badges + * - Staggered entrance animation + * - Click to release (line disappears, item falls) + * + * Future extensions: + * - Drag items to drop on Blobbi + * - Blobbi attracted to nearby items + * - Auto-use urgent items + * - Different animations per item category + */ + +import { useState, useCallback } from 'react'; + +import { cn } from '@/lib/utils'; +import type { CompanionItem, CompanionMenuAction } from './types'; +import { getMenuActionConfig } from './types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** State of a hanging item */ +type HangingItemState = 'hanging' | 'falling' | 'landed'; + +/** Internal state for each item */ +interface ItemState { + id: string; + state: HangingItemState; +} + +/** Props for the HangingItems component */ +interface HangingItemsProps { + /** Whether to show the hanging items */ + isVisible: boolean; + /** The selected action (for empty state messaging) */ + selectedAction: CompanionMenuAction | null; + /** Items to display */ + items: CompanionItem[]; + /** Callback when an item is clicked/released */ + onItemRelease?: (item: CompanionItem) => void; + /** Callback when an item finishes falling */ + onItemLanded?: (item: CompanionItem) => void; +} + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const HANGING_CONFIG = { + /** Size of item circles */ + circleSize: 52, + /** Horizontal spacing between items (center to center) */ + itemSpacing: 80, + /** Length of the hanging line */ + lineLength: 100, + /** Width of the hanging line */ + lineWidth: 2, + /** Stagger delay between item appearances (ms) */ + staggerDelay: 60, + /** Duration of the fall animation (ms) */ + fallDuration: 800, + /** How far below viewport the item falls to */ + fallDistance: 600, +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function HangingItems({ + isVisible, + selectedAction, + items, + onItemRelease, + onItemLanded, +}: HangingItemsProps) { + // Track state of each item (hanging, falling, landed) + const [itemStates, setItemStates] = useState>(new Map()); + + // Handle item click - starts the falling animation + const handleItemClick = useCallback((item: CompanionItem) => { + // Update state to falling + setItemStates(prev => { + const next = new Map(prev); + next.set(item.id, { id: item.id, state: 'falling' }); + return next; + }); + + // Notify parent + onItemRelease?.(item); + + // After fall animation completes, mark as landed + setTimeout(() => { + setItemStates(prev => { + const next = new Map(prev); + next.set(item.id, { id: item.id, state: 'landed' }); + return next; + }); + onItemLanded?.(item); + }, HANGING_CONFIG.fallDuration); + }, [onItemRelease, onItemLanded]); + + // Get current state for an item + const getItemState = (itemId: string): HangingItemState => { + return itemStates.get(itemId)?.state ?? 'hanging'; + }; + + // Don't render if not visible + if (!isVisible || !selectedAction) { + return null; + } + + // Empty state + if (items.length === 0) { + const actionConfig = getMenuActionConfig(selectedAction); + return ( +
+
+

+ No {actionConfig?.label.toLowerCase()} items in your inventory +

+
+
+ ); + } + + // Calculate horizontal positions for items (centered) + const totalWidth = (items.length - 1) * HANGING_CONFIG.itemSpacing; + const startX = -totalWidth / 2; + + return ( +
+ {/* Container for positioning items relative to center */} +
+ {items.map((item, index) => { + const state = getItemState(item.id); + const xOffset = startX + index * HANGING_CONFIG.itemSpacing; + const delay = index * HANGING_CONFIG.staggerDelay; + + // Don't render landed items + if (state === 'landed') { + return null; + } + + const isFalling = state === 'falling'; + + return ( +
+ {/* Hanging line - hidden when falling */} +
+ + {/* Item circle */} + +
+ ); + })} +
+ + {/* CSS animations */} + +
+ ); +} diff --git a/src/blobbi/companion/interaction/index.ts b/src/blobbi/companion/interaction/index.ts index 16cd243c..4c13ed72 100644 --- a/src/blobbi/companion/interaction/index.ts +++ b/src/blobbi/companion/interaction/index.ts @@ -5,16 +5,16 @@ * * Components: * - CompanionActionMenu: Radial action buttons around Blobbi - * - CompanionItemBubbles: Floating item display + * - HangingItems: Items displayed as hanging elements from the top of screen * * Hooks: * - useCompanionActionMenu: Menu state management * - useClickDetection: Click vs drag detection * * Future extensions: - * - Item falling animation * - Drag items to Blobbi - * - Blobbi reactions + * - Blobbi reactions to items + * - Auto-use urgent items */ // Types @@ -40,4 +40,4 @@ export { useClickDetection } from './useClickDetection'; // Components export { CompanionActionMenu } from './CompanionActionMenu'; -export { CompanionItemBubbles } from './CompanionItemBubbles'; +export { HangingItems } from './HangingItems';