Improve hanging items: larger size, slide animations, landed items persist

Size Improvements:
- Circle size: 52px → 72px (38% larger)
- Emoji size: text-2xl → text-4xl (2.25rem)
- Item spacing: 80px → 100px (center to center)
- Line length: 100px → 120px
- Badge size: 20px → 24px

Open/Close Slide Animation:
- Container states: hidden → opening → open → closing → hidden
- Items descend from above when opening (slide down animation)
- Items ascend when closing (slide up animation)
- 350ms slide animation duration
- Items no longer abruptly appear/disappear

Landed Items System:
- Released items fall all the way to the ground (calculated from viewport)
- Items remain visible on ground after landing
- Landed items render as separate LandedItem components
- Landed items persist even after menu closes
- Landed items are clickable (placeholder for future pickup)

State Model:
- ContainerState: hidden | opening | open | closing
- ItemState: hanging | falling | landed
- ItemStateData tracks: id, state, fallStartX, landedY
- landedItems Map persists item positions for ground rendering

Future-Ready Structure:
- onPickup callback ready for landed items
- Separate LandedItem component for ground items
- State model supports drag, attraction, consumption
- CSS variables for dynamic fall distance
This commit is contained in:
filemon
2026-03-24 20:16:26 -03:00
parent c659eaeead
commit 3a47fccf16
2 changed files with 388 additions and 169 deletions
@@ -236,6 +236,8 @@ export function BlobbiCompanionLayer() {
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
items={menuState.items}
viewportHeight={viewport.height}
groundOffset={config.padding.bottom}
onItemRelease={handleItemClick}
/>
</div>
+386 -169
View File
@@ -5,21 +5,27 @@
* Each item appears as a circle connected to the top by a thin vertical line,
* creating a playful, spatial feel.
*
* State Model:
* - Container states: hidden → opening → open → closing → hidden
* - Item states: hanging → falling → landed
*
* Features:
* - Smooth open/close slide animations (items descend/ascend)
* - Thin vertical lines from the top of screen
* - Circular item containers with emoji icons
* - Large circular item containers with emoji icons
* - Quantity badges
* - Staggered entrance animation
* - Click to release (line disappears, item falls)
* - Click to release (line disappears, item falls to ground)
* - Landed items remain visible on the ground
*
* Future extensions:
* - Drag items to drop on Blobbi
* - Drag landed items to Blobbi
* - Blobbi attracted to nearby items
* - Auto-use urgent items
* - Item pickup/consumption
* - Different animations per item category
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import type { CompanionItem, CompanionMenuAction } from './types';
@@ -27,13 +33,20 @@ import { getMenuActionConfig } from './types';
// ─── Types ────────────────────────────────────────────────────────────────────
/** State of a hanging item */
type HangingItemState = 'hanging' | 'falling' | 'landed';
/** State of the hanging items container */
type ContainerState = 'hidden' | 'opening' | 'open' | 'closing';
/** State of an individual item */
type ItemState = 'hanging' | 'falling' | 'landed';
/** Internal state for each item */
interface ItemState {
interface ItemStateData {
id: string;
state: HangingItemState;
state: ItemState;
/** Position when item started falling (for calculating ground position) */
fallStartX?: number;
/** Y position where item lands (ground level) */
landedY?: number;
}
/** Props for the HangingItems component */
@@ -44,235 +57,439 @@ interface HangingItemsProps {
selectedAction: CompanionMenuAction | null;
/** Items to display */
items: CompanionItem[];
/** Viewport height for calculating ground position */
viewportHeight?: number;
/** Ground Y offset from bottom of viewport */
groundOffset?: number;
/** Callback when an item is clicked/released */
onItemRelease?: (item: CompanionItem) => void;
/** Callback when an item finishes falling */
/** Callback when an item finishes falling and lands */
onItemLanded?: (item: CompanionItem) => void;
}
// ─── Configuration ────────────────────────────────────────────────────────────
const HANGING_CONFIG = {
/** Size of item circles */
circleSize: 52,
/** Size of item circles (increased for better visibility) */
circleSize: 72,
/** Emoji font size */
emojiSize: '2.25rem', // text-4xl equivalent
/** Horizontal spacing between items (center to center) */
itemSpacing: 80,
itemSpacing: 100,
/** Length of the hanging line */
lineLength: 100,
lineLength: 120,
/** Width of the hanging line */
lineWidth: 2,
/** Stagger delay between item appearances (ms) */
staggerDelay: 60,
/** Duration of open/close slide animation (ms) */
slideAnimationDuration: 350,
/** Stagger delay between items during open (ms) */
staggerDelay: 40,
/** Duration of the fall animation (ms) */
fallDuration: 800,
/** How far below viewport the item falls to */
fallDistance: 600,
fallDuration: 600,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of quantity badge */
badgeSize: 24,
};
// ─── Component ────────────────────────────────────────────────────────────────
// ─── Landed Item Component ────────────────────────────────────────────────────
interface LandedItemProps {
item: CompanionItem;
xPosition: number;
groundY: number;
onPickup?: (item: CompanionItem) => void;
}
/**
* A landed item that rests on the ground after falling.
* Rendered separately from hanging items to persist after menu closes.
*/
function LandedItem({ item, xPosition, groundY, onPickup }: LandedItemProps) {
return (
<button
className={cn(
"fixed flex items-center justify-center rounded-full pointer-events-auto",
"bg-background/95 backdrop-blur-sm",
"shadow-lg border-2 border-muted/30",
"transition-all duration-200",
"hover:scale-110 hover:shadow-xl hover:border-primary/30 active:scale-95",
"cursor-pointer",
"animate-in fade-in zoom-in-90 duration-200"
)}
style={{
width: HANGING_CONFIG.circleSize,
height: HANGING_CONFIG.circleSize,
left: xPosition - HANGING_CONFIG.circleSize / 2,
top: groundY - HANGING_CONFIG.circleSize,
zIndex: 10003,
}}
onClick={() => onPickup?.(item)}
title={`${item.name} (x${item.quantity}) - Click to pick up`}
aria-label={`${item.name} on ground, quantity ${item.quantity}. Click to pick up.`}
>
<span
className="select-none"
style={{ fontSize: HANGING_CONFIG.emojiSize }}
role="img"
aria-hidden="true"
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 6px',
}}
>
{item.quantity}
</span>
</button>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HangingItems({
isVisible,
selectedAction,
items,
viewportHeight = window.innerHeight,
groundOffset = HANGING_CONFIG.defaultGroundOffset,
onItemRelease,
onItemLanded,
}: HangingItemsProps) {
// Track state of each item (hanging, falling, landed)
const [itemStates, setItemStates] = useState<Map<string, ItemState>>(new Map());
// Container animation state
const [containerState, setContainerState] = useState<ContainerState>('hidden');
// Track state of each item
const [itemStates, setItemStates] = useState<Map<string, ItemStateData>>(new Map());
// Track landed items with their positions (persists after menu closes)
const [landedItems, setLandedItems] = useState<Map<string, { item: CompanionItem; x: number }>>( new Map());
// Reference to track if we should animate (prevents animation on initial mount)
const hasBeenVisible = useRef(false);
// Calculate ground Y position
const groundY = viewportHeight - groundOffset;
// Handle visibility changes with animation
useEffect(() => {
if (isVisible && selectedAction) {
// Opening
if (containerState === 'hidden' || containerState === 'closing') {
setContainerState('opening');
hasBeenVisible.current = true;
// Transition to open after animation
const timer = setTimeout(() => {
setContainerState('open');
}, HANGING_CONFIG.slideAnimationDuration);
return () => clearTimeout(timer);
}
} else {
// Closing
if (containerState === 'open' || containerState === 'opening') {
setContainerState('closing');
// Transition to hidden after animation
const timer = setTimeout(() => {
setContainerState('hidden');
// Clear hanging item states (but keep landed items)
setItemStates(new Map());
}, HANGING_CONFIG.slideAnimationDuration);
return () => clearTimeout(timer);
}
}
}, [isVisible, selectedAction, containerState]);
// Handle item click - starts the falling animation
const handleItemClick = useCallback((item: CompanionItem) => {
// Update state to falling
const handleItemClick = useCallback((item: CompanionItem, xPosition: number) => {
// Update state to falling with position info
setItemStates(prev => {
const next = new Map(prev);
next.set(item.id, { id: item.id, state: 'falling' });
next.set(item.id, {
id: item.id,
state: 'falling',
fallStartX: xPosition,
landedY: groundY,
});
return next;
});
// Notify parent
onItemRelease?.(item);
// After fall animation completes, mark as landed
// After fall animation completes, move to landed state
setTimeout(() => {
setItemStates(prev => {
const next = new Map(prev);
next.set(item.id, { id: item.id, state: 'landed' });
const current = next.get(item.id);
if (current) {
next.set(item.id, { ...current, state: 'landed' });
}
return next;
});
// Add to landed items collection
setLandedItems(prev => {
const next = new Map(prev);
next.set(item.id, { item, x: xPosition });
return next;
});
onItemLanded?.(item);
}, HANGING_CONFIG.fallDuration);
}, [onItemRelease, onItemLanded]);
}, [groundY, onItemRelease, onItemLanded]);
// Get current state for an item
const getItemState = (itemId: string): HangingItemState => {
const getItemState = (itemId: string): ItemState => {
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>
);
}
// Handle picking up a landed item (placeholder for future)
const handleLandedItemPickup = useCallback((item: CompanionItem) => {
// For now, just log - actual pickup/use will be implemented later
console.log('[HangingItems] Landed item clicked:', item);
}, []);
// Calculate horizontal positions for items (centered)
const totalWidth = (items.length - 1) * HANGING_CONFIG.itemSpacing;
const startX = -totalWidth / 2;
const getItemXPosition = (index: number) => {
const viewportCenterX = window.innerWidth / 2;
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Don't render container if fully hidden and no action selected
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
// Empty state (shown when action selected but no items)
const showEmptyState = isVisible && selectedAction && items.length === 0;
// Calculate slide offset based on container state
const getSlideOffset = () => {
switch (containerState) {
case 'hidden':
return -(HANGING_CONFIG.lineLength + HANGING_CONFIG.circleSize + 40);
case 'opening':
case 'closing':
return containerState === 'opening' ? 0 : -(HANGING_CONFIG.lineLength + HANGING_CONFIG.circleSize + 40);
case 'open':
return 0;
default:
return 0;
}
};
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,
}}
/>
<>
{/* Empty state message */}
{showEmptyState && (
<div
className={cn(
"fixed left-1/2 -translate-x-1/2 top-8 pointer-events-auto",
"transition-all duration-300",
containerState === 'opening' || containerState === 'open'
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-4"
)}
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 {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
</p>
</div>
</div>
)}
{/* Hanging items container */}
{shouldRenderContainer && items.length > 0 && (
<div
className="fixed inset-x-0 top-0 flex justify-center pointer-events-none"
style={{
zIndex: 10003,
transition: `transform ${HANGING_CONFIG.slideAnimationDuration}ms ease-out`,
transform: `translateY(${getSlideOffset()}px)`,
}}
>
{/* 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;
const itemX = getItemXPosition(index);
{/* 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"
// Don't render landed items here (they're rendered separately below)
if (state === 'landed') {
return null;
}
const isFalling = state === 'falling';
const isHanging = state === 'hanging';
return (
<div
key={item.id}
className="absolute pointer-events-auto"
style={{
left: '50%',
transform: `translateX(calc(-50% + ${xOffset}px))`,
}}
>
{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>
{/* 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))',
// Subtle sway animation when hanging
animation: isHanging && containerState === 'open'
? `hanging-sway 3s ease-in-out ${delay}ms infinite`
: undefined,
transformOrigin: 'top center',
}}
/>
{/* 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
isHanging && "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,
// Fall animation when released
animation: isFalling
? `item-fall-to-ground ${HANGING_CONFIG.fallDuration}ms ease-in forwards`
: undefined,
// CSS variable for ground distance
'--fall-distance': `${groundY - HANGING_CONFIG.lineLength - HANGING_CONFIG.circleSize}px`,
} as React.CSSProperties}
onClick={() => isHanging && handleItemClick(item, itemX)}
disabled={isFalling}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to use.`}
>
{/* Item emoji */}
<span
className="select-none"
style={{ fontSize: HANGING_CONFIG.emojiSize }}
role="img"
aria-hidden="true"
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md",
// Fade out badge when falling
isFalling && "opacity-0 transition-opacity duration-150"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 6px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
})}
</div>
</div>
)}
{/* Landed items (persist even after menu closes) */}
{Array.from(landedItems.values()).map(({ item, x }) => (
<LandedItem
key={`landed-${item.id}`}
item={item}
xPosition={x}
groundY={groundY}
onPickup={handleLandedItemPickup}
/>
))}
{/* CSS animations */}
<style>{`
@keyframes hanging-line-in {
from {
transform: scaleY(0);
transform-origin: top;
@keyframes hanging-sway {
0%, 100% {
transform: rotate(0deg);
}
to {
transform: scaleY(1);
transform-origin: top;
25% {
transform: rotate(1deg);
}
75% {
transform: rotate(-1deg);
}
}
@keyframes hanging-circle-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes item-fall {
@keyframes item-fall-to-ground {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
20% {
transform: translateY(20px) rotate(-5deg);
15% {
transform: translateY(30px) rotate(-8deg);
}
30% {
transform: translateY(80px) rotate(5deg);
}
50% {
transform: translateY(180px) rotate(-3deg);
}
70% {
transform: translateY(320px) rotate(2deg);
}
85% {
opacity: 1;
}
100% {
transform: translateY(${HANGING_CONFIG.fallDistance}px) rotate(15deg);
transform: translateY(var(--fall-distance)) rotate(0deg);
opacity: 0;
}
}
`}</style>
</div>
</>
);
}