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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user