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