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)
This commit is contained in:
filemon
2026-03-24 20:07:16 -03:00
parent 37c7a37bdf
commit c659eaeead
6 changed files with 324 additions and 216 deletions
@@ -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 */}
<CompanionItemBubbles
{/* Hanging Items - items displayed as hanging elements from top */}
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
onItemClick={handleItemClick}
onClose={clearAction}
onItemRelease={handleItemClick}
/>
</div>
);
+1 -1
View File
@@ -115,7 +115,7 @@ export {
useCompanionActionMenu,
useClickDetection,
CompanionActionMenu,
CompanionItemBubbles,
HangingItems,
MENU_ACTIONS,
INITIAL_MENU_STATE,
DEFAULT_CLICK_CONFIG,
@@ -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();
@@ -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 (
<div
className="fixed left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto"
style={{
top: BUBBLES_CONFIG.topMargin,
zIndex: 10003,
}}
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {actionConfig?.label.toLowerCase()} items in your inventory
</p>
</div>
</div>
);
}
return null;
}
const actionConfig = getMenuActionConfig(selectedAction);
return (
<div
className="fixed left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto"
style={{
top: BUBBLES_CONFIG.topMargin,
zIndex: 10003,
}}
>
{/* Container with subtle background */}
<div className="bg-background/90 backdrop-blur-sm rounded-2xl p-4 shadow-xl border">
{/* Header */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-sm font-medium text-muted-foreground">
{actionConfig?.emoji} {actionConfig?.label}
</span>
<button
onClick={onClose}
className="text-muted-foreground/60 hover:text-foreground transition-colors p-1"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Item bubbles row */}
<div
className="flex items-center justify-center"
style={{ gap: BUBBLES_CONFIG.gap }}
>
{items.map((item, index) => (
<button
key={item.id}
className={cn(
// Base styles
"relative flex items-center justify-center rounded-full",
"bg-accent/80 hover:bg-accent",
"shadow-md transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-primary/50",
// Hover effects
"hover:scale-110 hover:shadow-lg active:scale-95",
// Animation
"animate-in fade-in zoom-in-75"
)}
style={{
width: BUBBLES_CONFIG.bubbleSize,
height: BUBBLES_CONFIG.bubbleSize,
animationDelay: `${index * BUBBLES_CONFIG.staggerDelay}ms`,
animationFillMode: 'backwards',
}}
onClick={() => onItemClick?.(item)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}`}
>
{/* Item emoji */}
<span
className="text-2xl select-none"
role="img"
aria-hidden="true"
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"min-w-[20px] h-5 px-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-medium rounded-full",
"shadow-sm"
)}
>
{item.quantity}
</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -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<Map<string, ItemState>>(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 (
<div
className="fixed left-1/2 -translate-x-1/2 top-8 animate-in fade-in slide-in-from-top-4 duration-300 pointer-events-auto"
style={{ zIndex: 10003 }}
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {actionConfig?.label.toLowerCase()} items in your inventory
</p>
</div>
</div>
);
}
// Calculate horizontal positions for items (centered)
const totalWidth = (items.length - 1) * HANGING_CONFIG.itemSpacing;
const startX = -totalWidth / 2;
return (
<div
className="fixed inset-x-0 top-0 flex justify-center pointer-events-none"
style={{ zIndex: 10003 }}
>
{/* Container for positioning items relative to center */}
<div className="relative" style={{ height: HANGING_CONFIG.lineLength + HANGING_CONFIG.circleSize + 20 }}>
{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 (
<div
key={item.id}
className="absolute pointer-events-auto"
style={{
left: '50%',
transform: `translateX(calc(-50% + ${xOffset}px))`,
}}
>
{/* Hanging line - hidden when falling */}
<div
className={cn(
"mx-auto transition-opacity duration-150",
isFalling ? "opacity-0" : "opacity-100"
)}
style={{
width: HANGING_CONFIG.lineWidth,
height: HANGING_CONFIG.lineLength,
background: 'linear-gradient(to bottom, hsl(var(--muted-foreground) / 0.3), hsl(var(--muted-foreground) / 0.5))',
// Entrance animation
animation: !isFalling ? `hanging-line-in 400ms ease-out ${delay}ms backwards` : undefined,
}}
/>
{/* Item circle */}
<button
className={cn(
"relative flex items-center justify-center rounded-full",
"bg-background/95 backdrop-blur-sm",
"shadow-lg border-2 border-muted/30",
"transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-primary/50",
// Only show hover effects when hanging
!isFalling && "hover:scale-110 hover:shadow-xl hover:border-primary/30 active:scale-95",
// Cursor
isFalling ? "cursor-default" : "cursor-pointer"
)}
style={{
width: HANGING_CONFIG.circleSize,
height: HANGING_CONFIG.circleSize,
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
// Entrance animation (when hanging)
animation: !isFalling
? `hanging-circle-in 400ms ease-out ${delay}ms backwards`
: `item-fall ${HANGING_CONFIG.fallDuration}ms ease-in forwards`,
}}
onClick={() => !isFalling && handleItemClick(item)}
disabled={isFalling}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to use.`}
>
{/* Item emoji */}
<span
className="text-2xl select-none"
role="img"
aria-hidden="true"
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"min-w-[20px] h-5 px-1.5",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md",
// Hide badge when falling
isFalling && "opacity-0 transition-opacity duration-150"
)}
>
{item.quantity}
</span>
</button>
</div>
);
})}
</div>
{/* CSS animations */}
<style>{`
@keyframes hanging-line-in {
from {
transform: scaleY(0);
transform-origin: top;
}
to {
transform: scaleY(1);
transform-origin: top;
}
}
@keyframes hanging-circle-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes item-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
20% {
transform: translateY(20px) rotate(-5deg);
}
100% {
transform: translateY(${HANGING_CONFIG.fallDistance}px) rotate(15deg);
opacity: 0;
}
}
`}</style>
</div>
);
}
+4 -4
View File
@@ -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';